Owner decision (2026-10-03, "A remote account deletes itself"): its posts are kept but
hidden everywhere.
- Post.AuthorGone (additive bool). DeleteHandler's actor-delete branch sets it on every
post whose ActorURI is the actor (one update-many), besides dropping its follows and
timeline rows as before. RemotePosts.Build sets it on a post stored later for an
account already marked Deleted.
- One rule in VisibilityPolicy: IsShown (not deleted, author not gone), IsPublic and
CanSee exclude AuthorGone, plus Shown(post) for loaded posts.
- Lookups by id answer 404 through CanSee (statuses/:id and every sub-route, context,
bookmarks, favourites, polls, reactions, search); provenance, account statuses,
home/public/tag timelines, notifications, conversations, reblogged_by, the clientapi
home and post/DM lists, a community's outbox and our Announces filter on IsShown or
IsPublic; the Mastodon mapper never renders a hidden post or a boost of one.
Tests (30 new):
- AuthorGoneTests: the rule, the handler (posts kept, boosts included, follows and rows
gone), a post fetched after the delete, and 20 Mastodon/ActivityPub lookups over HTTP
seen before and hidden after.
- InboxGapTests: actor Update refresh (name, sanitised summary, key rotation in place
and to a new key id) even with an older `updated`; Undo{Follow} by activity id and by
object; Reject of our QuoteRequest (and a stranger's ignored); group-wrapped
Announce{Like} and Announce{Undo{Like}}; a locked persona's pending follow,
FollowRequest notification, and Decide accepting and rejecting with the original Follow.
- JobHandlerTests: AncestorsJobHandler up to its depth limit; PollRefreshJob and
PollCloseJob (local and remote polls); InstanceDescriber from a peer's NodeInfo and
the weekly dedupe through ObjectRecords; LinkPreviews for public posts only;
DeliveryJobHandler outcomes (2xx, 404/410, 429/503 with Retry-After in seconds and as
a date, 5xx) and a signature and Digest the peer can verify; MediaJanitor.Sweep;
OAuthPruner.Prune.
- MigrationTests: _003, _004, _006 and _007 on seeded rows.
- PublicPagesTests: /@user and /@user/{id} (visibility, junk ids, exact CSP,
Referrer-Policy and nosniff), circle 404, community page, the instance actor,
ActivityPub redirects, and markup escaped in posts, titles and bios.
Production changes besides the rule:
- LinkPreviews.Handle re-checks that a post is still shown and public (the rule
Wanted applies) before fetching anything; before, only enqueueing checked it.
- The legacy /clientapi post and DM lists no longer return soft-deleted posts.
- MediaJanitor.Sweep and OAuthPruner.Prune are the loop bodies, now public and tested.
- InstanceDescriber.Address: a protected virtual identity seam so a test can point
the https NodeInfo addresses at a plain-http peer; production behaviour unchanged.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ELjqpznMFMNrJoJUj6K5p2
288 lines
10 KiB
C#
288 lines
10 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.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.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;
|
|
return await _localActors.FindById(LocalActorKind.Person, avatarId, token);
|
|
}
|
|
|
|
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
|
|
};
|
|
}
|
|
}
|