[backend/federation] Context collection

This commit is contained in:
Kopper 2024-11-06 18:27:24 +03:00 committed by Laura Hausmann
parent 64e8ef03c8
commit 36d9a8cc49
No known key found for this signature in database
GPG key ID: D044E84C5BE01605
10 changed files with 277 additions and 8 deletions

View file

@ -66,6 +66,10 @@ This document **MAY** alias JSON-LD namespace IRIs to their well-known aliases.
- [FEP-67ff: FEDERATION.md](https://codeberg.org/fediverse/fep/src/branch/main/fep/67ff/fep-67ff.md) - [FEP-67ff: FEDERATION.md](https://codeberg.org/fediverse/fep/src/branch/main/fep/67ff/fep-67ff.md)
- [FEP-2c59: Discovery of a Webfinger address from an ActivityPub actor](https://codeberg.org/fediverse/fep/src/branch/main/fep/2c59/fep-2c59.md) - [FEP-2c59: Discovery of a Webfinger address from an ActivityPub actor](https://codeberg.org/fediverse/fep/src/branch/main/fep/2c59/fep-2c59.md)
- [FEP-9fde: Mechanism for servers to expose supported operations](https://codeberg.org/fediverse/fep/src/branch/main/fep/9fde/fep-9fde.md) - [FEP-9fde: Mechanism for servers to expose supported operations](https://codeberg.org/fediverse/fep/src/branch/main/fep/9fde/fep-9fde.md)
- [FEP-7888: Demystifying the context property](https://codeberg.org/fediverse/fep/src/branch/main/fep/7888/fep-7888.md)
+ Specifically, we use it in a "conversational context" sense, where each note has an attached context, which maps to an internal "thread".
+ We currently do not use the context for anything other than grouping.
+ Our context collections contain objects, not activities.
## FEPs we intend to support in the future ## FEPs we intend to support in the future
- [FEP-8fcf: Followers collection synchronization across servers](https://codeberg.org/fediverse/fep/src/branch/main/fep/8fcf/fep-8fcf.md) - [FEP-8fcf: Followers collection synchronization across servers](https://codeberg.org/fediverse/fep/src/branch/main/fep/8fcf/fep-8fcf.md)

View file

@ -107,6 +107,38 @@ public class ActivityPubController(
return res.Compact(); return res.Compact();
} }
[HttpGet("/threads/{id}")]
[AuthorizedFetch]
[OverrideResultType<ASOrderedCollection>]
[ProducesResults(HttpStatusCode.OK)]
[ProducesErrors(HttpStatusCode.NotFound)]
public async Task<JObject> GetThread(string id)
{
var actor = HttpContext.GetActor();
var thread = await db.NoteThreads
.Include(p => p.User)
.FirstOrDefaultAsync(p => p.Id == id && p.User != null && p.User.IsLocalUser) ??
throw GracefulException.NotFound("Thread not found");
var notes = await db.Notes
.Where(p => p.ThreadId == id)
.EnsureVisibleFor(actor)
.OrderByDescending(p => p.Id)
.Select(p => new Note { Id = p.Id, Uri = p.Uri })
.ToListAsync();
var rendered = notes.Select(noteRenderer.RenderLite).Cast<ASObject>().ToList();
var res = new ASOrderedCollection
{
Id = thread.GetPublicUri(config.Value),
AttributedTo = [new ASObjectBase(thread.User!.GetPublicUri(config.Value))],
TotalItems = (ulong)rendered.Count,
Items = rendered
};
return res.Compact();
}
[HttpGet("/users/{id}")] [HttpGet("/users/{id}")]
[AuthorizedFetch] [AuthorizedFetch]
[OutputCache(PolicyName = "federation")] [OutputCache(PolicyName = "federation")]

View file

@ -2807,8 +2807,27 @@ namespace Iceshrimp.Backend.Core.Database.Migrations
.HasColumnType("timestamp with time zone") .HasColumnType("timestamp with time zone")
.HasColumnName("backfilledAt"); .HasColumnName("backfilledAt");
b.Property<bool?>("IsResolvable")
.HasColumnType("boolean")
.HasColumnName("isResolvable");
b.Property<string>("Uri")
.HasMaxLength(512)
.HasColumnType("character varying(512)")
.HasColumnName("uri");
b.Property<string>("UserId")
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasColumnName("userId");
b.HasKey("Id"); b.HasKey("Id");
b.HasIndex("Uri")
.IsUnique();
b.HasIndex("UserId");
b.ToTable("note_thread"); b.ToTable("note_thread");
}); });
@ -5374,6 +5393,16 @@ namespace Iceshrimp.Backend.Core.Database.Migrations
b.Navigation("User"); b.Navigation("User");
}); });
modelBuilder.Entity("Iceshrimp.Backend.Core.Database.Tables.NoteThread", b =>
{
b.HasOne("Iceshrimp.Backend.Core.Database.Tables.User", "User")
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("User");
});
modelBuilder.Entity("Iceshrimp.Backend.Core.Database.Tables.NoteThreadMuting", b => modelBuilder.Entity("Iceshrimp.Backend.Core.Database.Tables.NoteThreadMuting", b =>
{ {
b.HasOne("Iceshrimp.Backend.Core.Database.Tables.NoteThread", "Thread") b.HasOne("Iceshrimp.Backend.Core.Database.Tables.NoteThread", "Thread")

View file

@ -0,0 +1,74 @@
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Infrastructure;
#nullable disable
namespace Iceshrimp.Backend.Core.Database.Migrations
{
/// <inheritdoc />
[DbContext(typeof(DatabaseContext))]
[Migration("20241106151513_AddNoteThreadContext")]
public partial class AddNoteThreadContext : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "isResolvable",
table: "note_thread",
type: "boolean",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "uri",
table: "note_thread",
type: "character varying(512)",
maxLength: 512,
nullable: true);
migrationBuilder.AddColumn<string>(
name: "userId",
table: "note_thread",
type: "character varying(32)",
maxLength: 32,
nullable: true);
migrationBuilder.CreateIndex(
name: "IX_note_thread_userId",
table: "note_thread",
column: "userId");
migrationBuilder.AddForeignKey(
name: "FK_note_thread_user_userId",
table: "note_thread",
column: "userId",
principalTable: "user",
principalColumn: "id",
onDelete: ReferentialAction.SetNull);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_note_thread_user_userId",
table: "note_thread");
migrationBuilder.DropIndex(
name: "IX_note_thread_userId",
table: "note_thread");
migrationBuilder.DropColumn(
name: "isResolvable",
table: "note_thread");
migrationBuilder.DropColumn(
name: "uri",
table: "note_thread");
migrationBuilder.DropColumn(
name: "userId",
table: "note_thread");
}
}
}

