Files
Synapsis/src/app/api/swarm/users/[handle]/route.ts
T
cyph3rasi 0ecb622565 Reconcile validated Turso runtime fix with republished main history
Hop-State: A_06FP6A2CG8PVBRDVQ5WGCN0
Hop-Proposal: R_06FP6A0YW23RY0N7Z5M8DW0
Hop-Task: T_06FP67JVF4BHPYKKC37XKQ8
Hop-Attempt: AT_06FP69RZ96ZMNF9Y5S02MT0
2026-07-14 17:23:33 -07:00

258 lines
8.0 KiB
TypeScript

/**
* 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, userSwarmReposts } from '@/db';
import { eq, desc, and, isNull } from 'drizzle-orm';
import { parseLinkPreviewMediaJson } from '@/lib/media/linkPreview';
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;
botOwnerHandle?: string; // Handle of the bot's owner (e.g., "user" or "user@domain")
nodeDomain: string;
publicKey?: string; // Signing key for verifying actions
did?: string;
}
export interface SwarmUserPost {
id: string;
originalPostId?: string;
content: string;
createdAt: string;
isNsfw: boolean;
likesCount: number;
repostsCount: number;
repliesCount: number;
nodeDomain?: string;
author?: {
handle: string;
displayName?: string;
avatarUrl?: string;
isBot?: boolean;
nodeDomain?: string;
};
media?: { url: string; mimeType?: string; altText?: string }[];
linkPreviewUrl?: string;
linkPreviewTitle?: string;
linkPreviewDescription?: string;
linkPreviewImage?: string;
linkPreviewType?: 'card' | 'image' | 'gallery' | 'video';
linkPreviewVideoUrl?: string;
linkPreviewMedia?: Array<{ url: string; width?: number | null; height?: number | null; mimeType?: string | null }>;
repostOfId?: string;
repostOf?: SwarmUserPost | null;
}
type RouteContext = { params: Promise<{ handle: string }> };
const profilePostRelations = {
author: true,
media: true,
repostOf: {
with: {
author: true,
media: true,
},
},
} as const;
function parseMediaJson(mediaJson: string | null) {
if (!mediaJson) {
return [];
}
try {
return JSON.parse(mediaJson);
} catch {
return [];
}
}
function mapLocalPostToSwarmPost(post: any, nodeDomain: string): SwarmUserPost {
return {
id: post.id,
originalPostId: post.id,
content: post.content,
createdAt: post.createdAt.toISOString(),
isNsfw: post.isNsfw,
likesCount: post.likesCount,
repostsCount: post.repostsCount,
repliesCount: post.repliesCount,
nodeDomain,
author: post.author ? {
handle: post.author.handle,
displayName: post.author.displayName || post.author.handle,
avatarUrl: post.author.avatarUrl || undefined,
isBot: post.author.isBot || undefined,
nodeDomain,
} : undefined,
media: (post.media || []).map((item: any) => ({
url: item.url,
mimeType: item.mimeType || undefined,
altText: item.altText || undefined,
})),
linkPreviewUrl: post.linkPreviewUrl || undefined,
linkPreviewTitle: post.linkPreviewTitle || undefined,
linkPreviewDescription: post.linkPreviewDescription || undefined,
linkPreviewImage: post.linkPreviewImage || undefined,
linkPreviewType: post.linkPreviewType || undefined,
linkPreviewVideoUrl: post.linkPreviewVideoUrl || undefined,
linkPreviewMedia: parseLinkPreviewMediaJson(post.linkPreviewMediaJson),
repostOfId: post.repostOfId || undefined,
repostOf: post.repostOf ? mapLocalPostToSwarmPost(post.repostOf, nodeDomain) : undefined,
};
}
function mapUserSwarmRepostToSwarmPost(
row: typeof userSwarmReposts.$inferSelect,
author: typeof users.$inferSelect,
nodeDomain: string
): SwarmUserPost {
return {
id: row.id,
originalPostId: row.id,
content: '',
createdAt: row.repostedAt.toISOString(),
isNsfw: false,
likesCount: 0,
repostsCount: 0,
repliesCount: 0,
nodeDomain,
author: {
handle: author.handle,
displayName: author.displayName || author.handle,
avatarUrl: author.avatarUrl || undefined,
isBot: author.isBot || undefined,
nodeDomain,
},
repostOfId: row.originalPostId,
repostOf: {
id: row.originalPostId,
originalPostId: row.originalPostId,
content: row.content,
createdAt: row.postCreatedAt.toISOString(),
isNsfw: false,
likesCount: row.likesCount,
repostsCount: row.repostsCount,
repliesCount: row.repliesCount,
nodeDomain: row.nodeDomain,
author: {
handle: row.authorHandle.includes('@') ? row.authorHandle : `${row.authorHandle}@${row.nodeDomain}`,
displayName: row.authorDisplayName || row.authorHandle,
avatarUrl: row.authorAvatarUrl || undefined,
nodeDomain: row.nodeDomain,
},
media: parseMediaJson(row.mediaJson),
linkPreviewUrl: row.linkPreviewUrl || undefined,
linkPreviewTitle: row.linkPreviewTitle || undefined,
linkPreviewDescription: row.linkPreviewDescription || undefined,
linkPreviewImage: row.linkPreviewImage || undefined,
linkPreviewType: (row.linkPreviewType as SwarmUserPost['linkPreviewType']) || undefined,
linkPreviewVideoUrl: row.linkPreviewVideoUrl || undefined,
linkPreviewMedia: parseLinkPreviewMediaJson(row.linkPreviewMediaJson),
},
};
}
/**
* 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: { handle: cleanHandle },
with: {
botOwner: true, // Include bot owner if this is a bot
},
});
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,
botOwnerHandle: user.isBot && user.botOwner ? user.botOwner.handle : undefined,
nodeDomain,
publicKey: user.publicKey, // Expose signing key
did: user.did || undefined,
};
const localPosts = await db.query.posts.findMany({
where: { AND: [{ userId: user.id }, { isRemoved: false }, { replyToId: { isNull: true } }, { swarmReplyToId: { isNull: true } }] },
with: profilePostRelations,
orderBy: (posts, { desc }) => [desc(posts.createdAt)],
limit: limit * 2,
});
const remoteRepostRows = await db.query.userSwarmReposts.findMany({
where: { userId: user.id },
orderBy: (userSwarmReposts, { desc }) => [desc(userSwarmReposts.repostedAt)],
limit: limit * 2,
});
const swarmPosts: SwarmUserPost[] = [
...localPosts.map((post) => mapLocalPostToSwarmPost(post, nodeDomain)),
...remoteRepostRows.map((row) => mapUserSwarmRepostToSwarmPost(row, user, nodeDomain)),
]
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
.slice(0, limit);
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 }
);
}
}