[backend/federation] Federate pinned notes (ISH-111)
This commit is contained in:
parent
c9152badbf
commit
4c4488d522
6 changed files with 106 additions and 15 deletions
|
@ -71,6 +71,15 @@ public class AdminController(
|
||||||
return await apController.GetUser(id);
|
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]
|
[UseNewtonsoftJson]
|
||||||
[HttpGet("activities/users/@{acct}")]
|
[HttpGet("activities/users/@{acct}")]
|
||||||
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(ASActor))]
|
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(ASActor))]
|
||||||
|
|
|
@ -2,6 +2,7 @@ using System.Text;
|
||||||
using Iceshrimp.Backend.Controllers.Attributes;
|
using Iceshrimp.Backend.Controllers.Attributes;
|
||||||
using Iceshrimp.Backend.Controllers.Federation.Attributes;
|
using Iceshrimp.Backend.Controllers.Federation.Attributes;
|
||||||
using Iceshrimp.Backend.Controllers.Schemas;
|
using Iceshrimp.Backend.Controllers.Schemas;
|
||||||
|
using Iceshrimp.Backend.Core.Configuration;
|
||||||
using Iceshrimp.Backend.Core.Database;
|
using Iceshrimp.Backend.Core.Database;
|
||||||
using Iceshrimp.Backend.Core.Extensions;
|
using Iceshrimp.Backend.Core.Extensions;
|
||||||
using Iceshrimp.Backend.Core.Federation.ActivityStreams;
|
using Iceshrimp.Backend.Core.Federation.ActivityStreams;
|
||||||
|
@ -11,6 +12,7 @@ using Iceshrimp.Backend.Core.Queues;
|
||||||
using Iceshrimp.Backend.Core.Services;
|
using Iceshrimp.Backend.Core.Services;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
|
||||||
namespace Iceshrimp.Backend.Controllers.Federation;
|
namespace Iceshrimp.Backend.Controllers.Federation;
|
||||||
|
|
||||||
|
@ -21,7 +23,8 @@ public class ActivityPubController(
|
||||||
DatabaseContext db,
|
DatabaseContext db,
|
||||||
QueueService queues,
|
QueueService queues,
|
||||||
ActivityPub.NoteRenderer noteRenderer,
|
ActivityPub.NoteRenderer noteRenderer,
|
||||||
ActivityPub.UserRenderer userRenderer
|
ActivityPub.UserRenderer userRenderer,
|
||||||
|
IOptions<Config.InstanceSection> config
|
||||||
) : ControllerBase
|
) : ControllerBase
|
||||||
{
|
{
|
||||||
[HttpGet("/notes/{id}")]
|
[HttpGet("/notes/{id}")]
|
||||||
|
@ -58,6 +61,37 @@ public class ActivityPubController(
|
||||||
return Ok(compacted);
|
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}")]
|
[HttpGet("/@{acct}")]
|
||||||
[AuthorizedFetch]
|
[AuthorizedFetch]
|
||||||
[MediaTypeRouteFilter("application/activity+json", "application/ld+json")]
|
[MediaTypeRouteFilter("application/activity+json", "application/ld+json")]
|
||||||
|
|
|
@ -63,6 +63,7 @@ public class UserRenderer(IOptions<Config.InstanceSection> config, DatabaseConte
|
||||||
IsCat = user.IsCat,
|
IsCat = user.IsCat,
|
||||||
IsDiscoverable = user.IsExplorable,
|
IsDiscoverable = user.IsExplorable,
|
||||||
IsLocked = user.IsLocked,
|
IsLocked = user.IsLocked,
|
||||||
|
Featured = new ASOrderedCollection($"{id}/collections/featured"),
|
||||||
Avatar = user.AvatarUrl != null
|
Avatar = user.AvatarUrl != null
|
||||||
? new ASImage { Url = new ASLink(user.AvatarUrl) }
|
? new ASImage { Url = new ASLink(user.AvatarUrl) }
|
||||||
: null,
|
: null,
|
||||||
|
|
|
@ -81,7 +81,7 @@ public sealed class ASCollectionConverter : JsonConverter
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
internal sealed class ASCollectionItemsConverter : JsonConverter
|
internal class ASCollectionItemsConverter : JsonConverter
|
||||||
{
|
{
|
||||||
public override bool CanWrite => false;
|
public override bool CanWrite => false;
|
||||||
|
|
||||||
|
|
|
@ -4,7 +4,6 @@ using Newtonsoft.Json;
|
||||||
using Newtonsoft.Json.Linq;
|
using Newtonsoft.Json.Linq;
|
||||||
using J = Newtonsoft.Json.JsonPropertyAttribute;
|
using J = Newtonsoft.Json.JsonPropertyAttribute;
|
||||||
using JC = Newtonsoft.Json.JsonConverterAttribute;
|
using JC = Newtonsoft.Json.JsonConverterAttribute;
|
||||||
using JI = Newtonsoft.Json.JsonIgnoreAttribute;
|
|
||||||
|
|
||||||
namespace Iceshrimp.Backend.Core.Federation.ActivityStreams.Types;
|
namespace Iceshrimp.Backend.Core.Federation.ActivityStreams.Types;
|
||||||
|
|
||||||
|
@ -16,18 +15,51 @@ public class ASOrderedCollection : ASCollection
|
||||||
[SetsRequiredMembers]
|
[SetsRequiredMembers]
|
||||||
public ASOrderedCollection(string id, bool withType = false) : this(withType) => Id = id;
|
public ASOrderedCollection(string id, bool withType = false) : this(withType) => Id = id;
|
||||||
|
|
||||||
[J($"{Constants.ActivityStreamsNs}#orderedItems")]
|
[J($"{Constants.ActivityStreamsNs}#items")]
|
||||||
[JC(typeof(ASCollectionItemsConverter))]
|
[JC(typeof(ASOrderedCollectionItemsConverter))]
|
||||||
[JI]
|
public new List<ASObject>? Items
|
||||||
public List<ASObject>? OrderedItems
|
|
||||||
{
|
{
|
||||||
get => Items;
|
get => base.Items;
|
||||||
set => Items = value;
|
set => base.Items = value;
|
||||||
}
|
}
|
||||||
|
|
||||||
public new const string ObjectType = $"{Constants.ActivityStreamsNs}#OrderedCollection";
|
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 sealed class ASOrderedCollectionConverter : JsonConverter
|
||||||
{
|
{
|
||||||
public override bool CanWrite => false;
|
public override bool CanWrite => false;
|
||||||
|
|
|
@ -778,6 +778,8 @@ public class NoteService(
|
||||||
|
|
||||||
public async Task BookmarkNoteAsync(Note note, User user)
|
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))
|
if (!await db.NoteBookmarks.AnyAsync(p => p.Note == note && p.User == user))
|
||||||
{
|
{
|
||||||
var bookmark = new NoteBookmark
|
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)
|
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)
|
if (note.User != user)
|
||||||
throw GracefulException.UnprocessableEntity("Validation failed: Someone else's post cannot be pinned");
|
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.UserNotePins.AddAsync(pin);
|
||||||
await db.SaveChangesAsync();
|
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)
|
public async Task UpdatePinnedNotesAsync(ASActor actor, User user)
|
||||||
|
@ -827,9 +842,9 @@ public class NoteService(
|
||||||
if (collection == null) return;
|
if (collection == null) return;
|
||||||
if (collection.IsUnresolved)
|
if (collection.IsUnresolved)
|
||||||
collection = await objectResolver.ResolveObject(collection, force: true) as ASOrderedCollection;
|
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 notes = await items.OfType<ASNote>().Select(p => ResolveNoteAsync(p.Id, p)).AwaitAllNoConcurrencyAsync();
|
||||||
var previousPins = await db.Users.Where(p => p.Id == user.Id)
|
var previousPins = await db.Users.Where(p => p.Id == user.Id)
|
||||||
.Select(p => p.PinnedNotes.Select(i => i.Id))
|
.Select(p => p.PinnedNotes.Select(i => i.Id))
|
||||||
|
|
Loading…
Add table
Reference in a new issue