View file

@ -0,0 +1,31 @@
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Infrastructure;
#nullable disable
namespace Iceshrimp.Backend.Core.Database.Migrations
{
/// <inheritdoc />
[DbContext(typeof(DatabaseContext))]
[Migration("20241106163544_IndexNoteThreadContext")]
public partial class IndexNoteThreadContext : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateIndex(
name: "IX_note_thread_uri",
table: "note_thread",
column: "uri",
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "IX_note_thread_uri",
table: "note_thread");
}
}
}

View file

@ -1,9 +1,15 @@
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema; using System.ComponentModel.DataAnnotations.Schema;
using System.Diagnostics.CodeAnalysis;
using EntityFrameworkCore.Projectables;
using Iceshrimp.Backend.Core.Configuration;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace Iceshrimp.Backend.Core.Database.Tables; namespace Iceshrimp.Backend.Core.Database.Tables;
[Table("note_thread")] [Table("note_thread")]
[Index(nameof(Uri), IsUnique = true)]
public class NoteThread : IEntity public class NoteThread : IEntity
{ {
[Column("id")] [Column("id")]
@ -16,9 +22,46 @@ public class NoteThread : IEntity
[Column("backfilledAt")] [Column("backfilledAt")]
public DateTime? BackfilledAt { get; set; } public DateTime? BackfilledAt { get; set; }
/// <summary>
/// The ID of the collection representing this thread. Will be null when the thread not part of a context collection.
/// </summary>
[Column("uri")]
[StringLength(512)]
public string? Uri { get; set; }
/// <summary>
/// The User owning this thread. Will be null if unknown. Determined by the context collection's attributedTo property.
/// </summary>
[Column("userId")]
[StringLength(32)]
public string? UserId { get; set; }
/// <summary>
/// Is the context collection associated with this thread resolvable? Null if this is a local thread that we don't need to resolve.
/// </summary>
[Column("isResolvable")]
public bool? IsResolvable { get; set; } = false;
[InverseProperty(nameof(Note.Thread))] [InverseProperty(nameof(Note.Thread))]
public virtual ICollection<Note> Notes { get; set; } = new List<Note>(); public virtual ICollection<Note> Notes { get; set; } = new List<Note>();
[InverseProperty(nameof(NoteThreadMuting.Thread))] [InverseProperty(nameof(NoteThreadMuting.Thread))]
public virtual ICollection<NoteThreadMuting> NoteThreadMutings { get; set; } = new List<NoteThreadMuting>(); public virtual ICollection<NoteThreadMuting> NoteThreadMutings { get; set; } = new List<NoteThreadMuting>();
[ForeignKey(nameof(UserId))]
public virtual User? User { get; set; }
[Projectable]
[SuppressMessage("ReSharper", "MergeIntoPattern", Justification = "Projectables does not support this")]
public string? GetPublicUri(Config.InstanceSection config) => User != null && User.IsLocalUser ? $"https://{config.WebDomain}/threads/{Id}" : null;
private class NoteThreadConfiguration : IEntityTypeConfiguration<NoteThread>
{
public void Configure(EntityTypeBuilder<NoteThread> entity)
{
entity.HasOne(e => e.User)
.WithMany()
.OnDelete(DeleteBehavior.SetNull);
}
}
} }

