This commit is contained in:
@@ -0,0 +1,179 @@
|
||||
using System.IO.Compression;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace LehrerApp.Templating;
|
||||
|
||||
public sealed record TemplateLimits(long MaxPackageBytes = 50 * 1024 * 1024,
|
||||
long MaxAssetBytes = 20 * 1024 * 1024, int MaxImagePixels = 50_000_000);
|
||||
|
||||
public sealed class TemplateLoader(TemplateLimits? limits = null,
|
||||
IEnumerable<ITemplateMigrator>? migrators = null) : ITemplateLoader
|
||||
{
|
||||
public const int CurrentSchemaVersion = 1;
|
||||
private readonly TemplateLimits _limits = limits ?? new();
|
||||
private readonly IReadOnlyDictionary<int, ITemplateMigrator> _migrators =
|
||||
(migrators ?? []).ToDictionary(x => x.SourceVersion);
|
||||
internal static readonly JsonSerializerOptions JsonOptions = CreateJsonOptions();
|
||||
|
||||
public LoadedTemplate LoadFromPackage(string packagePath)
|
||||
{
|
||||
if (!File.Exists(packagePath)) throw new FileNotFoundException("Vorlagenpaket nicht gefunden.", packagePath);
|
||||
var info = new FileInfo(packagePath);
|
||||
if (info.Length > _limits.MaxPackageBytes) throw new InvalidDataException("Vorlagenpaket überschreitet das Größenlimit.");
|
||||
using var stream = File.OpenRead(packagePath);
|
||||
return LoadFromPackage(stream, Path.GetFileName(packagePath));
|
||||
}
|
||||
|
||||
public LoadedTemplate LoadFromPackage(Stream package, string sourceName = "")
|
||||
{
|
||||
var issues = new List<ValidationIssue>();
|
||||
try
|
||||
{
|
||||
using var archive = new ZipArchive(package, ZipArchiveMode.Read, leaveOpen: true);
|
||||
var entries = new Dictionary<string, ZipArchiveEntry>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var entry in archive.Entries)
|
||||
{
|
||||
if (!IsSafeRelativePath(entry.FullName))
|
||||
issues.Add(new(ValidationSeverity.Error, $"Unsicherer Paketpfad: „{entry.FullName}“."));
|
||||
else if (!string.IsNullOrEmpty(entry.Name)) entries[Normalize(entry.FullName)] = entry;
|
||||
}
|
||||
if (issues.Count > 0) throw new TemplateValidationException(new(issues));
|
||||
|
||||
if (!entries.TryGetValue("manifest.json", out var manifestEntry))
|
||||
throw Error("manifest.json fehlt.");
|
||||
TemplateManifest manifest;
|
||||
try
|
||||
{
|
||||
using var manifestStream = manifestEntry.Open();
|
||||
manifest = JsonSerializer.Deserialize<TemplateManifest>(manifestStream, JsonOptions)
|
||||
?? throw new JsonException("Leeres Manifest.");
|
||||
}
|
||||
catch (JsonException ex) { throw Error($"manifest.json ist ungültig: {ex.Message}"); }
|
||||
|
||||
while (manifest.SchemaVersion != CurrentSchemaVersion &&
|
||||
_migrators.TryGetValue(manifest.SchemaVersion, out var migrator))
|
||||
manifest = migrator.Migrate(manifest);
|
||||
if (manifest.SchemaVersion != CurrentSchemaVersion)
|
||||
throw Error($"schemaVersion {manifest.SchemaVersion} wird nicht unterstützt.");
|
||||
if (!IsSafeRelativePath(manifest.LayoutFile)) throw Error("layoutFile enthält einen unsicheren Pfad.");
|
||||
if (!entries.TryGetValue(Normalize(manifest.LayoutFile), out var layoutEntry))
|
||||
throw Error($"Layoutdatei „{manifest.LayoutFile}“ fehlt.");
|
||||
|
||||
string layoutSource;
|
||||
using (var reader = new StreamReader(layoutEntry.Open())) layoutSource = reader.ReadToEnd();
|
||||
var layout = new LayoutParser().Parse(layoutSource);
|
||||
var referencedAssets = layout.Elements.Select(AssetPath).Where(x => x is not null).Cast<string>()
|
||||
.Select(Normalize).Distinct(StringComparer.OrdinalIgnoreCase).ToList();
|
||||
foreach (var path in referencedAssets)
|
||||
{
|
||||
if (!IsSafeRelativePath(path)) { issues.Add(new(ValidationSeverity.Error, $"Unsicherer Assetpfad „{path}“.")); continue; }
|
||||
if (!entries.TryGetValue(Normalize(path), out var asset))
|
||||
issues.Add(new(ValidationSeverity.Error, $"Referenziertes Bild „{path}“ fehlt."));
|
||||
}
|
||||
|
||||
var assets = new Dictionary<string, byte[]>(StringComparer.OrdinalIgnoreCase);
|
||||
var layoutPath = Normalize(manifest.LayoutFile);
|
||||
foreach (var (path, asset) in entries.Where(x =>
|
||||
!x.Key.Equals("manifest.json", StringComparison.OrdinalIgnoreCase)
|
||||
&& !x.Key.Equals(layoutPath, StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
if (asset.Length > _limits.MaxAssetBytes)
|
||||
{ issues.Add(new(ValidationSeverity.Error, $"Bild „{path}“ überschreitet das Größenlimit.")); continue; }
|
||||
using var stream = asset.Open();
|
||||
using var memory = new MemoryStream(); stream.CopyTo(memory);
|
||||
var bytes = memory.ToArray();
|
||||
if (!ImageDimensions.TryRead(bytes, out var imageWidth, out var imageHeight))
|
||||
{ issues.Add(new(ValidationSeverity.Error, $"„{path}“ ist kein unterstütztes PNG/JPEG-Bild.")); continue; }
|
||||
if ((long)imageWidth * imageHeight > _limits.MaxImagePixels)
|
||||
issues.Add(new(ValidationSeverity.Error, $"Bild „{path}“ überschreitet das Auflösungslimit."));
|
||||
else assets[path] = bytes;
|
||||
}
|
||||
|
||||
var declared = manifest.Placeholders.Select(x => x.Name).ToHashSet(StringComparer.Ordinal);
|
||||
foreach (var used in UsedPlaceholders(layout).Where(x => !declared.Contains(x)))
|
||||
issues.Add(new(ValidationSeverity.Error, $"Platzhalter „{used}“ wird im Layout verwendet, aber nicht im Manifest deklariert."));
|
||||
foreach (var duplicate in manifest.Placeholders.GroupBy(x => x.Name, StringComparer.Ordinal).Where(x => x.Count() > 1))
|
||||
issues.Add(new(ValidationSeverity.Error, $"Platzhalter „{duplicate.Key}“ ist mehrfach deklariert."));
|
||||
if (issues.Count > 0) throw new TemplateValidationException(new(issues));
|
||||
return new(manifest, layout, assets, sourceName);
|
||||
}
|
||||
catch (InvalidDataException ex)
|
||||
{ throw Error($"Paket ist kein lesbares ZIP-Archiv: {ex.Message}"); }
|
||||
}
|
||||
|
||||
public ValidationResult Validate(LoadedTemplate template, IReadOnlyDictionary<string, PlaceholderType> providedTypes)
|
||||
{
|
||||
var issues = new List<ValidationIssue>();
|
||||
foreach (var placeholder in template.Manifest.Placeholders)
|
||||
{
|
||||
if (!providedTypes.TryGetValue(placeholder.Name, out var actual))
|
||||
{
|
||||
if (placeholder.Required) issues.Add(new(ValidationSeverity.Error, $"Pflichtwert „{placeholder.Name}“ fehlt."));
|
||||
}
|
||||
else if (actual != placeholder.Type)
|
||||
issues.Add(new(ValidationSeverity.Error, $"„{placeholder.Name}“ erwartet {placeholder.Type}, geliefert wurde {actual}."));
|
||||
}
|
||||
return new(issues);
|
||||
}
|
||||
|
||||
public static IEnumerable<string> UsedPlaceholders(TemplateLayout layout) => layout.Elements.Select(x => x switch
|
||||
{
|
||||
TextElement { Placeholder: { } p } => p,
|
||||
TextBoxElement { Placeholder: { } p } => p,
|
||||
TableElement t => t.Placeholder,
|
||||
ChartElement c => c.Placeholder,
|
||||
_ => null,
|
||||
}).Where(x => x is not null).Cast<string>().Distinct(StringComparer.Ordinal);
|
||||
|
||||
internal static bool IsSafeRelativePath(string path) => !string.IsNullOrWhiteSpace(path)
|
||||
&& !Path.IsPathRooted(path) && !path.Split('/', '\\').Any(part => part == ".." || part.Length == 0);
|
||||
internal static string Normalize(string path) => path.Replace('\\', '/');
|
||||
private static string? AssetPath(TemplateElement element) => element switch
|
||||
{ BackgroundElement bg => bg.Path, ImageElement img => img.Path, _ => null };
|
||||
private static TemplateValidationException Error(string message) =>
|
||||
new(new([new(ValidationSeverity.Error, message)]));
|
||||
|
||||
private static JsonSerializerOptions CreateJsonOptions()
|
||||
{
|
||||
var options = new JsonSerializerOptions(JsonSerializerDefaults.Web) { WriteIndented = true };
|
||||
options.Converters.Add(new JsonStringEnumConverter(JsonNamingPolicy.CamelCase));
|
||||
return options;
|
||||
}
|
||||
}
|
||||
|
||||
internal static class ImageDimensions
|
||||
{
|
||||
public static bool TryRead(byte[] data, out int width, out int height)
|
||||
{
|
||||
width = height = 0;
|
||||
if (data.Length >= 24 && data.AsSpan(0, 8).SequenceEqual(new byte[] { 137, 80, 78, 71, 13, 10, 26, 10 }))
|
||||
{
|
||||
width = ReadBigEndian(data, 16); height = ReadBigEndian(data, 20); return width > 0 && height > 0;
|
||||
}
|
||||
if (data.Length >= 4 && data[0] == 0xFF && data[1] == 0xD8)
|
||||
{
|
||||
var offset = 2;
|
||||
while (offset + 9 < data.Length)
|
||||
{
|
||||
if (data[offset] != 0xFF) { offset++; continue; }
|
||||
var marker = data[offset + 1];
|
||||
if (marker is >= 0xC0 and <= 0xC3)
|
||||
{ height = (data[offset + 5] << 8) | data[offset + 6]; width = (data[offset + 7] << 8) | data[offset + 8]; return true; }
|
||||
if (offset + 3 >= data.Length) break;
|
||||
var length = (data[offset + 2] << 8) | data[offset + 3];
|
||||
if (length < 2) break; offset += length + 2;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
private static int ReadBigEndian(byte[] data, int offset) =>
|
||||
(data[offset] << 24) | (data[offset + 1] << 16) | (data[offset + 2] << 8) | data[offset + 3];
|
||||
}
|
||||
|
||||
public static class ImageInspector
|
||||
{
|
||||
public static ImageMetadata Inspect(byte[] data) => ImageDimensions.TryRead(data, out var width, out var height)
|
||||
? new(width, height)
|
||||
: throw new InvalidDataException("Das Asset ist kein unterstütztes PNG/JPEG-Bild.");
|
||||
}
|
||||
Reference in New Issue
Block a user