Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 58 additions & 0 deletions dotnet/EcencyApi.Tests/CachePolicyTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
using EcencyApi.Infrastructure;
using Xunit;

namespace EcencyApi.Tests;

/// <summary>
/// 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.
/// </summary>
public class CachePolicyTests
{
public static TheoryData<string> 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..]);
}
}
83 changes: 83 additions & 0 deletions dotnet/EcencyApi.Tests/PostTipsPathTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
using EcencyApi.Handlers;
using Xunit;

namespace EcencyApi.Tests;

/// <summary>
/// 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.
/// </summary>
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);
}
}
3 changes: 3 additions & 0 deletions dotnet/EcencyApi/Handlers/Announcements.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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());
}
}
5 changes: 4 additions & 1 deletion dotnet/EcencyApi/Handlers/PrivateApi.Core.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
59 changes: 58 additions & 1 deletion dotnet/EcencyApi/Handlers/PrivateApi.Misc.cs
Original file line number Diff line number Diff line change
Expand Up @@ -263,12 +263,69 @@ public static async Task RequestDelete(HttpContext ctx)
});
}

/// <summary>
/// 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.
/// </summary>
private static bool IsDotSegment(string value) => value is "." or "..";

/// <summary>
/// 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.
/// </summary>
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);
}

/// <summary>
/// GET twin of <see cref="Tips"/>, 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.
/// </summary>
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);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

public static async Task GameGet(HttpContext ctx)
Expand Down
3 changes: 3 additions & 0 deletions dotnet/EcencyApi/Handlers/Routes.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Record the new GET route as an intentional divergence

When the parity harness compares this build with the legacy Node image, driver.py derives the case /private-api/post-tips/x/x::get from this route. The legacy service has no matching GET route and returns its HTML fallback, while this handler returns the mock upstream JSON; because this case is absent from KNOWN_DIVERGENCES, every documented Node-vs-C# parity diff now reports a mismatch and exits nonzero. Add the deterministic route addition to the divergence list so the harness remains usable.

Useful? React with 👍 / 👎.


// ---- Private Api (POST) ----
app.MapPost("/private-api/comment-history", PrivateApi.CommentHistory);
Expand Down
46 changes: 46 additions & 0 deletions dotnet/EcencyApi/Infrastructure/CachePolicy.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
namespace EcencyApi.Infrastructure;

/// <summary>
/// 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.
/// </summary>
public static class CachePolicy
{
/// <summary>
/// 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.
/// </summary>
public const string ProMembers = "public, max-age=600, stale-while-revalidate=3600";

/// <summary>
/// Announcements are a compile-time constant in this repo (Announcements.cs),
/// so the body can only change on deploy.
/// </summary>
public const string Announcements = "public, max-age=1800, stale-while-revalidate=86400";

/// <summary>
/// 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.
/// </summary>
public const string PostTips = "public, max-age=60, stale-while-revalidate=600";

/// <summary>
/// A policy applies only to a successful response. Handlers attach it before
/// the upstream call resolves, and <see cref="Upstream.Pipe"/> 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.
/// </summary>
public static string? ForStatus(int status, string policy) => status == 200 ? policy : null;
}
22 changes: 22 additions & 0 deletions dotnet/EcencyApi/Infrastructure/HttpContextExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -96,4 +96,26 @@ public static async Task SendJson(this HttpContext ctx, int status, JsonNode? no
/// <summary>Raw node for a body field (undefined -> null).</summary>
public static JsonNode? Field(this JsonObject body, string key) =>
body.TryGetPropertyValue(key, out var v) ? v : null;

/// <summary>
/// Attach a <see cref="CachePolicy"/> 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.
/// </summary>
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;
});
}
}
5 changes: 5 additions & 0 deletions dotnet/parity/driver.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading