divan/DivanRazor/Utils/AnonymousPageOutputCachePolicy.cs
Anas Rashid 532d005cdb Rename Ganjoor -> Divan throughout the code
Projects (GanjooRazor -> DivanRazor, GanjoorService.sln -> DivanService.sln), files,
folders, classes, namespaces, settings, API routes (/api/divan) and DB tables (Divan*).
External addresses (ganjoor.net, github.com/ganjoor) unchanged. Migrations renamed
consistently (no pending model changes); requires a fresh database.
Upstream RUNNING_LOCALLY/SEMANTIC_SEARCH docs archived unmodified under docs/.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 23:49:01 +02:00

106 lines
5.3 KiB
C#

using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.OutputCaching;
using Microsoft.Extensions.Primitives;
using Microsoft.Net.Http.Headers;
using System;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
namespace DivanRazor.Utils
{
/// <summary>
/// Output-cache policy for the public content pages (DivanPage, Index, Search, Simi,
/// Contribs, Hashieha, Quotes, FAQ).
///
/// These pages bake per-visitor state into the rendered HTML: login status, the CanEdit /
/// KeepHistory / CanTranslate cookies (which change the editor UI, admin links, and the
/// tracking script), and comment/bookmark "mine" flags. None of that is safe to share between
/// visitors, so this policy only allows a response to be served from (or written to) the
/// output cache when the incoming request carries none of the cookies that drive that
/// personalization - i.e. anonymous visitors and, importantly, search-engine crawlers and
/// other bots, which make up a large share of the repeat hits on the same poem/poet/category
/// URL and never send cookies at all.
///
/// Any visitor with one of these cookies - logged-in readers, and especially editors - always
/// gets a fully fresh, uncached render, so this never makes the site show stale content to a
/// logged-on user.
/// </summary>
public sealed class AnonymousPageOutputCachePolicy : IOutputCachePolicy
{
public static readonly AnonymousPageOutputCachePolicy Instance = new();
private static readonly string[] PersonalizationCookies =
{
"Token", // logged in
"UserId", // logged in
"CanEdit", // editor UI / admin links
"KeepHistory", // "you last visited this on..." text
"CanTranslate", // translation UI
};
private AnonymousPageOutputCachePolicy() { }
ValueTask IOutputCachePolicy.CacheRequestAsync(OutputCacheContext context, CancellationToken cancellationToken)
{
var request = context.HttpContext.Request;
bool isPersonalized =
!HttpMethods.IsGet(request.Method) ||
PersonalizationCookies.Any(request.Cookies.ContainsKey);
context.EnableOutputCaching = true;
context.AllowCacheLookup = !isPersonalized;
context.AllowCacheStorage = !isPersonalized;
context.Tags.Add("divan-public-page");
// Vary the cache entry by every query string key. Without this, the cache key is
// just the bare path - so /Search and /Simi (which read the actual search/rhythm
// term, poet, category, page number etc. entirely from the query string, not the
// path) would collapse every distinct search onto ONE shared cache entry, and every
// visitor would get back whichever stranger's search happened to be cached. This is
// the same "vary by any query by default" line ASP.NET Core's own DefaultPolicy sets
// - it's not automatic for a standalone custom policy like this one, it has to be set
// explicitly.
context.CacheVaryByRules.QueryKeys = "*";
// Short TTL: a poem/poet/category edit needs to reach anonymous visitors within
// minutes, not be pinned for the site's whole lifetime. Tune per page type later if
// needed (e.g. shorter for recently-active pages, longer for old archived poets).
context.ResponseExpirationTimeSpan = TimeSpan.FromMinutes(5);
return ValueTask.CompletedTask;
}
ValueTask IOutputCachePolicy.ServeFromCacheAsync(OutputCacheContext context, CancellationToken cancellationToken)
=> ValueTask.CompletedTask;
/// <summary>
/// Runs after the response has actually been generated, right before the middleware would
/// store it. Registering this policy directly (via
/// <c>options.AddPolicy(name, IOutputCachePolicy)</c> in Startup.cs) means it's used
/// standalone instead of chained onto ASP.NET Core's built-in DefaultPolicy - so none of
/// DefaultPolicy's own storage safety checks run unless reproduced here. Without this
/// method, EVERY response was eligible for storage, including 302 redirects (DivanPage
/// and Index both have legitimate redirect paths - legacy URL redirects, the "?p=" lookup,
/// 404-to-redirecturl fallback) - one of those got cached and was then replayed to every
/// anonymous visitor of that URL until the TTL expired. This restores the same three
/// checks DefaultPolicy applies: only store 200 responses, never store a response that set
/// a cookie, never store for an authenticated request.
/// </summary>
ValueTask IOutputCachePolicy.ServeResponseAsync(OutputCacheContext context, CancellationToken cancellationToken)
{
var response = context.HttpContext.Response;
if (!StringValues.IsNullOrEmpty(response.Headers[HeaderNames.SetCookie]) ||
context.HttpContext.User?.Identity?.IsAuthenticated == true ||
response.StatusCode != StatusCodes.Status200OK)
{
context.AllowCacheStorage = false;
}
return ValueTask.CompletedTask;
}
}
}