diff --git a/src/Wiki.Application/Reading/ArticleQueries.cs b/src/Wiki.Application/Reading/ArticleQueries.cs index b76ba85..23e32eb 100644 --- a/src/Wiki.Application/Reading/ArticleQueries.cs +++ b/src/Wiki.Application/Reading/ArticleQueries.cs @@ -9,7 +9,8 @@ namespace Wiki.Application.Reading; /// /// Read side of the house encyclopedia, straight from the catalog. An article file that does not parse /// counts as missing. Anonymous visibility is per subject: both language files must exist and be public. -/// Redirects come from articles/_plan.json at the catalog's commit. +/// Redirects come from articles/_plan.json at the catalog's commit; anonymous readers get only those whose +/// retired article was public when it was folded. /// public sealed class ArticleQueries(IDocumentCatalog catalog, INoteStore store, ILogger log) { @@ -19,38 +20,50 @@ public sealed class ArticleQueries(IDocumentCatalog catalog, INoteStore store, I private static readonly IReadOnlyDictionary NoRedirects = new Dictionary(); - private sealed record Cached(string Commit, IReadOnlyDictionary Redirects); + private sealed record Cached(string Commit, IReadOnlyDictionary All, IReadOnlyDictionary Public); private volatile Cached? _redirects; /// - /// Retired slug → slug that took over; empty when the plan is missing, unreadable or the store fails. - /// Cached per commit, a failure too (no store request per page view until the commit changes). + /// Retired slug → slug that took over; anonymous ( false): only redirects whose retired article was + /// public when it was folded, so a chain through a non-public retired slug ends there. Empty when the plan is missing, + /// unreadable or the store fails. Cached per commit, a failure too (no store request per page view until the commit changes). /// - public async Task> RedirectsAsync(CancellationToken ct) + public async Task> RedirectsAsync(bool signedIn, CancellationToken ct) { var commit = (await catalog.GetStateAsync(ct)).Commit; if (commit is null) return NoRedirects; - if (_redirects is { } cached && cached.Commit == commit) return cached.Redirects; - IReadOnlyDictionary redirects; + if (_redirects is not { } cached || cached.Commit != commit) + { + cached = await LoadAsync(commit, ct); + _redirects = cached; + } + return signedIn ? cached.All : cached.Public; + } + + private async Task LoadAsync(string commit, CancellationToken ct) + { try { var text = await store.ReadTextAsync(PlanPath, commit, ct); - redirects = (text is null ? null : ArticlePlanJson.Read(text.Text))?.Redirects ?? NoRedirects; + if (text is not null && ArticlePlanJson.Read(text.Text) is { } plan) + return new Cached(commit, plan.Redirects, + plan.Redirects.Where(r => plan.PublicRedirects.Contains(r.Key)).ToDictionary(StringComparer.Ordinal)); } catch (Exception e) when (e is not OperationCanceledException) { log.LogWarning(e, "Cannot read {Path} at {Commit}; serving without redirects", PlanPath, commit); - redirects = NoRedirects; } - _redirects = new Cached(commit, redirects); - return redirects; + return new Cached(commit, NoRedirects, NoRedirects); } - /// The article slug stands for (itself when it is an article), following redirects; null when none, cyclic or too long a chain. - public async Task ResolveAsync(string slug, CancellationToken ct) + /// + /// The article slug stands for (itself when it is an article), following the redirects + /// gives this reader; null when none, cyclic or too long a chain. + /// + public async Task ResolveAsync(string slug, bool signedIn, CancellationToken ct) { - var redirects = await RedirectsAsync(ct); + var redirects = await RedirectsAsync(signedIn, ct); var slugs = (await AllAsync(ct)).Select(a => a.Slug).ToHashSet(StringComparer.Ordinal); return ArticlePlan.ResolveRedirect(slug, redirects, slugs.Contains); } diff --git a/src/Wiki.Host/Components/Pages/HouseArticle.razor b/src/Wiki.Host/Components/Pages/HouseArticle.razor index 59bd502..3ef688a 100644 --- a/src/Wiki.Host/Components/Pages/HouseArticle.razor +++ b/src/Wiki.Host/Components/Pages/HouseArticle.razor @@ -95,18 +95,21 @@ else if (_article is not null && _rendered is not null) return; } var titles = await Articles.VisibleTitlesAsync(Lang, _signedIn, ct); - var redirects = await Articles.RedirectsAsync(ct); + var redirects = await Articles.RedirectsAsync(_signedIn, ct); _article = article; _rendered = ArticleMarkdown.Render(article.Body, Lang, Slug, _signedIn, titles, redirects, Log); _infobox = WithTitleBar(_rendered.InfoboxHtml, article.Title); _otherExists = await Articles.GetAsync(Other, Slug, ct) is not null; } - /// The slug a retired resolves to when that article exists in and this reader may see it; else null. + /// + /// The slug a retired resolves to when that article exists in and this reader may see it; + /// else null. Anonymous readers follow only redirects whose every hop was public when folded. + /// private async Task RedirectTargetAsync(CancellationToken ct) { - if (!(await Articles.RedirectsAsync(ct)).ContainsKey(Slug)) return null; - var target = await Articles.ResolveAsync(Slug, ct); + if (!(await Articles.RedirectsAsync(_signedIn, ct)).ContainsKey(Slug)) return null; + var target = await Articles.ResolveAsync(Slug, _signedIn, ct); if (target is null || target == Slug || !HouseEndpoints.IsSlug(target) || await Articles.GetAsync(Lang, target, ct) is null) return null; return !_signedIn && await Articles.IsPrivateAsync(target, ct) ? null : target; } diff --git a/test/Wiki.Host.Tests/HouseTests.cs b/test/Wiki.Host.Tests/HouseTests.cs index 12996b4..9ef63fa 100644 --- a/test/Wiki.Host.Tests/HouseTests.cs +++ b/test/Wiki.Host.Tests/HouseTests.cs @@ -49,7 +49,10 @@ public class HouseTests(WikiHostFactory factory) : IClassFixture