Build / Build (push) Successful in 7m58s
A remote account's searchableBy, and a post's own, outrank indexable in status search: Public lets anyone find a public post, the author's followers only those who follow it, anything else nobody but those it already reaches (their own, named, favourited, bookmarked or boosted posts). Read on actors and posts, kept through edits; PrivaPub does not emit it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
202 lines
10 KiB
C#
202 lines
10 KiB
C#
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<IActionResult> 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<object>() });
|
|
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<List<Status>> 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<Status>();
|
|
var ids = candidates.Select(p => p.ID).ToList();
|
|
var mine = new HashSet<string>();
|
|
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<Models.Social.Bookmark>().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).ToHashSet();
|
|
// a remote author's searchableBy (FEP-268d) outranks its indexable, and a post's own outranks both
|
|
var remoteAuthors = (await _dbEntities.ForeignAvatars.Match(f => authors.Contains(f.ID)).ExecuteAsync(token)).ToDictionary(f => f.ID);
|
|
var stating = remoteAuthors.Values.Where(f => f.SearchableBy != default).Select(f => f.ActorURI)
|
|
.Concat(candidates.Where(p => p.SearchableBy != default).Select(p => p.ActorURI))
|
|
.Where(uri => uri != default).Distinct().ToList();
|
|
var followed = stating.Count == 0
|
|
? new HashSet<string>()
|
|
: (await _dbEntities.Followings.Match(f => f.AvatarId == MyId && stating.Contains(f.TargetActorURI) && f.State == Models.Social.FollowState.Accepted)
|
|
.ExecuteAsync(token)).Select(f => f.TargetActorURI).ToHashSet();
|
|
bool Searchable(PostEntity post, string author)
|
|
{
|
|
var remote = remoteAuthors.GetValueOrDefault(author);
|
|
var rule = post.SearchableBy ?? remote?.SearchableBy;
|
|
if (rule != default)
|
|
return Federation.Objects.ObjectShapes.Searchable(rule, remote?.FollowersURL, followed.Contains(post.ActorURI ?? string.Empty));
|
|
return remote?.IsIndexable == true || indexable.Contains(author);
|
|
}
|
|
var found = new List<PostEntity>();
|
|
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 && Searchable(post, 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<object>());
|
|
|
|
[HttpGet("/api/v1/announcements"), Microsoft.AspNetCore.Authorization.AllowAnonymous]
|
|
public IActionResult Announcements() => Json(Array.Empty<object>());
|
|
|
|
[HttpGet("/api/v1/suggestions"), Scope("read")]
|
|
public IActionResult SuggestionsV1() => Json(Array.Empty<object>());
|
|
|
|
[HttpGet("/api/v2/suggestions"), Scope("read")]
|
|
public IActionResult SuggestionsV2() => Json(Array.Empty<object>());
|
|
|
|
[HttpGet("/api/v1/endorsements"), Scope("read:accounts")]
|
|
public IActionResult Endorsements() => Json(Array.Empty<object>());
|
|
|
|
[HttpGet("/api/v1/featured_tags"), Scope("read:accounts")]
|
|
public IActionResult FeaturedTags() => Json(Array.Empty<object>());
|
|
|
|
[HttpGet("/api/v1/preferences"), Scope("read:accounts")]
|
|
public IActionResult Preferences() => Json(new Dictionary<string, object>
|
|
{
|
|
["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<object>());
|
|
|
|
[HttpGet("/api/v1/accounts/{id}/identity_proofs"), Microsoft.AspNetCore.Authorization.AllowAnonymous]
|
|
public IActionResult IdentityProofs(string id) => Json(Array.Empty<object>());
|
|
|
|
[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<object>());
|
|
|
|
[HttpGet("/api/v1/instance/privacy_policy"), Microsoft.AspNetCore.Authorization.AllowAnonymous]
|
|
public IActionResult PrivacyPolicy() => Json(new
|
|
{
|
|
updated_at = "2026-10-04T00:00:00.000Z",
|
|
content = "<p>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.</p>"
|
|
});
|
|
}
|
|
}
|