feat(host): house encyclopedia pages with anonymous public view
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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,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
|
||||
|
||||
@@ -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,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;
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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 = "     \n\n```\n\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]
|
||||
|
||||
@@ -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)));
|
||||
|
||||
|
||||
@@ -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" +
|
||||
"\n\n" +
|
||||
"## Bedienung\n\nEinmal drücken schaltet das Licht.\n\n" +
|
||||
"::: private\nGEHEIM steht hier. \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: ");
|
||||
Put("y", "Notiz", "Note", "public", "public", "Bild: ");
|
||||
Put("z", "Zwischenspeicher", "Cache", "public", "public", "Bild: ");
|
||||
// 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");
|
||||
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", "", "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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user