R6 moderation: roles, silence and suspension, bans that write nothing on the personas, deletions, report actions
Roles (user, moderator, administrator) from the CLI and /clientapi/admin/roots/{id}/role, never one's own nor the last
administrator. Silence keeps an account's posts out of public timelines, hashtags, trends, search and relays while its
followers still get them; suspension refuses and revokes a local persona's tokens, answers 404 for its posts and
collections, holds back every delivery and tells other servers nothing new, its actor unchanged; a suspended remote
account's activities are dropped and nothing goes to its own inbox (a shared one still serves its server). A banned
login's personas are held back in memory alone (Restrictions), so nothing pairs them in stored data. Personas and groups
are deleted one at a time (PersonaRemoval, GroupRemoval), by their login or an administrator with the password. Reports
resolve with an action and a note; posts are taken down, a remote copy hidden. Warnings reach the persona as Mastodon's
moderation_warning. Every staff act goes to the staff's log. Migration _020 limits posts from servers already silenced;
a restore keeps the moderation made since the backup.
Verified: interop.sh moderation gts mastodon with SecureMode, 194 passed; restore.sh, 25 passed, now with a suspension,
a silence and a takedown made after the backup.
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
70d1ffe9a2
commit
9f68ca3ed0
71 files changed
+2229
-219
No files matched your search
@@ -82,7 +82,8 @@ namespace PrivaPub.Api.Mastodon.Auth
|
||||
var link = await _dbEntities.RootToAvatars.Match(r => r.AvatarId == avatarId).ExecuteFirstAsync(token);
|
||||
var root = link == default ? default : await _dbEntities.RootUsers.MatchID(link.RootId).ExecuteFirstAsync(token);
|
||||
var avatar = await _dbEntities.Avatars.MatchID(avatarId).ExecuteFirstAsync(token);
|
||||
return root is { IsBanned: false, DeletedAt: null } && avatar is { DeletionAt: null };
|
||||
// a suspended persona gets no token (R6), as its tokens were revoked when it was suspended
|
||||
return root is { IsBanned: false, DeletedAt: null } && avatar is { DeletionAt: null, SuspendedAt: null };
|
||||
}
|
||||
|
||||
ForbidResult Refuse(string error, string description) => Forbid(new AuthenticationProperties(new Dictionary<string, string>
|
||||
|
||||
@@ -158,11 +158,12 @@ namespace PrivaPub.Api.Mastodon.Controllers
|
||||
if (parts.Length == 1 || parts[1].Equals(localDomain, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var local = await _localActors.FindByUserName(parts[0], token);
|
||||
return local is { IsFederated: true, IsCircle: false, IsServerActor: false } ? Json(await _mapper.Local(local, false, token)) : NotFoundError();
|
||||
return local is { IsFederated: true, IsCircle: false, IsServerActor: false, Suspended: false } ? Json(await _mapper.Local(local, false, token)) : NotFoundError();
|
||||
}
|
||||
var userName = parts[0];
|
||||
var domain = parts[1].ToLowerInvariant();
|
||||
var foreign = await _dbEntities.ForeignAvatars.Match(f => f.UserName == userName && f.Domain == domain && !f.DeletionAt.HasValue).ExecuteFirstAsync(token);
|
||||
var foreign = await _dbEntities.ForeignAvatars.Match(f => f.UserName == userName && f.Domain == domain && !f.DeletionAt.HasValue && !f.SuspendedAt.HasValue)
|
||||
.ExecuteFirstAsync(token);
|
||||
return foreign == default ? NotFoundError() : Json(_mapper.Foreign(foreign));
|
||||
}
|
||||
|
||||
@@ -203,7 +204,8 @@ namespace PrivaPub.Api.Mastodon.Controllers
|
||||
public async Task<IActionResult> Statuses(string id, CancellationToken token)
|
||||
{
|
||||
var (local, remote) = await Find(id, token);
|
||||
if (local == default && remote == default)
|
||||
// a suspended account's posts answer 404, as its account does (R6)
|
||||
if (local == default && remote == default || local is { Suspended: true } || remote is { SuspendedAt: not null })
|
||||
return NotFoundError();
|
||||
if (Params.Bool("pinned") == true)
|
||||
{
|
||||
@@ -292,7 +294,8 @@ namespace PrivaPub.Api.Mastodon.Controllers
|
||||
public async Task<IActionResult> Follow(string id, CancellationToken token)
|
||||
{
|
||||
var (local, remote) = await Find(id, token);
|
||||
if (local == default && remote == default)
|
||||
// nobody follows a suspended account (R6), here or elsewhere: it is not shown
|
||||
if (local == default && remote == default || local is { Suspended: true } || remote is { SuspendedAt: not null })
|
||||
return NotFoundError();
|
||||
// a feed is read through the server, whose reader follows its communities; the persona follows nothing
|
||||
if (remote is { AvatarType: AvatarType.Feed })
|
||||
|
||||
@@ -65,7 +65,7 @@ namespace PrivaPub.Api.Mastodon.Controllers
|
||||
if (string.IsNullOrEmpty(id))
|
||||
return default;
|
||||
var avatar = await DB.Default.Find<Avatar>().Match(a => a.ID == id && !a.DeletionAt.HasValue).ExecuteFirstAsync(token);
|
||||
if (avatar == default || (await Counted.BarredPersonas(token)).Contains(id))
|
||||
if (avatar == default || (await Counted.HeldBackPersonas(token)).Contains(id))
|
||||
return default;
|
||||
return await _mapper.Local(_localActors.FromAvatar(avatar), false, token);
|
||||
}
|
||||
|
||||
@@ -93,7 +93,7 @@ namespace PrivaPub.Api.Mastodon.Controllers
|
||||
// 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)
|
||||
var candidates = await _dbEntities.Posts.Match(f => f.Text(q)).Match(p => p.ReblogOfPostId == null).Match(VisibilityPolicy.IsShown)
|
||||
.Sort(p => p.ID, MongoDB.Entities.Order.Descending).Limit(WordCandidates).ExecuteAsync(token);
|
||||
if (candidates.Count == 0)
|
||||
return new List<Status>();
|
||||
@@ -128,7 +128,7 @@ namespace PrivaPub.Api.Mastodon.Controllers
|
||||
{
|
||||
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);
|
||||
|| post.Visibility == PostVisibility.Public && !post.AuthorLimited && Searchable(post, author);//a silenced author's only to who met it (R6)
|
||||
if (reachable && await VisibilityPolicy.CanSee(post, MyId, token))
|
||||
found.Add(post);
|
||||
if (found.Count >= offset + limit)
|
||||
|
||||
@@ -159,6 +159,8 @@ namespace PrivaPub.Api.Mastodon.Controllers
|
||||
continue;
|
||||
await Send(new JsonObject { ["stream"] = new JsonArray(key.Wire.Select(w => (JsonNode)w).ToArray()), ["event"] = e.Name, ["payload"] = payload });
|
||||
}
|
||||
// the queue ends only when the stream was closed from here (the persona was suspended): the socket goes too
|
||||
done.Cancel();
|
||||
await reading;
|
||||
}
|
||||
catch (Exception ex) when (ex is OperationCanceledException or WebSocketException)
|
||||
@@ -202,20 +204,23 @@ namespace PrivaPub.Api.Mastodon.Controllers
|
||||
try
|
||||
{
|
||||
await Write(":)\n\n");
|
||||
using var done = CancellationTokenSource.CreateLinkedTokenSource(token);
|
||||
var beating = Task.Run(async () =>
|
||||
{
|
||||
while (!token.IsCancellationRequested)
|
||||
while (!done.IsCancellationRequested)
|
||||
{
|
||||
await Task.Delay(KeepAlive, token);
|
||||
await Task.Delay(KeepAlive, done.Token);
|
||||
await Write(":thump\n\n");
|
||||
}
|
||||
}, token);
|
||||
}, done.Token);
|
||||
await foreach (var (_, e) in listener.Queue.Reader.ReadAllAsync(token))
|
||||
{
|
||||
if (await Payload(e, token) is not { } payload)
|
||||
continue;
|
||||
await Write($"event: {e.Name}\ndata: {payload}\n\n");
|
||||
}
|
||||
// closed from here (the persona was suspended): the response ends
|
||||
done.Cancel();
|
||||
await beating;
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
|
||||
@@ -44,6 +44,7 @@ namespace PrivaPub.Api.Mastodon.Controllers
|
||||
var since = today.AddDays(-6);
|
||||
var posts = await dbEntities.Posts
|
||||
.Match(p => p.Tags.Contains(tag) && p.Visibility == PostVisibility.Public && !p.DeletedAt.HasValue && p.CreationDate >= since)
|
||||
.Match(PrivaPub.Domain.Privacy.VisibilityPolicy.IsPublic).Match(PrivaPub.Domain.Privacy.VisibilityPolicy.IsListed)
|
||||
.Project(p => p.Include(x => x.CreationDate).Include(x => x.AuthorAccountId).Include(x => x.GroupUserId))
|
||||
.ExecuteAsync(token);
|
||||
return Enumerable.Range(0, 7).Select(back => today.AddDays(-back)).Select(day =>
|
||||
|
||||
@@ -54,7 +54,8 @@ namespace PrivaPub.Api.Mastodon.Controllers
|
||||
[HttpGet("/api/v1/timelines/public"), Scope("read:statuses", requiresUser: false), Microsoft.AspNetCore.Authorization.AllowAnonymous]
|
||||
public async Task<IActionResult> Public(CancellationToken token)
|
||||
{
|
||||
var query = _dbEntities.Posts.Match(p => p.Visibility == PostVisibility.Public && p.ReblogOfPostId == null).Match(VisibilityPolicy.IsPublic);
|
||||
var query = _dbEntities.Posts.Match(p => p.Visibility == PostVisibility.Public && p.ReblogOfPostId == null).Match(VisibilityPolicy.IsPublic)
|
||||
.Match(VisibilityPolicy.IsListed);
|
||||
if (Params.Bool("local") == true)
|
||||
query.Match(p => !p.IsFederatedCopy);
|
||||
if (Params.Bool("remote") == true)
|
||||
@@ -88,7 +89,8 @@ namespace PrivaPub.Api.Mastodon.Controllers
|
||||
public async Task<IActionResult> Tag(string hashtag, CancellationToken token)
|
||||
{
|
||||
var tag = hashtag.TrimStart('#').ToLowerInvariant();
|
||||
var query = _dbEntities.Posts.Match(p => p.Tags.Contains(tag) && p.Visibility == PostVisibility.Public && p.ReblogOfPostId == null).Match(VisibilityPolicy.IsPublic);
|
||||
var query = _dbEntities.Posts.Match(p => p.Tags.Contains(tag) && p.Visibility == PostVisibility.Public && p.ReblogOfPostId == null).Match(VisibilityPolicy.IsPublic)
|
||||
.Match(VisibilityPolicy.IsListed);
|
||||
if (Params.Bool("local") == true)
|
||||
query.Match(p => !p.IsFederatedCopy);
|
||||
return await Respond(query, $"/api/v1/timelines/tag/{hashtag}", token);
|
||||
|
||||
@@ -90,30 +90,30 @@ namespace PrivaPub.Api.Mastodon.Controllers
|
||||
var offset = Math.Max(0, Params.Int("offset") ?? 0);
|
||||
var limit = Limit(40, 80);
|
||||
var localOnly = Params.Bool("local") == true;
|
||||
var barred = await Counted.BarredPersonas(token);
|
||||
var barred = await Counted.HeldBackPersonas(token);
|
||||
var wanted = offset + limit;
|
||||
var ordered = new List<string>();
|
||||
if (Params.Get("order") == "new")
|
||||
{
|
||||
// many share a day: the first `wanted` are read with every other of the last one's day, so each page is cut the same way
|
||||
var personas = await _dbEntities.Avatars.Match(a => a.Settings.IsDiscoverable && !a.DeletionAt.HasValue && !barred.Contains(a.ID))
|
||||
var personas = await _dbEntities.Avatars.Match(a => a.Settings.IsDiscoverable && !a.DeletionAt.HasValue && !barred.Contains(a.ID) && !a.SilencedAt.HasValue)
|
||||
.Sort(a => a.PublishedOn, Order.Descending).Limit(wanted).ExecuteAsync(token);
|
||||
if (personas.Count == wanted)
|
||||
{
|
||||
var edge = personas[^1].PublishedOn;
|
||||
var known = personas.Select(a => a.ID).ToList();
|
||||
personas.AddRange(await _dbEntities.Avatars.Match(a => a.Settings.IsDiscoverable && !a.DeletionAt.HasValue && !barred.Contains(a.ID)
|
||||
personas.AddRange(await _dbEntities.Avatars.Match(a => a.Settings.IsDiscoverable && !a.DeletionAt.HasValue && !barred.Contains(a.ID) && !a.SilencedAt.HasValue
|
||||
&& a.PublishedOn == edge && !known.Contains(a.ID)).ExecuteAsync(token));
|
||||
}
|
||||
var remote = localOnly
|
||||
? new List<ForeignAvatar>()
|
||||
: await _dbEntities.ForeignAvatars.Match(f => f.IsDiscoverable && !f.DeletionAt.HasValue && !f.SuspendedAt.HasValue && f.Published != null)
|
||||
: await _dbEntities.ForeignAvatars.Match(f => f.IsDiscoverable && !f.DeletionAt.HasValue && !f.SuspendedAt.HasValue && !f.SilencedAt.HasValue && f.Published != null)
|
||||
.Sort(f => f.Published, Order.Descending).Limit(wanted).ExecuteAsync(token);
|
||||
if (remote.Count == wanted)
|
||||
{
|
||||
var edge = remote[^1].Published;
|
||||
var known = remote.Select(f => f.ID).ToList();
|
||||
remote.AddRange(await _dbEntities.ForeignAvatars.Match(f => f.IsDiscoverable && !f.DeletionAt.HasValue && !f.SuspendedAt.HasValue
|
||||
remote.AddRange(await _dbEntities.ForeignAvatars.Match(f => f.IsDiscoverable && !f.DeletionAt.HasValue && !f.SuspendedAt.HasValue && !f.SilencedAt.HasValue
|
||||
&& f.Published == edge && !known.Contains(f.ID)).ExecuteAsync(token));
|
||||
}
|
||||
ordered = personas.Select(a => (a.ID, Joined: a.PublishedOn, Tie: Tie(a.UserName)))
|
||||
@@ -123,7 +123,7 @@ namespace PrivaPub.Api.Mastodon.Controllers
|
||||
}
|
||||
else
|
||||
{
|
||||
var discoverableLocal = (await _dbEntities.Avatars.Match(a => a.Settings.IsDiscoverable && !a.DeletionAt.HasValue && !barred.Contains(a.ID))
|
||||
var discoverableLocal = (await _dbEntities.Avatars.Match(a => a.Settings.IsDiscoverable && !a.DeletionAt.HasValue && !barred.Contains(a.ID) && !a.SilencedAt.HasValue)
|
||||
.Project(a => a.Include(x => x.ID)).ExecuteAsync(token)).Select(a => a.ID).ToHashSet();
|
||||
var seen = new HashSet<string>();
|
||||
var maxId = default(string);
|
||||
@@ -144,7 +144,7 @@ namespace PrivaPub.Api.Mastodon.Controllers
|
||||
var remoteIds = fresh.Where(a => a.IsFederatedCopy).Select(a => a.Id).ToList();
|
||||
var discoverableRemote = remoteIds.Count == 0
|
||||
? new HashSet<string>()
|
||||
: (await _dbEntities.ForeignAvatars.Match(f => remoteIds.Contains(f.ID) && f.IsDiscoverable && !f.DeletionAt.HasValue && !f.SuspendedAt.HasValue)
|
||||
: (await _dbEntities.ForeignAvatars.Match(f => remoteIds.Contains(f.ID) && f.IsDiscoverable && !f.DeletionAt.HasValue && !f.SuspendedAt.HasValue && !f.SilencedAt.HasValue)
|
||||
.Project(f => f.Include(x => x.ID)).ExecuteAsync(token)).Select(f => f.ID).ToHashSet();
|
||||
ordered.AddRange(fresh.Where(a => a.IsFederatedCopy ? discoverableRemote.Contains(a.Id) : discoverableLocal.Contains(a.Id)).Select(a => a.Id));
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
public List<CustomEmojiEntity> Emojis { get; set; } = new();
|
||||
public List<object> Roles { get; set; } = new();
|
||||
public bool? Memorial { get; set; }
|
||||
public bool? Limited { get; set; }//silenced by this server's staff, or its server silenced (Mastodon's `limited`)
|
||||
public string AvatarDescription { get; set; } = string.Empty;
|
||||
public string HeaderDescription { get; set; } = string.Empty;
|
||||
public List<Field> Fields { get; set; } = new();
|
||||
|
||||
@@ -53,7 +53,15 @@ namespace PrivaPub.Api.Mastodon.Infrastructure
|
||||
context.Result = Error(StatusCodes.Status403Forbidden, "This action is outside the authorized scopes");
|
||||
return;
|
||||
}
|
||||
Me = await Resolve(User.GetClaim(Claims.Subject), HttpContext.RequestAborted);
|
||||
var subject = User.GetClaim(Claims.Subject);
|
||||
Me = await Resolve(subject, HttpContext.RequestAborted);
|
||||
// a persona's token whose persona can no longer act (suspended, its login banned or deleted) is refused
|
||||
// everywhere, as Mastodon refuses a revoked token; an application's own token has no persona
|
||||
if (Me == default && !string.IsNullOrEmpty(subject) && !subject.StartsWith("app:", StringComparison.Ordinal))
|
||||
{
|
||||
context.Result = Error(StatusCodes.Status401Unauthorized, "The access token is invalid");
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (scope?.RequiresUser == true && Me == default)
|
||||
{
|
||||
@@ -75,7 +83,10 @@ namespace PrivaPub.Api.Mastodon.Infrastructure
|
||||
var root = await db.RootUsers.MatchID(link.RootId).ExecuteFirstAsync(token);
|
||||
if (root is not { IsBanned: false, DeletedAt: null })
|
||||
return default;
|
||||
return await services.GetRequiredService<ILocalActorService>().FindById(LocalActorKind.Person, avatarId, token);
|
||||
var avatar = await db.Avatars.MatchID(avatarId).ExecuteFirstAsync(token);
|
||||
if (avatar is not { DeletionAt: null, SuspendedAt: null })
|
||||
return default;
|
||||
return services.GetRequiredService<ILocalActorService>().FromAvatar(avatar) is { Suspended: false } me ? me : default;
|
||||
}
|
||||
|
||||
protected JsonResult Json(object value, int status = StatusCodes.Status200OK) =>
|
||||
|
||||
@@ -64,22 +64,25 @@ namespace PrivaPub.Api.Mastodon.Mappers
|
||||
var actorUri = await _remoteActors.ResolveHandle(q, token);
|
||||
known = actorUri == default ? default : await _remoteActors.GetActor(actorUri, refresh: false, token);
|
||||
}
|
||||
if (known is { DeletionAt: null } && offset == 0)
|
||||
if (known is { DeletionAt: null, SuspendedAt: null } && offset == 0)
|
||||
results.Add(_mapper.Foreign(known));
|
||||
return results;
|
||||
}
|
||||
|
||||
var prefix = parts[0].ToLowerInvariant();
|
||||
var barred = await Domain.Privacy.Counted.BarredPersonas(token);
|
||||
var barred = await Domain.Privacy.Counted.HeldBackPersonas(token);
|
||||
var locals = await _dbEntities.Avatars
|
||||
.Match(a => a.UserName.StartsWith(prefix) && !a.DeletionAt.HasValue && a.Settings.IsDiscoverable != false && !barred.Contains(a.ID))
|
||||
// a silenced persona is found only by its exact name (R6), as its followers look for it
|
||||
.Match(a => a.UserName.StartsWith(prefix) && !a.DeletionAt.HasValue && a.Settings.IsDiscoverable != false && !barred.Contains(a.ID)
|
||||
&& (!a.SilencedAt.HasValue || a.UserName == prefix))
|
||||
.Sort(a => a.UserName, MongoDB.Entities.Order.Ascending)
|
||||
.Limit(offset + limit).ExecuteAsync(token);
|
||||
foreach (var avatar in locals.Skip(offset))
|
||||
results.Add(await _mapper.Local(_localActors.FromAvatar(avatar), false, token));
|
||||
var skipRemote = Math.Max(0, offset - locals.Count);
|
||||
if (results.Count < limit)
|
||||
foreach (var foreign in await _dbEntities.ForeignAvatars.Match(f => f.UserName.StartsWith(parts[0]) && f.IsDiscoverable && !f.DeletionAt.HasValue)
|
||||
foreach (var foreign in await _dbEntities.ForeignAvatars.Match(f => f.UserName.StartsWith(parts[0]) && f.IsDiscoverable && !f.DeletionAt.HasValue
|
||||
&& !f.SuspendedAt.HasValue && !f.SilencedAt.HasValue)
|
||||
.Sort(f => f.UserName, MongoDB.Entities.Order.Ascending)
|
||||
.Skip(skipRemote).Limit(limit - results.Count).ExecuteAsync(token))
|
||||
results.Add(_mapper.Foreign(foreign));
|
||||
|
||||
@@ -28,12 +28,15 @@ namespace PrivaPub.Api.Mastodon.Mappers
|
||||
readonly DbEntities _dbEntities;
|
||||
readonly ILocalActorService _localActors;
|
||||
readonly IMediaProxy _proxy;
|
||||
readonly PrivaPub.Federation.Moderation.IDomainBlocks _domainBlocks;
|
||||
|
||||
public MastodonMapper(DbEntities dbEntities, ILocalActorService localActors, IMediaProxy proxy = default)
|
||||
public MastodonMapper(DbEntities dbEntities, ILocalActorService localActors, IMediaProxy proxy = default,
|
||||
PrivaPub.Federation.Moderation.IDomainBlocks domainBlocks = default)
|
||||
{
|
||||
_dbEntities = dbEntities;
|
||||
_localActors = localActors;
|
||||
_proxy = proxy;
|
||||
_domainBlocks = domainBlocks;
|
||||
}
|
||||
|
||||
string Proxied(string url) => _proxy == default ? url : _proxy.Wrap(url);
|
||||
@@ -51,7 +54,9 @@ namespace PrivaPub.Api.Mastodon.Mappers
|
||||
var statuses = (await Statuses(posts, viewerId, token)).ToDictionary(s => s.Id);
|
||||
var warned = notifications.Any(n => n.Type == NotificationType.ModerationWarning) && viewerId != default ? await Account(viewerId, token) : default;
|
||||
return notifications
|
||||
.Where(n => accounts.ContainsKey(n.FromAccountId ?? string.Empty) && (n.PostId == default || statuses.ContainsKey(n.PostId)))
|
||||
// a warning names the posts it is about, which the staff may have removed
|
||||
.Where(n => accounts.ContainsKey(n.FromAccountId ?? string.Empty)
|
||||
&& (n.PostId == default || n.Type == NotificationType.ModerationWarning || statuses.ContainsKey(n.PostId)))
|
||||
.Select(n => new Entities.Notification
|
||||
{
|
||||
Id = n.ID,
|
||||
@@ -66,8 +71,9 @@ namespace PrivaPub.Api.Mastodon.Mappers
|
||||
ModerationWarning = n.Type != NotificationType.ModerationWarning ? default : new AccountWarning
|
||||
{
|
||||
Id = n.ID,
|
||||
Action = n.WarningAction ?? "none",
|
||||
Text = n.Text ?? string.Empty,
|
||||
StatusIds = n.PostId == default ? new List<string>() : new List<string> { n.PostId },
|
||||
StatusIds = n.StatusIds ?? (n.PostId == default ? new List<string>() : new List<string> { n.PostId }),
|
||||
TargetAccount = warned,
|
||||
CreatedAt = MastodonJson.Time(n.CreatedAt)
|
||||
}
|
||||
@@ -104,6 +110,7 @@ namespace PrivaPub.Api.Mastodon.Mappers
|
||||
Noindex = !actor.Indexable,
|
||||
HideCollections = true,//follower and following lists are never shown to others, only their counts
|
||||
Group = actor.Kind == LocalActorKind.Group,
|
||||
Limited = actor.Silenced ? true : default(bool?),//silenced by the staff (R6), as Mastodon says it
|
||||
CreatedAt = MastodonJson.Day(actor.Published),
|
||||
Note = ActivityPubRenderer.Html(actor.Summary),
|
||||
Url = actor.HtmlUrl,
|
||||
@@ -144,6 +151,7 @@ namespace PrivaPub.Api.Mastodon.Mappers
|
||||
Discoverable = foreign.IsDiscoverable,
|
||||
Indexable = foreign.IsIndexable,
|
||||
Memorial = foreign.IsMemorial ? true : default(bool?),
|
||||
Limited = foreign.SilencedAt.HasValue || DomainSilenced(foreign) ? true : default(bool?),
|
||||
CreatedAt = MastodonJson.Day(foreign.Published ?? foreign.CreatedAt),
|
||||
Emojis = Emojis(foreign.Emojis),
|
||||
Fields = foreign.Fields.Select(f => new Field { Name = f.Key, Value = f.Value }).ToList(),
|
||||
@@ -161,6 +169,10 @@ namespace PrivaPub.Api.Mastodon.Mappers
|
||||
StatusesCount = foreign.StatusesCount ?? 0
|
||||
};
|
||||
|
||||
bool DomainSilenced(ForeignAvatar foreign) =>
|
||||
_domainBlocks != default && Uri.TryCreate(foreign.ActorURI, UriKind.Absolute, out var uri)
|
||||
&& _domainBlocks.Find(uri.Host)?.Severity == DomainBlockSeverity.Silence;
|
||||
|
||||
public async Task<Account> Account(string id, CancellationToken token) =>
|
||||
(await Accounts(new[] { id }, token)).GetValueOrDefault(id);
|
||||
|
||||
@@ -171,11 +183,14 @@ namespace PrivaPub.Api.Mastodon.Mappers
|
||||
if (wanted.Count == 0)
|
||||
return accounts;
|
||||
|
||||
foreach (var avatar in await _dbEntities.Avatars.Match(a => wanted.Contains(a.ID) && !a.DeletionAt.HasValue).ExecuteAsync(token))
|
||||
accounts[avatar.ID] = await Local(_localActors.FromAvatar(avatar), false, token);
|
||||
// a suspended account, here or elsewhere, and a banned login's personas are shown to nobody (R6): their posts and
|
||||
// what they notify are left out with them, wherever a list holds them
|
||||
foreach (var avatar in await _dbEntities.Avatars.Match(a => wanted.Contains(a.ID) && !a.DeletionAt.HasValue && !a.SuspendedAt.HasValue).ExecuteAsync(token))
|
||||
if (_localActors.FromAvatar(avatar) is { Suspended: false } persona)
|
||||
accounts[avatar.ID] = await Local(persona, false, token);
|
||||
foreach (var group in await _dbEntities.Groups.Match(g => wanted.Contains(g.ID) && !g.DeletionAt.HasValue && g.Kind == GroupKind.Community).ExecuteAsync(token))
|
||||
accounts[group.ID] = await Local(_localActors.FromGroup(group), false, token);
|
||||
var foreigns = await _dbEntities.ForeignAvatars.Match(f => wanted.Contains(f.ID) && !f.DeletionAt.HasValue).ExecuteAsync(token);
|
||||
var foreigns = await _dbEntities.ForeignAvatars.Match(f => wanted.Contains(f.ID) && !f.DeletionAt.HasValue && !f.SuspendedAt.HasValue).ExecuteAsync(token);
|
||||
foreach (var foreign in foreigns)
|
||||
accounts[foreign.ID] = Foreign(foreign);
|
||||
// an account that moved shows where to, once the new account names it as one of its own (as a Move is believed)
|
||||
|
||||
Reference in new issue
Block a user