diff --git a/dotnet/EcencyApi.Tests/FavoriteTagsPathTests.cs b/dotnet/EcencyApi.Tests/FavoriteTagsPathTests.cs new file mode 100644 index 00000000..62d22431 --- /dev/null +++ b/dotnet/EcencyApi.Tests/FavoriteTagsPathTests.cs @@ -0,0 +1,119 @@ +using System.Text.Json.Nodes; +using EcencyApi.Handlers; +using Xunit; + +namespace EcencyApi.Tests; + +/// +/// The favorite-tag check and delete handlers interpolate a body-supplied tag into +/// an upstream path. A tag is an arbitrary string, so anything structural left +/// unescaped is re-parsed when the string becomes a Uri, and the upstream call +/// carries this service's credentials. Same reasoning as NotificationsPathTests. +/// +public class FavoriteTagsPathTests +{ + [Fact] + public void RealRequestsAreUnchanged() + { + // Hive names and tags are unreserved characters; escaping must be a no-op for + // them or this would change every live request. + Assert.Equal( + "isfavoritetag/good-karma/photography", + PrivateApi.FavoriteTagPath("isfavoritetag", "good-karma", "photography")); + Assert.Equal( + "favoriteTag/user.name/contest-2026", + PrivateApi.FavoriteTagPath("favoriteTag", "user.name", "contest-2026")); + } + + [Fact] + public void LeadingHashIsEscapedNotDropped() + { + // Raw, `#` would truncate the path at a fragment. Escaped, it reaches the + // upstream, which normalises the tag and strips one leading hash itself. + Assert.Equal( + "isfavoritetag/good-karma/%23photography", + PrivateApi.FavoriteTagPath("isfavoritetag", "good-karma", "#photography")); + } + + [Theory] + // A missing tag must not become the literal "undefined" the way a missing + // account does in the favorites handlers: "undefined", "null", "true" and "123" + // are all valid tag names upstream, so templating would check or delete a follow + // the caller never named. + [InlineData("{}")] + [InlineData("{\"tag\": null}")] + [InlineData("{\"tag\": true}")] + [InlineData("{\"tag\": 123}")] + [InlineData("{\"tag\": \"\"}")] + [InlineData("{\"tag\": [\"photography\"]}")] + [InlineData("{\"tag\": {\"name\": \"photography\"}}")] + public void AbsentOrNonStringTagIsRejected(string json) + { + var body = (JsonObject)JsonNode.Parse(json)!; + + Assert.Null(PrivateApi.FavoriteTagField(body)); + } + + [Fact] + public void StringTagIsReadAsIs() + { + // Read leniently and passed through untouched: normalising is the upstream's job. + var body = (JsonObject)JsonNode.Parse("{\"tag\": \"#Photography\"}")!; + Assert.Equal("#Photography", PrivateApi.FavoriteTagField(body)); + + // Lone surrogates are valid JSON string content and must not be dropped. + var lone = (JsonObject)JsonNode.Parse("{\"tag\": \"\\ud800\"}")!; + Assert.Equal("\ud800", PrivateApi.FavoriteTagField(lone)); + } + + [Theory] + // A slash would add path segments and address a different resource. + [InlineData("a", "b/c")] + [InlineData("a/b", "c")] + // A question mark would truncate the path and turn the rest into a query. + [InlineData("a", "b?x=1")] + // A hash would truncate the path at a fragment. + [InlineData("a", "b#f")] + // Whitespace and non-ASCII must not leak into the request line either. + [InlineData("a", "b c")] + [InlineData("a", "caf\u00e9")] + public void StructuralCharactersCannotEscapeTheirSegment(string username, string tag) + { + var path = PrivateApi.FavoriteTagPath("isfavoritetag", username, tag); + + Assert.NotNull(path); + Assert.DoesNotContain("?x=1", path); + Assert.DoesNotContain("#f", path); + Assert.DoesNotContain(" ", path); + Assert.True(path!.All(c => c < 128)); + // The only separators left are the two this builder wrote itself. + Assert.Equal(2, path.Split('/').Length - 1); + } + + [Fact] + public void LoneSurrogateIsEncodedNotThrown() + { + // JSON strings are arbitrary UTF-16, so a lone surrogate reaches the helper as + // a real value (see TryGetStringLenient). On the runtime this service targets, + // EscapeDataString encodes it as U+FFFD rather than throwing, so the request + // stays on the handler's own path instead of falling through to the 500 page. + Assert.Equal( + "isfavoritetag/good-karma/%EF%BF%BD", + PrivateApi.FavoriteTagPath("isfavoritetag", "good-karma", "\ud800")); + Assert.Equal( + "favoriteTag/good-karma/a%EF%BF%BDb", + PrivateApi.FavoriteTagPath("favoriteTag", "good-karma", "a\udc00b")); + } + + [Theory] + // Dot segments cannot be fixed by escaping: Uri decodes %2E back to `.` before it + // removes dot segments, so they have to be rejected outright. + [InlineData(".", "photography")] + [InlineData("..", "photography")] + [InlineData("good-karma", ".")] + [InlineData("good-karma", "..")] + public void DotSegmentsAreRejected(string username, string tag) + { + Assert.Null(PrivateApi.FavoriteTagPath("isfavoritetag", username, tag)); + } +} diff --git a/dotnet/EcencyApi/Handlers/PrivateApi.UserData2.cs b/dotnet/EcencyApi/Handlers/PrivateApi.UserData2.cs index 2bf92ee8..0f77d930 100644 --- a/dotnet/EcencyApi/Handlers/PrivateApi.UserData2.cs +++ b/dotnet/EcencyApi/Handlers/PrivateApi.UserData2.cs @@ -129,6 +129,118 @@ public static async Task FavoritesDelete(HttpContext ctx) await Upstream.Pipe(ApiClient.ApiRequest($"favoriteUser/{username}/{account}", HttpMethod.Delete), ctx); } + /// + /// The body's `tag`, or null when it is absent, not a string, or empty. + /// + /// The favorites handlers template a missing `account` into the literal + /// "undefined". A tag must not go that way: "undefined", "null", "true" and + /// "123" are all valid tag names upstream, so a request with no tag, or a + /// non-string one, would check or delete a follow the caller never named. + /// String extraction is lenient on purpose: JSON strings are arbitrary UTF-16. + /// + public static string? FavoriteTagField(JsonObject body) + { + if (!body.TryGetPropertyValue("tag", out var node) || node is not JsonValue value) + { + return null; + } + + return JsVal.TryGetStringLenient(value, out var tag) && tag.Length > 0 ? tag : null; + } + + /// + /// Upstream path for a followed-tag check or removal, or null when a value cannot + /// be expressed as a single path segment. + /// + /// The tag is a body string, so one 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. Same + /// reasoning as NotificationsPath. Real tags are lowercase words with digits and + /// hyphens, which EscapeDataString leaves byte-identical. A leading `#` a client + /// may send is escaped rather than dropped: normalising the tag is the upstream's + /// job, and it strips one. + /// + public static string? FavoriteTagPath(string action, string username, string tag) + { + if (IsDotSegment(username) || IsDotSegment(tag)) + { + return null; + } + + return $"{action}/{Uri.EscapeDataString(username)}/{Uri.EscapeDataString(tag)}"; + } + + public static async Task FavoriteTags(HttpContext ctx) + { + var body = await ctx.ReadBody(); + var username = await ValidateCode(body); + if (username == null) + { + await ctx.SendText(401, "Unauthorized"); + return; + } + await Upstream.Pipe( + ApiClient.ApiRequest($"favorite-tags/{username}", HttpMethod.Get, null, null, UserData2Js.Query(ctx)), + ctx); + } + + public static async Task FavoriteTagsCheck(HttpContext ctx) + { + var body = await ctx.ReadBody(); + var username = await ValidateCode(body); + if (username == null) + { + await ctx.SendText(401, "Unauthorized"); + return; + } + var tag = FavoriteTagField(body); + var path = tag == null ? null : FavoriteTagPath("isfavoritetag", username, tag); + if (path == null) + { + await ctx.SendText(400, "Invalid tag"); + return; + } + await Upstream.Pipe(ApiClient.ApiRequest(path, HttpMethod.Get), ctx); + } + + public static async Task FavoriteTagsAdd(HttpContext ctx) + { + var body = await ctx.ReadBody(); + var username = await ValidateCode(body); + if (username == null) + { + await ctx.SendText(401, "Unauthorized"); + return; + } + var tag = FavoriteTagField(body); + if (tag == null) + { + await ctx.SendText(400, "Invalid tag"); + return; + } + var data = new JsonObject { ["username"] = username, ["tag"] = tag }; + await Upstream.Pipe(ApiClient.ApiRequest("favorite-tag", HttpMethod.Post, null, data), ctx); + } + + public static async Task FavoriteTagsDelete(HttpContext ctx) + { + var body = await ctx.ReadBody(); + var username = await ValidateCode(body); + if (username == null) + { + await ctx.SendText(401, "Unauthorized"); + return; + } + var tag = FavoriteTagField(body); + var path = tag == null ? null : FavoriteTagPath("favoriteTag", username, tag); + if (path == null) + { + await ctx.SendText(400, "Invalid tag"); + return; + } + await Upstream.Pipe(ApiClient.ApiRequest(path, HttpMethod.Delete), ctx); + } + public static async Task Fragments(HttpContext ctx) { var body = await ctx.ReadBody(); diff --git a/dotnet/EcencyApi/Handlers/Routes.cs b/dotnet/EcencyApi/Handlers/Routes.cs index dd74c8d6..9348c0a7 100644 --- a/dotnet/EcencyApi/Handlers/Routes.cs +++ b/dotnet/EcencyApi/Handlers/Routes.cs @@ -127,6 +127,10 @@ public static void Map(WebApplication app) app.MapPost("/private-api/favorites-check", PrivateApi.FavoritesCheck); app.MapPost("/private-api/favorites-add", PrivateApi.FavoritesAdd); app.MapPost("/private-api/favorites-delete", PrivateApi.FavoritesDelete); + app.MapPost("/private-api/favorite-tags", PrivateApi.FavoriteTags); + app.MapPost("/private-api/favorite-tags-check", PrivateApi.FavoriteTagsCheck); + app.MapPost("/private-api/favorite-tags-add", PrivateApi.FavoriteTagsAdd); + app.MapPost("/private-api/favorite-tags-delete", PrivateApi.FavoriteTagsDelete); app.MapPost("/private-api/fragments", PrivateApi.Fragments); app.MapPost("/private-api/fragments-add", PrivateApi.FragmentsAdd); app.MapPost("/private-api/fragments-update", PrivateApi.FragmentsUpdate); diff --git a/dotnet/parity/driver.py b/dotnet/parity/driver.py index c65239e3..2ee86618 100644 --- a/dotnet/parity/driver.py +++ b/dotnet/parity/driver.py @@ -62,6 +62,9 @@ "/private-api/account-create-friend": {"username": "newuser123", "email": "x@example.com", "friend": True}, "/private-api/subscribe": {"email": "x@example.com"}, + "/private-api/favorite-tags-check": {"code": "invalid", "tag": "photography"}, + "/private-api/favorite-tags-add": {"code": "invalid", "tag": "photography"}, + "/private-api/favorite-tags-delete": {"code": "invalid", "tag": "photography"}, "/private-api/notifications": {"code": "invalid", "filter": "", "since": 0, "limit": 50, "user": "good-karma"}, "/private-api/report": {"type": "content", "data": {"a": 1}}, @@ -224,6 +227,13 @@ def norm_body(text): "::pop and ::badcode for every POST route, which is why all three appear here." ) +FAVORITE_TAGS_DIVERGENCE = ( + "Followed-hashtag routes added after the port (the favorites passthroughs, keyed by " + "tag instead of account). The reference build has no such route and answers 404; " + "this one validates the signed code and answers 401 or proxies. Deterministic and " + "additive -- no behavior the reference ever had is changing." +) + AI_IMAGES_DIVERGENCE = ( "Per-user AI image history route added after the port (ePoints users//ai-images). " "The reference build has no such route and answers 404; this one validates the " @@ -272,6 +282,32 @@ def norm_body(text): AI_IMAGES_DIVERGENCE, "/private-api/ai-images::badcode": AI_IMAGES_DIVERGENCE, + # Followed-hashtag passthroughs, added after the port. Same shape as the entries + # above: additive routes the reference build never had. + "/private-api/favorite-tags::min": + FAVORITE_TAGS_DIVERGENCE, + "/private-api/favorite-tags::pop": + FAVORITE_TAGS_DIVERGENCE, + "/private-api/favorite-tags::badcode": + FAVORITE_TAGS_DIVERGENCE, + "/private-api/favorite-tags-check::min": + FAVORITE_TAGS_DIVERGENCE, + "/private-api/favorite-tags-check::pop": + FAVORITE_TAGS_DIVERGENCE, + "/private-api/favorite-tags-check::badcode": + FAVORITE_TAGS_DIVERGENCE, + "/private-api/favorite-tags-add::min": + FAVORITE_TAGS_DIVERGENCE, + "/private-api/favorite-tags-add::pop": + FAVORITE_TAGS_DIVERGENCE, + "/private-api/favorite-tags-add::badcode": + FAVORITE_TAGS_DIVERGENCE, + "/private-api/favorite-tags-delete::min": + FAVORITE_TAGS_DIVERGENCE, + "/private-api/favorite-tags-delete::pop": + FAVORITE_TAGS_DIVERGENCE, + "/private-api/favorite-tags-delete::badcode": + FAVORITE_TAGS_DIVERGENCE, } # Deliberately NOT listed above: /wallet-api/portfolio-v2::pop, whose HP action list