[backend/federation] Add relay support (ISH-266)
This commit is contained in:
parent
a5fa4e4be9
commit
df26db0585
17 changed files with 307 additions and 27 deletions
|
@ -33,7 +33,8 @@ public class AdminController(
|
|||
ActivityPub.NoteRenderer noteRenderer,
|
||||
ActivityPub.UserRenderer userRenderer,
|
||||
IOptions<Config.InstanceSection> config,
|
||||
QueueService queueSvc
|
||||
QueueService queueSvc,
|
||||
RelayService relaySvc
|
||||
) : ControllerBase
|
||||
{
|
||||
[HttpPost("invites/generate")]
|
||||
|
@ -144,6 +145,38 @@ public class AdminController(
|
|||
await queueSvc.AbandonJobAsync(job);
|
||||
}
|
||||
|
||||
[HttpGet("relays")]
|
||||
[ProducesResults(HttpStatusCode.OK)]
|
||||
public async Task<List<RelaySchemas.RelayResponse>> GetRelays()
|
||||
{
|
||||
return await db.Relays
|
||||
.ToArrayAsync()
|
||||
.ContinueWithResult(res => res.Select(p => new RelaySchemas.RelayResponse
|
||||
{
|
||||
Id = p.Id,
|
||||
Inbox = p.Inbox,
|
||||
Status = (RelaySchemas.RelayStatus)p.Status
|
||||
})
|
||||
.ToList());
|
||||
}
|
||||
|
||||
[HttpPost("relays")]
|
||||
[ProducesResults(HttpStatusCode.OK)]
|
||||
public async Task SubscribeToRelay(RelaySchemas.RelayRequest rq)
|
||||
{
|
||||
await relaySvc.SubscribeToRelay(rq.Inbox);
|
||||
}
|
||||
|
||||
[HttpDelete("relays/{id}")]
|
||||
[ProducesResults(HttpStatusCode.OK)]
|
||||
[ProducesErrors(HttpStatusCode.NotFound)]
|
||||
public async Task UnsubscribeFromRelay(string id)
|
||||
{
|
||||
var relay = await db.Relays.FirstOrDefaultAsync(p => p.Id == id) ??
|
||||
throw GracefulException.NotFound("Relay not found");
|
||||
await relaySvc.UnsubscribeFromRelay(relay);
|
||||
}
|
||||
|
||||
[UseNewtonsoftJson]
|
||||
[HttpGet("activities/notes/{id}")]
|
||||
[OverrideResultType<ASNote>]
|
||||
|
|
|
@ -4053,6 +4053,10 @@ namespace Iceshrimp.Backend.Core.Database.Migrations
|
|||
.HasColumnName("isModerator")
|
||||
.HasComment("Whether the User is a moderator.");
|
||||
|
||||
b.Property<bool>("IsRelayActor")
|
||||
.HasColumnType("boolean")
|
||||
.HasColumnName("isRelayActor");
|
||||
|
||||
b.Property<bool>("IsSilenced")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("boolean")
|
||||
|
@ -4067,6 +4071,10 @@ namespace Iceshrimp.Backend.Core.Database.Migrations
|
|||
.HasColumnName("isSuspended")
|
||||
.HasComment("Whether the User is suspended.");
|
||||
|
||||
b.Property<bool>("IsSystem")
|
||||
.HasColumnType("boolean")
|
||||
.HasColumnName("isSystem");
|
||||
|
||||
b.Property<DateTime?>("LastActiveDate")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("lastActiveDate");
|
||||
|
|
|
@ -0,0 +1,51 @@
|
|||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Iceshrimp.Backend.Core.Database.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
[DbContext(typeof(DatabaseContext))]
|
||||
[Migration("20240927214613_AddRelayAndSystemUserColumns")]
|
||||
public partial class AddRelayAndSystemUserColumns : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "isRelayActor",
|
||||
table: "user",
|
||||
type: "boolean",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "isSystem",
|
||||
table: "user",
|
||||
type: "boolean",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
|
||||
migrationBuilder.Sql("""
|
||||
UPDATE "user" SET "isSystem" = TRUE WHERE "host" IS NULL AND ("usernameLower" = 'instance.actor' OR "usernameLower" = 'relay.actor');
|
||||
""");
|
||||
|
||||
migrationBuilder.Sql("""
|
||||
UPDATE "user" SET "isRelayActor" = TRUE WHERE "inbox" IN (SELECT "inbox" FROM "relay");
|
||||
""");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "isRelayActor",
|
||||
table: "user");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "isSystem",
|
||||
table: "user");
|
||||
}
|
||||
}
|
||||
}
|
|
@ -12,9 +12,9 @@ public class Relay
|
|||
[PgName("relay_status_enum")]
|
||||
public enum RelayStatus
|
||||
{
|
||||
[PgName("requesting")] Requesting,
|
||||
[PgName("accepted")] Accepted,
|
||||
[PgName("rejected")] Rejected
|
||||
[PgName("requesting")] Requesting = 0,
|
||||
[PgName("accepted")] Accepted = 1,
|
||||
[PgName("rejected")] Rejected = 2
|
||||
}
|
||||
|
||||
[Key]
|
||||
|
|
|
@ -124,6 +124,10 @@ public class User : IEntity
|
|||
[Column("isBot")]
|
||||
public bool IsBot { get; set; }
|
||||
|
||||
[Column("isSystem")] public bool IsSystem { get; set; }
|
||||
|
||||
[Column("isRelayActor")] public bool IsRelayActor { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether the User is a cat.
|
||||
/// </summary>
|
||||
|
@ -491,8 +495,8 @@ public class User : IEntity
|
|||
[NotMapped] public bool? PrecomputedIsRequested { get; set; }
|
||||
[NotMapped] public bool? PrecomputedIsRequestedBy { get; set; }
|
||||
|
||||
[Projectable] public bool IsLocalUser => Host == null;
|
||||
[Projectable] public bool IsRemoteUser => Host != null;
|
||||
[Projectable] public bool IsLocalUser => Host == null;
|
||||
[Projectable] public bool IsRemoteUser => Host != null;
|
||||
[Projectable] public string IdenticonUrlPath => $"/identicon/{Id}";
|
||||
|
||||
[Key]
|
||||
|
|
|
@ -82,7 +82,8 @@ public static class ServiceExtensions
|
|||
.AddScoped<UserProfileRenderer>()
|
||||
.AddScoped<CacheService>()
|
||||
.AddScoped<MetaService>()
|
||||
.AddScoped<StorageMaintenanceService>();
|
||||
.AddScoped<StorageMaintenanceService>()
|
||||
.AddScoped<RelayService>();
|
||||
|
||||
// Singleton = instantiated once across application lifetime
|
||||
services
|
||||
|
|
|
@ -64,4 +64,19 @@ public class ActivityDeliverService(
|
|||
|
||||
await DeliverToAsync(activity, actor, recipients);
|
||||
}
|
||||
|
||||
public async Task DeliverToAsync(ASActivity activity, User actor, string recipientInbox)
|
||||
{
|
||||
logger.LogDebug("Queuing deliver-to-inbox job for activity {id}", activity.Id);
|
||||
if (activity.Actor == null) throw new Exception("Actor must not be null");
|
||||
|
||||
await queueService.DeliverQueue.EnqueueAsync(new DeliverJobData
|
||||
{
|
||||
RecipientHost = new Uri(recipientInbox).Host,
|
||||
InboxUrl = recipientInbox,
|
||||
Payload = activity.CompactToPayload(),
|
||||
ContentType = "application/activity+json",
|
||||
UserId = actor.Id
|
||||
});
|
||||
}
|
||||
}
|
|
@ -26,7 +26,8 @@ public class ActivityHandlerService(
|
|||
ObjectResolver objectResolver,
|
||||
FollowupTaskService followupTaskSvc,
|
||||
EmojiService emojiSvc,
|
||||
EventService eventSvc
|
||||
EventService eventSvc,
|
||||
RelayService relaySvc
|
||||
)
|
||||
{
|
||||
public async Task PerformActivityAsync(ASActivity activity, string? inboxUserId, string? authenticatedUserId)
|
||||
|
@ -187,6 +188,13 @@ public class ActivityHandlerService(
|
|||
if (activity.Object is not ASFollow obj)
|
||||
throw GracefulException.UnprocessableEntity("Accept activity object is invalid");
|
||||
|
||||
var relayPrefix = $"https://{config.Value.WebDomain}/activities/follow-relay/";
|
||||
if (obj.Id.StartsWith(relayPrefix))
|
||||
{
|
||||
await relaySvc.HandleAccept(actor, obj.Id[relayPrefix.Length..]);
|
||||
return;
|
||||
}
|
||||
|
||||
var prefix = $"https://{config.Value.WebDomain}/follows/";
|
||||
if (!obj.Id.StartsWith(prefix))
|
||||
throw GracefulException.UnprocessableEntity($"Object id '{obj.Id}' not a valid follow request id");
|
||||
|
@ -227,6 +235,13 @@ public class ActivityHandlerService(
|
|||
if (activity.Object is not ASFollow follow)
|
||||
throw GracefulException.UnprocessableEntity("Reject activity object is invalid");
|
||||
|
||||
var relayPrefix = $"https://{config.Value.WebDomain}/activities/follow-relay/";
|
||||
if (follow.Id.StartsWith(relayPrefix))
|
||||
{
|
||||
await relaySvc.HandleReject(resolvedActor, follow.Id[relayPrefix.Length..]);
|
||||
return;
|
||||
}
|
||||
|
||||
if (follow is not { Actor: not null })
|
||||
throw GracefulException.UnprocessableEntity("Refusing to reject object with invalid follow object");
|
||||
|
||||
|
@ -436,6 +451,9 @@ public class ActivityHandlerService(
|
|||
return;
|
||||
}
|
||||
|
||||
if (resolvedActor.IsRelayActor)
|
||||
return;
|
||||
|
||||
if (await db.Notes.AnyAsync(p => p.Uri == activity.Id))
|
||||
{
|
||||
logger.LogDebug("Renote '{id}' already exists, skipping", activity.Id);
|
||||
|
|
|
@ -112,6 +112,16 @@ public class ActivityRenderer(
|
|||
RenderFollowId(follower, followee, relationshipId));
|
||||
}
|
||||
|
||||
public ASFollow RenderFollow(User actor, Relay relay)
|
||||
{
|
||||
return new ASFollow
|
||||
{
|
||||
Id = $"https://{config.Value.WebDomain}/activities/follow-relay/{relay.Id}",
|
||||
Actor = userRenderer.RenderLite(actor),
|
||||
Object = new ASObject { Id = "https://www.w3.org/ns/activitystreams#Public" }
|
||||
};
|
||||
}
|
||||
|
||||
public ASActivity RenderUnfollow(User follower, User followee, Guid? relationshipId)
|
||||
{
|
||||
if (follower.IsLocalUser && followee.IsLocalUser)
|
||||
|
|
|
@ -174,9 +174,21 @@ public class UserResolver(
|
|||
return (finalAcct, finalUri);
|
||||
}
|
||||
|
||||
private static string? GetAcctUri(WebFingerResponse fingerRes) => (fingerRes.Aliases ?? [])
|
||||
.Prepend(fingerRes.Subject)
|
||||
.FirstOrDefault(p => p.StartsWith("acct:"));
|
||||
private static string? GetAcctUri(WebFingerResponse fingerRes)
|
||||
{
|
||||
var acct = (fingerRes.Aliases ?? [])
|
||||
.Prepend(fingerRes.Subject)
|
||||
.FirstOrDefault(p => p.StartsWith("acct:"));
|
||||
if (acct != null) return acct;
|
||||
|
||||
// AodeRelay doesn't prefix its actor's subject with acct, so we have to fall back to guessing here
|
||||
acct = (fingerRes.Aliases ?? [])
|
||||
.Prepend(fingerRes.Subject)
|
||||
.FirstOrDefault(p => !p.Contains(':') &&
|
||||
!p.Contains(' ') &&
|
||||
p.Split("@").Length == 2);
|
||||
return acct is not null ? $"acct:{acct}" : acct;
|
||||
}
|
||||
|
||||
public static string NormalizeQuery(string query)
|
||||
{
|
||||
|
|
|
@ -91,6 +91,9 @@ public class ASAnnounce : ASActivity
|
|||
public class ASDelete : ASActivity
|
||||
{
|
||||
public ASDelete() => Type = Types.Delete;
|
||||
|
||||
[J($"{Constants.ActivityStreamsNs}#to")]
|
||||
public List<ASObjectBase>? To { get; set; }
|
||||
}
|
||||
|
||||
public class ASFollow : ASActivity
|
||||
|
|
|
@ -161,8 +161,10 @@ public class InboxValidationMiddleware(
|
|||
logger.LogDebug("Error validating HTTP signature: {error}", e.Message);
|
||||
}
|
||||
|
||||
if ((!verified || (key?.User.Uri != null && activity.Actor?.Id != key.User.Uri)) &&
|
||||
(activity is ASDelete || config.Value.AcceptLdSignatures))
|
||||
if (
|
||||
(!verified || (key?.User.Uri != null && activity.Actor?.Id != key.User.Uri)) &&
|
||||
(activity is ASDelete || config.Value.AcceptLdSignatures)
|
||||
)
|
||||
{
|
||||
if (activity is ASDelete)
|
||||
logger.LogDebug("Activity is ASDelete & actor uri is not matching, trying LD signature next...");
|
||||
|
|
|
@ -80,19 +80,43 @@ public class PreDeliverQueue(int parallelism)
|
|||
}
|
||||
|
||||
foreach (var inboxQueryResult in inboxQueryResults)
|
||||
{
|
||||
// @formatter:off
|
||||
await queueSvc.DeliverQueue.EnqueueAsync(new DeliverJobData
|
||||
{
|
||||
RecipientHost =
|
||||
inboxQueryResult.Host ??
|
||||
throw new Exception("Recipient host must not be null"),
|
||||
InboxUrl =
|
||||
inboxQueryResult.InboxUrl ??
|
||||
throw new
|
||||
Exception("Recipient inboxUrl must not be null"),
|
||||
Payload = payload,
|
||||
ContentType = "application/activity+json",
|
||||
UserId = jobData.ActorId
|
||||
RecipientHost = inboxQueryResult.Host ?? throw new Exception("Recipient host must not be null"),
|
||||
InboxUrl = inboxQueryResult.InboxUrl ?? throw new Exception("Recipient inboxUrl must not be null"),
|
||||
Payload = payload,
|
||||
ContentType = "application/activity+json",
|
||||
UserId = jobData.ActorId
|
||||
});
|
||||
// @formatter:on
|
||||
}
|
||||
|
||||
if (activity is ASCreate or ASDelete { Object: ASNote })
|
||||
{
|
||||
var relays = await db.Relays.ToArrayAsync(token);
|
||||
if (relays is []) return;
|
||||
|
||||
if (!config.Value.AttachLdSignatures || activity is ASDelete)
|
||||
{
|
||||
if (activity is ASDelete del) del.To = [new ASObjectBase($"{Constants.ActivityStreamsNs}#Public")];
|
||||
var keypair = await db.UserKeypairs.FirstAsync(p => p.UserId == jobData.ActorId, token);
|
||||
payload = await activity.SignAndCompactAsync(keypair);
|
||||
}
|
||||
|
||||
foreach (var relay in relays)
|
||||
{
|
||||
await queueSvc.DeliverQueue.EnqueueAsync(new DeliverJobData
|
||||
{
|
||||
RecipientHost = new Uri(relay.Inbox).Host,
|
||||
InboxUrl = relay.Inbox,
|
||||
Payload = payload,
|
||||
ContentType = "application/activity+json",
|
||||
UserId = jobData.ActorId
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
75
Iceshrimp.Backend/Core/Services/RelayService.cs
Normal file
75
Iceshrimp.Backend/Core/Services/RelayService.cs
Normal file
|
@ -0,0 +1,75 @@
|
|||
using Iceshrimp.Backend.Core.Database;
|
||||
using Iceshrimp.Backend.Core.Database.Tables;
|
||||
using Iceshrimp.Backend.Core.Helpers;
|
||||
using Iceshrimp.Backend.Core.Middleware;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Iceshrimp.Backend.Core.Services;
|
||||
|
||||
public class RelayService(
|
||||
DatabaseContext db,
|
||||
SystemUserService systemUserSvc,
|
||||
ActivityPub.ActivityRenderer activityRenderer,
|
||||
ActivityPub.ActivityDeliverService deliverSvc,
|
||||
ActivityPub.UserRenderer userRenderer
|
||||
)
|
||||
{
|
||||
public async Task SubscribeToRelay(string uri)
|
||||
{
|
||||
uri = new Uri(uri).AbsoluteUri;
|
||||
if (await db.Relays.AnyAsync(p => p.Inbox == uri)) return;
|
||||
|
||||
var relay = new Relay
|
||||
{
|
||||
Id = IdHelpers.GenerateSlowflakeId(),
|
||||
Inbox = uri,
|
||||
Status = Relay.RelayStatus.Requesting
|
||||
};
|
||||
|
||||
db.Add(relay);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var actor = await systemUserSvc.GetRelayActorAsync();
|
||||
var activity = activityRenderer.RenderFollow(actor, relay);
|
||||
await deliverSvc.DeliverToAsync(activity, actor, uri);
|
||||
}
|
||||
|
||||
public async Task UnsubscribeFromRelay(Relay relay)
|
||||
{
|
||||
var actor = await systemUserSvc.GetRelayActorAsync();
|
||||
var follow = activityRenderer.RenderFollow(actor, relay);
|
||||
var activity = activityRenderer.RenderUndo(userRenderer.RenderLite(actor), follow);
|
||||
await deliverSvc.DeliverToAsync(activity, actor, relay.Inbox);
|
||||
|
||||
db.Remove(relay);
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
public async Task HandleAccept(User actor, string id)
|
||||
{
|
||||
// @formatter:off
|
||||
if (await db.Relays.FirstOrDefaultAsync(p => p.Id == id) is not { } relay)
|
||||
throw GracefulException.UnprocessableEntity($"Relay with id {id} was not found");
|
||||
if (relay.Inbox != new Uri(actor.Inbox ?? throw new Exception("Relay actor must have an inbox")).AbsoluteUri)
|
||||
throw GracefulException.UnprocessableEntity($"Relay inbox ({relay.Inbox}) does not match relay actor inbox ({actor.Inbox})");
|
||||
// @formatter:on
|
||||
|
||||
relay.Status = Relay.RelayStatus.Accepted;
|
||||
actor.IsRelayActor = true;
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
public async Task HandleReject(User actor, string id)
|
||||
{
|
||||
// @formatter:off
|
||||
if (db.Relays.FirstOrDefault(p => p.Id == id) is not { } relay)
|
||||
throw GracefulException.UnprocessableEntity($"Relay with id {id} was not found");
|
||||
if (relay.Inbox != new Uri(actor.Inbox ?? throw new Exception("Relay actor must have an inbox")).AbsoluteUri)
|
||||
throw GracefulException.UnprocessableEntity($"Relay inbox ({relay.Inbox}) does not match relay actor inbox ({actor.Inbox})");
|
||||
// @formatter:on
|
||||
|
||||
relay.Status = Relay.RelayStatus.Rejected;
|
||||
actor.IsRelayActor = true;
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
}
|
|
@ -79,7 +79,8 @@ public class SystemUserService(ILogger<SystemUserService> logger, DatabaseContex
|
|||
IsAdmin = false,
|
||||
IsLocked = true,
|
||||
IsExplorable = false,
|
||||
IsBot = true
|
||||
IsBot = true,
|
||||
IsSystem = true
|
||||
};
|
||||
|
||||
var userKeypair = new UserKeypair
|
||||
|
|
|
@ -27,10 +27,10 @@ CharacterLimit = 8192
|
|||
;; It is highly recommend you keep this enabled if you intend to use block- or allowlist federation
|
||||
AuthorizedFetch = true
|
||||
|
||||
;; Whether to attach LD signatures to outgoing activities
|
||||
;; Whether to attach LD signatures to outgoing activities. Outgoing relayed activities get signed regardless of this option.
|
||||
AttachLdSignatures = false
|
||||
|
||||
;; Whether to accept activities signed using LD signatures
|
||||
;; Whether to accept activities signed using LD signatures. Needs to be enabled for relayed activities to be accepted.
|
||||
AcceptLdSignatures = false
|
||||
|
||||
;; Whether to allow requests to IPv4 & IPv6 loopback addresses
|
||||
|
|
23
Iceshrimp.Shared/Schemas/Web/RelaySchemas.cs
Normal file
23
Iceshrimp.Shared/Schemas/Web/RelaySchemas.cs
Normal file
|
@ -0,0 +1,23 @@
|
|||
namespace Iceshrimp.Shared.Schemas.Web;
|
||||
|
||||
public class RelaySchemas
|
||||
{
|
||||
public enum RelayStatus
|
||||
{
|
||||
Requesting = 0,
|
||||
Accepted = 1,
|
||||
Rejected = 2
|
||||
}
|
||||
|
||||
public class RelayResponse
|
||||
{
|
||||
public required string Id { get; set; }
|
||||
public required string Inbox { get; set; }
|
||||
public required RelayStatus Status { get; set; }
|
||||
}
|
||||
|
||||
public class RelayRequest
|
||||
{
|
||||
public required string Inbox { get; set; }
|
||||
}
|
||||
}
|
Loading…
Add table
Reference in a new issue