From 82d70e4c60b31152360bc737624aa806afcf6b90 Mon Sep 17 00:00:00 2001 From: Claude Bot Date: Sun, 25 Oct 2026 09:27:40 +0100 Subject: [PATCH] feat(host): house encyclopedia pages with anonymous public view Co-Authored-By: Claude Opus 5.5 --- .../Reading/ArticleQueries.cs | 67 +++ src/Wiki.Core/Model/Attachment.cs | 4 + src/Wiki.Host/Api/HouseEndpoints.cs | 106 ++++ src/Wiki.Host/Api/HouseMediaCache.cs | 45 ++ src/Wiki.Host/Components/App.razor | 74 ++- .../Components/Layout/HouseLayout.razor | 19 + .../Components/Layout/MainLayout.razor | 1 + src/Wiki.Host/Components/Pages/Error.razor | 1 + src/Wiki.Host/Components/Pages/House.razor | 92 +++ .../Components/Pages/HouseArticle.razor | 117 ++++ .../Components/Shared/HouseNotFound.razor | 12 + src/Wiki.Host/Components/_Imports.razor | 1 + src/Wiki.Host/Program.cs | 10 +- src/Wiki.Host/Rendering/ArticleMarkdown.cs | 46 +- src/Wiki.Host/wwwroot/house.css | 25 + test/Wiki.Host.Tests/ArticleMarkdownTests.cs | 8 +- test/Wiki.Host.Tests/Fakes.cs | 10 +- test/Wiki.Host.Tests/HouseTests.cs | 532 ++++++++++++++++++ 18 files changed, 1124 insertions(+), 46 deletions(-) create mode 100644 src/Wiki.Application/Reading/ArticleQueries.cs create mode 100644 src/Wiki.Host/Api/HouseEndpoints.cs create mode 100644 src/Wiki.Host/Api/HouseMediaCache.cs create mode 100644 src/Wiki.Host/Components/Layout/HouseLayout.razor create mode 100644 src/Wiki.Host/Components/Pages/House.razor create mode 100644 src/Wiki.Host/Components/Pages/HouseArticle.razor create mode 100644 src/Wiki.Host/Components/Shared/HouseNotFound.razor create mode 100644 test/Wiki.Host.Tests/HouseTests.cs diff --git a/src/Wiki.Application/Reading/ArticleQueries.cs b/src/Wiki.Application/Reading/ArticleQueries.cs new file mode 100644 index 0000000..0c01a25 --- /dev/null +++ b/src/Wiki.Application/Reading/ArticleQueries.cs @@ -0,0 +1,67 @@ +using Wiki.Core.Model; +using Wiki.Core.Parsing; +using Wiki.Core.Ports; + +namespace Wiki.Application.Reading; + +/// +/// Read side of the house encyclopedia, straight from the catalog. An article file that does not parse +/// counts as missing. Anonymous visibility is per subject: both language files must exist and be public. +/// +public sealed class ArticleQueries(IDocumentCatalog catalog) +{ + public static readonly IReadOnlyList Languages = ["de", "en"]; + + public async Task GetAsync(string lang, string slug, CancellationToken ct) + { + var doc = await catalog.GetAsync(DocumentKind.Article, $"{lang}/{slug}", ct); + return doc is null ? null : TryParse(doc); + } + + /// Every article in , by title. + public async Task> ListAsync(string lang, CancellationToken ct) => + (await AllAsync(ct)).Where(a => a.Lang == lang) + .OrderBy(a => a.Title, StringComparer.InvariantCultureIgnoreCase) + .ThenBy(a => a.Slug, StringComparer.Ordinal) + .ToList(); + + /// slug → title in ; anonymous: only slugs whose de and en files are both public. + public async Task> VisibleTitlesAsync(string lang, bool signedIn, CancellationToken ct) + { + var all = await AllAsync(ct); + var publicSlugs = signedIn ? null : PublicSlugs(all); + return all.Where(a => a.Lang == lang && (publicSlugs is null || publicSlugs.Contains(a.Slug))) + .ToDictionary(a => a.Slug, a => a.Title, StringComparer.Ordinal); + } + + /// True unless both language files exist, parse and are public. + public async Task IsPrivateAsync(string slug, CancellationToken ct) + { + foreach (var lang in Languages) + { + if (await GetAsync(lang, slug, ct) is not { Visibility: ArticleVisibility.Public }) return true; + } + return false; + } + + private async Task> AllAsync(CancellationToken ct) => + (await catalog.ListDocumentsAsync(DocumentKind.Article, ct)).Select(TryParse).OfType
().ToList(); + + private static HashSet PublicSlugs(List
all) => + all.GroupBy(a => a.Slug, StringComparer.Ordinal) + .Where(g => Languages.All(l => g.Any(a => a.Lang == l && a.Visibility == ArticleVisibility.Public))) + .Select(g => g.Key) + .ToHashSet(StringComparer.Ordinal); + + private static Article? TryParse(Document doc) + { + try + { + return ArticleFile.Parse(doc); + } + catch (FormatException) + { + return null; + } + } +} diff --git a/src/Wiki.Core/Model/Attachment.cs b/src/Wiki.Core/Model/Attachment.cs index 496707d..6f8ebbc 100644 --- a/src/Wiki.Core/Model/Attachment.cs +++ b/src/Wiki.Core/Model/Attachment.cs @@ -19,5 +19,9 @@ public sealed record Attachment(string MediaType, byte[] Bytes, string? Caption /// The file extension for a supported media type, or null. public static string? ExtensionFor(string mediaType) => Extensions.TryGetValue(mediaType, out var e) ? e : null; + /// The supported media type for a file extension without the dot, or null. + public static string? MediaTypeFor(string extension) => + Extensions.FirstOrDefault(kv => string.Equals(kv.Value, extension, StringComparison.OrdinalIgnoreCase)).Key; + public string? Extension => ExtensionFor(MediaType); } diff --git a/src/Wiki.Host/Api/HouseEndpoints.cs b/src/Wiki.Host/Api/HouseEndpoints.cs new file mode 100644 index 0000000..d2fb7ac --- /dev/null +++ b/src/Wiki.Host/Api/HouseEndpoints.cs @@ -0,0 +1,106 @@ +using System.Security.Claims; +using System.Text.RegularExpressions; +using Wiki.Application.Reading; +using Wiki.Core.Model; +using Wiki.Core.Ports; +using Wiki.Host.Auth; +using Wiki.Host.Rendering; + +namespace Wiki.Host.Api; + +/// +/// The house encyclopedia's non-page routes: the login entry and the article images. Every /house +/// response is marked noindex and no-store. Private content is for signed-in humans only; a machine +/// identity reads the anonymous view. +/// +public static partial class HouseEndpoints +{ + public const string FrontPage = "/house/de"; + + [GeneratedRegex("^[a-z0-9]+(-[a-z0-9]+)*$")] + private static partial Regex SlugPattern(); + + public static bool IsLanguage(string? lang) => lang is not null && ArticleQueries.Languages.Contains(lang); + + public static bool IsSlug(string? slug) => slug is not null && SlugPattern().IsMatch(slug); + + /// A human signed in with OIDC; API-key and BasicAuth identities are not. + public static bool IsHuman(ClaimsPrincipal user) => + user.Identity?.IsAuthenticated == true && user.HasClaim(Claims.Kind, "human"); + + public static string LoginHref(string returnUrl) => $"/house/login?returnUrl={Uri.EscapeDataString(returnUrl)}"; + + /// A local /house/ path, or null. + public static string? SafeReturnUrl(string? returnUrl) => + returnUrl is not null + && returnUrl.StartsWith("/house/", StringComparison.Ordinal) + && !returnUrl.StartsWith("//", StringComparison.Ordinal) + && !returnUrl.StartsWith("/\\", StringComparison.Ordinal) + && !returnUrl.Contains('\\') + && !returnUrl.Any(char.IsControl) + ? returnUrl + : null; + + /// + /// Makes a house page's response a 404 that keeps its body. Static SSR discards the body of a response whose + /// status is already 404 after rendering unless it was re-executed, so the status is set when the response starts. + /// + public static void RespondNotFound(HttpResponse response) => + response.OnStarting(() => + { + response.StatusCode = StatusCodes.Status404NotFound; + return Task.CompletedTask; + }); + + /// Adds the noindex and no-store headers to every /house response, also one rewritten by the exception handler. + public static IApplicationBuilder UseHouseHeaders(this IApplicationBuilder app) => + app.Use((ctx, next) => + { + if (ctx.Request.Path.StartsWithSegments("/house")) + { + ctx.Response.OnStarting(() => + { + ctx.Response.Headers["X-Robots-Tag"] = "noindex"; + ctx.Response.Headers.CacheControl = "no-store"; + return Task.CompletedTask; + }); + } + return next(ctx); + }); + + public static void MapHouseEndpoints(this IEndpointRouteBuilder app) + { + // The default policy: the cookie, or a challenge that sends the browser to the OIDC provider and back here. + app.MapGet("/house/login", (string? returnUrl) => Results.LocalRedirect(SafeReturnUrl(returnUrl) ?? FrontPage)) + .RequireAuthorization(); + + app.MapGet("/house/media/{lang}/{slug}/{n:int}", MediaAsync).AllowAnonymous(); + } + + private static async Task MediaAsync(string lang, string slug, int n, HttpContext http, ArticleQueries articles, + IDocumentCatalog catalog, INoteStore store, HouseMediaCache cache, CancellationToken ct) + { + if (!IsLanguage(lang) || !IsSlug(slug)) return Results.NotFound(); + var signedIn = IsHuman(http.User); + var article = await articles.GetAsync(lang, slug, ct); + if (article is null || (!signedIn && await articles.IsPrivateAsync(slug, ct))) return Results.NotFound(); + + var titles = await articles.VisibleTitlesAsync(lang, signedIn, ct); + var paths = ArticleMarkdown.VisibleImagePaths(article.Body, lang, slug, signedIn, titles); + if (n < 0 || n >= paths.Count) return Results.NotFound(); + var path = paths[n]; + var mediaType = Attachment.MediaTypeFor(Path.GetExtension(path).TrimStart('.')); + var commit = (await catalog.GetStateAsync(ct)).Commit; + if (mediaType is null || commit is null) return Results.NotFound(); + + try + { + var bytes = await cache.GetAsync(commit, path, c => store.ReadBytesAsync(path, commit, c), ct); + return Results.Bytes(bytes, mediaType); + } + catch (FileNotFoundException) + { + return Results.NotFound(); + } + } +} diff --git a/src/Wiki.Host/Api/HouseMediaCache.cs b/src/Wiki.Host/Api/HouseMediaCache.cs new file mode 100644 index 0000000..011d138 --- /dev/null +++ b/src/Wiki.Host/Api/HouseMediaCache.cs @@ -0,0 +1,45 @@ +namespace Wiki.Host.Api; + +/// +/// Image bytes of the house encyclopedia by (commit, path); a path's bytes never change at a commit. +/// Holds at most in total and evicts the oldest entries first. Concurrent misses +/// for the same key may each load; the first stored wins. +/// +public sealed class HouseMediaCache(long maxBytes) +{ + public const long MaxBytes = 64L * 1024 * 1024; + + private readonly object _gate = new(); + private readonly Dictionary<(string Commit, string Path), LinkedListNode> _entries = new(); + private readonly LinkedList _order = new(); + private long _total; + + public HouseMediaCache() : this(MaxBytes) { } + + public async Task GetAsync(string commit, string path, Func> load, CancellationToken ct) + { + var key = (commit, path); + lock (_gate) + { + if (_entries.TryGetValue(key, out var hit)) return hit.Value.Bytes; + } + var bytes = await load(ct); + lock (_gate) + { + if (_entries.TryGetValue(key, out var stored)) return stored.Value.Bytes; + if (bytes.Length > maxBytes) return bytes; + _entries[key] = _order.AddLast(new Entry(key, bytes)); + _total += bytes.Length; + while (_total > maxBytes) + { + var oldest = _order.First!.Value; + _order.RemoveFirst(); + _entries.Remove(oldest.Key); + _total -= oldest.Bytes.Length; + } + } + return bytes; + } + + private sealed record Entry((string Commit, string Path) Key, byte[] Bytes); +} diff --git a/src/Wiki.Host/Components/App.razor b/src/Wiki.Host/Components/App.razor index 6bb833e..be236a9 100644 --- a/src/Wiki.Host/Components/App.razor +++ b/src/Wiki.Host/Components/App.razor @@ -1,24 +1,58 @@ - +@if (House) +{ + @* Static SSR with its own head; no base href, so in-page #fragment links stay on the page. *@ + - - - - - - - - - - - + + + + + + + - - - - - - - + + + + + - + +} +else +{ + + + + + + + + + + + + + + + + + + + + + + + +} + +@code { + [CascadingParameter] + private HttpContext? HttpContext { get; set; } + + /// The house encyclopedia's pages, the in-page 404 and the private notice included. + private bool House => HttpContext?.Request.Path.StartsWithSegments("/house") == true; + + private string HouseLang => HttpContext?.Request.Path.StartsWithSegments("/house/en") == true ? "en" : "de"; +} diff --git a/src/Wiki.Host/Components/Layout/HouseLayout.razor b/src/Wiki.Host/Components/Layout/HouseLayout.razor new file mode 100644 index 0000000..26f0244 --- /dev/null +++ b/src/Wiki.Host/Components/Layout/HouseLayout.razor @@ -0,0 +1,19 @@ +@inherits LayoutComponentBase + + + +@code { + [CascadingParameter] + private HttpContext? HttpContext { get; set; } + + private bool English => HttpContext?.Request.Path.StartsWithSegments("/house/en") == true; + + private string Lang => English ? "en" : "de"; +} diff --git a/src/Wiki.Host/Components/Layout/MainLayout.razor b/src/Wiki.Host/Components/Layout/MainLayout.razor index f66687d..efbf00f 100644 --- a/src/Wiki.Host/Components/Layout/MainLayout.razor +++ b/src/Wiki.Host/Components/Layout/MainLayout.razor @@ -19,6 +19,7 @@ Maintenance Lab Status + Haus diff --git a/src/Wiki.Host/Components/Pages/Error.razor b/src/Wiki.Host/Components/Pages/Error.razor index 576cc2d..941f5e7 100644 --- a/src/Wiki.Host/Components/Pages/Error.razor +++ b/src/Wiki.Host/Components/Pages/Error.razor @@ -1,5 +1,6 @@ @page "/Error" @using System.Diagnostics +@attribute [AllowAnonymous] Error diff --git a/src/Wiki.Host/Components/Pages/House.razor b/src/Wiki.Host/Components/Pages/House.razor new file mode 100644 index 0000000..1c9277e --- /dev/null +++ b/src/Wiki.Host/Components/Pages/House.razor @@ -0,0 +1,92 @@ +@page "/house" +@page "/house/{Lang}" +@attribute [AllowAnonymous] +@layout HouseLayout +@using Wiki.Host.Api +@inject ArticleQueries Articles +@inject NavigationManager Navigation + +@if (_notFound) +{ + +} +else if (_groups is not null) +{ + @Heading + +

@Heading

+
+ @(English ? "Articles about the house, generated from the wiki." : "Artikel über das Haus, erstellt aus dem Wiki.") + · @(English ? "Deutsch" : "English") + @if (!_signedIn) + { + · @(English ? "Log in to see more" : "Anmelden, um mehr zu sehen") + } +
+ + @if (_groups.Count == 0) + { +

@(English ? "No articles yet." : "Noch keine Artikel.")

+ } + @foreach (var (category, articles) in _groups) + { +

@CategoryName(category)

+ + } +} + +@code { + [CascadingParameter] + private HttpContext HttpContext { get; set; } = default!; + + [Parameter] + public string? Lang { get; set; } + + private bool _notFound; + private bool _signedIn; + private List<(ArticleCategory Category, List
Articles)>? _groups; + + private bool English => Lang == "en"; + private string Other => English ? "de" : "en"; + private string Heading => English ? "House encyclopedia" : "Hausenzyklopädie"; + + protected override async Task OnInitializedAsync() + { + if (Lang is null) + { + Navigation.NavigateTo(HouseEndpoints.FrontPage); + return; + } + if (!HouseEndpoints.IsLanguage(Lang)) + { + _notFound = true; + HouseEndpoints.RespondNotFound(HttpContext.Response); + return; + } + var ct = HttpContext.RequestAborted; + _signedIn = HouseEndpoints.IsHuman(HttpContext.User); + var visible = await Articles.VisibleTitlesAsync(Lang, _signedIn, ct); + var articles = (await Articles.ListAsync(Lang, ct)).Where(a => visible.ContainsKey(a.Slug)).ToList(); + _groups = Enum.GetValues() + .Select(c => (c, articles.Where(a => a.Category == c).ToList())) + .Where(g => g.Item2.Count > 0) + .ToList(); + } + + private string CategoryName(ArticleCategory category) => (category, English) switch + { + (ArticleCategory.Room, false) => "Räume", + (ArticleCategory.Room, true) => "Rooms", + (ArticleCategory.System, false) => "Systeme", + (ArticleCategory.System, true) => "Systems", + (ArticleCategory.Device, false) => "Geräte", + (ArticleCategory.Device, true) => "Devices", + (_, false) => "Themen", + (_, true) => "Topics", + }; +} diff --git a/src/Wiki.Host/Components/Pages/HouseArticle.razor b/src/Wiki.Host/Components/Pages/HouseArticle.razor new file mode 100644 index 0000000..295a28a --- /dev/null +++ b/src/Wiki.Host/Components/Pages/HouseArticle.razor @@ -0,0 +1,117 @@ +@page "/house/{Lang}/{Slug}" +@page "/house/{Lang}/{Slug}/{*Rest}" +@attribute [AllowAnonymous] +@layout HouseLayout +@using System.Net +@using Wiki.Host.Api +@inject ArticleQueries Articles +@inject ILogger Log + +@if (_notFound) +{ + +} +else if (_private) +{ + @(English ? "Private article" : "Privater Artikel") + +

+ + @(English ? "This article is private — log in" : "Dieser Artikel ist privat — anmelden") +

+} +else if (_article is not null && _rendered is not null) +{ + @_article.Title + +

@_article.Title

+
+ @(English ? "From the house encyclopedia" : "Aus der Hausenzyklopädie") + · @(English ? "generated" : "erstellt am") @_article.Generated.ToString("yyyy-MM-dd") + @if (_otherExists) + { + · @(English ? "Deutsch" : "English") + } + @if (!_signedIn) + { + · @(English ? "Log in to see more" : "Anmelden, um mehr zu sehen") + } +
+ + @if (_infobox is not null) + { + @((MarkupString)_infobox) + } + + @if (_rendered.Toc.Count > 0) + { + + } + + @((MarkupString)_rendered.Html) +} + +@code { + [CascadingParameter] + private HttpContext HttpContext { get; set; } = default!; + + [Parameter] + public string Lang { get; set; } = ""; + + [Parameter] + public string Slug { get; set; } = ""; + + [Parameter] + public string? Rest { get; set; } + + private bool _notFound; + private bool _private; + private bool _signedIn; + private bool _otherExists; + private Article? _article; + private RenderedArticle? _rendered; + private string? _infobox; + + private bool English => Lang == "en"; + private string Other => English ? "de" : "en"; + private string SelfPath => $"/house/{Lang}/{Slug}"; + + protected override async Task OnInitializedAsync() + { + var ct = HttpContext.RequestAborted; + if (Rest is not null || !HouseEndpoints.IsLanguage(Lang) || !HouseEndpoints.IsSlug(Slug) + || await Articles.GetAsync(Lang, Slug, ct) is not { } article) + { + _notFound = true; + HouseEndpoints.RespondNotFound(HttpContext.Response); + return; + } + _signedIn = HouseEndpoints.IsHuman(HttpContext.User); + if (!_signedIn && await Articles.IsPrivateAsync(Slug, ct)) + { + _private = true; + return; + } + var titles = await Articles.VisibleTitlesAsync(Lang, _signedIn, ct); + _article = article; + _rendered = ArticleMarkdown.Render(article.Body, Lang, Slug, _signedIn, titles, Log); + _infobox = WithTitleBar(_rendered.InfoboxHtml, article.Title); + _otherExists = await Articles.GetAsync(Other, Slug, ct) is not null; + } + + /// Puts a title bar first into the infobox, after its opening tag. + private static string? WithTitleBar(string? infoboxHtml, string title) + { + if (infoboxHtml is null) return null; + var open = infoboxHtml.IndexOf('>') + 1; + return infoboxHtml.Insert(open, $"\n
{WebUtility.HtmlEncode(title)}
"); + } +} diff --git a/src/Wiki.Host/Components/Shared/HouseNotFound.razor b/src/Wiki.Host/Components/Shared/HouseNotFound.razor new file mode 100644 index 0000000..1e0a3f6 --- /dev/null +++ b/src/Wiki.Host/Components/Shared/HouseNotFound.razor @@ -0,0 +1,12 @@ +@* The house's own 404 body; the page sets the status. *@ +@(English ? "Article not found" : "Artikel nicht gefunden") + +

@(English ? "Article not found" : "Artikel nicht gefunden")

+

@(English ? "To the front page" : "Zur Startseite")

+ +@code { + [Parameter] + public string? Lang { get; set; } + + private bool English => Lang == "en"; +} diff --git a/src/Wiki.Host/Components/_Imports.razor b/src/Wiki.Host/Components/_Imports.razor index 712ea26..4f41bc6 100644 --- a/src/Wiki.Host/Components/_Imports.razor +++ b/src/Wiki.Host/Components/_Imports.razor @@ -1,4 +1,5 @@ @using System.Net.Http +@using Microsoft.AspNetCore.Authorization @using System.Net.Http.Json @using Microsoft.AspNetCore.Components.Forms @using Microsoft.AspNetCore.Components.Routing diff --git a/src/Wiki.Host/Program.cs b/src/Wiki.Host/Program.cs index 501832f..4f87c50 100644 --- a/src/Wiki.Host/Program.cs +++ b/src/Wiki.Host/Program.cs @@ -145,6 +145,8 @@ builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(new HouseMediaCache()); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); @@ -167,14 +169,17 @@ builder.Services.AddMudServices(); var app = builder.Build(); if (encyclopediaConfigWarning is not null) app.Logger.LogWarning("{Warning}", encyclopediaConfigWarning); +// Outermost, so the headers also reach a response the exception handler rewrites. +app.UseHouseHeaders(); if (!app.Environment.IsDevelopment()) { app.UseExceptionHandler("/Error", createScopeForErrors: true); } // The not-found page is for the UI only: an API 401/404 must stay a JSON status, not be // re-executed as a POST against a Blazor page (which fails antiforgery with an empty 400). +// House 404s keep their own response: /not-found needs a login, which would turn them into a redirect. app.UseWhen( - ctx => HttpMethods.IsGet(ctx.Request.Method) && !ctx.Request.Path.StartsWithSegments("/api"), + ctx => HttpMethods.IsGet(ctx.Request.Method) && !ctx.Request.Path.StartsWithSegments("/api") && !ctx.Request.Path.StartsWithSegments("/house"), branch => branch.UseStatusCodePagesWithReExecute("/not-found", createScopeForStatusCodePages: true)); app.UseAuthentication(); app.UseAuthorization(); @@ -183,8 +188,9 @@ app.UseAntiforgery(); app.MapHealthEndpoints(); app.MapIndexEndpoints(); app.MapApiV1(); +app.MapHouseEndpoints(); -app.MapStaticAssets(); +app.MapStaticAssets().AllowAnonymous(); app.MapRazorComponents().AddInteractiveServerRenderMode(); app.Run(); diff --git a/src/Wiki.Host/Rendering/ArticleMarkdown.cs b/src/Wiki.Host/Rendering/ArticleMarkdown.cs index 8975771..c7bd936 100644 --- a/src/Wiki.Host/Rendering/ArticleMarkdown.cs +++ b/src/Wiki.Host/Rendering/ArticleMarkdown.cs @@ -28,7 +28,7 @@ public sealed record RenderedArticle(string Html, string? InfoboxHtml, IReadOnly /// readers and is dropped for signed-in ones. /// Raw HTML is disabled. Link targets are limited to http:, https:, mailto:, #… and /// /house/… pages the reader may see; other links keep only their text. Link titles are removed. Images are -/// served by the media endpoint by index into ; every other image is dropped. +/// served by the media endpoint by index into ; every other image is dropped. /// public static partial class ArticleMarkdown { @@ -75,9 +75,21 @@ public static partial class ArticleMarkdown IReadOnlyDictionary visibleTitles, ILogger? logger = null) => RenderVisible(signedIn ? body : ArticleBody.RedactPrivate(body), lang, slug, signedIn, visibleTitles, logger); + /// + /// The raw/ image paths the media endpoint may serve for this reader: index n is the image + /// rewrote to /house/media/{lang}/{slug}/{n} with the same arguments. Empty when the render is locked. + /// + public static IReadOnlyList VisibleImagePaths(string body, string lang, string slug, bool signedIn, + IReadOnlyDictionary visibleTitles) => + Process(signedIn ? body : ArticleBody.RedactPrivate(body), lang, slug, signedIn, visibleTitles, logger: null).Images; + /// Renders a body that is already what this reader may see. internal static RenderedArticle RenderVisible(string visibleBody, string lang, string slug, bool signedIn, - IReadOnlyDictionary visibleTitles, ILogger? logger = null) + IReadOnlyDictionary visibleTitles, ILogger? logger = null) => + Process(visibleBody, lang, slug, signedIn, visibleTitles, logger).Article; + + private static (RenderedArticle Article, IReadOnlyList Images) Process(string visibleBody, string lang, string slug, + bool signedIn, IReadOnlyDictionary visibleTitles, ILogger? logger) { var slugs = new List(); var doc = Parse(visibleBody, slugs); @@ -86,13 +98,14 @@ public static partial class ArticleMarkdown if (doc.Descendants().Any(c => c.Info is not (Infobox or Locked))) { logger?.LogWarning("Article {Slug} ({Lang}) has an unexpected container after redaction; rendering it locked", slug, lang); - return LockedArticle(lang, slug); + return (LockedArticle(lang, slug), []); } foreach (var c in doc.Descendants().Where(c => c.Info is not (Infobox or Locked)).ToList()) c.Parent?.Remove(c); } - SanitizeLinks(doc, lang, slug, visibleTitles); + var images = new List(); + SanitizeLinks(doc, lang, slug, visibleTitles, images); var headings = ResolveWikiLinks(doc, lang, slugs, visibleTitles); var leftovers = doc.Descendants().Where(HasLeftover).ToList(); @@ -101,7 +114,7 @@ public static partial class ArticleMarkdown if (!signedIn) { logger?.LogWarning("Article {Slug} ({Lang}) has unresolved link text after rendering; rendering it locked", slug, lang); - return LockedArticle(lang, slug); + return (LockedArticle(lang, slug), []); } foreach (var leaf in leftovers) StripLeftover(leaf, headings); } @@ -116,19 +129,13 @@ public static partial class ArticleMarkdown .Select(h => new TocEntry(h.Level, h.GetAttributes().Id!, PlainText(h.Inline, includeCode: true).Trim())) .ToList(); - return new RenderedArticle( + var article = new RenderedArticle( ToHtml(doc, lang, slug, signedIn), infobox is null ? null : ToHtml(infobox, lang, slug, signedIn), toc); + return (article, images); } - /// The valid raw/ image paths of a body, in order; the media index n of points into this list. - public static IReadOnlyList ImagePaths(string visibleBody) => - Parse(visibleBody, []).Descendants() - .Where(l => l.IsImage && l.Url is not null && RawImage.IsMatch(l.Url)) - .Select(l => l.Url!) - .ToList(); - private static RenderedArticle LockedArticle(string lang, string slug) => new(ToHtml(Parse($"{ArticleBody.PrivateLocked}\n{ArticleBody.Close}", []), lang, slug, signedIn: false), null, []); @@ -217,12 +224,12 @@ public static partial class ArticleMarkdown } /// - /// Rewrites valid raw/ images to the media endpoint and drops every other image; keeps only the text of links - /// whose target is not allowed; removes all link titles. + /// Rewrites valid raw/ images to the media endpoint, appending each path to at its index, + /// and drops every other image; keeps only the text of links whose target is not allowed; removes all link titles. /// - private static void SanitizeLinks(MarkdownDocument doc, string lang, string slug, IReadOnlyDictionary visibleTitles) + private static void SanitizeLinks(MarkdownDocument doc, string lang, string slug, IReadOnlyDictionary visibleTitles, + List images) { - var index = 0; foreach (var obj in doc.Descendants().ToList()) { if (obj is AutolinkInline auto) @@ -241,7 +248,10 @@ public static partial class ArticleMarkdown if (link.IsImage) { if (link.GetDynamicUrl is null && link.Url is not null && RawImage.IsMatch(link.Url)) - link.Url = $"/house/media/{Uri.EscapeDataString(lang)}/{Uri.EscapeDataString(slug)}/{index++}"; + { + images.Add(link.Url); + link.Url = $"/house/media/{Uri.EscapeDataString(lang)}/{Uri.EscapeDataString(slug)}/{images.Count - 1}"; + } else link.Remove(); continue; diff --git a/src/Wiki.Host/wwwroot/house.css b/src/Wiki.Host/wwwroot/house.css index 34eebbe..2560440 100644 --- a/src/Wiki.Host/wwwroot/house.css +++ b/src/Wiki.Host/wwwroot/house.css @@ -1,3 +1,27 @@ +/* House encyclopedia pages: header, then the article column. */ +.house-body { margin: 0; background: #f6f6f6; } + +.house-page { max-width: 64rem; margin: 0 auto; } + +.house-header { + padding: .6rem 1.75rem; + border-bottom: 1px solid #a2a9b1; + background: #fff; + font-family: sans-serif; +} +.house-header .house-brand { color: #202122; font-size: 1.15em; text-decoration: none; } + +.house-article .house-from { font-family: sans-serif; font-size: .85em; color: #54595d; margin-bottom: 1rem; } +.house-article .house-index { columns: 2 16rem; padding-left: 1.2rem; } +.house-article .infobox .infobox-title { + background: #cee0f2; + text-align: center; + font-weight: bold; + padding: 6px; + font-size: 1.1em; +} +.house-article .toc .toc-level-3 { margin-left: 1rem; } + /* House encyclopedia article, Wikipedia look. */ .house-article { font-family: Georgia, "Linux Libertine", serif; @@ -113,5 +137,6 @@ @media (max-width: 720px) { .house-article { padding: 1rem; } + .house-header { padding: .6rem 1rem; } .house-article .infobox { float: none; width: auto; margin: 0 0 1rem; } } diff --git a/test/Wiki.Host.Tests/ArticleMarkdownTests.cs b/test/Wiki.Host.Tests/ArticleMarkdownTests.cs index ce4c6f8..b29a966 100644 --- a/test/Wiki.Host.Tests/ArticleMarkdownTests.cs +++ b/test/Wiki.Host.Tests/ArticleMarkdownTests.cs @@ -218,12 +218,12 @@ public class ArticleMarkdownTests Assert.DoesNotContain("raw/", anonymous.Html); Assert.DoesNotContain("example.com", anonymous.Html); Assert.DoesNotContain("Keller", anonymous.Html); - Assert.Equal(["raw/taster-1.png"], ArticleMarkdown.ImagePaths(Wiki.Core.Parsing.ArticleBody.RedactPrivate(body))); + Assert.Equal(["raw/taster-1.png"], ArticleMarkdown.VisibleImagePaths(body, "de", "switches", signedIn: false, Titles)); var signedIn = SignedIn(body); Assert.Contains("\"Keller\"", signedIn.Html); Assert.Contains("\"Taster\"", signedIn.Html); - Assert.Equal(["raw/keller.jpg", "raw/taster-1.png"], ArticleMarkdown.ImagePaths(body)); + Assert.Equal(["raw/keller.jpg", "raw/taster-1.png"], ArticleMarkdown.VisibleImagePaths(body, "de", "switches", signedIn: true, Titles)); } [Fact] @@ -237,11 +237,11 @@ public class ArticleMarkdownTests } [Fact] - public void ImagePaths_RejectsTraversalAndNonImages() + public void VisibleImagePaths_RejectsTraversalAndNonImages() { var body = "![a](raw/../wiki/x.png) ![b](raw/sub/x.png) ![c](raw/x.md) ![d](raw/x.jpeg) ![e](https://example.com/x.png) ![f](raw/zaehler-1.jpg)\n\n```\n![g](raw/in-code.jpg)\n```"; - Assert.Equal(["raw/zaehler-1.jpg"], ArticleMarkdown.ImagePaths(body)); + Assert.Equal(["raw/zaehler-1.jpg"], ArticleMarkdown.VisibleImagePaths(body, "de", "switches", signedIn: true, Titles)); } [Theory] diff --git a/test/Wiki.Host.Tests/Fakes.cs b/test/Wiki.Host.Tests/Fakes.cs index 0634a53..f961085 100644 --- a/test/Wiki.Host.Tests/Fakes.cs +++ b/test/Wiki.Host.Tests/Fakes.cs @@ -72,10 +72,16 @@ public sealed class FakeStore : INoteStore public Task ReadTextAsync(string path, string commit, CancellationToken ct) => Task.FromResult(Files.TryGetValue(path, out var text) ? new StoredText(text, BlobSha(text)) : null); - public Task ReadBytesAsync(string path, string commit, CancellationToken ct) => - Task.FromResult(Binaries.TryGetValue(path, out var bytes) ? bytes + /// Every path was asked for, in order. + public List ByteReads { get; } = []; + + public Task ReadBytesAsync(string path, string commit, CancellationToken ct) + { + lock (ByteReads) ByteReads.Add(path); + return Task.FromResult(Binaries.TryGetValue(path, out var bytes) ? bytes : Files.TryGetValue(path, out var text) ? Encoding.UTF8.GetBytes(text) : throw new FileNotFoundException(path)); + } private static string BlobSha(string text) => Convert.ToHexString(SHA1.HashData(Encoding.UTF8.GetBytes(text))); diff --git a/test/Wiki.Host.Tests/HouseTests.cs b/test/Wiki.Host.Tests/HouseTests.cs new file mode 100644 index 0000000..5babc85 --- /dev/null +++ b/test/Wiki.Host.Tests/HouseTests.cs @@ -0,0 +1,532 @@ +using System.Net; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.AspNetCore.WebUtilities; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Wiki.Core.Model; +using Wiki.Core.Ports; + +namespace Wiki.Host.Tests; + +/// +/// The house encyclopedia: anonymous readers get the public view and nothing else, signed-in humans +/// everything, and every other route keeps requiring a login. +/// +public class HouseTests(WikiHostFactory factory) : IClassFixture +{ + private static readonly byte[] TasterPng = [0x89, 0x50, 0x4e, 0x47, 1, 2, 3]; + private static readonly byte[] GeheimPng = [0x89, 0x50, 0x4e, 0x47, 9, 9, 9]; + + private const string SchalterBody = + "::: infobox\n| Eigenschaft | Wert |\n|---|---|\n| Typ | Taster |\n| Siehe | [[network-internals]] |\n:::\n\n" + + "Die **Schalter** im Haus sind Taster. Mehr unter [[network-internals]].\n\n" + + "![Taster](raw/taster.png)\n\n" + + "## Bedienung\n\nEinmal drücken schaltet das Licht.\n\n" + + "::: private\nGEHEIM steht hier. ![Geheim](raw/geheim.png)\n:::\n"; + + private static string ArticleText(string lang, string slug, string title, string visibility, string body, string category = "device") => + $"---\ntitle: {title}\nlang: {lang}\nsubject: {slug}\ncategory: {category}\nvisibility: {visibility}\nsources: []\ngenerated: 2026-10-24\nmodel: test\n---\n\n{body}"; + + private void Put(string slug, string titleDe, string titleEn, string visibilityDe, string visibilityEn, string body, string category = "device") + { + factory.Store.Files[$"articles/de/{slug}.md"] = ArticleText("de", slug, titleDe, visibilityDe, body, category); + factory.Store.Files[$"articles/en/{slug}.md"] = ArticleText("en", slug, titleEn, visibilityEn, body, category); + } + + private async Task SeedAsync() + { + Put("schalter", "Wandschalter", "Wall switches", "public", "public", SchalterBody); + Put("network-internals", "Netzwerk-Interna", "Network internals", "private", "private", "Das VLAN-Layout.", "system"); + Put("heizung", "Heizung", "Heating", "public", "private", "Die Wärmepumpe.", "system"); + Put("x", "Pfad", "Path", "public", "public", "Bild: ![a](raw/../articles/_state.json)"); + Put("y", "Notiz", "Note", "public", "public", "Bild: ![a](raw/notes.md)"); + Put("z", "Zwischenspeicher", "Cache", "public", "public", "Bild: ![a](raw/cache.png)"); + // escaped brackets survive as link text, so the anonymous render locks the whole body + Put("locked", "Gesperrt", "Locked", "public", "public", "Öffentlich.\n\n\\[\\[network-internals]]\n\n![Bild](raw/locked.png)"); + factory.Store.Binaries["raw/taster.png"] = TasterPng; + factory.Store.Binaries["raw/geheim.png"] = GeheimPng; + factory.Store.Binaries["raw/cache.png"] = [1, 2, 3]; + factory.Store.Binaries["raw/locked.png"] = [4, 5, 6]; + factory.Store.Files["raw/notes.md"] = "---\ntitle: N\ntype: raw\n---\n\nnotes\n"; + + var sync = new HttpRequestMessage(HttpMethod.Post, "/api/v1/sync?force=true"); + sync.Headers.Add("X-Api-Key", WikiHostFactory.ApiKey); + Assert.Equal(HttpStatusCode.OK, (await factory.CreateClient().SendAsync(sync)).StatusCode); + } + + private HttpClient Browser() => factory.CreateClient(new WebApplicationFactoryClientOptions + { + AllowAutoRedirect = false, + HandleCookies = true, + BaseAddress = new Uri("https://localhost"), + }); + + /// A browser with a session: the code flow by hand, started from the house login. + private async Task SignedInAsync() + { + var browser = Browser(); + var challenge = await browser.GetAsync("/house/login?returnUrl=%2Fhouse%2Fde"); + Assert.StartsWith(factory.Idp.Configuration.AuthorizationEndpoint, challenge.Headers.Location!.ToString()); + var query = QueryHelpers.ParseQuery(challenge.Headers.Location!.Query); + factory.Idp.Username = "alice"; + factory.Idp.Nonce = query["nonce"]; + var callback = await browser.PostAsync("/signin-oidc", new FormUrlEncodedContent(new Dictionary + { + ["code"] = "the-code", + ["state"] = query["state"].ToString(), + })); + Assert.Equal(HttpStatusCode.Redirect, callback.StatusCode); + return browser; + } + + private static async Task Html(HttpResponseMessage response) => await response.Content.ReadAsStringAsync(); + + /// The page as text: static SSR writes every non-ASCII character as an entity. + private static string Text(string html) => WebUtility.HtmlDecode(html); + + [Fact] + public async Task PublicArticle_Anonymous_200_NoPrivateText() + { + await SeedAsync(); + + var page = await Browser().GetAsync("/house/de/schalter"); + + Assert.Equal(HttpStatusCode.OK, page.StatusCode); + var html = await Html(page); + Assert.Contains("Wandschalter", html); + Assert.Contains("Einmal drücken", html); + Assert.Contains("Aus der Hausenzyklopädie", Text(html)); + Assert.Contains("2026-10-24", html); + Assert.Contains("
Wandschalter
", html); + Assert.Contains("href=\"#bedienung\"", html); + Assert.Contains("href=\"/house/en/schalter\"", html); + Assert.Contains("href=\"/house/login?returnUrl=%2Fhouse%2Fde%2Fschalter\"", html); + Assert.Contains("Netzwerk-Interna", html); + Assert.Contains("/house/media/de/schalter/1", html); + Assert.DoesNotContain("/house/login", Text(html)); + } + + [Fact] + public async Task PublicArticle_MachineIdentity_SeesNoPrivateText() + { + await SeedAsync(); + var keyed = Browser(); + keyed.DefaultRequestHeaders.Add("X-Api-Key", WikiHostFactory.ApiKey); + + var html = await Html(await keyed.GetAsync("/house/de/schalter")); + Assert.DoesNotContain("GEHEIM", Text(html)); + Assert.DoesNotContain("Netzwerk-Interna", Text(html)); + + var basic = Browser(); + basic.DefaultRequestHeaders.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Basic", Convert.ToBase64String("test:test"u8.ToArray())); + Assert.DoesNotContain("Netzwerk-Interna", Text(await Html(await basic.GetAsync("/house/de/network-internals")))); + Assert.Equal(HttpStatusCode.NotFound, (await basic.GetAsync("/house/media/de/schalter/1")).StatusCode); + } + + [Fact] + public async Task PrivateArticle_Anonymous_ShowsNoTitle() + { + await SeedAsync(); + + var page = await Browser().GetAsync("/house/de/network-internals"); + + Assert.Equal(HttpStatusCode.OK, page.StatusCode); + var html = await Html(page); + Assert.Contains("Dieser Artikel ist privat — anmelden", Text(html)); + Assert.Contains("href=\"/house/login?returnUrl=%2Fhouse%2Fde%2Fnetwork-internals\"", html); + Assert.DoesNotContain("Netzwerk-Interna", Text(html)); + Assert.DoesNotContain("Network internals", Text(html)); + Assert.DoesNotContain("VLAN", Text(html)); + } + + [Fact] + public async Task PrivateArticle_SignedIn_ShowsBody() + { + await SeedAsync(); + var browser = await SignedInAsync(); + + var html = await Html(await browser.GetAsync("/house/de/network-internals")); + + Assert.Contains("Netzwerk-Interna", html); + Assert.Contains("VLAN", html); + } + + [Fact] + public async Task PublicArticle_Anonymous_NoPrivateArticleTitle() + { + await SeedAsync(); + + var html = await Html(await Browser().GetAsync("/house/de/schalter")); + + Assert.DoesNotContain("network-internals", Text(html)); + Assert.DoesNotContain("Netzwerk-Interna", Text(html)); + } + + [Fact] + public async Task FrontPage_Anonymous_ListsOnlyPublic() + { + await SeedAsync(); + + var page = await Browser().GetAsync("/house/de"); + + Assert.Equal(HttpStatusCode.OK, page.StatusCode); + var html = await Html(page); + Assert.Contains("Wandschalter", html); + Assert.Contains("Geräte", Text(html)); + Assert.DoesNotContain("network-internals", Text(html)); + Assert.DoesNotContain("Netzwerk-Interna", Text(html)); + } + + [Fact] + public async Task FrontPage_Anonymous_HidesArticlePrivateInOneLanguage() + { + await SeedAsync(); + + var html = await Html(await Browser().GetAsync("/house/de")); + Assert.DoesNotContain("/house/de/heizung", Text(html)); + Assert.DoesNotContain("Heizung", Text(html)); + Assert.Equal(HttpStatusCode.OK, (await Browser().GetAsync("/house/de/heizung")).StatusCode); + Assert.DoesNotContain("Die Wärmepumpe", Text(await Html(await Browser().GetAsync("/house/de/heizung")))); + } + + [Fact] + public async Task FrontPage_SignedIn_ListsEverything() + { + await SeedAsync(); + var browser = await SignedInAsync(); + + var html = await Html(await browser.GetAsync("/house/de")); + + Assert.Contains("Netzwerk-Interna", html); + Assert.Contains("Heizung", html); + Assert.Contains("Systeme", html); + } + + [Fact] + public async Task HouseRoot_RedirectsToGerman() + { + var root = await Browser().GetAsync("/house"); + + Assert.Equal(HttpStatusCode.Redirect, root.StatusCode); + Assert.Equal(new Uri("https://localhost/house/de"), new Uri(new Uri("https://localhost"), root.Headers.Location!)); + } + + [Fact] + public async Task MissingArticle_Anonymous_Is404NotRedirect() + { + await SeedAsync(); + + var page = await Browser().GetAsync("/house/de/does-not-exist"); + + Assert.Equal(HttpStatusCode.NotFound, page.StatusCode); + Assert.Null(page.Headers.Location); + var html = await Html(page); + Assert.Contains("Artikel nicht gefunden", html); + Assert.Contains("class=\"house-page\"", html); + Assert.Contains("href=\"/house/de\"", html); + + var english = await Browser().GetAsync("/house/en/does-not-exist"); + Assert.Equal(HttpStatusCode.NotFound, english.StatusCode); + Assert.Contains("Article not found", await Html(english)); + } + + [Theory] + [InlineData("/house/fr")] + [InlineData("/house/fr/schalter")] + [InlineData("/house/de/Schalter")] + [InlineData("/house/de/schalter--x")] + [InlineData("/house/de/schalter/extra")] + [InlineData("/house/de/a.b")] + public async Task UnknownLanguageOrBadSlug_Anonymous_Is404InPage(string path) + { + await SeedAsync(); + + var page = await Browser().GetAsync(path); + + Assert.Equal(HttpStatusCode.NotFound, page.StatusCode); + Assert.Null(page.Headers.Location); + Assert.Contains("class=\"house-page\"", await Html(page)); + } + + [Fact] + public async Task HousePageException_Anonymous_IsNoLoginRedirect() + { + using var broken = factory.WithWebHostBuilder(b => b.ConfigureServices(s => + { + s.RemoveAll(); + s.AddSingleton(new ThrowingCatalog()); + })); + var browser = broken.CreateClient(new WebApplicationFactoryClientOptions { AllowAutoRedirect = false, BaseAddress = new Uri("https://localhost") }); + + var page = await browser.GetAsync("/house/de/schalter"); + + Assert.Equal(HttpStatusCode.InternalServerError, page.StatusCode); + Assert.Null(page.Headers.Location); + Assert.DoesNotContain("Wandschalter", await Html(page)); + + var media = await browser.GetAsync("/house/media/de/schalter/0"); + Assert.Equal(HttpStatusCode.InternalServerError, media.StatusCode); + Assert.Null(media.Headers.Location); + } + + [Fact] + public async Task House_SendsNoindexAndNoStore() + { + await SeedAsync(); + + foreach (var path in new[] { "/house/de/schalter", "/house/de", "/house/de/does-not-exist", "/house/media/de/schalter/0" }) + { + var response = await Browser().GetAsync(path); + Assert.Equal(["noindex"], response.Headers.GetValues("X-Robots-Tag")); + Assert.True(response.Headers.CacheControl?.NoStore, path); + } + Assert.Contains(" p == "raw/cache.png"); + } + + [Fact] + public async Task StaticAssets_Anonymous200() + { + await SeedAsync(); + var css = await Browser().GetAsync("/house.css"); + + Assert.Equal(HttpStatusCode.OK, css.StatusCode); + + // the assets an article page references, fingerprinted + var html = await Html(await Browser().GetAsync("/house/de/schalter")); + var assets = System.Text.RegularExpressions.Regex.Matches(html, "(?:href|src)=\"(/[^\"]+\\.(?:css|js))\"").Select(m => m.Groups[1].Value).ToList(); + Assert.Contains(assets, a => a.Contains("mermaid.min", StringComparison.Ordinal)); + Assert.Contains(assets, a => a.StartsWith("/house.", StringComparison.Ordinal)); + foreach (var asset in assets) + Assert.Equal(HttpStatusCode.OK, (await Browser().GetAsync(asset)).StatusCode); + } + + [Fact] + public async Task MediaCache_EvictsOldestBeyondLimit() + { + var cache = new Wiki.Host.Api.HouseMediaCache(maxBytes: 10); + var loads = new List(); + Task Load(string path, int size) + { + loads.Add(path); + return Task.FromResult(new byte[size]); + } + + await cache.GetAsync("c1", "a", _ => Load("a", 4), CancellationToken.None); + await cache.GetAsync("c1", "b", _ => Load("b", 4), CancellationToken.None); + await cache.GetAsync("c1", "a", _ => Load("a", 4), CancellationToken.None); + await cache.GetAsync("c1", "c", _ => Load("c", 4), CancellationToken.None); + await cache.GetAsync("c1", "b", _ => Load("b", 4), CancellationToken.None); + await cache.GetAsync("c1", "a", _ => Load("a", 4), CancellationToken.None); + await cache.GetAsync("c2", "b", _ => Load("b", 4), CancellationToken.None); + + Assert.Equal(["a", "b", "c", "a", "b"], loads); + } + + private int Reads() + { + lock (factory.Store.ByteReads) return factory.Store.ByteReads.Count; + } + + private async Task SeedSyncOnlyAsync() + { + var sync = new HttpRequestMessage(HttpMethod.Post, "/api/v1/sync?force=true"); + sync.Headers.Add("X-Api-Key", WikiHostFactory.ApiKey); + Assert.Equal(HttpStatusCode.OK, (await factory.CreateClient().SendAsync(sync)).StatusCode); + } + + private sealed class ThrowingCatalog : IDocumentCatalog + { + public string Name => "throwing-catalog"; + private static Exception Boom => new InvalidOperationException("catalog down"); + public Task GetStateAsync(CancellationToken ct) => throw Boom; + public Task SetStateAsync(CatalogState state, CancellationToken ct) => throw Boom; + public Task UpsertAsync(IReadOnlyList documents, CancellationToken ct) => throw Boom; + public Task RemoveAsync(IReadOnlyList references, CancellationToken ct) => throw Boom; + public Task GetAsync(DocumentKind kind, string slug, CancellationToken ct) => throw Boom; + public Task> ListAsync(DocumentKind kind, CancellationToken ct) => throw Boom; + public Task> ListDocumentsAsync(DocumentKind kind, CancellationToken ct) => throw Boom; + public Task> BacklinksAsync(string slug, CancellationToken ct) => throw Boom; + public Task> OutgoingLinksAsync(string slug, CancellationToken ct) => throw Boom; + public Task> TagCountsAsync(CancellationToken ct) => throw Boom; + public Task FlushAsync(CancellationToken ct) => throw Boom; + public Task CheckAsync(CancellationToken ct) => throw Boom; + } +}