Files
SocialPub/PrivaPub/Services/PostsService.cs
T
thepraandClaude Opus 5.5 9f68ca3ed0 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
2026-10-17 05:23:45 +02:00

289 lines
11 KiB
C#

using Microsoft.Extensions.Localization;
using MongoDB.Driver;
using MongoDB.Entities;
using PrivaPub.ClientModels;
using PrivaPub.ClientModels.Post;
using PrivaPub.Models.Federation;
using PrivaPub.Models.Group;
using PrivaPub.Models.Post;
using PrivaPub.Models.Social;
using PrivaPub.Resources;
using PrivaPub.StaticServices;
using System.Text.Json.Nodes;
using PostEntity = PrivaPub.Models.Post.Post;
using PrivaPub.Domain.Privacy;
using PrivaPub.Domain.Statuses;
using PrivaPub.Domain.Timelines;
using PrivaPub.Federation.Actors;
using PrivaPub.Federation.Rendering;
using PrivaPub.Federation.Outbox;
namespace PrivaPub.Services
{
public interface IPostsService
{
Task<WebResult> InsertPost(string rootUserId, InsertPostForm form, CancellationToken token);
Task<WebResult> DeletePost(string rootUserId, DeletePostForm form, CancellationToken token);
Task<WebResult> UpdatePost(string rootUserId, UpdatePostForm form, CancellationToken token);
Task<WebResult> Nearby(string rootUserId, string avatarId, double latitude, double longitude, CancellationToken token);
Task<WebResult> GetPosts(string rootUserId, string avatarId, string groupId, CancellationToken token);
Task<WebResult> InsertDm(string rootUserId, InsertDmForm form, CancellationToken token);
Task<WebResult> GetDms(string rootUserId, string avatarId, string dmGroupId, CancellationToken token);
}
public class PostsService : IPostsService
{
const int PageSize = 50;
readonly DbEntities _dbEntities;
readonly ILocalActorService _localActors;
readonly IStatusService _statuses;
readonly IStringLocalizer<GenericRes> _localizer;
readonly ILogger<PostsService> _logger;
public PostsService(DbEntities dbEntities,
ILocalActorService localActors,
IStatusService statuses,
IStringLocalizer<GenericRes> localizer,
ILogger<PostsService> logger)
{
_dbEntities = dbEntities;
_localActors = localActors;
_statuses = statuses;
_localizer = localizer;
_logger = logger;
}
public async Task<WebResult> InsertPost(string rootUserId, InsertPostForm form, CancellationToken token)
{
var author = await OwnedAvatar(rootUserId, form.AvatarId, token);
if (author == default)
return new WebResult().Invalidate(_localizer["Avatar not found."], StatusCodes.Status404NotFound);
return await Answer(() => _statuses.Publish(author, new StatusDraft
{
Text = form.Text,
Title = form.Title,
SpoilerText = form.SpoilerText,
Sensitive = form.HasContentWarning,
Visibility = LocalVisibility(form.Visibility),
InReplyTo = form.AnsweringToPostId,
GroupId = form.GroupId,
Latitude = form.Latitude,
Longitude = form.Longitude,
RangeKm = form.RangeKm
}, token), nameof(InsertPost));
}
public async Task<WebResult> DeletePost(string rootUserId, DeletePostForm form, CancellationToken token)
{
var author = await OwnedAvatar(rootUserId, form.AvatarId, token);
if (author == default)
return new WebResult().Invalidate(_localizer["Avatar not found."], StatusCodes.Status404NotFound);
var result = await Answer(() => _statuses.Remove(author, form.PostId, token), nameof(DeletePost));
result.Data = default;
return result;
}
public async Task<WebResult> UpdatePost(string rootUserId, UpdatePostForm form, CancellationToken token)
{
var author = await OwnedAvatar(rootUserId, form.AvatarId, token);
if (author == default)
return new WebResult().Invalidate(_localizer["Avatar not found."], StatusCodes.Status404NotFound);
return await Answer(() => _statuses.Edit(author, form.PostId, new StatusDraft
{
Text = form.Text,
Title = form.Title,
SpoilerText = form.SpoilerText,
Sensitive = form.HasContentWarning
}, token), nameof(UpdatePost));
}
public async Task<WebResult> GetPosts(string rootUserId, string avatarId, string groupId, CancellationToken token)
{
var result = new WebResult();
try
{
var avatar = await OwnedAvatar(rootUserId, avatarId, token);
if (avatar == default)
return result.Invalidate(_localizer["Avatar not found."], StatusCodes.Status404NotFound);
var query = _dbEntities.Posts;
if (string.IsNullOrEmpty(groupId))
query.Match(p => p.GroupUserId == avatar.Id && p.Visibility != PostVisibility.Direct);
else
{
var isMember = await _dbEntities.Groups
.Match(g => g.ID == groupId && g.Members.Any(m => !m.IsForeign && m.AvatarId == avatar.Id))
.ExecuteAnyAsync(token);
if (!isMember)
return result.Invalidate(_localizer["Group not found."], StatusCodes.Status404NotFound);
query.Match(p => p.GroupId == groupId);
}
var posts = await query.Match(VisibilityPolicy.IsShown).Sort(p => p.CreationDate, Order.Descending).Limit(PageSize).ExecuteAsync(token);
result.Data = posts.Where(p => !VisibilityPolicy.Barred(p)).Select(ToView).ToList();
return result;
}
catch (Exception ex)
{
_logger.LogError(ex, $"{nameof(PostsService)}.{nameof(GetPosts)}");
return result.Invalidate(_localizer["Something went wrong."], exception: ex);
}
}
public async Task<WebResult> InsertDm(string rootUserId, InsertDmForm form, CancellationToken token)
{
var author = await OwnedAvatar(rootUserId, form.AvatarId, token);
if (author == default)
return new WebResult().Invalidate(_localizer["Avatar not found."], StatusCodes.Status404NotFound);
if (string.IsNullOrEmpty(form.DmGroupId) && form.Recipients.Count == 0)
return new WebResult().Invalidate(_localizer["At least one recipient is required."]);
return await Answer(() => _statuses.Publish(author, new StatusDraft
{
Text = form.Text,
Sensitive = form.HasContentWarning,
Visibility = PostVisibility.Direct,
ConversationId = form.DmGroupId,
Recipients = form.Recipients
}, token), nameof(InsertDm));
}
public async Task<WebResult> GetDms(string rootUserId, string avatarId, string dmGroupId, CancellationToken token)
{
var result = new WebResult();
try
{
var avatar = await OwnedAvatar(rootUserId, avatarId, token);
if (avatar == default)
return result.Invalidate(_localizer["Avatar not found."], StatusCodes.Status404NotFound);
if (string.IsNullOrEmpty(dmGroupId))
{
var groups = await _dbEntities.DmGroups
.Match(g => !g.DeletionAt.HasValue && g.Members.Any(m => !m.IsForeign && m.AvatarId == avatar.Id))
.Sort(g => g.UpdatedAt, Order.Descending)
.ExecuteAsync(token);
result.Data = groups.Select(g => new ViewDmGroup
{
Id = g.ID,
Members = g.Members.Select(m => m.AvatarId).ToList(),
UpdatedAt = g.UpdatedAt
}).ToList();
return result;
}
var isMember = await _dbEntities.DmGroups
.Match(g => g.ID == dmGroupId && g.Members.Any(m => !m.IsForeign && m.AvatarId == avatar.Id))
.ExecuteAnyAsync(token);
if (!isMember)
return result.Invalidate(_localizer["Conversation not found."], StatusCodes.Status404NotFound);
var dms = await _dbEntities.Posts
.Match(p => p.ConversationId == dmGroupId && p.Visibility == PostVisibility.Direct)
.Match(VisibilityPolicy.IsShown)
.Sort(p => p.CreationDate, Order.Descending)
.Limit(PageSize)
.ExecuteAsync(token);
result.Data = dms.Where(p => !VisibilityPolicy.Barred(p)).Select(ToView).ToList();
return result;
}
catch (Exception ex)
{
_logger.LogError(ex, $"{nameof(PostsService)}.{nameof(GetDms)}");
return result.Invalidate(_localizer["Something went wrong."], exception: ex);
}
}
public async Task<WebResult> Nearby(string rootUserId, string avatarId, double latitude, double longitude, CancellationToken token)
{
var result = new WebResult();
var viewer = await OwnedAvatar(rootUserId, avatarId, token);
if (viewer == default)
return result.Invalidate(_localizer["Avatar not found."], StatusCodes.Status404NotFound);
if (latitude is < -90 or > 90 || longitude is < -180 or > 180)
return result.Invalidate(_localizer["Invalid model."]);
var query = new MongoDB.Bson.BsonDocument
{
["Visibility"] = (int)PostVisibility.LocalGeo,
["DeletedAt"] = MongoDB.Bson.BsonNull.Value
};
var candidates = await DB.Default.GeoNear<PostEntity>(new Coordinates2D(longitude, latitude), p => p.Distance, true, 50_000, default, default, query)
.Limit(200)
.ToListAsync(token);
var near = candidates.Where(p => p.Distance <= p.RangeKm * 1000).ToList();
var hidden = await Domain.Relationships.Hidden.AuthorsHiddenFrom(viewer.Id, near.Select(p => p.ActorURI), forNotifications: false, token);
result.Data = near.Where(p => !hidden.Contains(p.ActorURI)).Select(p =>
{
var view = ToView(p);
view.RangeKm = p.RangeKm;
view.DistanceKm = Math.Round(p.Distance.Value / 1000, 1);
return view;
}).ToList();
return result;
}
async Task<WebResult> Answer(Func<Task<StatusOutcome>> action, string operation)
{
var result = new WebResult();
try
{
var outcome = await action();
if (!outcome.Ok)
return result.Invalidate(_localizer[outcome.Error], outcome.Status);
result.Data = ToView(outcome.Post);
return result;
}
catch (Exception ex)
{
_logger.LogError(ex, "{Service}.{Operation}", nameof(PostsService), operation);
return result.Invalidate(_localizer["Something went wrong."], exception: ex);
}
}
static PostVisibility LocalVisibility(string requested) => requested?.ToLowerInvariant() switch
{
"unlisted" => PostVisibility.Unlisted,
"followersonly" or "private" => PostVisibility.FollowersOnly,
_ => PostVisibility.Public
};
async Task<LocalActor> OwnedAvatar(string rootUserId, string avatarId, CancellationToken token)
{
if (string.IsNullOrEmpty(rootUserId) || string.IsNullOrEmpty(avatarId))
return default;
if (!await _dbEntities.RootToAvatars.Match(ra => ra.RootId == rootUserId && ra.AvatarId == avatarId).ExecuteAnyAsync(token))
return default;
// a suspended persona does nothing through the client API either (R6)
return await _localActors.FindById(LocalActorKind.Person, avatarId, token) is { Suspended: false } persona ? persona : default;
}
static ViewPost ToView(PostEntity post) => new()
{
Id = post.ID,
ObjectURI = post.ObjectURI,
AuthorAvatarId = post.IsFederatedCopy ? default : post.GroupUserId,
AuthorActorURI = post.ActorURI,
GroupId = post.GroupId,
DmGroupId = post.ConversationId,
Visibility = post.Visibility.ToString(),
AnsweringToPostId = post.AnsweringToPostId,
Title = post.Title,
Text = post.Text,
ContentHtml = post.ContentHtml,
HasContentWarning = post.HasContentWarning,
IsFederatedCopy = post.IsFederatedCopy,
CreationDate = post.CreationDate
};
}
}