[backend/federation] Federate pinned notes (ISH-111)

This commit is contained in:
Laura Hausmann 2024-03-04 04:09:29 +01:00
parent c9152badbf
commit 4c4488d522
No known key found for this signature in database
GPG key ID: D044E84C5BE01605
6 changed files with 106 additions and 15 deletions

View file

@ -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<IActionResult> GetUserFeaturedActivity(string id)
{
return await apController.GetUserFeatured(id);
}
[UseNewtonsoftJson]
[HttpGet("activities/users/@{acct}")]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(ASActor))]

View file

@ -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.InstanceSection> 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<IActionResult> 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<ASObject>().ToList()
};
var compacted = LdHelpers.Compact(res);
return Ok(compacted);
}
[HttpGet("/@{acct}")]
[AuthorizedFetch]
[MediaTypeRouteFilter("application/activity+json", "application/ld+json")]

View file

@ -63,6 +63,7 @@ public class UserRenderer(IOptions<Config.InstanceSection> 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,

View file

@ -81,7 +81,7 @@ public sealed class ASCollectionConverter : JsonConverter
}
}
internal sealed class ASCollectionItemsConverter : JsonConverter
internal class ASCollectionItemsConverter : JsonConverter
{
public override bool CanWrite => false;

View file

@ -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<ASObject>? OrderedItems
[J($"{Constants.ActivityStreamsNs}#items")]
[JC(typeof(ASOrderedCollectionItemsConverter))]
public new List<ASObject>? 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<ASObject>().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;

View file

@ -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<ASNote>().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))