refactor: Migrate from ActivityPub federation to native Swarm network

- Remove ActivityPub infrastructure (webfinger, nodeinfo, inbox, activities, signatures)
- Implement native peer-to-peer Swarm network with gossip protocol
- Replace federated timeline with Swarm timeline aggregating posts from all nodes
- Migrate direct messaging to end-to-end encrypted Swarm Chat system
- Update user interactions (follow, like, repost) to use Swarm protocol
- Add cryptographic key management for DID-based identity system
- Remove server-bound identity model in favor of portable DID identities
- Update documentation to reflect Swarm architecture and capabilities
- Add debug utilities and chat testing tools for Swarm network
- Refactor database schema to support distributed user directory
- Update bot framework to work with Swarm network interactions
- Simplify API routes to use Swarm protocol instead of ActivityPub
- This transition enables true peer-to-peer communication, instant interactions, and encrypted messaging while maintaining sovereign identity through DIDs
This commit is contained in:
Christomatt
2026-01-27 01:27:15 +01:00
parent 6b8eeb6814
commit eb63194c56
49 changed files with 1173 additions and 3559 deletions
+48 -149
View File
@@ -3,10 +3,6 @@ import crypto from 'crypto';
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, createUndoActivity } from '@/lib/activitypub/activities';
import { deliverActivity } from '@/lib/activitypub/outbox';
import { cacheRemoteUserPosts } from '@/lib/activitypub/cache';
import { isSwarmNode, deliverSwarmFollow, deliverSwarmUnfollow, cacheSwarmUserPosts } from '@/lib/swarm/interactions';
type RouteContext = { params: Promise<{ handle: string }> };
@@ -20,12 +16,6 @@ const parseRemoteHandle = (handle: string) => {
return null;
};
// Strip HTML tags from a string (for Mastodon bios that come as HTML)
const stripHtml = (html: string | null | undefined): string | null => {
if (!html) return null;
return html.replace(/<[^>]*>/g, '').trim() || null;
};
// Check follow status
export async function GET(request: Request, context: RouteContext) {
try {
@@ -115,98 +105,42 @@ export async function POST(request: Request, context: RouteContext) {
return NextResponse.json({ error: 'Already following' }, { status: 400 });
}
// SWARM-FIRST: Check if this is a Synapsis swarm node
// Only allow following swarm nodes
const isSwarm = await isSwarmNode(remote.domain);
if (isSwarm) {
// Use swarm protocol for Synapsis nodes
const activityId = crypto.randomUUID();
const result = await deliverSwarmFollow(remote.domain, {
targetHandle: remote.handle,
follow: {
followerHandle: currentUser.handle,
followerDisplayName: currentUser.displayName || currentUser.handle,
followerAvatarUrl: currentUser.avatarUrl || undefined,
followerBio: currentUser.bio || undefined,
followerNodeDomain: nodeDomain,
interactionId: activityId,
timestamp: new Date().toISOString(),
},
});
if (!result.success) {
console.warn(`[Swarm] Follow delivery failed, falling back to ActivityPub: ${result.error}`);
// Fall through to ActivityPub below
} else {
// Swarm follow succeeded - store the follow locally
await db.insert(remoteFollows).values({
followerId: currentUser.id,
targetHandle,
targetActorUrl: `swarm://${remote.domain}/${remote.handle}`,
inboxUrl: `https://${remote.domain}/api/swarm/interactions/inbox`,
activityId,
displayName: null, // Will be fetched later
bio: null,
avatarUrl: null,
});
// Update the user's following count
await db.update(users)
.set({ followingCount: currentUser.followingCount + 1 })
.where(eq(users.id, currentUser.id));
// Cache the remote user's recent posts in the background
cacheSwarmUserPosts(remote.handle, remote.domain, targetHandle, 20)
.then(result => console.log(`[Swarm] Cached ${result.cached} posts for ${targetHandle}`))
.catch(err => console.error('[Swarm] Error caching remote posts:', err));
console.log(`[Swarm] Follow delivered to ${remote.domain} for @${remote.handle}`);
return NextResponse.json({ success: true, following: true, remote: true, swarm: true });
}
}
// FALLBACK: Use ActivityPub for non-swarm nodes or if swarm failed
const remoteProfile = await resolveRemoteUser(remote.handle, remote.domain);
if (!remoteProfile) {
return NextResponse.json({ error: 'User not found' }, { status: 404 });
}
const targetInbox = remoteProfile.endpoints?.sharedInbox || remoteProfile.inbox;
if (!targetInbox) {
return NextResponse.json({ error: 'Remote inbox not available' }, { status: 400 });
if (!isSwarm) {
return NextResponse.json({ error: 'Can only follow users on Synapsis swarm nodes' }, { status: 400 });
}
// Use swarm protocol
const activityId = crypto.randomUUID();
const followActivity = createFollowActivity(currentUser, remoteProfile.id, nodeDomain, activityId);
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(followActivity, targetInbox, privateKey, keyId);
const result = await deliverSwarmFollow(remote.domain, {
targetHandle: remote.handle,
follow: {
followerHandle: currentUser.handle,
followerDisplayName: currentUser.displayName || currentUser.handle,
followerAvatarUrl: currentUser.avatarUrl || undefined,
followerBio: currentUser.bio || undefined,
followerNodeDomain: nodeDomain,
interactionId: activityId,
timestamp: new Date().toISOString(),
},
});
if (!result.success) {
return NextResponse.json({ error: result.error || 'Failed to follow remote user' }, { status: 502 });
}
// Extract avatar URL from remote profile
let avatarUrl: string | null = null;
if (remoteProfile.icon) {
if (typeof remoteProfile.icon === 'string') {
avatarUrl = remoteProfile.icon;
} else if (typeof remoteProfile.icon === 'object' && remoteProfile.icon.url) {
avatarUrl = remoteProfile.icon.url;
}
return NextResponse.json({ error: result.error || 'Failed to follow user' }, { status: 502 });
}
// Store the follow locally
await db.insert(remoteFollows).values({
followerId: currentUser.id,
targetHandle,
targetActorUrl: remoteProfile.id,
inboxUrl: targetInbox,
targetActorUrl: `swarm://${remote.domain}/${remote.handle}`,
inboxUrl: `https://${remote.domain}/api/swarm/interactions/inbox`,
activityId,
displayName: remoteProfile.name || null,
bio: stripHtml(remoteProfile.summary),
avatarUrl,
displayName: null,
bio: null,
avatarUrl: null,
});
// Update the user's following count
@@ -215,12 +149,12 @@ export async function POST(request: Request, context: RouteContext) {
.where(eq(users.id, currentUser.id));
// Cache the remote user's recent posts in the background
const origin = new URL(request.url).origin;
cacheRemoteUserPosts(remoteProfile, targetHandle, origin, 20)
.then(result => console.log(`Cached ${result.cached} posts for ${targetHandle}`))
.catch(err => console.error('Error caching remote posts:', err));
cacheSwarmUserPosts(remote.handle, remote.domain, targetHandle, 20)
.then(result => console.log(`[Swarm] Cached ${result.cached} posts for ${targetHandle}`))
.catch(err => console.error('[Swarm] Error caching remote posts:', err));
return NextResponse.json({ success: true, following: true, remote: true });
console.log(`[Swarm] Follow delivered to ${remote.domain} for @${remote.handle}`);
return NextResponse.json({ success: true, following: true, remote: true, swarm: true });
}
if (!db) {
@@ -263,14 +197,14 @@ export async function POST(request: Request, context: RouteContext) {
});
if (currentUser.id !== targetUser.id) {
// Create notification with actor info stored directly
// Create notification
await db.insert(notifications).values({
userId: targetUser.id,
actorId: currentUser.id,
actorHandle: currentUser.handle,
actorDisplayName: currentUser.displayName,
actorAvatarUrl: currentUser.avatarUrl,
actorNodeDomain: null, // Local user
actorNodeDomain: null,
type: 'follow',
});
@@ -297,8 +231,6 @@ export async function POST(request: Request, context: RouteContext) {
.set({ followersCount: targetUser.followersCount + 1 })
.where(eq(users.id, targetUser.id));
// TODO: Send ActivityPub Follow activity
return NextResponse.json({ success: true, following: true });
} catch (error) {
if (error instanceof Error && error.message === 'Authentication required') {
@@ -333,55 +265,23 @@ export async function DELETE(request: Request, context: RouteContext) {
return NextResponse.json({ error: 'Not following' }, { status: 400 });
}
// SWARM-FIRST: Check if this is a swarm follow (swarm:// actor URL)
const isSwarmFollow = existingRemoteFollow.targetActorUrl.startsWith('swarm://');
if (isSwarmFollow) {
// Use swarm protocol for unfollow
const result = await deliverSwarmUnfollow(remote.domain, {
targetHandle: remote.handle,
unfollow: {
followerHandle: currentUser.handle,
followerNodeDomain: nodeDomain,
interactionId: crypto.randomUUID(),
timestamp: new Date().toISOString(),
},
});
// Use swarm protocol for unfollow
const result = await deliverSwarmUnfollow(remote.domain, {
targetHandle: remote.handle,
unfollow: {
followerHandle: currentUser.handle,
followerNodeDomain: nodeDomain,
interactionId: crypto.randomUUID(),
timestamp: new Date().toISOString(),
},
});
if (!result.success) {
console.warn(`[Swarm] Unfollow delivery failed: ${result.error}`);
// Continue anyway - remove local record
}
// Remove the follow record
await db.delete(remoteFollows).where(eq(remoteFollows.id, existingRemoteFollow.id));
// Update the user's following count
await db.update(users)
.set({ followingCount: Math.max(0, currentUser.followingCount - 1) })
.where(eq(users.id, currentUser.id));
console.log(`[Swarm] Unfollow delivered to ${remote.domain}`);
return NextResponse.json({ success: true, following: false, remote: true, swarm: true });
}
// FALLBACK: Use ActivityPub for non-swarm follows
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 });
console.warn(`[Swarm] Unfollow delivery failed: ${result.error}`);
// Continue anyway - remove local record
}
// Remove the follow record
await db.delete(remoteFollows).where(eq(remoteFollows.id, existingRemoteFollow.id));
// Update the user's following count
@@ -389,7 +289,8 @@ export async function DELETE(request: Request, context: RouteContext) {
.set({ followingCount: Math.max(0, currentUser.followingCount - 1) })
.where(eq(users.id, currentUser.id));
return NextResponse.json({ success: true, following: false, remote: true });
console.log(`[Swarm] Unfollow delivered to ${remote.domain}`);
return NextResponse.json({ success: true, following: false, remote: true, swarm: true });
}
if (!db) {
@@ -432,8 +333,6 @@ export async function DELETE(request: Request, context: RouteContext) {
.set({ followersCount: Math.max(0, targetUser.followersCount - 1) })
.where(eq(users.id, targetUser.id));
// TODO: Send ActivityPub Undo Follow activity
return NextResponse.json({ success: true, following: false });
} catch (error) {
if (error instanceof Error && error.message === 'Authentication required') {
@@ -49,7 +49,7 @@ export async function GET(request: Request, context: RouteContext) {
}));
return NextResponse.json({ following, nextCursor: null });
}
// If swarm fetch fails, return empty (could add ActivityPub fallback later)
// If swarm fetch fails, return empty
return NextResponse.json({ following: [], nextCursor: null });
}
-89
View File
@@ -1,89 +0,0 @@
/**
* ActivityPub User Inbox Endpoint
*
* Receives incoming activities from remote servers for a specific user.
* POST /users/{handle}/inbox
*/
import { NextResponse } from 'next/server';
import { db, users } from '@/db';
import { eq } from 'drizzle-orm';
import { processIncomingActivity, type IncomingActivity } from '@/lib/activitypub/inbox';
type RouteContext = { params: Promise<{ handle: string }> };
export async function POST(request: Request, context: RouteContext) {
try {
const { handle } = await context.params;
const cleanHandle = handle.toLowerCase().replace(/^@/, '');
// Verify the target user exists
if (!db) {
console.error('[Inbox] Database not available');
return NextResponse.json({ error: 'Service unavailable' }, { status: 503 });
}
const targetUser = await db.query.users.findFirst({
where: eq(users.handle, cleanHandle),
});
if (!targetUser) {
console.error(`[Inbox] User not found: ${cleanHandle}`);
return NextResponse.json({ error: 'User not found' }, { status: 404 });
}
// Parse the activity
let activity: IncomingActivity;
try {
activity = await request.json();
} catch (e) {
console.error('[Inbox] Invalid JSON body:', e);
return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 });
}
console.log(`[Inbox] Received ${activity.type} activity for @${cleanHandle} from ${activity.actor}`);
// Extract headers for signature verification
const headers: Record<string, string> = {};
request.headers.forEach((value, key) => {
headers[key] = value;
});
// Get the request path
const url = new URL(request.url);
const path = url.pathname;
// Process the activity
const result = await processIncomingActivity(activity, headers, path, targetUser);
if (!result.success) {
console.error(`[Inbox] Activity processing failed: ${result.error}`);
return NextResponse.json({ error: result.error }, { status: 400 });
}
// Return 202 Accepted (standard for ActivityPub)
return new NextResponse(null, { status: 202 });
} catch (error) {
console.error('[Inbox] Error processing activity:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
// ActivityPub requires the inbox to be discoverable
export async function GET(request: Request, context: RouteContext) {
const { handle } = await context.params;
return NextResponse.json(
{
'@context': 'https://www.w3.org/ns/activitystreams',
summary: `Inbox for @${handle}`,
type: 'OrderedCollection',
totalItems: 0,
orderedItems: [],
},
{
headers: {
'Content-Type': 'application/activity+json',
},
}
);
}
+25 -277
View File
@@ -1,90 +1,10 @@
import { NextResponse } from 'next/server';
import { db, posts, users, likes } from '@/db';
import { eq, desc, and, inArray } from 'drizzle-orm';
import { resolveRemoteUser } from '@/lib/activitypub/fetch';
import { eq, desc, and, inArray, lt } from 'drizzle-orm';
import { fetchSwarmUserProfile, isSwarmNode } from '@/lib/swarm/interactions';
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(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#39;/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 extractTextAndUrls = (value?: string | null) => {
if (!value) return { text: '', urls: [] as string[] };
let html = value;
// Replace <br> with spaces to avoid words running together.
html = html.replace(/<br\s*\/?>/gi, ' ');
// Replace anchor tags with their hrefs (preferred) or inner text.
html = html.replace(/<a[^>]*href=["']([^"']+)["'][^>]*>(.*?)<\/a>/gi, (_, href, text) => {
const cleanedHref = decodeEntities(String(href));
const cleanedText = decodeEntities(String(text)).replace(/<[^>]*>/g, ' ').trim();
return cleanedHref || cleanedText;
});
const withoutTags = html.replace(/<[^>]*>/g, ' ');
const decoded = decodeEntities(withoutTags);
const text = decoded.replace(/\s+/g, ' ').trim();
const urls = Array.from(text.matchAll(/https?:\/\/[^\s]+/gi)).map((match) => match[0]);
return { text, urls };
};
const normalizeUrl = (value: string) => value.replace(/[)\].,!?]+$/, '');
const fetchLinkPreview = async (url: string, origin: string) => {
try {
const previewUrl = new URL('/api/media/preview', origin);
previewUrl.searchParams.set('url', url);
const res = await fetch(previewUrl.toString(), {
headers: { 'Accept': 'application/json' },
signal: AbortSignal.timeout(4000),
});
if (!res.ok) return null;
const data = await res.json();
return {
url: data?.url || url,
title: data?.title || null,
description: data?.description || null,
image: data?.image || null,
};
} catch {
return null;
}
};
const stripFirstUrl = (text: string, url: string) => {
const idx = text.indexOf(url);
if (idx === -1) return text;
const before = text.slice(0, idx).trimEnd();
const after = text.slice(idx + url.length).trimStart();
return `${before} ${after}`.trim();
};
// Normalize content for deduplication (strip HTML entities, URLs, whitespace, category suffixes)
const normalizeForDedup = (content: string): string => {
return content
.replace(/Posted into [\w\s-]+/gi, '') // Remove "Posted into [Category]" patterns
.replace(/&[a-z]+;/gi, '') // Remove HTML entities like &lsquo;
.replace(/&#\d+;/g, '') // Remove numeric entities
.replace(/https?:\/\/[^\s]+/gi, '') // Remove URLs
.replace(/[^\w\s]/g, '') // Remove punctuation
.replace(/\s+/g, ' ') // Normalize whitespace
.toLowerCase()
.trim()
.slice(0, 50); // Compare first 50 chars (article title)
};
const parseRemoteHandle = (handle: string) => {
const clean = handle.toLowerCase().replace(/^@/, '');
const parts = clean.split('@').filter(Boolean);
@@ -94,52 +14,6 @@ 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: {
'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;
@@ -155,10 +29,15 @@ export async function GET(request: Request, context: RouteContext) {
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;
// Only fetch from swarm nodes
const isSwarm = await isSwarmNode(remote.domain);
if (!isSwarm) {
return NextResponse.json({ posts: [], message: 'Only Synapsis swarm nodes are supported' });
}
const profileData = await fetchSwarmUserProfile(remote.handle, remote.domain, limit);
if (profileData?.posts) {
const profile = profileData.profile;
const authorHandle = `${profile.handle}@${remote.domain}`;
const author = {
id: `swarm:${remote.domain}:${profile.handle}`,
@@ -167,7 +46,7 @@ export async function GET(request: Request, context: RouteContext) {
avatarUrl: profile.avatarUrl,
};
const posts = swarmData.posts.map((post: any) => ({
const posts = profileData.posts.map((post: any) => ({
id: post.id,
content: post.content,
createdAt: post.createdAt,
@@ -188,72 +67,7 @@ export async function GET(request: Request, context: RouteContext) {
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: [] });
}
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 = [];
const seenIds = new Set<string>();
const seenContentKeys = new Set<string>(); // For content-based dedup
const origin = new URL(request.url).origin;
for (const item of outboxItems) {
const activity = item?.type === 'Create' ? item : null;
const object = activity?.object;
if (!object || typeof object === 'string' || object.type !== 'Note') {
continue;
}
// Deduplicate by object ID
const postId = object.id || activity.id;
if (seenIds.has(postId)) {
continue;
}
// Content-based dedup: similar content = skip
const contentKey = normalizeForDedup(object.content || '');
if (seenContentKeys.has(contentKey)) {
continue;
}
seenIds.add(postId);
seenContentKeys.add(contentKey);
const attachments = Array.isArray(object.attachment) ? object.attachment : [];
const { text, urls } = extractTextAndUrls(object.content);
const normalizedUrl = urls.length > 0 ? normalizeUrl(urls[0]) : null;
const linkPreview = normalizedUrl ? await fetchLinkPreview(normalizedUrl, origin) : null;
const contentText = linkPreview && normalizedUrl ? stripFirstUrl(text, normalizedUrl) : text;
posts.push({
id: postId,
content: contentText || '',
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: `${postId || 'media'}-${index}`,
url: attachment.url,
altText: sanitizeText(attachment.name) || null,
})),
linkPreviewUrl: linkPreview?.url || normalizedUrl,
linkPreviewTitle: linkPreview?.title || null,
linkPreviewDescription: linkPreview?.description || null,
linkPreviewImage: linkPreview?.image || null,
});
}
return NextResponse.json({ posts, nextCursor: null });
return NextResponse.json({ posts: [] });
}
// Find the user
@@ -266,10 +80,15 @@ export async function GET(request: Request, context: RouteContext) {
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;
// Only fetch from swarm nodes
const isSwarm = await isSwarmNode(remote.domain);
if (!isSwarm) {
return NextResponse.json({ posts: [], message: 'Only Synapsis swarm nodes are supported' });
}
const profileData = await fetchSwarmUserProfile(remote.handle, remote.domain, limit);
if (profileData?.posts) {
const profile = profileData.profile;
const authorHandle = `${profile.handle}@${remote.domain}`;
const author = {
id: `swarm:${remote.domain}:${profile.handle}`,
@@ -278,7 +97,7 @@ export async function GET(request: Request, context: RouteContext) {
avatarUrl: profile.avatarUrl,
};
const posts = swarmData.posts.map((post: any) => ({
const posts = profileData.posts.map((post: any) => ({
id: post.id,
content: post.content,
createdAt: post.createdAt,
@@ -299,85 +118,14 @@ export async function GET(request: Request, context: RouteContext) {
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: [] });
}
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 = [];
const seenIds = new Set<string>();
const seenContentKeys = new Set<string>(); // For content-based dedup
const origin = new URL(request.url).origin;
for (const item of outboxItems) {
const activity = item?.type === 'Create' ? item : null;
const object = activity?.object;
if (!object || typeof object === 'string' || object.type !== 'Note') {
continue;
}
// Deduplicate by object ID
const postId = object.id || activity.id;
if (seenIds.has(postId)) {
continue;
}
// Content-based dedup: similar content = skip
const contentKey = normalizeForDedup(object.content || '');
if (seenContentKeys.has(contentKey)) {
continue;
}
seenIds.add(postId);
seenContentKeys.add(contentKey);
const attachments = Array.isArray(object.attachment) ? object.attachment : [];
const { text, urls } = extractTextAndUrls(object.content);
const normalizedUrl = urls.length > 0 ? normalizeUrl(urls[0]) : null;
const linkPreview = normalizedUrl ? await fetchLinkPreview(normalizedUrl, origin) : null;
const contentText = linkPreview && normalizedUrl ? stripFirstUrl(text, normalizedUrl) : text;
posts.push({
id: postId,
content: contentText || '',
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: `${postId || 'media'}-${index}`,
url: attachment.url,
altText: sanitizeText(attachment.name) || null,
})),
linkPreviewUrl: linkPreview?.url || normalizedUrl,
linkPreviewTitle: linkPreview?.title || null,
linkPreviewDescription: linkPreview?.description || null,
linkPreviewImage: linkPreview?.image || null,
});
}
return NextResponse.json({
posts,
nextCursor: null,
});
return NextResponse.json({ posts: [] });
}
if (user.isSuspended) {
return NextResponse.json({ error: 'User not found' }, { status: 404 });
}
// Get user's posts with cursor-based pagination
const { lt } = await import('drizzle-orm');
let whereConditions = and(eq(posts.userId, user.id), eq(posts.isRemoved, false));
// If cursor provided, get posts older than the cursor
+30 -133
View File
@@ -1,65 +1,10 @@
import { NextResponse } from 'next/server';
import { db, users } from '@/db';
import { eq } from 'drizzle-orm';
import { userToActor } from '@/lib/activitypub/actor';
import { resolveRemoteUser } from '@/lib/activitypub/fetch';
import { fetchSwarmUserProfile, isSwarmNode } from '@/lib/swarm/interactions';
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(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#39;/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 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;
};
/**
* 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;
@@ -93,72 +38,37 @@ export async function GET(request: Request, context: RouteContext) {
if (!user || isRemotePlaceholder) {
if (remoteHandle && remoteDomain) {
// Try Swarm API first (for Synapsis nodes)
const swarmData = await fetchSwarmProfile(remoteHandle, remoteDomain);
if (swarmData?.profile) {
const profile = swarmData.profile;
// Build botOwner object if this is a bot with an owner
let botOwner = undefined;
if (profile.isBot && profile.botOwnerHandle) {
botOwner = {
id: `swarm:${remoteDomain}:${profile.botOwnerHandle}`,
handle: `${profile.botOwnerHandle}@${remoteDomain}`,
};
// Only fetch from swarm nodes
const isSwarm = await isSwarmNode(remoteDomain);
if (isSwarm) {
const profileData = await fetchSwarmUserProfile(remoteHandle, remoteDomain, 0);
if (profileData?.profile) {
const profile = profileData.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,
}
});
}
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,
botOwner,
}
});
}
// 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;
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}`,
handle: `${remoteProfile.preferredUsername || remoteHandle}@${remoteDomain}`,
displayName,
bio: sanitizeText(remoteProfile.summary),
avatarUrl: iconUrl ?? null,
headerUrl: headerUrl ?? null,
followersCount,
followingCount,
postsCount,
website: profileUrl ?? null,
createdAt: new Date().toISOString(),
isRemote: true,
profileUrl: profileUrl ?? null,
}
});
}
// Non-swarm nodes are no longer supported
return NextResponse.json({ error: 'User not found. Only Synapsis swarm nodes are supported.' }, { status: 404 });
}
// Only return 404 if this wasn't a remote placeholder we were trying to refresh
if (!isRemotePlaceholder) {
@@ -169,20 +79,7 @@ export async function GET(request: Request, context: RouteContext) {
return NextResponse.json({ error: 'User not found' }, { status: 404 });
}
// Check if ActivityPub request
const accept = request.headers.get('accept') || '';
if (accept.includes('application/activity+json') || accept.includes('application/ld+json')) {
const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:3000';
const actor = userToActor(user, nodeDomain);
return NextResponse.json(actor, {
headers: {
'Content-Type': 'application/activity+json',
},
});
}
// Return user profile (without sensitive data)
// Include bot info if this is a bot account
const userResponse: Record<string, unknown> = {
id: user.id,
handle: user.handle,