fix(house): anonymous readers follow only redirects public at fold time
ArticleQueries serves anonymous readers only the redirects listed in publicRedirects, so a retired slug whose article was not public (or a chain through one) answers the usual 404, identical to a missing slug. Signed-in readers follow every redirect. Body links of the anonymous view resolve through the same filtered set. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -9,7 +9,8 @@ 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.
|
||||
/// Redirects come from <c>articles/_plan.json</c> at the catalog's commit.
|
||||
/// Redirects come from <c>articles/_plan.json</c> at the catalog's commit; anonymous readers get only those whose
|
||||
/// retired article was public when it was folded.
|
||||
/// </summary>
|
||||
public sealed class ArticleQueries(IDocumentCatalog catalog, INoteStore store, ILogger<ArticleQueries> log)
|
||||
{
|
||||
@@ -19,38 +20,50 @@ public sealed class ArticleQueries(IDocumentCatalog catalog, INoteStore store, I
|
||||
|
||||
private static readonly IReadOnlyDictionary<string, string> NoRedirects = new Dictionary<string, string>();
|
||||
|
||||
private sealed record Cached(string Commit, IReadOnlyDictionary<string, string> Redirects);
|
||||
private sealed record Cached(string Commit, IReadOnlyDictionary<string, string> All, IReadOnlyDictionary<string, string> Public);
|
||||
|
||||
private volatile Cached? _redirects;
|
||||
|
||||
/// <summary>
|
||||
/// 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 (<paramref name="signedIn"/> 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).
|
||||
/// </summary>
|
||||
public async Task<IReadOnlyDictionary<string, string>> RedirectsAsync(CancellationToken ct)
|
||||
public async Task<IReadOnlyDictionary<string, string>> 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<string, string> 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<Cached> 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);
|
||||
}
|
||||
|
||||
/// <summary>The article slug <paramref name="slug"/> stands for (itself when it is an article), following redirects; null when none, cyclic or too long a chain.</summary>
|
||||
public async Task<string?> ResolveAsync(string slug, CancellationToken ct)
|
||||
/// <summary>
|
||||
/// The article slug <paramref name="slug"/> stands for (itself when it is an article), following the redirects
|
||||
/// <see cref="RedirectsAsync"/> gives this reader; null when none, cyclic or too long a chain.
|
||||
/// </summary>
|
||||
public async Task<string?> 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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/// <summary>The slug a retired <see cref="Slug"/> resolves to when that article exists in <see cref="Lang"/> and this reader may see it; else null.</summary>
|
||||
/// <summary>
|
||||
/// The slug a retired <see cref="Slug"/> resolves to when that article exists in <see cref="Lang"/> and this reader may see it;
|
||||
/// else null. Anonymous readers follow only redirects whose every hop was public when folded.
|
||||
/// </summary>
|
||||
private async Task<string?> 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;
|
||||
}
|
||||
|
||||
@@ -49,7 +49,10 @@ public class HouseTests(WikiHostFactory factory) : IClassFixture<WikiHostFactory
|
||||
{"articles":[],"assignments":{},"redirects":{
|
||||
"old-switch":"schalter","old-net":"network-internals","chain-a":"chain-b","chain-b":"schalter",
|
||||
"loop-a":"loop-b","loop-b":"loop-a","old-gone":"deleted-article","old-nur-de":"nur-de",
|
||||
"hop-1":"hop-2","hop-2":"hop-3","hop-3":"hop-4","hop-4":"hop-5","hop-5":"hop-6","hop-6":"schalter"}}
|
||||
"hop-1":"hop-2","hop-2":"hop-3","hop-3":"hop-4","hop-4":"hop-5","hop-5":"hop-6","hop-6":"schalter",
|
||||
"secret-switch":"schalter","mixed-a":"mixed-b","mixed-b":"schalter"},
|
||||
"publicRedirects":["old-switch","old-net","chain-a","chain-b","loop-a","loop-b","old-gone","old-nur-de",
|
||||
"hop-1","hop-2","hop-3","hop-4","hop-5","hop-6","mixed-a"]}
|
||||
""";
|
||||
factory.Store.Files["articles/de/nur-de.md"] = ArticleText("de", "nur-de", "Nur Deutsch", "public", "Text.");
|
||||
factory.Store.Binaries["raw/taster.png"] = TasterPng;
|
||||
@@ -198,6 +201,37 @@ public class HouseTests(WikiHostFactory factory) : IClassFixture<WikiHostFactory
|
||||
Assert.Equal("/house/de/schalter", page.Headers.Location!.ToString());
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("secret-switch")]
|
||||
[InlineData("mixed-a")]
|
||||
public async Task Redirect_FromNonPublicRetiredSlug_Anonymous_Is404_LikeMissing(string old)
|
||||
{
|
||||
await SeedAsync();
|
||||
|
||||
var page = await Browser().GetAsync($"/house/de/{old}");
|
||||
var missing = await Browser().GetAsync("/house/de/does-not-exist");
|
||||
|
||||
Assert.Equal(HttpStatusCode.NotFound, page.StatusCode);
|
||||
Assert.Null(page.Headers.Location);
|
||||
Assert.Equal(WithoutComponentState(await Html(missing)).Replace("does-not-exist", "{slug}", StringComparison.Ordinal),
|
||||
WithoutComponentState(await Html(page)).Replace(old, "{slug}", StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("secret-switch")]
|
||||
[InlineData("mixed-a")]
|
||||
[InlineData("old-switch")]
|
||||
public async Task Redirect_PublicOrNot_SignedIn_Is301(string old)
|
||||
{
|
||||
await SeedAsync();
|
||||
var browser = await SignedInAsync();
|
||||
|
||||
var page = await browser.GetAsync($"/house/de/{old}");
|
||||
|
||||
Assert.Equal(HttpStatusCode.MovedPermanently, page.StatusCode);
|
||||
Assert.Equal("/house/de/schalter", page.Headers.Location!.ToString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Redirect_Chain_Followed()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user