diff --git a/src/app/[handle]/page.tsx b/src/app/[handle]/page.tsx index a603191..ec68dd3 100644 --- a/src/app/[handle]/page.tsx +++ b/src/app/[handle]/page.tsx @@ -331,9 +331,20 @@ export default function ProfilePage() { background: 'var(--background)', zIndex: 10, }}> - +

{user.displayName || user.handle}

{user.postsCount} posts

diff --git a/src/app/api/swarm/users/[handle]/route.ts b/src/app/api/swarm/users/[handle]/route.ts new file mode 100644 index 0000000..157df7a --- /dev/null +++ b/src/app/api/swarm/users/[handle]/route.ts @@ -0,0 +1,158 @@ +/** + * Swarm User Profile Endpoint + * + * GET: Returns a user's profile and posts for swarm requests + */ + +import { NextRequest, NextResponse } from 'next/server'; +import { db, posts, users, media, nodes } from '@/db'; +import { eq, desc, and } from 'drizzle-orm'; + +export interface SwarmUserProfile { + handle: string; + displayName: string; + bio?: string; + avatarUrl?: string; + headerUrl?: string; + website?: string; + followersCount: number; + followingCount: number; + postsCount: number; + createdAt: string; + isBot?: boolean; + nodeDomain: string; +} + +export interface SwarmUserPost { + id: string; + content: string; + createdAt: string; + isNsfw: boolean; + likesCount: number; + repostsCount: number; + repliesCount: number; + media?: { url: string; mimeType?: string; altText?: string }[]; + linkPreviewUrl?: string; + linkPreviewTitle?: string; + linkPreviewDescription?: string; + linkPreviewImage?: string; +} + +type RouteContext = { params: Promise<{ handle: string }> }; + +/** + * GET /api/swarm/users/[handle] + * + * Returns a user's profile and recent posts. + * Used by other nodes to display remote user profiles. + */ +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') || '25'), 50); + + 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 }); + } + + // Build profile response + const profile: SwarmUserProfile = { + handle: user.handle, + displayName: user.displayName || user.handle, + bio: user.bio || undefined, + avatarUrl: user.avatarUrl || undefined, + headerUrl: user.headerUrl || undefined, + website: user.website || undefined, + followersCount: user.followersCount, + followingCount: user.followingCount, + postsCount: user.postsCount, + createdAt: user.createdAt.toISOString(), + isBot: user.isBot || undefined, + nodeDomain, + }; + + // Get user's recent posts + const userPosts = await db + .select({ + id: posts.id, + content: posts.content, + createdAt: posts.createdAt, + isNsfw: posts.isNsfw, + likesCount: posts.likesCount, + repostsCount: posts.repostsCount, + repliesCount: posts.repliesCount, + linkPreviewUrl: posts.linkPreviewUrl, + linkPreviewTitle: posts.linkPreviewTitle, + linkPreviewDescription: posts.linkPreviewDescription, + linkPreviewImage: posts.linkPreviewImage, + }) + .from(posts) + .where( + and( + eq(posts.userId, user.id), + eq(posts.isRemoved, false) + ) + ) + .orderBy(desc(posts.createdAt)) + .limit(limit); + + // Fetch media for each post + const swarmPosts: SwarmUserPost[] = []; + + for (const post of userPosts) { + const postMedia = await db + .select({ url: media.url, mimeType: media.mimeType, altText: media.altText }) + .from(media) + .where(eq(media.postId, post.id)); + + swarmPosts.push({ + id: post.id, + content: post.content, + createdAt: post.createdAt.toISOString(), + isNsfw: post.isNsfw, + likesCount: post.likesCount, + repostsCount: post.repostsCount, + repliesCount: post.repliesCount, + media: postMedia.length > 0 ? postMedia.map(m => ({ + url: m.url, + mimeType: m.mimeType || undefined, + altText: m.altText || undefined, + })) : undefined, + linkPreviewUrl: post.linkPreviewUrl || undefined, + linkPreviewTitle: post.linkPreviewTitle || undefined, + linkPreviewDescription: post.linkPreviewDescription || undefined, + linkPreviewImage: post.linkPreviewImage || undefined, + }); + } + + return NextResponse.json({ + profile, + posts: swarmPosts, + nodeDomain, + timestamp: new Date().toISOString(), + }); + } catch (error) { + console.error('Swarm user profile error:', error); + return NextResponse.json( + { error: 'Failed to fetch user profile' }, + { status: 500 } + ); + } +} diff --git a/src/app/api/users/[handle]/posts/route.ts b/src/app/api/users/[handle]/posts/route.ts index 717ed62..b219b28 100644 --- a/src/app/api/users/[handle]/posts/route.ts +++ b/src/app/api/users/[handle]/posts/route.ts @@ -94,6 +94,26 @@ const parseRemoteHandle = (handle: string) => { return null; }; +/** + * Fetch remote user posts via Swarm API (preferred for Synapsis nodes) + */ +const fetchSwarmUserPosts = async (handle: string, domain: string, limit: number) => { + try { + const protocol = domain.includes('localhost') ? 'http' : 'https'; + const url = `${protocol}://${domain}/api/swarm/users/${handle}?limit=${limit}`; + const res = await fetch(url, { + headers: { 'Accept': 'application/json' }, + signal: AbortSignal.timeout(5000), + }); + if (!res.ok) return null; + const data = await res.json(); + if (!data.profile || !data.posts) return null; + return data; + } catch { + return null; + } +}; + const fetchOutboxItems = async (outboxUrl: string, limit: number) => { const res = await fetch(outboxUrl, { headers: { @@ -133,6 +153,41 @@ export async function GET(request: Request, context: RouteContext) { if (!remote) { return NextResponse.json({ posts: [], nextCursor: null }); } + + // Try Swarm API first (for Synapsis nodes) + const swarmData = await fetchSwarmUserPosts(remote.handle, remote.domain, limit); + if (swarmData?.posts) { + const profile = swarmData.profile; + const authorHandle = `${profile.handle}@${remote.domain}`; + const author = { + id: `swarm:${remote.domain}:${profile.handle}`, + handle: authorHandle, + displayName: profile.displayName || profile.handle, + avatarUrl: profile.avatarUrl, + }; + + const posts = swarmData.posts.map((post: any) => ({ + id: post.id, + content: post.content, + createdAt: post.createdAt, + likesCount: post.likesCount || 0, + repostsCount: post.repostsCount || 0, + repliesCount: post.repliesCount || 0, + author, + media: post.media || [], + linkPreviewUrl: post.linkPreviewUrl || null, + linkPreviewTitle: post.linkPreviewTitle || null, + linkPreviewDescription: post.linkPreviewDescription || null, + linkPreviewImage: post.linkPreviewImage || null, + isSwarm: true, + nodeDomain: remote.domain, + originalPostId: post.id, + })); + + return NextResponse.json({ posts, nextCursor: null }); + } + + // Fall back to ActivityPub for non-Synapsis nodes const remoteProfile = await resolveRemoteUser(remote.handle, remote.domain); if (!remoteProfile?.outbox) { return NextResponse.json({ posts: [] }); @@ -209,6 +264,41 @@ export async function GET(request: Request, context: RouteContext) { if (!remote) { return NextResponse.json({ error: 'User not found' }, { status: 404 }); } + + // Try Swarm API first (for Synapsis nodes) + const swarmData = await fetchSwarmUserPosts(remote.handle, remote.domain, limit); + if (swarmData?.posts) { + const profile = swarmData.profile; + const authorHandle = `${profile.handle}@${remote.domain}`; + const author = { + id: `swarm:${remote.domain}:${profile.handle}`, + handle: authorHandle, + displayName: profile.displayName || profile.handle, + avatarUrl: profile.avatarUrl, + }; + + const posts = swarmData.posts.map((post: any) => ({ + id: post.id, + content: post.content, + createdAt: post.createdAt, + likesCount: post.likesCount || 0, + repostsCount: post.repostsCount || 0, + repliesCount: post.repliesCount || 0, + author, + media: post.media || [], + linkPreviewUrl: post.linkPreviewUrl || null, + linkPreviewTitle: post.linkPreviewTitle || null, + linkPreviewDescription: post.linkPreviewDescription || null, + linkPreviewImage: post.linkPreviewImage || null, + isSwarm: true, + nodeDomain: remote.domain, + originalPostId: post.id, + })); + + return NextResponse.json({ posts, nextCursor: null }); + } + + // Fall back to ActivityPub for non-Synapsis nodes const remoteProfile = await resolveRemoteUser(remote.handle, remote.domain); if (!remoteProfile?.outbox) { return NextResponse.json({ posts: [] }); diff --git a/src/app/api/users/[handle]/route.ts b/src/app/api/users/[handle]/route.ts index f017119..355f232 100644 --- a/src/app/api/users/[handle]/route.ts +++ b/src/app/api/users/[handle]/route.ts @@ -40,6 +40,26 @@ const fetchCollectionCount = async (url?: string | null) => { return 0; }; +/** + * Fetch remote user profile via Swarm API (preferred for Synapsis nodes) + */ +const fetchSwarmProfile = async (handle: string, domain: string) => { + try { + const protocol = domain.includes('localhost') ? 'http' : 'https'; + const url = `${protocol}://${domain}/api/swarm/users/${handle}`; + const res = await fetch(url, { + headers: { 'Accept': 'application/json' }, + signal: AbortSignal.timeout(5000), + }); + if (!res.ok) return null; + const data = await res.json(); + if (!data.profile) return null; + return data; + } catch { + return null; + } +}; + export async function GET(request: Request, context: RouteContext) { try { const { handle } = await context.params; @@ -70,6 +90,32 @@ export async function GET(request: Request, context: RouteContext) { if (!user) { if (remoteHandle && remoteDomain) { + // Try Swarm API first (for Synapsis nodes) + const swarmData = await fetchSwarmProfile(remoteHandle, remoteDomain); + if (swarmData?.profile) { + const profile = swarmData.profile; + return NextResponse.json({ + user: { + id: `swarm:${remoteDomain}:${profile.handle}`, + handle: `${profile.handle}@${remoteDomain}`, + displayName: profile.displayName, + bio: profile.bio || null, + avatarUrl: profile.avatarUrl || null, + headerUrl: profile.headerUrl || null, + followersCount: profile.followersCount, + followingCount: profile.followingCount, + postsCount: profile.postsCount, + website: profile.website || null, + createdAt: profile.createdAt, + isRemote: true, + isSwarm: true, + nodeDomain: remoteDomain, + isBot: profile.isBot || false, + } + }); + } + + // Fall back to ActivityPub for non-Synapsis nodes const remoteProfile = await resolveRemoteUser(remoteHandle, remoteDomain); if (remoteProfile) { const displayName = sanitizeText(remoteProfile.name) || sanitizeText(remoteProfile.preferredUsername) || remoteHandle; diff --git a/src/lib/types.ts b/src/lib/types.ts index 66fcaad..330c705 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -14,6 +14,8 @@ export interface User { isRemote?: boolean; profileUrl?: string | null; isBot?: boolean; + isSwarm?: boolean; // Whether this user is from a Synapsis swarm node + nodeDomain?: string | null; // Domain of the node this user is from (for swarm users) botOwner?: { id: string; handle: string;