Add docker publish flow, node blocklist & API updates

Replace docker/metadata GH Action with a docker-publish.sh driven workflow (supports auto-versioning, extra tags, multi-arch builds and optional builder). Update docs and READMEs to use the updater and new publish flow. Add server-side swarm/node blocklist support and admin nodes API. Improve auth endpoints (me, logout, switch, session accounts) and support account switching. Extend bots API to support custom LLM provider and optional endpoint. Enforce node blocklist on chat send/receive, refactor link preview handling into dedicated preview modules, and enhance notifications and posts like/repost handlers to accept both signed actions and regular auth flows. Misc: remove admin update UI details, add helper libs (media previews, node-blocklist, reposts, notifications, etc.), and minor housekeeping (pyc, workflow tweaks).
This commit is contained in:
cyph3rasi
2026-03-10 12:40:05 -07:00
parent 044196cfa7
commit b4a9d82d05
79 changed files with 4714 additions and 1114 deletions
+72
View File
@@ -0,0 +1,72 @@
import { NextRequest, NextResponse } from 'next/server';
import { db, swarmNodes } from '@/db';
import { desc } from 'drizzle-orm';
import { z } from 'zod';
import { requireAdmin } from '@/lib/auth/admin';
import { normalizeNodeDomain, unblockNode, upsertBlockedNode } from '@/lib/swarm/node-blocklist';
const mutateNodeSchema = z.object({
action: z.enum(['block', 'unblock']),
domain: z.string().min(1),
reason: z.string().max(500).optional().nullable(),
});
export async function GET() {
try {
await requireAdmin();
const nodes = await db.query.swarmNodes.findMany({
orderBy: [desc(swarmNodes.isBlocked), desc(swarmNodes.blockedAt), desc(swarmNodes.lastSeenAt)],
});
return NextResponse.json({
nodes: nodes.map((node) => ({
id: node.id,
domain: node.domain,
name: node.name,
description: node.description,
isActive: node.isActive,
isBlocked: node.isBlocked,
blockReason: node.blockReason,
blockedAt: node.blockedAt,
lastSeenAt: node.lastSeenAt,
trustScore: node.trustScore,
isNsfw: node.isNsfw,
})),
});
} catch (error) {
console.error('Admin get nodes error:', error);
return NextResponse.json({ error: 'Failed to load nodes' }, { status: 500 });
}
}
export async function PATCH(request: NextRequest) {
try {
await requireAdmin();
const body = await request.json();
const data = mutateNodeSchema.parse(body);
const domain = normalizeNodeDomain(data.domain);
const localDomain = normalizeNodeDomain(process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:3000');
if (domain === localDomain) {
return NextResponse.json({ error: 'Cannot block this node itself' }, { status: 400 });
}
const node = data.action === 'block'
? await upsertBlockedNode(domain, data.reason || null)
: await unblockNode(domain);
if (!node) {
return NextResponse.json({ error: 'Node not found' }, { status: 404 });
}
return NextResponse.json({ node });
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json({ error: 'Invalid payload', details: error.issues }, { status: 400 });
}
console.error('Admin update nodes error:', error);
return NextResponse.json({ error: 'Failed to update node blocklist' }, { status: 500 });
}
}
+18 -4
View File
@@ -1,11 +1,25 @@
import { NextResponse } from 'next/server';
import { destroySession } from '@/lib/auth';
import { destroySession, getSession } from '@/lib/auth';
import { clearStorageSession } from '@/lib/storage/session';
import { z } from 'zod';
export async function POST() {
const logoutSchema = z.object({
userId: z.string().uuid().optional(),
});
export async function POST(request: Request) {
try {
await clearStorageSession();
await destroySession();
const currentSession = await getSession();
const body = await request.json().catch(() => ({}));
const data = logoutSchema.parse(body);
const targetUserId = data.userId ?? currentSession?.user.id;
if (targetUserId) {
await clearStorageSession(targetUserId);
}
await destroySession(targetUserId);
return NextResponse.json({ success: true });
} catch (error) {
+5 -3
View File
@@ -1,5 +1,5 @@
import { NextResponse } from 'next/server';
import { getSession } from '@/lib/auth';
import { getSession, getSessionAccounts } from '@/lib/auth';
import { db, users } from '@/db';
import { eq } from 'drizzle-orm';
import { z } from 'zod';
@@ -22,9 +22,10 @@ export async function GET() {
}
const session = await getSession();
const accounts = await getSessionAccounts();
if (!session) {
return NextResponse.json({ user: null });
return NextResponse.json({ user: null, accounts: [] });
}
return NextResponse.json({
@@ -40,10 +41,11 @@ export async function GET() {
publicKey: session.user.publicKey,
privateKeyEncrypted: session.user.privateKeyEncrypted,
},
accounts,
});
} catch (error) {
console.error('Session check error:', error);
return NextResponse.json({ user: null });
return NextResponse.json({ user: null, accounts: [] });
}
}
+37
View File
@@ -0,0 +1,37 @@
import { NextResponse } from 'next/server';
import { switchSession } from '@/lib/auth';
import { z } from 'zod';
const switchSchema = z.object({
userId: z.string().uuid(),
});
export async function POST(request: Request) {
try {
const body = await request.json();
const data = switchSchema.parse(body);
const session = await switchSession(data.userId);
return NextResponse.json({
success: true,
user: {
id: session.user.id,
handle: session.user.handle,
displayName: session.user.displayName,
avatarUrl: session.user.avatarUrl,
bio: session.user.bio,
website: session.user.website,
dmPrivacy: session.user.dmPrivacy,
did: session.user.did,
publicKey: session.user.publicKey,
privateKeyEncrypted: session.user.privateKeyEncrypted,
},
});
} catch (error) {
console.error('Switch session error:', error);
return NextResponse.json(
{ error: error instanceof Error ? error.message : 'Failed to switch account' },
{ status: 400 }
);
}
}
+1 -1
View File
@@ -24,7 +24,7 @@ type RouteContext = { params: Promise<{ id: string }> };
// Schema for setting API key
const setApiKeySchema = z.object({
apiKey: z.string().min(1, 'API key is required'),
provider: z.enum(['openrouter', 'openai', 'anthropic']).optional(),
provider: z.enum(['openrouter', 'openai', 'anthropic', 'custom']).optional(),
});
/**
+5 -1
View File
@@ -34,8 +34,9 @@ const updateBotSchema = z.object({
maxTokens: z.number().int().min(1).max(100000),
responseStyle: z.string().optional(),
}).optional(),
llmProvider: z.enum(['openrouter', 'openai', 'anthropic']).optional(),
llmProvider: z.enum(['openrouter', 'openai', 'anthropic', 'custom']).optional(),
llmModel: z.string().min(1).optional(),
llmEndpoint: z.string().url().optional().nullable(),
llmApiKey: z.string().min(1).optional(),
schedule: z.object({
type: z.enum(['interval', 'times', 'cron']),
@@ -91,6 +92,7 @@ export async function GET(request: Request, context: RouteContext) {
personalityConfig: bot.personalityConfig,
llmProvider: bot.llmProvider,
llmModel: bot.llmModel,
llmEndpoint: bot.llmEndpoint,
scheduleConfig: bot.scheduleConfig,
autonomousMode: bot.autonomousMode,
isActive: bot.isActive,
@@ -162,6 +164,7 @@ async function handleUpdate(request: Request, context: RouteContext) {
if (data.personality !== undefined) updateInput.personality = data.personality;
if (data.llmProvider !== undefined) updateInput.llmProvider = data.llmProvider;
if (data.llmModel !== undefined) updateInput.llmModel = data.llmModel;
if (data.llmEndpoint !== undefined) updateInput.llmEndpoint = data.llmEndpoint;
if (data.llmApiKey !== undefined) updateInput.llmApiKey = data.llmApiKey;
if (data.schedule !== undefined) updateInput.schedule = data.schedule;
if (data.autonomousMode !== undefined) updateInput.autonomousMode = data.autonomousMode;
@@ -183,6 +186,7 @@ async function handleUpdate(request: Request, context: RouteContext) {
personalityConfig: updatedBot.personalityConfig,
llmProvider: updatedBot.llmProvider,
llmModel: updatedBot.llmModel,
llmEndpoint: updatedBot.llmEndpoint,
scheduleConfig: updatedBot.scheduleConfig,
autonomousMode: updatedBot.autonomousMode,
isActive: updatedBot.isActive,
+5 -1
View File
@@ -33,8 +33,9 @@ const createBotSchema = z.object({
maxTokens: z.number().int().min(1).max(100000),
responseStyle: z.string().optional(),
}),
llmProvider: z.enum(['openrouter', 'openai', 'anthropic']),
llmProvider: z.enum(['openrouter', 'openai', 'anthropic', 'custom']),
llmModel: z.string().min(1),
llmEndpoint: z.string().url().optional(),
llmApiKey: z.string().min(1),
schedule: z.object({
type: z.enum(['interval', 'times', 'cron']),
@@ -96,6 +97,7 @@ export async function POST(request: Request) {
personality: data.personality,
llmProvider: data.llmProvider,
llmModel: data.llmModel,
llmEndpoint: data.llmEndpoint,
llmApiKey: data.llmApiKey,
schedule: data.schedule,
autonomousMode: data.autonomousMode,
@@ -114,6 +116,7 @@ export async function POST(request: Request) {
personalityConfig: bot.personalityConfig,
llmProvider: bot.llmProvider,
llmModel: bot.llmModel,
llmEndpoint: bot.llmEndpoint,
scheduleConfig: bot.scheduleConfig,
autonomousMode: bot.autonomousMode,
isActive: bot.isActive,
@@ -189,6 +192,7 @@ export async function GET() {
personalityConfig: bot.personalityConfig,
llmProvider: bot.llmProvider,
llmModel: bot.llmModel,
llmEndpoint: bot.llmEndpoint,
scheduleConfig: bot.scheduleConfig,
autonomousMode: bot.autonomousMode,
isActive: bot.isActive,
+19 -3
View File
@@ -6,6 +6,7 @@ import { verifyActionSignature, type SignedAction } from '@/lib/auth/verify-sign
import { verifySwarmRequest } from '@/lib/swarm/signature';
import { fetchAndCacheRemoteKey, logKeyChange } from '@/lib/swarm/identity-cache';
import { z } from 'zod';
import { isNodeBlocked, normalizeNodeDomain } from '@/lib/swarm/node-blocklist';
const signedChatActionSchema = z.object({
action: z.string().min(1),
@@ -76,6 +77,11 @@ export async function POST(request: NextRequest) {
let fullSenderHandle: string | null = null;
if (swarmSignature && sourceDomain && body.userAction) {
const normalizedSourceDomain = normalizeNodeDomain(sourceDomain);
if (await isNodeBlocked(normalizedSourceDomain)) {
return NextResponse.json({ error: 'Blocked node' }, { status: 403 });
}
// Federated envelope format - validate and verify node signature
const envelopeValidation = federatedEnvelopeSchema.safeParse(body);
if (!envelopeValidation.success) {
@@ -117,6 +123,12 @@ export async function POST(request: NextRequest) {
// Use full handle if provided in envelope, otherwise fall back to signed handle
const senderHandle = fullSenderHandle || handle;
const senderDomainFromHandle = senderHandle.includes('@')
? normalizeNodeDomain(senderHandle.split('@').pop() || '')
: null;
if (senderDomainFromHandle && await isNodeBlocked(senderDomainFromHandle)) {
return NextResponse.json({ error: 'Blocked node' }, { status: 403 });
}
console.log(`[Chat Receive] From: ${senderHandle} (DID: ${did}), To: ${recipientDid}`);
// 1. Resolve Sender Public Key
@@ -134,21 +146,25 @@ export async function POST(request: NextRequest) {
// Derive domain from full sender handle if possible
if (senderHandle.includes('@')) {
const parts = senderHandle.split('@');
senderNodeDomain = parts[parts.length - 1];
senderNodeDomain = normalizeNodeDomain(parts[parts.length - 1]);
} else {
// Try to get from header first
const sourceDomainHeader = request.headers.get('X-Swarm-Source-Domain');
if (sourceDomainHeader) {
senderNodeDomain = sourceDomainHeader;
senderNodeDomain = normalizeNodeDomain(sourceDomainHeader);
} else {
// Try handle registry (though we likely don't have it if we don't have the user)
const registryEntry = await db.query.handleRegistry.findFirst({
where: eq(handleRegistry.did, did)
});
if (registryEntry) senderNodeDomain = registryEntry.nodeDomain;
if (registryEntry) senderNodeDomain = normalizeNodeDomain(registryEntry.nodeDomain);
}
}
if (senderNodeDomain && await isNodeBlocked(senderNodeDomain)) {
return NextResponse.json({ error: 'Blocked node' }, { status: 403 });
}
if (senderNodeDomain) {
try {
const protocol = senderNodeDomain.includes('localhost') ? 'http' : 'https';
+6
View File
@@ -6,6 +6,7 @@ import { eq, and } from 'drizzle-orm';
import { z } from 'zod';
import { createSignedPayload } from '@/lib/swarm/signature';
import { federatedHandleSchema } from '@/lib/utils/federation';
import { isNodeBlocked, normalizeNodeDomain } from '@/lib/swarm/node-blocklist';
const chatSendSchema = z.object({
recipientDid: z.string().min(1).regex(/^did:/, 'Must be a valid DID (did:key:... or did:synapsis:...)'),
@@ -167,6 +168,11 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: 'Recipient node not found' }, { status: 404 });
}
targetDomain = normalizeNodeDomain(targetDomain);
if (await isNodeBlocked(targetDomain)) {
return NextResponse.json({ error: 'This node is blocked by the server administrator' }, { status: 403 });
}
console.log(`[Remote Send] Sending to ${targetHandle} at ${targetDomain}`);
// 2. Send to Remote Node (Forward the Signed Action)
+24 -104
View File
@@ -1,8 +1,8 @@
import { NextRequest, NextResponse } from 'next/server';
import type { LinkPreviewData } from '@/lib/media/linkPreview';
import { fetchRedditRichPreview } from '@/lib/media/redditPreview';
import { fetchGenericLinkPreview } from '@/lib/media/genericPreview';
/**
* Check if a URL is from Reddit.
*/
function isRedditUrl(url: string): boolean {
try {
const parsed = new URL(url);
@@ -12,58 +12,16 @@ function isRedditUrl(url: string): boolean {
}
}
/**
* Fetch preview for Reddit URLs using their oEmbed API.
*/
async function fetchRedditPreview(url: string): Promise<{
url: string;
title: string | null;
description: string | null;
image: string | null;
} | null> {
try {
const oembedUrl = `https://www.reddit.com/oembed?url=${encodeURIComponent(url)}`;
const response = await fetch(oembedUrl, {
headers: { 'Accept': 'application/json' },
signal: AbortSignal.timeout(5000),
});
if (!response.ok) {
return null;
}
const data = await response.json();
// Extract title - try title field first, then parse from HTML
let title = data.title || null;
if (!title && data.html) {
const titleMatch = data.html.match(/href="[^"]+">([^<]+)<\/a>/);
if (titleMatch && titleMatch[1] && titleMatch[1] !== 'Comment') {
title = titleMatch[1];
}
}
// Build description from subreddit info
let description = null;
if (data.author_name) {
description = `Posted by ${data.author_name}`;
} else if (data.html) {
const subredditMatch = data.html.match(/r\/([a-zA-Z0-9_]+)/);
if (subredditMatch) {
description = `r/${subredditMatch[1]}`;
}
}
return {
url,
title,
description,
image: data.thumbnail_url || null,
};
} catch {
return null;
}
function buildBasicPreview(url: string, title?: string | null, description?: string | null, image?: string | null): LinkPreviewData {
return {
url,
title: title || url,
description: description || null,
image: image || null,
type: image ? 'image' : 'card',
videoUrl: null,
media: image ? [{ url: image }] : null,
};
}
export async function GET(req: NextRequest) {
@@ -75,68 +33,30 @@ export async function GET(req: NextRequest) {
return NextResponse.json({ error: 'No URL provided' }, { status: 400 });
}
// Normalize URL
if (!url.startsWith('http://') && !url.startsWith('https://')) {
url = 'https://' + url;
}
// Use Reddit-specific handler
if (isRedditUrl(url)) {
const preview = await fetchRedditPreview(url);
const preview = await fetchRedditRichPreview(url);
if (preview) {
return NextResponse.json(preview);
}
// Fall back to URL-only response if oEmbed fails
return NextResponse.json({
url,
title: 'Reddit',
description: null,
image: null,
});
return NextResponse.json(buildBasicPreview(url, 'Reddit'));
}
// Generic OG tag scraping for other sites
let response;
try {
response = await fetch(url, {
headers: {
'User-Agent': 'Mozilla/5.0 (compatible; SynapsisBot/1.0; +https://synapsis.social)',
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
'Accept-Language': 'en-US,en;q=0.5',
},
signal: AbortSignal.timeout(5000),
});
} catch (fetchError) {
console.warn(`Fetch failed for URL: ${url}`, fetchError);
const preview = await fetchGenericLinkPreview(url);
if (!preview) {
return NextResponse.json({ error: 'Could not reach the URL' }, { status: 404 });
}
if (!response.ok) {
return NextResponse.json({ error: `URL returned status ${response.status}` }, { status: 404 });
}
const html = await response.text();
const getMeta = (property: string) => {
const regex = new RegExp(`<meta[^>]+(?:property|name)=["'](?:og:)?${property}["'][^>]+content=["']([^"']+)["']`, 'i');
const match = html.match(regex);
if (match) return match[1];
const regexRev = new RegExp(`<meta[^>]+content=["']([^"']+)["'][^>]+(?:property|name)=["'](?:og:)?${property}["']`, 'i');
const matchRev = html.match(regexRev);
return matchRev ? matchRev[1] : null;
};
const title = getMeta('title') || html.match(/<title>([^<]+)<\/title>/i)?.[1];
const description = getMeta('description');
const image = getMeta('image');
return NextResponse.json({
url,
title: title?.trim() || url,
description: description?.trim() || null,
image: image?.trim() || null,
});
return NextResponse.json(buildBasicPreview(
preview.url,
preview.title?.trim() || url,
preview.description?.trim() || null,
preview.image?.trim() || null,
));
} catch (error) {
console.error('Link preview error:', error);
return NextResponse.json({ error: 'Failed to fetch preview' }, { status: 500 });
+9
View File
@@ -99,6 +99,15 @@ export async function GET(request: Request) {
avatarUrl: freshProfile?.avatarUrl || row.actorAvatarUrl,
nodeDomain: row.actorNodeDomain,
},
target: row.targetHandle ? {
handle: row.targetNodeDomain
? `${row.targetHandle}@${row.targetNodeDomain}`
: row.targetHandle,
displayName: row.targetDisplayName,
avatarUrl: row.targetAvatarUrl,
nodeDomain: row.targetNodeDomain,
isBot: row.targetIsBot,
} : null,
post: row.postId ? {
id: row.postId,
content: row.postContent,
+53 -17
View File
@@ -1,9 +1,12 @@
import { NextResponse } from 'next/server';
import { db, posts, likes, users, notifications, userSwarmLikes } from '@/db';
import { requireAuth } from '@/lib/auth';
import { requireSignedAction, type SignedAction } from '@/lib/auth/verify-signature';
import { eq, and, sql } from 'drizzle-orm';
import { z } from 'zod';
import crypto from 'crypto';
import { buildNotificationTarget } from '@/lib/notifications';
import { serializeLinkPreviewMedia } from '@/lib/media/linkPreview';
type RouteContext = { params: Promise<{ id: string }> };
@@ -13,6 +16,25 @@ const postIdSchema = z.union([
z.string().regex(/^swarm:[^:]+:[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/, 'Invalid swarm post ID format'),
]);
function isSignedActionPayload(payload: unknown): payload is SignedAction {
if (!payload || typeof payload !== 'object') return false;
const value = payload as Record<string, unknown>;
return typeof value.action === 'string'
&& typeof value.did === 'string'
&& typeof value.handle === 'string'
&& typeof value.ts === 'number'
&& typeof value.nonce === 'string'
&& typeof value.sig === 'string'
&& typeof value.data === 'object'
&& value.data !== null;
}
async function readOptionalJson(request: Request) {
const rawBody = await request.text();
if (!rawBody.trim()) return null;
return JSON.parse(rawBody);
}
/**
* Extract domain from a swarm post ID (swarm:domain:postId)
*/
@@ -71,6 +93,9 @@ async function fetchSwarmPostSnapshot(domain: string, originalPostId: string) {
linkPreviewTitle: post.linkPreviewTitle || null,
linkPreviewDescription: post.linkPreviewDescription || null,
linkPreviewImage: post.linkPreviewImage || null,
linkPreviewType: post.linkPreviewType || null,
linkPreviewVideoUrl: post.linkPreviewVideoUrl || null,
linkPreviewMediaJson: serializeLinkPreviewMedia(post.linkPreviewMedia),
mediaJson: post.media ? JSON.stringify(post.media) : null,
};
} catch {
@@ -81,15 +106,19 @@ async function fetchSwarmPostSnapshot(domain: string, originalPostId: string) {
// Like a post
export async function POST(request: Request, context: RouteContext) {
try {
// Parse the signed action from the request body
const signedAction: SignedAction = await request.json();
const body = await readOptionalJson(request);
const { id: paramId } = await context.params;
const decodedParamId = decodeURIComponent(paramId);
// Verify the signature and get the user
const user = await requireSignedAction(signedAction);
const user = isSignedActionPayload(body)
? await requireSignedAction(body)
: await requireAuth();
// Extract and validate postId from the signed action data
const { postId: rawId } = signedAction.data;
const decodedId = decodeURIComponent(rawId);
if (isSignedActionPayload(body) && body.data?.postId && body.data.postId !== decodedParamId) {
return NextResponse.json({ error: 'Post ID mismatch' }, { status: 400 });
}
const decodedId = decodedParamId;
const postId = postIdSchema.parse(decodedId);
const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:3000';
@@ -183,6 +212,10 @@ export async function POST(request: Request, context: RouteContext) {
.where(eq(posts.id, postId));
if (post.userId !== user.id) {
const postAuthor = await db.query.users.findFirst({
where: eq(users.id, post.userId),
});
// Create notification with actor info stored directly
await db.insert(notifications).values({
userId: post.userId,
@@ -193,13 +226,11 @@ export async function POST(request: Request, context: RouteContext) {
actorNodeDomain: null, // Local user
postId,
postContent: post.content?.slice(0, 200) || null,
...(postAuthor?.isBot ? buildNotificationTarget(postAuthor) : {}),
type: 'like',
});
// Also notify bot owner if this is a bot's post
const postAuthor = await db.query.users.findFirst({
where: eq(users.id, post.userId),
});
if (postAuthor?.isBot && postAuthor.botOwnerId) {
await db.insert(notifications).values({
userId: postAuthor.botOwnerId,
@@ -210,6 +241,7 @@ export async function POST(request: Request, context: RouteContext) {
actorNodeDomain: null,
postId,
postContent: post.content?.slice(0, 200) || null,
...buildNotificationTarget(postAuthor),
type: 'like',
});
}
@@ -277,15 +309,19 @@ export async function POST(request: Request, context: RouteContext) {
// Unlike a post
export async function DELETE(request: Request, context: RouteContext) {
try {
// Parse the signed action from the request body
const signedAction: SignedAction = await request.json();
const body = await readOptionalJson(request);
const { id: paramId } = await context.params;
const decodedParamId = decodeURIComponent(paramId);
// Verify the signature and get the user
const user = await requireSignedAction(signedAction);
const user = isSignedActionPayload(body)
? await requireSignedAction(body)
: await requireAuth();
// Extract and validate postId from the signed action data
const { postId: rawId } = signedAction.data;
const decodedId = decodeURIComponent(rawId);
if (isSignedActionPayload(body) && body.data?.postId && body.data.postId !== decodedParamId) {
return NextResponse.json({ error: 'Post ID mismatch' }, { status: 400 });
}
const decodedId = decodedParamId;
const postId = postIdSchema.parse(decodedId);
const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:3000';
+105 -4
View File
@@ -1,9 +1,11 @@
import { NextResponse } from 'next/server';
import { db, posts, users, notifications } from '@/db';
import { db, posts, users, notifications, userSwarmReposts } from '@/db';
import { requireAuth } from '@/lib/auth';
import { eq, and, sql } from 'drizzle-orm';
import { z } from 'zod';
import crypto from 'crypto';
import { buildNotificationTarget } from '@/lib/notifications';
import { serializeLinkPreviewMedia } from '@/lib/media/linkPreview';
type RouteContext = { params: Promise<{ id: string }> };
@@ -40,6 +42,47 @@ function extractSwarmPostId(apId: string): string | null {
return apId.substring(lastColonIndex + 1);
}
async function fetchSwarmPostSnapshot(domain: string, originalPostId: string) {
try {
const protocol = domain.includes('localhost') ? 'http' : 'https';
const res = await fetch(`${protocol}://${domain}/api/swarm/posts/${originalPostId}`, {
headers: { Accept: 'application/json' },
signal: AbortSignal.timeout(5000),
});
if (!res.ok) {
return null;
}
const data = await res.json();
const post = data.post;
if (!post) {
return null;
}
return {
authorHandle: post.author?.handle || 'unknown',
authorDisplayName: post.author?.displayName || post.author?.handle || 'Unknown',
authorAvatarUrl: post.author?.avatarUrl || null,
content: post.content || '',
postCreatedAt: new Date(post.createdAt || new Date().toISOString()),
likesCount: post.likesCount || 0,
repostsCount: post.repostsCount || 0,
repliesCount: post.repliesCount || 0,
linkPreviewUrl: post.linkPreviewUrl || null,
linkPreviewTitle: post.linkPreviewTitle || null,
linkPreviewDescription: post.linkPreviewDescription || null,
linkPreviewImage: post.linkPreviewImage || null,
linkPreviewType: post.linkPreviewType || null,
linkPreviewVideoUrl: post.linkPreviewVideoUrl || null,
linkPreviewMediaJson: serializeLinkPreviewMedia(post.linkPreviewMedia),
mediaJson: post.media ? JSON.stringify(post.media) : null,
};
} catch {
return null;
}
}
// Repost a post
export async function POST(request: Request, context: RouteContext) {
try {
@@ -62,6 +105,18 @@ export async function POST(request: Request, context: RouteContext) {
return NextResponse.json({ error: 'Invalid swarm post ID' }, { status: 400 });
}
const existingRepost = await db.query.userSwarmReposts.findFirst({
where: and(
eq(userSwarmReposts.userId, user.id),
eq(userSwarmReposts.nodeDomain, targetDomain),
eq(userSwarmReposts.originalPostId, originalPostId),
),
});
if (existingRepost) {
return NextResponse.json({ error: 'Already reposted' }, { status: 400 });
}
// Deliver repost directly to the origin node
const { deliverSwarmRepost } = await import('@/lib/swarm/interactions');
@@ -83,6 +138,27 @@ export async function POST(request: Request, context: RouteContext) {
return NextResponse.json({ error: 'Failed to deliver repost to remote node' }, { status: 502 });
}
const snapshot = await fetchSwarmPostSnapshot(targetDomain, originalPostId);
if (snapshot) {
await db.insert(userSwarmReposts).values({
userId: user.id,
nodeDomain: targetDomain,
originalPostId,
...snapshot,
repostedAt: new Date(),
}).onConflictDoUpdate({
target: [userSwarmReposts.userId, userSwarmReposts.nodeDomain, userSwarmReposts.originalPostId],
set: {
...snapshot,
repostedAt: new Date(),
},
});
}
await db.update(users)
.set({ postsCount: sql`${users.postsCount} + 1` })
.where(eq(users.id, user.id));
console.log(`[Swarm] Repost delivered to ${targetDomain} for post ${originalPostId}`);
return NextResponse.json({ success: true, reposted: true });
}
@@ -133,6 +209,10 @@ export async function POST(request: Request, context: RouteContext) {
.where(eq(users.id, user.id));
if (originalPost.userId !== user.id) {
const postAuthor = await db.query.users.findFirst({
where: eq(users.id, originalPost.userId),
});
// Create notification with actor info stored directly
await db.insert(notifications).values({
userId: originalPost.userId,
@@ -143,13 +223,11 @@ export async function POST(request: Request, context: RouteContext) {
actorNodeDomain: null, // Local user
postId,
postContent: originalPost.content?.slice(0, 200) || null,
...(postAuthor?.isBot ? buildNotificationTarget(postAuthor) : {}),
type: 'repost',
});
// Also notify bot owner if this is a bot's post
const postAuthor = await db.query.users.findFirst({
where: eq(users.id, originalPost.userId),
});
if (postAuthor?.isBot && postAuthor.botOwnerId) {
await db.insert(notifications).values({
userId: postAuthor.botOwnerId,
@@ -160,6 +238,7 @@ export async function POST(request: Request, context: RouteContext) {
actorNodeDomain: null,
postId,
postContent: originalPost.content?.slice(0, 200) || null,
...buildNotificationTarget(postAuthor),
type: 'repost',
});
}
@@ -236,6 +315,18 @@ export async function DELETE(request: Request, context: RouteContext) {
return NextResponse.json({ error: 'Invalid swarm post ID' }, { status: 400 });
}
const existingRepost = await db.query.userSwarmReposts.findFirst({
where: and(
eq(userSwarmReposts.userId, user.id),
eq(userSwarmReposts.nodeDomain, targetDomain),
eq(userSwarmReposts.originalPostId, originalPostId),
),
});
if (!existingRepost) {
return NextResponse.json({ error: 'Not reposted' }, { status: 400 });
}
// Deliver unrepost directly to the origin node
const { deliverSwarmUnrepost } = await import('@/lib/swarm/interactions');
@@ -254,6 +345,16 @@ export async function DELETE(request: Request, context: RouteContext) {
return NextResponse.json({ error: 'Failed to deliver unrepost to remote node' }, { status: 502 });
}
await db.delete(userSwarmReposts).where(and(
eq(userSwarmReposts.userId, user.id),
eq(userSwarmReposts.nodeDomain, targetDomain),
eq(userSwarmReposts.originalPostId, originalPostId),
));
await db.update(users)
.set({ postsCount: sql`GREATEST(0, ${users.postsCount} - 1)` })
.where(eq(users.id, user.id));
console.log(`[Swarm] Unrepost delivered to ${targetDomain} for post ${originalPostId}`);
return NextResponse.json({ success: true, reposted: false });
}
+87 -62
View File
@@ -2,6 +2,7 @@ import { NextResponse } from 'next/server';
import { db, posts, users, media, remotePosts } from '@/db';
import { eq, desc, and, sql, inArray } from 'drizzle-orm';
import { z } from 'zod';
import { parseLinkPreviewMediaJson } from '@/lib/media/linkPreview';
// Schema for local post ID (UUID)
const localPostIdSchema = z.string().uuid('Invalid post ID format');
@@ -15,6 +16,69 @@ const swarmPostIdSchema = z.string().regex(
// Combined schema that accepts either format
const postIdSchema = z.union([localPostIdSchema, swarmPostIdSchema]);
const embeddedPostRelations = {
author: true,
bot: true,
media: true,
replyTo: {
with: {
author: true,
bot: true,
media: true,
},
},
} as const;
const postDetailRelations = {
...embeddedPostRelations,
repostOf: {
with: embeddedPostRelations,
},
} as const;
function mapSwarmDetailPost(post: any, fallbackDomain: string): any {
const effectiveDomain = post.nodeDomain || fallbackDomain;
const rawId = post.originalPostId || post.id;
return {
id: post.id?.startsWith('swarm:') ? post.id : `swarm:${effectiveDomain}:${rawId}`,
originalPostId: rawId,
content: post.content,
createdAt: post.createdAt,
likesCount: post.likesCount || 0,
repostsCount: post.repostsCount || 0,
repliesCount: post.repliesCount || 0,
isSwarm: true,
nodeDomain: effectiveDomain,
repostOfId: post.repostOf
? (post.repostOf.id?.startsWith('swarm:')
? post.repostOf.id
: `swarm:${post.repostOf.nodeDomain || effectiveDomain}:${post.repostOf.originalPostId || post.repostOf.id}`)
: (post.repostOfId ? `swarm:${effectiveDomain}:${post.repostOfId}` : null),
repostOf: post.repostOf ? mapSwarmDetailPost(post.repostOf, post.repostOf.nodeDomain || effectiveDomain) : null,
author: post.author ? {
id: `swarm:${effectiveDomain}:${post.author.handle}`,
handle: post.author.handle.includes('@') ? post.author.handle : `${post.author.handle}@${effectiveDomain}`,
displayName: post.author.displayName,
avatarUrl: post.author.avatarUrl,
isSwarm: true,
nodeDomain: effectiveDomain,
} : null,
media: post.media?.map((m: any, idx: number) => ({
id: m.id || `swarm:${effectiveDomain}:${rawId}:media:${idx}`,
url: m.url,
altText: m.altText || null,
})) || [],
linkPreviewUrl: post.linkPreviewUrl,
linkPreviewTitle: post.linkPreviewTitle,
linkPreviewDescription: post.linkPreviewDescription,
linkPreviewImage: post.linkPreviewImage,
linkPreviewType: post.linkPreviewType || null,
linkPreviewVideoUrl: post.linkPreviewVideoUrl || null,
linkPreviewMedia: post.linkPreviewMedia || null,
};
}
export async function GET(
request: Request,
{ params }: { params: Promise<{ id: string }> }
@@ -47,67 +111,25 @@ export async function GET(
if (res.ok) {
const data = await res.json();
// Transform to match expected format
mainPost = {
id: id,
originalPostId: originalPostId,
content: data.post.content,
createdAt: data.post.createdAt,
likesCount: data.post.likesCount || 0,
repostsCount: data.post.repostsCount || 0,
repliesCount: data.post.repliesCount || 0,
isSwarm: true,
mainPost = mapSwarmDetailPost({
...data.post,
id,
originalPostId,
nodeDomain: originDomain,
author: {
id: `swarm:${originDomain}:${data.post.author.handle}`,
handle: data.post.author.handle,
displayName: data.post.author.displayName,
avatarUrl: data.post.author.avatarUrl,
isSwarm: true,
nodeDomain: originDomain,
},
media: data.post.media?.map((m: any, idx: number) => ({
id: `swarm:${originDomain}:${originalPostId}:media:${idx}`,
url: m.url,
altText: m.altText || null,
})) || [],
linkPreviewUrl: data.post.linkPreviewUrl,
linkPreviewTitle: data.post.linkPreviewTitle,
linkPreviewDescription: data.post.linkPreviewDescription,
linkPreviewImage: data.post.linkPreviewImage,
};
}, originDomain);
// Transform replies from the origin node
replyPosts = (data.replies || []).map((r: any) => ({
id: `swarm:${originDomain}:${r.id}`,
originalPostId: r.id,
content: r.content,
createdAt: r.createdAt,
likesCount: r.likesCount || 0,
repostsCount: r.repostsCount || 0,
repliesCount: r.repliesCount || 0,
isSwarm: true,
replyPosts = (data.replies || []).map((reply: any) => mapSwarmDetailPost({
...reply,
nodeDomain: originDomain,
author: {
id: `swarm:${originDomain}:${r.author.handle}`,
handle: r.author.handle,
displayName: r.author.displayName,
avatarUrl: r.author.avatarUrl,
isSwarm: true,
nodeDomain: originDomain,
},
media: r.media?.map((m: any, idx: number) => ({
id: `swarm:${originDomain}:${r.id}:media:${idx}`,
url: m.url,
altText: m.altText || null,
})) || [],
}));
}, originDomain));
mainPost.repliesCount = replyPosts.length;
// Check if current user has liked this post
try {
const { requireAuth } = await import('@/lib/auth');
const { getViewerSwarmRepostedPostIds } = await import('@/lib/swarm/reposts');
const viewer = await requireAuth();
const likeCheckRes = await fetch(
@@ -119,6 +141,15 @@ export async function GET(
const likeData = await likeCheckRes.json();
mainPost.isLiked = likeData.isLiked;
}
const repostedIds = await getViewerSwarmRepostedPostIds([
{
id,
nodeDomain: originDomain,
originalPostId,
},
], viewer.id);
mainPost.isReposted = repostedIds.has(id);
} catch {
// Not logged in or timeout
}
@@ -138,13 +169,7 @@ export async function GET(
const post = await db.query.posts.findFirst({
where: eq(posts.id, id),
with: {
author: true,
media: true,
replyTo: {
with: { author: true },
},
},
with: postDetailRelations,
});
if (post) {
@@ -155,10 +180,7 @@ export async function GET(
eq(posts.replyToId, id),
eq(posts.isRemoved, false)
),
with: {
author: true,
media: true,
},
with: postDetailRelations,
orderBy: [desc(posts.createdAt)],
});
@@ -235,6 +257,9 @@ export async function GET(
linkPreviewTitle: cached.linkPreviewTitle,
linkPreviewDescription: cached.linkPreviewDescription,
linkPreviewImage: cached.linkPreviewImage,
linkPreviewType: cached.linkPreviewType,
linkPreviewVideoUrl: cached.linkPreviewVideoUrl,
linkPreviewMedia: parseLinkPreviewMediaJson(cached.linkPreviewMediaJson) || null,
isLiked: false,
isReposted: false,
};
+293 -104
View File
@@ -1,10 +1,12 @@
import { NextResponse } from 'next/server';
import { db, posts, users, media, follows, mutes, blocks, likes, remoteFollows, remotePosts } from '@/db';
import { db, posts, users, media, follows, mutes, blocks, likes, remoteFollows, remotePosts, userSwarmReposts, notifications } from '@/db';
import { requireAuth } from '@/lib/auth';
import { requireSignedAction, type SignedAction } from '@/lib/auth/verify-signature';
import { eq, desc, and, inArray, isNull, isNotNull, or, lt, sql } from 'drizzle-orm';
import type { SQL } from 'drizzle-orm';
import { z } from 'zod';
import { buildNotificationTarget } from '@/lib/notifications';
import { serializeLinkPreviewMedia, parseLinkPreviewMediaJson } from '@/lib/media/linkPreview';
const POST_MAX_LENGTH = 600;
const CURATION_WINDOW_HOURS = 72;
@@ -17,6 +19,138 @@ const buildWhere = (...conditions: Array<SQL | undefined>) => {
return and(...filtered);
};
type FeedPostWithChildren = {
id: string;
repostOf?: FeedPostWithChildren | null;
replyTo?: FeedPostWithChildren | null;
isLiked?: boolean;
isReposted?: boolean;
nodeDomain?: string | null;
originalPostId?: string | null;
};
function mapUserSwarmRepostToFeedPost(
row: typeof userSwarmReposts.$inferSelect,
author: Pick<typeof users.$inferSelect, 'id' | 'handle' | 'displayName' | 'avatarUrl' | 'isBot'>
): FeedPostWithChildren {
const remoteAuthorHandle = row.authorHandle.includes('@')
? row.authorHandle
: `${row.authorHandle}@${row.nodeDomain}`;
const remoteOriginalId = `swarm:${row.nodeDomain}:${row.originalPostId}`;
return {
id: `swarm-repost:${row.id}`,
content: '',
createdAt: row.repostedAt.toISOString(),
likesCount: 0,
repostsCount: 0,
repliesCount: 0,
author: {
id: author.id,
handle: author.handle,
displayName: author.displayName,
avatarUrl: author.avatarUrl,
isBot: author.isBot,
},
repostOfId: remoteOriginalId,
repostOf: {
id: remoteOriginalId,
originalPostId: row.originalPostId,
content: row.content,
createdAt: row.postCreatedAt.toISOString(),
likesCount: row.likesCount,
repostsCount: row.repostsCount,
repliesCount: row.repliesCount,
isSwarm: true,
nodeDomain: row.nodeDomain,
author: {
id: `swarm:${row.nodeDomain}:${row.authorHandle}`,
handle: remoteAuthorHandle,
displayName: row.authorDisplayName || row.authorHandle,
avatarUrl: row.authorAvatarUrl,
isRemote: true,
nodeDomain: row.nodeDomain,
},
media: row.mediaJson ? JSON.parse(row.mediaJson) : [],
linkPreviewUrl: row.linkPreviewUrl,
linkPreviewTitle: row.linkPreviewTitle,
linkPreviewDescription: row.linkPreviewDescription,
linkPreviewImage: row.linkPreviewImage,
linkPreviewType: row.linkPreviewType,
linkPreviewVideoUrl: row.linkPreviewVideoUrl,
linkPreviewMedia: parseLinkPreviewMediaJson(row.linkPreviewMediaJson) || null,
},
} as any;
}
async function getMixedFeedCursorDate(cursor: string | null) {
if (!cursor) {
return null;
}
if (cursor.startsWith('swarm-repost:')) {
const repostRow = await db.query.userSwarmReposts.findFirst({
where: eq(userSwarmReposts.id, cursor.replace('swarm-repost:', '')),
});
return repostRow?.repostedAt ?? null;
}
const cursorPost = await db.query.posts.findFirst({
where: eq(posts.id, cursor),
});
return cursorPost?.createdAt ?? null;
}
function collectNestedPosts(posts: FeedPostWithChildren[]): FeedPostWithChildren[] {
const collected: FeedPostWithChildren[] = [];
const seen = new Set<string>();
const visit = (post: FeedPostWithChildren | null | undefined) => {
if (!post || seen.has(post.id)) return;
seen.add(post.id);
collected.push(post);
visit(post.repostOf);
visit(post.replyTo);
};
posts.forEach(visit);
return collected;
}
function applyInteractionFlags(
posts: FeedPostWithChildren[],
likedIds: Set<string>,
repostedIds: Set<string>
): FeedPostWithChildren[] {
return posts.map((post) => ({
...post,
isLiked: likedIds.has(post.id),
isReposted: repostedIds.has(post.id),
repostOf: post.repostOf ? applyInteractionFlags([post.repostOf], likedIds, repostedIds)[0] : post.repostOf,
replyTo: post.replyTo ? applyInteractionFlags([post.replyTo], likedIds, repostedIds)[0] : post.replyTo,
}));
}
const embeddedPostRelations = {
author: true,
bot: true,
media: true,
replyTo: {
with: {
author: true,
bot: true,
media: true,
},
},
} as const;
const feedPostRelations = {
...embeddedPostRelations,
repostOf: {
with: embeddedPostRelations,
},
} as const;
const createPostSchema = z.object({
content: z.string().min(1).max(POST_MAX_LENGTH),
replyToId: z.string().uuid().optional(), // Must be UUID (swarm replies use separate field)
@@ -38,21 +172,40 @@ const createPostSchema = z.object({
title: z.string().optional(),
description: z.string().optional(),
image: z.string().url().optional().nullable(),
type: z.enum(['card', 'image', 'gallery', 'video']).optional().nullable(),
videoUrl: z.string().url().optional().nullable(),
media: z.array(z.object({
url: z.string().url(),
width: z.number().optional().nullable(),
height: z.number().optional().nullable(),
mimeType: z.string().optional().nullable(),
})).optional().nullable(),
}).optional().nullable(),
});
function isSignedActionPayload(payload: unknown): payload is SignedAction {
if (!payload || typeof payload !== 'object') return false;
const value = payload as Record<string, unknown>;
return typeof value.action === 'string'
&& typeof value.did === 'string'
&& typeof value.handle === 'string'
&& typeof value.ts === 'number'
&& typeof value.nonce === 'string'
&& typeof value.sig === 'string'
&& typeof value.data === 'object'
&& value.data !== null;
}
// Create a new post
export async function POST(request: Request) {
try {
// Parse the signed action from the request body
const signedAction: SignedAction = await request.json();
// Strictly verify the signature and get the user
// This replaces requireAuth() - the signature proves identity AND intent
const user = await requireSignedAction(signedAction);
// Extract post data from the signed action
const data = createPostSchema.parse(signedAction.data);
const requestBody = await request.json();
const user = isSignedActionPayload(requestBody)
? await requireSignedAction(requestBody)
: await requireAuth();
const data = createPostSchema.parse(
isSignedActionPayload(requestBody) ? requestBody.data : requestBody
);
if (user.isSuspended || user.isSilenced) {
return NextResponse.json({ error: 'Account restricted' }, { status: 403 });
@@ -84,6 +237,9 @@ export async function POST(request: Request) {
linkPreviewTitle: data.linkPreview?.title,
linkPreviewDescription: data.linkPreview?.description,
linkPreviewImage: data.linkPreview?.image,
linkPreviewType: data.linkPreview?.type,
linkPreviewVideoUrl: data.linkPreview?.videoUrl,
linkPreviewMediaJson: serializeLinkPreviewMedia(data.linkPreview?.media),
}).returning();
let unattachedMedia: typeof media.$inferSelect[] = [];
@@ -181,12 +337,56 @@ export async function POST(request: Request) {
});
}
if (data.replyToId) {
try {
const parentPost = await db.query.posts.findFirst({
where: eq(posts.id, data.replyToId),
with: {
author: true,
},
});
if (parentPost && parentPost.userId !== user.id) {
const parentAuthor = parentPost.author as typeof users.$inferSelect | undefined;
await db.insert(notifications).values({
userId: parentPost.userId,
actorId: user.id,
actorHandle: user.handle,
actorDisplayName: user.displayName,
actorAvatarUrl: user.avatarUrl,
actorNodeDomain: null,
postId: parentPost.id,
postContent: post.content?.slice(0, 200) || null,
...(parentAuthor?.isBot ? buildNotificationTarget(parentAuthor) : {}),
type: 'reply',
});
if (parentAuthor?.isBot && parentAuthor.botOwnerId) {
await db.insert(notifications).values({
userId: parentAuthor.botOwnerId,
actorId: user.id,
actorHandle: user.handle,
actorDisplayName: user.displayName,
actorAvatarUrl: user.avatarUrl,
actorNodeDomain: null,
postId: parentPost.id,
postContent: post.content?.slice(0, 200) || null,
...buildNotificationTarget(parentAuthor),
type: 'reply',
});
}
}
} catch (err) {
console.error('[Posts] Error creating reply notifications:', err);
console.error('[Posts] Context:', { postId: post.id, replyToId: data.replyToId, userId: user.id });
}
}
// Handle local mentions (create notifications for users on this node)
(async () => {
try {
const { extractMentions } = await import('@/lib/swarm/interactions');
const { notifications } = await import('@/db');
const mentions = extractMentions(data.content);
for (const mention of mentions) {
@@ -209,6 +409,7 @@ export async function POST(request: Request) {
actorNodeDomain: null, // Local user
postId: post.id,
postContent: post.content?.slice(0, 200) || null,
...(mentionedUser.isBot ? buildNotificationTarget(mentionedUser) : {}),
type: 'mention',
});
@@ -223,6 +424,7 @@ export async function POST(request: Request) {
actorNodeDomain: null,
postId: post.id,
postContent: post.content?.slice(0, 200) || null,
...buildNotificationTarget(mentionedUser),
type: 'mention',
});
}
@@ -374,6 +576,9 @@ const transformRemotePosts = (remotePostsData: typeof remotePosts.$inferSelect[]
linkPreviewTitle: rp.linkPreviewTitle,
linkPreviewDescription: rp.linkPreviewDescription,
linkPreviewImage: rp.linkPreviewImage,
linkPreviewType: rp.linkPreviewType,
linkPreviewVideoUrl: rp.linkPreviewVideoUrl,
linkPreviewMedia: parseLinkPreviewMediaJson(rp.linkPreviewMediaJson) || null,
author: {
id: rp.authorActorUrl,
handle: rp.authorHandle,
@@ -437,14 +642,7 @@ export async function GET(request: Request) {
feedPosts = await db.query.posts.findMany({
where: whereCondition,
with: {
author: true,
bot: true,
media: true,
replyTo: {
with: { author: true, media: true },
},
},
with: feedPostRelations,
orderBy: [desc(posts.createdAt)],
limit,
});
@@ -452,14 +650,7 @@ export async function GET(request: Request) {
// Public timeline - all local posts + all cached remote posts
const localPosts = await db.query.posts.findMany({
where: baseFilter,
with: {
author: true,
bot: true,
media: true,
replyTo: {
with: { author: true, media: true },
},
},
with: feedPostRelations,
orderBy: [desc(posts.createdAt)],
limit: limit * 2,
});
@@ -492,14 +683,7 @@ export async function GET(request: Request) {
feedPosts = await db.query.posts.findMany({
where: whereCondition,
with: {
author: true,
bot: true,
media: true,
replyTo: {
with: { author: true, media: true },
},
},
with: feedPostRelations,
orderBy: [desc(posts.createdAt)],
limit,
});
@@ -519,14 +703,7 @@ export async function GET(request: Request) {
feedPosts = await db.query.posts.findMany({
where: whereCondition,
with: {
author: true,
bot: true,
media: true,
replyTo: {
with: { author: true, media: true },
},
},
with: feedPostRelations,
orderBy: [desc(posts.createdAt)],
limit,
});
@@ -554,7 +731,7 @@ export async function GET(request: Request) {
includeNsfw,
});
const swarmPosts = swarmResult.posts.map(sp => ({
const mapSwarmPostToFeedPost = (sp: (typeof swarmResult.posts)[number]): any => ({
id: `swarm:${sp.nodeDomain}:${sp.id}`,
originalPostId: sp.id, // Keep the original ID for replies
content: sp.content,
@@ -564,6 +741,8 @@ export async function GET(request: Request) {
repliesCount: sp.replyCount,
isSwarm: true,
nodeDomain: sp.nodeDomain,
repostOfId: sp.repostOfId ? `swarm:${sp.nodeDomain}:${sp.repostOfId}` : null,
repostOf: sp.repostOf ? mapSwarmPostToFeedPost(sp.repostOf) : null,
author: {
id: `swarm:${sp.nodeDomain}:${sp.author.handle}`,
handle: sp.author.handle,
@@ -583,8 +762,13 @@ export async function GET(request: Request) {
linkPreviewTitle: sp.linkPreviewTitle || null,
linkPreviewDescription: sp.linkPreviewDescription || null,
linkPreviewImage: sp.linkPreviewImage || null,
linkPreviewType: sp.linkPreviewType || null,
linkPreviewVideoUrl: sp.linkPreviewVideoUrl || null,
linkPreviewMedia: sp.linkPreviewMedia || null,
replyTo: null,
}));
});
const swarmPosts = swarmResult.posts.map(mapSwarmPostToFeedPost);
let mutedIds = new Set<string>();
let blockedIds = new Set<string>();
@@ -674,31 +858,48 @@ export async function GET(request: Request) {
// Build where condition with cursor support
let whereCondition = buildWhere(baseFilter, inArray(posts.userId, allowedUserIds));
const cursorDate = await getMixedFeedCursorDate(cursor);
if (cursor) {
const cursorPost = await db.query.posts.findFirst({
where: eq(posts.id, cursor),
});
if (cursorPost) {
whereCondition = buildWhere(baseFilter, inArray(posts.userId, allowedUserIds), lt(posts.createdAt, cursorPost.createdAt));
}
if (cursorDate) {
whereCondition = buildWhere(baseFilter, inArray(posts.userId, allowedUserIds), lt(posts.createdAt, cursorDate));
}
// Get local posts from people the user follows + their own posts
const localPosts = await db.query.posts.findMany({
where: whereCondition,
with: {
author: true,
bot: true,
media: true,
replyTo: {
with: { author: true, media: true },
},
},
with: feedPostRelations,
orderBy: [desc(posts.createdAt)],
limit: cursor ? limit : limit * 2, // Get more on first load to account for mixing with remote
});
const swarmRepostWhere = cursorDate
? and(
inArray(userSwarmReposts.userId, allowedUserIds),
lt(userSwarmReposts.repostedAt, cursorDate)
)
: inArray(userSwarmReposts.userId, allowedUserIds);
const swarmRepostRows = await db.query.userSwarmReposts.findMany({
where: swarmRepostWhere,
orderBy: [desc(userSwarmReposts.repostedAt)],
limit: cursor ? limit : limit * 2,
});
const swarmRepostAuthors = swarmRepostRows.length > 0
? await db.query.users.findMany({
where: inArray(users.id, Array.from(new Set(swarmRepostRows.map((row) => row.userId)))),
})
: [];
const swarmRepostAuthorMap = new Map(swarmRepostAuthors.map((author) => [author.id, author]));
const localRepostEvents = swarmRepostRows
.map((row) => {
const author = swarmRepostAuthorMap.get(row.userId);
if (!author) {
return null;
}
return mapUserSwarmRepostToFeedPost(row, author);
})
.filter(Boolean);
// Get handles of remote users we follow
const followedRemoteUsers = await db.query.remoteFollows.findMany({
where: eq(remoteFollows.followerId, user.id),
@@ -708,6 +909,7 @@ export async function GET(request: Request) {
let liveRemotePosts: any[] = [];
if (followedRemoteUsers.length > 0) {
const { fetchSwarmUserProfile, isSwarmNode } = await import('@/lib/swarm/interactions');
const { mapRemoteProfilePost } = await import('@/lib/swarm/remote-profile-posts');
// Wrap each fetch with a timeout to prevent slow nodes from blocking
const withTimeout = <T>(promise: Promise<T>, ms: number): Promise<T | null> => {
@@ -737,34 +939,15 @@ export async function GET(request: Request) {
return profileData.posts
.filter((post: any) => !post.replyToId && !post.swarmReplyToId && !post.isReply)
.map(post => ({
id: `swarm:${domain}:${post.id}`,
content: post.content,
createdAt: new Date(post.createdAt),
likesCount: post.likesCount || 0,
repostsCount: post.repostsCount || 0,
repliesCount: post.repliesCount || 0,
isRemote: true,
isNsfw: post.isNsfw,
linkPreviewUrl: post.linkPreviewUrl,
linkPreviewTitle: post.linkPreviewTitle,
linkPreviewDescription: post.linkPreviewDescription,
linkPreviewImage: post.linkPreviewImage,
author: {
id: `swarm:${domain}:${handle}`,
handle: follow.targetHandle,
displayName: follow.displayName || profileData.profile?.displayName || handle,
avatarUrl: follow.avatarUrl || profileData.profile?.avatarUrl,
isRemote: true,
isBot: profileData.profile?.isBot,
},
media: post.media?.map((m: any, idx: number) => ({
id: `swarm:${domain}:${post.id}:media:${idx}`,
url: m.url,
altText: m.altText || null,
})) || [],
replyTo: null,
}));
.map((post: any) => mapRemoteProfilePost({
...post,
author: post.author || {
handle,
displayName: follow.displayName || profileData.profile?.displayName || handle,
avatarUrl: follow.avatarUrl || profileData.profile?.avatarUrl,
isBot: profileData.profile?.isBot,
},
}, domain));
} catch (error) {
console.error(`[Home] Error fetching posts from ${follow.targetHandle}:`, error);
return [];
@@ -776,7 +959,7 @@ export async function GET(request: Request) {
}
// Merge and sort by date
const allPosts = [...localPosts, ...liveRemotePosts]
const allPosts = [...localPosts, ...localRepostEvents, ...liveRemotePosts]
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
.slice(0, limit);
@@ -785,14 +968,7 @@ export async function GET(request: Request) {
// Not authenticated, return public timeline
feedPosts = await db.query.posts.findMany({
where: baseFilter,
with: {
author: true,
bot: true,
media: true,
replyTo: {
with: { author: true, media: true },
},
},
with: feedPostRelations,
orderBy: [desc(posts.createdAt)],
limit,
});
@@ -807,12 +983,13 @@ export async function GET(request: Request) {
if (session?.user && feedPosts && feedPosts.length > 0) {
const viewer = session.user;
const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:3000';
const allFeedPosts = collectNestedPosts(feedPosts as FeedPostWithChildren[]);
// Separate local and swarm posts
const localPostIds: string[] = [];
const swarmPosts: Array<{ id: string; domain: string; originalId: string }> = [];
for (const p of feedPosts as Array<{ id: string }>) {
for (const p of allFeedPosts) {
if (p.id.startsWith('swarm:')) {
const parts = p.id.split(':');
if (parts.length >= 3) {
@@ -852,6 +1029,8 @@ export async function GET(request: Request) {
// Check swarm likes in real-time (query origin nodes)
if (swarmPosts.length > 0) {
const { getViewerSwarmRepostedPostIds } = await import('@/lib/swarm/reposts');
const checkPromises = swarmPosts.map(async (sp) => {
try {
const protocol = sp.domain.includes('localhost') ? 'http' : 'https';
@@ -874,13 +1053,23 @@ export async function GET(request: Request) {
});
await Promise.all(checkPromises);
const swarmRepostedIds = await getViewerSwarmRepostedPostIds(
swarmPosts.map((sp) => ({
id: sp.id,
nodeDomain: sp.domain,
originalPostId: sp.originalId,
})),
viewer.id
);
swarmRepostedIds.forEach((id) => repostedPostIds.add(id));
}
feedPosts = feedPosts.map((p: { id: string }) => ({
...p,
isLiked: likedPostIds.has(p.id),
isReposted: repostedPostIds.has(p.id),
})) as any;
feedPosts = applyInteractionFlags(
feedPosts as FeedPostWithChildren[],
likedPostIds,
repostedPostIds
) as any;
}
} catch (error) {
console.error('Error populating interaction flags:', error);
+58 -5
View File
@@ -8,6 +8,51 @@ import { NextRequest, NextResponse } from 'next/server';
import { fetchSwarmTimeline } from '@/lib/swarm/timeline';
import { getSession } from '@/lib/auth';
import { getViewerSwarmLikedPostIds } from '@/lib/swarm/likes';
import { getViewerSwarmRepostedPostIds } from '@/lib/swarm/reposts';
type SwarmFeedPost = {
id: string;
nodeDomain: string;
repostOf?: SwarmFeedPost | null;
replyTo?: SwarmFeedPost | null;
isLiked?: boolean;
isReposted?: boolean;
};
function collectNestedSwarmPosts(posts: SwarmFeedPost[]): SwarmFeedPost[] {
const collected: SwarmFeedPost[] = [];
const seen = new Set<string>();
const visit = (post: SwarmFeedPost | null | undefined) => {
if (!post) return;
const key = `${post.nodeDomain}:${post.id}`;
if (seen.has(key)) return;
seen.add(key);
collected.push(post);
visit(post.repostOf);
visit(post.replyTo);
};
posts.forEach(visit);
return collected;
}
function applyInteractionFlags(
posts: SwarmFeedPost[],
likedIds: Set<string>,
repostedIds: Set<string>
): SwarmFeedPost[] {
return posts.map((post) => {
const normalizedId = `swarm:${post.nodeDomain}:${post.id}`;
return {
...post,
isLiked: likedIds.has(normalizedId),
isReposted: repostedIds.has(normalizedId),
repostOf: post.repostOf ? applyInteractionFlags([post.repostOf], likedIds, repostedIds)[0] : post.repostOf,
replyTo: post.replyTo ? applyInteractionFlags([post.replyTo], likedIds, repostedIds)[0] : post.replyTo,
};
});
}
/**
* GET /api/posts/swarm
@@ -35,9 +80,10 @@ export async function GET(request: NextRequest) {
const session = await getSession().catch(() => null);
const viewer = session?.user;
const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:3000';
const allTimelinePosts = collectNestedSwarmPosts(timeline.posts as SwarmFeedPost[]);
const likedPostIds = viewer
? await getViewerSwarmLikedPostIds(
timeline.posts.map(post => ({
allTimelinePosts.map(post => ({
id: `swarm:${post.nodeDomain}:${post.id}`,
nodeDomain: post.nodeDomain,
originalPostId: post.id,
@@ -46,12 +92,19 @@ export async function GET(request: NextRequest) {
nodeDomain
)
: new Set<string>();
const repostedPostIds = viewer
? await getViewerSwarmRepostedPostIds(
allTimelinePosts.map(post => ({
id: `swarm:${post.nodeDomain}:${post.id}`,
nodeDomain: post.nodeDomain,
originalPostId: post.id,
})),
viewer.id
)
: new Set<string>();
return NextResponse.json({
posts: timeline.posts.map(post => ({
...post,
isLiked: likedPostIds.has(`swarm:${post.nodeDomain}:${post.id}`),
})),
posts: applyInteractionFlags(timeline.posts as SwarmFeedPost[], likedPostIds, repostedPostIds),
sources: timeline.sources,
cached: false,
fetchedAt: timeline.fetchedAt,
+21 -5
View File
@@ -4,6 +4,26 @@ import { ilike, or, desc, and, notInArray, eq, inArray } from 'drizzle-orm';
import { fetchSwarmUserProfile, isSwarmNode } from '@/lib/swarm/interactions';
import { discoverNode } from '@/lib/swarm/discovery';
const embeddedPostRelations = {
author: true,
bot: true,
media: true,
replyTo: {
with: {
author: true,
bot: true,
media: true,
},
},
} as const;
const searchPostRelations = {
...embeddedPostRelations,
repostOf: {
with: embeddedPostRelations,
},
} as const;
type SearchUser = {
id: string;
handle: string;
@@ -176,11 +196,7 @@ export async function GET(request: Request) {
}
const postResults = await db.query.posts.findMany({
where: and(...postConditions),
with: {
author: true,
media: true,
bot: true,
},
with: searchPostRelations,
orderBy: [desc(posts.createdAt)],
limit,
});
@@ -8,6 +8,7 @@ import { NextRequest, NextResponse } from 'next/server';
import { db, chatConversations, chatMessages, users } from '@/db';
import { eq, and } from 'drizzle-orm';
import { getSession } from '@/lib/auth';
import { isNodeBlocked, normalizeNodeDomain } from '@/lib/swarm/node-blocklist';
import { z } from 'zod';
// Schema for conversation ID parameter
@@ -73,10 +74,14 @@ export async function DELETE(
if (isRemote) {
// Extract domain from handle (format: handle@domain)
const domain = participant2Handle.split('@')[1];
const domain = normalizeNodeDomain(participant2Handle.split('@')[1]);
const handle = participant2Handle.split('@')[0];
try {
if (await isNodeBlocked(domain)) {
return NextResponse.json({ success: true });
}
const protocol = domain.includes('localhost') ? 'http' : 'https';
const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost';
+8 -2
View File
@@ -12,6 +12,7 @@ import { db, users, chatConversations } from '@/db';
import { eq, and } from 'drizzle-orm';
import { z } from 'zod';
import { verifyUserInteraction } from '@/lib/swarm/signature';
import { isNodeBlocked, normalizeNodeDomain } from '@/lib/swarm/node-blocklist';
const deletionSchema = z.object({
senderHandle: z.string(),
@@ -30,6 +31,11 @@ export async function POST(request: NextRequest) {
const body = await request.json();
const data = deletionSchema.parse(body);
const senderNodeDomain = normalizeNodeDomain(data.senderNodeDomain);
if (await isNodeBlocked(senderNodeDomain)) {
return NextResponse.json({ error: 'Blocked node' }, { status: 403 });
}
// SECURITY: Verify the signature
const { signature, ...payload } = data;
@@ -37,7 +43,7 @@ export async function POST(request: NextRequest) {
payload,
signature,
data.senderHandle,
data.senderNodeDomain
senderNodeDomain
);
if (!isValid) {
@@ -55,7 +61,7 @@ export async function POST(request: NextRequest) {
}
// Find the conversation with the sender
const senderFullHandle = `${data.senderHandle}@${data.senderNodeDomain}`;
const senderFullHandle = `${data.senderHandle}@${senderNodeDomain}`;
const conversation = await db.query.chatConversations.findFirst({
where: and(
eq(chatConversations.participant1Id, recipient.id),
+8
View File
@@ -29,6 +29,14 @@ const swarmPostSchema = z.object({
linkPreviewTitle: z.string().optional(),
linkPreviewDescription: z.string().optional(),
linkPreviewImage: z.string().optional(),
linkPreviewType: z.enum(['card', 'image', 'gallery', 'video']).optional(),
linkPreviewVideoUrl: z.string().optional(),
linkPreviewMedia: z.array(z.object({
url: z.string(),
width: z.number().nullable().optional(),
height: z.number().nullable().optional(),
mimeType: z.string().nullable().optional(),
})).optional(),
}),
author: z.object({
handle: z.string(),
@@ -15,6 +15,7 @@ import { eq, and, sql } from 'drizzle-orm';
import { z } from 'zod';
import { verifySwarmRequest } from '@/lib/swarm/signature';
import { localHandleSchema, nodeDomainSchema } from '@/lib/utils/federation';
import { buildNotificationTarget } from '@/lib/notifications';
const swarmFollowSchema = z.object({
targetHandle: localHandleSchema,
@@ -107,6 +108,7 @@ export async function POST(request: NextRequest) {
actorDisplayName: data.follow.followerDisplayName,
actorAvatarUrl: data.follow.followerAvatarUrl || null,
actorNodeDomain: data.follow.followerNodeDomain,
...(targetUser.isBot ? buildNotificationTarget(targetUser) : {}),
type: 'follow',
});
console.log(`[Swarm] Created follow notification for @${data.targetHandle} from ${data.follow.followerHandle}@${data.follow.followerNodeDomain}`);
@@ -125,6 +127,7 @@ export async function POST(request: NextRequest) {
actorDisplayName: data.follow.followerDisplayName,
actorAvatarUrl: data.follow.followerAvatarUrl || null,
actorNodeDomain: data.follow.followerNodeDomain,
...buildNotificationTarget(targetUser),
type: 'follow',
});
} catch (err) {
+5 -1
View File
@@ -12,6 +12,7 @@ import { eq, and } from 'drizzle-orm';
import { z } from 'zod';
import { verifySwarmRequest } from '@/lib/swarm/signature';
import { localHandleSchema, nodeDomainSchema } from '@/lib/utils/federation';
import { buildNotificationTarget } from '@/lib/notifications';
const swarmLikeSchema = z.object({
postId: z.string().uuid(),
@@ -88,6 +89,8 @@ export async function POST(request: NextRequest) {
.set({ likesCount: post.likesCount + 1 })
.where(eq(posts.id, data.postId));
const author = post.author as { isBot?: boolean; botOwnerId?: string; handle?: string; displayName?: string | null; avatarUrl?: string | null } | null;
// Create notification with actor info stored directly
try {
await db.insert(notifications).values({
@@ -98,6 +101,7 @@ export async function POST(request: NextRequest) {
actorNodeDomain: data.like.actorNodeDomain,
postId: data.postId,
postContent: post.content?.slice(0, 200) || null,
...(author?.isBot ? buildNotificationTarget(author as any) : {}),
type: 'like',
});
console.log(`[Swarm] Created like notification for post ${data.postId} from ${data.like.actorHandle}@${data.like.actorNodeDomain}`);
@@ -108,7 +112,6 @@ export async function POST(request: NextRequest) {
}
// Also notify bot owner if this is a bot's post
const author = post.author as { isBot?: boolean; botOwnerId?: string } | null;
if (author?.isBot && author.botOwnerId) {
try {
await db.insert(notifications).values({
@@ -119,6 +122,7 @@ export async function POST(request: NextRequest) {
actorNodeDomain: data.like.actorNodeDomain,
postId: data.postId,
postContent: post.content?.slice(0, 200) || null,
...buildNotificationTarget(author as any),
type: 'like',
});
} catch (err) {
@@ -12,6 +12,7 @@ import { eq } from 'drizzle-orm';
import { z } from 'zod';
import { verifySwarmRequest } from '@/lib/swarm/signature';
import { localHandleSchema, nodeDomainSchema } from '@/lib/utils/federation';
import { buildNotificationTarget } from '@/lib/notifications';
const swarmMentionSchema = z.object({
mentionedHandle: localHandleSchema,
@@ -73,6 +74,7 @@ export async function POST(request: NextRequest) {
actorAvatarUrl: data.mention.actorAvatarUrl || null,
actorNodeDomain: data.mention.actorNodeDomain,
postContent: data.mention.postContent.slice(0, 200),
...(mentionedUser.isBot ? buildNotificationTarget(mentionedUser) : {}),
type: 'mention',
});
console.log(`[Swarm] Created mention notification for @${data.mentionedHandle} from ${data.mention.actorHandle}@${data.mention.actorNodeDomain}`);
@@ -90,6 +92,7 @@ export async function POST(request: NextRequest) {
actorAvatarUrl: data.mention.actorAvatarUrl || null,
actorNodeDomain: data.mention.actorNodeDomain,
postContent: data.mention.postContent.slice(0, 200),
...buildNotificationTarget(mentionedUser),
type: 'mention',
});
} catch (err) {
+28 -3
View File
@@ -7,11 +7,12 @@
*/
import { NextRequest, NextResponse } from 'next/server';
import { db, posts, users, notifications } from '@/db';
import { eq, sql } from 'drizzle-orm';
import { db, posts, users, notifications, remoteReposts } from '@/db';
import { eq, sql, and } from 'drizzle-orm';
import { z } from 'zod';
import { verifySwarmRequest } from '@/lib/swarm/signature';
import { localHandleSchema, nodeDomainSchema } from '@/lib/utils/federation';
import { buildNotificationTarget } from '@/lib/notifications';
const swarmRepostSchema = z.object({
postId: z.string().uuid(),
@@ -64,11 +65,34 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: 'Post not found' }, { status: 404 });
}
const existingRepost = await db.query.remoteReposts.findFirst({
where: and(
eq(remoteReposts.postId, data.postId),
eq(remoteReposts.actorHandle, data.repost.actorHandle),
eq(remoteReposts.actorNodeDomain, data.repost.actorNodeDomain),
),
});
if (existingRepost) {
return NextResponse.json({
success: true,
message: 'Repost already recorded',
});
}
// Increment repost count
await db.update(posts)
.set({ repostsCount: sql`${posts.repostsCount} + 1` })
.where(eq(posts.id, data.postId));
await db.insert(remoteReposts).values({
postId: data.postId,
actorHandle: data.repost.actorHandle,
actorNodeDomain: data.repost.actorNodeDomain,
});
const author = post.author as { isBot?: boolean; botOwnerId?: string; handle?: string; displayName?: string | null; avatarUrl?: string | null } | null;
// Create notification with actor info stored directly
try {
await db.insert(notifications).values({
@@ -79,6 +103,7 @@ export async function POST(request: NextRequest) {
actorNodeDomain: data.repost.actorNodeDomain,
postId: data.postId,
postContent: post.content?.slice(0, 200) || null,
...(author?.isBot ? buildNotificationTarget(author as any) : {}),
type: 'repost',
});
console.log(`[Swarm] Created repost notification for post ${data.postId} from ${data.repost.actorHandle}@${data.repost.actorNodeDomain}`);
@@ -87,7 +112,6 @@ export async function POST(request: NextRequest) {
}
// Also notify bot owner if this is a bot's post
const author = post.author as { isBot?: boolean; botOwnerId?: string } | null;
if (author?.isBot && author.botOwnerId) {
try {
await db.insert(notifications).values({
@@ -98,6 +122,7 @@ export async function POST(request: NextRequest) {
actorNodeDomain: data.repost.actorNodeDomain,
postId: data.postId,
postContent: post.content?.slice(0, 200) || null,
...buildNotificationTarget(author as any),
type: 'repost',
});
} catch (err) {
@@ -7,8 +7,8 @@
*/
import { NextRequest, NextResponse } from 'next/server';
import { db, posts } from '@/db';
import { eq, sql } from 'drizzle-orm';
import { db, posts, remoteReposts } from '@/db';
import { eq, sql, and } from 'drizzle-orm';
import { z } from 'zod';
import { verifySwarmRequest } from '@/lib/swarm/signature';
import { localHandleSchema, nodeDomainSchema } from '@/lib/utils/federation';
@@ -60,11 +60,28 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: 'Post not found' }, { status: 404 });
}
const existingRepost = await db.query.remoteReposts.findFirst({
where: and(
eq(remoteReposts.postId, data.postId),
eq(remoteReposts.actorHandle, data.unrepost.actorHandle),
eq(remoteReposts.actorNodeDomain, data.unrepost.actorNodeDomain),
),
});
if (!existingRepost) {
return NextResponse.json({
success: true,
message: 'Repost already removed',
});
}
// Decrement repost count
await db.update(posts)
.set({ repostsCount: sql`GREATEST(0, ${posts.repostsCount} - 1)` })
.where(eq(posts.id, data.postId));
await db.delete(remoteReposts).where(eq(remoteReposts.id, existingRepost.id));
console.log(`[Swarm] Received unrepost from ${data.unrepost.actorHandle}@${data.unrepost.actorNodeDomain} on post ${data.postId}`);
return NextResponse.json({
+68 -2
View File
@@ -5,9 +5,10 @@
*/
import { NextRequest, NextResponse } from 'next/server';
import { db, posts } from '@/db';
import { db, posts, userSwarmReposts, users } from '@/db';
import { eq, desc, and } from 'drizzle-orm';
import { z } from 'zod';
import { parseLinkPreviewMediaJson } from '@/lib/media/linkPreview';
type RouteContext = { params: Promise<{ id: string }> };
@@ -43,7 +44,62 @@ export async function GET(request: NextRequest, context: RouteContext) {
});
if (!post || post.isRemoved) {
return NextResponse.json({ error: 'Post not found' }, { status: 404 });
const remoteRepost = await db.query.userSwarmReposts.findFirst({
where: eq(userSwarmReposts.id, postId),
});
if (!remoteRepost) {
return NextResponse.json({ error: 'Post not found' }, { status: 404 });
}
const repostAuthor = await db.query.users.findFirst({
where: eq(users.id, remoteRepost.userId),
});
return NextResponse.json({
post: {
id: remoteRepost.id,
apId: null,
content: '',
createdAt: remoteRepost.repostedAt.toISOString(),
likesCount: 0,
repostsCount: 0,
repliesCount: 0,
author: repostAuthor ? {
handle: repostAuthor.handle,
displayName: repostAuthor.displayName,
avatarUrl: repostAuthor.avatarUrl,
} : null,
media: [],
repostOfId: remoteRepost.originalPostId,
repostOf: {
id: remoteRepost.originalPostId,
originalPostId: remoteRepost.originalPostId,
content: remoteRepost.content,
createdAt: remoteRepost.postCreatedAt.toISOString(),
likesCount: remoteRepost.likesCount,
repostsCount: remoteRepost.repostsCount,
repliesCount: remoteRepost.repliesCount,
nodeDomain: remoteRepost.nodeDomain,
author: {
handle: remoteRepost.authorHandle.includes('@')
? remoteRepost.authorHandle
: `${remoteRepost.authorHandle}@${remoteRepost.nodeDomain}`,
displayName: remoteRepost.authorDisplayName,
avatarUrl: remoteRepost.authorAvatarUrl,
},
media: remoteRepost.mediaJson ? JSON.parse(remoteRepost.mediaJson) : [],
linkPreviewUrl: remoteRepost.linkPreviewUrl,
linkPreviewTitle: remoteRepost.linkPreviewTitle,
linkPreviewDescription: remoteRepost.linkPreviewDescription,
linkPreviewImage: remoteRepost.linkPreviewImage,
linkPreviewType: remoteRepost.linkPreviewType,
linkPreviewVideoUrl: remoteRepost.linkPreviewVideoUrl,
linkPreviewMedia: parseLinkPreviewMediaJson(remoteRepost.linkPreviewMediaJson) || [],
},
},
replies: [],
});
}
// Get replies
@@ -84,6 +140,9 @@ export async function GET(request: NextRequest, context: RouteContext) {
linkPreviewTitle: post.linkPreviewTitle,
linkPreviewDescription: post.linkPreviewDescription,
linkPreviewImage: post.linkPreviewImage,
linkPreviewType: post.linkPreviewType,
linkPreviewVideoUrl: post.linkPreviewVideoUrl,
linkPreviewMedia: parseLinkPreviewMediaJson(post.linkPreviewMediaJson) || [],
},
replies: replies.map(r => {
const replyAuthor = r.author as any;
@@ -103,6 +162,13 @@ export async function GET(request: NextRequest, context: RouteContext) {
url: m.url,
altText: m.altText,
})) || [],
linkPreviewUrl: r.linkPreviewUrl,
linkPreviewTitle: r.linkPreviewTitle,
linkPreviewDescription: r.linkPreviewDescription,
linkPreviewImage: r.linkPreviewImage,
linkPreviewType: r.linkPreviewType,
linkPreviewVideoUrl: r.linkPreviewVideoUrl,
linkPreviewMedia: parseLinkPreviewMediaJson(r.linkPreviewMediaJson) || [],
};
}),
});
+18
View File
@@ -11,6 +11,7 @@ import { eq, desc, and, sql } from 'drizzle-orm';
import { z } from 'zod';
import { verifySwarmRequest } from '@/lib/swarm/signature';
import { upsertRemoteUser } from '@/lib/swarm/user-cache';
import { buildNotificationTarget } from '@/lib/notifications';
// Schema for incoming swarm reply
const swarmReplySchema = z.object({
@@ -145,6 +146,8 @@ export async function POST(request: NextRequest) {
await syncParentReplyCount(data.postId);
const parentAuthor = parentPost.author as { isBot?: boolean; botOwnerId?: string; handle?: string; displayName?: string | null; avatarUrl?: string | null } | null;
if (parentPost.userId !== remoteUser.id) {
await db.insert(notifications).values({
userId: parentPost.userId,
@@ -154,8 +157,23 @@ export async function POST(request: NextRequest) {
actorNodeDomain: sourceDomain,
postId: data.postId,
postContent: data.reply.content.slice(0, 200),
...(parentAuthor?.isBot ? buildNotificationTarget(parentAuthor as any) : {}),
type: 'reply',
});
if (parentAuthor?.isBot && parentAuthor.botOwnerId) {
await db.insert(notifications).values({
userId: parentAuthor.botOwnerId,
actorHandle: data.reply.author.handle,
actorDisplayName: data.reply.author.displayName || data.reply.author.handle,
actorAvatarUrl: data.reply.author.avatarUrl || null,
actorNodeDomain: sourceDomain,
postId: data.postId,
postContent: data.reply.content.slice(0, 200),
...buildNotificationTarget(parentAuthor as any),
type: 'reply',
});
}
}
return NextResponse.json({ success: true, message: 'Reply received' });
+149 -37
View File
@@ -6,7 +6,8 @@
import { NextRequest, NextResponse } from 'next/server';
import { db, posts, users, media, nodes } from '@/db';
import { eq, desc, and, isNull, lt, sql } from 'drizzle-orm';
import { eq, desc, and, isNull, lt, sql, inArray } from 'drizzle-orm';
import { parseLinkPreviewMediaJson } from '@/lib/media/linkPreview';
export interface SwarmPost {
id: string;
@@ -15,6 +16,8 @@ export interface SwarmPost {
isReply?: boolean;
replyToId?: string | null;
swarmReplyToId?: string | null;
repostOfId?: string | null;
repostOf?: SwarmPost | null;
author: {
handle: string;
displayName: string;
@@ -34,6 +37,74 @@ export interface SwarmPost {
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 }>;
}
interface TimelinePostRow {
id: string;
content: string;
createdAt: Date;
replyToId: string | null;
swarmReplyToId: string | null;
repostOfId: string | null;
isNsfw: boolean;
likesCount: number;
repostsCount: number;
repliesCount: number;
linkPreviewUrl: string | null;
linkPreviewTitle: string | null;
linkPreviewDescription: string | null;
linkPreviewImage: string | null;
linkPreviewType: string | null;
linkPreviewVideoUrl: string | null;
linkPreviewMediaJson: string | null;
authorHandle: string;
authorDisplayName: string | null;
authorAvatarUrl: string | null;
authorIsNsfw: boolean;
authorIsBot: boolean | null;
}
function buildSwarmPost(
post: TimelinePostRow,
mediaByPostId: Map<string, Array<{ url: string; mimeType?: string; altText?: string }>>,
repostById: Map<string, SwarmPost>,
nodeDomain: string,
nodeIsNsfw: boolean
): SwarmPost {
return {
id: post.id,
content: post.content,
createdAt: post.createdAt.toISOString(),
isReply: Boolean(post.replyToId || post.swarmReplyToId),
replyToId: post.replyToId,
swarmReplyToId: post.swarmReplyToId,
repostOfId: post.repostOfId,
repostOf: post.repostOfId ? repostById.get(post.repostOfId) || null : null,
author: {
handle: post.authorHandle,
displayName: post.authorDisplayName || post.authorHandle,
avatarUrl: post.authorAvatarUrl || undefined,
isNsfw: post.authorIsNsfw,
isBot: post.authorIsBot || undefined,
},
nodeDomain,
nodeIsNsfw,
isNsfw: post.isNsfw || post.authorIsNsfw,
likeCount: post.likesCount,
repostCount: post.repostsCount,
replyCount: post.repliesCount,
media: mediaByPostId.get(post.id),
linkPreviewUrl: post.linkPreviewUrl || undefined,
linkPreviewTitle: post.linkPreviewTitle || undefined,
linkPreviewDescription: post.linkPreviewDescription || undefined,
linkPreviewImage: post.linkPreviewImage || undefined,
linkPreviewType: (post.linkPreviewType as SwarmPost['linkPreviewType']) || undefined,
linkPreviewVideoUrl: post.linkPreviewVideoUrl || undefined,
linkPreviewMedia: parseLinkPreviewMediaJson(post.linkPreviewMediaJson),
};
}
/**
@@ -89,6 +160,7 @@ export async function GET(request: NextRequest) {
createdAt: posts.createdAt,
replyToId: posts.replyToId,
swarmReplyToId: posts.swarmReplyToId,
repostOfId: posts.repostOfId,
isNsfw: posts.isNsfw,
likesCount: posts.likesCount,
repostsCount: posts.repostsCount,
@@ -97,6 +169,9 @@ export async function GET(request: NextRequest) {
linkPreviewTitle: posts.linkPreviewTitle,
linkPreviewDescription: posts.linkPreviewDescription,
linkPreviewImage: posts.linkPreviewImage,
linkPreviewType: posts.linkPreviewType,
linkPreviewVideoUrl: posts.linkPreviewVideoUrl,
linkPreviewMediaJson: posts.linkPreviewMediaJson,
authorHandle: users.handle,
authorDisplayName: users.displayName,
authorAvatarUrl: users.avatarUrl,
@@ -112,47 +187,84 @@ export async function GET(request: NextRequest) {
console.log(`[Swarm Timeline API] Found ${recentPosts.length} posts for ${nodeDomain}`);
// Fetch media for each post
const swarmPosts: SwarmPost[] = [];
const repostIds = Array.from(new Set(
recentPosts
.map(post => post.repostOfId)
.filter((id): id is string => Boolean(id))
));
for (const post of recentPosts) {
const postMedia = await db
.select({ url: media.url, mimeType: media.mimeType, altText: media.altText })
.from(media)
.where(eq(media.postId, post.id));
const repostTargets = repostIds.length > 0
? await db
.select({
id: posts.id,
content: posts.content,
createdAt: posts.createdAt,
replyToId: posts.replyToId,
swarmReplyToId: posts.swarmReplyToId,
repostOfId: posts.repostOfId,
isNsfw: posts.isNsfw,
likesCount: posts.likesCount,
repostsCount: posts.repostsCount,
repliesCount: posts.repliesCount,
linkPreviewUrl: posts.linkPreviewUrl,
linkPreviewTitle: posts.linkPreviewTitle,
linkPreviewDescription: posts.linkPreviewDescription,
linkPreviewImage: posts.linkPreviewImage,
linkPreviewType: posts.linkPreviewType,
linkPreviewVideoUrl: posts.linkPreviewVideoUrl,
linkPreviewMediaJson: posts.linkPreviewMediaJson,
authorHandle: users.handle,
authorDisplayName: users.displayName,
authorAvatarUrl: users.avatarUrl,
authorIsNsfw: users.isNsfw,
authorIsBot: users.isBot,
})
.from(posts)
.innerJoin(users, eq(posts.userId, users.id))
.where(and(
inArray(posts.id, repostIds),
eq(posts.isRemoved, false),
))
: [];
swarmPosts.push({
id: post.id,
content: post.content,
createdAt: post.createdAt.toISOString(),
isReply: Boolean(post.replyToId || post.swarmReplyToId),
replyToId: post.replyToId,
swarmReplyToId: post.swarmReplyToId,
author: {
handle: post.authorHandle,
displayName: post.authorDisplayName || post.authorHandle,
avatarUrl: post.authorAvatarUrl || undefined,
isNsfw: post.authorIsNsfw,
isBot: post.authorIsBot,
},
nodeDomain,
nodeIsNsfw,
isNsfw: post.isNsfw || post.authorIsNsfw, // Post-level NSFW (not node-level)
likeCount: post.likesCount,
repostCount: post.repostsCount,
replyCount: 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,
const mediaPostIds = Array.from(new Set([
...recentPosts.map(post => post.id),
...repostTargets.map(post => post.id),
]));
const mediaRows = mediaPostIds.length > 0
? await db
.select({
postId: media.postId,
url: media.url,
mimeType: media.mimeType,
altText: media.altText,
})
.from(media)
.where(inArray(media.postId, mediaPostIds))
: [];
const mediaByPostId = new Map<string, Array<{ url: string; mimeType?: string; altText?: string }>>();
for (const item of mediaRows) {
if (!item.postId) continue;
const bucket = mediaByPostId.get(item.postId) || [];
bucket.push({
url: item.url,
mimeType: item.mimeType || undefined,
altText: item.altText || undefined,
});
mediaByPostId.set(item.postId, bucket);
}
const repostById = new Map<string, SwarmPost>();
for (const post of repostTargets) {
repostById.set(post.id, buildSwarmPost(post, mediaByPostId, repostById, nodeDomain, nodeIsNsfw));
}
const swarmPosts = recentPosts.map(post =>
buildSwarmPost(post, mediaByPostId, repostById, nodeDomain, nodeIsNsfw)
);
return NextResponse.json({
posts: swarmPosts,
nodeDomain,
+147 -54
View File
@@ -5,8 +5,9 @@
*/
import { NextRequest, NextResponse } from 'next/server';
import { db, posts, users, media, nodes } from '@/db';
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;
@@ -28,21 +29,144 @@ export interface SwarmUserProfile {
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]
*
@@ -97,61 +221,30 @@ export async function GET(request: NextRequest, context: RouteContext) {
did: user.did || undefined,
};
// 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),
isNull(posts.replyToId),
isNull(posts.swarmReplyToId)
)
)
.orderBy(desc(posts.createdAt))
.limit(limit);
const localPosts = await db.query.posts.findMany({
where: and(
eq(posts.userId, user.id),
eq(posts.isRemoved, false),
isNull(posts.replyToId),
isNull(posts.swarmReplyToId)
),
with: profilePostRelations,
orderBy: [desc(posts.createdAt)],
limit: limit * 2,
});
// Fetch media for each post
const swarmPosts: SwarmUserPost[] = [];
const remoteRepostRows = await db.query.userSwarmReposts.findMany({
where: eq(userSwarmReposts.userId, user.id),
orderBy: [desc(userSwarmReposts.repostedAt)],
limit: limit * 2,
});
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,
});
}
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,
@@ -6,6 +6,7 @@ import { requireAuth } from '@/lib/auth';
import { requireSignedAction } from '@/lib/auth/verify-signature';
import { isSwarmNode, deliverSwarmFollow, deliverSwarmUnfollow, cacheSwarmUserPosts } from '@/lib/swarm/interactions';
import { discoverNode } from '@/lib/swarm/discovery';
import { buildNotificationTarget } from '@/lib/notifications';
type RouteContext = { params: Promise<{ handle: string }> };
@@ -220,6 +221,7 @@ export async function POST(request: Request, context: RouteContext) {
actorDisplayName: currentUser.displayName,
actorAvatarUrl: currentUser.avatarUrl,
actorNodeDomain: null,
...(targetUser.isBot ? buildNotificationTarget(targetUser) : {}),
type: 'follow',
});
@@ -232,6 +234,7 @@ export async function POST(request: Request, context: RouteContext) {
actorDisplayName: currentUser.displayName,
actorAvatarUrl: currentUser.avatarUrl,
actorNodeDomain: null,
...buildNotificationTarget(targetUser),
type: 'follow',
});
}
+119 -6
View File
@@ -1,9 +1,34 @@
import { NextResponse } from 'next/server';
import { db, likes, posts, users, userSwarmLikes } from '@/db';
import { eq, desc, and, inArray } from 'drizzle-orm';
import { discoverNode } from '@/lib/swarm/discovery';
import { isSwarmNode } from '@/lib/swarm/interactions';
import { getRemoteBaseUrl, mapRemoteProfilePost, parseRemoteHandle } from '@/lib/swarm/remote-profile-posts';
import { getViewerSwarmRepostedPostIds } from '@/lib/swarm/reposts';
import { parseLinkPreviewMediaJson } from '@/lib/media/linkPreview';
type RouteContext = { params: Promise<{ handle: string }> };
const embeddedPostRelations = {
author: true,
bot: true,
media: true,
replyTo: {
with: {
author: true,
bot: true,
media: true,
},
},
} as const;
const likedPostRelations = {
...embeddedPostRelations,
repostOf: {
with: embeddedPostRelations,
},
} as const;
const parseMediaJson = (mediaJson: string | null) => {
if (!mediaJson) {
return [];
@@ -22,11 +47,88 @@ export async function GET(request: Request, context: RouteContext) {
const cleanHandle = handle.toLowerCase().replace(/^@/, '');
const { searchParams } = new URL(request.url);
const limit = Math.min(parseInt(searchParams.get('limit') || '25'), 50);
const remote = parseRemoteHandle(handle);
const fetchRemoteLikesRoute = async () => {
if (!remote) {
return NextResponse.json({ posts: [], nextCursor: null });
}
const baseUrl = getRemoteBaseUrl(remote.domain);
const res = await fetch(`${baseUrl}/api/users/${remote.handle}/likes?limit=${limit}`, {
headers: { Accept: 'application/json' },
signal: AbortSignal.timeout(10000),
});
if (!res.ok) {
return NextResponse.json({ posts: [], nextCursor: null });
}
const data = await res.json();
const { getSession } = await import('@/lib/auth');
const session = await getSession();
const viewer = session?.user;
const mappedPosts = (data.posts || []).map((post: any) => mapRemoteProfilePost(post, remote.domain));
const repostedIds = viewer
? await getViewerSwarmRepostedPostIds(
mappedPosts.map((post: any) => ({
id: post.id,
nodeDomain: remote.domain,
originalPostId: post.originalPostId || post.id.split(':').pop(),
})),
viewer.id
)
: new Set<string>();
return NextResponse.json({
posts: mappedPosts.map((post: any) => ({
...post,
isReposted: repostedIds.has(post.id),
})),
nextCursor: null,
});
};
if (!db) {
if (!remote) {
return NextResponse.json({ posts: [], nextCursor: null });
}
let swarm = await isSwarmNode(remote.domain);
if (!swarm) {
const discovery = await discoverNode(remote.domain);
swarm = discovery.success;
}
if (!swarm) {
return NextResponse.json({ posts: [], nextCursor: null });
}
return await fetchRemoteLikesRoute();
}
// Find the user
const user = await db.query.users.findFirst({
where: eq(users.handle, cleanHandle),
});
const isRemotePlaceholder = user && cleanHandle.includes('@');
if (!user || isRemotePlaceholder) {
if (!remote) {
return NextResponse.json({ error: 'User not found' }, { status: 404 });
}
let swarm = await isSwarmNode(remote.domain);
if (!swarm) {
const discovery = await discoverNode(remote.domain);
swarm = discovery.success;
}
if (!swarm) {
return NextResponse.json({ posts: [], nextCursor: null });
}
return await fetchRemoteLikesRoute();
}
if (!user) {
return NextResponse.json({ error: 'User not found' }, { status: 404 });
@@ -42,11 +144,7 @@ export async function GET(request: Request, context: RouteContext) {
where: eq(likes.userId, user.id),
with: {
post: {
with: {
author: true,
media: true,
bot: true,
},
with: likedPostRelations,
},
},
orderBy: [desc(likes.createdAt)],
@@ -82,6 +180,9 @@ export async function GET(request: Request, context: RouteContext) {
linkPreviewTitle: like.linkPreviewTitle,
linkPreviewDescription: like.linkPreviewDescription,
linkPreviewImage: like.linkPreviewImage,
linkPreviewType: like.linkPreviewType,
linkPreviewVideoUrl: like.linkPreviewVideoUrl,
linkPreviewMedia: parseLinkPreviewMediaJson(like.linkPreviewMediaJson) || null,
isSwarm: true,
nodeDomain: like.nodeDomain,
likedAt: like.likedAt.toISOString(),
@@ -110,6 +211,17 @@ export async function GET(request: Request, context: RouteContext) {
.filter((post: any) => !post.isSwarm)
.map((post: any) => post.id)
.filter(Boolean);
const swarmTargets = likedPosts
.filter((post: any) => post.isSwarm)
.map((post: any) => ({
id: post.id,
nodeDomain: post.nodeDomain,
originalPostId: post.originalPostId,
}))
.filter((post: any) => post.nodeDomain && post.originalPostId);
const swarmRepostedIds = swarmTargets.length > 0
? await getViewerSwarmRepostedPostIds(swarmTargets as any, viewer.id)
: new Set<string>();
if (localPostIds.length > 0) {
const viewerLikes = await db.query.likes.findMany({
@@ -132,12 +244,13 @@ export async function GET(request: Request, context: RouteContext) {
likedPosts = likedPosts.map(p => ({
...p!,
isLiked: p!.isSwarm ? isOwnLikesView : likedPostIds.has(p!.id),
isReposted: repostedPostIds.has(p!.id),
isReposted: p!.isSwarm ? swarmRepostedIds.has(p!.id) : repostedPostIds.has(p!.id),
})) as any;
} else {
likedPosts = likedPosts.map(p => ({
...p!,
isLiked: p!.isSwarm ? isOwnLikesView : p!.isLiked,
isReposted: p!.isSwarm ? swarmRepostedIds.has(p!.id) : p!.isReposted,
})) as any;
}
}
+291 -92
View File
@@ -1,24 +1,169 @@
import { NextResponse } from 'next/server';
import { db, posts, users, likes } from '@/db';
import { db, posts, users, likes, userSwarmReposts } from '@/db';
import { eq, desc, and, inArray, lt, sql, isNull } from 'drizzle-orm';
import { fetchSwarmUserProfile, isSwarmNode } from '@/lib/swarm/interactions';
import { discoverNode } from '@/lib/swarm/discovery';
import { getViewerSwarmLikedPostIds } from '@/lib/swarm/likes';
import { getRemoteBaseUrl, mapRemoteProfilePost, parseRemoteHandle } from '@/lib/swarm/remote-profile-posts';
import { parseLinkPreviewMediaJson } from '@/lib/media/linkPreview';
const embeddedPostRelations = {
author: true,
bot: true,
media: true,
replyTo: {
with: {
author: true,
bot: true,
media: true,
},
},
} as const;
const userPostRelations = {
...embeddedPostRelations,
repostOf: {
with: embeddedPostRelations,
},
} as const;
type RouteContext = { params: Promise<{ handle: string }> };
const parseRemoteHandle = (handle: string) => {
const clean = handle.toLowerCase().replace(/^@/, '');
const parts = clean.split('@').filter(Boolean);
if (parts.length === 2) {
return { handle: parts[0], domain: parts[1] };
}
return null;
type FeedPostWithChildren = {
id: string;
createdAt?: string | Date;
repostOf?: FeedPostWithChildren | null;
replyTo?: FeedPostWithChildren | null;
isLiked?: boolean;
isReposted?: boolean;
nodeDomain?: string | null;
originalPostId?: string | null;
};
function parseMediaJson(mediaJson: string | null) {
if (!mediaJson) {
return [];
}
try {
return JSON.parse(mediaJson);
} catch {
return [];
}
}
function mapUserSwarmRepostToFeedPost(
row: typeof userSwarmReposts.$inferSelect,
author: Pick<typeof users.$inferSelect, 'id' | 'handle' | 'displayName' | 'avatarUrl' | 'isBot'>
): FeedPostWithChildren {
const remoteAuthorHandle = row.authorHandle.includes('@')
? row.authorHandle
: `${row.authorHandle}@${row.nodeDomain}`;
const remoteOriginalId = `swarm:${row.nodeDomain}:${row.originalPostId}`;
return {
id: `swarm-repost:${row.id}`,
content: '',
createdAt: row.repostedAt.toISOString(),
likesCount: 0,
repostsCount: 0,
repliesCount: 0,
author: {
id: author.id,
handle: author.handle,
displayName: author.displayName,
avatarUrl: author.avatarUrl,
isBot: author.isBot,
},
repostOfId: remoteOriginalId,
repostOf: {
id: remoteOriginalId,
originalPostId: row.originalPostId,
content: row.content,
createdAt: row.postCreatedAt.toISOString(),
likesCount: row.likesCount,
repostsCount: row.repostsCount,
repliesCount: row.repliesCount,
isSwarm: true,
nodeDomain: row.nodeDomain,
author: {
id: `swarm:${row.nodeDomain}:${row.authorHandle}`,
handle: remoteAuthorHandle,
displayName: row.authorDisplayName || row.authorHandle,
avatarUrl: row.authorAvatarUrl,
isRemote: true,
nodeDomain: row.nodeDomain,
},
media: parseMediaJson(row.mediaJson),
linkPreviewUrl: row.linkPreviewUrl,
linkPreviewTitle: row.linkPreviewTitle,
linkPreviewDescription: row.linkPreviewDescription,
linkPreviewImage: row.linkPreviewImage,
linkPreviewType: row.linkPreviewType,
linkPreviewVideoUrl: row.linkPreviewVideoUrl,
linkPreviewMedia: parseLinkPreviewMediaJson(row.linkPreviewMediaJson) || null,
},
} as any;
}
function collectNestedPosts(posts: FeedPostWithChildren[]): FeedPostWithChildren[] {
const collected: FeedPostWithChildren[] = [];
const seen = new Set<string>();
const visit = (post: FeedPostWithChildren | null | undefined) => {
if (!post || seen.has(post.id)) return;
seen.add(post.id);
collected.push(post);
visit(post.repostOf);
visit(post.replyTo);
};
posts.forEach(visit);
return collected;
}
function applyInteractionFlags(
posts: FeedPostWithChildren[],
likedIds: Set<string>,
repostedIds: Set<string>
): FeedPostWithChildren[] {
return posts.map((post) => ({
...post,
isLiked: likedIds.has(post.id),
isReposted: repostedIds.has(post.id),
repostOf: post.repostOf ? applyInteractionFlags([post.repostOf], likedIds, repostedIds)[0] : post.repostOf,
replyTo: post.replyTo ? applyInteractionFlags([post.replyTo], likedIds, repostedIds)[0] : post.replyTo,
}));
}
function getPostTimestamp(post: { createdAt?: string | Date }) {
if (!post.createdAt) {
return 0;
}
return new Date(post.createdAt).getTime();
}
async function getMixedProfileCursorDate(cursor: string | null) {
if (!cursor) {
return null;
}
if (cursor.startsWith('swarm-repost:')) {
const repostRow = await db.query.userSwarmReposts.findFirst({
where: eq(userSwarmReposts.id, cursor.replace('swarm-repost:', '')),
});
return repostRow?.repostedAt ?? null;
}
const cursorPost = await db.query.posts.findFirst({
where: eq(posts.id, cursor),
});
return cursorPost?.createdAt ?? null;
}
async function populateViewerLikeState(
remotePosts: any[],
domain: string
remotePosts: any[]
) {
if (!remotePosts.length) {
return remotePosts;
@@ -34,20 +179,32 @@ async function populateViewerLikeState(
return remotePosts;
}
const { getViewerSwarmRepostedPostIds } = await import('@/lib/swarm/reposts');
const allRemotePosts = collectNestedPosts(remotePosts as FeedPostWithChildren[]);
const swarmTargets = allRemotePosts
.filter((post) => post.id.startsWith('swarm:') && post.originalPostId && post.nodeDomain)
.map((post) => ({
id: post.id,
nodeDomain: post.nodeDomain!,
originalPostId: post.originalPostId!,
}));
const likedIds = await getViewerSwarmLikedPostIds(
remotePosts.map((post) => ({
id: `swarm:${domain}:${post.originalPostId}`,
nodeDomain: domain,
originalPostId: post.originalPostId,
})),
swarmTargets,
viewer.handle,
nodeDomain
);
const repostedIds = await getViewerSwarmRepostedPostIds(
swarmTargets,
viewer.id
);
return remotePosts.map((post) => ({
...post,
isLiked: likedIds.has(`swarm:${domain}:${post.originalPostId}`),
}));
return applyInteractionFlags(
remotePosts as FeedPostWithChildren[],
likedIds,
repostedIds
);
} catch {
return remotePosts;
}
@@ -62,6 +219,31 @@ export async function GET(request: Request, context: RouteContext) {
const cursor = searchParams.get('cursor');
const remote = parseRemoteHandle(handle);
const fetchRemotePostsRoute = async () => {
if (!remote) {
return NextResponse.json({ posts: [], nextCursor: null });
}
const baseUrl = getRemoteBaseUrl(remote.domain);
const res = await fetch(
`${baseUrl}/api/users/${remote.handle}/posts?limit=${limit}`,
{
headers: { Accept: 'application/json' },
signal: AbortSignal.timeout(10000),
}
);
if (!res.ok) {
return NextResponse.json({ posts: [], nextCursor: null });
}
const data = await res.json();
const mappedPosts = (data.posts || []).map((post: any) => mapRemoteProfilePost(post, remote.domain));
return NextResponse.json({
posts: await populateViewerLikeState(mappedPosts),
nextCursor: null,
});
};
if (!db) {
if (!remote) {
@@ -104,11 +286,14 @@ export async function GET(request: Request, context: RouteContext) {
linkPreviewTitle: post.linkPreviewTitle || null,
linkPreviewDescription: post.linkPreviewDescription || null,
linkPreviewImage: post.linkPreviewImage || null,
linkPreviewType: post.linkPreviewType || null,
linkPreviewVideoUrl: post.linkPreviewVideoUrl || null,
linkPreviewMedia: post.linkPreviewMedia || null,
isSwarm: true,
nodeDomain: remote.domain,
}));
return NextResponse.json({ posts: await populateViewerLikeState(remotePosts, remote.domain), nextCursor: null });
return NextResponse.json({ posts: await populateViewerLikeState(remotePosts), nextCursor: null });
}
return NextResponse.json({ posts: [] });
@@ -118,8 +303,9 @@ export async function GET(request: Request, context: RouteContext) {
const user = await db.query.users.findFirst({
where: eq(users.handle, cleanHandle),
});
const isRemotePlaceholder = user && cleanHandle.includes('@');
if (!user) {
if (!user || isRemotePlaceholder) {
if (!remote) {
return NextResponse.json({ error: 'User not found' }, { status: 404 });
}
@@ -135,39 +321,7 @@ export async function GET(request: Request, context: RouteContext) {
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}`,
handle: authorHandle,
displayName: profile.displayName || profile.handle,
avatarUrl: profile.avatarUrl,
};
const remotePosts = profileData.posts.map((post: any) => ({
id: post.id,
originalPostId: 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,
}));
return NextResponse.json({ posts: await populateViewerLikeState(remotePosts, remote.domain), nextCursor: null });
}
return NextResponse.json({ posts: [] });
return await fetchRemotePostsRoute();
}
if (user.isSuspended) {
@@ -175,43 +329,49 @@ export async function GET(request: Request, context: RouteContext) {
}
// Get user's posts with cursor-based pagination
const cursorDate = await getMixedProfileCursorDate(cursor);
let whereConditions = and(
eq(posts.userId, user.id),
eq(posts.isRemoved, false),
isNull(posts.replyToId),
isNull(posts.swarmReplyToId)
);
// If cursor provided, get posts older than the cursor
if (cursor) {
const cursorPost = await db.query.posts.findFirst({
where: eq(posts.id, cursor),
});
if (cursorPost) {
whereConditions = and(
eq(posts.userId, user.id),
eq(posts.isRemoved, false),
isNull(posts.replyToId),
isNull(posts.swarmReplyToId),
lt(posts.createdAt, cursorPost.createdAt)
);
}
if (cursorDate) {
whereConditions = and(
eq(posts.userId, user.id),
eq(posts.isRemoved, false),
isNull(posts.replyToId),
isNull(posts.swarmReplyToId),
lt(posts.createdAt, cursorDate)
);
}
let userPosts: any[] = await db.query.posts.findMany({
const localPosts = await db.query.posts.findMany({
where: whereConditions,
with: {
author: true,
media: true,
bot: true,
replyTo: {
with: { author: true },
},
},
with: userPostRelations,
orderBy: [desc(posts.createdAt)],
limit,
limit: cursor ? limit : limit * 2,
});
const swarmRepostWhere = cursorDate
? and(
eq(userSwarmReposts.userId, user.id),
lt(userSwarmReposts.repostedAt, cursorDate)
)
: eq(userSwarmReposts.userId, user.id);
const swarmRepostRows = await db.query.userSwarmReposts.findMany({
where: swarmRepostWhere,
orderBy: [desc(userSwarmReposts.repostedAt)],
limit: cursor ? limit : limit * 2,
});
let userPosts: any[] = [
...localPosts,
...swarmRepostRows.map((row) => mapUserSwarmRepostToFeedPost(row, user)),
]
.sort((a, b) => getPostTimestamp(b) - getPostTimestamp(a))
.slice(0, limit);
// Populate isLiked and isReposted for authenticated users
try {
const { getSession } = await import('@/lib/auth');
@@ -219,32 +379,71 @@ export async function GET(request: Request, context: RouteContext) {
if (session?.user && userPosts.length > 0) {
const viewer = session.user;
const postIds = userPosts.map(p => p.id).filter(Boolean);
const allProfilePosts = collectNestedPosts(userPosts as FeedPostWithChildren[]);
const localPostIds: string[] = [];
const swarmTargets: Array<{ id: string; nodeDomain: string; originalPostId: string }> = [];
if (postIds.length > 0) {
for (const post of allProfilePosts) {
if (post.id.startsWith('swarm:') && post.nodeDomain && post.originalPostId) {
swarmTargets.push({
id: post.id,
nodeDomain: post.nodeDomain,
originalPostId: post.originalPostId,
});
} else if (!post.id.startsWith('swarm-repost:')) {
localPostIds.push(post.id);
}
}
const likedPostIds = new Set<string>();
const repostedPostIds = new Set<string>();
if (localPostIds.length > 0) {
const viewerLikes = await db.query.likes.findMany({
where: and(
eq(likes.userId, viewer.id),
inArray(likes.postId, postIds)
inArray(likes.postId, localPostIds)
),
});
const likedPostIds = new Set(viewerLikes.map(l => l.postId));
viewerLikes.forEach((like) => likedPostIds.add(like.postId));
const viewerReposts = await db.query.posts.findMany({
where: and(
eq(posts.userId, viewer.id),
inArray(posts.repostOfId, postIds),
inArray(posts.repostOfId, localPostIds),
eq(posts.isRemoved, false)
),
});
const repostedPostIds = new Set(viewerReposts.map(r => r.repostOfId));
userPosts = userPosts.map(p => ({
...p,
isLiked: likedPostIds.has(p.id),
isReposted: repostedPostIds.has(p.id),
}));
viewerReposts.forEach((repost) => {
if (repost.repostOfId) {
repostedPostIds.add(repost.repostOfId);
}
});
}
if (swarmTargets.length > 0) {
const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:3000';
const likedIds = await getViewerSwarmLikedPostIds(
swarmTargets.map((post) => ({
id: post.id,
nodeDomain: post.nodeDomain,
originalPostId: post.originalPostId,
})),
viewer.handle,
nodeDomain
);
likedIds.forEach((id) => likedPostIds.add(id));
const { getViewerSwarmRepostedPostIds } = await import('@/lib/swarm/reposts');
const repostedIds = await getViewerSwarmRepostedPostIds(swarmTargets, viewer.id);
repostedIds.forEach((id) => repostedPostIds.add(id));
}
userPosts = applyInteractionFlags(
userPosts as FeedPostWithChildren[],
likedPostIds,
repostedPostIds
) as any;
}
} catch (error) {
console.error('Error populating interaction flags:', error);
+197
View File
@@ -0,0 +1,197 @@
import { NextResponse } from 'next/server';
import { db, likes, posts, users } from '@/db';
import { and, desc, eq, inArray, lt, not, or, isNotNull } from 'drizzle-orm';
import { discoverNode } from '@/lib/swarm/discovery';
import { getRemoteBaseUrl, mapRemoteProfilePost, parseRemoteHandle } from '@/lib/swarm/remote-profile-posts';
import { isSwarmNode } from '@/lib/swarm/interactions';
import { getViewerSwarmRepostedPostIds } from '@/lib/swarm/reposts';
const embeddedPostRelations = {
author: true,
bot: true,
media: true,
replyTo: {
with: {
author: true,
bot: true,
media: true,
},
},
} as const;
const replyRelations = {
...embeddedPostRelations,
repostOf: {
with: embeddedPostRelations,
},
} as const;
type RouteContext = { params: Promise<{ handle: string }> };
export async function GET(request: Request, 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);
const cursor = searchParams.get('cursor');
const remote = parseRemoteHandle(handle);
const fetchRemoteReplies = async () => {
if (!remote) {
return NextResponse.json({ posts: [], nextCursor: null });
}
const baseUrl = getRemoteBaseUrl(remote.domain);
const res = await fetch(`${baseUrl}/api/users/${remote.handle}/replies?limit=${limit}`, {
headers: { Accept: 'application/json' },
signal: AbortSignal.timeout(10000),
});
if (!res.ok) {
return NextResponse.json({ posts: [], nextCursor: null });
}
const data = await res.json();
const { getSession } = await import('@/lib/auth');
const session = await getSession();
const viewer = session?.user;
const mappedPosts = (data.posts || []).map((post: any) => mapRemoteProfilePost(post, remote.domain));
const repostedIds = viewer
? await getViewerSwarmRepostedPostIds(
mappedPosts.map((post: any) => ({
id: post.id,
nodeDomain: remote.domain,
originalPostId: post.originalPostId || post.id.split(':').pop(),
})),
viewer.id
)
: new Set<string>();
return NextResponse.json({
posts: mappedPosts.map((post: any) => ({
...post,
isReposted: repostedIds.has(post.id),
})),
nextCursor: null,
});
};
if (!db) {
if (!remote) {
return NextResponse.json({ posts: [], nextCursor: null });
}
let swarm = await isSwarmNode(remote.domain);
if (!swarm) {
const discovery = await discoverNode(remote.domain);
swarm = discovery.success;
}
if (!swarm) {
return NextResponse.json({ posts: [], nextCursor: null });
}
return await fetchRemoteReplies();
}
const user = await db.query.users.findFirst({
where: eq(users.handle, cleanHandle),
});
const isRemotePlaceholder = user && cleanHandle.includes('@');
if (!user || isRemotePlaceholder) {
if (!remote) {
return NextResponse.json({ error: 'User not found' }, { status: 404 });
}
let swarm = await isSwarmNode(remote.domain);
if (!swarm) {
const discovery = await discoverNode(remote.domain);
swarm = discovery.success;
}
if (!swarm) {
return NextResponse.json({ posts: [], nextCursor: null });
}
return await fetchRemoteReplies();
}
if (user.isSuspended) {
return NextResponse.json({ error: 'User not found' }, { status: 404 });
}
let whereConditions = and(
eq(posts.userId, user.id),
eq(posts.isRemoved, false),
or(isNotNull(posts.replyToId), isNotNull(posts.swarmReplyToId)),
);
if (cursor) {
const cursorPost = await db.query.posts.findFirst({
where: eq(posts.id, cursor),
});
if (cursorPost) {
whereConditions = and(
eq(posts.userId, user.id),
eq(posts.isRemoved, false),
or(isNotNull(posts.replyToId), isNotNull(posts.swarmReplyToId)),
lt(posts.createdAt, cursorPost.createdAt),
);
}
}
let replyPosts: any[] = await db.query.posts.findMany({
where: whereConditions,
with: replyRelations,
orderBy: [desc(posts.createdAt)],
limit,
});
try {
const { getSession } = await import('@/lib/auth');
const session = await getSession();
if (session?.user && replyPosts.length > 0) {
const viewer = session.user;
const postIds = replyPosts.map((post) => post.id).filter(Boolean);
const viewerLikes = postIds.length > 0
? await db.query.likes.findMany({
where: and(
eq(likes.userId, viewer.id),
inArray(likes.postId, postIds),
),
})
: [];
const likedPostIds = new Set(viewerLikes.map((like) => like.postId));
const viewerReposts = postIds.length > 0
? await db.query.posts.findMany({
where: and(
eq(posts.userId, viewer.id),
inArray(posts.repostOfId, postIds),
eq(posts.isRemoved, false),
),
})
: [];
const repostedPostIds = new Set(viewerReposts.map((post) => post.repostOfId));
replyPosts = replyPosts.map((post) => ({
...post,
isLiked: likedPostIds.has(post.id),
isReposted: repostedPostIds.has(post.id),
}));
}
} catch {
}
return NextResponse.json({
posts: replyPosts,
nextCursor: replyPosts.length === limit ? replyPosts[replyPosts.length - 1]?.id : null,
});
} catch (error) {
console.error('Get user replies error:', error);
return NextResponse.json({ error: 'Failed to get replies' }, { status: 500 });
}
}
+15
View File
@@ -51,6 +51,15 @@ export async function GET(request: Request, context: RouteContext) {
const profileData = await fetchSwarmUserProfile(remoteHandle, remoteDomain, 0);
if (profileData?.profile) {
const profile = profileData.profile;
const rawBotOwnerHandle = profile.botOwnerHandle?.toLowerCase().replace(/^@/, '') || null;
const normalizedBotOwnerHandle = rawBotOwnerHandle
? rawBotOwnerHandle.includes('@')
? rawBotOwnerHandle
: `${rawBotOwnerHandle}@${remoteDomain}`
: null;
const botOwnerLocalHandle = rawBotOwnerHandle
? rawBotOwnerHandle.split('@')[0]
: null;
// CACHE: Upsert the remote user into our local database
const { upsertRemoteUser } = await import('@/lib/swarm/user-cache');
@@ -80,6 +89,12 @@ export async function GET(request: Request, context: RouteContext) {
isSwarm: true,
nodeDomain: remoteDomain,
isBot: profile.isBot || false,
botOwner: normalizedBotOwnerHandle && botOwnerLocalHandle ? {
id: `swarm:${remoteDomain}:${botOwnerLocalHandle}`,
handle: normalizedBotOwnerHandle,
displayName: botOwnerLocalHandle,
avatarUrl: null,
} : null,
did: profile.did,
}
});