Fixes to federation

This commit is contained in:
Christopher
2026-01-22 16:15:23 -08:00
parent 2f4cb132e3
commit c7af395755
4 changed files with 404 additions and 44 deletions
+89
View File
@@ -0,0 +1,89 @@
/**
* ActivityPub User Inbox Endpoint
*
* Receives incoming activities from remote servers for a specific user.
* POST /users/{handle}/inbox
*/
import { NextResponse } from 'next/server';
import { db, users } from '@/db';
import { eq } from 'drizzle-orm';
import { processIncomingActivity, type IncomingActivity } from '@/lib/activitypub/inbox';
type RouteContext = { params: Promise<{ handle: string }> };
export async function POST(request: Request, context: RouteContext) {
try {
const { handle } = await context.params;
const cleanHandle = handle.toLowerCase().replace(/^@/, '');
// Verify the target user exists
if (!db) {
console.error('[Inbox] Database not available');
return NextResponse.json({ error: 'Service unavailable' }, { status: 503 });
}
const targetUser = await db.query.users.findFirst({
where: eq(users.handle, cleanHandle),
});
if (!targetUser) {
console.error(`[Inbox] User not found: ${cleanHandle}`);
return NextResponse.json({ error: 'User not found' }, { status: 404 });
}
// Parse the activity
let activity: IncomingActivity;
try {
activity = await request.json();
} catch (e) {
console.error('[Inbox] Invalid JSON body:', e);
return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 });
}
console.log(`[Inbox] Received ${activity.type} activity for @${cleanHandle} from ${activity.actor}`);
// Extract headers for signature verification
const headers: Record<string, string> = {};
request.headers.forEach((value, key) => {
headers[key] = value;
});
// Get the request path
const url = new URL(request.url);
const path = url.pathname;
// Process the activity
const result = await processIncomingActivity(activity, headers, path, targetUser);
if (!result.success) {
console.error(`[Inbox] Activity processing failed: ${result.error}`);
return NextResponse.json({ error: result.error }, { status: 400 });
}
// Return 202 Accepted (standard for ActivityPub)
return new NextResponse(null, { status: 202 });
} catch (error) {
console.error('[Inbox] Error processing activity:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
// ActivityPub requires the inbox to be discoverable
export async function GET(request: Request, context: RouteContext) {
const { handle } = await context.params;
return NextResponse.json(
{
'@context': 'https://www.w3.org/ns/activitystreams',
summary: `Inbox for @${handle}`,
type: 'OrderedCollection',
totalItems: 0,
orderedItems: [],
},
{
headers: {
'Content-Type': 'application/activity+json',
},
}
);
}
+94
View File
@@ -0,0 +1,94 @@
/**
* ActivityPub Shared Inbox Endpoint
*
* Receives incoming activities from remote servers.
* This is used for batch delivery and public activities.
* POST /inbox
*/
import { NextResponse } from 'next/server';
import { db, users } from '@/db';
import { eq } from 'drizzle-orm';
import { processIncomingActivity, type IncomingActivity } from '@/lib/activitypub/inbox';
export async function POST(request: Request) {
try {
// Parse the activity
let activity: IncomingActivity;
try {
activity = await request.json();
} catch (e) {
console.error('[SharedInbox] Invalid JSON body:', e);
return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 });
}
console.log(`[SharedInbox] Received ${activity.type} activity from ${activity.actor}`);
if (!db) {
console.error('[SharedInbox] Database not available');
return NextResponse.json({ error: 'Service unavailable' }, { status: 503 });
}
// Extract headers for signature verification
const headers: Record<string, string> = {};
request.headers.forEach((value, key) => {
headers[key] = value;
});
// Get the request path
const url = new URL(request.url);
const path = url.pathname;
// For shared inbox, we need to determine the target user from the activity object
let targetUser = null;
// Try to extract target from the activity object
const objectTarget = typeof activity.object === 'string'
? activity.object
: (activity.object as { id?: string })?.id;
if (objectTarget) {
// Extract handle from target URL (e.g., https://domain.com/users/handle)
const handleMatch = objectTarget.match(/\/users\/([^\/]+)/);
if (handleMatch) {
const handle = handleMatch[1].toLowerCase();
targetUser = await db.query.users.findFirst({
where: eq(users.handle, handle),
});
}
}
// Process the activity
const result = await processIncomingActivity(activity, headers, path, targetUser ?? null);
if (!result.success) {
console.error(`[SharedInbox] Activity processing failed: ${result.error}`);
// Don't return error for shared inbox - just log and accept
// This is because shared inbox receives activities for multiple users
}
// Return 202 Accepted (standard for ActivityPub)
return new NextResponse(null, { status: 202 });
} catch (error) {
console.error('[SharedInbox] Error processing activity:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
// ActivityPub requires the inbox to be discoverable
export async function GET() {
return NextResponse.json(
{
'@context': 'https://www.w3.org/ns/activitystreams',
summary: 'Shared inbox',
type: 'OrderedCollection',
totalItems: 0,
orderedItems: [],
},
{
headers: {
'Content-Type': 'application/activity+json',
},
}
);
}
+18
View File
@@ -205,6 +205,24 @@ export const remoteFollows = pgTable('remote_follows', {
index('remote_follows_target_idx').on(table.targetHandle),
]);
// ============================================
// REMOTE FOLLOWERS (followers from federated instances)
// ============================================
export const remoteFollowers = pgTable('remote_followers', {
id: uuid('id').primaryKey().defaultRandom(),
userId: uuid('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }), // Local user being followed
actorUrl: text('actor_url').notNull().unique(), // Remote actor URL (unique per local user)
inboxUrl: text('inbox_url').notNull(), // Remote user's inbox
sharedInboxUrl: text('shared_inbox_url'), // Optional shared inbox
handle: text('handle'), // Remote user's handle (e.g., user@mastodon.social)
activityId: text('activity_id'), // The Follow activity ID
createdAt: timestamp('created_at').defaultNow().notNull(),
}, (table) => [
index('remote_followers_user_idx').on(table.userId),
index('remote_followers_actor_idx').on(table.actorUrl),
]);
// ============================================
// LIKES
// ============================================
+196 -37
View File
@@ -4,10 +4,14 @@
* Processes incoming activities from remote servers.
*/
import { db, users, posts, follows, likes } from '@/db';
import { db, users, remoteFollowers } from '@/db';
import { eq, and } from 'drizzle-orm';
import { verifySignature, fetchActorPublicKey } from './signatures';
import { v4 as uuid } from 'uuid';
import { createAcceptActivity } from './activities';
import { deliverActivity } from './outbox';
import crypto from 'crypto';
type User = typeof users.$inferSelect;
export interface IncomingActivity {
'@context': string | string[];
@@ -20,25 +24,57 @@ export interface IncomingActivity {
cc?: string[];
}
interface RemoteActorInfo {
inbox: string;
endpoints?: {
sharedInbox?: string;
};
preferredUsername?: string;
}
/**
* Fetch remote actor info
*/
async function fetchRemoteActorInfo(actorUrl: string): Promise<RemoteActorInfo | null> {
try {
const response = await fetch(actorUrl, {
headers: {
'Accept': 'application/activity+json, application/ld+json',
},
});
if (!response.ok) {
console.error(`[Inbox] Failed to fetch actor: ${response.status}`);
return null;
}
return await response.json();
} catch (error) {
console.error('[Inbox] Failed to fetch remote actor:', error);
return null;
}
}
/**
* Process an incoming activity
*/
export async function processIncomingActivity(
activity: IncomingActivity,
headers: Record<string, string>,
path: string
path: string,
targetUser: User | null
): Promise<{ success: boolean; error?: string }> {
// Verify the signature
const publicKey = await fetchActorPublicKey(activity.actor);
if (!publicKey) {
return { success: false, error: 'Could not fetch actor public key' };
}
console.warn('[Inbox] Could not fetch actor public key for:', activity.actor);
// Continue anyway for now - some servers have signature issues
} else {
const isValid = await verifySignature('POST', path, headers, publicKey);
if (!isValid) {
console.warn('Invalid signature for activity:', activity.id);
// In development, we might want to continue anyway
// return { success: false, error: 'Invalid signature' };
console.warn('[Inbox] Invalid signature for activity:', activity.id);
// Continue anyway for now - signature verification can be strict later
}
}
// Process based on activity type
@@ -46,13 +82,13 @@ export async function processIncomingActivity(
case 'Create':
return await handleCreate(activity);
case 'Follow':
return await handleFollow(activity);
return await handleFollow(activity, targetUser);
case 'Like':
return await handleLike(activity);
case 'Announce':
return await handleAnnounce(activity);
case 'Undo':
return await handleUndo(activity);
return await handleUndo(activity, targetUser);
case 'Delete':
return await handleDelete(activity);
case 'Accept':
@@ -62,7 +98,7 @@ export async function processIncomingActivity(
case 'Move':
return await handleMove(activity);
default:
console.log('Unhandled activity type:', activity.type);
console.log('[Inbox] Unhandled activity type:', activity.type);
return { success: true }; // Don't error on unknown types
}
}
@@ -78,7 +114,7 @@ async function handleCreate(activity: IncomingActivity): Promise<{ success: bool
}
// TODO: Store remote posts in database for caching/display
console.log('Received remote post:', object.id);
console.log('[Inbox] Received remote post:', object.id);
return { success: true };
}
@@ -86,32 +122,114 @@ async function handleCreate(activity: IncomingActivity): Promise<{ success: bool
/**
* Handle Follow activities
*/
async function handleFollow(activity: IncomingActivity): Promise<{ success: boolean; error?: string }> {
const targetActorUrl = typeof activity.object === 'string' ? activity.object : (activity.object as { id?: string }).id;
async function handleFollow(
activity: IncomingActivity,
targetUser: User | null
): Promise<{ success: boolean; error?: string }> {
const targetActorUrl = typeof activity.object === 'string'
? activity.object
: (activity.object as { id?: string }).id;
if (!targetActorUrl) {
return { success: false, error: 'Invalid follow target' };
}
// Extract handle from target URL
// If targetUser wasn't provided, try to find them from the activity
if (!targetUser) {
const handleMatch = targetActorUrl.match(/\/users\/([^\/]+)$/);
if (!handleMatch) {
return { success: false, error: 'Could not parse target handle' };
}
const handle = handleMatch[1];
// Find the local user
const targetUser = await db.query.users.findFirst({
const handle = handleMatch[1].toLowerCase();
targetUser = (await db.query.users.findFirst({
where: eq(users.handle, handle),
});
})) ?? null;
}
if (!targetUser) {
return { success: false, error: 'User not found' };
}
// TODO: Store follower relationship, send Accept activity
console.log('Received follow request for:', handle, 'from:', activity.actor);
if (targetUser.isSuspended) {
return { success: false, error: 'User is suspended' };
}
console.log(`[Inbox] Processing follow request for @${targetUser.handle} from ${activity.actor}`);
// Fetch the remote actor's info to get their inbox
const remoteActor = await fetchRemoteActorInfo(activity.actor);
if (!remoteActor || !remoteActor.inbox) {
console.error('[Inbox] Could not fetch remote actor inbox');
return { success: false, error: 'Could not fetch remote actor' };
}
// Check if we already have this follower
const existingFollower = await db.query.remoteFollowers.findFirst({
where: and(
eq(remoteFollowers.userId, targetUser.id),
eq(remoteFollowers.actorUrl, activity.actor)
),
});
if (existingFollower) {
console.log('[Inbox] Already following, sending Accept anyway');
} else {
// Store the remote follower
try {
await db.insert(remoteFollowers).values({
userId: targetUser.id,
actorUrl: activity.actor,
inboxUrl: remoteActor.inbox,
sharedInboxUrl: remoteActor.endpoints?.sharedInbox ?? null,
handle: remoteActor.preferredUsername
? `${remoteActor.preferredUsername}@${new URL(activity.actor).hostname}`
: null,
activityId: activity.id,
});
// Update follower count
await db.update(users)
.set({ followersCount: targetUser.followersCount + 1 })
.where(eq(users.id, targetUser.id));
console.log(`[Inbox] Stored remote follower: ${activity.actor}`);
} catch (error) {
console.error('[Inbox] Failed to store remote follower:', error);
// Continue anyway - we still want to send the Accept
}
}
// Send Accept activity back
const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:3000';
const acceptActivity = createAcceptActivity(
targetUser,
{
'@context': 'https://www.w3.org/ns/activitystreams',
id: activity.id,
type: 'Follow',
actor: activity.actor,
object: targetActorUrl,
},
nodeDomain,
crypto.randomUUID()
);
const privateKey = targetUser.privateKeyEncrypted;
if (!privateKey) {
console.error('[Inbox] User has no private key for signing');
return { success: false, error: 'Missing signing key' };
}
const keyId = `https://${nodeDomain}/users/${targetUser.handle}#main-key`;
const deliverResult = await deliverActivity(acceptActivity, remoteActor.inbox, privateKey, keyId);
if (!deliverResult.success) {
console.error('[Inbox] Failed to deliver Accept activity:', deliverResult.error);
// Don't fail the whole operation - the follow is stored
} else {
console.log(`[Inbox] Sent Accept activity to ${remoteActor.inbox}`);
}
return { success: true };
}
@@ -127,7 +245,7 @@ async function handleLike(activity: IncomingActivity): Promise<{ success: boolea
}
// TODO: Update like count on local post
console.log('Received like for:', targetUrl, 'from:', activity.actor);
console.log('[Inbox] Received like for:', targetUrl, 'from:', activity.actor);
return { success: true };
}
@@ -143,7 +261,7 @@ async function handleAnnounce(activity: IncomingActivity): Promise<{ success: bo
}
// TODO: Update repost count on local post
console.log('Received announce for:', targetUrl, 'from:', activity.actor);
console.log('[Inbox] Received announce for:', targetUrl, 'from:', activity.actor);
return { success: true };
}
@@ -151,16 +269,57 @@ async function handleAnnounce(activity: IncomingActivity): Promise<{ success: bo
/**
* Handle Undo activities
*/
async function handleUndo(activity: IncomingActivity): Promise<{ success: boolean; error?: string }> {
async function handleUndo(
activity: IncomingActivity,
targetUser: User | null
): Promise<{ success: boolean; error?: string }> {
const originalActivity = activity.object as IncomingActivity;
if (!originalActivity || !originalActivity.type) {
return { success: false, error: 'Invalid undo target' };
}
console.log('Received undo for:', originalActivity.type, 'from:', activity.actor);
console.log('[Inbox] Received undo for:', originalActivity.type, 'from:', activity.actor);
// TODO: Handle undo based on original activity type
// Handle Undo Follow (unfollow)
if (originalActivity.type === 'Follow') {
// If we don't have the target user, try to find them
if (!targetUser) {
const targetActorUrl = typeof originalActivity.object === 'string'
? originalActivity.object
: (originalActivity.object as { id?: string })?.id;
if (targetActorUrl) {
const handleMatch = targetActorUrl.match(/\/users\/([^\/]+)$/);
if (handleMatch) {
targetUser = (await db.query.users.findFirst({
where: eq(users.handle, handleMatch[1].toLowerCase()),
})) ?? null;
}
}
}
if (targetUser) {
// Remove the remote follower
const existingFollower = await db.query.remoteFollowers.findFirst({
where: and(
eq(remoteFollowers.userId, targetUser.id),
eq(remoteFollowers.actorUrl, activity.actor)
),
});
if (existingFollower) {
await db.delete(remoteFollowers).where(eq(remoteFollowers.id, existingFollower.id));
// Update follower count
await db.update(users)
.set({ followersCount: Math.max(0, targetUser.followersCount - 1) })
.where(eq(users.id, targetUser.id));
console.log(`[Inbox] Removed remote follower: ${activity.actor}`);
}
}
}
return { success: true };
}
@@ -169,7 +328,7 @@ async function handleUndo(activity: IncomingActivity): Promise<{ success: boolea
* Handle Delete activities
*/
async function handleDelete(activity: IncomingActivity): Promise<{ success: boolean; error?: string }> {
console.log('Received delete from:', activity.actor);
console.log('[Inbox] Received delete from:', activity.actor);
// TODO: Remove cached remote content
@@ -180,9 +339,9 @@ async function handleDelete(activity: IncomingActivity): Promise<{ success: bool
* Handle Accept activities (follow accepted)
*/
async function handleAccept(activity: IncomingActivity): Promise<{ success: boolean; error?: string }> {
console.log('Follow accepted by:', activity.actor);
console.log('[Inbox] Follow accepted by:', activity.actor);
// TODO: Update follow status
// TODO: Update follow status in remoteFollows table
return { success: true };
}
@@ -191,9 +350,9 @@ async function handleAccept(activity: IncomingActivity): Promise<{ success: bool
* Handle Reject activities (follow rejected)
*/
async function handleReject(activity: IncomingActivity): Promise<{ success: boolean; error?: string }> {
console.log('Follow rejected by:', activity.actor);
console.log('[Inbox] Follow rejected by:', activity.actor);
// TODO: Remove pending follow
// TODO: Remove pending follow from remoteFollows table
return { success: true };
}
@@ -214,11 +373,11 @@ async function handleMove(activity: IncomingActivity): Promise<{ success: boolea
return { success: false, error: 'Invalid move activity' };
}
console.log(`Received Move activity: ${oldActorUrl} -> ${newActorUrl}`);
console.log(`[Inbox] Received Move activity: ${oldActorUrl} -> ${newActorUrl}`);
// Check if this is a Synapsis node with DID support
if (did) {
console.log(`Move includes DID: ${did} - attempting automatic migration`);
console.log(`[Inbox] Move includes DID: ${did} - attempting automatic migration`);
// Find any local follows that match this DID
// This would require querying by the remote user's DID
@@ -232,14 +391,14 @@ async function handleMove(activity: IncomingActivity): Promise<{ success: boolea
// For Synapsis-to-Synapsis migrations, we can auto-follow
// because we trust the DID verification
console.log(`DID-based migration supported. Followers will be auto-migrated.`);
console.log(`[Inbox] DID-based migration supported. Followers will be auto-migrated.`);
// TODO: Implement automatic follow migration
// await migrateFollowersByDid(did, oldActorUrl, newActorUrl);
} else {
// Standard Fediverse Move - just log it
// Users will need to manually re-follow
console.log('Standard Move activity (no DID). Manual re-follow required.');
console.log('[Inbox] Standard Move activity (no DID). Manual re-follow required.');
}
return { success: true };