View file

@ -62,6 +62,12 @@ public class NoteRenderer(
}) })
.ToListAsync(); .ToListAsync();
var contextId = await db.NoteThreads
.Where(p => p.Id == note.ThreadId)
.Select(p => p.Uri ?? p.GetPublicUri(config.Value))
.FirstOrDefaultAsync();
var context = contextId != null ? new ASCollection(contextId) : null;
var emoji = note.Emojis.Count != 0 var emoji = note.Emojis.Count != 0
? await db.Emojis.Where(p => note.Emojis.Contains(p.Id) && p.Host == null).ToListAsync() ? await db.Emojis.Where(p => note.Emojis.Contains(p.Id) && p.Host == null).ToListAsync()
: []; : [];
@ -166,6 +172,7 @@ public class NoteRenderer(
Sensitive = sensitive, Sensitive = sensitive,
InReplyTo = replyId, InReplyTo = replyId,
Replies = replies, Replies = replies,
Context = context,
Cc = cc, Cc = cc,
To = to, To = to,
Tags = tags, Tags = tags,
@ -197,6 +204,7 @@ public class NoteRenderer(
Sensitive = sensitive, Sensitive = sensitive,
InReplyTo = replyId, InReplyTo = replyId,
Replies = replies, Replies = replies,
Context = context,
Cc = cc, Cc = cc,
To = to, To = to,
Tags = tags, Tags = tags,

View file

@ -18,6 +18,9 @@ public class ASCollection : ASObject
[SetsRequiredMembers] [SetsRequiredMembers]
public ASCollection(string id, bool withType = false) : this(withType) => Id = id; public ASCollection(string id, bool withType = false) : this(withType) => Id = id;
[J($"{Constants.ActivityStreamsNs}#attributedTo")]
public List<ASObjectBase>? AttributedTo { get; set; }
[J($"{Constants.ActivityStreamsNs}#items")] [J($"{Constants.ActivityStreamsNs}#items")]
[JC(typeof(ASCollectionItemsConverter))] [JC(typeof(ASCollectionItemsConverter))]
public List<ASObject>? Items { get; set; } public List<ASObject>? Items { get; set; }

View file

@ -91,6 +91,10 @@ public class ASNote : ASObjectWithId
[JC(typeof(ASCollectionConverter))] [JC(typeof(ASCollectionConverter))]
public ASCollection? Replies { get; set; } public ASCollection? Replies { get; set; }
[J($"{Constants.ActivityStreamsNs}#context")]
[JC(typeof(ASCollectionConverter))]
public ASCollection? Context { get; set; }
public Note.NoteVisibility GetVisibility(User actor) public Note.NoteVisibility GetVisibility(User actor)
{ {
if (actor.IsLocalUser) throw new Exception("Can't get recipients for local actor"); if (actor.IsLocalUser) throw new Exception("Can't get recipients for local actor");

View file

@ -252,8 +252,49 @@ public class NoteService(
var noteId = IdHelpers.GenerateSnowflakeId(data.CreatedAt); var noteId = IdHelpers.GenerateSnowflakeId(data.CreatedAt);
var threadId = data.Reply?.ThreadId ?? noteId; var threadId = data.Reply?.ThreadId ?? noteId;
var thread = await db.NoteThreads.Where(t => t.Id == threadId).FirstOrDefaultAsync() ?? var context = data.ASNote?.Context;
new NoteThread { Id = threadId }; var contextId = context?.Id;
var thread = contextId != null
? await db.NoteThreads.Where(t => t.Uri == contextId || t.Id == threadId).FirstOrDefaultAsync()
: await db.NoteThreads.Where(t => t.Id == threadId).FirstOrDefaultAsync();
var contextOwner = data.User.IsLocalUser ? data.User : null;
bool? contextResolvable = data.User.IsLocalUser ? null : false;
if (thread == null && context != null)
{
try
{
if (await objectResolver.ResolveObjectAsync(context) is ASCollection maybeContext)
{
context = maybeContext;
contextResolvable = true;
var owner = context.AttributedTo?.FirstOrDefault();
if (owner?.Id != null
&& Uri.TryCreate(owner.Id, UriKind.Absolute, out var ownerUri)
&& Uri.TryCreate(contextId, UriKind.Absolute, out var contextUri)
&& ownerUri.Host == contextUri.Host)
contextOwner = await userResolver.ResolveOrNullAsync(owner.Id, ResolveFlags.Uri);
}
}
catch
{
/*
* some instance software such as the Pleroma family expose a context that isn't resolvable, which is permitted by spec.
* in that case we still use it for threading but mark it as unresolvable.
*/
}
}
thread ??= new NoteThread
{
Id = threadId,
Uri = contextId,
User = contextOwner,
IsResolvable = contextResolvable,
};
var note = new Note var note = new Note
{ {