From 4a9a3d32353dda39f43d80f4cba3a0f3cf5a9f8a Mon Sep 17 00:00:00 2001 From: thepra Date: Mon, 5 Oct 2026 06:10:25 +0200 Subject: [PATCH] A handle finds its account, not posts and a made-up hashtag Since status search by words, searching "@name@host" also answered posts sharing the handle's words and a hashtag "#@name@host" made of the query, so a client that opens the profile when the account is the only result (decePub's search) stayed on the list. A handle now searches accounts only, and a hashtag result is offered only when the query is one (letters, marks, digits and underscores, with a letter). Found by decePub's end-to-end tests: every profile follow test failed. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw --- PrivaPub.Tests/Http/MastodonWordSearchTests.cs | 18 ++++++++++++++++++ .../Mastodon/Controllers/SearchController.cs | 10 +++++++--- .../Api/Mastodon/Controllers/TagsController.cs | 6 ++++++ 3 files changed, 31 insertions(+), 3 deletions(-) diff --git a/PrivaPub.Tests/Http/MastodonWordSearchTests.cs b/PrivaPub.Tests/Http/MastodonWordSearchTests.cs index c37cedb..41f8a2a 100644 --- a/PrivaPub.Tests/Http/MastodonWordSearchTests.cs +++ b/PrivaPub.Tests/Http/MastodonWordSearchTests.cs @@ -46,5 +46,23 @@ namespace PrivaPub.Tests.Http Assert.Equal(new[] { named }, (await reader.Client.Get($"/api/v2/search?q={word}&type=statuses&limit=1&offset=1")).Ok().Body["statuses"]!.AsArray().Select(s => s.Text("id"))); } + + // a handle asks for its account: no posts sharing its words, and no hashtag made of it + [Fact] + public async Task A_handle_finds_its_account_and_neither_posts_nor_a_hashtag() + { + var reader = await _host.Mastodon("reader"); + var named = await _host.Mastodon("named"); + await reader.Status($"talking of {named.UserName} here"); + + foreach (var q in new[] { $"@{named.UserName}", $"@{named.UserName}@{PrivaPubHost.Host}", $"{named.UserName}@{PrivaPubHost.Host}" }) + { + var body = (await reader.Client.Get($"/api/v2/search?q={Uri.EscapeDataString(q)}")).Ok().Body; + Assert.Contains(body["accounts"]!.AsArray(), a => a.Text("id") == named.Id); + Assert.Empty(body["statuses"]!.AsArray()); + Assert.Empty(body["hashtags"]!.AsArray()); + } + Assert.Equal("cats", Assert.Single((await reader.Client.Get("/api/v2/search?q=%23Cats")).Ok().Body["hashtags"]!.AsArray()).Text("name")); + } } } diff --git a/PrivaPub/Api/Mastodon/Controllers/SearchController.cs b/PrivaPub/Api/Mastodon/Controllers/SearchController.cs index a9477db..fefea75 100644 --- a/PrivaPub/Api/Mastodon/Controllers/SearchController.cs +++ b/PrivaPub/Api/Mastodon/Controllers/SearchController.cs @@ -73,10 +73,11 @@ namespace PrivaPub.Api.Mastodon.Controllers { results.Accounts = await _search.Find(q, resolve, Limit(), Math.Max(0, offset), token); } - if (type is null or "statuses" && MyId != default && !q.StartsWith('#')) + // an account's handle asks for the account, never for posts that happen to share its words + if (type is null or "statuses" && MyId != default && !q.StartsWith('#') && !Handle().IsMatch(q)) results.Statuses = await Words(q, Math.Max(0, offset), Limit(), token); - if (type is null or "hashtags" && q.TrimStart('#') is { Length: > 0 } tag) - results.Hashtags.Add(new { name = tag.ToLowerInvariant(), url = $"{_localActors.BaseAddress}/tags/{Uri.EscapeDataString(tag.ToLowerInvariant())}", history = Array.Empty() }); + if (type is null or "hashtags" && TagsController.Normalise(q) is var tag && TagsController.IsHashtag(tag)) + results.Hashtags.Add(new { name = tag, url = $"{_localActors.BaseAddress}/tags/{Uri.EscapeDataString(tag)}", history = Array.Empty() }); return Json(results); } } @@ -85,6 +86,9 @@ namespace PrivaPub.Api.Mastodon.Controllers { const int WordCandidates = 400; + [System.Text.RegularExpressions.GeneratedRegex(@"^@?[^\s@]+@[^\s@]+$|^@[^\s@]+$")] + private static partial System.Text.RegularExpressions.Regex Handle(); + // Posts by their words, as Mastodon searches them: those the persona wrote, boosted, favourited, bookmarked or was // named in, and public posts of authors who let themselves be indexed; newest first, what the persona may see only async Task> Words(string q, int offset, int limit, CancellationToken token) diff --git a/PrivaPub/Api/Mastodon/Controllers/TagsController.cs b/PrivaPub/Api/Mastodon/Controllers/TagsController.cs index 058c779..e6b3c62 100644 --- a/PrivaPub/Api/Mastodon/Controllers/TagsController.cs +++ b/PrivaPub/Api/Mastodon/Controllers/TagsController.cs @@ -31,6 +31,12 @@ namespace PrivaPub.Api.Mastodon.Controllers public static string Normalise(string name) => name?.Trim().TrimStart('#').Normalize(NormalizationForm.FormKC).ToLowerInvariant() ?? string.Empty; + // what a hashtag can be, as Mastodon has it: letters, marks, digits and underscores, with a letter or underscore + public static bool IsHashtag(string normal) => + normal.Length is > 0 and <= 100 && normal.All(c => char.IsLetterOrDigit(c) || c == '_' || char.GetUnicodeCategory(c) is + System.Globalization.UnicodeCategory.NonSpacingMark or System.Globalization.UnicodeCategory.SpacingCombiningMark) + && normal.Any(c => char.IsLetter(c) || c == '_'); + // a day's uses and authors, newest first, for the last week, as Mastodon gives them (strings, days as unix time) public static async Task> History(DbEntities dbEntities, string tag, CancellationToken token) {