using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Caching.Memory; using MongoDB.Entities; using PrivaPub.Api.Mastodon.Entities; using PrivaPub.Api.Mastodon.Infrastructure; using PrivaPub.Api.Mastodon.Mappers; using PrivaPub.Domain.Privacy; using PrivaPub.Domain.Statuses; using PrivaPub.Models.Post; using PrivaPub.StaticServices; using PostEntity = PrivaPub.Models.Post.Post; namespace PrivaPub.Api.Mastodon.Controllers { public class StatusesController : MastodonController { const int MaxContext = 200; readonly IStatusService _statuses; readonly MastodonMapper _mapper; readonly DbEntities _dbEntities; readonly IMemoryCache _cache; public StatusesController(IStatusService statuses, MastodonMapper mapper, DbEntities dbEntities, IMemoryCache cache) { _statuses = statuses; _mapper = mapper; _dbEntities = dbEntities; _cache = cache; } [HttpPost("/api/v1/statuses"), Scope("write:statuses")] public async Task Create(CancellationToken token) { var idempotency = Request.Headers["Idempotency-Key"].ToString(); var cacheKey = string.IsNullOrEmpty(idempotency) ? default : $"idempotency:{MyId}:{idempotency}"; if (cacheKey != default && _cache.TryGetValue(cacheKey, out string earlier)) { var existing = await _dbEntities.Posts.MatchID(earlier).ExecuteFirstAsync(token); if (existing != default) return Json(await _mapper.Status(existing, MyId, token)); } if (Params.List("media_ids").Count > 0) return Error(StatusCodes.Status422UnprocessableEntity, "Validation failed: Media attachments are not supported yet"); if (Params.Has("poll[options]")) return Error(StatusCodes.Status422UnprocessableEntity, "Validation failed: Polls are not supported yet"); var outcome = await _statuses.Publish(Me, new StatusDraft { Text = Params.Get("status"), PlainText = true, SpoilerText = Params.Get("spoiler_text"), Sensitive = Params.Bool("sensitive") ?? Me.Settings.DefaultSensitive, Visibility = Visibility(Params.Get("visibility") ?? Me.Settings.DefaultVisibility), InReplyTo = Params.Get("in_reply_to_id"), Language = Params.Get("language") ?? Me.Settings.DefaultLanguage }, token); if (!outcome.Ok) return Error(outcome.Status, outcome.Error); if (cacheKey != default) _cache.Set(cacheKey, outcome.Post.ID, TimeSpan.FromHours(1)); return Json(await _mapper.Status(outcome.Post, MyId, token)); } [HttpGet("/api/v1/statuses"), Scope("read:statuses", requiresUser: false), Microsoft.AspNetCore.Authorization.AllowAnonymous] public async Task Many(CancellationToken token) { var ids = Params.List("id").Take(20).ToList(); var posts = await _dbEntities.Posts.Match(p => ids.Contains(p.ID) && !p.DeletedAt.HasValue).ExecuteAsync(token); var visible = new List(); foreach (var post in posts) if (await VisibilityPolicy.CanSee(post, MyId, token)) visible.Add(post); return Json(await _mapper.Statuses(visible, MyId, token)); } [HttpGet("/api/v1/statuses/{id}"), Scope("read:statuses", requiresUser: false), Microsoft.AspNetCore.Authorization.AllowAnonymous] public async Task Get(string id, CancellationToken token) { var post = await Visible(id, token); return post == default ? NotFoundError() : Json(await _mapper.Status(post, MyId, token)); } [HttpPut("/api/v1/statuses/{id}"), Scope("write:statuses")] public async Task Edit(string id, CancellationToken token) { if (Params.List("media_ids").Count > 0) return Error(StatusCodes.Status422UnprocessableEntity, "Validation failed: Media attachments are not supported yet"); var outcome = await _statuses.Edit(Me, id, new StatusDraft { Text = Params.Get("status"), PlainText = true, SpoilerText = Params.Get("spoiler_text"), Sensitive = Params.Bool("sensitive") ?? false, Language = Params.Get("language") }, token); return outcome.Ok ? Json(await _mapper.Status(outcome.Post, MyId, token)) : Error(outcome.Status, outcome.Error); } [HttpDelete("/api/v1/statuses/{id}"), Scope("write:statuses")] public async Task Delete(string id, CancellationToken token) { var before = await _dbEntities.Posts.Match(p => p.ID == id && p.GroupUserId == MyId && !p.IsFederatedCopy && !p.DeletedAt.HasValue).ExecuteFirstAsync(token); if (before == default) return NotFoundError(); var status = await _mapper.Status(before, MyId, token); var outcome = await _statuses.Remove(Me, id, token); if (!outcome.Ok) return Error(outcome.Status, outcome.Error); if (status != default) { status.Text = before.Text; status.SpoilerText = before.SpoilerText ?? string.Empty; } return Json(status); } [HttpGet("/api/v1/statuses/{id}/context"), Scope("read:statuses", requiresUser: false), Microsoft.AspNetCore.Authorization.AllowAnonymous] public async Task Context(string id, CancellationToken token) { var post = await Visible(id, token); if (post == default) return NotFoundError(); var ancestors = new List(); var cursor = post; while (!string.IsNullOrEmpty(cursor.AnsweringToPostId) && ancestors.Count < MaxContext) { cursor = await _dbEntities.Posts.MatchID(cursor.AnsweringToPostId).ExecuteFirstAsync(token); if (cursor == default || cursor.DeletedAt.HasValue || !await VisibilityPolicy.CanSee(cursor, MyId, token)) break; ancestors.Insert(0, cursor); } var descendants = new List(); var frontier = new List { post.ID }; while (frontier.Count > 0 && descendants.Count < MaxContext) { var parents = frontier; var children = await _dbEntities.Posts.Match(p => parents.Contains(p.AnsweringToPostId) && !p.DeletedAt.HasValue && p.ReblogOfPostId == null) .Sort(p => p.ID, Order.Ascending).Limit(MaxContext).ExecuteAsync(token); frontier = new List(); foreach (var child in children) if (await VisibilityPolicy.CanSee(child, MyId, token)) { descendants.Add(child); frontier.Add(child.ID); } } return Json(new Context { Ancestors = await _mapper.Statuses(ancestors, MyId, token), Descendants = await _mapper.Statuses(descendants.OrderBy(d => d.ID, StringComparer.Ordinal).ToList(), MyId, token) }); } [HttpGet("/api/v1/statuses/{id}/history"), Scope("read:statuses", requiresUser: false), Microsoft.AspNetCore.Authorization.AllowAnonymous] public async Task History(string id, CancellationToken token) { var post = await Visible(id, token); if (post == default) return NotFoundError(); var account = await _mapper.Account(MastodonMapper.AuthorOf(post), token); var edits = post.Revisions.Select(r => new StatusEdit { Content = r.ContentHtml ?? string.Empty, SpoilerText = r.SpoilerText ?? string.Empty, Sensitive = r.HasContentWarning, CreatedAt = MastodonJson.Time(r.EditedAt), Account = account }).ToList(); edits.Add(new StatusEdit { Content = MastodonMapper.Content(post), SpoilerText = post.SpoilerText ?? string.Empty, Sensitive = post.HasContentWarning, CreatedAt = MastodonJson.Time(post.EditedAt ?? post.CreationDate), Account = account }); return Json(edits); } [HttpGet("/api/v1/statuses/{id}/source"), Scope("read:statuses")] public async Task Source(string id, CancellationToken token) { var post = await _dbEntities.Posts.Match(p => p.ID == id && p.GroupUserId == MyId && !p.IsFederatedCopy && !p.DeletedAt.HasValue).ExecuteFirstAsync(token); return post == default ? NotFoundError() : Json(new StatusSource { Id = post.ID, Text = post.Text ?? string.Empty, SpoilerText = post.SpoilerText ?? string.Empty }); } [HttpPost("/api/v1/statuses/{id}/favourite"), Scope("write:favourites")] public Task Favourite(string id, CancellationToken token) => Toggle(_statuses.Favourite(Me, id, true, token), id, token); [HttpPost("/api/v1/statuses/{id}/unfavourite"), Scope("write:favourites")] public Task Unfavourite(string id, CancellationToken token) => Toggle(_statuses.Favourite(Me, id, false, token), id, token); [HttpPost("/api/v1/statuses/{id}/reblog"), Scope("write:statuses")] public async Task Reblog(string id, CancellationToken token) { var outcome = await _statuses.Reblog(Me, id, true, Visibility(Params.Get("visibility")), token); return outcome.Ok ? Json(await _mapper.Status(outcome.Post, MyId, token)) : Error(outcome.Status, outcome.Error); } [HttpPost("/api/v1/statuses/{id}/unreblog"), Scope("write:statuses")] public Task Unreblog(string id, CancellationToken token) => Toggle(_statuses.Reblog(Me, id, false, PostVisibility.Public, token), id, token); [HttpGet("/api/v1/statuses/{id}/favourited_by"), Scope("read:accounts", requiresUser: false), Microsoft.AspNetCore.Authorization.AllowAnonymous] public async Task FavouritedBy(string id, CancellationToken token) { if (await Visible(id, token) == default) return NotFoundError(); var favourites = await _dbEntities.Favourites.Match(f => f.PostId == id).Sort(f => f.ID, Order.Descending).Limit(Limit(40, 80)).ExecuteAsync(token); var accounts = await _mapper.Accounts(favourites.Select(f => f.AccountId), token); return Json(favourites.Select(f => accounts.GetValueOrDefault(f.AccountId)).Where(a => a != default).ToList()); } [HttpGet("/api/v1/statuses/{id}/reblogged_by"), Scope("read:accounts", requiresUser: false), Microsoft.AspNetCore.Authorization.AllowAnonymous] public async Task RebloggedBy(string id, CancellationToken token) { if (await Visible(id, token) == default) return NotFoundError(); var reblogs = await _dbEntities.Posts.Match(p => p.ReblogOfPostId == id && !p.DeletedAt.HasValue).Sort(p => p.ID, Order.Descending).Limit(Limit(40, 80)).ExecuteAsync(token); var accounts = await _mapper.Accounts(reblogs.Select(MastodonMapper.AuthorOf), token); return Json(reblogs.Select(r => accounts.GetValueOrDefault(MastodonMapper.AuthorOf(r))).Where(a => a != default).ToList()); } [HttpPost("/api/v1/statuses/{id}/bookmark"), Scope("write:bookmarks")] public Task Bookmark(string id, CancellationToken token) => Unchanged(id, token); [HttpPost("/api/v1/statuses/{id}/unbookmark"), Scope("write:bookmarks")] public Task Unbookmark(string id, CancellationToken token) => Unchanged(id, token); [HttpPost("/api/v1/statuses/{id}/mute"), Scope("write:mutes")] public Task Mute(string id, CancellationToken token) => Unchanged(id, token); [HttpPost("/api/v1/statuses/{id}/unmute"), Scope("write:mutes")] public Task Unmute(string id, CancellationToken token) => Unchanged(id, token); [HttpPost("/api/v1/statuses/{id}/pin"), Scope("write:accounts")] public IActionResult Pin(string id) => Error(StatusCodes.Status422UnprocessableEntity, "Validation failed: Pinning is not supported yet"); [HttpPost("/api/v1/statuses/{id}/unpin"), Scope("write:accounts")] public Task Unpin(string id, CancellationToken token) => Unchanged(id, token); async Task Toggle(Task action, string id, CancellationToken token) { var outcome = await action; if (!outcome.Ok) return Error(outcome.Status, outcome.Error); var post = await _dbEntities.Posts.MatchID(id).ExecuteFirstAsync(token); return Json(await _mapper.Status(post, MyId, token)); } async Task Unchanged(string id, CancellationToken token) { var post = await Visible(id, token); return post == default ? NotFoundError() : Json(await _mapper.Status(post, MyId, token)); } async Task Visible(string id, CancellationToken token) { var post = await _dbEntities.Posts.Match(p => p.ID == id && !p.DeletedAt.HasValue).ExecuteFirstAsync(token); return post != default && post.Visibility != PostVisibility.LocalGeo && await VisibilityPolicy.CanSee(post, MyId, token) ? post : default; } static PostVisibility Visibility(string value) => value?.ToLowerInvariant() switch { "unlisted" => PostVisibility.Unlisted, "private" => PostVisibility.FollowersOnly, "direct" => PostVisibility.Direct, _ => PostVisibility.Public }; } }