From 898125a1312eeca2ce9fa3762ff64e6099d65b8e Mon Sep 17 00:00:00 2001 From: Christopher Date: Thu, 22 Jan 2026 15:42:15 -0800 Subject: [PATCH] Remote follow fix --- src/app/api/users/[handle]/follow/route.ts | 68 ++++++++- src/app/api/users/[handle]/posts/route.ts | 152 ++++++++++++++++++++- src/app/api/users/[handle]/route.ts | 28 +++- src/db/schema.ts | 17 +++ src/lib/activitypub/fetch.ts | 3 + 5 files changed, 257 insertions(+), 11 deletions(-) diff --git a/src/app/api/users/[handle]/follow/route.ts b/src/app/api/users/[handle]/follow/route.ts index 818c38a..77edfa5 100644 --- a/src/app/api/users/[handle]/follow/route.ts +++ b/src/app/api/users/[handle]/follow/route.ts @@ -1,10 +1,10 @@ import { NextResponse } from 'next/server'; import crypto from 'crypto'; -import { db, follows, users, notifications } from '@/db'; +import { db, follows, users, notifications, remoteFollows } from '@/db'; import { eq, and } from 'drizzle-orm'; import { requireAuth } from '@/lib/auth'; import { resolveRemoteUser } from '@/lib/activitypub/fetch'; -import { createFollowActivity } from '@/lib/activitypub/activities'; +import { createFollowActivity, createUndoActivity } from '@/lib/activitypub/activities'; import { deliverActivity } from '@/lib/activitypub/outbox'; type RouteContext = { params: Promise<{ handle: string }> }; @@ -31,7 +31,17 @@ export async function GET(request: Request, context: RouteContext) { } if (remote) { - return NextResponse.json({ following: false, remote: true }); + if (!db) { + return NextResponse.json({ error: 'Database not available' }, { status: 503 }); + } + const targetHandle = `${remote.handle}@${remote.domain}`; + const existingRemoteFollow = await db.query.remoteFollows.findFirst({ + where: and( + eq(remoteFollows.followerId, currentUser.id), + eq(remoteFollows.targetHandle, targetHandle) + ), + }); + return NextResponse.json({ following: !!existingRemoteFollow, remote: true }); } if (!db) { @@ -92,6 +102,18 @@ export async function POST(request: Request, context: RouteContext) { return NextResponse.json({ error: 'Remote inbox not available' }, { status: 400 }); } const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:3000'; + const targetHandle = `${remote.handle}@${remote.domain}`; + + const existingRemoteFollow = await db.query.remoteFollows.findFirst({ + where: and( + eq(remoteFollows.followerId, currentUser.id), + eq(remoteFollows.targetHandle, targetHandle) + ), + }); + if (existingRemoteFollow) { + return NextResponse.json({ error: 'Already following' }, { status: 400 }); + } + const activityId = crypto.randomUUID(); const followActivity = createFollowActivity(currentUser, remoteProfile.id, nodeDomain, activityId); const keyId = `https://${nodeDomain}/users/${currentUser.handle}#main-key`; @@ -103,6 +125,13 @@ export async function POST(request: Request, context: RouteContext) { if (!result.success) { return NextResponse.json({ error: result.error || 'Failed to follow remote user' }, { status: 502 }); } + await db.insert(remoteFollows).values({ + followerId: currentUser.id, + targetHandle, + targetActorUrl: remoteProfile.id, + inboxUrl: targetInbox, + activityId, + }); return NextResponse.json({ success: true, following: true, remote: true }); } @@ -183,7 +212,38 @@ export async function DELETE(request: Request, context: RouteContext) { const remote = parseRemoteHandle(handle); if (remote) { - return NextResponse.json({ error: 'Unfollow for remote users not supported yet' }, { status: 501 }); + if (!db) { + return NextResponse.json({ error: 'Database not available' }, { status: 503 }); + } + const targetHandle = `${remote.handle}@${remote.domain}`; + const existingRemoteFollow = await db.query.remoteFollows.findFirst({ + where: and( + eq(remoteFollows.followerId, currentUser.id), + eq(remoteFollows.targetHandle, targetHandle) + ), + }); + if (!existingRemoteFollow) { + return NextResponse.json({ error: 'Not following' }, { status: 400 }); + } + const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:3000'; + const originalFollow = createFollowActivity( + currentUser, + existingRemoteFollow.targetActorUrl, + nodeDomain, + existingRemoteFollow.activityId + ); + const undoActivity = createUndoActivity(currentUser, originalFollow, nodeDomain, crypto.randomUUID()); + const keyId = `https://${nodeDomain}/users/${currentUser.handle}#main-key`; + const privateKey = currentUser.privateKeyEncrypted; + if (!privateKey) { + return NextResponse.json({ error: 'Missing signing key' }, { status: 500 }); + } + const result = await deliverActivity(undoActivity, existingRemoteFollow.inboxUrl, privateKey, keyId); + if (!result.success) { + return NextResponse.json({ error: result.error || 'Failed to unfollow remote user' }, { status: 502 }); + } + await db.delete(remoteFollows).where(eq(remoteFollows.id, existingRemoteFollow.id)); + return NextResponse.json({ success: true, following: false, remote: true }); } if (!db) { diff --git a/src/app/api/users/[handle]/posts/route.ts b/src/app/api/users/[handle]/posts/route.ts index 7c4d5af..d76f2f7 100644 --- a/src/app/api/users/[handle]/posts/route.ts +++ b/src/app/api/users/[handle]/posts/route.ts @@ -1,19 +1,115 @@ import { NextResponse } from 'next/server'; import { db, posts, users } from '@/db'; import { eq, desc, and } from 'drizzle-orm'; +import { resolveRemoteUser } from '@/lib/activitypub/fetch'; type RouteContext = { params: Promise<{ handle: string }> }; +const decodeEntities = (value: string) => + value + .replace(/&#x([0-9a-fA-F]+);/g, (_, hex) => String.fromCharCode(parseInt(hex, 16))) + .replace(/&#(\d+);/g, (_, num) => String.fromCharCode(Number(num))) + .replace(/&/g, '&') + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/"/g, '"') + .replace(/'/g, "'"); + +const sanitizeText = (value?: string | null) => { + if (!value) return null; + const withoutTags = value.replace(/<[^>]*>/g, ' '); + const decoded = decodeEntities(withoutTags); + return decoded.replace(/\s+/g, ' ').trim() || null; +}; + +const parseRemoteHandle = (handle: string) => { + const clean = handle.toLowerCase().replace(/^@/, ''); + const parts = clean.split('@').filter(Boolean); + if (parts.length === 2) { + return { handle: parts[0], domain: parts[1] }; + } + return null; +}; + +const fetchOutboxItems = async (outboxUrl: string, limit: number) => { + const res = await fetch(outboxUrl, { + headers: { + 'Accept': 'application/activity+json, application/ld+json; profile="https://www.w3.org/ns/activitystreams"', + }, + }); + if (!res.ok) return []; + const data = await res.json(); + const first = data?.first; + if (first) { + if (typeof first === 'string') { + const pageRes = await fetch(first, { + headers: { + 'Accept': 'application/activity+json, application/ld+json; profile="https://www.w3.org/ns/activitystreams"', + }, + }); + if (!pageRes.ok) return []; + const page = await pageRes.json(); + return page?.orderedItems || page?.items || []; + } + return first?.orderedItems || first?.items || []; + } + const items = data?.orderedItems || data?.items || []; + return items.slice(0, limit); +}; + export async function GET(request: Request, context: RouteContext) { try { const { handle } = await context.params; const cleanHandle = handle.toLowerCase().replace(/^@/, ''); const { searchParams } = new URL(request.url); - const limit = Math.min(parseInt(searchParams.get('limit') || '20'), 50); + const limit = Math.min(parseInt(searchParams.get('limit') || '25'), 50); + + const remote = parseRemoteHandle(handle); - // Return empty if no database if (!db) { - return NextResponse.json({ posts: [], nextCursor: null }); + if (!remote) { + return NextResponse.json({ posts: [], nextCursor: null }); + } + const remoteProfile = await resolveRemoteUser(remote.handle, remote.domain); + if (!remoteProfile?.outbox) { + return NextResponse.json({ posts: [] }); + } + const outboxItems = await fetchOutboxItems(remoteProfile.outbox, limit); + const authorHandle = `${remoteProfile.preferredUsername || remote.handle}@${remote.domain}`; + const author = { + id: remoteProfile.id || `remote:${authorHandle}`, + handle: authorHandle, + displayName: sanitizeText(remoteProfile.name) || remoteProfile.preferredUsername || remote.handle, + avatarUrl: typeof remoteProfile.icon === 'string' ? remoteProfile.icon : remoteProfile.icon?.url, + bio: sanitizeText(remoteProfile.summary), + }; + const posts = outboxItems + .map((item: any) => { + const activity = item?.type === 'Create' ? item : null; + const object = activity?.object; + if (!object || typeof object === 'string' || object.type !== 'Note') { + return null; + } + const attachments = Array.isArray(object.attachment) ? object.attachment : []; + return { + id: object.id || activity.id, + content: sanitizeText(object.content) || '', + createdAt: object.published || activity.published || new Date().toISOString(), + likesCount: 0, + repostsCount: 0, + repliesCount: 0, + author, + media: attachments + .filter((attachment: any) => attachment?.url) + .map((attachment: any, index: number) => ({ + id: `${object.id || activity.id || 'media'}-${index}`, + url: attachment.url, + altText: sanitizeText(attachment.name) || null, + })), + }; + }) + .filter(Boolean); + return NextResponse.json({ posts, nextCursor: null }); } // Find the user @@ -22,7 +118,55 @@ export async function GET(request: Request, context: RouteContext) { }); if (!user) { - return NextResponse.json({ error: 'User not found' }, { status: 404 }); + if (!remote) { + return NextResponse.json({ error: 'User not found' }, { status: 404 }); + } + const remoteProfile = await resolveRemoteUser(remote.handle, remote.domain); + if (!remoteProfile?.outbox) { + return NextResponse.json({ posts: [] }); + } + + const outboxItems = await fetchOutboxItems(remoteProfile.outbox, limit); + const authorHandle = `${remoteProfile.preferredUsername || remote.handle}@${remote.domain}`; + const author = { + id: remoteProfile.id || `remote:${authorHandle}`, + handle: authorHandle, + displayName: sanitizeText(remoteProfile.name) || remoteProfile.preferredUsername || remote.handle, + avatarUrl: typeof remoteProfile.icon === 'string' ? remoteProfile.icon : remoteProfile.icon?.url, + bio: sanitizeText(remoteProfile.summary), + }; + + const posts = outboxItems + .map((item: any) => { + const activity = item?.type === 'Create' ? item : null; + const object = activity?.object; + if (!object || typeof object === 'string' || object.type !== 'Note') { + return null; + } + const attachments = Array.isArray(object.attachment) ? object.attachment : []; + return { + id: object.id || activity.id, + content: sanitizeText(object.content) || '', + createdAt: object.published || activity.published || new Date().toISOString(), + likesCount: 0, + repostsCount: 0, + repliesCount: 0, + author, + media: attachments + .filter((attachment: any) => attachment?.url) + .map((attachment: any, index: number) => ({ + id: `${object.id || activity.id || 'media'}-${index}`, + url: attachment.url, + altText: sanitizeText(attachment.name) || null, + })), + }; + }) + .filter(Boolean); + + return NextResponse.json({ + posts, + nextCursor: null, + }); } if (user.isSuspended) { return NextResponse.json({ error: 'User not found' }, { status: 404 }); diff --git a/src/app/api/users/[handle]/route.ts b/src/app/api/users/[handle]/route.ts index bcc1972..63f675c 100644 --- a/src/app/api/users/[handle]/route.ts +++ b/src/app/api/users/[handle]/route.ts @@ -23,6 +23,23 @@ const sanitizeText = (value?: string | null) => { return decoded.replace(/\s+/g, ' ').trim() || null; }; +const fetchCollectionCount = async (url?: string | null) => { + if (!url) return 0; + try { + const res = await fetch(url, { + headers: { + 'Accept': 'application/activity+json, application/ld+json; profile="https://www.w3.org/ns/activitystreams"', + }, + }); + if (!res.ok) return 0; + const data = await res.json(); + if (typeof data?.totalItems === 'number') return data.totalItems; + } catch { + return 0; + } + return 0; +}; + export async function GET(request: Request, context: RouteContext) { try { const { handle } = await context.params; @@ -59,6 +76,11 @@ export async function GET(request: Request, context: RouteContext) { const iconUrl = typeof remoteProfile.icon === 'string' ? remoteProfile.icon : remoteProfile.icon?.url; const headerUrl = typeof remoteProfile.image === 'string' ? remoteProfile.image : remoteProfile.image?.url; const profileUrl = typeof remoteProfile.url === 'string' ? remoteProfile.url : remoteProfile.id; + const [followersCount, followingCount, postsCount] = await Promise.all([ + fetchCollectionCount(remoteProfile.followers), + fetchCollectionCount(remoteProfile.following), + fetchCollectionCount(remoteProfile.outbox), + ]); return NextResponse.json({ user: { id: remoteProfile.id || profileUrl || `remote:${cleanHandle}`, @@ -67,9 +89,9 @@ export async function GET(request: Request, context: RouteContext) { bio: sanitizeText(remoteProfile.summary), avatarUrl: iconUrl ?? null, headerUrl: headerUrl ?? null, - followersCount: 0, - followingCount: 0, - postsCount: 0, + followersCount, + followingCount, + postsCount, website: profileUrl ?? null, createdAt: new Date().toISOString(), isRemote: true, diff --git a/src/db/schema.ts b/src/db/schema.ts index a486f36..3b17313 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -188,6 +188,23 @@ export const followsRelations = relations(follows, ({ one }) => ({ }), })); +// ============================================ +// REMOTE FOLLOWS (for federated follows) +// ============================================ + +export const remoteFollows = pgTable('remote_follows', { + id: uuid('id').primaryKey().defaultRandom(), + followerId: uuid('follower_id').notNull().references(() => users.id, { onDelete: 'cascade' }), + targetHandle: text('target_handle').notNull(), // username@domain + targetActorUrl: text('target_actor_url').notNull(), + inboxUrl: text('inbox_url').notNull(), + activityId: text('activity_id').notNull(), // UUID token for activity URL + createdAt: timestamp('created_at').defaultNow().notNull(), +}, (table) => [ + index('remote_follows_follower_idx').on(table.followerId), + index('remote_follows_target_idx').on(table.targetHandle), +]); + // ============================================ // LIKES // ============================================ diff --git a/src/lib/activitypub/fetch.ts b/src/lib/activitypub/fetch.ts index a15dad1..43373ec 100644 --- a/src/lib/activitypub/fetch.ts +++ b/src/lib/activitypub/fetch.ts @@ -9,6 +9,9 @@ export interface ActivityPubProfile { summary?: string; url?: string; inbox?: string; + outbox?: string; + followers?: string; + following?: string; endpoints?: { sharedInbox?: string; };