From 5d7035e63cbcd682d882620427f5fd4ce383fac1 Mon Sep 17 00:00:00 2001 From: Laura Hausmann Date: Thu, 22 Feb 2024 02:23:21 +0100 Subject: [PATCH] [backend/masto-client] Implement /reblog and /unreblog endpoints --- .../Controllers/Mastodon/StatusController.cs | 47 +++++++++++++++++++ .../ActivityPub/ActivityRenderer.cs | 32 +++++++++++++ .../Federation/ActivityPub/NoteRenderer.cs | 4 ++ .../Core/Services/NoteService.cs | 31 +++++++++--- 4 files changed, 107 insertions(+), 7 deletions(-) diff --git a/Iceshrimp.Backend/Controllers/Mastodon/StatusController.cs b/Iceshrimp.Backend/Controllers/Mastodon/StatusController.cs index 43d77d3b..728053c7 100644 --- a/Iceshrimp.Backend/Controllers/Mastodon/StatusController.cs +++ b/Iceshrimp.Backend/Controllers/Mastodon/StatusController.cs @@ -5,6 +5,7 @@ using Iceshrimp.Backend.Controllers.Mastodon.Schemas; using Iceshrimp.Backend.Controllers.Mastodon.Schemas.Entities; 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.Middleware; using Iceshrimp.Backend.Core.Services; @@ -119,6 +120,52 @@ public class StatusController( return await GetNote(id); } + [HttpPost("{id}/reblog")] + [Authorize("write:favourites")] + [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(StatusEntity))] + [ProducesResponseType(StatusCodes.Status401Unauthorized, Type = typeof(MastodonErrorResponse))] + [ProducesResponseType(StatusCodes.Status403Forbidden, Type = typeof(MastodonErrorResponse))] + [ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(MastodonErrorResponse))] + public async Task Renote(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.CreateNoteAsync(user, Note.NoteVisibility.Followers, renote: note); + return await GetNote(id); + } + + [HttpPost("{id}/unreblog")] + [Authorize("write:favourites")] + [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(StatusEntity))] + [ProducesResponseType(StatusCodes.Status401Unauthorized, Type = typeof(MastodonErrorResponse))] + [ProducesResponseType(StatusCodes.Status403Forbidden, Type = typeof(MastodonErrorResponse))] + [ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(MastodonErrorResponse))] + public async Task UndoRenote(string id) + { + var user = HttpContext.GetUserOrFail(); + if (!await db.Notes.Where(p => p.Id == id).EnsureVisibleFor(user).AnyAsync()) + throw GracefulException.RecordNotFound(); + + var renotes = await db.Notes.Where(p => p.RenoteId == id && p.IsPureRenote && p.User == user) + .IncludeCommonProperties() + .ToListAsync(); + + if (renotes.Count > 0) + { + renotes[0].Renote!.RenoteCount--; + await db.SaveChangesAsync(); + } + + foreach (var renote in renotes) await noteSvc.DeleteNoteAsync(renote); + + return await GetNote(id); + } + [HttpPost] [Authorize("write:statuses")] [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(StatusEntity))] diff --git a/Iceshrimp.Backend/Core/Federation/ActivityPub/ActivityRenderer.cs b/Iceshrimp.Backend/Core/Federation/ActivityPub/ActivityRenderer.cs index 4ef88495..2fd6ca2b 100644 --- a/Iceshrimp.Backend/Core/Federation/ActivityPub/ActivityRenderer.cs +++ b/Iceshrimp.Backend/Core/Federation/ActivityPub/ActivityRenderer.cs @@ -122,4 +122,36 @@ public class ActivityRenderer( [SuppressMessage("ReSharper", "SuggestBaseTypeForParameter", Justification = "This only makes sense for users")] private string RenderFollowId(User follower, User followee) => $"https://{config.Value.WebDomain}/follows/{follower.Id}/{followee.Id}"; + + public static ASAnnounce RenderAnnounce( + ASNote note, ASActor actor, List to, List cc, string uri + ) => new() + { + Id = uri, + Actor = actor.Compact(), + Object = note, + To = to, + Cc = cc + }; + + public static ASAnnounce RenderAnnounce( + ASNote note, string renoteUri, ASActor actor, Note.NoteVisibility visibility, string followersUri + ) + { + List to = visibility switch + { + Note.NoteVisibility.Public => [new ASLink($"{Constants.ActivityStreamsNs}#Public")], + Note.NoteVisibility.Followers => [new ASLink(followersUri)], + Note.NoteVisibility.Specified => throw new Exception("Announce cannot be specified"), + _ => [] + }; + + List cc = visibility switch + { + Note.NoteVisibility.Home => [new ASLink($"{Constants.ActivityStreamsNs}#Public")], + _ => [] + }; + + return RenderAnnounce(note, actor, to, cc, renoteUri); + } } \ No newline at end of file diff --git a/Iceshrimp.Backend/Core/Federation/ActivityPub/NoteRenderer.cs b/Iceshrimp.Backend/Core/Federation/ActivityPub/NoteRenderer.cs index a1064e66..1f991246 100644 --- a/Iceshrimp.Backend/Core/Federation/ActivityPub/NoteRenderer.cs +++ b/Iceshrimp.Backend/Core/Federation/ActivityPub/NoteRenderer.cs @@ -4,6 +4,7 @@ using Iceshrimp.Backend.Core.Database.Tables; using Iceshrimp.Backend.Core.Extensions; using Iceshrimp.Backend.Core.Federation.ActivityStreams.Types; using Iceshrimp.Backend.Core.Helpers.LibMfm.Conversion; +using Iceshrimp.Backend.Core.Middleware; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Options; @@ -29,6 +30,9 @@ public class NoteRenderer(IOptions config, MfmConverter ? new ASObjectBase(note.Reply.Uri ?? note.Reply.GetPublicUri(config.Value)) : null; + if (note.IsPureRenote) + throw GracefulException.BadRequest("Refusing to render pure renote as ASNote"); + mentions ??= await db.Users .Where(p => note.Mentions.Contains(p.Id)) .IncludeCommonProperties() diff --git a/Iceshrimp.Backend/Core/Services/NoteService.cs b/Iceshrimp.Backend/Core/Services/NoteService.cs index 467db437..e7ce22f5 100644 --- a/Iceshrimp.Backend/Core/Services/NoteService.cs +++ b/Iceshrimp.Backend/Core/Services/NoteService.cs @@ -104,12 +104,20 @@ public class NoteService( if (user.Host != null) return note; - var actor = userRenderer.RenderLite(user); - var obj = await noteRenderer.RenderAsync(note, mentions); - var activity = ActivityPub.ActivityRenderer.RenderCreate(obj, actor); + var actor = userRenderer.RenderLite(user); + ASActivity activity = note is { IsPureRenote: true, Renote: not null } + ? ActivityPub.ActivityRenderer.RenderAnnounce(noteRenderer.RenderLite(note.Renote), + note.GetPublicUri(config.Value), actor, note.Visibility, + user.GetPublicUri(config.Value) + "/followers") + : ActivityPub.ActivityRenderer.RenderCreate(await noteRenderer.RenderAsync(note, mentions), actor); + + List additionalUserIds = + note is { IsPureRenote: true, Renote: not null, Visibility: < Note.NoteVisibility.Followers } + ? [note.Renote.User.Id] + : []; var recipients = await db.Users - .Where(p => mentionedUserIds.Contains(p.Id)) + .Where(p => mentionedUserIds.Concat(additionalUserIds).Contains(p.Id)) .Select(p => new User { Host = p.Host, Inbox = p.Inbox }) .ToListAsync(); @@ -221,8 +229,16 @@ public class NoteService( .Select(p => new User { Host = p.Host, Inbox = p.Inbox }) .ToListAsync(); - var actor = userRenderer.RenderLite(note.User); - var activity = activityRenderer.RenderDelete(actor, new ASTombstone { Id = note.GetPublicUri(config.Value) }); + var actor = userRenderer.RenderLite(note.User); + ASActivity activity = note.IsPureRenote + ? activityRenderer.RenderUndo(actor, + ActivityPub.ActivityRenderer + .RenderAnnounce(noteRenderer.RenderLite(note.Renote ?? throw new Exception("Refusing to undo renote without renote")), + note.GetPublicUri(config.Value), actor, + note.Visibility, + note.User.GetPublicUri(config.Value) + + "/followers")) + : activityRenderer.RenderDelete(actor, new ASTombstone { Id = note.GetPublicUri(config.Value) }); if (note.Visibility == Note.NoteVisibility.Specified) await deliverSvc.DeliverToAsync(activity, note.User, recipients.ToArray()); @@ -250,6 +266,7 @@ public class NoteService( logger.LogDebug("Deleting note '{id}' owned by {userId}", note.Id, actor.Id); actor.NotesCount--; + if (dbNote.IsPureRenote) dbNote.RenoteCount--; db.Remove(dbNote); eventSvc.RaiseNoteDeleted(this, dbNote); await db.SaveChangesAsync(); @@ -332,7 +349,7 @@ public class NoteService( dbNote.ReplyUserId = dbNote.Reply.UserId; dbNote.ReplyUserHost = dbNote.Reply.UserHost; } - + if (dbNote.Renote != null) { dbNote.RenoteUserId = dbNote.Renote.UserId;