228 lines
13 KiB
C#
228 lines
13 KiB
C#
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 (!IsSafeRelativePath(manifest.MetadataFile)) throw Error("metadataFile enthält einen unsicheren Pfad.");
|
|
if (manifest.ContinuationLayoutFile is not null && !IsSafeRelativePath(manifest.ContinuationLayoutFile))
|
|
throw Error("continuationLayoutFile 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);
|
|
TemplateLayout? continuationLayout = null;
|
|
if (manifest.ContinuationLayoutFile is { } continuationPath)
|
|
{
|
|
if (!entries.TryGetValue(Normalize(continuationPath), out var continuationEntry))
|
|
throw Error($"Folgeseiten-Layoutdatei „{continuationPath}“ fehlt.");
|
|
using var reader = new StreamReader(continuationEntry.Open());
|
|
continuationLayout = new LayoutParser().Parse(reader.ReadToEnd());
|
|
if (continuationLayout.Width != layout.Width || continuationLayout.Height != layout.Height
|
|
|| !continuationLayout.Unit.Equals(layout.Unit, StringComparison.OrdinalIgnoreCase))
|
|
issues.Add(new(ValidationSeverity.Error, "Folgeseiten-Layout und Hauptlayout müssen dieselbe Seitengröße und Einheit verwenden."));
|
|
}
|
|
var allLayouts = continuationLayout is null ? new[] { layout } : new[] { layout, continuationLayout };
|
|
var flowBoxes = layout.Elements.OfType<FlowBoxElement>().ToList();
|
|
if (flowBoxes.Count > 1)
|
|
issues.Add(new(ValidationSeverity.Error, "Ein Layout darf höchstens eine FLOWBOX enthalten."));
|
|
if (continuationLayout is not null)
|
|
{
|
|
var continuationFlows = continuationLayout.Elements.OfType<FlowBoxElement>().ToList();
|
|
if (flowBoxes.Count != 1 || continuationFlows.Count != 1)
|
|
issues.Add(new(ValidationSeverity.Error, "Bei einem Folgeseiten-Layout müssen Haupt- und Folgeseite jeweils genau eine FLOWBOX enthalten."));
|
|
else if (flowBoxes[0].X != continuationFlows[0].X || flowBoxes[0].Y != continuationFlows[0].Y
|
|
|| flowBoxes[0].Width != continuationFlows[0].Width || flowBoxes[0].Height != continuationFlows[0].Height)
|
|
issues.Add(new(ValidationSeverity.Error, "Die FLOWBOX muss auf Haupt- und Folgeseiten dieselbe Position und Größe haben."));
|
|
}
|
|
var referencedAssets = allLayouts.SelectMany(x => x.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);
|
|
var continuationLayoutPath = manifest.ContinuationLayoutFile is null ? null : Normalize(manifest.ContinuationLayoutFile);
|
|
foreach (var (path, asset) in entries.Where(x =>
|
|
!x.Key.Equals("manifest.json", StringComparison.OrdinalIgnoreCase)
|
|
&& !x.Key.Equals(layoutPath, StringComparison.OrdinalIgnoreCase)
|
|
&& !x.Key.Equals(continuationLayoutPath, 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 allLayouts.SelectMany(UsedPlaceholders).Distinct(StringComparer.Ordinal).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."));
|
|
foreach (var issue in TemplateDataResolver.ValidateConstants(manifest))
|
|
issues.Add(new(ValidationSeverity.Error, issue));
|
|
if (issues.Count > 0) throw new TemplateValidationException(new(issues));
|
|
return new(manifest, layout, assets, sourceName, continuationLayout);
|
|
}
|
|
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 (placeholder.IsConstant) continue;
|
|
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,
|
|
FlowBoxElement { 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.");
|
|
}
|