diff --git a/packages/server/src/routes/settings.ts b/packages/server/src/routes/settings.ts index 13957055..bb31f916 100644 --- a/packages/server/src/routes/settings.ts +++ b/packages/server/src/routes/settings.ts @@ -189,6 +189,8 @@ export async function settingsRoutes(app: FastifyInstance): Promise { gifApiKey: gifKey ? `****${gifKey.slice(-4)}` : undefined, gifEnabled: !!gifKey, maxUploadSizeMb: Math.round(maxUploadBytes / (1024 * 1024)), + federationRelayEnabled: row.federationRelayEnabled === 1, + federationRelayTtlDays: row.federationRelayTtlDays, }; return reply.code(200).send(response); @@ -235,6 +237,18 @@ export async function settingsRoutes(app: FastifyInstance): Promise { updateData.maxUploadSizeBytes = Math.round(mb * 1024 * 1024); } + if (body.federationRelayEnabled !== undefined) { + updateData.federationRelayEnabled = body.federationRelayEnabled ? 1 : 0; + } + + if (body.federationRelayTtlDays !== undefined) { + const ttl = Number(body.federationRelayTtlDays); + if (isNaN(ttl) || !Number.isInteger(ttl) || ttl < 1 || ttl > 365) { + return reply.code(400).send({ error: 'federationRelayTtlDays must be an integer between 1 and 365', statusCode: 400 }); + } + updateData.federationRelayTtlDays = ttl; + } + db.update(schema.instanceSettings).set(updateData).where(eq(schema.instanceSettings.id, 1)).run(); const updatedRow = db.select().from(schema.instanceSettings).where(eq(schema.instanceSettings.id, 1)).get(); @@ -251,6 +265,8 @@ export async function settingsRoutes(app: FastifyInstance): Promise { gifApiKey: updatedGifKey ? `****${updatedGifKey.slice(-4)}` : undefined, gifEnabled: !!updatedGifKey, maxUploadSizeMb: Math.round(updatedMaxUploadBytes / (1024 * 1024)), + federationRelayEnabled: updatedRow.federationRelayEnabled === 1, + federationRelayTtlDays: updatedRow.federationRelayTtlDays, }; return reply.code(200).send(response); diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts index ed5f92cd..970d7c6e 100644 --- a/packages/shared/src/types.ts +++ b/packages/shared/src/types.ts @@ -606,6 +606,8 @@ export interface InstanceAdminSettings { gifApiKey?: string; gifEnabled?: boolean; maxUploadSizeMb: number; + federationRelayEnabled: boolean; + federationRelayTtlDays: number; } export interface InstanceStreamingLimits { diff --git a/packages/web/src/api/client.ts b/packages/web/src/api/client.ts index 595c889d..3f77e388 100644 --- a/packages/web/src/api/client.ts +++ b/packages/web/src/api/client.ts @@ -198,6 +198,12 @@ export class BackspaceApiClient { enabled: () => Promise<{ enabled: boolean }>; }; + readonly federation: { + initiatePeering: (data: { remoteOrigin: string }) => Promise<{ peer: { id: string; origin: string; instanceName: string | null; status: string; lastSeenAt: number | null; createdAt: number } }>; + peers: () => Promise<{ peers: Array<{ id: string; origin: string; instanceName: string | null; status: string; lastSeenAt: number | null; lastFailureAt: number | null; consecutiveFailures: number | null; lastSyncedAt: number | null; createdAt: number }> }>; + revokePeer: (id: string) => Promise<{ success: boolean }>; + }; + readonly admin: { storageStats: () => Promise; storageOrphans: () => Promise<{ orphans: OrphanedFile[] }>; @@ -615,6 +621,19 @@ export class BackspaceApiClient { enabled: () => request<{ enabled: boolean }>('GET', '/gif/enabled'), }; + this.federation = { + initiatePeering: (data: { remoteOrigin: string }) => + request<{ peer: { id: string; origin: string; instanceName: string | null; status: string; lastSeenAt: number | null; createdAt: number } }>( + 'POST', '/federation/peer/initiate', data + ), + peers: () => + request<{ peers: Array<{ id: string; origin: string; instanceName: string | null; status: string; lastSeenAt: number | null; lastFailureAt: number | null; consecutiveFailures: number | null; lastSyncedAt: number | null; createdAt: number }> }>( + 'GET', '/federation/peers' + ), + revokePeer: (id: string) => + request<{ success: boolean }>('DELETE', `/federation/peers/${id}`), + }; + this.admin = { storageStats: () => request('GET', '/admin/storage/stats'), storageOrphans: () => request<{ orphans: OrphanedFile[] }>('GET', '/admin/storage/orphans'), diff --git a/packages/web/src/components/chat/MessageList.tsx b/packages/web/src/components/chat/MessageList.tsx index e985f6f6..59038f51 100644 --- a/packages/web/src/components/chat/MessageList.tsx +++ b/packages/web/src/components/chat/MessageList.tsx @@ -387,6 +387,11 @@ function WelcomeHeader({ channelId }: { channelId: string }) {

This is the beginning of your direct message history with @{username}.

+ {otherUser?.homeInstance && ( +

+ Messages are stored on your and your recipient's home instances. They are not end-to-end encrypted. +

+ )} {isFriend && otherUser && (
+ {/* Federation */} +
+
Federation
+

+ Control DM relay between federated instances. When enabled, DMs with users on peer instances are relayed server-to-server. +

+
+ + +
+
Relay TTL (days)
+
How long relayed messages are retained in the outbox before cleanup
+ { + const val = parseInt(e.target.value, 10); + if (!isNaN(val) && val >= 1 && val <= 365) { + setDraft({ ...draft, federationRelayTtlDays: val }); + } + }} + className="input-standard w-24" + /> +
+ + {/* Peers list */} +
+
+
Peered Instances
+ +
+ + {visiblePeers.length === 0 && !peersLoading && ( +
+ No federation peers configured. Peers are created automatically when connecting to remote instances. +
+ )} + + {visiblePeers.length > 0 && ( +
+ {visiblePeers.map((peer) => ( +
+
+
+ + {peer.instanceName || new URL(peer.origin).host} + + + {peer.status} + +
+
{peer.origin}
+
+ Last seen: {formatRelativeTime(peer.lastSeenAt)} + {peer.lastSyncedAt ? ` · Synced: ${formatRelativeTime(peer.lastSyncedAt)}` : ''} +
+
+ +
+ ))} +
+ )} +
+
+
+ {/* Status messages */} {saveError && (
{saveError}
diff --git a/packages/web/src/stores/instanceStore.ts b/packages/web/src/stores/instanceStore.ts index 02237e6b..ad3fa643 100644 --- a/packages/web/src/stores/instanceStore.ts +++ b/packages/web/src/stores/instanceStore.ts @@ -289,6 +289,13 @@ export const useInstanceStore = create((set, get) => ({ }); } + // Initiate server-to-server peering for DM relay (non-fatal) + try { + await api.federation.initiatePeering({ remoteOrigin: origin }); + } catch (err) { + console.warn('[federation] Peering initiation failed (non-fatal):', err); + } + // Sync instance list to all instances (fire-and-forget) get().syncInstanceList().catch(() => {}); } catch (err) {