feat(swarm,profile): Add swarm user profile endpoint and improve navigation

- Create new GET /api/swarm/users/[handle] endpoint for federated user profile queries
- Add SwarmUserProfile and SwarmUserPost interfaces for standardized remote profile data
- Include user profile metadata (handle, displayName, bio, avatar, stats) in swarm responses
- Fetch and include user's recent posts with media and link preview data
- Implement fetchSwarmUserPosts helper to query remote Synapsis nodes via swarm API
- Update /api/users/[handle]/posts to prioritize swarm API for Synapsis node federation
- Replace profile page back navigation from Link to router.back() button for better UX
- Add proper error handling and suspended user filtering in swarm endpoint
- Support configurable post limit (max 50) for swarm profile queries
This commit is contained in:
AskIt
2026-01-26 04:49:03 +01:00
parent 498f6bfccf
commit 76e7d7fd55
5 changed files with 309 additions and 2 deletions
+13 -2
View File
@@ -331,9 +331,20 @@ export default function ProfilePage() {
background: 'var(--background)', background: 'var(--background)',
zIndex: 10, zIndex: 10,
}}> }}>
<Link href="/" style={{ color: 'var(--foreground)' }}> <button
onClick={() => router.back()}
style={{
color: 'var(--foreground)',
background: 'none',
border: 'none',
cursor: 'pointer',
padding: 0,
display: 'flex',
alignItems: 'center',
}}
>
<ArrowLeftIcon /> <ArrowLeftIcon />
</Link> </button>
<div> <div>
<h1 style={{ fontSize: '18px', fontWeight: 600 }}>{user.displayName || user.handle}</h1> <h1 style={{ fontSize: '18px', fontWeight: 600 }}>{user.displayName || user.handle}</h1>
<p style={{ fontSize: '13px', color: 'var(--foreground-tertiary)' }}>{user.postsCount} posts</p> <p style={{ fontSize: '13px', color: 'var(--foreground-tertiary)' }}>{user.postsCount} posts</p>
+158
View File
@@ -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 }
);
}
}
+90
View File
@@ -94,6 +94,26 @@ const parseRemoteHandle = (handle: string) => {
return null; 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 fetchOutboxItems = async (outboxUrl: string, limit: number) => {
const res = await fetch(outboxUrl, { const res = await fetch(outboxUrl, {
headers: { headers: {
@@ -133,6 +153,41 @@ export async function GET(request: Request, context: RouteContext) {
if (!remote) { if (!remote) {
return NextResponse.json({ posts: [], nextCursor: null }); 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); const remoteProfile = await resolveRemoteUser(remote.handle, remote.domain);
if (!remoteProfile?.outbox) { if (!remoteProfile?.outbox) {
return NextResponse.json({ posts: [] }); return NextResponse.json({ posts: [] });
@@ -209,6 +264,41 @@ export async function GET(request: Request, context: RouteContext) {
if (!remote) { if (!remote) {
return NextResponse.json({ error: 'User not found' }, { status: 404 }); 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); const remoteProfile = await resolveRemoteUser(remote.handle, remote.domain);
if (!remoteProfile?.outbox) { if (!remoteProfile?.outbox) {
return NextResponse.json({ posts: [] }); return NextResponse.json({ posts: [] });
+46
View File
@@ -40,6 +40,26 @@ const fetchCollectionCount = async (url?: string | null) => {
return 0; 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) { export async function GET(request: Request, context: RouteContext) {
try { try {
const { handle } = await context.params; const { handle } = await context.params;
@@ -70,6 +90,32 @@ export async function GET(request: Request, context: RouteContext) {
if (!user) { if (!user) {
if (remoteHandle && remoteDomain) { 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); const remoteProfile = await resolveRemoteUser(remoteHandle, remoteDomain);
if (remoteProfile) { if (remoteProfile) {
const displayName = sanitizeText(remoteProfile.name) || sanitizeText(remoteProfile.preferredUsername) || remoteHandle; const displayName = sanitizeText(remoteProfile.name) || sanitizeText(remoteProfile.preferredUsername) || remoteHandle;
+2
View File
@@ -14,6 +14,8 @@ export interface User {
isRemote?: boolean; isRemote?: boolean;
profileUrl?: string | null; profileUrl?: string | null;
isBot?: boolean; 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?: { botOwner?: {
id: string; id: string;
handle: string; handle: string;