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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
211 lines
10 KiB
C#
211 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)
|
|
.Concat((await _dbEntities.ForeignAvatars.Match(f => authors.Contains(f.ID) && f.IsIndexable).ExecuteAsync(token)).Select(f => f.ID))
|
|
.ToHashSet();
|
|
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 && 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<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>"
|
|
});
|
|
|
|
[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<object>());
|
|
}
|
|
}
|