diff --git a/dotnet/EcencyApi.Tests/ModerationMutesTests.cs b/dotnet/EcencyApi.Tests/ModerationMutesTests.cs new file mode 100644 index 00000000..7b84c107 --- /dev/null +++ b/dotnet/EcencyApi.Tests/ModerationMutesTests.cs @@ -0,0 +1,199 @@ +using System.Text.Json.Nodes; +using EcencyApi.Infrastructure; +using Xunit; + +namespace EcencyApi.Tests; + +/// +/// The moderation mute filter on promoted entries. The list is fetched from +/// chain, but the parts that can silently break a feed are pure: an empty or +/// unreadable list must leave the feed alone, and a match must remove exactly +/// the muted author's entries and nothing else. +/// +public class ModerationMutesTests +{ + private static long TotalRpcCalls() => + ModerationMutes.Rpc.HealthSnapshot().Sum(n => n!["calls"]!.GetValue()); + + private static JsonArray Entries(params string?[] authors) + { + var arr = new JsonArray(); + foreach (var a in authors) + { + var o = new JsonObject { ["permlink"] = "p-" + (a ?? "none") }; + if (a != null) + { + o["author"] = a; + } + arr.Add(o); + } + return arr; + } + + private static string?[] AuthorsOf(JsonArray arr) => + arr.Select(e => e is JsonObject o && o.TryGetPropertyValue("author", out var a) + ? a?.GetValue() + : null).ToArray(); + + [Fact] + public void AnEmptyMuteListLeavesTheFeedUntouched() + { + var entries = Entries("alice", "bob"); + var result = ModerationMutes.FilterMutedAuthors(entries, ModerationMutes.ToSet(Array.Empty())); + Assert.Equal(new[] { "alice", "bob" }, AuthorsOf(result)); + } + + [Fact] + public void MutedAuthorsAreDroppedAndTheRestKeptInOrder() + { + var entries = Entries("alice", "spammer", "bob", "spammer", "carol"); + var result = ModerationMutes.FilterMutedAuthors(entries, ModerationMutes.ToSet(new[] { "spammer" })); + Assert.Equal(new[] { "alice", "bob", "carol" }, AuthorsOf(result)); + } + + [Fact] + public void MatchingIsCaseInsensitive() + { + // Hive account names are lowercase, but nothing here guarantees the two + // sides were normalized by the same code path. + var entries = Entries("Spammer"); + var result = ModerationMutes.FilterMutedAuthors(entries, ModerationMutes.ToSet(new[] { "spammer" })); + Assert.Empty(result); + } + + [Fact] + public void AnEntryWithNoAuthorIsKept() + { + // An unreadable shape is not evidence of anything; dropping it would + // shrink the feed for a reason nobody could see. + var entries = Entries("alice", null); + var result = ModerationMutes.FilterMutedAuthors(entries, ModerationMutes.ToSet(new[] { "spammer" })); + Assert.Equal(2, result.Count); + } + + [Fact] + public void FilteringEveryEntryYieldsAnEmptyArrayNotNull() + { + var entries = Entries("spammer", "spammer"); + var result = ModerationMutes.FilterMutedAuthors(entries, ModerationMutes.ToSet(new[] { "spammer" })); + Assert.NotNull(result); + Assert.Empty(result); + } + + [Fact] + public void ReadFollowingTakesTheFollowingNamesAndSkipsUnusableRows() + { + var rows = new JsonArray( + new JsonObject { ["follower"] = "ecency", ["following"] = "spammer", ["what"] = new JsonArray("ignore") }, + new JsonObject { ["follower"] = "ecency" }, + new JsonObject { ["follower"] = "ecency", ["following"] = "" }, + new JsonObject { ["follower"] = "ecency", ["following"] = "phisher", ["what"] = new JsonArray("ignore") }); + + Assert.Equal(new[] { "spammer", "phisher" }, ModerationMutes.ReadFollowing(rows)); + } + + [Fact] + public void AnAuthorThatCannotBeReadAsAStringDoesNotThrow() + { + // GetValue() throws on a lone-surrogate escape, which JSON.parse + // accepts and Hive nodes do emit. Throwing here would fail the whole + // promoted-entries request over one malformed name. + var entries = new JsonArray( + new JsonObject { ["author"] = 42 }, + new JsonObject { ["author"] = JsonValue.Create((string?)null) }, + new JsonObject { ["author"] = new JsonObject() }, + new JsonObject { ["author"] = "spammer" }); + + var result = ModerationMutes.FilterMutedAuthors(entries, ModerationMutes.ToSet(new[] { "spammer" })); + + // The three unreadable ones survive; only the match is dropped. + Assert.Equal(3, result.Count); + } + + [Fact] + public void AFollowingThatCannotBeReadAsAStringIsSkippedNotFatal() + { + // Same reasoning on the refresh side: one malformed row must not abort + // the mute-list refresh and leave the feed unfiltered. + var rows = new JsonArray( + new JsonObject { ["following"] = 7 }, + new JsonObject { ["following"] = new JsonArray("nested") }, + new JsonObject { ["following"] = "spammer" }); + + Assert.Equal(new[] { "spammer" }, ModerationMutes.ReadFollowing(rows)); + } + + [Fact] + public async Task AFailedRefreshIsCachedSoQueuedRequestsDoNotEachRetry() + { + // The refresh gate serializes callers. Without caching the failure, a + // dead node pool makes that worse than no gate at all: each queued + // request waits out the one ahead of it and then runs its own full + // failover sweep, so the Nth caller pays N times the timeout budget. + var original = ModerationMutes.Rpc; + MemCache.Del("moderation-muted-authors"); + MemCache.Del("moderation-muted-authors-last-good"); + + // Port 9 (discard) refuses immediately, so this measures the code path + // rather than a real network timeout. + ModerationMutes.Rpc = new HiveRpcClient( + new[] { "http://127.0.0.1:9/" }, timeoutMs: 250, failoverThreshold: 1); + + try + { + var first = await ModerationMutes.Get(); + Assert.Empty(first); + + // Count attempts rather than elapsed time: a refused connection + // fails in microseconds, so a timing assertion passes just as + // happily whether or not the failure was cached. + var callsAfterFirst = TotalRpcCalls(); + + var followers = await Task.WhenAll( + Enumerable.Range(0, 8).Select(_ => ModerationMutes.Get())); + + Assert.All(followers, f => Assert.Empty(f)); + Assert.Equal(callsAfterFirst, TotalRpcCalls()); + } + finally + { + ModerationMutes.Rpc = original; + MemCache.Del("moderation-muted-authors"); + MemCache.Del("moderation-muted-authors-last-good"); + } + } + + [Fact] + public async Task AFailedRefreshFallsBackToTheLastListSeen() + { + var original = ModerationMutes.Rpc; + MemCache.Del("moderation-muted-authors"); + + // Stand in for a previously successful fetch. + MemCache.Set("moderation-muted-authors-last-good", new[] { "spammer" }); + ModerationMutes.Rpc = new HiveRpcClient( + new[] { "http://127.0.0.1:9/" }, timeoutMs: 250, failoverThreshold: 1); + + try + { + // Stale filtering, not no filtering: an unreachable pool must not be + // a way for a muted account back into the feed. + var muted = await ModerationMutes.Get(); + Assert.Equal(new[] { "spammer" }, muted.OrderBy(x => x)); + } + finally + { + ModerationMutes.Rpc = original; + MemCache.Del("moderation-muted-authors"); + MemCache.Del("moderation-muted-authors-last-good"); + } + } + + [Fact] + public void TheModerationAccountIsEcency() + { + // Pinned: this account name is the whole control surface. A typo here + // would read as "nobody is muted" with no error anywhere. + Assert.Equal("ecency", ModerationMutes.Account); + } +} diff --git a/dotnet/EcencyApi/Handlers/PrivateApi.Feeds.cs b/dotnet/EcencyApi/Handlers/PrivateApi.Feeds.cs index d2fe4f44..2ca30205 100644 --- a/dotnet/EcencyApi/Handlers/PrivateApi.Feeds.cs +++ b/dotnet/EcencyApi/Handlers/PrivateApi.Feeds.cs @@ -68,6 +68,15 @@ public static async Task PromotedEntries(HttpContext ctx) var shortContent = double.IsNaN(shortNum) ? 0 : (int)Math.Clamp(shortNum, int.MinValue, int.MaxValue); var posts = await ApiClient.GetPromotedEntries(limit, shortContent); + + // Promoted entries are served from here, not from the waves indexer, so + // the moderation mute list has to be applied on this path too. A muted + // account buying a promoted slot would otherwise land in the most + // prominent position in the feed. Filtered after the cache read rather + // than before it, so a new mute takes effect on the mute list's own + // refresh instead of waiting out the promoted cache. + posts = ModerationMutes.FilterMutedAuthors(posts, await ModerationMutes.Get()); + await ctx.SendJson(200, posts); } diff --git a/dotnet/EcencyApi/Infrastructure/ModerationMutes.cs b/dotnet/EcencyApi/Infrastructure/ModerationMutes.cs new file mode 100644 index 00000000..2166651d --- /dev/null +++ b/dotnet/EcencyApi/Infrastructure/ModerationMutes.cs @@ -0,0 +1,254 @@ +using System.Text.Json.Nodes; + +namespace EcencyApi.Infrastructure; + +/// +/// Ecency's on-chain moderation mute list. +/// +/// Muting an account from the moderation account is how spam and phishing are +/// kept out of the waves feeds; esync applies that list to every waves query it +/// serves. Promoted entries never go through esync, so without this they were +/// the one surface a muted account could still reach an audience through — and +/// the most prominent one, since a promoted card is a paid placement. +/// +/// Read straight from chain rather than from another service so this holds even +/// if the indexer is behind, and cached because the list changes only when a +/// moderator acts on it. +/// +public static class ModerationMutes +{ + /// The account whose mutes are treated as platform-wide. + public const string Account = "ecency"; + + private const string CacheKey = "moderation-muted-authors"; + + /// + /// Survives a failed refresh, so an unreachable node degrades to the list we + /// last saw rather than to no filtering at all. Never expires on purpose. + /// + private const string LastGoodCacheKey = "moderation-muted-authors-last-good"; + + private const double TtlSeconds = 300; + + /// + /// How long a failed refresh is held before trying again. Short, so a blip + /// costs one interval of stale filtering, but not zero: caching the failure + /// is what stops every request behind the gate from running its own full + /// node-failover sweep. + /// + private const double FailureTtlSeconds = 30; + + /// + /// How long a request waits for someone else's refresh before answering from + /// what it already has. A normal refresh is one RPC round trip, so waiters + /// get the real list; this only bounds the pathological case where the whole + /// node pool is timing out and the refresh takes tens of seconds. + /// + private static readonly TimeSpan RefreshWait = TimeSpan.FromSeconds(2); + + /// condenser_api.get_following caps a single response at 1000 rows. + private const int PageSize = 1000; + + /// + /// Bounds the paging loop. 20 pages is 20k muted accounts, far past any real + /// list, so a node that stops advancing the cursor truncates rather than + /// looping forever. + /// + private const int MaxPages = 20; + + /// Replaceable for tests (loopback stub nodes). + internal static HiveRpcClient Rpc = HiveClients.Default; + + /// + /// One refresh at a time. Without this, every request arriving after the TTL + /// lapses starts its own paging loop, so a burst turns one refresh into as + /// many RPC conversations as there are concurrent promoted-entries requests. + /// The waiters re-read the cache and take the winner's result. + /// + private static readonly SemaphoreSlim RefreshGate = new(1, 1); + + /// + /// The muted accounts, cached. Returns an empty set rather than throwing: + /// a moderation filter that cannot load must not take a feed down with it. + /// + public static async Task> Get() + { + var cached = MemCache.Get(CacheKey); + if (cached != null) + { + return ToSet(cached); + } + + if (!await RefreshGate.WaitAsync(RefreshWait)) + { + // A refresh is already running and is taking far longer than one RPC + // round trip. Queueing behind it would hand that latency to a + // promoted-entries request, so answer from the fallback instead. + return ToSet(MemCache.Get(LastGoodCacheKey) ?? Array.Empty()); + } + + try + { + // Someone else may have refreshed while this request queued. + cached = MemCache.Get(CacheKey); + if (cached != null) + { + return ToSet(cached); + } + + return ToSet(await Refresh()); + } + finally + { + RefreshGate.Release(); + } + } + + private static async Task Refresh() + { + try + { + var names = await Fetch(); + MemCache.Set(CacheKey, names, TtlSeconds); + + // Only a list with something in it is worth falling back to. An empty + // one is served live (unmuting everyone must take effect) but must not + // overwrite the fallback, or one empty answer would turn every later + // failure into no filtering at all. + if (names.Length > 0) + { + MemCache.Set(LastGoodCacheKey, names); + } + + return names; + } + catch (Exception) + { + // Deliberately silent: this runs on the promoted-entries request path + // and the service keeps its logs quiet there (CLAUDE.md, "No hot-path + // logging"). The caching below is what makes the failure survivable. + var fallback = MemCache.Get(LastGoodCacheKey) ?? Array.Empty(); + + // Cache the failure, including the empty one. Without this the gate + // above turns a node outage into something worse than no gate at all: + // each queued request waits out the one ahead of it and then runs its + // own full failover sweep, so the Nth caller pays N times the timeout + // budget. Caching lets every waiter answer immediately and puts one + // retry on the clock instead of one per request. + MemCache.Set(CacheKey, fallback, FailureTtlSeconds); + return fallback; + } + } + + private static async Task Fetch() + { + var names = new List(); + var start = ""; + + for (var page = 0; page < MaxPages; page++) + { + // Validate the shape at the client, so a node answering 200 with + // something that is not a row array fails over to another one and, + // if none can answer, throws. Without this an unusable answer read + // as "no more rows" and the caller cached an empty mute list -- + // filtering silently off, with nothing anywhere saying so. + var result = await Rpc.Call("condenser_api", "get_following", + new JsonArray(Account, start, "ignore", PageSize), + validateResult: r => r is JsonArray); + + var rows = (JsonArray)result!; + if (rows.Count == 0) + { + break; + } + + var pageNames = ReadFollowing(rows); + + // `start` is exclusive on Hive, so a page should not repeat the + // cursor. Drop it anyway: against a node treating it as inclusive + // this would re-append the same account until the page cap. + if (pageNames.Count > 0 && pageNames[0] == start) + { + pageNames.RemoveAt(0); + } + + if (pageNames.Count == 0) + { + break; + } + + names.AddRange(pageNames); + + if (rows.Count < PageSize) + { + break; + } + + start = pageNames[^1]; + } + + return names.ToArray(); + } + + /// + /// Read one string property the lenient way. `GetValue<string>()` throws on + /// a lone-surrogate escape, which JSON.parse accepts and Hive nodes do emit; + /// letting that throw here would fail a promoted-entries request, or abort a + /// mute-list refresh, over one malformed account name. + /// + private static string? ReadString(JsonNode? owner, string property) => + owner is JsonObject o + && o.TryGetPropertyValue(property, out var node) + && node is JsonValue value + && JsVal.TryGetStringLenient(value, out var s) + ? s + : null; + + internal static List ReadFollowing(JsonArray rows) + { + var names = new List(); + foreach (var row in rows) + { + var name = ReadString(row, "following"); + if (!string.IsNullOrEmpty(name)) + { + names.Add(name); + } + } + return names; + } + + internal static HashSet ToSet(IEnumerable names) => + new(names, StringComparer.OrdinalIgnoreCase); + + /// + /// Drop entries authored by a muted account. Returns a new array; entries + /// with no readable author are kept, since an unreadable shape is not + /// evidence of anything and dropping it would silently shrink the feed. + /// + public static JsonArray FilterMutedAuthors(JsonArray entries, ISet muted) + { + if (muted.Count == 0) + { + return entries; + } + + var kept = new JsonArray(); + foreach (var entry in entries.ToArray()) + { + var author = ReadString(entry, "author"); + + if (author != null && muted.Contains(author)) + { + continue; + } + + // A node can only live in one parent, and these come from a cache + // clone we own, so detach before re-parenting into the result. + entry?.Parent?.AsArray().Remove(entry); + kept.Add(entry); + } + + return kept; + } +}