diff --git a/src/app/api/users/[handle]/inbox/route.ts b/src/app/api/users/[handle]/inbox/route.ts new file mode 100644 index 0000000..f59f2e3 --- /dev/null +++ b/src/app/api/users/[handle]/inbox/route.ts @@ -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 = {}; + 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', + }, + } + ); +} diff --git a/src/app/inbox/route.ts b/src/app/inbox/route.ts new file mode 100644 index 0000000..c7023c1 --- /dev/null +++ b/src/app/inbox/route.ts @@ -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 = {}; + 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', + }, + } + ); +} diff --git a/src/db/schema.ts b/src/db/schema.ts index 3b17313..61aebd7 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -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 // ============================================ diff --git a/src/lib/activitypub/inbox.ts b/src/lib/activitypub/inbox.ts index 60c76ed..a5e0b49 100644 --- a/src/lib/activitypub/inbox.ts +++ b/src/lib/activitypub/inbox.ts @@ -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 { + 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, - 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' }; - } - - 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] 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('[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 - const handleMatch = targetActorUrl.match(/\/users\/([^\/]+)$/); - if (!handleMatch) { - return { success: false, error: 'Could not parse target handle' }; + // 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].toLowerCase(); + targetUser = (await db.query.users.findFirst({ + where: eq(users.handle, handle), + })) ?? null; } - const handle = handleMatch[1]; - - // Find the local user - const targetUser = await db.query.users.findFirst({ - where: eq(users.handle, handle), - }); - 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 };