[backend/federation] Context collection
This commit is contained in:
parent
64e8ef03c8
commit
36d9a8cc49
10 changed files with 277 additions and 8 deletions
|
@ -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)
|
||||||
|
|
|
@ -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")]
|
||||||
|
|
|
@ -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")
|
||||||
|
|
|
@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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; }
|
||||||
|
|
|
@ -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");
|
||||||
|
|
|
@ -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
|
||||||
{
|
{
|
||||||
|
|
Loading…
Add table
Reference in a new issue