diff --git a/dotnet/EcencyApi.Tests/CachePolicyTests.cs b/dotnet/EcencyApi.Tests/CachePolicyTests.cs new file mode 100644 index 00000000..0f591eb4 --- /dev/null +++ b/dotnet/EcencyApi.Tests/CachePolicyTests.cs @@ -0,0 +1,58 @@ +using EcencyApi.Infrastructure; +using Xunit; + +namespace EcencyApi.Tests; + +/// +/// The cache policies are a public contract: `public` lets shared caches store a +/// response, and the max-age is how long a wrong one would stay wrong. Pin both +/// the opt-in rule and the shape of each policy. +/// +public class CachePolicyTests +{ + public static TheoryData AllPolicies() => + new() { CachePolicy.ProMembers, CachePolicy.Announcements, CachePolicy.PostTips }; + + [Theory] + [MemberData(nameof(AllPolicies))] + public void EveryPolicyIsPubliclyCacheableWithAMaxAge(string policy) + { + Assert.StartsWith("public, max-age=", policy); + Assert.DoesNotContain("no-store", policy); + Assert.DoesNotContain("private", policy); + } + + [Theory] + [MemberData(nameof(AllPolicies))] + public void APolicyOnlyAppliesToASuccessfulResponse(string policy) + { + Assert.Equal(policy, CachePolicy.ForStatus(200, policy)); + + // Pipe() turns upstream transport failures into these after the handler + // has already attached the policy. Caching one would keep a healthy + // endpoint broken for the whole max-age. + Assert.Null(CachePolicy.ForStatus(504, policy)); + Assert.Null(CachePolicy.ForStatus(500, policy)); + + // Upstream error passthroughs must not be cached either. + Assert.Null(CachePolicy.ForStatus(404, policy)); + Assert.Null(CachePolicy.ForStatus(401, policy)); + Assert.Null(CachePolicy.ForStatus(429, policy)); + } + + [Fact] + public void AnnouncementsOutliveTheOtherPolicies() + { + // Announcements are a compile-time constant, so they can only change on a + // deploy; the proxied endpoints track data that moves independently. + Assert.True(MaxAge(CachePolicy.Announcements) > MaxAge(CachePolicy.ProMembers)); + Assert.True(MaxAge(CachePolicy.ProMembers) > MaxAge(CachePolicy.PostTips)); + } + + private static int MaxAge(string policy) + { + var token = policy.Split(',').Select(p => p.Trim()) + .Single(p => p.StartsWith("max-age=", StringComparison.Ordinal)); + return int.Parse(token["max-age=".Length..]); + } +} diff --git a/dotnet/EcencyApi.Tests/PostTipsPathTests.cs b/dotnet/EcencyApi.Tests/PostTipsPathTests.cs new file mode 100644 index 00000000..07bddef9 --- /dev/null +++ b/dotnet/EcencyApi.Tests/PostTipsPathTests.cs @@ -0,0 +1,83 @@ +using EcencyApi.Handlers; +using Xunit; + +namespace EcencyApi.Tests; + +/// +/// The tips handlers build an upstream path by interpolating caller-supplied +/// author and permlink values. Route values arrive percent-decoded and body +/// values are arbitrary strings, so anything structural left unescaped is +/// re-parsed when the string becomes a Uri — and the upstream call carries this +/// service's credentials, so a redirected path is a real problem, not a cosmetic +/// one. +/// +public class PostTipsPathTests +{ + [Fact] + public void RealAuthorsAndPermlinksAreUnchanged() + { + // Hive names and permlinks are unreserved characters; escaping must be a + // no-op for them or this would change every live request. + Assert.Equal( + "post-tips/good-karma/my-post-title-2026", + PrivateApi.PostTipsPath("good-karma", "my-post-title-2026")); + Assert.Equal( + "post-tips/user.name/a_b-c.d~e", + PrivateApi.PostTipsPath("user.name", "a_b-c.d~e")); + } + + [Theory] + // A slash would add path segments and address a different resource. + [InlineData("a/b", "p")] + [InlineData("a", "p/q")] + // A question mark would truncate the path and turn the rest into a query. + [InlineData("a?x=1", "p")] + [InlineData("a", "p?x=1")] + // A hash would truncate the path at a fragment. + [InlineData("a#f", "p")] + // Dots inside a longer segment are ordinary characters, not structure. + [InlineData("x..y", "p")] + public void StructuralCharactersCannotEscapeTheirSegment(string author, string permlink) + { + var path = PrivateApi.PostTipsPath(author, permlink); + Assert.NotNull(path); + + // Exactly three segments: the prefix plus one each for author and permlink. + Assert.Equal(3, path!.Split('/').Length); + Assert.StartsWith("post-tips/", path); + Assert.DoesNotContain("?", path); + Assert.DoesNotContain("#", path); + + // And the whole thing still resolves to a path under post-tips rather + // than somewhere else on the upstream host. + var resolved = new Uri(new Uri("https://upstream.invalid/api/"), path); + Assert.StartsWith("/api/post-tips/", resolved.AbsolutePath); + Assert.Equal("", resolved.Query); + } + + [Theory] + [InlineData("..", "p")] + [InlineData("a", "..")] + [InlineData(".", "p")] + [InlineData("a", ".")] + public void DotSegmentsAreRejectedRatherThanEscaped(string author, string permlink) + { + // Escaping cannot neutralise these: `.` and `..` are unreserved so + // EscapeDataString passes them through, and Uri decodes `%2E` back to `.` + // before removing dot segments, so hand-encoding them resolves upward too. + // Verified against Uri directly: + Assert.Equal("/api/p", new Uri(new Uri("https://upstream.invalid/api/"), "post-tips/../p").AbsolutePath); + Assert.Equal("/api/p", new Uri(new Uri("https://upstream.invalid/api/"), "post-tips/%2E%2E/p").AbsolutePath); + + Assert.Null(PrivateApi.PostTipsPath(author, permlink)); + } + + [Fact] + public void EmptyAndMissingValuesStayInsideTheirSegment() + { + // Missing route params and absent body keys interpolate as "undefined"; + // both must remain a single inert segment. + Assert.Equal("post-tips/undefined/undefined", PrivateApi.PostTipsPath("undefined", "undefined")); + Assert.Equal(3, PrivateApi.PostTipsPath("", "")!.Split('/').Length); + } +} diff --git a/dotnet/EcencyApi/Handlers/Announcements.cs b/dotnet/EcencyApi/Handlers/Announcements.cs index 10417172..d7341be7 100644 --- a/dotnet/EcencyApi/Handlers/Announcements.cs +++ b/dotnet/EcencyApi/Handlers/Announcements.cs @@ -40,8 +40,11 @@ public static class Announcements public static partial class PrivateApi { // GET ^/private-api/announcements$ + // The payload is the constant above, so it only changes when this repo is + // deployed; clients can hold on to it between page views. public static async Task GetAnnouncement(HttpContext ctx) { + ctx.CacheWhenOk(CachePolicy.Announcements); await ctx.SendJson(200, Announcements.Json()); } } diff --git a/dotnet/EcencyApi/Handlers/PrivateApi.Core.cs b/dotnet/EcencyApi/Handlers/PrivateApi.Core.cs index 67f9da08..5f65bec2 100644 --- a/dotnet/EcencyApi/Handlers/PrivateApi.Core.cs +++ b/dotnet/EcencyApi/Handlers/PrivateApi.Core.cs @@ -248,9 +248,12 @@ public static async Task RewardedCommunities(HttpContext ctx) } // Public list of Pro usernames for the Pro badge roster. No auth: this is a - // public, cached list served straight from the backend. + // public, cached list served straight from the backend. The response is the + // same for every caller, so it carries a Cache-Control and clients can reuse + // it across page views instead of refetching the roster on each one. public static async Task ProMembers(HttpContext ctx) { + ctx.CacheWhenOk(CachePolicy.ProMembers); await Upstream.Pipe(ApiClient.ApiRequest("pro-members", HttpMethod.Get), ctx); } } diff --git a/dotnet/EcencyApi/Handlers/PrivateApi.Misc.cs b/dotnet/EcencyApi/Handlers/PrivateApi.Misc.cs index e483ca77..c1d711e4 100644 --- a/dotnet/EcencyApi/Handlers/PrivateApi.Misc.cs +++ b/dotnet/EcencyApi/Handlers/PrivateApi.Misc.cs @@ -263,12 +263,69 @@ public static async Task RequestDelete(HttpContext ctx) }); } + /// + /// A segment URL resolution reads as structure rather than as a name. + /// + /// These cannot be escaped away: `.` and `..` are unreserved, so + /// EscapeDataString passes them through, and percent-encoding them by hand + /// does not help either because Uri decodes `%2E` back to `.` before it + /// removes dot segments. Rejecting is the only option that holds. + /// + private static bool IsDotSegment(string value) => value is "." or ".."; + + /// + /// Upstream path for post tips, or null when a value cannot be expressed as + /// a single path segment. + /// + /// Author and permlink are escaped as path segments. Route values arrive + /// percent-decoded and body values are arbitrary strings, so a value carrying + /// `/`, `?` or `#` would otherwise be re-parsed as URL structure once this + /// string becomes a Uri — addressing a different upstream resource, with this + /// service's credentials attached. + /// + /// Authors and permlinks are made of unreserved characters, which + /// EscapeDataString leaves byte-identical, so real traffic is unaffected. + /// + public static string? PostTipsPath(string author, string permlink) => + IsDotSegment(author) || IsDotSegment(permlink) + ? null + : $"post-tips/{Uri.EscapeDataString(author)}/{Uri.EscapeDataString(permlink)}"; + public static async Task Tips(HttpContext ctx) { var body = await ctx.ReadBody(); var author = MiscBodyInterp(body, "author"); var permlink = MiscBodyInterp(body, "permlink"); - await Upstream.Pipe(ApiClient.ApiRequest($"post-tips/{author}/{permlink}", HttpMethod.Get), ctx); + var path = PostTipsPath(author, permlink); + if (path == null) + { + await ctx.SendText(400, "Invalid author or permlink"); + return; + } + await Upstream.Pipe(ApiClient.ApiRequest(path, HttpMethod.Get), ctx); + } + + /// + /// GET twin of , same upstream call and same body. + /// + /// Tips are a plain read keyed only by author/permlink, but the original + /// endpoint is a POST, and a POST response is uncacheable by definition — so + /// every mount refetched it. This variant is addressable by URL and carries a + /// Cache-Control, which lets a client reuse it. The POST stays for clients + /// that have not moved over. + /// + public static async Task TipsGet(HttpContext ctx) + { + var author = MiscRouteParam(ctx, "author"); + var permlink = MiscRouteParam(ctx, "permlink"); + var path = PostTipsPath(author, permlink); + if (path == null) + { + await ctx.SendText(400, "Invalid author or permlink"); + return; + } + ctx.CacheWhenOk(CachePolicy.PostTips); + await Upstream.Pipe(ApiClient.ApiRequest(path, HttpMethod.Get), ctx); } public static async Task GameGet(HttpContext ctx) diff --git a/dotnet/EcencyApi/Handlers/Routes.cs b/dotnet/EcencyApi/Handlers/Routes.cs index 347a6193..50648a0c 100644 --- a/dotnet/EcencyApi/Handlers/Routes.cs +++ b/dotnet/EcencyApi/Handlers/Routes.cs @@ -71,6 +71,9 @@ public static void Map(WebApplication app) app.MapGet("/private-api/waves/following", PrivateApi.WavesFollowing); app.MapGet("/private-api/waves/trending/tags", PrivateApi.WavesTrendingTags); app.MapGet("/private-api/waves/trending/authors", PrivateApi.WavesTrendingAuthors); + // Cacheable twin of the POST /private-api/post-tips below; both hit the + // same upstream. Registered as GET so the response can be reused. + app.MapGet("/private-api/post-tips/{author}/{permlink}", PrivateApi.TipsGet); // ---- Private Api (POST) ---- app.MapPost("/private-api/comment-history", PrivateApi.CommentHistory); diff --git a/dotnet/EcencyApi/Infrastructure/CachePolicy.cs b/dotnet/EcencyApi/Infrastructure/CachePolicy.cs new file mode 100644 index 00000000..b8057b70 --- /dev/null +++ b/dotnet/EcencyApi/Infrastructure/CachePolicy.cs @@ -0,0 +1,46 @@ +namespace EcencyApi.Infrastructure; + +/// +/// Cache-Control policies for the handful of private-API reads that are the same +/// for every visitor. +/// +/// Most endpoints here are per-user or per-request and deliberately carry no +/// Cache-Control at all. The edge treats a missing Cache-Control as "do not +/// store", so a response only becomes reusable once a handler opts in — which is +/// why these policies are explicit per endpoint rather than a global default. +/// +/// Only opt an endpoint in when its body depends on nothing but the URL: no +/// authenticated user, no request body, no per-caller variation. `public` here +/// means shared caches may store it, so getting that wrong would let one +/// visitor's response reach another. +/// +public static class CachePolicy +{ + /// + /// Pro badge roster: one global list, no auth. It only moves when someone + /// starts or stops being a Pro member, so a short fresh window plus a long + /// revalidate window keeps badges correct without refetching per page view. + /// + public const string ProMembers = "public, max-age=600, stale-while-revalidate=3600"; + + /// + /// Announcements are a compile-time constant in this repo (Announcements.cs), + /// so the body can only change on deploy. + /// + public const string Announcements = "public, max-age=1800, stale-while-revalidate=86400"; + + /// + /// Tips for a single post, keyed entirely by author/permlink and readable + /// without auth. Tips arrive at any time, so the fresh window stays short; + /// the revalidate window is what absorbs repeat reads within one visit. + /// + public const string PostTips = "public, max-age=60, stale-while-revalidate=600"; + + /// + /// A policy applies only to a successful response. Handlers attach it before + /// the upstream call resolves, and can still turn + /// a transport failure into 504/500 afterwards — caching either would pin an + /// error in front of a healthy endpoint for the whole max-age. + /// + public static string? ForStatus(int status, string policy) => status == 200 ? policy : null; +} diff --git a/dotnet/EcencyApi/Infrastructure/HttpContextExtensions.cs b/dotnet/EcencyApi/Infrastructure/HttpContextExtensions.cs index 35a39c7f..4be22624 100644 --- a/dotnet/EcencyApi/Infrastructure/HttpContextExtensions.cs +++ b/dotnet/EcencyApi/Infrastructure/HttpContextExtensions.cs @@ -96,4 +96,26 @@ public static async Task SendJson(this HttpContext ctx, int status, JsonNode? no /// Raw node for a body field (undefined -> null). public static JsonNode? Field(this JsonObject body, string key) => body.TryGetPropertyValue(key, out var v) ? v : null; + + /// + /// Attach a to this response, applied only if it + /// finishes as a 200. + /// + /// The check has to happen at header-flush time, not at call time: handlers + /// call this before awaiting the upstream, and Pipe() can still replace the + /// status with 504/500 afterwards. OnStarting runs once the status is final + /// and before anything is written. + /// + public static void CacheWhenOk(this HttpContext ctx, string policy) + { + ctx.Response.OnStarting(() => + { + var value = CachePolicy.ForStatus(ctx.Response.StatusCode, policy); + if (value != null) + { + ctx.Response.Headers.CacheControl = value; + } + return Task.CompletedTask; + }); + } } diff --git a/dotnet/parity/driver.py b/dotnet/parity/driver.py index ed367eac..e990cc71 100644 --- a/dotnet/parity/driver.py +++ b/dotnet/parity/driver.py @@ -228,6 +228,11 @@ def norm_body(text): "Same request-delete rerouting as ::min.", "/private-api/request-delete::badcode": "Same request-delete rerouting as ::min.", + "/private-api/post-tips/x/x::get": + "New cacheable GET twin of POST /private-api/post-tips. The reference build " + "has no such route, so it answers with the unmatched-GET template page while " + "this one proxies the tips payload. Deterministic and additive; the POST case " + "still covers the shared upstream behavior.", } # Deliberately NOT listed above: /wallet-api/portfolio-v2::pop, whose HP action list