trends/tags, trends/statuses and trends/links replace the stubs. Hashtags and links rise with the number of different authors using them today against the week before, two at least; posts with their favourites, boosts and replies, halving every twelve hours. Only public posts PrivaPub already holds count, nothing behind a content warning, and a post trends only if its author is discoverable. Computed at most every ten minutes. The directory lists discoverable accounts by their latest public post, or by the day they say they joined: a persona's published day, never its creation, with ties broken by a hash of the name, so personas made together are not told apart by their order. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
167 lines
7.3 KiB
C#
167 lines
7.3 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 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);
|
|
}
|
|
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<object>() });
|
|
return Json(results);
|
|
}
|
|
}
|
|
|
|
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>());
|
|
}
|
|
}
|