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? migrators = null) : ITemplateLoader { public const int CurrentSchemaVersion = 1; private readonly TemplateLimits _limits = limits ?? new(); private readonly IReadOnlyDictionary _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(); try { using var archive = new ZipArchive(package, ZipArchiveMode.Read, leaveOpen: true); var entries = new Dictionary(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(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 (!IsSafeRelativePath(manifest.MetadataFile)) throw Error("metadataFile enthält einen unsicheren Pfad."); if (Normalize(manifest.MetadataFile).Equals(Normalize(manifest.LayoutFile), StringComparison.OrdinalIgnoreCase)) throw Error("metadataFile und layoutFile dürfen nicht identisch sein."); 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 metadataPath = Normalize(manifest.MetadataFile); manifest.Metadata = new(StringComparer.OrdinalIgnoreCase); if (entries.TryGetValue(metadataPath, out var metadataEntry)) { if (metadataEntry.Length > 1024 * 1024) throw Error("metadata.txt überschreitet das Größenlimit."); try { using var reader = new StreamReader(metadataEntry.Open()); manifest.Metadata = TemplateMetadataText.Parse(reader.ReadToEnd()); } catch (InvalidDataException ex) { throw Error($"{manifest.MetadataFile} ist ungültig: {ex.Message}"); } } var layout = new LayoutParser().Parse(layoutSource); var referencedAssets = layout.Elements.Select(AssetPath).Where(x => x is not null).Cast() .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(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) && !x.Key.Equals(metadataPath, 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 providedTypes) { var issues = new List(); 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 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().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."); }