R8 the Mastodon API made real: featured tags, endorsements, notes, notify and languages, suggestions, the direct stream
Featured tags per persona (/api/v1/featured_tags, suggestions, tags/:name/feature, accounts' featured tags, max 10),
listed at /tattoos as Hashtags and sent as Add/Remove{Hashtag} to followers on the featured collection, as Mastodon
sends them; remote accounts' tags received and read from featuredTags. Endorsements (pin/endorse) and private notes
(PersonaNote) kept per persona and never federated; the relationship's notifying, languages, endorsed and note, with
follow's notify (status notifications) and languages (home filter). Reading preferences set on /clientapi. Suggestions
v1/v2 with dismissals and staff picks, never the persona or a sibling. conversation events on the direct stream.
translate, annual reports and the admin API answer. New collections backed up and removed with their persona.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WpVCoDeM1qUTRp4fXQdfof
This commit is contained in:
1 parent
d08fbabaa5
commit
87529bca49
42 files changed
+1505
-77
No files matched your search
@@ -304,7 +304,10 @@ namespace PrivaPub.Api.Mastodon.Controllers
|
||||
await _feeds.Subscribe(Me, remote, true, token);
|
||||
return Json(await Relationship(id, token));
|
||||
}
|
||||
var following = await _follows.FollowAs(Me, local?.Uri ?? remote.ActorURI, Params.Bool("reblogs") != false, token);
|
||||
// notify: a `status` notification for each of its new posts; languages: only its posts in those reach the home (R8).
|
||||
// Either changes only when given, as on Mastodon
|
||||
var following = await _follows.FollowAs(Me, local?.Uri ?? remote.ActorURI, Params.Bool("reblogs") != false, token, Params.Bool("notify"),
|
||||
Params.Has("languages") ? Params.List("languages") : default);
|
||||
return following == default ? Error(StatusCodes.Status403Forbidden, "This action is not allowed") : Json(await Relationship(id, token));
|
||||
}
|
||||
|
||||
@@ -425,12 +428,112 @@ namespace PrivaPub.Api.Mastodon.Controllers
|
||||
public async Task<IActionResult> Reject(string id, CancellationToken token) =>
|
||||
await _follows.Decide(Me, id, accept: false, token) ? Json(await Relationship(id, token)) : NotFoundError();
|
||||
|
||||
[HttpGet("/api/v1/accounts/{id}/featured_tags"), Scope("read:accounts", requiresUser: false), Microsoft.AspNetCore.Authorization.AllowAnonymous]
|
||||
public IActionResult FeaturedTags(string id) => Json(Array.Empty<object>());
|
||||
|
||||
// Familiar followers stay empty by design: which of the accounts the persona follows also follow another account would
|
||||
// tell who follows whom among the personas here (and their followers lists are never shown, hide_collections), so
|
||||
// every account answers none
|
||||
[HttpGet("/api/v1/accounts/familiar_followers"), Scope("read:follows")]
|
||||
public IActionResult FamiliarFollowers() => Json(Params.List("id").Select(id => new { id, accounts = Array.Empty<Account>() }).ToList());
|
||||
|
||||
// Endorsements (R8): Mastodon's account pins under their old names (pin, unpin) and 4.4's (endorse, unendorse). Only an
|
||||
// account the persona follows may be endorsed, as Mastodon requires; they are the persona's alone, never federated as
|
||||
// featured accounts and never listed to anyone else (the owner's default: endorsements stay private)
|
||||
[HttpPost("/api/v1/accounts/{id}/pin"), HttpPost("/api/v1/accounts/{id}/endorse"), Scope("write:accounts")]
|
||||
public async Task<IActionResult> Endorse(string id, CancellationToken token)
|
||||
{
|
||||
var (local, remote) = await Find(id, token);
|
||||
if (local == default && remote == default || local?.Id == MyId || local is { Suspended: true } || remote is { SuspendedAt: not null })
|
||||
return NotFoundError();
|
||||
var uri = local?.Uri ?? remote.ActorURI;
|
||||
if (!await Follows(uri, token))
|
||||
return Error(StatusCodes.Status422UnprocessableEntity, "Validation failed: You must be already following the person you want to endorse");
|
||||
if (!await DB.Default.Find<Endorsement>().Match(e => e.AvatarId == MyId && e.TargetActorURI == uri).ExecuteAnyAsync(token))
|
||||
{
|
||||
if (await DB.Default.CountAsync<Endorsement>(e => e.AvatarId == MyId, token) >= MaxEndorsements)
|
||||
return Error(StatusCodes.Status422UnprocessableEntity, $"Validation failed: You can endorse at most {MaxEndorsements} accounts");
|
||||
try
|
||||
{
|
||||
await DB.Default.SaveAsync(new Endorsement { AvatarId = MyId, TargetAccountId = id, TargetActorURI = uri }, token);
|
||||
}
|
||||
catch (MongoDB.Driver.MongoWriteException ex) when (ex.WriteError?.Category == MongoDB.Driver.ServerErrorCategory.DuplicateKey)
|
||||
{
|
||||
}
|
||||
}
|
||||
return Json(await Relationship(id, token));
|
||||
}
|
||||
|
||||
[HttpPost("/api/v1/accounts/{id}/unpin"), HttpPost("/api/v1/accounts/{id}/unendorse"), Scope("write:accounts")]
|
||||
public async Task<IActionResult> Unendorse(string id, CancellationToken token)
|
||||
{
|
||||
var (local, remote) = await Find(id, token);
|
||||
if (local == default && remote == default)
|
||||
return NotFoundError();
|
||||
var uri = local?.Uri ?? remote.ActorURI;
|
||||
await DB.Default.DeleteAsync<Endorsement>(e => e.AvatarId == MyId && (e.TargetActorURI == uri || e.TargetAccountId == id));
|
||||
return Json(await Relationship(id, token));
|
||||
}
|
||||
|
||||
// the persona's own endorsements, newest first, those it still follows
|
||||
[HttpGet("/api/v1/endorsements"), Scope("read:accounts")]
|
||||
public Task<IActionResult> Endorsements(CancellationToken token) => EndorsementsOf("/api/v1/endorsements", token);
|
||||
|
||||
// Mastodon 4.4: an account's endorsements. Only the persona's own are answered, to the persona itself: endorsements
|
||||
// stay private, so every other account answers none
|
||||
[HttpGet("/api/v1/accounts/{id}/endorsements"), Scope("read:accounts", requiresUser: false), Microsoft.AspNetCore.Authorization.AllowAnonymous]
|
||||
public async Task<IActionResult> EndorsementsOfAccount(string id, CancellationToken token) =>
|
||||
MyId != default && id == MyId ? await EndorsementsOf($"/api/v1/accounts/{id}/endorsements", token) : Json(Array.Empty<Account>());
|
||||
|
||||
async Task<IActionResult> EndorsementsOf(string path, CancellationToken token)
|
||||
{
|
||||
var followed = (await _dbEntities.Followings.Match(f => f.AvatarId == MyId && f.State == FollowState.Accepted && f.FollowerKind == null)
|
||||
.Project(f => f.Include(x => x.TargetActorURI)).ExecuteAsync(token)).Select(f => f.TargetActorURI).ToList();
|
||||
var page = await Page.From(Params, Limit(40, 80))
|
||||
.Fetch(DB.Default.Find<Endorsement>().Match(e => e.AvatarId == MyId && followed.Contains(e.TargetActorURI)), e => e.ID, token);
|
||||
Link(path, page.LastOrDefault()?.ID, page.FirstOrDefault()?.ID);
|
||||
var accounts = await _mapper.Accounts(page.Select(e => e.TargetAccountId), token);
|
||||
return Json(page.Select(e => accounts.GetValueOrDefault(e.TargetAccountId)).Where(a => a != default).ToList());
|
||||
}
|
||||
|
||||
// A private note on an account (R8): the persona's own (PersonaNote, never its login's), shown only to it in the
|
||||
// relationship's `note`, never federated; an empty comment takes it away
|
||||
[HttpPost("/api/v1/accounts/{id}/note"), Scope("write:accounts")]
|
||||
public async Task<IActionResult> Note(string id, CancellationToken token)
|
||||
{
|
||||
var (local, remote) = await Find(id, token);
|
||||
if (local == default && remote == default)
|
||||
return NotFoundError();
|
||||
var comment = Params.Get("comment")?.Trim();
|
||||
if (comment?.Length > PersonaNote.MaxLength)
|
||||
return Error(StatusCodes.Status422UnprocessableEntity, $"Validation failed: Comment is too long (maximum is {PersonaNote.MaxLength} characters)");
|
||||
if (string.IsNullOrEmpty(comment))
|
||||
await DB.Default.DeleteAsync<PersonaNote>(n => n.AvatarId == MyId && n.TargetAccountId == id);
|
||||
else
|
||||
await DB.Default.Update<PersonaNote>().Match(n => n.AvatarId == MyId && n.TargetAccountId == id)
|
||||
.Modify(n => n.Text, comment)
|
||||
.Modify(n => n.UpdatedAt, DateTime.UtcNow)
|
||||
.Modify(b => b.SetOnInsert(n => n.CreatedAt, DateTime.UtcNow))
|
||||
.Option(o => o.IsUpsert = true)
|
||||
.ExecuteAsync(token);
|
||||
return Json(await Relationship(id, token));
|
||||
}
|
||||
|
||||
// Mastodon's preferences: posting defaults (update_credentials' source) and reading ones (R8: AvatarSettings, set
|
||||
// through /clientapi/avatar/private/preferences, since Mastodon's API sets none)
|
||||
[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"] = ExpandMediaModes.IsKnown(Me.Settings.ExpandMedia) ? Me.Settings.ExpandMedia : ExpandMediaModes.Default,
|
||||
["reading:expand:spoilers"] = Me.Settings.ExpandSpoilers
|
||||
});
|
||||
|
||||
const int MaxEndorsements = 500;
|
||||
|
||||
async Task<bool> Follows(string uri, CancellationToken token) =>
|
||||
await _dbEntities.Followings.Match(f => f.AvatarId == MyId && f.TargetActorURI == uri && f.State == FollowState.Accepted && f.FollowerKind == null)
|
||||
.ExecuteAnyAsync(token);
|
||||
|
||||
async Task<List<Account>> AccountsFor(IEnumerable<string> actorUris, CancellationToken token)
|
||||
{
|
||||
var accounts = new List<Account>();
|
||||
@@ -474,6 +577,10 @@ namespace PrivaPub.Api.Mastodon.Controllers
|
||||
: remote != default && await DB.Default.Find<BlockedBy>().Match(b => b.AvatarId == MyId && b.ActorURI == remote.ActorURI).ExecuteAnyAsync(token);
|
||||
var domainBlocked = remote != default && (await DB.Default.Find<AccountDomainBlock>().Match(b => b.AvatarId == MyId).ExecuteAsync(token))
|
||||
.Any(b => Hidden.Blocks(b.Domain, remote.ActorURI, remote.Domain));
|
||||
// the persona's own endorsement and note (R8), never another persona's: a note made as a sibling stays its own
|
||||
var endorsed = uri != default && following?.State == FollowState.Accepted
|
||||
&& await DB.Default.Find<Endorsement>().Match(e => e.AvatarId == MyId && e.TargetActorURI == uri).ExecuteAnyAsync(token);
|
||||
var note = await DB.Default.Find<PersonaNote>().Match(n => n.AvatarId == MyId && n.TargetAccountId == id).ExecuteFirstAsync(token);
|
||||
return new Relationship
|
||||
{
|
||||
Id = id,
|
||||
@@ -485,8 +592,12 @@ namespace PrivaPub.Api.Mastodon.Controllers
|
||||
Following = following?.State == FollowState.Accepted || reading,
|
||||
Requested = following?.State == FollowState.Requested,
|
||||
ShowingReblogs = following?.ShowReblogs ?? false,
|
||||
Notifying = following?.Notify ?? false,
|
||||
Languages = following?.Languages,
|
||||
FollowedBy = followedBy?.IsAccepted == true,
|
||||
RequestedBy = followedBy is { IsAccepted: false }
|
||||
RequestedBy = followedBy is { IsAccepted: false },
|
||||
Endorsed = endorsed,
|
||||
Note = note?.Text ?? string.Empty
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
using MongoDB.Entities;
|
||||
|
||||
using PrivaPub.Api.Mastodon.Entities;
|
||||
using PrivaPub.Api.Mastodon.Infrastructure;
|
||||
using PrivaPub.Domain.Social;
|
||||
using PrivaPub.Federation.Actors;
|
||||
using PrivaPub.Federation.Rendering;
|
||||
using PrivaPub.Models.Federation;
|
||||
using PrivaPub.Models.Social;
|
||||
using PrivaPub.StaticServices;
|
||||
|
||||
namespace PrivaPub.Api.Mastodon.Controllers
|
||||
{
|
||||
// Featured hashtags as Mastodon's API has them (completion program R8, Domain/Social/FeaturedTags): the persona's own,
|
||||
// featured and unfeatured (its followers are told), suggested from the tags it uses, and any account's: a persona's own
|
||||
// here, or those an account elsewhere features as its server tells them. They are public, as on Mastodon.
|
||||
public class FeaturedTagsController : MastodonController
|
||||
{
|
||||
const int MaxSuggested = 10;//Mastodon's RECENT_TAGS_LIMIT
|
||||
const int SuggestionPosts = 200;
|
||||
|
||||
readonly IFeaturedTags _featured;
|
||||
readonly DbEntities _dbEntities;
|
||||
readonly ILocalActorService _localActors;
|
||||
|
||||
public FeaturedTagsController(IFeaturedTags featured, DbEntities dbEntities, ILocalActorService localActors)
|
||||
{
|
||||
_featured = featured;
|
||||
_dbEntities = dbEntities;
|
||||
_localActors = localActors;
|
||||
}
|
||||
|
||||
[HttpGet("/api/v1/featured_tags"), Scope("read:accounts")]
|
||||
public async Task<IActionResult> Mine(CancellationToken token) => Json(await Entities(await _featured.Of(MyId, token), token));
|
||||
|
||||
[HttpPost("/api/v1/featured_tags"), Scope("write:accounts")]
|
||||
public async Task<IActionResult> Feature(CancellationToken token)
|
||||
{
|
||||
var (tag, error) = await _featured.Feature(Me, Params.Get("name"), token);
|
||||
return tag == default ? Error(StatusCodes.Status422UnprocessableEntity, error) : Json((await Entities(new List<FeaturedTag> { tag }, token))[0]);
|
||||
}
|
||||
|
||||
[HttpDelete("/api/v1/featured_tags/{id}"), Scope("write:accounts")]
|
||||
public async Task<IActionResult> Unfeature(string id, CancellationToken token) =>
|
||||
await _featured.Unfeature(Me, id, default, token) == default ? NotFoundError() : Json(new { });
|
||||
|
||||
// the tags the persona used lately and does not feature yet, the most used first, as Mastodon's Tag
|
||||
[HttpGet("/api/v1/featured_tags/suggestions"), Scope("read:accounts")]
|
||||
public async Task<IActionResult> Suggestions(CancellationToken token)
|
||||
{
|
||||
var featured = (await _featured.Of(MyId, token)).Select(t => t.Name).ToHashSet(StringComparer.Ordinal);
|
||||
var recent = await _dbEntities.Posts.Match(p => p.GroupUserId == MyId && !p.IsFederatedCopy && !p.DeletedAt.HasValue && p.ReblogOfPostId == null
|
||||
&& p.Tags.Count > 0)
|
||||
.Sort(p => p.ID, Order.Descending).Limit(SuggestionPosts).Project(p => p.Include(x => x.Tags)).ExecuteAsync(token);
|
||||
var used = recent.SelectMany((p, age) => p.Tags.Distinct().Select(tag => (tag, age)))
|
||||
.Where(t => !featured.Contains(t.tag) && TagsController.IsHashtag(t.tag))
|
||||
.GroupBy(t => t.tag)
|
||||
.OrderByDescending(g => g.Count()).ThenBy(g => g.Min(t => t.age))
|
||||
.Take(MaxSuggested)
|
||||
.Select(g => g.Key)
|
||||
.ToList();
|
||||
var views = new List<object>();
|
||||
foreach (var tag in used)
|
||||
views.Add(await TagsController.TagView(_dbEntities, _localActors.BaseAddress, tag, MyId, default, false, token));
|
||||
return Json(views);
|
||||
}
|
||||
|
||||
// what an account features: a persona's own hashtags, or what an account elsewhere tells (its featuredTags, its Add
|
||||
// and Remove); a community features none. A suspended account's answer 404, as its account does (R6).
|
||||
[HttpGet("/api/v1/accounts/{id}/featured_tags"), Scope("read:accounts", requiresUser: false), Microsoft.AspNetCore.Authorization.AllowAnonymous]
|
||||
public async Task<IActionResult> OfAccount(string id, CancellationToken token)
|
||||
{
|
||||
var avatar = await _dbEntities.Avatars.MatchID(id).Match(a => !a.DeletionAt.HasValue).ExecuteFirstAsync(token);
|
||||
if (avatar != default)
|
||||
return _localActors.FromAvatar(avatar) is { Suspended: false }
|
||||
? Json(await Entities(await _featured.Of(avatar.ID, token), token))
|
||||
: NotFoundError();
|
||||
if (await _dbEntities.Groups.MatchID(id).Match(g => !g.DeletionAt.HasValue).ExecuteAnyAsync(token))
|
||||
return Json(Array.Empty<FeaturedTagEntity>());
|
||||
var remote = await _dbEntities.ForeignAvatars.MatchID(id).Match(f => !f.DeletionAt.HasValue).ExecuteFirstAsync(token);
|
||||
if (remote == default || remote.SuspendedAt.HasValue)
|
||||
return NotFoundError();
|
||||
var tags = await DB.Default.Find<RemoteFeaturedTag>().Match(t => t.ActorURI == remote.ActorURI).Sort(t => t.Position, Order.Ascending)
|
||||
.Limit(FeaturedTags.MaxRemote).ExecuteAsync(token);
|
||||
var usage = await FeaturedTags.Usage(default, remote.ActorURI, tags.Select(t => t.Name).ToList(), _dbEntities, token);
|
||||
return Json(tags.Select(t => Entity(t.ID, t.Name, t.DisplayName, t.Href, usage.GetValueOrDefault(t.Name))).ToList());
|
||||
}
|
||||
|
||||
async Task<List<FeaturedTagEntity>> Entities(List<FeaturedTag> tags, CancellationToken token)
|
||||
{
|
||||
var byPersona = tags.GroupBy(t => t.AvatarId).ToList();
|
||||
var entities = new List<FeaturedTagEntity>();
|
||||
foreach (var persona in byPersona)
|
||||
{
|
||||
var usage = await FeaturedTags.Usage(persona.Key, default, persona.Select(t => t.Name).ToList(), _dbEntities, token);
|
||||
entities.AddRange(persona.Select(t => Entity(t.ID, t.Name, t.DisplayName, default, usage.GetValueOrDefault(t.Name))));
|
||||
}
|
||||
return entities;
|
||||
}
|
||||
|
||||
FeaturedTagEntity Entity(string id, string name, string displayName, string href, (long Count, DateTime? Last) usage) => new()
|
||||
{
|
||||
Id = id,
|
||||
Name = string.IsNullOrEmpty(displayName) ? name : displayName,
|
||||
// (a link to the remote site is only a link, which the reader follows or not; ours is this server's tag page)
|
||||
Url = href ?? ActivityPubRenderer.TagUrl(_localActors.BaseAddress, name),
|
||||
StatusesCount = usage.Count.ToString(System.Globalization.CultureInfo.InvariantCulture),
|
||||
LastStatusAt = usage.Last?.ToString("yyyy-MM-dd", System.Globalization.CultureInfo.InvariantCulture)
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -134,7 +134,7 @@ namespace PrivaPub.Api.Mastodon.Controllers
|
||||
registrations = Open,
|
||||
approval_required = false,
|
||||
invites_enabled = true,//a group invitation always creates an account
|
||||
configuration = new { accounts = new { max_featured_tags = 0 }, statuses = Statuses, media_attachments = MediaAttachments, polls = Polls },
|
||||
configuration = new { accounts = new { max_featured_tags = PrivaPub.Domain.Social.FeaturedTags.MaxFeatured }, statuses = Statuses, media_attachments = MediaAttachments, polls = Polls },
|
||||
contact_account = await ContactAccount(settings, token),
|
||||
rules = Rules(settings)
|
||||
});
|
||||
@@ -161,7 +161,7 @@ namespace PrivaPub.Api.Mastodon.Controllers
|
||||
configuration = new
|
||||
{
|
||||
urls = new { streaming = Streaming },
|
||||
accounts = new { max_featured_tags = 0, max_pinned_statuses = StatusesController.MaxPins },
|
||||
accounts = new { max_featured_tags = PrivaPub.Domain.Social.FeaturedTags.MaxFeatured, max_pinned_statuses = StatusesController.MaxPins },
|
||||
statuses = Statuses,
|
||||
media_attachments = MediaAttachments,
|
||||
polls = Polls,
|
||||
|
||||
@@ -140,28 +140,6 @@ namespace PrivaPub.Api.Mastodon.Controllers
|
||||
|
||||
public class StubsController : MastodonController
|
||||
{
|
||||
[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");
|
||||
|
||||
@@ -179,5 +157,21 @@ namespace PrivaPub.Api.Mastodon.Controllers
|
||||
|
||||
[HttpGet("/api/v1/instance/translation_languages"), Microsoft.AspNetCore.Authorization.AllowAnonymous]
|
||||
public IActionResult TranslationLanguages() => Json(new { });
|
||||
|
||||
// translation is cut (owner decision 2026-10-17): no language offers any (translation_languages), and a client that
|
||||
// asks anyway is told so, as Mastodon tells it when it can't translate
|
||||
[HttpPost("/api/v1/statuses/{id}/translate"), Scope("read:statuses")]
|
||||
public IActionResult Translate(string id) => Error(StatusCodes.Status422UnprocessableEntity, "Translation is not available on this server");
|
||||
|
||||
// Mastodon 4.3's yearly reports (Wrapstodon): none are made here, so there are never any to show
|
||||
[HttpGet("/api/v1/annual_reports"), Scope("read:accounts")]
|
||||
public IActionResult AnnualReports() => Json(new { annual_reports = Array.Empty<object>(), accounts = Array.Empty<object>(), statuses = Array.Empty<object>() });
|
||||
|
||||
// the Mastodon admin API is cut: moderation stays on /clientapi (ModeratorController, AdminController), so every
|
||||
// admin route answers as Mastodon answers an account that may not use it, whoever asks
|
||||
[HttpGet("/api/v1/admin/{**rest}"), HttpPost("/api/v1/admin/{**rest}"), HttpPut("/api/v1/admin/{**rest}"), HttpPatch("/api/v1/admin/{**rest}"),
|
||||
HttpDelete("/api/v1/admin/{**rest}"), HttpGet("/api/v2/admin/{**rest}"), HttpPost("/api/v2/admin/{**rest}"),
|
||||
ApiExplorerSettings(IgnoreApi = true), Microsoft.AspNetCore.Authorization.AllowAnonymous]
|
||||
public IActionResult Admin(string rest) => Error(StatusCodes.Status403Forbidden, "This action is not allowed");
|
||||
}
|
||||
}
|
||||
@@ -41,7 +41,7 @@ namespace PrivaPub.Api.Mastodon.Controllers
|
||||
{
|
||||
switch (name)
|
||||
{
|
||||
case Streams.User or Streams.UserNotification or Streams.Public or Streams.PublicLocal or Streams.PublicRemote:
|
||||
case Streams.User or Streams.UserNotification or Streams.Public or Streams.PublicLocal or Streams.PublicRemote or Streams.Direct:
|
||||
return (new Streams.Key(name), default);
|
||||
case Streams.Hashtag or Streams.HashtagLocal:
|
||||
var normal = TagsController.Normalise(tag);
|
||||
@@ -69,6 +69,15 @@ namespace PrivaPub.Api.Mastodon.Controllers
|
||||
}
|
||||
if (e.Name == "announcement.reaction")
|
||||
return JsonSerializer.Serialize(await AnnouncementsController.ReactionEvent(e.Id, e.Data, token), MastodonJson.Options);
|
||||
// a direct conversation the persona takes part in (R8): Mastodon's Conversation, as /api/v1/conversations lists it
|
||||
if (e.Name == "conversation")
|
||||
{
|
||||
var group = await _dbEntities.DmGroups.Match(g => g.ID == e.Id && !g.DeletionAt.HasValue && g.Members.Any(m => !m.IsForeign && m.AvatarId == MyId))
|
||||
.ExecuteFirstAsync(token);
|
||||
var conversation = group == default ? default
|
||||
: (await TimelinesController.ConversationsOf(_mapper, _dbEntities, MyId, new List<Models.Group.DmGroup> { group }, token)).FirstOrDefault();
|
||||
return conversation == default ? default : JsonSerializer.Serialize(conversation, MastodonJson.Options);
|
||||
}
|
||||
if (e.Name == "notification")
|
||||
{
|
||||
var notification = await _dbEntities.Notifications.Match(n => n.ID == e.Id && n.AvatarId == MyId).ExecuteFirstAsync(token);
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
using MongoDB.Driver;
|
||||
using MongoDB.Entities;
|
||||
|
||||
using PrivaPub.Api.Mastodon.Entities;
|
||||
using PrivaPub.Api.Mastodon.Infrastructure;
|
||||
using PrivaPub.Api.Mastodon.Mappers;
|
||||
using PrivaPub.Domain.Instance;
|
||||
using PrivaPub.Domain.Relationships;
|
||||
using PrivaPub.Domain.Social;
|
||||
using PrivaPub.Models.Social;
|
||||
|
||||
namespace PrivaPub.Api.Mastodon.Controllers
|
||||
{
|
||||
// Follow suggestions as Mastodon's API has them (completion program R8; who and why in Domain/Social/Suggestions): v1 the
|
||||
// accounts, v2 each with its reasons; a dismissed account is never suggested to that persona again.
|
||||
public class SuggestionsController : MastodonController
|
||||
{
|
||||
readonly MastodonMapper _mapper;
|
||||
readonly IInstanceSettings _settings;
|
||||
|
||||
public SuggestionsController(MastodonMapper mapper, IInstanceSettings settings)
|
||||
{
|
||||
_mapper = mapper;
|
||||
_settings = settings;
|
||||
}
|
||||
|
||||
[HttpGet("/api/v1/suggestions"), Scope("read")]
|
||||
public async Task<IActionResult> V1(CancellationToken token) => Json((await Suggested(token)).Select(s => s.Account).ToList());
|
||||
|
||||
[HttpGet("/api/v2/suggestions"), Scope("read")]
|
||||
public async Task<IActionResult> V2(CancellationToken token) => Json(await Suggested(token));
|
||||
|
||||
[HttpDelete("/api/v1/suggestions/{accountId}"), Scope("read")]
|
||||
public async Task<IActionResult> Dismiss(string accountId, CancellationToken token)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(accountId) && accountId.Length <= 64)
|
||||
try
|
||||
{
|
||||
await DB.Default.Update<SuggestionDismissal>().Match(d => d.AvatarId == MyId && d.AccountId == accountId)
|
||||
.Modify(b => b.SetOnInsert(d => d.CreatedAt, DateTime.UtcNow)).Option(o => o.IsUpsert = true).ExecuteAsync(token);
|
||||
}
|
||||
catch (MongoWriteException ex) when (ex.WriteError?.Category == ServerErrorCategory.DuplicateKey)
|
||||
{
|
||||
}
|
||||
return Json(new { });
|
||||
}
|
||||
|
||||
// the candidates mapped a few at a time, until the page is full: an account no longer shown (deleted, suspended), one
|
||||
// shown limited (silenced, or its server), a community outside the staff's picks, and one the persona hides (its
|
||||
// blocked servers) are passed over
|
||||
async Task<List<Suggestion>> Suggested(CancellationToken token)
|
||||
{
|
||||
var limit = Limit(40, 80);
|
||||
var candidates = await Suggestions.For(Me, _settings.Current.StaffPicks, token);
|
||||
var suggested = new List<Suggestion>();
|
||||
for (var start = 0; start < candidates.Count && suggested.Count < limit; start += limit * 2)
|
||||
{
|
||||
var chunk = candidates.Skip(start).Take(limit * 2).ToList();
|
||||
var accounts = await _mapper.Accounts(chunk.Select(c => c.AccountId), token);
|
||||
var hidden = await Hidden.AuthorsHiddenFrom(MyId, accounts.Values.Select(a => a.Uri), forNotifications: false, token);
|
||||
foreach (var candidate in chunk)
|
||||
{
|
||||
if (accounts.GetValueOrDefault(candidate.AccountId) is not { } account || account.Limited == true || hidden.Contains(account.Uri)
|
||||
|| account.Group && candidate.Source != Suggestions.Staff)
|
||||
continue;
|
||||
suggested.Add(new Suggestion { Source = candidate.Source, Sources = candidate.Sources, Account = account });
|
||||
if (suggested.Count >= limit)
|
||||
break;
|
||||
}
|
||||
}
|
||||
return suggested;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -59,12 +59,19 @@ namespace PrivaPub.Api.Mastodon.Controllers
|
||||
}).ToList();
|
||||
}
|
||||
|
||||
async Task<object> View(string tag, bool? following, CancellationToken token) => new
|
||||
Task<object> View(string tag, bool? following, CancellationToken token, bool? featuring = default) =>
|
||||
TagView(_dbEntities, _localActors.BaseAddress, tag, MyId, following, featuring, token);
|
||||
|
||||
// Mastodon's Tag: its week of use, and for a signed-in persona whether it follows it and (Mastodon 4.4) features it
|
||||
internal static async Task<object> TagView(DbEntities dbEntities, string baseAddress, string tag, string viewerId, bool? following, bool? featuring,
|
||||
CancellationToken token) => new
|
||||
{
|
||||
name = tag,
|
||||
url = $"{_localActors.BaseAddress}/tags/{Uri.EscapeDataString(tag)}",
|
||||
history = await History(_dbEntities, tag, token),
|
||||
following = following ?? (MyId != default && await DB.Default.Find<FollowedTag>().Match(t => t.AvatarId == MyId && t.Name == tag).ExecuteAnyAsync(token))
|
||||
url = $"{baseAddress}/tags/{Uri.EscapeDataString(tag)}",
|
||||
history = await History(dbEntities, tag, token),
|
||||
following = following ?? (viewerId != default && await DB.Default.Find<FollowedTag>().Match(t => t.AvatarId == viewerId && t.Name == tag).ExecuteAnyAsync(token)),
|
||||
featuring = viewerId == default ? default(bool?)
|
||||
: featuring ?? await DB.Default.Find<FeaturedTag>().Match(t => t.AvatarId == viewerId && t.Name == tag).ExecuteAnyAsync(token)
|
||||
};
|
||||
|
||||
[HttpGet("/api/v1/tags/{name}"), Scope("read", requiresUser: false), Microsoft.AspNetCore.Authorization.AllowAnonymous]
|
||||
@@ -103,6 +110,24 @@ namespace PrivaPub.Api.Mastodon.Controllers
|
||||
return Json(await View(tag, false, token));
|
||||
}
|
||||
|
||||
// Mastodon 4.4: a hashtag featured on the persona's profile, or no longer (R8, Domain/Social/FeaturedTags)
|
||||
[HttpPost("/api/v1/tags/{name}/feature"), Scope("write:accounts")]
|
||||
public async Task<IActionResult> Feature(string name, [FromServices] Domain.Social.IFeaturedTags featured, CancellationToken token)
|
||||
{
|
||||
var (tag, error) = await featured.Feature(Me, name, token);
|
||||
return tag == default ? Error(StatusCodes.Status422UnprocessableEntity, error) : Json(await View(tag.Name, default, token, featuring: true));
|
||||
}
|
||||
|
||||
[HttpPost("/api/v1/tags/{name}/unfeature"), Scope("write:accounts")]
|
||||
public async Task<IActionResult> Unfeature(string name, [FromServices] Domain.Social.IFeaturedTags featured, CancellationToken token)
|
||||
{
|
||||
var tag = Normalise(name);
|
||||
if (!IsHashtag(tag))
|
||||
return NotFoundError();
|
||||
await featured.Unfeature(Me, default, tag, token);
|
||||
return Json(await View(tag, default, token, featuring: false));
|
||||
}
|
||||
|
||||
[HttpGet("/api/v1/followed_tags"), Scope("read:follows")]
|
||||
public async Task<IActionResult> Followed(CancellationToken token)
|
||||
{
|
||||
|
||||
@@ -123,35 +123,39 @@ namespace PrivaPub.Api.Mastodon.Controllers
|
||||
return Json(await ConversationsOf(groups, token));
|
||||
}
|
||||
|
||||
async Task<List<Conversation>> ConversationsOf(List<DmGroup> groups, CancellationToken token)
|
||||
Task<List<Conversation>> ConversationsOf(List<DmGroup> groups, CancellationToken token) => ConversationsOf(_mapper, _dbEntities, MyId, groups, token);
|
||||
|
||||
// (also the `direct` stream's conversation event, StreamingController)
|
||||
internal static async Task<List<Conversation>> ConversationsOf(MastodonMapper mapper, DbEntities dbEntities, string viewerId, List<DmGroup> groups,
|
||||
CancellationToken token)
|
||||
{
|
||||
var ids = groups.Select(g => g.ID).ToList();
|
||||
var states = (await DB.Default.Find<ConversationState>().Match(s => s.AvatarId == MyId && ids.Contains(s.ConversationId)).ExecuteAsync(token))
|
||||
var states = (await DB.Default.Find<ConversationState>().Match(s => s.AvatarId == viewerId && ids.Contains(s.ConversationId)).ExecuteAsync(token))
|
||||
.ToDictionary(s => s.ConversationId);
|
||||
groups = groups.Where(g => !ConversationStates.IsHidden(states.GetValueOrDefault(g.ID), g.LastPostId)).ToList();
|
||||
|
||||
// the newest post each conversation still shows: usually its LastPostId, else (deleted) the one before
|
||||
var newestIds = groups.Select(g => g.LastPostId).ToList();
|
||||
var newest = (await _dbEntities.Posts.Match(p => newestIds.Contains(p.ID)).Match(VisibilityPolicy.IsShown).ExecuteAsync(token))
|
||||
var newest = (await dbEntities.Posts.Match(p => newestIds.Contains(p.ID)).Match(VisibilityPolicy.IsShown).ExecuteAsync(token))
|
||||
.ToDictionary(p => p.ConversationId);
|
||||
foreach (var group in groups.Where(g => !newest.ContainsKey(g.ID)))
|
||||
if (await _dbEntities.Posts.Match(p => p.ConversationId == group.ID).Match(VisibilityPolicy.IsShown).Sort(p => p.ID, Order.Descending)
|
||||
if (await dbEntities.Posts.Match(p => p.ConversationId == group.ID).Match(VisibilityPolicy.IsShown).Sort(p => p.ID, Order.Descending)
|
||||
.ExecuteFirstAsync(token) is { } shown)
|
||||
newest[group.ID] = shown;
|
||||
|
||||
var foreignUris = groups.SelectMany(g => g.Members).Where(m => m.IsForeign).Select(m => m.AvatarId).Distinct().ToList();
|
||||
var foreignIds = (await _dbEntities.ForeignAvatars.Match(f => foreignUris.Contains(f.ActorURI)).ExecuteAsync(token))
|
||||
var foreignIds = (await dbEntities.ForeignAvatars.Match(f => foreignUris.Contains(f.ActorURI)).ExecuteAsync(token))
|
||||
.GroupBy(f => f.ActorURI).ToDictionary(g => g.Key, g => g.First().ID);
|
||||
List<string> Others(DmGroup group) => group.Members.Where(m => !(m.AvatarId == MyId && !m.IsForeign))
|
||||
List<string> Others(DmGroup group) => group.Members.Where(m => !(m.AvatarId == viewerId && !m.IsForeign))
|
||||
.Select(m => m.IsForeign ? foreignIds.GetValueOrDefault(m.AvatarId) : m.AvatarId).Where(id => id != default).ToList();
|
||||
var accounts = await _mapper.Accounts(groups.SelectMany(Others).Distinct(), token);
|
||||
var statuses = (await _mapper.Statuses(newest.Values.ToList(), MyId, token)).ToDictionary(s => s.Id);
|
||||
var accounts = await mapper.Accounts(groups.SelectMany(Others).Distinct(), token);
|
||||
var statuses = (await mapper.Statuses(newest.Values.ToList(), viewerId, token)).ToDictionary(s => s.Id);
|
||||
|
||||
return groups.Where(g => newest.ContainsKey(g.ID) && statuses.ContainsKey(newest[g.ID].ID))
|
||||
.Select(g => new Conversation
|
||||
{
|
||||
Id = g.ID,
|
||||
Unread = ConversationStates.IsUnread(states.GetValueOrDefault(g.ID), newest[g.ID].ID, newest[g.ID].AuthorAccountId, MyId),
|
||||
Unread = ConversationStates.IsUnread(states.GetValueOrDefault(g.ID), newest[g.ID].ID, newest[g.ID].AuthorAccountId, viewerId),
|
||||
Accounts = Others(g).Select(id => accounts.GetValueOrDefault(id)).Where(a => a != default).ToList(),
|
||||
LastStatus = statuses[newest[g.ID].ID]
|
||||
})
|
||||
@@ -240,7 +244,8 @@ namespace PrivaPub.Api.Mastodon.Controllers
|
||||
[NotificationType.Poll] = "poll",
|
||||
[NotificationType.Reaction] = "pleroma:emoji_reaction",
|
||||
[NotificationType.Quote] = "quote",
|
||||
[NotificationType.ModerationWarning] = "moderation_warning"
|
||||
[NotificationType.ModerationWarning] = "moderation_warning",
|
||||
[NotificationType.Status] = "status"//an account followed with notify posted (R8)
|
||||
};
|
||||
|
||||
readonly MastodonMapper _mapper;
|
||||
|
||||
Reference in new issue
Block a user