feat(host): house encyclopedia pages with anonymous public view

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-25 09:27:40 +01:00
co-authored by Claude Opus 5.5
parent cf75cef563
commit 82d70e4c60
18 changed files with 1124 additions and 46 deletions
@@ -0,0 +1,67 @@
using Wiki.Core.Model;
using Wiki.Core.Parsing;
using Wiki.Core.Ports;
namespace Wiki.Application.Reading;
/// <summary>
/// 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.
/// </summary>
public sealed class ArticleQueries(IDocumentCatalog catalog)
{
public static readonly IReadOnlyList<string> Languages = ["de", "en"];
public async Task<Article?> GetAsync(string lang, string slug, CancellationToken ct)
{
var doc = await catalog.GetAsync(DocumentKind.Article, $"{lang}/{slug}", ct);
return doc is null ? null : TryParse(doc);
}
/// <summary>Every article in <paramref name="lang"/>, by title.</summary>
public async Task<IReadOnlyList<Article>> 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();
/// <summary>slug → title in <paramref name="lang"/>; anonymous: only slugs whose de and en files are both public.</summary>
public async Task<IReadOnlyDictionary<string, string>> 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);
}
/// <summary>True unless both language files exist, parse and are public.</summary>
public async Task<bool> 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<List<Article>> AllAsync(CancellationToken ct) =>
(await catalog.ListDocumentsAsync(DocumentKind.Article, ct)).Select(TryParse).OfType<Article>().ToList();
private static HashSet<string> PublicSlugs(List<Article> 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;
}
}
}
+4
View File
@@ -19,5 +19,9 @@ public sealed record Attachment(string MediaType, byte[] Bytes, string? Caption
/// <summary>The file extension for a supported media type, or null.</summary>
public static string? ExtensionFor(string mediaType) => Extensions.TryGetValue(mediaType, out var e) ? e : null;
/// <summary>The supported media type for a file extension without the dot, or null.</summary>
public static string? MediaTypeFor(string extension) =>
Extensions.FirstOrDefault(kv => string.Equals(kv.Value, extension, StringComparison.OrdinalIgnoreCase)).Key;
public string? Extension => ExtensionFor(MediaType);
}
+106
View File
@@ -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;
/// <summary>
/// The house encyclopedia's non-page routes: the login entry and the article images. Every <c>/house</c>
/// response is marked noindex and no-store. Private content is for signed-in humans only; a machine
/// identity reads the anonymous view.
/// </summary>
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);
/// <summary>A human signed in with OIDC; API-key and BasicAuth identities are not.</summary>
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)}";
/// <summary>A local <c>/house/</c> path, or null.</summary>
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;
/// <summary>
/// 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.
/// </summary>
public static void RespondNotFound(HttpResponse response) =>
response.OnStarting(() =>
{
response.StatusCode = StatusCodes.Status404NotFound;
return Task.CompletedTask;
});
/// <summary>Adds the noindex and no-store headers to every <c>/house</c> response, also one rewritten by the exception handler.</summary>
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<IResult> 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();
}
}
}
+45
View File
@@ -0,0 +1,45 @@
namespace Wiki.Host.Api;
/// <summary>
/// Image bytes of the house encyclopedia by (commit, path); a path's bytes never change at a commit.
/// Holds at most <see cref="MaxBytes"/> in total and evicts the oldest entries first. Concurrent misses
/// for the same key may each load; the first stored wins.
/// </summary>
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<Entry>> _entries = new();
private readonly LinkedList<Entry> _order = new();
private long _total;
public HouseMediaCache() : this(MaxBytes) { }
public async Task<byte[]> GetAsync(string commit, string path, Func<CancellationToken, Task<byte[]>> 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);
}
+54 -20
View File
@@ -1,24 +1,58 @@
<!DOCTYPE html>
<html lang="en">
@if (House)
{
@* Static SSR with its own head; no base href, so in-page #fragment links stay on the page. *@
<html lang="@HouseLang">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<base href="/" />
<ResourcePreloader />
<link rel="stylesheet" href="@Assets["_content/MudBlazor/MudBlazor.min.css"]" />
<link rel="stylesheet" href="@Assets["app.css"]" />
<link rel="stylesheet" href="@Assets["Wiki.Host.styles.css"]" />
<ImportMap />
<HeadOutlet />
</head>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="robots" content="noindex" />
<link rel="stylesheet" href="/@Assets["house.css"]" />
<HeadOutlet />
</head>
<body>
<Routes />
<ReconnectModal />
<script src="@Assets["_framework/blazor.web.js"]"></script>
<script src="@Assets["_content/MudBlazor/MudBlazor.min.js"]"></script>
<script src="@Assets["facts.js"]"></script>
</body>
<body class="house-body">
<Routes />
<script src="/@Assets["lib/mermaid/mermaid.min.js"]"></script>
<script>mermaid.initialize({startOnLoad: true, securityLevel: 'strict'});</script>
</body>
</html>
</html>
}
else
{
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<base href="/" />
<ResourcePreloader />
<link rel="stylesheet" href="@Assets["_content/MudBlazor/MudBlazor.min.css"]" />
<link rel="stylesheet" href="@Assets["app.css"]" />
<link rel="stylesheet" href="@Assets["Wiki.Host.styles.css"]" />
<ImportMap />
<HeadOutlet />
</head>
<body>
<Routes />
<ReconnectModal />
<script src="@Assets["_framework/blazor.web.js"]"></script>
<script src="@Assets["_content/MudBlazor/MudBlazor.min.js"]"></script>
<script src="@Assets["facts.js"]"></script>
</body>
</html>
}
@code {
[CascadingParameter]
private HttpContext? HttpContext { get; set; }
/// <summary>The house encyclopedia's pages, the in-page 404 and the private notice included.</summary>
private bool House => HttpContext?.Request.Path.StartsWithSegments("/house") == true;
private string HouseLang => HttpContext?.Request.Path.StartsWithSegments("/house/en") == true ? "en" : "de";
}
@@ -0,0 +1,19 @@
@inherits LayoutComponentBase
<div class="house-page">
<header class="house-header">
<a class="house-brand" href="/house/@Lang">@(English ? "House encyclopedia" : "Hausenzyklopädie")</a>
</header>
<main class="house-article">
@Body
</main>
</div>
@code {
[CascadingParameter]
private HttpContext? HttpContext { get; set; }
private bool English => HttpContext?.Request.Path.StartsWithSegments("/house/en") == true;
private string Lang => English ? "en" : "de";
}
@@ -19,6 +19,7 @@
<MudNavLink Href="maintenance" Icon="@Icons.Material.Outlined.Build">Maintenance</MudNavLink>
<MudNavLink Href="lab" Icon="@Icons.Material.Outlined.Science">Lab</MudNavLink>
<MudNavLink Href="status" Icon="@Icons.Material.Outlined.MonitorHeart">Status</MudNavLink>
<MudNavLink Href="house/de" Icon="@Icons.Material.Outlined.House" data-enhance-nav="false">Haus</MudNavLink>
</MudNavMenu>
</MudDrawer>
<MudMainContent>
@@ -1,5 +1,6 @@
@page "/Error"
@using System.Diagnostics
@attribute [AllowAnonymous]
<PageTitle>Error</PageTitle>
@@ -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)
{
<HouseNotFound Lang="@Lang" />
}
else if (_groups is not null)
{
<PageTitle>@Heading</PageTitle>
<h1>@Heading</h1>
<div class="house-from">
@(English ? "Articles about the house, generated from the wiki." : "Artikel über das Haus, erstellt aus dem Wiki.")
· <a href="/house/@Other" hreflang="@Other" lang="@Other">@(English ? "Deutsch" : "English")</a>
@if (!_signedIn)
{
<text> · <a href="@HouseEndpoints.LoginHref($"/house/{Lang}")">@(English ? "Log in to see more" : "Anmelden, um mehr zu sehen")</a></text>
}
</div>
@if (_groups.Count == 0)
{
<p>@(English ? "No articles yet." : "Noch keine Artikel.")</p>
}
@foreach (var (category, articles) in _groups)
{
<h2>@CategoryName(category)</h2>
<ul class="house-index">
@foreach (var article in articles)
{
<li><a href="/house/@Lang/@article.Slug">@article.Title</a></li>
}
</ul>
}
}
@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<Article> 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<ArticleCategory>()
.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",
};
}
@@ -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<HouseArticle> Log
@if (_notFound)
{
<HouseNotFound Lang="@Lang" />
}
else if (_private)
{
<PageTitle>@(English ? "Private article" : "Privater Artikel")</PageTitle>
<p class="private-locked">
<span aria-hidden="true">🔒</span>
<a href="@HouseEndpoints.LoginHref(SelfPath)">@(English ? "This article is private — log in" : "Dieser Artikel ist privat — anmelden")</a>
</p>
}
else if (_article is not null && _rendered is not null)
{
<PageTitle>@_article.Title</PageTitle>
<h1>@_article.Title</h1>
<div class="house-from">
@(English ? "From the house encyclopedia" : "Aus der Hausenzyklopädie")
· @(English ? "generated" : "erstellt am") @_article.Generated.ToString("yyyy-MM-dd")
@if (_otherExists)
{
<text> · <a href="/house/@Other/@Slug" hreflang="@Other" lang="@Other">@(English ? "Deutsch" : "English")</a></text>
}
@if (!_signedIn)
{
<text> · <a href="@HouseEndpoints.LoginHref(SelfPath)">@(English ? "Log in to see more" : "Anmelden, um mehr zu sehen")</a></text>
}
</div>
@if (_infobox is not null)
{
@((MarkupString)_infobox)
}
@if (_rendered.Toc.Count > 0)
{
<nav class="toc">
<b>@(English ? "Contents" : "Inhalt")</b>
<ol>
@foreach (var entry in _rendered.Toc)
{
<li class="toc-level-@entry.Level"><a href="#@entry.Id">@entry.Text</a></li>
}
</ol>
</nav>
}
@((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;
}
/// <summary>Puts a title bar first into the infobox, after its opening tag.</summary>
private static string? WithTitleBar(string? infoboxHtml, string title)
{
if (infoboxHtml is null) return null;
var open = infoboxHtml.IndexOf('>') + 1;
return infoboxHtml.Insert(open, $"\n<div class=\"infobox-title\">{WebUtility.HtmlEncode(title)}</div>");
}
}
@@ -0,0 +1,12 @@
@* The house's own 404 body; the page sets the status. *@
<PageTitle>@(English ? "Article not found" : "Artikel nicht gefunden")</PageTitle>
<h1>@(English ? "Article not found" : "Artikel nicht gefunden")</h1>
<p><a href="/house/@(English ? "en" : "de")">@(English ? "To the front page" : "Zur Startseite")</a></p>
@code {
[Parameter]
public string? Lang { get; set; }
private bool English => Lang == "en";
}
+1
View File
@@ -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
+8 -2
View File
@@ -145,6 +145,8 @@ builder.Services.AddSingleton<SyncUseCase>();
builder.Services.AddSingleton<SyncCoordinator>();
builder.Services.AddSingleton<RankUseCase>();
builder.Services.AddSingleton<Wiki.Application.Reading.NoteQueries>();
builder.Services.AddSingleton<Wiki.Application.Reading.ArticleQueries>();
builder.Services.AddSingleton(new HouseMediaCache());
builder.Services.AddSingleton<IChunker, Wiki.Core.Parsing.ParagraphChunker>();
builder.Services.AddSingleton<Wiki.Application.Ask.AskUseCase>();
builder.Services.AddSingleton<Wiki.Application.Facts.DepositFactUseCase>();
@@ -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<App>().AddInteractiveServerRenderMode();
app.Run();
+28 -18
View File
@@ -28,7 +28,7 @@ public sealed record RenderedArticle(string Html, string? InfoboxHtml, IReadOnly
/// readers and is dropped for signed-in ones.</para>
/// <para>Raw HTML is disabled. Link targets are limited to <c>http:</c>, <c>https:</c>, <c>mailto:</c>, <c>#…</c> and
/// <c>/house/…</c> 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 <see cref="ImagePaths"/>; every other image is dropped.</para>
/// served by the media endpoint by index into <see cref="VisibleImagePaths"/>; every other image is dropped.</para>
/// </summary>
public static partial class ArticleMarkdown
{
@@ -75,9 +75,21 @@ public static partial class ArticleMarkdown
IReadOnlyDictionary<string, string> visibleTitles, ILogger? logger = null) =>
RenderVisible(signedIn ? body : ArticleBody.RedactPrivate(body), lang, slug, signedIn, visibleTitles, logger);
/// <summary>
/// The <c>raw/</c> image paths the media endpoint may serve for this reader: index <c>n</c> is the image <see cref="Render"/>
/// rewrote to <c>/house/media/{lang}/{slug}/{n}</c> with the same arguments. Empty when the render is locked.
/// </summary>
public static IReadOnlyList<string> VisibleImagePaths(string body, string lang, string slug, bool signedIn,
IReadOnlyDictionary<string, string> visibleTitles) =>
Process(signedIn ? body : ArticleBody.RedactPrivate(body), lang, slug, signedIn, visibleTitles, logger: null).Images;
/// <summary>Renders a body that is already what this reader may see.</summary>
internal static RenderedArticle RenderVisible(string visibleBody, string lang, string slug, bool signedIn,
IReadOnlyDictionary<string, string> visibleTitles, ILogger? logger = null)
IReadOnlyDictionary<string, string> visibleTitles, ILogger? logger = null) =>
Process(visibleBody, lang, slug, signedIn, visibleTitles, logger).Article;
private static (RenderedArticle Article, IReadOnlyList<string> Images) Process(string visibleBody, string lang, string slug,
bool signedIn, IReadOnlyDictionary<string, string> visibleTitles, ILogger? logger)
{
var slugs = new List<string>();
var doc = Parse(visibleBody, slugs);
@@ -86,13 +98,14 @@ public static partial class ArticleMarkdown
if (doc.Descendants<CustomContainer>().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<CustomContainer>().Where(c => c.Info is not (Infobox or Locked)).ToList())
c.Parent?.Remove(c);
}
SanitizeLinks(doc, lang, slug, visibleTitles);
var images = new List<string>();
SanitizeLinks(doc, lang, slug, visibleTitles, images);
var headings = ResolveWikiLinks(doc, lang, slugs, visibleTitles);
var leftovers = doc.Descendants<LeafBlock>().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);
}
/// <summary>The valid <c>raw/</c> image paths of a body, in order; the media index <c>n</c> of <see cref="Render"/> points into this list.</summary>
public static IReadOnlyList<string> ImagePaths(string visibleBody) =>
Parse(visibleBody, []).Descendants<LinkInline>()
.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
}
/// <summary>
/// Rewrites valid <c>raw/</c> 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 <c>raw/</c> images to the media endpoint, appending each path to <paramref name="images"/> at its index,
/// and drops every other image; keeps only the text of links whose target is not allowed; removes all link titles.
/// </summary>
private static void SanitizeLinks(MarkdownDocument doc, string lang, string slug, IReadOnlyDictionary<string, string> visibleTitles)
private static void SanitizeLinks(MarkdownDocument doc, string lang, string slug, IReadOnlyDictionary<string, string> visibleTitles,
List<string> 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;
+25
View File
@@ -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; }
}
+4 -4
View File
@@ -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("<img src=\"/house/media/de/switches/0\" alt=\"Keller\" />", signedIn.Html);
Assert.Contains("<img src=\"/house/media/de/switches/1\" alt=\"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]
+8 -2
View File
@@ -72,10 +72,16 @@ public sealed class FakeStore : INoteStore
public Task<StoredText?> ReadTextAsync(string path, string commit, CancellationToken ct) =>
Task.FromResult(Files.TryGetValue(path, out var text) ? new StoredText(text, BlobSha(text)) : null);
public Task<byte[]> ReadBytesAsync(string path, string commit, CancellationToken ct) =>
Task.FromResult(Binaries.TryGetValue(path, out var bytes) ? bytes
/// <summary>Every path <see cref="ReadBytesAsync"/> was asked for, in order.</summary>
public List<string> ByteReads { get; } = [];
public Task<byte[]> 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)));
+532
View File
@@ -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;
/// <summary>
/// The house encyclopedia: anonymous readers get the public view and nothing else, signed-in humans
/// everything, and every other route keeps requiring a login.
/// </summary>
public class HouseTests(WikiHostFactory factory) : IClassFixture<WikiHostFactory>
{
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"),
});
/// <summary>A browser with a session: the code flow by hand, started from the house login.</summary>
private async Task<HttpClient> 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<string, string>
{
["code"] = "the-code",
["state"] = query["state"].ToString(),
}));
Assert.Equal(HttpStatusCode.Redirect, callback.StatusCode);
return browser;
}
private static async Task<string> Html(HttpResponseMessage response) => await response.Content.ReadAsStringAsync();
/// <summary>The page as text: static SSR writes every non-ASCII character as an entity.</summary>
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("<div class=\"infobox-title\">Wandschalter</div>", 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("<img src=\"/house/media/de/schalter/0\"", html);
Assert.DoesNotContain("GEHEIM", Text(html));
Assert.DoesNotContain("/house/media/de/schalter/1", Text(html));
}
[Fact]
public async Task PublicArticle_SignedIn_ShowsPrivateText()
{
await SeedAsync();
var browser = await SignedInAsync();
var html = await Html(await browser.GetAsync("/house/de/schalter"));
Assert.Contains("GEHEIM", html);
Assert.Contains("<a href=\"/house/de/network-internals\">Netzwerk-Interna</a>", 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("<a href=\"/house/de/schalter\">Wandschalter</a>", 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("<a href=\"/house/de/network-internals\">Netzwerk-Interna</a>", html);
Assert.Contains("<a href=\"/house/de/heizung\">Heizung</a>", 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<IDocumentCatalog>();
s.AddSingleton<IDocumentCatalog>(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("<meta name=\"robots\" content=\"noindex\"", await Html(await Browser().GetAsync("/house/de/schalter")));
Assert.Contains("<meta name=\"robots\" content=\"noindex\"", await Html(await Browser().GetAsync("/house/de")));
}
[Fact]
public async Task House_MermaidInitStrict()
{
await SeedAsync();
var html = await Html(await Browser().GetAsync("/house/de/schalter"));
Assert.Contains("mermaid.min", html);
Assert.Contains("securityLevel: 'strict'", html);
Assert.Contains("house", html);
Assert.DoesNotContain("MudBlazor.min.js", Text(html));
}
[Fact]
public async Task Notes_Anonymous_StillRejected()
{
var browser = Browser();
Assert.NotEqual(HttpStatusCode.OK, (await browser.GetAsync("/notes/x")).StatusCode);
Assert.Equal(HttpStatusCode.Redirect, (await browser.GetAsync("/facts")).StatusCode);
Assert.Equal(HttpStatusCode.Unauthorized, (await browser.GetAsync("/api/v1/notes")).StatusCode);
}
[Fact]
public async Task Login_Anonymous_ChallengesOidc()
{
var login = await Browser().GetAsync("/house/login?returnUrl=%2Fhouse%2Fde%2Fschalter");
Assert.Equal(HttpStatusCode.Redirect, login.StatusCode);
Assert.StartsWith(factory.Idp.Configuration.AuthorizationEndpoint, login.Headers.Location!.ToString());
}
[Theory]
[InlineData("https://evil")]
[InlineData("//evil/house/")]
[InlineData("/\\evil")]
[InlineData("/facts")]
[InlineData("/house")]
[InlineData("")]
public async Task Login_RejectsNonHouseReturnUrl(string returnUrl)
{
var browser = await SignedInAsync();
var login = await browser.GetAsync("/house/login?returnUrl=" + Uri.EscapeDataString(returnUrl));
Assert.Equal(HttpStatusCode.Redirect, login.StatusCode);
Assert.Equal("/house/de", login.Headers.Location!.OriginalString);
}
[Fact]
public async Task Login_SignedIn_RedirectsToHouseReturnUrl()
{
var browser = await SignedInAsync();
var login = await browser.GetAsync("/house/login?returnUrl=%2Fhouse%2Fen%2Fschalter");
Assert.Equal(HttpStatusCode.Redirect, login.StatusCode);
Assert.Equal("/house/en/schalter", login.Headers.Location!.OriginalString);
}
[Fact]
public async Task Media_ImageOnlyInPrivateBlock_Anonymous404()
{
await SeedAsync();
Assert.Equal(HttpStatusCode.NotFound, (await Browser().GetAsync("/house/media/de/schalter/1")).StatusCode);
var signedIn = await (await SignedInAsync()).GetAsync("/house/media/de/schalter/1");
Assert.Equal(HttpStatusCode.OK, signedIn.StatusCode);
Assert.Equal(GeheimPng, await signedIn.Content.ReadAsByteArrayAsync());
}
[Fact]
public async Task Media_PrivateArticle_Anonymous404()
{
await SeedAsync();
Put("network-internals", "Netzwerk-Interna", "Network internals", "private", "private", "![a](raw/taster.png)", "system");
await SeedSyncOnlyAsync();
try
{
Assert.Equal(HttpStatusCode.NotFound, (await Browser().GetAsync("/house/media/de/network-internals/0")).StatusCode);
Assert.Equal(HttpStatusCode.OK, (await (await SignedInAsync()).GetAsync("/house/media/de/network-internals/0")).StatusCode);
}
finally
{
await SeedAsync();
}
}
[Fact]
public async Task Media_BodyLockedByGuard_Anonymous404()
{
await SeedAsync();
var html = await Html(await Browser().GetAsync("/house/de/locked"));
Assert.Contains("private-locked", html);
Assert.DoesNotContain("/house/media/", Text(html));
Assert.Equal(HttpStatusCode.NotFound, (await Browser().GetAsync("/house/media/de/locked/0")).StatusCode);
Assert.Equal(HttpStatusCode.OK, (await (await SignedInAsync()).GetAsync("/house/media/de/locked/0")).StatusCode);
}
[Fact]
public async Task Media_PublicImage_Anonymous200()
{
await SeedAsync();
var media = await Browser().GetAsync("/house/media/de/schalter/0");
Assert.Equal(HttpStatusCode.OK, media.StatusCode);
Assert.Equal("image/png", media.Content.Headers.ContentType!.MediaType);
Assert.Equal(TasterPng, await media.Content.ReadAsByteArrayAsync());
}
[Theory]
[InlineData("/house/media/de/schalter/2")]
[InlineData("/house/media/de/schalter/-1")]
[InlineData("/house/media/de/does-not-exist/0")]
[InlineData("/house/media/fr/schalter/0")]
[InlineData("/house/media/de/Schalter/0")]
public async Task Media_OutOfRangeOrUnknown_404(string path)
{
await SeedAsync();
var media = await Browser().GetAsync(path);
Assert.Equal(HttpStatusCode.NotFound, media.StatusCode);
Assert.Null(media.Headers.Location);
}
[Fact]
public async Task Media_PathTraversal_404()
{
await SeedAsync();
var before = Reads();
Assert.Equal(HttpStatusCode.NotFound, (await Browser().GetAsync("/house/media/de/x/0")).StatusCode);
Assert.Equal(before, Reads());
}
[Fact]
public async Task Media_NonImage_404()
{
await SeedAsync();
var before = Reads();
Assert.Equal(HttpStatusCode.NotFound, (await Browser().GetAsync("/house/media/de/y/0")).StatusCode);
Assert.Equal(before, Reads());
}
[Fact]
public async Task Media_SecondRequest_ServedFromCache()
{
await SeedAsync();
Assert.Equal(HttpStatusCode.OK, (await Browser().GetAsync("/house/media/de/z/0")).StatusCode);
Assert.Equal(HttpStatusCode.OK, (await Browser().GetAsync("/house/media/de/z/0")).StatusCode);
lock (factory.Store.ByteReads)
Assert.Single(factory.Store.ByteReads, p => 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<string>();
Task<byte[]> 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<CatalogState> GetStateAsync(CancellationToken ct) => throw Boom;
public Task SetStateAsync(CatalogState state, CancellationToken ct) => throw Boom;
public Task UpsertAsync(IReadOnlyList<Document> documents, CancellationToken ct) => throw Boom;
public Task RemoveAsync(IReadOnlyList<DocumentRef> references, CancellationToken ct) => throw Boom;
public Task<Document?> GetAsync(DocumentKind kind, string slug, CancellationToken ct) => throw Boom;
public Task<IReadOnlyList<DocumentRef>> ListAsync(DocumentKind kind, CancellationToken ct) => throw Boom;
public Task<IReadOnlyList<Document>> ListDocumentsAsync(DocumentKind kind, CancellationToken ct) => throw Boom;
public Task<IReadOnlyList<Link>> BacklinksAsync(string slug, CancellationToken ct) => throw Boom;
public Task<IReadOnlyList<Link>> OutgoingLinksAsync(string slug, CancellationToken ct) => throw Boom;
public Task<IReadOnlyDictionary<string, int>> TagCountsAsync(CancellationToken ct) => throw Boom;
public Task FlushAsync(CancellationToken ct) => throw Boom;
public Task<AdapterHealth> CheckAsync(CancellationToken ct) => throw Boom;
}
}