diff --git a/server/src/Flaro.Api/Endpoints/SystemEndpoints.cs b/server/src/Flaro.Api/Endpoints/SystemEndpoints.cs index 313abc1..b1f077b 100644 --- a/server/src/Flaro.Api/Endpoints/SystemEndpoints.cs +++ b/server/src/Flaro.Api/Endpoints/SystemEndpoints.cs @@ -71,6 +71,24 @@ public static class SystemEndpoints Uptime: (DateTimeOffset.UtcNow - Gestartet).TotalSeconds))) .RequireAuthorization(FlaroPolicies.Administrator); + // **Ordner zum Durchklicken.** Der Pfad einer Mediathek ist der im + // Container, nicht der auf dem NAS — eine Unterscheidung, die nur + // kennt, wer die Compose-Datei selbst geschrieben hat. Ein Tippfehler + // darin ergibt eine leere Mediathek ohne erkennbaren Grund. Wer sich + // durchklickt, sieht stattdessen, was wirklich da ist. + // + // Das Textfeld daneben bleibt trotzdem: eintippen ist schneller, wenn + // man den Pfad kennt, und bei sehr tiefen Bäumen die einzige Geduld + // schonende Möglichkeit. + app.MapGet("/api/v1/system/folders", (string? path) => + Ordnerbaum.Lesen(path) is { } baum + ? Results.Ok(baum) + : Results.Problem( + title: "Ordner nicht gefunden", + detail: $"Den Ordner {path} gibt es nicht, oder es ist keiner.", + statusCode: StatusCodes.Status404NotFound)) + .RequireAuthorization(FlaroPolicies.Administrator); + app.MapPut("/api/v1/system/name", async (ServerNameRequest anfrage, FlaroContext db) => { var name = anfrage.Name.Trim(); diff --git a/server/src/Flaro.Api/Ordnerbaum.cs b/server/src/Flaro.Api/Ordnerbaum.cs new file mode 100644 index 0000000..29182ac --- /dev/null +++ b/server/src/Flaro.Api/Ordnerbaum.cs @@ -0,0 +1,123 @@ +namespace Flaro.Api; + +/// Zeigt Ordner des Dateisystems, damit Pfade nicht getippt werden müssen. +/// +/// +/// +/// Warum das überhaupt nötig ist. Ein Pfad in einer Mediathek ist der +/// Pfad im Container, nicht der auf dem NAS — also das, was in der +/// Compose-Datei hinter dem Doppelpunkt steht. Diese Unterscheidung kennt +/// niemand, der nicht gerade die Compose-Datei geschrieben hat, und ein +/// Tippfehler darin ergibt eine leere Mediathek ohne erkennbaren Grund. +/// Wer sich durchklicken kann, sieht stattdessen, was wirklich da ist. +/// +/// +/// Nur für Administratoren, und das ist keine Förmlichkeit. Diese +/// Auskunft legt den Aufbau des Dateisystems offen. Im Container ist das +/// wenig, aber eingehängte Ordner tragen die Namen echter Verzeichnisse. +/// +/// +public static class Ordnerbaum +{ + /// Wo die Suche beginnt, wenn niemand einen Pfad nennt. + /// + /// + /// In dieser Reihenfolge, weil die Anleitung Medien nach /media + /// einhängen lässt. Steht dort nichts, ist die Wurzel der ehrlichste + /// Anfang — raten hilft niemandem. + /// + private static readonly string[] Anfänge = ["/media", "/mnt", "/data", "/volume1"]; + + /// Was an der Wurzel nicht angezeigt wird. + /// + /// + /// Keine Sicherheitsmassnahme, eine Aufräumarbeit. Wer den Pfad + /// kennt, kann ihn weiterhin eintippen; verborgen wird nichts. Es geht + /// darum, dass eine Liste aus zwanzig Einträgen, von denen achtzehn + /// /proc, /sys und /usr heissen, unbrauchbar ist. + /// Medien liegen dort nie. + /// + private static readonly HashSet AnDerWurzelVerborgen = new(StringComparer.Ordinal) + { + "bin", "boot", "dev", "etc", "lib", "lib32", "lib64", "libx32", + "proc", "root", "run", "sbin", "sys", "tmp", "usr", "var", + }; + + public sealed record Eintrag(string Name, string Path); + + public sealed record Antwort( + string Path, + string? Parent, + IReadOnlyList Entries, + /// Warum die Liste leer ist — oder null, wenn sie es nicht ist. + string? Note); + + /// Der Pfad, mit dem die Auswahl aufgeht. + public static string Anfang() + { + foreach (var kandidat in Anfänge) + { + if (Directory.Exists(kandidat)) return kandidat; + } + + // Nicht hart „/": unter Windows und macOS gibt es das so nicht, und + // die Tests laufen dort. + return Path.GetPathRoot(Environment.CurrentDirectory) is { Length: > 0 } wurzel + ? wurzel + : "/"; + } + + /// Listet die Unterordner eines Verzeichnisses. + /// + /// + /// Gibt auch dann eine Antwort, wenn das Lesen scheitert. Ein + /// Ordner ohne Leserecht ist kein Fehler des Aufrufers, sondern eine + /// Tatsache über das Dateisystem — und eine, die in der Oberfläche + /// stehen soll, statt als 500 im Protokoll zu landen. null heisst + /// dagegen: diesen Pfad gibt es nicht. + /// + public static Antwort? Lesen(string? angefragt) + { + var pfad = string.IsNullOrWhiteSpace(angefragt) ? Anfang() : angefragt.Trim(); + + // Macht aus „/media/../etc" wieder „/etc". Das ist keine Abwehr — + // Administratoren dürfen überallhin — sondern sorgt dafür, dass der + // angezeigte Pfad der ist, den man auch eintippen würde. + try { pfad = Path.GetFullPath(pfad); } + catch (Exception e) when (e is ArgumentException or NotSupportedException or PathTooLongException) + { + return null; + } + + if (!Directory.Exists(pfad)) return null; + + var eltern = Path.GetDirectoryName(pfad.TrimEnd(Path.DirectorySeparatorChar)); + var anDerWurzel = string.IsNullOrEmpty(eltern); + + List eintraege; + string? hinweis = null; + try + { + eintraege = Directory.EnumerateDirectories(pfad) + .Select(p => new Eintrag(Path.GetFileName(p), p)) + .Where(e => e.Name.Length > 0) + .Where(e => !anDerWurzel || !AnDerWurzelVerborgen.Contains(e.Name)) + .OrderBy(e => e.Name, StringComparer.OrdinalIgnoreCase) + .ToList(); + + if (eintraege.Count == 0) hinweis = "Keine Unterordner."; + } + catch (UnauthorizedAccessException) + { + eintraege = []; + hinweis = "Kein Leserecht für diesen Ordner."; + } + catch (IOException) + { + eintraege = []; + hinweis = "Der Ordner liess sich nicht lesen."; + } + + return new Antwort(pfad, anDerWurzel ? null : eltern, eintraege, hinweis); + } +}