diff --git a/src/app/api/swarm/users/[handle]/followers/route.ts b/src/app/api/swarm/users/[handle]/followers/route.ts new file mode 100644 index 0000000..39edc36 --- /dev/null +++ b/src/app/api/swarm/users/[handle]/followers/route.ts @@ -0,0 +1,103 @@ +/** + * Swarm User Followers Endpoint + * + * GET: Returns a user's followers list for swarm requests + */ + +import { NextRequest, NextResponse } from 'next/server'; +import { db, follows, users, remoteFollowers } from '@/db'; +import { eq } from 'drizzle-orm'; + +export interface SwarmFollowerUser { + handle: string; + displayName: string; + avatarUrl?: string; + bio?: string; + isBot?: boolean; + isRemote?: boolean; +} + +type RouteContext = { params: Promise<{ handle: string }> }; + +/** + * GET /api/swarm/users/[handle]/followers + * + * Returns a user's followers list. + * Used by other nodes to display who follows a remote user. + */ +export async function GET(request: NextRequest, 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') || '50'), 100); + + if (!db) { + return NextResponse.json({ error: 'Database not available' }, { status: 503 }); + } + + const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost'; + + // Find the user + const user = await db.query.users.findFirst({ + where: eq(users.handle, cleanHandle), + }); + + if (!user) { + return NextResponse.json({ error: 'User not found' }, { status: 404 }); + } + + if (user.isSuspended) { + return NextResponse.json({ error: 'User not found' }, { status: 404 }); + } + + // Get local followers + const userFollowers = await db + .select({ + id: follows.id, + follower: users, + }) + .from(follows) + .innerJoin(users, eq(follows.followerId, users.id)) + .where(eq(follows.followingId, user.id)) + .limit(limit); + + const localFollowers: SwarmFollowerUser[] = userFollowers.map(f => ({ + handle: f.follower.handle, // Local handle without domain + displayName: f.follower.displayName || f.follower.handle, + avatarUrl: f.follower.avatarUrl || undefined, + bio: f.follower.bio || undefined, + isBot: f.follower.isBot || undefined, + isRemote: false, + })); + + // Get remote followers + const userRemoteFollowers = await db.query.remoteFollowers.findMany({ + where: eq(remoteFollowers.userId, user.id), + limit, + }); + + const remoteFollowersList: SwarmFollowerUser[] = userRemoteFollowers.map(f => ({ + handle: f.handle || 'unknown', // Remote handle with @domain + displayName: f.handle?.split('@')[0] || 'Unknown', + avatarUrl: undefined, + bio: undefined, + isRemote: true, + })); + + // Merge all followers + const allFollowers = [...localFollowers, ...remoteFollowersList].slice(0, limit); + + return NextResponse.json({ + followers: allFollowers, + nodeDomain, + timestamp: new Date().toISOString(), + }); + } catch (error) { + console.error('Swarm user followers error:', error); + return NextResponse.json( + { error: 'Failed to fetch followers list' }, + { status: 500 } + ); + } +} diff --git a/src/app/api/swarm/users/[handle]/following/route.ts b/src/app/api/swarm/users/[handle]/following/route.ts new file mode 100644 index 0000000..74def1b --- /dev/null +++ b/src/app/api/swarm/users/[handle]/following/route.ts @@ -0,0 +1,103 @@ +/** + * Swarm User Following Endpoint + * + * GET: Returns a user's following list for swarm requests + */ + +import { NextRequest, NextResponse } from 'next/server'; +import { db, follows, users, remoteFollows } from '@/db'; +import { eq } from 'drizzle-orm'; + +export interface SwarmFollowingUser { + handle: string; + displayName: string; + avatarUrl?: string; + bio?: string; + isBot?: boolean; + isRemote?: boolean; +} + +type RouteContext = { params: Promise<{ handle: string }> }; + +/** + * GET /api/swarm/users/[handle]/following + * + * Returns a user's following list. + * Used by other nodes to display who a remote user follows. + */ +export async function GET(request: NextRequest, 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') || '50'), 100); + + if (!db) { + return NextResponse.json({ error: 'Database not available' }, { status: 503 }); + } + + const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost'; + + // Find the user + const user = await db.query.users.findFirst({ + where: eq(users.handle, cleanHandle), + }); + + if (!user) { + return NextResponse.json({ error: 'User not found' }, { status: 404 }); + } + + if (user.isSuspended) { + return NextResponse.json({ error: 'User not found' }, { status: 404 }); + } + + // Get local following + const userFollowing = await db + .select({ + id: follows.id, + following: users, + }) + .from(follows) + .innerJoin(users, eq(follows.followingId, users.id)) + .where(eq(follows.followerId, user.id)) + .limit(limit); + + const localFollowing: SwarmFollowingUser[] = userFollowing.map(f => ({ + handle: f.following.handle, // Local handle without domain + displayName: f.following.displayName || f.following.handle, + avatarUrl: f.following.avatarUrl || undefined, + bio: f.following.bio || undefined, + isBot: f.following.isBot || undefined, + isRemote: false, + })); + + // Get remote following + const userRemoteFollowing = await db.query.remoteFollows.findMany({ + where: eq(remoteFollows.followerId, user.id), + limit, + }); + + const remoteFollowing: SwarmFollowingUser[] = userRemoteFollowing.map(f => ({ + handle: f.targetHandle, // Already includes @domain + displayName: f.displayName || f.targetHandle.split('@')[0], + avatarUrl: f.avatarUrl || undefined, + bio: f.bio || undefined, + isRemote: true, + })); + + // Merge all following + const allFollowing = [...localFollowing, ...remoteFollowing].slice(0, limit); + + return NextResponse.json({ + following: allFollowing, + nodeDomain, + timestamp: new Date().toISOString(), + }); + } catch (error) { + console.error('Swarm user following error:', error); + return NextResponse.json( + { error: 'Failed to fetch following list' }, + { status: 500 } + ); + } +} diff --git a/src/app/api/users/[handle]/followers/route.ts b/src/app/api/users/[handle]/followers/route.ts index 7259159..f3462d8 100644 --- a/src/app/api/users/[handle]/followers/route.ts +++ b/src/app/api/users/[handle]/followers/route.ts @@ -4,6 +4,24 @@ import { eq } from 'drizzle-orm'; type RouteContext = { params: Promise<{ handle: string }> }; +/** + * Fetch followers list from a remote swarm node + */ +const fetchSwarmFollowers = async (handle: string, domain: string, limit: number) => { + try { + const protocol = domain.includes('localhost') ? 'http' : 'https'; + const url = `${protocol}://${domain}/api/swarm/users/${handle}/followers?limit=${limit}`; + const res = await fetch(url, { + headers: { 'Accept': 'application/json' }, + signal: AbortSignal.timeout(5000), + }); + if (!res.ok) return null; + return await res.json(); + } catch { + return null; + } +}; + export async function GET(request: Request, context: RouteContext) { try { const { handle } = await context.params; @@ -11,6 +29,29 @@ export async function GET(request: Request, context: RouteContext) { const { searchParams } = new URL(request.url); const limit = Math.min(parseInt(searchParams.get('limit') || '20'), 50); + // Check if this is a remote user + const [remoteHandle, remoteDomain] = cleanHandle.split('@'); + + if (remoteDomain) { + // Fetch from remote swarm node + const swarmData = await fetchSwarmFollowers(remoteHandle, remoteDomain, limit); + if (swarmData?.followers) { + // Transform to include full handles for local users on that node + const followers = swarmData.followers.map((f: any) => ({ + id: f.isRemote ? f.handle : `${f.handle}@${remoteDomain}`, + handle: f.isRemote ? f.handle : `${f.handle}@${remoteDomain}`, + displayName: f.displayName, + avatarUrl: f.avatarUrl, + bio: f.bio, + isRemote: true, + isBot: f.isBot, + })); + return NextResponse.json({ followers, nextCursor: null }); + } + // If swarm fetch fails, return empty + return NextResponse.json({ followers: [], nextCursor: null }); + } + // Return empty if no database if (!db) { return NextResponse.json({ followers: [], nextCursor: null }); diff --git a/src/app/api/users/[handle]/following/route.ts b/src/app/api/users/[handle]/following/route.ts index 13d45a0..5eedf0a 100644 --- a/src/app/api/users/[handle]/following/route.ts +++ b/src/app/api/users/[handle]/following/route.ts @@ -4,6 +4,24 @@ import { eq } from 'drizzle-orm'; type RouteContext = { params: Promise<{ handle: string }> }; +/** + * Fetch following list from a remote swarm node + */ +const fetchSwarmFollowing = async (handle: string, domain: string, limit: number) => { + try { + const protocol = domain.includes('localhost') ? 'http' : 'https'; + const url = `${protocol}://${domain}/api/swarm/users/${handle}/following?limit=${limit}`; + const res = await fetch(url, { + headers: { 'Accept': 'application/json' }, + signal: AbortSignal.timeout(5000), + }); + if (!res.ok) return null; + return await res.json(); + } catch { + return null; + } +}; + export async function GET(request: Request, context: RouteContext) { try { const { handle } = await context.params; @@ -11,6 +29,29 @@ export async function GET(request: Request, context: RouteContext) { const { searchParams } = new URL(request.url); const limit = Math.min(parseInt(searchParams.get('limit') || '20'), 50); + // Check if this is a remote user + const [remoteHandle, remoteDomain] = cleanHandle.split('@'); + + if (remoteDomain) { + // Fetch from remote swarm node + const swarmData = await fetchSwarmFollowing(remoteHandle, remoteDomain, limit); + if (swarmData?.following) { + // Transform to include full handles for local users on that node + const following = swarmData.following.map((f: any) => ({ + id: f.isRemote ? f.handle : `${f.handle}@${remoteDomain}`, + handle: f.isRemote ? f.handle : `${f.handle}@${remoteDomain}`, + displayName: f.displayName, + avatarUrl: f.avatarUrl, + bio: f.bio, + isRemote: true, + isBot: f.isBot, + })); + return NextResponse.json({ following, nextCursor: null }); + } + // If swarm fetch fails, return empty (could add ActivityPub fallback later) + return NextResponse.json({ following: [], nextCursor: null }); + } + // Return empty if no database if (!db) { return NextResponse.json({ following: [], nextCursor: null });