diff --git a/Iceshrimp.Backend/Controllers/AdminController.cs b/Iceshrimp.Backend/Controllers/AdminController.cs index 10e5a8e8..593b01c9 100644 --- a/Iceshrimp.Backend/Controllers/AdminController.cs +++ b/Iceshrimp.Backend/Controllers/AdminController.cs @@ -71,6 +71,15 @@ public class AdminController( return await apController.GetUser(id); } + [UseNewtonsoftJson] + [HttpGet("activities/users/{id}/collections/featured")] + [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(ASActor))] + [Produces("application/activity+json", "application/ld+json; profile=\"https://www.w3.org/ns/activitystreams\"")] + public async Task GetUserFeaturedActivity(string id) + { + return await apController.GetUserFeatured(id); + } + [UseNewtonsoftJson] [HttpGet("activities/users/@{acct}")] [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(ASActor))] diff --git a/Iceshrimp.Backend/Controllers/Federation/ActivityPubController.cs b/Iceshrimp.Backend/Controllers/Federation/ActivityPubController.cs index 4c71af74..3b92ea27 100644 --- a/Iceshrimp.Backend/Controllers/Federation/ActivityPubController.cs +++ b/Iceshrimp.Backend/Controllers/Federation/ActivityPubController.cs @@ -2,6 +2,7 @@ using System.Text; using Iceshrimp.Backend.Controllers.Attributes; using Iceshrimp.Backend.Controllers.Federation.Attributes; using Iceshrimp.Backend.Controllers.Schemas; +using Iceshrimp.Backend.Core.Configuration; using Iceshrimp.Backend.Core.Database; using Iceshrimp.Backend.Core.Extensions; using Iceshrimp.Backend.Core.Federation.ActivityStreams; @@ -11,6 +12,7 @@ using Iceshrimp.Backend.Core.Queues; using Iceshrimp.Backend.Core.Services; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; namespace Iceshrimp.Backend.Controllers.Federation; @@ -21,7 +23,8 @@ public class ActivityPubController( DatabaseContext db, QueueService queues, ActivityPub.NoteRenderer noteRenderer, - ActivityPub.UserRenderer userRenderer + ActivityPub.UserRenderer userRenderer, + IOptions config ) : ControllerBase { [HttpGet("/notes/{id}")] @@ -58,6 +61,37 @@ public class ActivityPubController( return Ok(compacted); } + [HttpGet("/users/{id}/collections/featured")] + [AuthorizedFetch] + [MediaTypeRouteFilter("application/activity+json", "application/ld+json")] + [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(ASActor))] + [ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(ErrorResponse))] + public async Task GetUserFeatured(string id) + { + var user = await db.Users.IncludeCommonProperties().FirstOrDefaultAsync(p => p.Id == id && p.Host == null); + if (user == null) return NotFound(); + + var pins = await db.UserNotePins.Where(p => p.User == user) + .OrderByDescending(p => p.Id) + .Include(p => p.Note.User.UserProfile) + .Include(p => p.Note.Renote!.User.UserProfile) + .Include(p => p.Note.Reply!.User.UserProfile) + .Select(p => p.Note) + .Take(10) + .ToListAsync(); + + var rendered = await pins.Select(p => noteRenderer.RenderAsync(p)).AwaitAllNoConcurrencyAsync(); + var res = new ASOrderedCollection + { + Id = $"{user.GetPublicUri(config.Value)}/collections/featured", + TotalItems = (ulong)rendered.Count, + Items = rendered.Cast().ToList() + }; + + var compacted = LdHelpers.Compact(res); + return Ok(compacted); + } + [HttpGet("/@{acct}")] [AuthorizedFetch] [MediaTypeRouteFilter("application/activity+json", "application/ld+json")] diff --git a/Iceshrimp.Backend/Core/Federation/ActivityPub/UserRenderer.cs b/Iceshrimp.Backend/Core/Federation/ActivityPub/UserRenderer.cs index acc8e79b..88bb441b 100644 --- a/Iceshrimp.Backend/Core/Federation/ActivityPub/UserRenderer.cs +++ b/Iceshrimp.Backend/Core/Federation/ActivityPub/UserRenderer.cs @@ -63,6 +63,7 @@ public class UserRenderer(IOptions config, DatabaseConte IsCat = user.IsCat, IsDiscoverable = user.IsExplorable, IsLocked = user.IsLocked, + Featured = new ASOrderedCollection($"{id}/collections/featured"), Avatar = user.AvatarUrl != null ? new ASImage { Url = new ASLink(user.AvatarUrl) } : null, diff --git a/Iceshrimp.Backend/Core/Federation/ActivityStreams/Types/ASCollection.cs b/Iceshrimp.Backend/Core/Federation/ActivityStreams/Types/ASCollection.cs index 6308e16e..ca9dd922 100644 --- a/Iceshrimp.Backend/Core/Federation/ActivityStreams/Types/ASCollection.cs +++ b/Iceshrimp.Backend/Core/Federation/ActivityStreams/Types/ASCollection.cs @@ -81,7 +81,7 @@ public sealed class ASCollectionConverter : JsonConverter } } -internal sealed class ASCollectionItemsConverter : JsonConverter +internal class ASCollectionItemsConverter : JsonConverter { public override bool CanWrite => false; diff --git a/Iceshrimp.Backend/Core/Federation/ActivityStreams/Types/ASOrderedCollection.cs b/Iceshrimp.Backend/Core/Federation/ActivityStreams/Types/ASOrderedCollection.cs index e9bf2d02..b4d5011f 100644 --- a/Iceshrimp.Backend/Core/Federation/ActivityStreams/Types/ASOrderedCollection.cs +++ b/Iceshrimp.Backend/Core/Federation/ActivityStreams/Types/ASOrderedCollection.cs @@ -4,7 +4,6 @@ using Newtonsoft.Json; using Newtonsoft.Json.Linq; using J = Newtonsoft.Json.JsonPropertyAttribute; using JC = Newtonsoft.Json.JsonConverterAttribute; -using JI = Newtonsoft.Json.JsonIgnoreAttribute; namespace Iceshrimp.Backend.Core.Federation.ActivityStreams.Types; @@ -16,18 +15,51 @@ public class ASOrderedCollection : ASCollection [SetsRequiredMembers] public ASOrderedCollection(string id, bool withType = false) : this(withType) => Id = id; - [J($"{Constants.ActivityStreamsNs}#orderedItems")] - [JC(typeof(ASCollectionItemsConverter))] - [JI] - public List? OrderedItems + [J($"{Constants.ActivityStreamsNs}#items")] + [JC(typeof(ASOrderedCollectionItemsConverter))] + public new List? Items { - get => Items; - set => Items = value; + get => base.Items; + set => base.Items = value; } public new const string ObjectType = $"{Constants.ActivityStreamsNs}#OrderedCollection"; } +internal sealed class ASOrderedCollectionItemsConverter : ASCollectionItemsConverter +{ + public override bool CanWrite => true; + + public override bool CanConvert(Type objectType) => true; + + public override object? ReadJson( + JsonReader reader, Type objectType, object? existingValue, + JsonSerializer serializer + ) + { + if (reader.TokenType != JsonToken.StartArray) throw new Exception("this shouldn't happen"); + + var obj = JArray.Load(reader); + return obj.Count == 0 + ? null + : obj.SelectToken("$.[*].@list")?.Children().Select(ASObject.Deserialize).OfType().ToList(); + } + + public override void WriteJson(JsonWriter writer, object? value, JsonSerializer serializer) + { + if (value == null) + { + writer.WriteNull(); + return; + } + + writer.WriteStartObject(); + writer.WritePropertyName("@list"); + serializer.Serialize(writer, value); + writer.WriteEndObject(); + } +} + public sealed class ASOrderedCollectionConverter : JsonConverter { public override bool CanWrite => false; diff --git a/Iceshrimp.Backend/Core/Services/NoteService.cs b/Iceshrimp.Backend/Core/Services/NoteService.cs index 2af25000..e0a628b7 100644 --- a/Iceshrimp.Backend/Core/Services/NoteService.cs +++ b/Iceshrimp.Backend/Core/Services/NoteService.cs @@ -778,6 +778,8 @@ public class NoteService( public async Task BookmarkNoteAsync(Note note, User user) { + if (user.Host != null) throw new Exception("This method is only valid for local users"); + if (!await db.NoteBookmarks.AnyAsync(p => p.Note == note && p.User == user)) { var bookmark = new NoteBookmark @@ -790,13 +792,17 @@ public class NoteService( } } - public async Task UnbookmarkNoteAsync(Note note, User actor) + public async Task UnbookmarkNoteAsync(Note note, User user) { - await db.NoteBookmarks.Where(p => p.Note == note && p.User == actor).ExecuteDeleteAsync(); + if (user.Host != null) throw new Exception("This method is only valid for local users"); + + await db.NoteBookmarks.Where(p => p.Note == note && p.User == user).ExecuteDeleteAsync(); } public async Task PinNoteAsync(Note note, User user) { + if (user.Host != null) throw new Exception("This method is only valid for local users"); + if (note.User != user) throw GracefulException.UnprocessableEntity("Validation failed: Someone else's post cannot be pinned"); @@ -812,12 +818,21 @@ public class NoteService( await db.UserNotePins.AddAsync(pin); await db.SaveChangesAsync(); + + var activity = activityRenderer.RenderUpdate(await userRenderer.RenderAsync(user)); + await deliverSvc.DeliverToFollowersAsync(activity, user, []); } } - public async Task UnpinNoteAsync(Note note, User actor) + public async Task UnpinNoteAsync(Note note, User user) { - await db.UserNotePins.Where(p => p.Note == note && p.User == actor).ExecuteDeleteAsync(); + if (user.Host != null) throw new Exception("This method is only valid for local users"); + + var count = await db.UserNotePins.Where(p => p.Note == note && p.User == user).ExecuteDeleteAsync(); + if (count == 0) return; + + var activity = activityRenderer.RenderUpdate(await userRenderer.RenderAsync(user)); + await deliverSvc.DeliverToFollowersAsync(activity, user, []); } public async Task UpdatePinnedNotesAsync(ASActor actor, User user) @@ -827,9 +842,9 @@ public class NoteService( if (collection == null) return; if (collection.IsUnresolved) collection = await objectResolver.ResolveObject(collection, force: true) as ASOrderedCollection; - if (collection is not { OrderedItems: not null }) return; + if (collection is not { Items: not null }) return; - var items = await collection.OrderedItems.Take(10).Select(p => objectResolver.ResolveObject(p)).AwaitAllAsync(); + var items = await collection.Items.Take(10).Select(p => objectResolver.ResolveObject(p)).AwaitAllAsync(); var notes = await items.OfType().Select(p => ResolveNoteAsync(p.Id, p)).AwaitAllNoConcurrencyAsync(); var previousPins = await db.Users.Where(p => p.Id == user.Id) .Select(p => p.PinnedNotes.Select(i => i.Id))