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