using System.Net; using System.Net.Mime; using AsyncKeyedLock; using Iceshrimp.Backend.Controllers.Mastodon.Attributes; using Iceshrimp.Backend.Controllers.Mastodon.Renderers; using Iceshrimp.Backend.Controllers.Mastodon.Schemas; using Iceshrimp.Backend.Controllers.Mastodon.Schemas.Entities; using Iceshrimp.Backend.Controllers.Shared.Attributes; using Iceshrimp.Backend.Core.Configuration; using Iceshrimp.Backend.Core.Database; using Iceshrimp.Backend.Core.Database.Tables; using Iceshrimp.Backend.Core.Extensions; using Iceshrimp.Backend.Core.Helpers; using Iceshrimp.Backend.Core.Middleware; using Iceshrimp.Backend.Core.Services; using Iceshrimp.MfmSharp; using Microsoft.AspNetCore.Cors; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.RateLimiting; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Options; namespace Iceshrimp.Backend.Controllers.Mastodon; [MastodonApiController] [Route("/api/v1/statuses")] [Authenticate] [EnableCors("mastodon")] [EnableRateLimiting("sliding")] [Produces(MediaTypeNames.Application.Json)] public class StatusController( DatabaseContext db, NoteRenderer noteRenderer, NoteService noteSvc, CacheService cache, IOptions config, IOptionsSnapshot security, UserRenderer userRenderer ) : ControllerBase { private static readonly AsyncKeyedLocker KeyedLocker = new(o => { o.PoolSize = 100; o.PoolInitialFill = 5; }); [HttpGet("{id}")] [Authenticate("read:statuses")] [ProducesResults(HttpStatusCode.OK)] [ProducesErrors(HttpStatusCode.Forbidden, HttpStatusCode.NotFound)] public async Task GetNote(string id) { var user = HttpContext.GetUser(); if (security.Value.PublicPreview == Enums.PublicPreview.Lockdown && user == null) throw GracefulException.Forbidden("Public preview is disabled on this instance"); var note = await db.Notes .Where(p => p.Id == id) .IncludeCommonProperties() .FilterHidden(user, db, false, false, filterMentions: false) .EnsureVisibleFor(user) .PrecomputeVisibilities(user) .FirstOrDefaultAsync() ?? throw GracefulException.RecordNotFound(); if (security.Value.PublicPreview <= Enums.PublicPreview.Restricted && note.User.IsRemoteUser && user == null) throw GracefulException.Forbidden("Public preview is disabled on this instance"); return await noteRenderer.RenderAsync(note.EnforceRenoteReplyVisibility(), user); } [HttpGet("{id}/context")] [Authenticate("read:statuses")] [ProducesResults(HttpStatusCode.OK)] [ProducesErrors(HttpStatusCode.Forbidden, HttpStatusCode.NotFound)] public async Task GetStatusContext(string id) { var user = HttpContext.GetUser(); if (security.Value.PublicPreview == Enums.PublicPreview.Lockdown && user == null) throw GracefulException.Forbidden("Public preview is disabled on this instance"); var maxAncestors = user != null ? 4096 : 40; var maxDescendants = user != null ? 4096 : 60; var maxDepth = user != null ? 4096 : 20; var note = await db.Notes .Where(p => p.Id == id) .EnsureVisibleFor(user) .FilterHidden(user, db, false, false) .FirstOrDefaultAsync() ?? throw GracefulException.RecordNotFound(); if (security.Value.PublicPreview <= Enums.PublicPreview.Restricted && note.UserHost != null && user == null) throw GracefulException.Forbidden("Public preview is disabled on this instance"); // Akkoma-FE calls /context on boosts if (note.IsPureRenote) return await GetStatusContext(note.RenoteId!); var ancestors = await db.NoteAncestors(id, maxAncestors) .IncludeCommonProperties() .EnsureVisibleFor(user) .FilterHidden(user, db) .PrecomputeVisibilities(user) .RenderAllForMastodonAsync(noteRenderer, user, Filter.FilterContext.Threads); var descendants = await db.NoteDescendants(id, maxDepth, maxDescendants) .Where(p => !p.IsQuote || p.RenoteId != id) .IncludeCommonProperties() .EnsureVisibleFor(user) .FilterHidden(user, db) .PrecomputeVisibilities(user) .RenderAllForMastodonAsync(noteRenderer, user, Filter.FilterContext.Threads); if (user != null) await noteSvc.EnqueueBackfillTaskAsync(note, user); return new StatusContext { Ancestors = ancestors.OrderAncestors(), Descendants = descendants.OrderDescendants() }; } [HttpPost("{id}/favourite")] [Authorize("write:favourites")] [ProducesResults(HttpStatusCode.OK)] [ProducesErrors(HttpStatusCode.NotFound)] public async Task LikeNote(string id) { var user = HttpContext.GetUserOrFail(); var note = await db.Notes.Where(p => p.Id == id) .IncludeCommonProperties() .EnsureVisibleFor(user) .FilterHidden(user, db, filterMutes: false) .FirstOrDefaultAsync() ?? throw GracefulException.RecordNotFound(); var success = await noteSvc.LikeNoteAsync(note, user); if (success) note.LikeCount++; // we do not want to call save changes after this point return await GetNote(id); } [HttpPost("{id}/unfavourite")] [Authorize("write:favourites")] [ProducesResults(HttpStatusCode.OK)] [ProducesErrors(HttpStatusCode.NotFound)] public async Task UnlikeNote(string id) { var user = HttpContext.GetUserOrFail(); var note = await db.Notes.Where(p => p.Id == id) .IncludeCommonProperties() .EnsureVisibleFor(user) .FilterHidden(user, db, filterMutes: false) .FirstOrDefaultAsync() ?? throw GracefulException.RecordNotFound(); var success = await noteSvc.UnlikeNoteAsync(note, user); if (success) note.LikeCount--; // we do not want to call save changes after this point return await GetNote(id); } [HttpPost("{id}/react/{reaction}")] [Authorize("write:favourites")] [ProducesResults(HttpStatusCode.OK)] [ProducesErrors(HttpStatusCode.NotFound)] public async Task ReactNote(string id, string reaction) { var user = HttpContext.GetUserOrFail(); var note = await db.Notes.Where(p => p.Id == id) .IncludeCommonProperties() .EnsureVisibleFor(user) .FilterHidden(user, db, filterMutes: false) .FirstOrDefaultAsync() ?? throw GracefulException.RecordNotFound(); var res = await noteSvc.ReactToNoteAsync(note, user, reaction); if (res.success && !note.Reactions.TryAdd(res.name, 1)) note.Reactions[res.name]++; // we do not want to call save changes after this point return await GetNote(id); } [HttpPost("{id}/unreact/{reaction}")] [Authorize("write:favourites")] [ProducesResults(HttpStatusCode.OK)] [ProducesErrors(HttpStatusCode.NotFound)] public async Task UnreactNote(string id, string reaction) { var user = HttpContext.GetUserOrFail(); var note = await db.Notes.Where(p => p.Id == id) .IncludeCommonProperties() .EnsureVisibleFor(user) .FilterHidden(user, db, filterMutes: false) .FirstOrDefaultAsync() ?? throw GracefulException.RecordNotFound(); var res = await noteSvc.RemoveReactionFromNoteAsync(note, user, reaction); if (res.success && note.Reactions.TryGetValue(res.name, out var value)) note.Reactions[res.name] = --value; // we do not want to call save changes after this point return await GetNote(id); } [HttpPost("{id}/bookmark")] [Authorize("write:bookmarks")] [ProducesResults(HttpStatusCode.OK)] [ProducesErrors(HttpStatusCode.NotFound)] public async Task BookmarkNote(string id) { var user = HttpContext.GetUserOrFail(); var note = await db.Notes.Where(p => p.Id == id) .IncludeCommonProperties() .EnsureVisibleFor(user) .FilterHidden(user, db, filterMutes: false) .FirstOrDefaultAsync() ?? throw GracefulException.RecordNotFound(); await noteSvc.BookmarkNoteAsync(note, user); return await GetNote(id); } [HttpPost("{id}/unbookmark")] [Authorize("write:bookmarks")] [ProducesResults(HttpStatusCode.OK)] [ProducesErrors(HttpStatusCode.NotFound)] public async Task UnbookmarkNote(string id) { var user = HttpContext.GetUserOrFail(); var note = await db.Notes.Where(p => p.Id == id) .IncludeCommonProperties() .EnsureVisibleFor(user) .FilterHidden(user, db, filterMutes: false) .FirstOrDefaultAsync() ?? throw GracefulException.RecordNotFound(); await noteSvc.UnbookmarkNoteAsync(note, user); return await GetNote(id); } [HttpPost("{id}/pin")] [Authorize("write:accounts")] [ProducesResults(HttpStatusCode.OK)] [ProducesErrors(HttpStatusCode.BadRequest, HttpStatusCode.NotFound, HttpStatusCode.UnprocessableEntity)] public async Task PinNote(string id) { var user = HttpContext.GetUserOrFail(); var note = await db.Notes.Where(p => p.Id == id) .IncludeCommonProperties() .EnsureVisibleFor(user) .FirstOrDefaultAsync() ?? throw GracefulException.RecordNotFound(); await noteSvc.PinNoteAsync(note, user); return await GetNote(id); } [HttpPost("{id}/unpin")] [Authorize("write:accounts")] [ProducesResults(HttpStatusCode.OK)] [ProducesErrors(HttpStatusCode.NotFound)] public async Task UnpinNote(string id) { var user = HttpContext.GetUserOrFail(); var note = await db.Notes.Where(p => p.Id == id).FirstOrDefaultAsync() ?? throw GracefulException.RecordNotFound(); await noteSvc.UnpinNoteAsync(note, user); return await GetNote(id); } [HttpPost("{id}/reblog")] [Authorize("write:favourites")] [ProducesResults(HttpStatusCode.OK)] [ProducesErrors(HttpStatusCode.NotFound)] public async Task Renote(string id, [FromHybrid] StatusSchemas.ReblogRequest? request) { var user = HttpContext.GetUserOrFail(); var renote = await db.Notes.IncludeCommonProperties() .FirstOrDefaultAsync(p => p.RenoteId == id && p.User == user && p.IsPureRenote); if (renote == null) { var note = await db.Notes.Where(p => p.Id == id) .IncludeCommonProperties() .EnsureVisibleFor(user) .FirstOrDefaultAsync() ?? throw GracefulException.RecordNotFound(); var renoteVisibility = request?.Visibility != null ? StatusEntity.DecodeVisibility(request.Visibility) : user.UserSettings?.DefaultRenoteVisibility ?? Note.NoteVisibility.Public; renote = await noteSvc.RenoteNoteAsync(note, user, renoteVisibility) ?? throw new Exception("Created renote was null"); note.RenoteCount++; // we do not want to call save changes after this point } return await GetNote(renote.Id); } [HttpPost("{id}/unreblog")] [Authorize("write:favourites")] [ProducesResults(HttpStatusCode.OK)] [ProducesErrors(HttpStatusCode.NotFound)] public async Task UndoRenote(string id) { var user = HttpContext.GetUserOrFail(); var note = await db.Notes.Where(p => p.Id == id) .IncludeCommonProperties() .EnsureVisibleFor(user) .FirstOrDefaultAsync() ?? throw GracefulException.RecordNotFound(); var count = await noteSvc.UnrenoteNoteAsync(note, user); note.RenoteCount -= (short)count; // we do not want to call save changes after this point return await GetNote(id); } [HttpPost] [Authorize("write:statuses")] [ProducesResults(HttpStatusCode.OK)] [ProducesErrors(HttpStatusCode.BadRequest, HttpStatusCode.UnprocessableEntity)] public async Task PostNote([FromHybrid] StatusSchemas.PostStatusRequest request) { if (request.Preview) throw GracefulException.UnprocessableEntity("Previewing is not supported yet"); var token = HttpContext.GetOauthToken() ?? throw new Exception("Token must not be null at this stage"); var user = token.User; if (request.ScheduledAt != null) throw GracefulException.UnprocessableEntity("Scheduled statuses are not supported yet"); //TODO: handle scheduled statuses Request.Headers.TryGetValue("Idempotency-Key", out var idempotencyKeyHeader); var idempotencyKey = idempotencyKeyHeader.FirstOrDefault(); if (idempotencyKey != null) { var key = $"idempotency:{user.Id}:{idempotencyKey}"; string hit; using (await KeyedLocker.LockAsync(key)) { hit = await cache.FetchAsync(key, TimeSpan.FromHours(24), () => $"_:{HttpContext.TraceIdentifier}"); } if (hit != $"_:{HttpContext.TraceIdentifier}") { for (var i = 0; i <= 10; i++) { if (!hit.StartsWith('_')) break; await Task.Delay(100); hit = await cache.GetAsync(key) ?? throw new Exception("Idempotency key status disappeared in for loop"); if (i >= 10) throw GracefulException.RequestTimeout("Failed to resolve idempotency key note within 1000 ms"); } return await GetNote(hit); } } if (string.IsNullOrWhiteSpace(request.Text) && request.MediaIds is not { Count: > 0 } && request.Poll == null) throw GracefulException.BadRequest("Posts must have text, media or poll"); var poll = request.Poll != null ? new Poll { Choices = request.Poll.Options, Multiple = request.Poll.Multiple, ExpiresAt = DateTime.UtcNow + TimeSpan.FromSeconds(request.Poll.ExpiresIn) } : null; if (request.Visibility == "local") { request.Visibility = "public"; request.LocalOnly = true; } var visibility = StatusEntity.DecodeVisibility(request.Visibility); var reply = request.ReplyId != null ? await db.Notes.Where(p => p.Id == request.ReplyId) .IncludeCommonProperties() .EnsureVisibleFor(user) .FilterHidden(user, db, filterMutes: false) .FirstOrDefaultAsync() ?? throw GracefulException.BadRequest("Reply target is nonexistent or inaccessible") : null; var attachments = request.MediaIds != null ? await db.DriveFiles.Where(p => request.MediaIds.Contains(p.Id)).ToListAsync() : null; string? quoteUri = null; string? newText = null; if (token.AutoDetectQuotes && request.Text != null) { var parsed = MfmParser.Parse(request.Text); quoteUri = parsed.LastOrDefault() switch { MfmUrlNode urlNode => urlNode.Url, MfmLinkNode linkNode => linkNode.Url, _ => quoteUri }; newText = quoteUri != null ? parsed.SkipLast(1).Serialize() : parsed.Serialize(); } if (request is { Sensitive: true, MediaIds.Count: > 0 }) { await db.DriveFiles.Where(p => request.MediaIds.Contains(p.Id) && !p.IsSensitive) .ExecuteUpdateAsync(p => p.SetProperty(i => i.IsSensitive, _ => true)); } var quote = request.QuoteId != null ? await db.Notes .IncludeCommonProperties() .EnsureVisibleFor(user) .FilterHidden(user, db, filterMutes: false) .FirstOrDefaultAsync(p => p.Id == request.QuoteId) ?? throw GracefulException.BadRequest("Quote target is nonexistent or inaccessible") : null; quote ??= quoteUri != null ? quoteUri.StartsWith($"https://{config.Value.WebDomain}/notes/") ? await db.Notes .IncludeCommonProperties() .Where(p => p.Id == quoteUri.Substring($"https://{config.Value.WebDomain}/notes/".Length)) .EnsureVisibleFor(user) .FilterHidden(user, db, filterMutes: false) .FirstOrDefaultAsync() : await db.Notes .IncludeCommonProperties() .Where(p => p.Uri == quoteUri) .EnsureVisibleFor(user) .FilterHidden(user, db, filterMutes: false) .FirstOrDefaultAsync() ?? await db.Notes .IncludeCommonProperties() .Where(p => p.Url == quoteUri) .EnsureVisibleFor(user) .FilterHidden(user, db, filterMutes: false) .FirstOrDefaultAsync() : null; List urls = quote == null ? [] : [quote.Url, quote.Uri, quote.GetPublicUriOrNull(config.Value)]; if (quote != null && request.Text != null && newText != null && urls.OfType().Contains(quoteUri)) request.Text = newText; var note = await noteSvc.CreateNoteAsync(new NoteService.NoteCreationData { User = user, Visibility = visibility, Text = request.Text, Cw = request.Cw, Reply = reply, Renote = quote, Attachments = attachments, Poll = poll, LocalOnly = request.LocalOnly }); if (idempotencyKey != null) await cache.SetAsync($"idempotency:{user.Id}:{idempotencyKey}", note.Id, TimeSpan.FromHours(24)); return await noteRenderer.RenderAsync(note, user); } [HttpPut("{id}")] [Authorize("write:statuses")] [ProducesResults(HttpStatusCode.OK)] [ProducesErrors(HttpStatusCode.BadRequest, HttpStatusCode.NotFound)] public async Task EditNote(string id, [FromHybrid] StatusSchemas.EditStatusRequest request) { var user = HttpContext.GetUserOrFail(); var note = await db.Notes .Include(p => p.Poll) .IncludeCommonProperties() .FirstOrDefaultAsync(p => p.Id == id && p.User == user) ?? throw GracefulException.RecordNotFound(); if (request.Text == null && request.MediaIds is not { Count: > 0 } && request.Poll == null) throw GracefulException.BadRequest("Posts must have text, media or poll"); var poll = request.Poll != null ? new Poll { Choices = request.Poll.Options, Multiple = request.Poll.Multiple, ExpiresAt = DateTime.UtcNow + TimeSpan.FromSeconds(request.Poll.ExpiresIn) } : null; var attachments = request.MediaIds != null ? await db.DriveFiles.Where(p => request.MediaIds.Contains(p.Id)).ToListAsync() : []; if (request.MediaAttributes != null) { foreach (var attr in request.MediaAttributes) { var file = attachments.FirstOrDefault(p => p.Id == attr.Id); if (file != null) file.Comment = attr.Description; } await db.SaveChangesAsync(); } note = await noteSvc.UpdateNoteAsync(new NoteService.NoteUpdateData { Note = note, Text = request.Text, Cw = request.Cw, Attachments = attachments, Poll = poll }); return await noteRenderer.RenderAsync(note, user); } [HttpDelete("{id}")] [Authorize("write:statuses")] [ProducesResults(HttpStatusCode.OK)] [ProducesErrors(HttpStatusCode.NotFound)] public async Task DeleteNote(string id) { var user = HttpContext.GetUserOrFail(); var note = await db.Notes.IncludeCommonProperties().FirstOrDefaultAsync(p => p.Id == id && p.User == user) ?? throw GracefulException.RecordNotFound(); var res = await noteRenderer.RenderAsync(note, user, data: new NoteRenderer.NoteRendererDto { Source = true }); await noteSvc.DeleteNoteAsync(note); return res; } [HttpGet("{id}/source")] [Authorize("read:statuses")] [ProducesResults(HttpStatusCode.OK)] [ProducesErrors(HttpStatusCode.NotFound)] public async Task GetNoteSource(string id) { var user = HttpContext.GetUserOrFail(); return await db.Notes.Where(p => p.Id == id && p.User == user) .Select(p => new StatusSource { Id = p.Id, ContentWarning = p.Cw ?? "", Text = p.Text ?? "" }) .FirstOrDefaultAsync() ?? throw GracefulException.RecordNotFound(); } [HttpGet("{id}/favourited_by")] [Authenticate("read:statuses")] [LinkPagination(40, 80)] [ProducesResults(HttpStatusCode.OK)] [ProducesErrors(HttpStatusCode.Forbidden, HttpStatusCode.NotFound)] public async Task> GetNoteLikes(string id, MastodonPaginationQuery pq) { var user = HttpContext.GetUser(); if (security.Value.PublicPreview == Enums.PublicPreview.Lockdown && user == null) throw GracefulException.Forbidden("Public preview is disabled on this instance"); var note = await db.Notes.Where(p => p.Id == id) .EnsureVisibleFor(user) .FilterHidden(user, db, filterMutes: false) .FirstOrDefaultAsync() ?? throw GracefulException.RecordNotFound(); if (security.Value.PublicPreview <= Enums.PublicPreview.Restricted && note.UserHost != null && user == null) throw GracefulException.Forbidden("Public preview is disabled on this instance"); var likes = await db.NoteLikes.Where(p => p.Note == note) .Include(p => p.User.UserProfile) .Select(p => new EntityWrapper { Id = p.Id, Entity = p.User }) .Paginate(pq, ControllerContext) .ToListAsync(); HttpContext.SetPaginationData(likes); return await userRenderer.RenderManyAsync(likes.Select(p => p.Entity), user); } [HttpGet("{id}/reblogged_by")] [Authenticate("read:statuses")] [LinkPagination(40, 80)] [ProducesResults(HttpStatusCode.OK)] [ProducesErrors(HttpStatusCode.Forbidden, HttpStatusCode.NotFound)] public async Task> GetNoteRenotes(string id, MastodonPaginationQuery pq) { var user = HttpContext.GetUser(); if (security.Value.PublicPreview == Enums.PublicPreview.Lockdown && user == null) throw GracefulException.Forbidden("Public preview is disabled on this instance"); var note = await db.Notes .Where(p => p.Id == id) .EnsureVisibleFor(user) .FilterHidden(user, db, filterMutes: false) .FirstOrDefaultAsync() ?? throw GracefulException.RecordNotFound(); if (security.Value.PublicPreview <= Enums.PublicPreview.Restricted && note.UserHost != null && user == null) throw GracefulException.Forbidden("Public preview is disabled on this instance"); var renotes = await db.Notes .Where(p => p.Renote == note && p.IsPureRenote) .EnsureVisibleFor(user) .Include(p => p.User.UserProfile) .Select(p => new EntityWrapper { Id = p.Id, Entity = p.User }) .Paginate(pq, ControllerContext) .ToListAsync(); HttpContext.SetPaginationData(renotes); return await userRenderer.RenderManyAsync(renotes.Select(p => p.Entity), user); } [HttpGet("{id}/history")] [Authenticate("read:statuses")] [ProducesResults(HttpStatusCode.OK)] [ProducesErrors(HttpStatusCode.Forbidden, HttpStatusCode.NotFound)] public async Task> GetNoteEditHistory(string id) { var user = HttpContext.GetUser(); if (security.Value.PublicPreview == Enums.PublicPreview.Lockdown && user == null) throw GracefulException.Forbidden("Public preview is disabled on this instance"); var note = await db.Notes .IncludeCommonProperties() .Where(p => p.Id == id) .EnsureVisibleFor(user) .FilterHidden(user, db, filterMutes: false) .FirstOrDefaultAsync() ?? throw GracefulException.RecordNotFound(); if (security.Value.PublicPreview <= Enums.PublicPreview.Restricted && note.User.IsRemoteUser && user == null) throw GracefulException.Forbidden("Public preview is disabled on this instance"); return await noteRenderer.RenderHistoryAsync(note, user); } [HttpPost("{id}/mute")] [Authorize("write:mutes")] [ProducesResults(HttpStatusCode.OK)] [ProducesErrors(HttpStatusCode.NotFound)] public async Task MuteNoteThread(string id) { var user = HttpContext.GetUserOrFail(); var target = await db.Notes.Where(p => p.Id == id) .EnsureVisibleFor(user) .Select(p => p.ThreadId) .FirstOrDefaultAsync() ?? throw GracefulException.RecordNotFound(); var mute = new NoteThreadMuting { Id = IdHelpers.GenerateSnowflakeId(), CreatedAt = DateTime.UtcNow, ThreadId = target, UserId = user.Id }; await db.NoteThreadMutings.Upsert(mute).On(p => new { p.UserId, p.ThreadId }).NoUpdate().RunAsync(); return await GetNote(id); } [HttpPost("{id}/unmute")] [Authorize("write:mutes")] [ProducesResults(HttpStatusCode.OK)] [ProducesErrors(HttpStatusCode.NotFound)] public async Task UnmuteNoteThread(string id) { var user = HttpContext.GetUserOrFail(); var target = await db.Notes.Where(p => p.Id == id) .EnsureVisibleFor(user) .Select(p => p.ThreadId) .FirstOrDefaultAsync() ?? throw GracefulException.RecordNotFound(); await db.NoteThreadMutings.Where(p => p.User == user && p.ThreadId == target).ExecuteDeleteAsync(); return await GetNote(id); } }