using Microsoft.AspNetCore.Mvc; using PrivaPub.Api.Mastodon.Entities; using PrivaPub.Api.Mastodon.Infrastructure; using PrivaPub.Api.Mastodon.Mappers; using PrivaPub.Domain.Privacy; using PrivaPub.Federation.Actors; using PrivaPub.Federation.Inbox; using PrivaPub.Federation.Outbox; using PrivaPub.Domain.Social; using PrivaPub.Models.Post; using PrivaPub.StaticServices; using PostEntity = PrivaPub.Models.Post.Post; namespace PrivaPub.Api.Mastodon.Controllers { public partial class SearchController : MastodonController { readonly MastodonMapper _mapper; readonly DbEntities _dbEntities; readonly ILocalActorService _localActors; readonly IRemoteActorService _remoteActors; readonly IRemotePosts _remotePosts; readonly AccountSearch _search; public SearchController(MastodonMapper mapper, DbEntities dbEntities, ILocalActorService localActors, IRemoteActorService remoteActors, IRemotePosts remotePosts, AccountSearch search) { _mapper = mapper; _dbEntities = dbEntities; _localActors = localActors; _remoteActors = remoteActors; _remotePosts = remotePosts; _search = search; } // Anyone may search, as on Mastodon; only resolving (which fetches from other servers) and paging need a sign-in. [HttpGet("/api/v2/search"), Scope("read:search", requiresUser: false), Microsoft.AspNetCore.Authorization.AllowAnonymous] public async Task Search(CancellationToken token) { var q = Params.Get("q")?.Trim(); var type = Params.Get("type"); var offset = Params.Int("offset") ?? 0; if (MyId == default && (Params.Bool("resolve") == true || offset > 0)) return Error(StatusCodes.Status401Unauthorized, "Search queries that resolve uris or use offset require the read:search scope"); var resolve = Params.Bool("resolve") == true && MyId != default; var results = new SearchResults(); if (string.IsNullOrEmpty(q)) return Json(results); if (q.StartsWith("https://", StringComparison.OrdinalIgnoreCase)) { var post = await _dbEntities.Posts.Match(p => (p.ObjectURI == q || p.Url == q) && !p.DeletedAt.HasValue).ExecuteFirstAsync(token); if (post == default && resolve && type is null or "statuses") post = await _remotePosts.StoreContext(q, 0, token); if (post != default && type is null or "statuses" && await VisibilityPolicy.CanSee(post, MyId, token)) if (await _mapper.Status(post, MyId, token) is { } status) results.Statuses.Add(status); if (results.Statuses.Count == 0 && type is null or "accounts") { var local = await _localActors.FindByUri(q, token); var foreign = local == default ? (resolve ? await _remoteActors.GetActor(q, refresh: false, token) : default) : default; if (local is { IsFederated: true, IsCircle: false }) results.Accounts.Add(await _mapper.Local(local, false, token)); else if (foreign != default) results.Accounts.Add(_mapper.Foreign(foreign)); } return Json(results); } if (type is null or "accounts") { results.Accounts = await _search.Find(q, resolve, Limit(), Math.Max(0, offset), token); } // 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" && 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); } } public partial class SearchController { 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) { var candidates = await _dbEntities.Posts.Match(f => f.Text(q)).Match(p => !p.DeletedAt.HasValue && p.ReblogOfPostId == null && !p.AuthorGone) .Sort(p => p.ID, MongoDB.Entities.Order.Descending).Limit(WordCandidates).ExecuteAsync(token); if (candidates.Count == 0) return new List(); var ids = candidates.Select(p => p.ID).ToList(); var mine = new HashSet(); mine.UnionWith((await _dbEntities.Favourites.Match(f => f.AccountId == MyId && ids.Contains(f.PostId)).ExecuteAsync(token)).Select(f => f.PostId)); mine.UnionWith((await MongoDB.Entities.DB.Default.Find().Match(b => b.AvatarId == MyId && ids.Contains(b.PostId)).ExecuteAsync(token)) .Select(b => b.PostId)); mine.UnionWith((await _dbEntities.Posts.Match(p => p.AuthorAccountId == MyId && ids.Contains(p.ReblogOfPostId) && !p.DeletedAt.HasValue).ExecuteAsync(token)) .Select(p => p.ReblogOfPostId)); var authors = candidates.Select(p => p.AuthorAccountId ?? p.GroupUserId).Where(a => a != default).Distinct().ToList(); var indexable = (await _dbEntities.Avatars.Match(a => authors.Contains(a.ID) && a.Settings.IsIndexable).ExecuteAsync(token)).Select(a => a.ID) .Concat((await _dbEntities.ForeignAvatars.Match(f => authors.Contains(f.ID) && f.IsIndexable).ExecuteAsync(token)).Select(f => f.ID)) .ToHashSet(); var found = new List(); foreach (var post in candidates) { var author = post.AuthorAccountId ?? post.GroupUserId; var reachable = author == MyId && !post.IsFederatedCopy || mine.Contains(post.ID) || post.Mentions.Any(m => m.AccountId == MyId) || post.Visibility == PostVisibility.Public && indexable.Contains(author); if (reachable && await VisibilityPolicy.CanSee(post, MyId, token)) found.Add(post); if (found.Count >= offset + limit) break; } return await _mapper.Statuses(found.Skip(offset).Take(limit).ToList(), MyId, token); } } public class StubsController : MastodonController { [HttpGet("/api/v1/custom_emojis"), Microsoft.AspNetCore.Authorization.AllowAnonymous] public IActionResult CustomEmojis() => Json(Array.Empty()); [HttpGet("/api/v1/announcements"), Microsoft.AspNetCore.Authorization.AllowAnonymous] public IActionResult Announcements() => Json(Array.Empty()); [HttpGet("/api/v1/suggestions"), Scope("read")] public IActionResult SuggestionsV1() => Json(Array.Empty()); [HttpGet("/api/v2/suggestions"), Scope("read")] public IActionResult SuggestionsV2() => Json(Array.Empty()); [HttpGet("/api/v1/endorsements"), Scope("read:accounts")] public IActionResult Endorsements() => Json(Array.Empty()); [HttpGet("/api/v1/featured_tags"), Scope("read:accounts")] public IActionResult FeaturedTags() => Json(Array.Empty()); [HttpGet("/api/v1/preferences"), Scope("read:accounts")] public IActionResult Preferences() => Json(new Dictionary { ["posting:default:visibility"] = Me.Settings.DefaultVisibility ?? "public", ["posting:default:sensitive"] = Me.Settings.DefaultSensitive, ["posting:default:language"] = Me.Settings.DefaultLanguage, ["reading:expand:media"] = "default", ["reading:expand:spoilers"] = false }); [HttpGet("/api/v1/push/subscription"), Scope("push")] public IActionResult PushSubscription() => Error(StatusCodes.Status404NotFound, "Record not found"); // Routes Mastodon answers that we have nothing behind: an empty or default answer lets clients degrade, where a // 404 breaks some of them (Mastodon invariant 5) [HttpGet("/api/v1/timelines/link"), Microsoft.AspNetCore.Authorization.AllowAnonymous] public IActionResult LinkTimeline() => Json(Array.Empty()); [HttpGet("/api/v1/accounts/{id}/identity_proofs"), Microsoft.AspNetCore.Authorization.AllowAnonymous] public IActionResult IdentityProofs(string id) => Json(Array.Empty()); [HttpGet("/api/v1/instance/languages"), Microsoft.AspNetCore.Authorization.AllowAnonymous] public IActionResult Languages() => Json(InstanceController.LanguageCodes(HttpContext.RequestServices) .Select(code => new { code, name = System.Globalization.CultureInfo.GetCultureInfo(code).EnglishName })); [HttpGet("/api/v1/instance/translation_languages"), Microsoft.AspNetCore.Authorization.AllowAnonymous] public IActionResult TranslationLanguages() => Json(new { }); [HttpGet("/api/v1/instance/domain_blocks"), Microsoft.AspNetCore.Authorization.AllowAnonymous] public IActionResult InstanceDomainBlocks() => Json(Array.Empty()); [HttpGet("/api/v1/instance/privacy_policy"), Microsoft.AspNetCore.Authorization.AllowAnonymous] public IActionResult PrivacyPolicy() => Json(new { updated_at = "2026-10-04T00:00:00.000Z", content = "

One private login owns several public personas, and nobody but this server's admin can tell they belong " + "together. What a persona posts is shared as its visibility says; a located post never leaves this server. " + "Uploads lose their metadata. Statistics name servers, never people: see /stargazing.

" }); [HttpGet("/api/v1/notifications/policy"), Scope("read:notifications")] public IActionResult NotificationPolicyV1() => Json(new { filter_not_following = false, filter_not_followers = false, filter_new_accounts = false, filter_private_mentions = false, summary = new { pending_requests_count = 0, pending_notifications_count = 0 } }); [HttpGet("/api/v2/notifications/policy"), Scope("read:notifications")] public IActionResult NotificationPolicyV2() => Json(new { for_not_following = "accept", for_not_followers = "accept", for_new_accounts = "accept", for_private_mentions = "accept", for_limited_accounts = "accept", summary = new { pending_requests_count = 0, pending_notifications_count = 0 } }); [HttpGet("/api/v1/notifications/requests"), Scope("read:notifications")] public IActionResult NotificationRequests() => Json(Array.Empty()); } }