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
-62
View File
@@ -387,23 +387,6 @@ export default function AdminPage() {
);
}
const formatTimestamp = (value?: string | null) => {
if (!value) return 'Never';
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return date.toLocaleString();
};
const updateStatusLabel = (() => {
if (loadingUpdateStatus) return 'Checking...';
if (!updateStatus) return 'Unavailable';
if (!updateStatus.updater.available) return updateStatus.updater.message || 'Updater unavailable';
if (updateStatus.updater.status === 'updating') return updateStatus.updater.message || 'Update in progress';
if (updateStatus.updater.status === 'error') return updateStatus.updater.message || 'Last update failed';
if (updateStatus.updateAvailable) return 'Update available';
return 'Up to date';
})();
return (
<>
<header style={{
@@ -783,51 +766,6 @@ export default function AdminPage() {
</div>
)}
<div style={{ display: 'grid', gap: '8px', marginTop: '16px', fontSize: '13px' }}>
<div>
<strong>Current build:</strong>{' '}
{updateStatus?.current?.version || 'Unknown'}
</div>
<div>
<strong>Latest build:</strong>{' '}
{updateStatus?.latest?.version || 'Unavailable'}
</div>
<div>
<strong>Status:</strong>{' '}
{updateStatusLabel}
</div>
{updateStatus?.updater.lastStartedAt && (
<div>
<strong>Last started:</strong>{' '}
{formatTimestamp(updateStatus.updater.lastStartedAt)}
</div>
)}
{updateStatus?.updater.lastFinishedAt && (
<div>
<strong>Last finished:</strong>{' '}
{formatTimestamp(updateStatus.updater.lastFinishedAt)}
</div>
)}
{typeof updateStatus?.updater.lastExitCode === 'number' && (
<div>
<strong>Last exit code:</strong>{' '}
{updateStatus.updater.lastExitCode}
</div>
)}
{updateStatus?.updater.trigger && (
<div>
<strong>Last trigger:</strong>{' '}
{updateStatus.updater.trigger === 'auto' ? 'Automatic update' : 'Manual update'}
</div>
)}
{updateStatus?.updater.lastError && (
<div style={{ color: 'var(--danger)' }}>
<strong>Last error:</strong>{' '}
{updateStatus.updater.lastError}
</div>
)}
</div>
{!updateStatus?.updater.available && (
<div style={{
marginTop: '16px',
+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,
}
});
+40 -16
View File
@@ -59,6 +59,7 @@ export default function EditBotPage() {
systemPrompt: '',
llmProvider: 'openai',
llmModel: 'gpt-4',
llmEndpoint: '',
llmApiKey: '',
autonomousMode: false,
@@ -137,6 +138,7 @@ export default function EditBotPage() {
systemPrompt: personalityConfig.systemPrompt || '',
llmProvider: bot.llmProvider || 'openai',
llmModel: bot.llmModel || 'gpt-4',
llmEndpoint: bot.llmEndpoint || '',
llmApiKey: '', // Don't pre-fill API key for security
autonomousMode: bot.autonomousMode || false,
postingFrequency,
@@ -288,6 +290,7 @@ export default function EditBotPage() {
},
llmProvider: formData.llmProvider,
llmModel: formData.llmModel,
llmEndpoint: formData.llmProvider === 'custom' ? formData.llmEndpoint : null,
autonomousMode: formData.autonomousMode,
schedule: formData.autonomousMode ? {
type: 'interval',
@@ -482,9 +485,29 @@ export default function EditBotPage() {
<option value="openai">OpenAI</option>
<option value="anthropic">Anthropic</option>
<option value="openrouter">OpenRouter</option>
<option value="custom">Custom (OpenAI Compatible)</option>
</select>
</div>
{formData.llmProvider === 'custom' && (
<div>
<label style={{ display: 'block', fontSize: '14px', fontWeight: 500, marginBottom: '8px' }}>
OpenAI-Compatible Endpoint
</label>
<input
type="url"
value={formData.llmEndpoint}
onChange={(e) => setFormData({ ...formData, llmEndpoint: e.target.value })}
className="input"
placeholder="https://api.example.com/v1/chat/completions"
required
/>
<p style={{ fontSize: '13px', color: 'var(--foreground-tertiary)', marginTop: '6px' }}>
Enter the full chat completions endpoint for a public OpenAI-compatible API.
</p>
</div>
)}
<div>
<label style={{ display: 'block', fontSize: '14px', fontWeight: 500, marginBottom: '8px' }}>
Model
@@ -850,22 +873,23 @@ export default function EditBotPage() {
</p>
</div>
<div>
<label style={{ display: 'block', fontSize: '14px', fontWeight: 500, marginBottom: '8px' }}>
Posting Frequency
</label>
<select
value={formData.postingFrequency}
onChange={(e) => setFormData({ ...formData, postingFrequency: e.target.value })}
className="input"
disabled={!formData.autonomousMode}
>
<option value="every_2_hours">Every 2 Hours</option>
<option value="every_4_hours">Every 4 Hours</option>
<option value="every_6_hours">Every 6 Hours</option>
<option value="daily">Once Daily</option>
</select>
</div>
{formData.autonomousMode && (
<div>
<label style={{ display: 'block', fontSize: '14px', fontWeight: 500, marginBottom: '8px' }}>
Posting Frequency
</label>
<select
value={formData.postingFrequency}
onChange={(e) => setFormData({ ...formData, postingFrequency: e.target.value })}
className="input"
>
<option value="every_2_hours">Every 2 Hours</option>
<option value="every_4_hours">Every 4 Hours</option>
<option value="every_6_hours">Every 6 Hours</option>
<option value="daily">Once Daily</option>
</select>
</div>
)}
+41 -18
View File
@@ -52,6 +52,7 @@ export default function NewBotPage() {
systemPrompt: '',
llmProvider: 'openai',
llmModel: 'gpt-4',
llmEndpoint: '',
llmApiKey: '',
autonomousMode: false,
postingFrequency: 'every_4_hours',
@@ -88,6 +89,7 @@ export default function NewBotPage() {
...prev,
llmProvider: lastBot.llmProvider || 'openai',
llmModel: lastBot.llmModel || 'gpt-4',
llmEndpoint: lastBot.llmEndpoint || '',
}));
}
}
@@ -237,6 +239,7 @@ export default function NewBotPage() {
},
llmProvider: formData.llmProvider,
llmModel: formData.llmModel,
llmEndpoint: formData.llmProvider === 'custom' ? formData.llmEndpoint : undefined,
llmApiKey: formData.llmApiKey,
autonomousMode: formData.autonomousMode,
schedule: formData.autonomousMode ? {
@@ -430,9 +433,28 @@ export default function NewBotPage() {
<option value="openai">OpenAI</option>
<option value="anthropic">Anthropic</option>
<option value="openrouter">OpenRouter</option>
<option value="custom">Custom (OpenAI Compatible)</option>
</select>
</div>
{formData.llmProvider === 'custom' && (
<div>
<label style={{ display: 'block', fontSize: '14px', fontWeight: 500, marginBottom: '8px' }}>
OpenAI-Compatible Endpoint
</label>
<input
type="url"
value={formData.llmEndpoint}
onChange={(e) => setFormData({ ...formData, llmEndpoint: e.target.value })}
className="input"
placeholder="https://api.example.com/v1/chat/completions"
/>
<p style={{ fontSize: '13px', color: 'var(--foreground-tertiary)', marginTop: '6px' }}>
Enter the full chat completions endpoint for a public OpenAI-compatible API.
</p>
</div>
)}
<div>
<label style={{ display: 'block', fontSize: '14px', fontWeight: 500, marginBottom: '8px' }}>
Model
@@ -445,7 +467,7 @@ export default function NewBotPage() {
placeholder="gpt-4"
/>
<p style={{ fontSize: '13px', color: 'var(--foreground-tertiary)', marginTop: '6px' }}>
e.g., gpt-4, claude-3-opus, etc.
e.g., gpt-4, claude-3-opus, mistral-large, etc.
</p>
</div>
@@ -782,22 +804,23 @@ export default function NewBotPage() {
</p>
</div>
<div>
<label style={{ display: 'block', fontSize: '14px', fontWeight: 500, marginBottom: '8px' }}>
Posting Frequency
</label>
<select
value={formData.postingFrequency}
onChange={(e) => setFormData({ ...formData, postingFrequency: e.target.value })}
className="input"
disabled={!formData.autonomousMode}
>
<option value="every_2_hours">Every 2 Hours</option>
<option value="every_4_hours">Every 4 Hours</option>
<option value="every_6_hours">Every 6 Hours</option>
<option value="daily">Once Daily</option>
</select>
</div>
{formData.autonomousMode && (
<div>
<label style={{ display: 'block', fontSize: '14px', fontWeight: 500, marginBottom: '8px' }}>
Posting Frequency
</label>
<select
value={formData.postingFrequency}
onChange={(e) => setFormData({ ...formData, postingFrequency: e.target.value })}
className="input"
>
<option value="every_2_hours">Every 2 Hours</option>
<option value="every_4_hours">Every 4 Hours</option>
<option value="every_6_hours">Every 6 Hours</option>
<option value="daily">Once Daily</option>
</select>
</div>
)}
@@ -895,7 +918,7 @@ export default function NewBotPage() {
className="btn btn-primary"
disabled={
(step === 'identity' && (!formData.name || !formData.handle)) ||
(step === 'personality' && (!formData.systemPrompt || !formData.llmApiKey))
(step === 'personality' && (!formData.systemPrompt || !formData.llmModel || !formData.llmApiKey || (formData.llmProvider === 'custom' && !formData.llmEndpoint)))
}
>
Next
+126 -3
View File
@@ -246,10 +246,18 @@ a.btn-primary:visited {
flex-shrink: 0;
border-right: 1px solid var(--border);
padding: 16px;
display: flex;
flex-direction: column;
position: sticky;
top: 0;
height: 100vh;
overflow-y: auto;
z-index: 20;
}
.sidebar nav {
display: flex;
flex-direction: column;
}
.main {
@@ -258,6 +266,8 @@ a.btn-primary:visited {
min-width: 0;
min-height: 100vh;
border-right: 1px solid var(--border);
position: relative;
z-index: 1;
}
.layout.hide-right-sidebar .main {
@@ -320,6 +330,22 @@ a.btn-primary:visited {
padding-top: 8px;
}
.post.embedded {
margin-top: 0;
padding: 12px;
border: 1px solid var(--border);
border-radius: var(--radius-md);
background: var(--background);
}
.post.embedded:hover {
background: var(--background-secondary);
}
.post.embedded .post-link-overlay {
border-radius: inherit;
}
/* Post */
.post {
padding: 16px;
@@ -359,6 +385,34 @@ a.btn-primary:visited {
word-wrap: break-word;
}
.repost-event-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 12px;
color: var(--accent);
font-size: 13px;
}
.repost-event-icon {
display: inline-flex;
align-items: center;
justify-content: center;
}
.repost-event-text a {
color: inherit;
font-weight: 600;
}
.repost-event-copy {
color: var(--foreground-secondary);
}
.repost-embed {
margin-top: 4px;
}
.post-reasons {
display: flex;
flex-wrap: wrap;
@@ -2402,6 +2456,54 @@ a.btn-primary:visited {
border-bottom: 1px solid var(--border);
}
.link-preview-video video {
width: 100%;
max-height: 480px;
display: block;
background: #000;
border-bottom: 1px solid var(--border);
}
.link-preview-gallery {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 2px;
background: var(--border);
}
.link-preview-gallery.compact {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.link-preview-gallery-item {
position: relative;
background: var(--background-tertiary);
min-height: 140px;
}
.link-preview-gallery.compact .link-preview-gallery-item {
min-height: 80px;
}
.link-preview-gallery-item img {
width: 100%;
height: 100%;
display: block;
object-fit: cover;
}
.link-preview-gallery-more {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.45);
color: #fff;
font-size: 20px;
font-weight: 700;
}
.link-preview-info {
padding: 12px;
}
@@ -2455,22 +2557,43 @@ a.btn-primary:visited {
.link-preview-card.mini {
display: flex;
max-height: 80px;
max-height: none;
}
.link-preview-card.mini .link-preview-image {
width: 80px;
min-width: 80px;
height: 80px;
overflow: hidden;
}
.link-preview-card.mini .link-preview-image img {
.link-preview-card.mini .link-preview-image img,
.link-preview-card.mini .link-preview-image video {
height: 80px;
width: 80px;
object-fit: cover;
border-bottom: none;
border-right: 1px solid var(--border);
}
.link-preview-card.mini .link-preview-video {
width: 120px;
min-width: 120px;
}
.link-preview-card.mini .link-preview-video video {
height: 100%;
min-height: 80px;
border-bottom: none;
border-right: 1px solid var(--border);
}
.link-preview-card.mini .link-preview-gallery {
width: 120px;
min-width: 120px;
border-right: 1px solid var(--border);
}
.link-preview-card.mini .link-preview-info {
padding: 8px 12px;
display: flex;
@@ -2589,4 +2712,4 @@ a.btn-primary:visited {
.layout.hide-right-sidebar .main {
max-width: none;
border-right: none;
}
}
+105 -11
View File
@@ -4,7 +4,7 @@ import { useState, useEffect, useRef } from 'react';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import Image from 'next/image';
import { TriangleAlert } from 'lucide-react';
import { TriangleAlert, X } from 'lucide-react';
import { decryptPrivateKey } from '@/lib/crypto/private-key-client';
import { keyStore, importPrivateKey } from '@/lib/crypto/user-signing';
import { useAuth } from '@/lib/contexts/AuthContext';
@@ -24,7 +24,13 @@ declare global {
}
}
export default function LoginPage() {
interface AuthScreenProps {
modal?: boolean;
onClose?: () => void;
onSuccess?: () => void;
}
export function AuthScreen({ modal = false, onClose, onSuccess }: AuthScreenProps) {
const router = useRouter();
const [mode, setMode] = useState<'login' | 'register' | 'import'>('login');
const [email, setEmail] = useState('');
@@ -204,7 +210,12 @@ export default function LoginPage() {
setImportSuccess(data.message);
// Soft navigation to preserve AuthContext/KeyStore state
setTimeout(() => {
router.push('/');
router.refresh();
if (onSuccess) {
onSuccess();
} else {
router.push('/');
}
}, 2000);
} catch (err) {
@@ -319,7 +330,12 @@ export default function LoginPage() {
}
// Soft navigation to preserve AuthContext/KeyStore state
router.push('/');
router.refresh();
if (onSuccess) {
onSuccess();
} else {
router.push('/');
}
} catch (err) {
setError(err instanceof Error ? err.message : 'An error occurred');
// Reset Turnstile on error
@@ -332,15 +348,17 @@ export default function LoginPage() {
}
};
return (
const content = (
<div style={{
minHeight: '100vh',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: '24px',
padding: modal ? '0' : '24px',
}}>
<div style={{ width: '100%', maxWidth: mode === 'register' ? '680px' : '400px' }}>
<div style={{
width: '100%',
maxWidth: mode === 'register' ? '680px' : '400px',
}}>
{/* Logo */}
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', marginBottom: '32px', minHeight: '120px' }}>
{nodeInfoLoaded && (
@@ -984,10 +1002,86 @@ export default function LoginPage() {
</form>
)}
<p style={{ textAlign: 'center', marginTop: '24px', color: 'var(--foreground-tertiary)', fontSize: '14px' }}>
<Link href="/"> Back to home</Link>
</p>
{!modal && (
<p style={{ textAlign: 'center', marginTop: '24px', color: 'var(--foreground-tertiary)', fontSize: '14px' }}>
<Link href="/"> Back to home</Link>
</p>
)}
</div>
</div>
);
if (modal) {
return (
<div
style={{
position: 'fixed',
inset: 0,
background: 'rgba(0, 0, 0, 0.72)',
backdropFilter: 'blur(10px)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: '24px',
zIndex: 120,
}}
onClick={onClose}
>
<div
style={{
position: 'relative',
width: 'min(760px, 100%)',
}}
onClick={(event) => event.stopPropagation()}
>
{onClose && (
<button
onClick={onClose}
aria-label="Close"
style={{
position: 'absolute',
top: '18px',
right: '18px',
width: '44px',
height: '44px',
borderRadius: '999px',
border: '1px solid var(--border)',
background: 'rgba(0, 0, 0, 0.78)',
color: 'var(--foreground)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
cursor: 'pointer',
zIndex: 3,
}}
>
<X size={20} />
</button>
)}
<div
className="card"
style={{
maxHeight: 'calc(100vh - 48px)',
overflowY: 'auto',
padding: '28px',
background: 'rgba(10, 10, 10, 0.98)',
boxShadow: '0 30px 90px rgba(0, 0, 0, 0.55)',
}}
>
{content}
</div>
</div>
</div>
);
}
return (
<div style={{ minHeight: '100vh' }}>
{content}
</div>
);
}
export default function LoginPage() {
return <AuthScreen />;
}
+157 -1
View File
@@ -45,6 +45,20 @@ type Report = {
target?: AdminPost | AdminUser | null;
};
type AdminNode = {
id: string;
domain: string;
name?: string | null;
description?: string | null;
isActive: boolean;
isBlocked: boolean;
blockReason?: string | null;
blockedAt?: string | null;
lastSeenAt?: string | null;
trustScore?: number | null;
isNsfw?: boolean;
};
const formatDate = (value: string) => {
const date = new Date(value);
return date.toLocaleString();
@@ -52,12 +66,15 @@ const formatDate = (value: string) => {
export default function ModerationPage() {
const [isAdmin, setIsAdmin] = useState<boolean | null>(null);
const [tab, setTab] = useState<'reports' | 'posts' | 'users'>('reports');
const [tab, setTab] = useState<'reports' | 'posts' | 'users' | 'nodes'>('reports');
const [reports, setReports] = useState<Report[]>([]);
const [posts, setPosts] = useState<AdminPost[]>([]);
const [users, setUsers] = useState<AdminUser[]>([]);
const [nodes, setNodes] = useState<AdminNode[]>([]);
const [loading, setLoading] = useState(false);
const [reportStatus, setReportStatus] = useState<'open' | 'resolved' | 'all'>('open');
const [nodeDomain, setNodeDomain] = useState('');
const [nodeReason, setNodeReason] = useState('');
useEffect(() => {
fetch('/api/admin/me')
@@ -105,11 +122,25 @@ export default function ModerationPage() {
}
};
const loadNodes = async () => {
setLoading(true);
try {
const res = await fetch('/api/admin/nodes');
const data = await res.json();
setNodes(data.nodes || []);
} catch {
setNodes([]);
} finally {
setLoading(false);
}
};
useEffect(() => {
if (!isAdmin) return;
if (tab === 'reports') loadReports();
if (tab === 'posts') loadPosts();
if (tab === 'users') loadUsers();
if (tab === 'nodes') loadNodes();
}, [tab, isAdmin, reportStatus]);
const handleReportResolve = async (id: string, status: 'open' | 'resolved') => {
@@ -147,6 +178,17 @@ export default function ModerationPage() {
loadUsers();
};
const handleNodeAction = async (action: 'block' | 'unblock', domain: string, reason?: string) => {
await fetch('/api/admin/nodes', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action, domain, reason }),
});
setNodeDomain('');
setNodeReason('');
loadNodes();
};
const reportCounts = useMemo(() => {
return {
open: reports.filter((r) => r.status === 'open').length,
@@ -209,6 +251,12 @@ export default function ModerationPage() {
>
Users
</button>
<button
className={`btn btn-sm ${tab === 'nodes' ? 'btn-primary' : 'btn-ghost'}`}
onClick={() => setTab('nodes')}
>
Nodes
</button>
</div>
{tab === 'reports' && (
@@ -470,6 +518,114 @@ export default function ModerationPage() {
)}
</div>
)}
{tab === 'nodes' && (
<div style={{ padding: '16px' }}>
<div className="card" style={{ padding: '16px', marginBottom: '16px' }}>
<h2 style={{ fontSize: '16px', fontWeight: 600, marginBottom: '8px' }}>Block node</h2>
<p style={{ color: 'var(--foreground-secondary)', marginBottom: '12px' }}>
Blocked nodes cannot deliver swarm interactions, appear in swarm feeds, or exchange chat with this node.
</p>
<div style={{ display: 'grid', gap: '12px' }}>
<input
value={nodeDomain}
onChange={(e) => setNodeDomain(e.target.value)}
placeholder="node.example.com"
className="input"
/>
<textarea
value={nodeReason}
onChange={(e) => setNodeReason(e.target.value)}
placeholder="Reason for blocking this node (optional)"
className="input"
style={{ minHeight: '88px', resize: 'vertical' }}
/>
<div>
<button
className="btn btn-primary btn-sm"
onClick={() => handleNodeAction('block', nodeDomain, nodeReason)}
disabled={!nodeDomain.trim()}
>
Block node
</button>
</div>
</div>
</div>
{loading ? (
<div style={{ padding: '48px', textAlign: 'center', color: 'var(--foreground-tertiary)' }}>Loading nodes...</div>
) : nodes.length === 0 ? (
<div style={{ padding: '48px', textAlign: 'center', color: 'var(--foreground-tertiary)' }}>No known nodes.</div>
) : (
<div style={{ display: 'grid', gap: '12px' }}>
{nodes.map((node) => (
<div key={node.id} className="card" style={{ padding: '16px' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', gap: '16px', alignItems: 'flex-start' }}>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: 'flex', gap: '8px', alignItems: 'center', marginBottom: '8px', flexWrap: 'wrap' }}>
<span style={{
fontSize: '11px',
padding: '2px 8px',
borderRadius: '4px',
background: node.isBlocked ? 'rgba(239, 68, 68, 0.1)' : 'rgba(34, 197, 94, 0.1)',
color: node.isBlocked ? 'rgb(239, 68, 68)' : 'rgb(34, 197, 94)',
fontWeight: 600,
textTransform: 'uppercase',
}}>
{node.isBlocked ? 'blocked' : 'allowed'}
</span>
{!node.isBlocked && !node.isActive && (
<span style={{ fontSize: '11px', color: 'var(--foreground-tertiary)' }}>
inactive
</span>
)}
{node.isNsfw && (
<span style={{ fontSize: '11px', color: 'rgb(245, 158, 11)' }}>
NSFW
</span>
)}
</div>
<div style={{ fontWeight: 600, marginBottom: '4px' }}>
{node.name || node.domain}
</div>
<div style={{ color: 'var(--foreground-secondary)', marginBottom: '8px' }}>
{node.domain}
</div>
{node.description && (
<div style={{ fontSize: '14px', color: 'var(--foreground-secondary)', marginBottom: '8px' }}>
{node.description}
</div>
)}
<div style={{ fontSize: '13px', color: 'var(--foreground-tertiary)' }}>
{node.blockedAt ? `Blocked ${formatDate(node.blockedAt)}` : node.lastSeenAt ? `Last seen ${formatDate(node.lastSeenAt)}` : 'Never seen'}
{typeof node.trustScore === 'number' && <span> Trust {node.trustScore}</span>}
</div>
{node.blockReason && (
<div style={{ fontSize: '13px', color: 'var(--foreground-secondary)', marginTop: '6px' }}>
Reason: {node.blockReason}
</div>
)}
</div>
<div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
{node.isBlocked ? (
<button className="btn btn-ghost btn-sm" onClick={() => handleNodeAction('unblock', node.domain)}>
Unblock
</button>
) : (
<button
className="btn btn-primary btn-sm"
onClick={() => handleNodeAction('block', node.domain, window.prompt('Reason (optional):') || '')}
>
Block
</button>
)}
</div>
</div>
</div>
))}
</div>
)}
</div>
)}
</>
);
}
+25 -5
View File
@@ -13,6 +13,14 @@ interface NotificationActor {
avatarUrl: string | null;
}
interface NotificationTarget {
handle: string;
displayName: string | null;
avatarUrl: string | null;
nodeDomain?: string | null;
isBot?: boolean | null;
}
interface NotificationPost {
id: string;
content: string;
@@ -24,6 +32,7 @@ interface Notification {
createdAt: string;
readAt: string | null;
actor: NotificationActor | null;
target: NotificationTarget | null;
post: NotificationPost | null;
}
@@ -77,17 +86,28 @@ export default function NotificationsPage() {
};
const getNotificationText = (notification: Notification) => {
const targetName = notification.target?.displayName || notification.target?.handle;
switch (notification.type) {
case 'follow':
return 'followed you';
return notification.target?.isBot && targetName
? `followed your bot ${targetName}`
: 'followed you';
case 'like':
return 'liked your post';
return notification.target?.isBot && targetName
? `liked a post from ${targetName}`
: 'liked your post';
case 'repost':
return 'reposted your post';
return notification.target?.isBot && targetName
? `reposted a post from ${targetName}`
: 'reposted your post';
case 'mention':
return 'mentioned you';
return notification.target?.isBot && targetName
? `mentioned your bot ${targetName}`
: 'mentioned you';
case 'reply':
return 'replied to your post';
return notification.target?.isBot && targetName
? `replied to a post from ${targetName}`
: 'replied to your post';
default:
return 'interacted with you';
}
+44 -22
View File
@@ -175,7 +175,7 @@ export default function ProfilePage() {
if (!repliesCursor || repliesLoadingMore || !user) return;
setRepliesLoadingMore(true);
try {
const res = await fetch(`/api/posts?type=replies&userId=${user.id}&cursor=${repliesCursor}`);
const res = await fetch(`/api/users/${handle}/replies?cursor=${repliesCursor}`);
const data = await res.json();
setRepliesPosts(prev => [...prev, ...(data.posts || [])]);
setRepliesCursor(data.nextCursor || null);
@@ -266,7 +266,10 @@ export default function ProfilePage() {
}, [user, currentUser]);
useEffect(() => {
if (!currentUser || !user || currentUser.handle === user.handle) {
const ownerHandle = user?.botOwner?.handle?.replace(/^@/, '').split('@')[0];
const isOwnedBot = Boolean(user?.isBot && currentUser && ownerHandle === currentUser.handle);
if (!currentUser || !user || currentUser.handle === user.handle || isOwnedBot) {
setIsFollowing(false);
setIsBlocked(false);
return;
@@ -314,7 +317,7 @@ export default function ProfilePage() {
if (activeTab === 'replies' && user) {
setRepliesLoading(true);
setRepliesCursor(null);
fetch(`/api/posts?type=replies&userId=${user.id}`)
fetch(`/api/users/${handle}/replies`)
.then(res => res.json())
.then(data => {
setRepliesPosts(data.posts || []);
@@ -323,7 +326,13 @@ export default function ProfilePage() {
.catch(() => setRepliesPosts([]))
.finally(() => setRepliesLoading(false));
}
}, [activeTab, handle, user]);
}, [activeTab, handle, user]);
useEffect(() => {
if (user?.isBot && activeTab === 'following') {
setActiveTab('posts');
}
}, [user?.isBot, activeTab]);
const handleFollow = async () => {
if (!currentUser) return;
@@ -447,6 +456,19 @@ export default function ProfilePage() {
}
const isOwnProfile = currentUser?.handle === user.handle;
const botOwner = user.botOwner as BotOwner | null | undefined;
const botOwnerLocalHandle = botOwner?.handle?.replace(/^@/, '').split('@')[0] || null;
const isOwnedBotProfile = Boolean(
user.isBot &&
currentUser &&
botOwner &&
(botOwner.id === currentUser.id || botOwnerLocalHandle === currentUser.handle)
);
const showFollowingUi = !user.isBot;
const visibleTabs = (user.isBot
? ['posts', 'replies', 'followers'] as const
: ['posts', 'replies', 'likes', 'followers', 'following'] as const
);
return (
<div style={{ maxWidth: '600px', margin: '0 auto', minHeight: '100vh' }}>
@@ -518,7 +540,8 @@ export default function ProfilePage() {
{/* Banner */}
<div
style={{
height: '150px',
width: '100%',
aspectRatio: '3 / 1',
background: (isEditing ? profileForm.headerUrl : user.headerUrl)
? `url(${isEditing ? profileForm.headerUrl : user.headerUrl}) center/cover`
: 'linear-gradient(135deg, var(--accent-muted) 0%, var(--background-tertiary) 100%)',
@@ -553,7 +576,7 @@ export default function ProfilePage() {
</div>
<div style={{ paddingTop: '12px', display: 'flex', gap: '8px', alignItems: 'center' }}>
{!isOwnProfile && currentUser && (
{!isOwnProfile && !isOwnedBotProfile && currentUser && (
<>
{!isBlocked && (
<button
@@ -713,18 +736,20 @@ export default function ProfilePage() {
<strong>{user.followersCount}</strong>{' '}
<span style={{ color: 'var(--foreground-tertiary)' }}>Followers</span>
</button>
<button
onClick={() => setActiveTab('following')}
style={{
background: 'none',
border: 'none',
color: 'var(--foreground)',
cursor: 'pointer',
}}
>
<strong>{user.followingCount}</strong>{' '}
<span style={{ color: 'var(--foreground-tertiary)' }}>Following</span>
</button>
{showFollowingUi && (
<button
onClick={() => setActiveTab('following')}
style={{
background: 'none',
border: 'none',
color: 'var(--foreground)',
cursor: 'pointer',
}}
>
<strong>{user.followingCount}</strong>{' '}
<span style={{ color: 'var(--foreground-tertiary)' }}>Following</span>
</button>
)}
</div>
</div>
</div>
@@ -807,10 +832,7 @@ export default function ProfilePage() {
{/* Tabs */}
<div style={{ display: 'flex', borderTop: '1px solid var(--border)' }}>
{(user?.isBot
? ['posts', 'replies', 'followers', 'following'] as const
: ['posts', 'replies', 'likes', 'followers', 'following'] as const
).map(tab => (
{visibleTabs.map(tab => (
<button
key={tab}
onClick={() => setActiveTab(tab)}
+31 -3
View File
@@ -36,6 +36,13 @@ export function Compose({ onPost, replyingTo, onCancelReply, placeholder = "What
const [isNsfwNode, setIsNsfwNode] = useState(false);
const maxLength = 600;
const remaining = maxLength - content.length;
const previewMedia = linkPreview?.media?.length
? linkPreview.media
: linkPreview?.image
? [{ url: linkPreview.image }]
: [];
const previewImage = previewMedia[0]?.url || linkPreview?.image || null;
const isEmbeddedVideo = Boolean(linkPreview?.url?.match(/(youtube\.com|youtu\.be|vimeo\.com)/));
// Check if user can post NSFW content and if node is NSFW
useEffect(() => {
@@ -210,11 +217,32 @@ export function Compose({ onPost, replyingTo, onCancelReply, placeholder = "What
x
</button>
<VideoEmbed url={linkPreview.url} />
{!linkPreview.url.match(/(youtube\.com|youtu\.be|vimeo\.com)/) && (
{!isEmbeddedVideo && (
<div className="link-preview-card mini">
{linkPreview.image && (
{linkPreview.type === 'video' && linkPreview.videoUrl ? (
<div className="link-preview-image">
<img src={linkPreview.image} alt="" />
<video
src={linkPreview.videoUrl}
poster={previewImage || undefined}
muted
playsInline
preload="metadata"
/>
</div>
) : linkPreview.type === 'gallery' && previewMedia.length > 0 ? (
<div className="link-preview-gallery compact">
{previewMedia.slice(0, 3).map((item: { url: string }, index: number) => (
<div className="link-preview-gallery-item" key={`${item.url}-${index}`}>
<img src={item.url} alt="" />
{index === Math.min(previewMedia.length, 3) - 1 && previewMedia.length > 3 && (
<span className="link-preview-gallery-more">+{previewMedia.length - 3}</span>
)}
</div>
))}
</div>
) : previewImage && (
<div className="link-preview-image">
<img src={previewImage} alt="" />
</div>
)}
<div className="link-preview-info">
+252 -41
View File
@@ -5,7 +5,7 @@ import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { HeartIcon, RepeatIcon, MessageIcon, FlagIcon, TrashIcon } from '@/components/Icons';
import { Bot, MoreHorizontal, UserX, VolumeX, Globe } from 'lucide-react';
import { Post } from '@/lib/types';
import { Post, LinkPreviewMediaItem } from '@/lib/types';
import { useAuth } from '@/lib/contexts/AuthContext';
import { useToast } from '@/lib/contexts/ToastContext';
import { VideoEmbed } from '@/components/VideoEmbed';
@@ -13,6 +13,7 @@ import BlurredVideo from '@/components/BlurredVideo';
import { useFormattedHandle } from '@/lib/utils/handle';
import { useDomain } from '@/lib/contexts/ConfigContext';
import { signedAPI } from '@/lib/api/signed-fetch';
import type { LinkPreviewData } from '@/lib/media/linkPreview';
// Component for link preview image that hides on error
function LinkPreviewImage({ src, alt }: { src: string; alt: string }) {
@@ -31,6 +32,65 @@ function LinkPreviewImage({ src, alt }: { src: string; alt: string }) {
);
}
const EMBED_VIDEO_REGEX = /(youtube\.com|youtu\.be|vimeo\.com)/;
function isPlaceholderPreview(post: Post): boolean {
if (!post.linkPreviewUrl) {
return false;
}
try {
const hostname = new URL(
post.linkPreviewUrl.startsWith('http') ? post.linkPreviewUrl : `https://${post.linkPreviewUrl}`
).hostname.replace(/^www\./, '').toLowerCase();
const title = post.linkPreviewTitle?.trim().toLowerCase() || '';
const hasRichData = Boolean(
post.linkPreviewDescription ||
post.linkPreviewImage ||
post.linkPreviewVideoUrl ||
(post.linkPreviewMedia && post.linkPreviewMedia.length > 0)
);
if (hasRichData) {
return false;
}
return (
!title ||
title === 'reddit' ||
title === hostname ||
title === `www.${hostname}`
);
} catch {
return false;
}
}
function LinkPreviewGallery({
media,
alt,
compact = false,
}: {
media: LinkPreviewMediaItem[];
alt: string;
compact?: boolean;
}) {
const visibleMedia = media.slice(0, compact ? 3 : 4);
return (
<div className={`link-preview-gallery ${compact ? 'compact' : ''}`}>
{visibleMedia.map((item, index) => (
<div className="link-preview-gallery-item" key={`${item.url}-${index}`}>
<img src={item.url} alt={alt} loading="lazy" />
{index === visibleMedia.length - 1 && media.length > visibleMedia.length && (
<span className="link-preview-gallery-more">+{media.length - visibleMedia.length}</span>
)}
</div>
))}
</div>
);
}
interface PostCardProps {
post: Post;
onLike?: (id: string, currentLiked: boolean) => void;
@@ -41,10 +101,11 @@ interface PostCardProps {
isDetail?: boolean;
showThread?: boolean; // Show parent post inline as a thread
isThreadParent?: boolean; // This post is being shown as a parent in a thread
isEmbedded?: boolean;
parentPostAuthorId?: string; // ID of the parent post's author (for allowing deletion of replies)
}
export function PostCard({ post, onLike, onRepost, onComment, onDelete, onHide, isDetail, showThread = true, isThreadParent, parentPostAuthorId }: PostCardProps) {
export function PostCard({ post, onLike, onRepost, onComment, onDelete, onHide, isDetail, showThread = true, isThreadParent, isEmbedded = false, parentPostAuthorId }: PostCardProps) {
const { user: currentUser, did, handle: currentUserHandle, isIdentityUnlocked } = useAuth();
const { showToast } = useToast();
const router = useRouter();
@@ -57,8 +118,25 @@ export function PostCard({ post, onLike, onRepost, onComment, onDelete, onHide,
const [reporting, setReporting] = useState(false);
const [deleting, setDeleting] = useState(false);
const [showMenu, setShowMenu] = useState(false);
const [hydratedPreview, setHydratedPreview] = useState<LinkPreviewData | null>(null);
const domain = useDomain();
const authorHandle = useFormattedHandle(post.author.handle, post.nodeDomain);
const isOwnOrOwnedBotPost = Boolean(
currentUser && (
currentUser.id === post.author.id ||
(post.bot && currentUser.id === post.bot.ownerId) ||
(post.author.id.startsWith('swarm:') && (
post.author.handle === currentUser.handle ||
post.author.handle === `${currentUser.handle}@${domain}`
))
)
);
const canDeletePost = Boolean(
currentUser && (
isOwnOrOwnedBotPost ||
(parentPostAuthorId && currentUser.id === parentPostAuthorId)
)
);
// Sync state if post changes (e.g. after a re-render from parent)
useEffect(() => {
setLiked(post.isLiked || false);
@@ -67,6 +145,47 @@ export function PostCard({ post, onLike, onRepost, onComment, onDelete, onHide,
setRepostsCount(post.repostsCount || 0);
}, [post.isLiked, post.likesCount, post.isReposted, post.repostsCount, post.id]);
useEffect(() => {
let cancelled = false;
const missingPreviewData = Boolean(
post.linkPreviewUrl &&
!post.linkPreviewTitle &&
!post.linkPreviewDescription &&
!post.linkPreviewImage &&
!post.linkPreviewVideoUrl &&
(!post.linkPreviewMedia || post.linkPreviewMedia.length === 0)
);
const placeholderPreviewData = isPlaceholderPreview(post);
if ((!missingPreviewData && !placeholderPreviewData) || !post.linkPreviewUrl) {
setHydratedPreview(null);
return;
}
(async () => {
try {
const res = await fetch(`/api/media/preview?url=${encodeURIComponent(post.linkPreviewUrl!)}`);
if (!res.ok) return;
const data = await res.json();
if (!cancelled) {
setHydratedPreview(data);
}
} catch {
}
})();
return () => {
cancelled = true;
};
}, [
post.linkPreviewUrl,
post.linkPreviewTitle,
post.linkPreviewDescription,
post.linkPreviewImage,
post.linkPreviewVideoUrl,
post.linkPreviewMedia,
]);
const formatTime = (dateStr: string | Date) => {
const date = new Date(dateStr);
@@ -431,11 +550,91 @@ export function PostCard({ post, onLike, onRepost, onComment, onDelete, onHide,
: post.swarmReplyToAuthor)?.nodeDomain,
} as Post : null);
const replyToHandle = effectiveReplyTo?.author?.handle ? useFormattedHandle(effectiveReplyTo.author.handle, effectiveReplyTo.nodeDomain) : '';
const repostHandle = useFormattedHandle(post.author.handle, post.nodeDomain);
const hasOwnContent = decodeHtmlEntities(post.content).trim().length > 0;
const isRepostEvent = Boolean(post.repostOf);
const effectivePreview = {
url: hydratedPreview?.url || post.linkPreviewUrl || null,
title: hydratedPreview?.title || post.linkPreviewTitle || null,
description: hydratedPreview?.description || post.linkPreviewDescription || null,
image: hydratedPreview?.image || post.linkPreviewImage || null,
type: hydratedPreview?.type || post.linkPreviewType || null,
videoUrl: hydratedPreview?.videoUrl || post.linkPreviewVideoUrl || null,
media: hydratedPreview?.media || post.linkPreviewMedia || null,
};
const rawPreviewMedia = (() => {
const mediaJson = (post as Post & { linkPreviewMediaJson?: string | null }).linkPreviewMediaJson;
if (!mediaJson) {
return [];
}
try {
const parsed = JSON.parse(mediaJson);
return Array.isArray(parsed) ? parsed : [];
} catch {
return [];
}
})();
const previewMedia = (effectivePreview.media && effectivePreview.media.length > 0)
? effectivePreview.media
: rawPreviewMedia.length > 0
? rawPreviewMedia
: effectivePreview.image
? [{ url: effectivePreview.image }]
: [];
const previewImage = previewMedia[0]?.url || effectivePreview.image || null;
const isEmbeddedVideo = Boolean(effectivePreview.url && effectivePreview.url.match(EMBED_VIDEO_REGEX));
const isRichVideoPreview = effectivePreview.type === 'video' && Boolean(effectivePreview.videoUrl);
const isGalleryPreview = effectivePreview.type === 'gallery' && previewMedia.length > 1;
const renderLinkPreviewCard = (compact = false) => {
if (!effectivePreview.url || isEmbeddedVideo) {
return null;
}
return (
<a
href={effectivePreview.url}
target="_blank"
rel="noopener noreferrer"
className={`link-preview-card ${compact ? 'mini' : ''}`}
onClick={(e) => e.stopPropagation()}
>
{isRichVideoPreview && effectivePreview.videoUrl ? (
<div className="link-preview-video">
<video
src={effectivePreview.videoUrl}
poster={previewImage || undefined}
controls
playsInline
preload="metadata"
/>
</div>
) : isGalleryPreview ? (
<LinkPreviewGallery
media={previewMedia}
alt={effectivePreview.title || 'Link preview'}
compact={compact}
/>
) : previewImage ? (
<LinkPreviewImage src={previewImage} alt={effectivePreview.title || ''} />
) : null}
<div className="link-preview-info">
<div className="link-preview-title">{effectivePreview.title ? decodeHtmlEntities(effectivePreview.title) : ''}</div>
{effectivePreview.description && (
<div className="link-preview-description">{decodeHtmlEntities(effectivePreview.description)}</div>
)}
<div className="link-preview-url">
{new URL(effectivePreview.url.startsWith('http') ? effectivePreview.url : `https://${effectivePreview.url}`).hostname}
</div>
</div>
</a>
);
};
// If this is a thread parent being rendered, just render the article
if (isThreadParent) {
return (
<article className="post thread-parent">
<article className={`post thread-parent ${isEmbedded ? 'embedded' : ''}`}>
<div className="post-header">
<Link href={`/u/${profileHandle}`} className="avatar-link" onClick={(e) => e.stopPropagation()}>
<div className="avatar">
@@ -458,6 +657,46 @@ export function PostCard({ post, onLike, onRepost, onComment, onDelete, onHide,
);
}
if (isRepostEvent && post.repostOf) {
return (
<>
<article className={`post repost-event ${isDetail ? 'detail' : ''} ${isEmbedded ? 'embedded' : ''}`}>
<div className="repost-event-header">
<span className="repost-event-icon" aria-hidden="true">
<RepeatIcon />
</span>
<span className="repost-event-text">
<Link href={`/u/${profileHandle}`} onClick={(e) => e.stopPropagation()}>
{post.author.displayName || post.author.handle}
</Link>
<span className="repost-event-copy"> reposted</span>
<span className="post-time"> {repostHandle} · {formatTime(post.createdAt)}</span>
</span>
</div>
{hasOwnContent && (
<div className="post-content">{renderContent(post.content, post.linkPreviewUrl ?? undefined)}</div>
)}
<div className="repost-embed">
<PostCard
post={post.repostOf}
onLike={onLike}
onRepost={onRepost}
onComment={onComment}
onDelete={onDelete}
onHide={onHide}
isDetail={isDetail}
showThread={false}
isEmbedded={true}
parentPostAuthorId={parentPostAuthorId}
/>
</div>
</article>
</>
);
}
return (
<>
{/* Show parent post as part of thread - only on detail page */}
@@ -475,7 +714,7 @@ export function PostCard({ post, onLike, onRepost, onComment, onDelete, onHide,
/>
</div>
)}
<article className={`post ${isDetail ? 'detail' : ''}`}>
<article className={`post ${isDetail ? 'detail' : ''} ${isEmbedded ? 'embedded' : ''}`}>
{!isDetail && <Link href={postUrl} className="post-link-overlay" aria-label="View post" />}
<div className="post-header">
@@ -515,7 +754,7 @@ export function PostCard({ post, onLike, onRepost, onComment, onDelete, onHide,
</div>
<span className="post-time">{authorHandle} · {formatTime(post.createdAt)}</span>
</div>
{currentUser && currentUser.id !== post.author.id && (
{currentUser && !isOwnOrOwnedBotPost && (
<div style={{ position: 'relative', marginLeft: 'auto' }}>
<button
className="post-menu-btn"
@@ -671,28 +910,7 @@ export function PostCard({ post, onLike, onRepost, onComment, onDelete, onHide,
<VideoEmbed url={post.linkPreviewUrl} />
)}
{post.linkPreviewUrl && !post.linkPreviewUrl.match(/(youtube\.com|youtu\.be|vimeo\.com)/) && (
<a
href={post.linkPreviewUrl}
target="_blank"
rel="noopener noreferrer"
className="link-preview-card"
onClick={(e) => e.stopPropagation()}
>
{post.linkPreviewImage && (
<LinkPreviewImage src={post.linkPreviewImage} alt={post.linkPreviewTitle || ''} />
)}
<div className="link-preview-info">
<div className="link-preview-title">{post.linkPreviewTitle ? decodeHtmlEntities(post.linkPreviewTitle) : ''}</div>
{post.linkPreviewDescription && (
<div className="link-preview-description">{decodeHtmlEntities(post.linkPreviewDescription)}</div>
)}
<div className="link-preview-url">
{new URL(post.linkPreviewUrl.startsWith('http') ? post.linkPreviewUrl : `https://${post.linkPreviewUrl}`).hostname}
</div>
</div>
</a>
)}
{renderLinkPreviewCard()}
<div className="post-actions">
<button className="post-action" onClick={handleComment}>
@@ -707,20 +925,13 @@ export function PostCard({ post, onLike, onRepost, onComment, onDelete, onHide,
<HeartIcon filled={liked} />
<span>{likesCount || ''}</span>
</button>
<button className="post-action" onClick={handleReport} disabled={reporting}>
<FlagIcon />
<span>{reporting ? '...' : ''}</span>
</button>
{(currentUser && (
currentUser.id === post.author.id ||
(post.bot && currentUser.id === post.bot.ownerId) ||
(parentPostAuthorId && currentUser.id === parentPostAuthorId) ||
// Allow deleting own remote posts where handle might be username@node_domain
(post.author.id.startsWith('swarm:') && (
post.author.handle === currentUser.handle ||
post.author.handle === `${currentUser.handle}@${domain}`
))
)) && (
{!isOwnOrOwnedBotPost && (
<button className="post-action" onClick={handleReport} disabled={reporting}>
<FlagIcon />
<span>{reporting ? '...' : ''}</span>
</button>
)}
{canDeletePost && (
<button className="post-action delete-action" onClick={handleDelete} disabled={deleting} title="Delete post">
<TrashIcon />
<span>{deleting ? '...' : ''}</span>
+244 -26
View File
@@ -1,24 +1,38 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { useState, useEffect, useCallback, useRef } from 'react';
import { createPortal } from 'react-dom';
import Link from 'next/link';
import Image from 'next/image';
import { usePathname, useRouter } from 'next/navigation';
import { useAuth } from '@/lib/contexts/AuthContext';
import { HomeIcon, SearchIcon, BellIcon, UserIcon, ShieldIcon, SettingsIcon, BotIcon } from './Icons';
import { useFormattedHandle } from '@/lib/utils/handle';
import { LogOut, Settings2 } from 'lucide-react';
import { Check, ChevronDown, LogOut, Plus, Settings2 } from 'lucide-react';
import { AuthScreen } from '@/app/login/page';
// import { IdentityUnlockPrompt } from './IdentityUnlockPrompt'; // Moved to LayoutWrapper
function shortHandle(handle: string) {
const cleanHandle = handle.startsWith('@') ? handle.slice(1) : handle;
return `@${cleanHandle.split('@')[0]}`;
}
export function Sidebar() {
const { user, isAdmin, logout } = useAuth();
const { user, accounts, isAdmin, logout, switchAccount } = useAuth();
const pathname = usePathname();
const router = useRouter();
const [customLogoUrl, setCustomLogoUrl] = useState<string | null | undefined>(undefined);
const [unreadCount, setUnreadCount] = useState(0);
const [unreadChatCount, setUnreadChatCount] = useState(0);
const [loggingOut, setLoggingOut] = useState(false);
const formattedHandle = user ? useFormattedHandle(user.handle) : '';
const [switchingAccountId, setSwitchingAccountId] = useState<string | null>(null);
const [accountMenuOpen, setAccountMenuOpen] = useState(false);
const [showAuthModal, setShowAuthModal] = useState(false);
const accountMenuRef = useRef<HTMLDivElement | null>(null);
const accountTriggerRef = useRef<HTMLButtonElement | null>(null);
const accountPopupRef = useRef<HTMLDivElement | null>(null);
const [accountPopupStyle, setAccountPopupStyle] = useState<React.CSSProperties | null>(null);
const formattedHandle = user ? shortHandle(user.handle) : '';
useEffect(() => {
fetch('/api/node')
@@ -91,18 +105,101 @@ export function Sidebar() {
const isHome = pathname === '/';
const handleLogout = async () => {
if (loggingOut) return;
if (loggingOut || !user) return;
setLoggingOut(true);
try {
await logout();
window.location.href = '/explore';
const isLastAccount = accounts.length <= 1;
await logout(user.id);
setAccountMenuOpen(false);
if (isLastAccount) {
window.location.href = '/explore';
} else {
router.refresh();
}
} catch (error) {
console.error('Logout failed:', error);
setLoggingOut(false);
}
};
const handleSwitchAccount = async (userId: string) => {
if (switchingAccountId || userId === user?.id) {
setAccountMenuOpen(false);
return;
}
setSwitchingAccountId(userId);
try {
await switchAccount(userId);
setAccountMenuOpen(false);
router.refresh();
} catch (error) {
console.error('Account switch failed:', error);
} finally {
setSwitchingAccountId(null);
}
};
const updateAccountPopupPosition = useCallback(() => {
if (!accountTriggerRef.current) return;
const rect = accountTriggerRef.current.getBoundingClientRect();
const popupWidth = 320;
const viewportPadding = 16;
const left = Math.min(
rect.left,
window.innerWidth - popupWidth - viewportPadding
);
const bottom = window.innerHeight - rect.top + 12;
setAccountPopupStyle({
position: 'fixed',
left: `${Math.max(viewportPadding, left)}px`,
bottom: `${bottom}px`,
width: `${popupWidth}px`,
maxWidth: `min(${popupWidth}px, calc(100vw - 32px))`,
background: 'rgba(0, 0, 0, 0.96)',
border: '1px solid var(--border)',
borderRadius: '18px',
boxShadow: '0 20px 60px rgba(0, 0, 0, 0.58)',
overflow: 'hidden',
zIndex: 10000,
backdropFilter: 'blur(18px)',
});
}, []);
useEffect(() => {
if (!accountMenuOpen) return;
updateAccountPopupPosition();
const handlePointerDown = (event: MouseEvent) => {
const target = event.target as Node;
const clickedTrigger = accountMenuRef.current?.contains(target);
const clickedPopup = accountPopupRef.current?.contains(target);
if (!clickedTrigger && !clickedPopup) {
setAccountMenuOpen(false);
}
};
const handleWindowChange = () => {
updateAccountPopupPosition();
};
window.addEventListener('mousedown', handlePointerDown);
window.addEventListener('resize', handleWindowChange);
window.addEventListener('scroll', handleWindowChange, true);
return () => {
window.removeEventListener('mousedown', handlePointerDown);
window.removeEventListener('resize', handleWindowChange);
window.removeEventListener('scroll', handleWindowChange, true);
};
}, [accountMenuOpen, updateAccountPopupPosition]);
return (
<aside className="sidebar">
<Link href={user ? "/" : "/explore"} className="logo" style={{ minHeight: '42px' }}>
@@ -196,8 +293,130 @@ export function Sidebar() {
)}
</nav>
{user && (
<div style={{ marginTop: 'auto', paddingTop: '16px' }} className="sidebar-user-info">
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', minWidth: 0, marginBottom: '12px' }}>
<div
ref={accountMenuRef}
style={{ marginTop: 'auto', paddingTop: '16px', position: 'relative' }}
className="sidebar-user-info"
>
{accountMenuOpen && accountPopupStyle && typeof document !== 'undefined' && createPortal(
<div style={{
...accountPopupStyle,
}}>
<div ref={accountPopupRef}>
<div style={{ padding: '8px 0' }}>
{accounts.map((account) => {
const isActive = account.id === user.id;
const isSwitching = switchingAccountId === account.id;
return (
<button
key={account.id}
onClick={() => void handleSwitchAccount(account.id)}
disabled={isSwitching || loggingOut}
style={{
width: '100%',
display: 'flex',
alignItems: 'center',
gap: '14px',
padding: '14px 18px',
background: 'transparent',
color: 'var(--foreground)',
border: 'none',
cursor: 'pointer',
textAlign: 'left',
}}
>
<div className="avatar avatar-sm" style={{ flexShrink: 0 }}>
{account.avatarUrl ? (
<img src={account.avatarUrl} alt={account.displayName || account.handle} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
) : (
(account.displayName?.charAt(0) || account.handle.charAt(0)).toUpperCase()
)}
</div>
<div style={{ minWidth: 0, flex: 1, display: 'grid', gap: '2px' }}>
<div style={{ fontWeight: 700, fontSize: '15px', lineHeight: 1.2, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{account.displayName || account.handle}
</div>
<div style={{ color: 'var(--foreground-secondary)', fontSize: '13px', lineHeight: 1.2, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{shortHandle(account.handle)}
</div>
</div>
<div style={{ width: '24px', display: 'flex', justifyContent: 'center', color: 'var(--accent)', flexShrink: 0 }}>
{isActive ? <Check size={18} /> : isSwitching ? <span style={{ fontSize: '12px' }}>...</span> : null}
</div>
</button>
);
})}
</div>
<div style={{ borderTop: '1px solid var(--border)', padding: '6px 0' }}>
<button
onClick={() => {
setAccountMenuOpen(false);
setShowAuthModal(true);
}}
style={{
width: '100%',
display: 'flex',
alignItems: 'center',
gap: '14px',
padding: '14px 18px',
background: 'transparent',
color: 'var(--foreground)',
border: 'none',
cursor: 'pointer',
textAlign: 'left',
fontWeight: 600,
whiteSpace: 'nowrap',
}}
>
<Plus size={18} style={{ flexShrink: 0 }} />
<span>Add an existing account</span>
</button>
<button
onClick={handleLogout}
disabled={loggingOut}
style={{
width: '100%',
display: 'flex',
alignItems: 'center',
gap: '14px',
padding: '14px 18px',
background: 'transparent',
color: 'var(--foreground)',
border: 'none',
cursor: 'pointer',
textAlign: 'left',
fontWeight: 600,
whiteSpace: 'nowrap',
}}
>
<LogOut size={18} style={{ flexShrink: 0 }} />
<span>{loggingOut ? 'Signing out...' : `Sign out ${formattedHandle}`}</span>
</button>
</div>
</div>
</div>,
document.body
)}
<button
ref={accountTriggerRef}
onClick={() => setAccountMenuOpen(open => !open)}
className="btn btn-ghost"
style={{
width: '100%',
display: 'flex',
alignItems: 'center',
gap: '12px',
minWidth: 0,
padding: '10px 12px',
borderRadius: '16px',
border: '1px solid var(--border)',
background: accountMenuOpen ? 'var(--background-secondary)' : 'transparent',
}}
>
<div className="avatar avatar-sm" style={{ flexShrink: 0 }}>
{user.avatarUrl ? (
<img src={user.avatarUrl} alt={user.displayName} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
@@ -205,31 +424,30 @@ export function Sidebar() {
(user.displayName?.charAt(0) || user.handle.charAt(0)).toUpperCase()
)}
</div>
<div style={{ minWidth: 0, flex: 1 }}>
<div style={{ minWidth: 0, flex: 1, textAlign: 'left' }}>
<div style={{ fontWeight: 600, fontSize: '14px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{user.displayName}</div>
<div style={{ color: 'var(--foreground-tertiary)', fontSize: '13px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{formattedHandle}</div>
</div>
</div>
<button
onClick={handleLogout}
disabled={loggingOut}
className="btn btn-ghost"
style={{
width: '100%',
justifyContent: 'flex-start',
gap: '12px',
padding: '10px 12px',
fontSize: '14px',
}}
>
<LogOut size={20} />
<span>{loggingOut ? 'Signing out...' : 'Sign out'}</span>
<ChevronDown size={18} style={{
transform: accountMenuOpen ? 'rotate(180deg)' : 'rotate(0deg)',
transition: 'transform 0.2s ease',
flexShrink: 0,
}} />
</button>
</div>
)}
{/* Identity Unlock Prompt Modal is now handled in LayoutWrapper */}
{showAuthModal && (
<AuthScreen
modal
onClose={() => setShowAuthModal(false)}
onSuccess={() => {
setShowAuthModal(false);
router.refresh();
}}
/>
)}
</aside>
);
}
+55 -1
View File
@@ -143,6 +143,9 @@ export const posts = pgTable('posts', {
linkPreviewTitle: text('link_preview_title'),
linkPreviewDescription: text('link_preview_description'),
linkPreviewImage: text('link_preview_image'),
linkPreviewType: text('link_preview_type'),
linkPreviewVideoUrl: text('link_preview_video_url'),
linkPreviewMediaJson: text('link_preview_media_json'),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().notNull(),
}, (table) => [
@@ -301,6 +304,9 @@ export const remotePosts = pgTable('remote_posts', {
linkPreviewTitle: text('link_preview_title'),
linkPreviewDescription: text('link_preview_description'),
linkPreviewImage: text('link_preview_image'),
linkPreviewType: text('link_preview_type'),
linkPreviewVideoUrl: text('link_preview_video_url'),
linkPreviewMediaJson: text('link_preview_media_json'),
// Media attachments stored as JSON
mediaJson: text('media_json'), // JSON array of {url, altText}
// Metadata
@@ -374,6 +380,9 @@ export const userSwarmLikes = pgTable('user_swarm_likes', {
linkPreviewTitle: text('link_preview_title'),
linkPreviewDescription: text('link_preview_description'),
linkPreviewImage: text('link_preview_image'),
linkPreviewType: text('link_preview_type'),
linkPreviewVideoUrl: text('link_preview_video_url'),
linkPreviewMediaJson: text('link_preview_media_json'),
mediaJson: text('media_json'),
likedAt: timestamp('liked_at').defaultNow().notNull(),
}, (table) => [
@@ -382,6 +391,38 @@ export const userSwarmLikes = pgTable('user_swarm_likes', {
uniqueIndex('user_swarm_likes_unique').on(table.userId, table.nodeDomain, table.originalPostId),
]);
// ============================================
// USER SWARM REPOSTS (local users reposting remote swarm posts)
// ============================================
export const userSwarmReposts = pgTable('user_swarm_reposts', {
id: uuid('id').primaryKey().defaultRandom(),
userId: uuid('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
nodeDomain: text('node_domain').notNull(),
originalPostId: text('original_post_id').notNull(),
authorHandle: text('author_handle').notNull(),
authorDisplayName: text('author_display_name'),
authorAvatarUrl: text('author_avatar_url'),
content: text('content').notNull(),
postCreatedAt: timestamp('post_created_at').notNull(),
likesCount: integer('likes_count').default(0).notNull(),
repostsCount: integer('reposts_count').default(0).notNull(),
repliesCount: integer('replies_count').default(0).notNull(),
linkPreviewUrl: text('link_preview_url'),
linkPreviewTitle: text('link_preview_title'),
linkPreviewDescription: text('link_preview_description'),
linkPreviewImage: text('link_preview_image'),
linkPreviewType: text('link_preview_type'),
linkPreviewVideoUrl: text('link_preview_video_url'),
linkPreviewMediaJson: text('link_preview_media_json'),
mediaJson: text('media_json'),
repostedAt: timestamp('reposted_at').defaultNow().notNull(),
}, (table) => [
index('user_swarm_reposts_user_idx').on(table.userId, table.repostedAt),
index('user_swarm_reposts_post_idx').on(table.nodeDomain, table.originalPostId),
uniqueIndex('user_swarm_reposts_unique').on(table.userId, table.nodeDomain, table.originalPostId),
]);
// ============================================
// REMOTE REPOSTS (reposts from federated users on local posts)
// ============================================
@@ -411,6 +452,12 @@ export const notifications = pgTable('notifications', {
actorDisplayName: text('actor_display_name'),
actorAvatarUrl: text('actor_avatar_url'),
actorNodeDomain: text('actor_node_domain'), // null for local actors
// Target info - used for owner-shadow notifications like interactions with owned bots
targetHandle: text('target_handle'),
targetDisplayName: text('target_display_name'),
targetAvatarUrl: text('target_avatar_url'),
targetNodeDomain: text('target_node_domain'),
targetIsBot: boolean('target_is_bot'),
// Post reference
postId: uuid('post_id').references(() => posts.id, { onDelete: 'cascade' }),
postContent: text('post_content'), // Cached content for display
@@ -586,8 +633,9 @@ export const bots = pgTable('bots', {
personalityConfig: text('personality_config').notNull(), // JSON
// LLM configuration
llmProvider: text('llm_provider').notNull(), // openrouter, openai, anthropic
llmProvider: text('llm_provider').notNull(), // openrouter, openai, anthropic, custom
llmModel: text('llm_model').notNull(),
llmEndpoint: text('llm_endpoint'),
llmApiKeyEncrypted: text('llm_api_key_encrypted').notNull(),
// Scheduling
@@ -840,6 +888,11 @@ export const swarmNodes = pgTable('swarm_nodes', {
// Trust/reputation (for future spam prevention)
trustScore: integer('trust_score').default(50).notNull(), // 0-100
// Admin moderation
isBlocked: boolean('is_blocked').default(false).notNull(),
blockReason: text('block_reason'),
blockedAt: timestamp('blocked_at'),
// Capabilities
capabilities: text('capabilities'), // JSON array: ["handles", "gossip", "relay"]
@@ -851,6 +904,7 @@ export const swarmNodes = pgTable('swarm_nodes', {
index('swarm_nodes_last_seen_idx').on(table.lastSeenAt),
index('swarm_nodes_trust_idx').on(table.trustScore),
index('swarm_nodes_nsfw_idx').on(table.isNsfw),
index('swarm_nodes_blocked_idx').on(table.isBlocked),
]);
/**
+174 -30
View File
@@ -3,7 +3,7 @@
*/
import { db, users, sessions } from '@/db';
import { eq } from 'drizzle-orm';
import { eq, inArray } from 'drizzle-orm';
import bcrypt from 'bcryptjs';
import { v4 as uuid } from 'uuid';
import { generateKeyPair } from '@/lib/crypto/keys';
@@ -13,8 +13,107 @@ import { cookies } from 'next/headers';
import { upsertHandleEntries } from '@/lib/federation/handles';
import { generateAndUploadAvatarToUserStorage } from '@/lib/storage/s3';
const SESSION_COOKIE_NAME = 'synapsis_session';
const SESSION_EXPIRY_DAYS = 30;
const ACTIVE_SESSION_COOKIE_NAME = 'synapsis_session';
const SESSION_COOKIE_NAME = 'synapsis_sessions';
const SESSION_EXPIRY_DAYS = 3650;
type SessionRecord = typeof sessions.$inferSelect & {
user: typeof users.$inferSelect;
};
export interface AuthAccount {
id: string;
handle: string;
displayName: string | null;
avatarUrl: string | null;
did: string;
publicKey: string;
privateKeyEncrypted: string | null;
email: string | null;
isActive: boolean;
}
function parseSessionCookie(value: string | undefined): string[] {
if (!value) {
return [];
}
return value
.split(',')
.map(token => token.trim())
.filter(Boolean);
}
async function readSessionState() {
const cookieStore = await cookies();
return {
cookieStore,
activeToken: cookieStore.get(ACTIVE_SESSION_COOKIE_NAME)?.value ?? null,
tokens: parseSessionCookie(cookieStore.get(SESSION_COOKIE_NAME)?.value),
};
}
async function writeSessionState(tokens: string[], activeToken?: string | null) {
const cookieStore = await cookies();
const dedupedTokens = [...new Set(tokens.filter(Boolean))];
if (dedupedTokens.length === 0) {
cookieStore.delete(ACTIVE_SESSION_COOKIE_NAME);
cookieStore.delete(SESSION_COOKIE_NAME);
return;
}
const expiresAt = new Date();
expiresAt.setDate(expiresAt.getDate() + SESSION_EXPIRY_DAYS);
const nextActiveToken = activeToken && dedupedTokens.includes(activeToken)
? activeToken
: dedupedTokens[0];
const cookieOptions = {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax' as const,
expires: expiresAt,
path: '/',
};
cookieStore.set(ACTIVE_SESSION_COOKIE_NAME, nextActiveToken, cookieOptions);
cookieStore.set(SESSION_COOKIE_NAME, dedupedTokens.join(','), cookieOptions);
}
async function loadSessionsByTokens(tokens: string[]): Promise<SessionRecord[]> {
const uniqueTokens = [...new Set(tokens.filter(Boolean))];
if (uniqueTokens.length === 0) {
return [];
}
const sessionRecords = await db.query.sessions.findMany({
where: inArray(sessions.token, uniqueTokens),
with: {
user: true,
},
});
const sessionMap = new Map(sessionRecords.map(session => [session.token, session]));
return uniqueTokens
.map(token => sessionMap.get(token))
.filter((session): session is SessionRecord => Boolean(session));
}
function toAuthAccount(session: SessionRecord, activeToken: string | null): AuthAccount {
return {
id: session.user.id,
handle: session.user.handle,
displayName: session.user.displayName,
avatarUrl: session.user.avatarUrl,
did: session.user.did,
publicKey: session.user.publicKey,
privateKeyEncrypted: session.user.privateKeyEncrypted,
email: session.user.email,
isActive: session.token === activeToken,
};
}
/**
* Hash a password
@@ -55,6 +154,16 @@ export function generateLegacyDID(): string {
* Create a new session for a user
*/
export async function createSession(userId: string): Promise<string> {
const { tokens } = await readSessionState();
const existingSessions = await loadSessionsByTokens(tokens);
const existingUserTokens = existingSessions
.filter(session => session.userId === userId)
.map(session => session.token);
if (existingUserTokens.length > 0) {
await db.delete(sessions).where(inArray(sessions.token, existingUserTokens));
}
const token = uuid();
const expiresAt = new Date();
expiresAt.setDate(expiresAt.getDate() + SESSION_EXPIRY_DAYS);
@@ -65,15 +174,8 @@ export async function createSession(userId: string): Promise<string> {
expiresAt,
});
// Set the session cookie
const cookieStore = await cookies();
cookieStore.set(SESSION_COOKIE_NAME, token, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
expires: expiresAt,
path: '/',
});
const filteredTokens = tokens.filter(existingToken => !existingUserTokens.includes(existingToken));
await writeSessionState([token, ...filteredTokens], token);
return token;
}
@@ -82,25 +184,50 @@ export async function createSession(userId: string): Promise<string> {
* Get the current session from cookies
*/
export async function getSession(): Promise<{ user: typeof users.$inferSelect } | null> {
const cookieStore = await cookies();
const token = cookieStore.get(SESSION_COOKIE_NAME)?.value;
const { activeToken, tokens } = await readSessionState();
const sessionRecords = await loadSessionsByTokens(tokens);
if (!token) {
if (sessionRecords.length === 0) {
await writeSessionState([], null);
return null;
}
const session = await db.query.sessions.findFirst({
where: eq(sessions.token, token),
with: {
user: true,
},
});
const activeSession = sessionRecords.find(session => session.token === activeToken) ?? sessionRecords[0];
await writeSessionState(sessionRecords.map(session => session.token), activeSession.token);
if (!session || session.expiresAt < new Date()) {
return null;
return { user: activeSession.user };
}
export async function getSessionAccounts(): Promise<AuthAccount[]> {
const { activeToken, tokens } = await readSessionState();
const sessionRecords = await loadSessionsByTokens(tokens);
if (sessionRecords.length === 0) {
await writeSessionState([], null);
return [];
}
return { user: session.user };
const resolvedActiveToken = sessionRecords.some(session => session.token === activeToken)
? activeToken
: sessionRecords[0].token;
await writeSessionState(sessionRecords.map(session => session.token), resolvedActiveToken);
return sessionRecords.map(session => toAuthAccount(session, resolvedActiveToken));
}
export async function switchSession(userId: string): Promise<{ user: typeof users.$inferSelect }> {
const { tokens } = await readSessionState();
const sessionRecords = await loadSessionsByTokens(tokens);
const matchingSession = sessionRecords.find(session => session.user.id === userId);
if (!matchingSession) {
throw new Error('Session not found');
}
await writeSessionState(sessionRecords.map(session => session.token), matchingSession.token);
return { user: matchingSession.user };
}
/**
@@ -119,14 +246,31 @@ export async function requireAuth(): Promise<typeof users.$inferSelect> {
/**
* Destroy the current session
*/
export async function destroySession(): Promise<void> {
const cookieStore = await cookies();
const token = cookieStore.get(SESSION_COOKIE_NAME)?.value;
export async function destroySession(userId?: string): Promise<void> {
const { activeToken, tokens } = await readSessionState();
const sessionRecords = await loadSessionsByTokens(tokens);
if (token) {
await db.delete(sessions).where(eq(sessions.token, token));
cookieStore.delete(SESSION_COOKIE_NAME);
if (sessionRecords.length === 0) {
await writeSessionState([], null);
return;
}
const targetSession = userId
? sessionRecords.find(session => session.user.id === userId)
: sessionRecords.find(session => session.token === activeToken) ?? sessionRecords[0];
if (!targetSession) {
return;
}
await db.delete(sessions).where(eq(sessions.token, targetSession.token));
const remainingSessions = sessionRecords.filter(session => session.token !== targetSession.token);
const nextActiveToken = targetSession.token === activeToken
? remainingSessions[0]?.token ?? null
: activeToken;
await writeSessionState(remainingSessions.map(session => session.token), nextActiveToken);
}
/**
+3 -2
View File
@@ -12,7 +12,7 @@ import { db, botContentItems, bots } from '@/db';
import { eq, and } from 'drizzle-orm';
import { ContentGenerator, type Bot as ContentGeneratorBot, type ContentItem } from './contentGenerator';
import { canPost } from './rateLimiter';
import { decryptApiKey, deserializeEncryptedData } from './encryption';
import { decryptApiKey, deserializeEncryptedData, type LLMProvider } from './encryption';
import { parseScheduleConfig, isDue } from './scheduler';
import { triggerPost } from './posting';
@@ -213,8 +213,9 @@ function toContentGeneratorBot(bot: typeof bots.$inferSelect, handle: string): C
name: bot.name,
handle: handle,
personalityConfig: JSON.parse(bot.personalityConfig),
llmProvider: bot.llmProvider as 'openrouter' | 'openai' | 'anthropic',
llmProvider: bot.llmProvider as LLMProvider,
llmModel: bot.llmModel,
llmEndpoint: bot.llmEndpoint,
llmApiKeyEncrypted: bot.llmApiKeyEncrypted,
};
}
+95 -9
View File
@@ -15,6 +15,7 @@ import { generateAndUploadAvatarToUserStorage } from '@/lib/storage/s3';
import { decryptPrivateKey, deserializeEncryptedKey } from '@/lib/crypto/private-key';
import { eq, and, count } from 'drizzle-orm';
import { generateKeyPair } from '@/lib/crypto/keys';
import { isIP } from 'net';
import {
encryptApiKey,
decryptApiKey,
@@ -52,6 +53,7 @@ export interface BotCreateInput {
personality: PersonalityConfig;
llmProvider: LLMProvider;
llmModel: string;
llmEndpoint?: string;
llmApiKey: string;
schedule?: ScheduleConfig;
autonomousMode?: boolean;
@@ -65,6 +67,7 @@ export interface BotUpdateInput {
personality?: PersonalityConfig;
llmProvider?: LLMProvider;
llmModel?: string;
llmEndpoint?: string | null;
llmApiKey?: string;
schedule?: ScheduleConfig | null;
autonomousMode?: boolean;
@@ -83,6 +86,7 @@ export interface Bot {
personalityConfig: PersonalityConfig;
llmProvider: LLMProvider;
llmModel: string;
llmEndpoint?: string | null;
scheduleConfig: ScheduleConfig | null;
autonomousMode: boolean;
isActive: boolean;
@@ -248,6 +252,65 @@ export function validateScheduleConfig(config: ScheduleConfig): void {
}
}
function isPrivateHostname(hostname: string): boolean {
const normalized = hostname.toLowerCase();
if (
normalized === 'localhost' ||
normalized.endsWith('.localhost') ||
normalized.endsWith('.local')
) {
return true;
}
const ipVersion = isIP(normalized);
if (ipVersion === 4) {
const octets = normalized.split('.').map(Number);
const [first, second] = octets;
return (
first === 10 ||
first === 127 ||
(first === 169 && second === 254) ||
(first === 172 && second >= 16 && second <= 31) ||
(first === 192 && second === 168)
);
}
if (ipVersion === 6) {
return normalized === '::1' || normalized.startsWith('fc') || normalized.startsWith('fd') || normalized.startsWith('fe80:');
}
return false;
}
export function normalizeAndValidateLlmEndpoint(endpoint: string): string {
if (!endpoint || typeof endpoint !== 'string') {
throw new BotValidationError('Custom endpoint is required');
}
let parsed: URL;
try {
parsed = new URL(endpoint.trim());
} catch {
throw new BotValidationError('Custom endpoint must be a valid URL');
}
if (parsed.protocol !== 'https:') {
throw new BotValidationError('Custom endpoint must use HTTPS');
}
if (parsed.username || parsed.password) {
throw new BotValidationError('Custom endpoint cannot include embedded credentials');
}
if (isPrivateHostname(parsed.hostname)) {
throw new BotValidationError('Custom endpoint must be publicly reachable and cannot target localhost or a private network');
}
return parsed.toString();
}
// ============================================
// HELPER FUNCTIONS
// ============================================
@@ -269,6 +332,7 @@ function dbBotToBot(dbBot: typeof bots.$inferSelect, botUser: typeof users.$infe
personalityConfig: JSON.parse(dbBot.personalityConfig) as PersonalityConfig,
llmProvider: dbBot.llmProvider as LLMProvider,
llmModel: dbBot.llmModel,
llmEndpoint: dbBot.llmEndpoint,
scheduleConfig: dbBot.scheduleConfig ? JSON.parse(dbBot.scheduleConfig) as ScheduleConfig : null,
autonomousMode: dbBot.autonomousMode,
isActive: dbBot.isActive,
@@ -308,6 +372,10 @@ export async function createBot(ownerId: string, config: BotCreateInput): Promis
if (config.schedule) {
validateScheduleConfig(config.schedule);
}
const normalizedEndpoint = config.llmProvider === 'custom'
? normalizeAndValidateLlmEndpoint(config.llmEndpoint || '')
: null;
// Validate API key format
const apiKeyValidation = validateApiKeyFormat(config.llmApiKey, config.llmProvider);
@@ -391,6 +459,7 @@ export async function createBot(ownerId: string, config: BotCreateInput): Promis
personalityConfig: JSON.stringify(config.personality),
llmProvider: config.llmProvider,
llmModel: config.llmModel,
llmEndpoint: normalizedEndpoint,
llmApiKeyEncrypted: serializeEncryptedData(encryptedApiKey),
scheduleConfig: config.schedule ? JSON.stringify(config.schedule) : null,
autonomousMode: config.autonomousMode ?? false,
@@ -443,15 +512,7 @@ export async function updateBot(botId: string, config: BotUpdateInput): Promise<
if (config.schedule !== undefined && config.schedule !== null) {
validateScheduleConfig(config.schedule);
}
// Validate API key if provided
if (config.llmApiKey !== undefined && config.llmProvider !== undefined) {
const apiKeyValidation = validateApiKeyFormat(config.llmApiKey, config.llmProvider);
if (!apiKeyValidation.valid) {
throw new BotValidationError(apiKeyValidation.error || 'Invalid API key');
}
}
// Check if bot exists and get its user account
const existingBot = await db.query.bots.findFirst({
where: eq(bots.id, botId),
@@ -469,6 +530,25 @@ export async function updateBot(botId: string, config: BotUpdateInput): Promise<
if (!botUser) {
throw new BotNotFoundError(botId);
}
const targetProvider = (config.llmProvider ?? existingBot.llmProvider) as LLMProvider;
if (targetProvider === 'custom') {
const endpointToValidate = config.llmEndpoint !== undefined ? config.llmEndpoint : existingBot.llmEndpoint;
if (!endpointToValidate) {
throw new BotValidationError('Custom endpoint is required');
}
config.llmEndpoint = normalizeAndValidateLlmEndpoint(endpointToValidate);
} else if (config.llmEndpoint !== undefined || config.llmProvider !== undefined) {
config.llmEndpoint = null;
}
if (config.llmApiKey !== undefined) {
const apiKeyValidation = validateApiKeyFormat(config.llmApiKey, targetProvider);
if (!apiKeyValidation.valid) {
throw new BotValidationError(apiKeyValidation.error || 'Invalid API key');
}
}
// Build update object for bot config
const botUpdateData: Partial<typeof bots.$inferInsert> = {
@@ -508,6 +588,12 @@ export async function updateBot(botId: string, config: BotUpdateInput): Promise<
if (config.llmModel !== undefined) {
botUpdateData.llmModel = config.llmModel;
}
if (config.llmEndpoint !== undefined) {
botUpdateData.llmEndpoint = config.llmEndpoint;
} else if (config.llmProvider !== undefined && config.llmProvider !== 'custom') {
botUpdateData.llmEndpoint = null;
}
if (config.llmApiKey !== undefined) {
const encryptedApiKey = encryptApiKey(config.llmApiKey);
+84
View File
@@ -12,6 +12,8 @@ import {
calculateBackoffDelay,
shouldRetrySource,
isSourceDueForFetch,
shouldExcludeRedditPost,
fetchRedditPosts,
BASE_BACKOFF_DELAY_MS,
MAX_BACKOFF_DELAY_MS,
MAX_CONSECUTIVE_ERRORS,
@@ -195,6 +197,88 @@ describe('isSourceDueForFetch', () => {
});
describe('Reddit filtering', () => {
const originalFetch = global.fetch;
afterEach(() => {
global.fetch = originalFetch;
vi.restoreAllMocks();
});
it('excludes stickied and pinned reddit posts', () => {
expect(shouldExcludeRedditPost({
id: 't3_stickied',
title: 'Community thread',
permalink: '/r/test/comments/stickied/community_thread/',
url: 'https://reddit.com/r/test/comments/stickied/community_thread/',
created_utc: 1710000000,
stickied: true,
})).toBe(true);
expect(shouldExcludeRedditPost({
id: 't3_pinned',
title: 'Pinned thread',
permalink: '/r/test/comments/pinned/pinned_thread/',
url: 'https://reddit.com/r/test/comments/pinned/pinned_thread/',
created_utc: 1710000000,
pinned: true,
})).toBe(true);
expect(shouldExcludeRedditPost({
id: 't3_normal',
title: 'Normal post',
permalink: '/r/test/comments/normal/normal_post/',
url: 'https://reddit.com/r/test/comments/normal/normal_post/',
created_utc: 1710000000,
})).toBe(false);
});
it('fetchRedditPosts removes community highlights before returning items', async () => {
const payload = {
data: {
children: [
{
data: {
id: 'abc123',
name: 't3_abc123',
title: 'Community Highlights',
selftext: 'Pinned moderator thread',
permalink: '/r/test/comments/abc123/community_highlights/',
url: 'https://www.reddit.com/r/test/comments/abc123/community_highlights/',
created_utc: 1710000000,
stickied: true,
},
},
{
data: {
id: 'def456',
name: 't3_def456',
title: 'Actual content post',
selftext: 'Useful content',
permalink: '/r/test/comments/def456/actual_content_post/',
url: 'https://example.com/article',
created_utc: 1710000100,
stickied: false,
pinned: false,
},
},
],
},
};
global.fetch = vi.fn().mockResolvedValue({
ok: true,
text: async () => JSON.stringify(payload),
} as Response);
const items = await fetchRedditPosts('test', { maxItems: 10 });
expect(items).toHaveLength(1);
expect(items[0]?.title).toBe('Actual content post');
expect(items[0]?.id).toBe('t3_def456');
});
});
// ============================================
// BACKOFF DELAY PROPERTY TESTS
// ============================================
+57 -8
View File
@@ -41,6 +41,7 @@ export interface ContentItemInput {
title: string;
content: string | null;
url: string;
url_overridden_by_dest?: string;
publishedAt: Date;
}
@@ -325,16 +326,39 @@ export async function fetchRedditPosts(
subreddit: string,
options: FetchOptions = {}
): Promise<FeedItem[]> {
// Use RSS feed instead of JSON API - more reliable and doesn't require auth
const rssUrl = `https://www.reddit.com/r/${subreddit}/hot.rss`;
const requestedMaxItems = options.maxItems ?? DEFAULT_MAX_ITEMS;
const fetchLimit = Math.min(Math.max(requestedMaxItems + 10, requestedMaxItems), 100);
const url = new URL(`${REDDIT_API_BASE}/r/${subreddit}/hot.json`);
url.searchParams.set('raw_json', '1');
url.searchParams.set('limit', String(fetchLimit));
const responseText = await fetchUrl(url.toString(), {
timeout: options.timeout,
headers: {
'Accept': 'application/json',
},
});
let response: RedditListingResponse;
try {
return await fetchRSSFeed(rssUrl, options);
} catch (error) {
// If RSS fails, try the old.reddit.com RSS which sometimes works better
const oldRedditUrl = `https://old.reddit.com/r/${subreddit}/hot.rss`;
return await fetchRSSFeed(oldRedditUrl, options);
response = JSON.parse(responseText) as RedditListingResponse;
} catch {
throw new ParseError('Failed to parse Reddit JSON response');
}
const posts = response.data?.children
?.map((child) => child.data)
.filter((post): post is RedditListingChildData => Boolean(post)) || [];
const filteredPosts = posts.filter((post) => !shouldExcludeRedditPost(post));
return filteredPosts.slice(0, requestedMaxItems).map((post): FeedItem => ({
id: post.name || post.id,
title: post.title,
content: post.selftext || '',
url: `${REDDIT_API_BASE}${post.permalink}`,
publishedAt: new Date(post.created_utc * 1000),
}));
}
// ============================================
@@ -384,6 +408,31 @@ interface BraveNewsResponse {
results?: BraveNewsResult[];
}
interface RedditListingChildData {
id: string;
name?: string;
title: string;
selftext?: string;
permalink: string;
url: string;
url_overridden_by_dest?: string;
created_utc: number;
stickied?: boolean;
pinned?: boolean;
}
interface RedditListingResponse {
data?: {
children?: Array<{
data?: RedditListingChildData;
}>;
};
}
export function shouldExcludeRedditPost(post: RedditListingChildData): boolean {
return Boolean(post.stickied || post.pinned);
}
/**
* Fetch articles from a news API.
*
+3
View File
@@ -26,6 +26,7 @@ export interface Bot {
personalityConfig: PersonalityConfig;
llmProvider: LLMProvider;
llmModel: string;
llmEndpoint?: string | null;
llmApiKeyEncrypted: string;
}
@@ -35,6 +36,7 @@ export interface Bot {
export interface ContentItem {
id: string;
sourceId: string;
externalId?: string;
title: string;
content: string | null;
url: string;
@@ -467,6 +469,7 @@ export class ContentGenerator {
provider: bot.llmProvider,
apiKey: bot.llmApiKeyEncrypted,
model: bot.llmModel,
endpoint: bot.llmEndpoint,
});
}
+4 -3
View File
@@ -13,7 +13,7 @@ import * as crypto from 'crypto';
// TYPES
// ============================================
export type LLMProvider = 'openrouter' | 'openai' | 'anthropic';
export type LLMProvider = 'openrouter' | 'openai' | 'anthropic' | 'custom';
export interface EncryptedData {
encrypted: string; // Base64 encoded ciphertext + auth tag
@@ -40,6 +40,7 @@ const API_KEY_PATTERNS: Record<LLMProvider, RegExp> = {
openrouter: /^sk-or-[a-zA-Z0-9_-]+$/,
anthropic: /^sk-ant-[a-zA-Z0-9_-]+$/,
openai: /^sk-[a-zA-Z0-9_-]+$/,
custom: /^[\s\S]+$/,
};
/**
@@ -330,7 +331,7 @@ export function validateAndDetectApiKey(apiKey: string): ApiKeyValidationResult
* @returns True if the provider is supported
*/
export function isSupportedProvider(provider: string): provider is LLMProvider {
return provider === 'openrouter' || provider === 'openai' || provider === 'anthropic';
return provider === 'openrouter' || provider === 'openai' || provider === 'anthropic' || provider === 'custom';
}
/**
@@ -339,5 +340,5 @@ export function isSupportedProvider(provider: string): provider is LLMProvider {
* @returns Array of supported provider names
*/
export function getSupportedProviders(): LLMProvider[] {
return ['openrouter', 'openai', 'anthropic'];
return ['openrouter', 'openai', 'anthropic', 'custom'];
}
+31 -6
View File
@@ -24,6 +24,7 @@ export interface LLMConfig {
provider: LLMProvider;
apiKey: string; // Encrypted before storage
model: string;
endpoint?: string | null;
}
/**
@@ -123,6 +124,7 @@ export const PROVIDER_ENDPOINTS: Record<LLMProvider, string> = {
openrouter: 'https://openrouter.ai/api/v1/chat/completions',
openai: 'https://api.openai.com/v1/chat/completions',
anthropic: 'https://api.anthropic.com/v1/messages',
custom: '',
};
/**
@@ -132,6 +134,7 @@ export const DEFAULT_MODELS: Record<LLMProvider, string> = {
openrouter: 'openai/gpt-3.5-turbo',
openai: 'gpt-3.5-turbo',
anthropic: 'claude-3-haiku-20240307',
custom: 'gpt-4o-mini',
};
// ============================================
@@ -299,7 +302,8 @@ export function parseOpenRouterResponse(
*/
export function parseOpenAIResponse(
data: Record<string, unknown>,
model: string
model: string,
provider: LLMProvider = 'openai'
): LLMCompletionResponse {
const choices = data.choices as Array<{ message: { content: string } }>;
const usage = data.usage as { prompt_tokens: number; completion_tokens: number; total_tokens: number };
@@ -312,7 +316,7 @@ export function parseOpenAIResponse(
total: usage?.total_tokens ?? 0,
},
model: (data.model as string) ?? model,
provider: 'openai',
provider,
};
}
@@ -359,6 +363,7 @@ export function buildHeaders(provider: LLMProvider, apiKey: string): Record<stri
headers['X-Title'] = 'Synapsis Bot';
break;
case 'openai':
case 'custom':
headers['Authorization'] = `Bearer ${apiKey}`;
break;
case 'anthropic':
@@ -386,6 +391,7 @@ export class LLMClient {
private provider: LLMProvider;
private apiKey: string;
private model: string;
private endpoint: string | null;
private retryConfig: RetryConfig;
private timeoutMs: number;
@@ -403,6 +409,7 @@ export class LLMClient {
) {
this.provider = config.provider;
this.model = config.model || DEFAULT_MODELS[config.provider];
this.endpoint = config.endpoint || null;
this.retryConfig = retryConfig;
this.timeoutMs = timeoutMs;
@@ -480,7 +487,16 @@ export class LLMClient {
* @throws LLMClientError if the request fails
*/
private async makeRequest(request: LLMCompletionRequest): Promise<LLMCompletionResponse> {
const endpoint = PROVIDER_ENDPOINTS[this.provider];
const endpoint = this.provider === 'custom' ? this.endpoint : PROVIDER_ENDPOINTS[this.provider];
if (!endpoint) {
throw new LLMClientError(
'Custom provider requires an endpoint',
'INVALID_REQUEST',
this.provider,
undefined,
false
);
}
const headers = buildHeaders(this.provider, this.apiKey);
const body = this.buildRequestBody(request);
@@ -566,6 +582,7 @@ export class LLMClient {
case 'openrouter':
return buildOpenRouterRequest(request, this.model);
case 'openai':
case 'custom':
return buildOpenAIRequest(request, this.model);
case 'anthropic':
return buildAnthropicRequest(request, this.model);
@@ -580,7 +597,9 @@ export class LLMClient {
case 'openrouter':
return parseOpenRouterResponse(data, this.model);
case 'openai':
return parseOpenAIResponse(data, this.model);
return parseOpenAIResponse(data, this.model, 'openai');
case 'custom':
return parseOpenAIResponse(data, this.model, 'custom');
case 'anthropic':
return parseAnthropicResponse(data, this.model);
}
@@ -618,12 +637,14 @@ export function createLLMClient(
export function createLLMClientFromBot(
provider: LLMProvider,
encryptedApiKey: string,
model: string
model: string,
endpoint?: string | null
): LLMClient {
return new LLMClient({
provider,
apiKey: encryptedApiKey,
model,
endpoint,
});
}
@@ -643,7 +664,7 @@ export function validateLLMConfig(config: unknown): { valid: boolean; errors: st
const configObj = config as Record<string, unknown>;
// Validate provider
const validProviders: LLMProvider[] = ['openrouter', 'openai', 'anthropic'];
const validProviders: LLMProvider[] = ['openrouter', 'openai', 'anthropic', 'custom'];
if (!configObj.provider || !validProviders.includes(configObj.provider as LLMProvider)) {
errors.push(`Provider must be one of: ${validProviders.join(', ')}`);
}
@@ -657,6 +678,10 @@ export function validateLLMConfig(config: unknown): { valid: boolean; errors: st
if (configObj.model !== undefined && typeof configObj.model !== 'string') {
errors.push('Model must be a string');
}
if (configObj.provider === 'custom' && (!configObj.endpoint || typeof configObj.endpoint !== 'string')) {
errors.push('Endpoint is required for custom provider');
}
return { valid: errors.length === 0, errors };
}
+1
View File
@@ -446,6 +446,7 @@ export async function processMention(mentionId: string): Promise<MentionResponse
personalityConfig: JSON.parse(bot.personalityConfig),
llmProvider: bot.llmProvider as any,
llmModel: bot.llmModel,
llmEndpoint: bot.llmEndpoint,
llmApiKeyEncrypted: bot.llmApiKeyEncrypted,
};
+67 -103
View File
@@ -7,12 +7,15 @@
* Requirements: 5.4, 11.5, 11.6
*/
import { db, posts, users, bots, botContentItems, botActivityLogs } from '@/db';
import { db, posts, users, bots, botContentItems, botActivityLogs, botContentSources } from '@/db';
import { eq, and, inArray } from 'drizzle-orm';
import { ContentGenerator, type Bot as ContentGeneratorBot, type ContentItem } from './contentGenerator';
import { canPost, recordPost } from './rateLimiter';
import { getBotById } from './botManager';
import { decryptApiKey, deserializeEncryptedData } from './encryption';
import { decryptApiKey, deserializeEncryptedData, type LLMProvider } from './encryption';
import { fetchRedditRichPreview } from '@/lib/media/redditPreview';
import type { LinkPreviewData } from '@/lib/media/linkPreview';
import { fetchGenericLinkPreview } from '@/lib/media/genericPreview';
// ============================================
// TYPES
@@ -139,8 +142,9 @@ function toContentGeneratorBot(bot: typeof bots.$inferSelect & { user: { handle:
name: bot.name,
handle: bot.user.handle,
personalityConfig: JSON.parse(bot.personalityConfig),
llmProvider: bot.llmProvider as 'openrouter' | 'openai' | 'anthropic',
llmProvider: bot.llmProvider as LLMProvider,
llmModel: bot.llmModel,
llmEndpoint: bot.llmEndpoint,
llmApiKeyEncrypted: bot.llmApiKeyEncrypted,
};
}
@@ -175,6 +179,7 @@ async function getContentItemById(contentItemId: string): Promise<ContentItem |
return {
id: item.id,
sourceId: item.sourceId,
externalId: item.externalId,
title: item.title,
content: item.content,
url: item.url,
@@ -250,6 +255,7 @@ async function getNextUnprocessedContentItem(botId: string): Promise<ContentItem
return {
id: item.id,
sourceId: item.sourceId,
externalId: item.externalId,
title: item.title,
content: item.content,
url: item.url,
@@ -630,62 +636,8 @@ function isRedditUrl(url: string): boolean {
* Fetch link preview for Reddit URLs using their oEmbed API.
* Reddit blocks regular scraping but provides oEmbed for embedding.
*/
async function fetchRedditPreview(url: string): Promise<{
url: string;
title: string | null;
description: string | null;
image: string | null;
} | null> {
try {
// Reddit's oEmbed endpoint
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) {
console.log(`Reddit oEmbed returned ${response.status} for ${url}`);
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) {
// Try to extract title from the embed HTML
const titleMatch = data.html.match(/href="[^"]+">([^<]+)<\/a>/);
if (titleMatch && titleMatch[1] && titleMatch[1] !== 'Comment') {
title = titleMatch[1];
}
}
// Build description from subreddit info if available
let description = null;
if (data.author_name) {
description = `Posted by ${data.author_name}`;
} else if (data.html) {
// Try to extract subreddit from 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 (error) {
console.error('Failed to fetch Reddit oEmbed preview:', error);
return null;
}
async function fetchRedditPreview(url: string): Promise<LinkPreviewData | null> {
return fetchRedditRichPreview(url);
}
/**
@@ -695,12 +647,7 @@ async function fetchRedditPreview(url: string): Promise<{
* @param url - The URL to fetch preview for
* @returns Link preview data or null
*/
async function fetchLinkPreview(url: string): Promise<{
url: string;
title: string | null;
description: string | null;
image: string | null;
} | null> {
async function fetchLinkPreview(url: string): Promise<LinkPreviewData | null> {
// Use Reddit-specific handler
if (isRedditUrl(url)) {
return fetchRedditPreview(url);
@@ -708,48 +655,61 @@ async function fetchLinkPreview(url: string): Promise<{
// Generic OG tag scraping for other sites
try {
const 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),
});
if (!response.ok) {
return null;
}
const html = await response.text();
// Simple regex extraction for OG tags
const getMeta = (property: string): string | null => {
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 {
url,
title: title?.trim() || null,
description: description?.trim() || null,
image: image?.trim() || null,
};
return await fetchGenericLinkPreview(url);
} catch (error) {
console.error('Failed to fetch link preview:', error);
return null;
}
}
function extractRedditPostId(contentItem: ContentItem): string | null {
if (contentItem.externalId) {
const normalizedExternalId = contentItem.externalId.replace(/^t3_/, '');
if (/^[a-z0-9]+$/i.test(normalizedExternalId)) {
return normalizedExternalId;
}
}
if (contentItem.url) {
try {
const pathnameParts = new URL(contentItem.url).pathname.split('/').filter(Boolean);
const commentsIndex = pathnameParts.indexOf('comments');
if (commentsIndex >= 0 && pathnameParts[commentsIndex + 1]) {
return pathnameParts[commentsIndex + 1];
}
} catch {
// Fall back to returning null below.
}
}
return null;
}
async function resolveContentItemSourceUrl(contentItem?: ContentItem | null): Promise<string | undefined> {
if (!contentItem?.url) {
return contentItem?.url;
}
const source = await db.query.botContentSources.findFirst({
where: eq(botContentSources.id, contentItem.sourceId),
columns: {
type: true,
subreddit: true,
},
});
if (source?.type !== 'reddit' || !source.subreddit) {
return contentItem.url;
}
const postId = extractRedditPostId(contentItem);
if (!postId) {
return contentItem.url;
}
return `https://www.reddit.com/r/${source.subreddit}/comments/${postId}/`;
}
/**
* Create a post in the database.
* Posts are created under the bot's own user account.
@@ -762,7 +722,7 @@ async function fetchLinkPreview(url: string): Promise<{
async function createPostInDatabase(
botId: string,
content: string,
sourceUrl?: string
contentItem?: ContentItem | null
): Promise<typeof posts.$inferSelect> {
// Get bot config
const bot = await db.query.bots.findFirst({
@@ -789,6 +749,7 @@ async function createPostInDatabase(
}
// Fetch link preview if source URL provided
const sourceUrl = await resolveContentItemSourceUrl(contentItem);
let linkPreview: Awaited<ReturnType<typeof fetchLinkPreview>> = null;
if (sourceUrl) {
linkPreview = await fetchLinkPreview(sourceUrl);
@@ -809,6 +770,9 @@ async function createPostInDatabase(
linkPreviewTitle: linkPreview?.title || null,
linkPreviewDescription: linkPreview?.description || null,
linkPreviewImage: linkPreview?.image || null,
linkPreviewType: linkPreview?.type || null,
linkPreviewVideoUrl: linkPreview?.videoUrl || null,
linkPreviewMediaJson: linkPreview?.media ? JSON.stringify(linkPreview.media) : null,
}).returning();
// Update bot user's post count
@@ -1079,7 +1043,7 @@ export async function triggerPost(
}
// Create post in database with source URL for link preview (if content item exists)
const post = await createPostInDatabase(botId, postContent, contentItem?.url);
const post = await createPostInDatabase(botId, postContent, contentItem);
// Record post for rate limiting
if (!skipRateLimitCheck) {
+92 -58
View File
@@ -14,8 +14,14 @@ export interface User {
privateKeyEncrypted?: string;
}
export interface AuthAccount extends User {
isActive: boolean;
}
interface AuthContextType {
user: User | null;
accounts: AuthAccount[];
activeAccountId: string | null;
isAdmin: boolean;
loading: boolean;
isIdentityUnlocked: boolean;
@@ -24,8 +30,10 @@ interface AuthContextType {
handle: string | null;
checkAdmin: () => Promise<void>;
unlockIdentity: (password: string, explicitUser?: User) => Promise<void>;
login: (user: User) => void;
logout: () => Promise<void>;
login: (user?: User) => Promise<void>;
logout: (userId?: string) => Promise<void>;
switchAccount: (userId: string) => Promise<void>;
refreshAuth: () => Promise<void>;
lockIdentity: () => Promise<void>; // New: manual lock
signUserAction: (action: string, data: any) => Promise<any>;
requiresUnlock: boolean; // True if user has encrypted key but not unlocked
@@ -35,6 +43,8 @@ interface AuthContextType {
const AuthContext = createContext<AuthContextType>({
user: null,
accounts: [],
activeAccountId: null,
isAdmin: false,
loading: true,
isIdentityUnlocked: false,
@@ -43,8 +53,10 @@ const AuthContext = createContext<AuthContextType>({
handle: null,
checkAdmin: async () => { },
unlockIdentity: async () => { },
login: () => { },
login: async () => { },
logout: async () => { },
switchAccount: async () => { },
refreshAuth: async () => { },
lockIdentity: async () => { },
signUserAction: async () => Promise.reject('Not initialized'),
requiresUnlock: false,
@@ -54,6 +66,7 @@ const AuthContext = createContext<AuthContextType>({
export function AuthProvider({ children }: { children: React.ReactNode }) {
const [user, setUser] = useState<User | null>(null);
const [accounts, setAccounts] = useState<AuthAccount[]>([]);
const [isAdmin, setIsAdmin] = useState(false);
const [loading, setLoading] = useState(true);
const [showUnlockPrompt, setShowUnlockPrompt] = useState(false);
@@ -80,6 +93,41 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
}
}, []);
const applyAuthState = useCallback(async (data: { user: User | null; accounts?: AuthAccount[] | null }) => {
const nextAccounts = data.accounts ?? [];
setAccounts(nextAccounts);
setUser(data.user);
if (data.user?.did && data.user?.publicKey) {
await initializeIdentity({
did: data.user.did,
handle: data.user.handle,
publicKey: data.user.publicKey,
privateKeyEncrypted: data.user.privateKeyEncrypted,
});
await checkAdmin();
} else {
await clearIdentity();
setIsAdmin(false);
}
}, [checkAdmin, clearIdentity, initializeIdentity]);
const refreshAuth = useCallback(async () => {
setLoading(true);
try {
const res = await fetch('/api/auth/me', { cache: 'no-store' });
const data = await res.json();
await applyAuthState({
user: res.ok ? data.user ?? null : null,
accounts: res.ok ? data.accounts ?? [] : [],
});
} catch {
await applyAuthState({ user: null, accounts: [] });
} finally {
setLoading(false);
}
}, [applyAuthState]);
/**
* Unlock the user's identity with their password
* Persists the key for auto-unlock on refresh
@@ -112,81 +160,65 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
/**
* Manually set the user state (called after successful login)
*/
const login = useCallback((userData: User) => {
setUser(userData);
checkAdmin();
// Initialize identity - will try to auto-restore if possible
if (userData.did && userData.publicKey) {
initializeIdentity({
did: userData.did,
handle: userData.handle,
publicKey: userData.publicKey,
privateKeyEncrypted: userData.privateKeyEncrypted,
});
}
}, [checkAdmin, initializeIdentity]);
const login = useCallback(async (_userData?: User) => {
await refreshAuth();
}, [refreshAuth]);
/**
* Logout the user and clear their identity
*/
const logout = useCallback(async () => {
const logout = useCallback(async (userId?: string) => {
try {
await fetch('/api/auth/logout', { method: 'POST' });
await clearIdentity();
await fetch('/api/auth/logout', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(userId ? { userId } : {}),
});
setShowUnlockPrompt(false);
setUser(null);
setIsAdmin(false);
await refreshAuth();
} catch (error) {
console.error('[Auth] Logout failed:', error);
throw error;
}
}, [clearIdentity]);
}, [refreshAuth]);
const switchAccount = useCallback(async (userId: string) => {
try {
setLoading(true);
const res = await fetch('/api/auth/switch', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ userId }),
});
const data = await res.json();
if (!res.ok) {
throw new Error(data.error || 'Failed to switch account');
}
await refreshAuth();
} catch (error) {
console.error('[Auth] Switch account failed:', error);
throw error;
} finally {
setLoading(false);
}
}, [refreshAuth]);
// Load auth state on mount
useEffect(() => {
const loadAuth = async () => {
setLoading(true);
try {
const res = await fetch('/api/auth/me');
if (res.ok) {
const data = await res.json();
setUser(data.user);
// Initialize identity - will auto-restore if persisted
if (data.user?.did && data.user?.publicKey) {
await initializeIdentity({
did: data.user.did,
handle: data.user.handle,
publicKey: data.user.publicKey,
privateKeyEncrypted: data.user.privateKeyEncrypted,
});
}
if (data.user) {
await checkAdmin();
}
} else {
setUser(null);
await clearIdentity();
}
} catch {
setUser(null);
await clearIdentity();
} finally {
setLoading(false);
}
};
loadAuth();
}, [checkAdmin, initializeIdentity, clearIdentity]);
refreshAuth();
}, [refreshAuth]);
// Determine if unlock is required (has encrypted key but not unlocked)
const requiresUnlock = !!user?.privateKeyEncrypted && !isUnlocked && !isRestoring;
const activeAccountId = user?.id ?? null;
return (
<AuthContext.Provider value={{
user,
accounts,
activeAccountId,
isAdmin,
loading,
isIdentityUnlocked: isUnlocked,
@@ -197,6 +229,8 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
unlockIdentity,
login,
logout,
switchAccount,
refreshAuth,
lockIdentity,
signUserAction,
requiresUnlock,
+39 -16
View File
@@ -17,8 +17,8 @@ import { deserializeEncryptedKey, type EncryptedPrivateKey } from './private-key
const DB_NAME = 'synapsis-identity';
const DB_VERSION = 1;
const STORE_NAME = 'keys';
const SESSION_KEY_ITEM = 'synapsis_session_key';
const WRAPPED_KEY_ITEM = 'synapsis_wrapped_key';
const SESSION_KEY_PREFIX = 'synapsis_session_key:';
const WRAPPED_KEY_PREFIX = 'synapsis_wrapped_key:';
interface WrappedKey {
wrapped: string; // Base64 of wrapped key
@@ -32,6 +32,14 @@ interface SessionData {
createdAt: number;
}
function getSessionKeyItem(identifier: string): string {
return `${SESSION_KEY_PREFIX}${identifier}`;
}
function getWrappedKeyItem(identifier: string): string {
return `${WRAPPED_KEY_PREFIX}${identifier}`;
}
// ============================================
// IndexedDB Operations
// ============================================
@@ -218,7 +226,8 @@ async function unwrapRawPrivateKey(
*/
export async function persistUnlockedKey(
privateKeyBase64: string,
password: string
password: string,
identifier: string
): Promise<void> {
try {
// Derive session key from password
@@ -228,7 +237,7 @@ export async function persistUnlockedKey(
const wrapped = await wrapRawPrivateKey(privateKeyBase64, sessionKey);
// Store wrapped key in IndexedDB
await storeInDB(WRAPPED_KEY_ITEM, wrapped);
await storeInDB(getWrappedKeyItem(identifier), wrapped);
// Store session key in localStorage (so it survives refreshes)
const sessionKeyData = await exportSessionKey(sessionKey);
@@ -236,7 +245,7 @@ export async function persistUnlockedKey(
key: sessionKeyData,
createdAt: Date.now(),
};
localStorage.setItem(SESSION_KEY_ITEM, JSON.stringify(sessionData));
localStorage.setItem(getSessionKeyItem(identifier), JSON.stringify(sessionData));
console.log('[KeyPersistence] Key persisted successfully');
} catch (error) {
@@ -250,10 +259,10 @@ export async function persistUnlockedKey(
* Returns the raw key bytes if available, null otherwise
* The caller must then import these bytes as a non-extractable CryptoKey
*/
export async function tryRestoreKey(): Promise<ArrayBuffer | null> {
export async function tryRestoreKey(identifier: string): Promise<ArrayBuffer | null> {
try {
// Get session key from localStorage
const sessionDataRaw = localStorage.getItem(SESSION_KEY_ITEM);
const sessionDataRaw = localStorage.getItem(getSessionKeyItem(identifier));
if (!sessionDataRaw) {
console.log('[KeyPersistence] No session key found');
return null;
@@ -262,15 +271,15 @@ export async function tryRestoreKey(): Promise<ArrayBuffer | null> {
const sessionData: SessionData = JSON.parse(sessionDataRaw);
// Check expiry (24 hours)
const MAX_AGE = 24 * 60 * 60 * 1000;
const MAX_AGE = 3650 * 24 * 60 * 60 * 1000;
if (Date.now() - sessionData.createdAt > MAX_AGE) {
console.log('[KeyPersistence] Session expired');
await clearPersistentKey();
await clearPersistentKey(identifier);
return null;
}
// Get wrapped key from IndexedDB
const wrapped = await getFromDB<WrappedKey>(WRAPPED_KEY_ITEM);
const wrapped = await getFromDB<WrappedKey>(getWrappedKeyItem(identifier));
if (!wrapped) {
console.log('[KeyPersistence] No wrapped key found');
return null;
@@ -293,10 +302,24 @@ export async function tryRestoreKey(): Promise<ArrayBuffer | null> {
/**
* Clear the persisted key (logout)
*/
export async function clearPersistentKey(): Promise<void> {
export async function clearPersistentKey(identifier?: string): Promise<void> {
try {
localStorage.removeItem(SESSION_KEY_ITEM);
await removeFromDB(WRAPPED_KEY_ITEM);
if (identifier) {
localStorage.removeItem(getSessionKeyItem(identifier));
await removeFromDB(getWrappedKeyItem(identifier));
return;
}
const keysToRemove: string[] = [];
for (let i = 0; i < localStorage.length; i += 1) {
const key = localStorage.key(i);
if (key?.startsWith(SESSION_KEY_PREFIX)) {
keysToRemove.push(key);
}
}
keysToRemove.forEach(key => localStorage.removeItem(key));
console.log('[KeyPersistence] Key cleared');
} catch (error) {
console.error('[KeyPersistence] Error clearing key:', error);
@@ -306,13 +329,13 @@ export async function clearPersistentKey(): Promise<void> {
/**
* Check if a persisted key is available
*/
export async function hasPersistentKey(): Promise<boolean> {
const sessionData = localStorage.getItem(SESSION_KEY_ITEM);
export async function hasPersistentKey(identifier: string): Promise<boolean> {
const sessionData = localStorage.getItem(getSessionKeyItem(identifier));
if (!sessionData) return false;
try {
const parsed: SessionData = JSON.parse(sessionData);
const MAX_AGE = 24 * 60 * 60 * 1000;
const MAX_AGE = 3650 * 24 * 60 * 60 * 1000;
return Date.now() - parsed.createdAt <= MAX_AGE;
} catch {
return false;
+19 -7
View File
@@ -51,10 +51,14 @@ export function useUserIdentity() {
setIsRestoring(false);
return;
}
if (!globalIdentity?.did) {
return;
}
// Try to restore from persistent storage
const restoredKeyBytes = await tryRestoreKey();
if (restoredKeyBytes && globalIdentity) {
const restoredKeyBytes = await tryRestoreKey(globalIdentity.did);
if (restoredKeyBytes) {
// Import the restored key as non-extractable
const cryptoKey = await importPrivateKey(restoredKeyBytes);
keyStore.setPrivateKey(cryptoKey);
@@ -86,6 +90,8 @@ export function useUserIdentity() {
publicKey: string;
privateKeyEncrypted?: string;
}) => {
keyStore.clear();
const coreIdentity = {
did: userData.did,
handle: userData.handle,
@@ -94,7 +100,7 @@ export function useUserIdentity() {
keyStore.setIdentity(coreIdentity);
// Try to auto-restore if we have persisted key
const restoredKeyBytes = await tryRestoreKey();
const restoredKeyBytes = await tryRestoreKey(userData.did);
if (restoredKeyBytes) {
const cryptoKey = await importPrivateKey(restoredKeyBytes);
keyStore.setPrivateKey(cryptoKey);
@@ -150,7 +156,11 @@ export function useUserIdentity() {
// PERSIST: Save raw key bytes for auto-restore on refresh
// We pass the raw bytes because the CryptoKey is non-extractable
await persistUnlockedKey(privateKeyBase64, password);
if (!userDid) {
throw new Error('User DID is required to persist the unlocked identity');
}
await persistUnlockedKey(privateKeyBase64, password, userDid);
console.log('[Identity] Private key stored in memory and persisted');
@@ -171,8 +181,9 @@ export function useUserIdentity() {
* Lock the identity (manual lock, keeps identity info)
*/
const lockIdentity = useCallback(async () => {
const identifier = keyStore.getIdentity()?.did;
keyStore.clear();
await clearPersistentKey();
await clearPersistentKey(identifier);
setIsUnlocked(false);
setIdentity(prev => prev ? { ...prev, isUnlocked: false } : null);
}, []);
@@ -181,8 +192,9 @@ export function useUserIdentity() {
* Clear the identity (logout)
*/
const clearIdentity = useCallback(async () => {
const identifier = keyStore.getIdentity()?.did;
keyStore.clear();
await clearPersistentKey();
await clearPersistentKey(identifier);
setIdentity(null);
setIsUnlocked(false);
}, []);
+62
View File
@@ -0,0 +1,62 @@
import type { LinkPreviewData } from './linkPreview';
const GENERIC_PREVIEW_USER_AGENT = 'Mozilla/5.0 (compatible; SynapsisBot/1.0; +https://synapsis.social)';
function decodeHtmlEntities(value: string): string {
return value
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'");
}
function extractMeta(html: string, property: string): string | null {
const patterns = [
new RegExp(`<meta[^>]+(?:property|name|itemprop)=["'](?:og:|twitter:)?${property}["'][^>]+content=["']([^"']+)["']`, 'i'),
new RegExp(`<meta[^>]+content=["']([^"']+)["'][^>]+(?:property|name|itemprop)=["'](?:og:|twitter:)?${property}["']`, 'i'),
];
for (const pattern of patterns) {
const match = html.match(pattern);
if (match?.[1]) {
return decodeHtmlEntities(match[1]);
}
}
return null;
}
export async function fetchGenericLinkPreview(url: string): Promise<LinkPreviewData | null> {
try {
const response = await fetch(url, {
headers: {
'User-Agent': GENERIC_PREVIEW_USER_AGENT,
'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),
});
if (!response.ok) {
return null;
}
const html = await response.text();
const title = extractMeta(html, 'title') || html.match(/<title>([^<]+)<\/title>/i)?.[1] || null;
const description = extractMeta(html, 'description');
const image = extractMeta(html, 'image');
return {
url,
title: title?.trim() || null,
description: description?.trim() || null,
image: image?.trim() || null,
type: image?.trim() ? 'image' : 'card',
videoUrl: null,
media: image?.trim() ? [{ url: image.trim() }] : null,
};
} catch {
return null;
}
}
+44
View File
@@ -0,0 +1,44 @@
export type LinkPreviewType = 'card' | 'image' | 'gallery' | 'video';
export interface LinkPreviewMediaItem {
url: string;
width?: number | null;
height?: number | null;
mimeType?: string | null;
}
export interface LinkPreviewData {
url: string;
title: string | null;
description: string | null;
image: string | null;
type?: LinkPreviewType | null;
videoUrl?: string | null;
media?: LinkPreviewMediaItem[] | null;
}
export function parseLinkPreviewMediaJson(
value?: string | null
): LinkPreviewMediaItem[] | undefined {
if (!value) return undefined;
try {
const parsed = JSON.parse(value);
if (!Array.isArray(parsed)) return undefined;
return parsed.filter((item): item is LinkPreviewMediaItem => (
item &&
typeof item === 'object' &&
typeof item.url === 'string'
));
} catch {
return undefined;
}
}
export function serializeLinkPreviewMedia(
media?: LinkPreviewMediaItem[] | null
): string | null {
if (!media || media.length === 0) return null;
return JSON.stringify(media);
}
+62
View File
@@ -0,0 +1,62 @@
import type { LinkPreviewData } from './linkPreview';
interface RedditOEmbedResponse {
title?: string;
author_name?: string;
provider_name?: string;
thumbnail_url?: string;
html?: string;
}
function extractTitleFromHtml(html?: string): string | null {
if (!html) return null;
const titleMatch = html.match(/href="[^"]+">([^<]+)<\/a>/);
if (titleMatch?.[1] && titleMatch[1] !== 'Comment') {
return titleMatch[1];
}
return null;
}
function extractSubredditFromHtml(html?: string): string | null {
if (!html) return null;
const subredditMatch = html.match(/r\/([a-zA-Z0-9_]+)/);
return subredditMatch?.[1] || null;
}
export async function fetchRedditRichPreview(url: string): Promise<LinkPreviewData | null> {
try {
const oembedUrl = `https://www.reddit.com/oembed?url=${encodeURIComponent(url)}`;
const response = await fetch(oembedUrl, {
headers: {
Accept: 'application/json',
'User-Agent': 'Synapsis Link Preview/1.0',
},
signal: AbortSignal.timeout(5000),
});
if (!response.ok) {
return null;
}
const data = await response.json() as RedditOEmbedResponse;
const title = data.title || extractTitleFromHtml(data.html) || 'Reddit';
const subreddit = extractSubredditFromHtml(data.html);
const description = data.author_name
? `Posted by ${data.author_name}${subreddit ? ` in r/${subreddit}` : ''}`
: subreddit
? `r/${subreddit}`
: (data.provider_name || 'Reddit');
return {
url,
title,
description,
image: null,
type: 'card',
videoUrl: null,
media: null,
};
} catch {
return null;
}
}
+19
View File
@@ -0,0 +1,19 @@
import { users } from '@/db';
type NotificationTargetUser = Pick<
typeof users.$inferSelect,
'handle' | 'displayName' | 'avatarUrl' | 'isBot'
>;
export function buildNotificationTarget(
user: NotificationTargetUser,
nodeDomain: string | null = null
) {
return {
targetHandle: user.handle,
targetDisplayName: user.displayName || user.handle,
targetAvatarUrl: user.avatarUrl || null,
targetNodeDomain: nodeDomain,
targetIsBot: user.isBot,
};
}
+79 -28
View File
@@ -3,8 +3,8 @@ import { cookies } from 'next/headers';
import type { users } from '@/db';
import { decryptS3Credentials, type StorageProvider } from '@/lib/storage/s3';
const STORAGE_SESSION_COOKIE = 'synapsis_storage_session';
const STORAGE_SESSION_TTL_MS = 12 * 60 * 60 * 1000;
const STORAGE_SESSION_COOKIE = 'synapsis_storage_sessions';
const STORAGE_SESSION_TTL_MS = 3650 * 24 * 60 * 60 * 1000;
interface StorageSessionPayload {
userId: string;
@@ -18,6 +18,8 @@ interface StorageSessionPayload {
expiresAt: number;
}
type StorageSessionMap = Record<string, StorageSessionPayload>;
function getEncryptionKey(): Buffer {
const secret = process.env.AUTH_SECRET;
@@ -63,6 +65,55 @@ function decryptPayload(token: string): StorageSessionPayload {
return JSON.parse(decrypted) as StorageSessionPayload;
}
function encryptPayloadMap(payload: StorageSessionMap): string {
return encryptPayload(payload as unknown as StorageSessionPayload);
}
function decryptPayloadMap(token: string): StorageSessionMap {
return decryptPayload(token) as unknown as StorageSessionMap;
}
async function readStorageSessionMap(): Promise<StorageSessionMap> {
const cookieStore = await cookies();
const token = cookieStore.get(STORAGE_SESSION_COOKIE)?.value;
if (!token) {
return {};
}
try {
const payload = decryptPayloadMap(token);
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
await clearStorageSession();
return {};
}
return payload;
} catch {
await clearStorageSession();
return {};
}
}
async function writeStorageSessionMap(payload: StorageSessionMap): Promise<void> {
const cookieStore = await cookies();
if (Object.keys(payload).length === 0) {
cookieStore.delete(STORAGE_SESSION_COOKIE);
return;
}
const maxExpiresAt = Math.max(...Object.values(payload).map(session => session.expiresAt));
cookieStore.set(STORAGE_SESSION_COOKIE, encryptPayloadMap(payload), {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
path: '/',
expires: new Date(maxExpiresAt),
});
}
export async function createStorageSession(
user: typeof users.$inferSelect,
password: string
@@ -73,7 +124,7 @@ export async function createStorageSession(
!user.storageSecretKeyEncrypted ||
!user.storageBucket
) {
await clearStorageSession();
await clearStorageSession(user.id);
return null;
}
@@ -95,42 +146,42 @@ export async function createStorageSession(
expiresAt: Date.now() + STORAGE_SESSION_TTL_MS,
};
const cookieStore = await cookies();
cookieStore.set(STORAGE_SESSION_COOKIE, encryptPayload(payload), {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
path: '/',
expires: new Date(payload.expiresAt),
});
const existingSessions = await readStorageSessionMap();
existingSessions[user.id] = payload;
await writeStorageSessionMap(existingSessions);
return payload;
}
export async function getStorageSession(userId: string): Promise<StorageSessionPayload | null> {
const cookieStore = await cookies();
const token = cookieStore.get(STORAGE_SESSION_COOKIE)?.value;
const sessions = await readStorageSessionMap();
const payload = sessions[userId];
if (!token) {
if (!payload) {
return null;
}
try {
const payload = decryptPayload(token);
if (payload.userId !== userId || payload.expiresAt <= Date.now()) {
await clearStorageSession();
return null;
}
return payload;
} catch {
await clearStorageSession();
if (payload.expiresAt <= Date.now()) {
delete sessions[userId];
await writeStorageSessionMap(sessions);
return null;
}
return payload;
}
export async function clearStorageSession(): Promise<void> {
const cookieStore = await cookies();
cookieStore.delete(STORAGE_SESSION_COOKIE);
export async function clearStorageSession(userId?: string): Promise<void> {
if (!userId) {
const cookieStore = await cookies();
cookieStore.delete(STORAGE_SESSION_COOKIE);
return;
}
const sessions = await readStorageSessionMap();
if (!(userId in sessions)) {
return;
}
delete sessions[userId];
await writeStorageSessionMap(sessions);
}
+66 -12
View File
@@ -14,6 +14,8 @@
import { getActiveSwarmNodes } from './registry';
import type { SwarmNodeInfo } from './types';
import { filterBlockedDomains, isNodeBlocked, normalizeNodeDomain } from './node-blocklist';
import { serializeLinkPreviewMedia } from '@/lib/media/linkPreview';
// ============================================
// TYPES
@@ -141,16 +143,24 @@ export interface SwarmMentionPayload {
* Check if a domain is a known Synapsis swarm node
*/
export async function isSwarmNode(domain: string): Promise<boolean> {
const normalizedDomain = normalizeNodeDomain(domain);
if (await isNodeBlocked(normalizedDomain)) {
return false;
}
const nodes = await getActiveSwarmNodes(500);
return nodes.some(n => n.domain === domain);
return nodes.some(n => n.domain === normalizedDomain);
}
/**
* Get swarm node info if the domain is a swarm node
*/
export async function getSwarmNodeInfo(domain: string): Promise<SwarmNodeInfo | null> {
const normalizedDomain = normalizeNodeDomain(domain);
if (await isNodeBlocked(normalizedDomain)) {
return null;
}
const nodes = await getActiveSwarmNodes(500);
return nodes.find(n => n.domain === domain) || null;
return nodes.find(n => n.domain === normalizedDomain) || null;
}
/**
@@ -257,11 +267,19 @@ async function deliverSwarmInteraction(
payload: unknown
): Promise<SwarmInteractionResponse> {
try {
const normalizedTargetDomain = normalizeNodeDomain(targetDomain);
if (await isNodeBlocked(normalizedTargetDomain)) {
return {
success: false,
error: `Blocked node: ${normalizedTargetDomain}`,
};
}
const baseUrl = targetDomain.startsWith('http')
? targetDomain
: targetDomain.startsWith('localhost') || targetDomain.startsWith('127.0.0.1')
? `http://${targetDomain}`
: `https://${targetDomain}`;
: normalizedTargetDomain.startsWith('localhost') || normalizedTargetDomain.startsWith('127.0.0.1')
? `http://${normalizedTargetDomain}`
: `https://${normalizedTargetDomain}`;
const url = `${baseUrl}${endpoint}`;
@@ -334,17 +352,31 @@ 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;
}
export interface SwarmProfileResponse {
@@ -363,11 +395,16 @@ export async function fetchSwarmUserProfile(
postsLimit: number = 25
): Promise<SwarmProfileResponse | null> {
try {
const normalizedDomain = normalizeNodeDomain(domain);
if (await isNodeBlocked(normalizedDomain)) {
return null;
}
const baseUrl = domain.startsWith('http')
? domain
: domain.startsWith('localhost') || domain.startsWith('127.0.0.1')
? `http://${domain}`
: `https://${domain}`;
: normalizedDomain.startsWith('localhost') || normalizedDomain.startsWith('127.0.0.1')
? `http://${normalizedDomain}`
: `https://${normalizedDomain}`;
const url = `${baseUrl}/api/swarm/users/${handle}?limit=${postsLimit}`;
@@ -449,6 +486,9 @@ export async function cacheSwarmUserPosts(
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,
});
@@ -470,11 +510,16 @@ export async function fetchSwarmPost(
domain: string
): Promise<SwarmUserPost | null> {
try {
const normalizedDomain = normalizeNodeDomain(domain);
if (await isNodeBlocked(normalizedDomain)) {
return null;
}
const baseUrl = domain.startsWith('http')
? domain
: domain.startsWith('localhost') || domain.startsWith('127.0.0.1')
? `http://${domain}`
: `https://${domain}`;
: normalizedDomain.startsWith('localhost') || normalizedDomain.startsWith('127.0.0.1')
? `http://${normalizedDomain}`
: `https://${normalizedDomain}`;
const url = `${baseUrl}/api/swarm/posts/${postId}`;
@@ -591,6 +636,9 @@ export interface SwarmPostDeliveryPayload {
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 }>;
};
author: {
handle: string;
@@ -637,7 +685,7 @@ export async function getSwarmFollowerDomains(userId: string): Promise<string[]>
return match ? match[1] : null;
}).filter((d): d is string => d !== null);
return [...new Set(domains)];
return await filterBlockedDomains([...new Set(domains)]);
} catch (error) {
console.error('[Swarm] Error getting swarm follower domains:', error);
return [];
@@ -660,6 +708,9 @@ export async function deliverPostToSwarmFollowers(
linkPreviewTitle?: string | null;
linkPreviewDescription?: string | null;
linkPreviewImage?: string | null;
linkPreviewType?: string | null;
linkPreviewVideoUrl?: string | null;
linkPreviewMedia?: Array<{ url: string; width?: number | null; height?: number | null; mimeType?: string | null }> | null;
},
author: {
handle: string;
@@ -693,6 +744,9 @@ export async function deliverPostToSwarmFollowers(
linkPreviewTitle: post.linkPreviewTitle || undefined,
linkPreviewDescription: post.linkPreviewDescription || undefined,
linkPreviewImage: post.linkPreviewImage || undefined,
linkPreviewType: (post.linkPreviewType as SwarmPostDeliveryPayload['post']['linkPreviewType']) || undefined,
linkPreviewVideoUrl: post.linkPreviewVideoUrl || undefined,
linkPreviewMedia: post.linkPreviewMedia || undefined,
},
author: {
handle: author.handle,
+124
View File
@@ -0,0 +1,124 @@
import { db, swarmNodes } from '@/db';
import { and, eq, inArray } from 'drizzle-orm';
export function normalizeNodeDomain(domain: string): string {
return domain
.trim()
.toLowerCase()
.replace(/^https?:\/\//, '')
.replace(/\/.*$/, '')
.replace(/^@/, '');
}
export async function isNodeBlocked(domain: string | null | undefined): Promise<boolean> {
if (!db || !domain) return false;
const normalized = normalizeNodeDomain(domain);
if (!normalized) return false;
const node = await db.query.swarmNodes.findFirst({
where: eq(swarmNodes.domain, normalized),
columns: {
isBlocked: true,
},
});
return Boolean(node?.isBlocked);
}
export async function getBlockedNodeDomains(): Promise<Set<string>> {
if (!db) return new Set();
const rows = await db.query.swarmNodes.findMany({
where: eq(swarmNodes.isBlocked, true),
columns: {
domain: true,
},
});
return new Set(rows.map((row) => row.domain));
}
export async function filterBlockedDomains(domains: string[]): Promise<string[]> {
if (!db || domains.length === 0) return domains;
const normalized = Array.from(new Set(domains.map(normalizeNodeDomain).filter(Boolean)));
if (normalized.length === 0) return [];
const blocked = await db.query.swarmNodes.findMany({
where: and(
inArray(swarmNodes.domain, normalized),
eq(swarmNodes.isBlocked, true),
),
columns: {
domain: true,
},
});
const blockedSet = new Set(blocked.map((row) => row.domain));
return normalized.filter((domain) => !blockedSet.has(domain));
}
export async function upsertBlockedNode(domain: string, reason?: string | null) {
if (!db) return null;
const normalized = normalizeNodeDomain(domain);
if (!normalized) return null;
const existing = await db.query.swarmNodes.findFirst({
where: eq(swarmNodes.domain, normalized),
});
if (existing) {
const [updated] = await db.update(swarmNodes)
.set({
isBlocked: true,
blockReason: reason || null,
blockedAt: new Date(),
isActive: false,
updatedAt: new Date(),
})
.where(eq(swarmNodes.id, existing.id))
.returning();
return updated;
}
const [created] = await db.insert(swarmNodes)
.values({
domain: normalized,
isBlocked: true,
blockReason: reason || null,
blockedAt: new Date(),
isActive: false,
trustScore: 0,
})
.returning();
return created;
}
export async function unblockNode(domain: string) {
if (!db) return null;
const normalized = normalizeNodeDomain(domain);
if (!normalized) return null;
const existing = await db.query.swarmNodes.findFirst({
where: eq(swarmNodes.domain, normalized),
});
if (!existing) return null;
const [updated] = await db.update(swarmNodes)
.set({
isBlocked: false,
blockReason: null,
blockedAt: null,
updatedAt: new Date(),
})
.where(eq(swarmNodes.id, existing.id))
.returning();
return updated;
}
+21 -9
View File
@@ -8,6 +8,7 @@ import { db, swarmNodes, swarmSeeds, swarmSyncLog } from '@/db';
import { eq, desc, and, gt, lt, sql } from 'drizzle-orm';
import type { SwarmNodeInfo, SwarmCapability, SwarmSyncResult } from './types';
import { SWARM_CONFIG, DEFAULT_SEED_NODES } from './types';
import { normalizeNodeDomain } from './node-blocklist';
/**
* Get or create a swarm node entry
@@ -21,14 +22,16 @@ export async function upsertSwarmNode(
}
const existing = await db.query.swarmNodes.findFirst({
where: eq(swarmNodes.domain, node.domain),
where: eq(swarmNodes.domain, normalizeNodeDomain(node.domain)),
});
const normalizedDomain = normalizeNodeDomain(node.domain);
const capabilities = node.capabilities ? JSON.stringify(node.capabilities) : null;
if (!existing) {
await db.insert(swarmNodes).values({
domain: node.domain,
domain: normalizedDomain,
name: node.name,
description: node.description,
logoUrl: node.logoUrl,
@@ -58,10 +61,10 @@ export async function upsertSwarmNode(
capabilities: capabilities ?? existing.capabilities,
lastSeenAt: new Date(),
consecutiveFailures: 0,
isActive: true,
isActive: existing.isBlocked ? false : true,
updatedAt: new Date(),
})
.where(eq(swarmNodes.domain, node.domain));
.where(eq(swarmNodes.domain, normalizedDomain));
return { isNew: false };
}
@@ -83,8 +86,10 @@ export async function upsertSwarmNodes(
// Filter out our own domain
const ourDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN;
const filteredNodes = nodes.filter(n => n.domain !== ourDomain);
const normalizedOurDomain = ourDomain ? normalizeNodeDomain(ourDomain) : null;
const safeNodes = filteredNodes.filter(n => normalizeNodeDomain(n.domain) !== normalizedOurDomain);
for (const node of filteredNodes) {
for (const node of safeNodes) {
const result = await upsertSwarmNode(node, discoveredVia);
if (result.isNew) {
added++;
@@ -105,7 +110,10 @@ export async function getActiveSwarmNodes(limit = 100): Promise<SwarmNodeInfo[]>
}
const nodes = await db.query.swarmNodes.findMany({
where: eq(swarmNodes.isActive, true),
where: and(
eq(swarmNodes.isActive, true),
eq(swarmNodes.isBlocked, false),
),
orderBy: [desc(swarmNodes.lastSeenAt)],
limit,
});
@@ -125,6 +133,7 @@ export async function getNodesForGossip(count: number): Promise<SwarmNodeInfo[]>
const nodes = await db.query.swarmNodes.findMany({
where: and(
eq(swarmNodes.isActive, true),
eq(swarmNodes.isBlocked, false),
gt(swarmNodes.trustScore, 20)
),
orderBy: sql`RANDOM()`,
@@ -143,7 +152,10 @@ export async function getNodesSince(since: Date, limit = 100): Promise<SwarmNode
}
const nodes = await db.query.swarmNodes.findMany({
where: gt(swarmNodes.updatedAt, since),
where: and(
gt(swarmNodes.updatedAt, since),
eq(swarmNodes.isBlocked, false),
),
orderBy: [desc(swarmNodes.updatedAt)],
limit,
});
@@ -177,7 +189,7 @@ export async function markNodeFailure(domain: string): Promise<void> {
.set({
consecutiveFailures: newFailures,
trustScore: newTrust,
isActive,
isActive: node.isBlocked ? false : isActive,
updatedAt: new Date(),
})
.where(eq(swarmNodes.domain, domain));
@@ -211,7 +223,7 @@ export async function markNodeSuccess(domain: string): Promise<void> {
.set({
consecutiveFailures: 0,
trustScore: newTrust,
isActive: true,
isActive: node.isBlocked ? false : true,
lastSeenAt: new Date(),
lastSyncAt: new Date(),
updatedAt: new Date(),
+62
View File
@@ -0,0 +1,62 @@
export 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;
};
export const getRemoteBaseUrl = (domain: string) =>
domain.startsWith('http')
? domain
: domain.startsWith('localhost') || domain.startsWith('127.0.0.1')
? `http://${domain}`
: `https://${domain}`;
type RemoteProfilePost = {
id: string;
originalPostId?: string;
author?: {
id?: string;
handle: string;
displayName?: string | null;
avatarUrl?: string | null;
isBot?: boolean;
};
nodeDomain?: string | null;
isSwarm?: boolean;
repostOf?: RemoteProfilePost | null;
replyTo?: RemoteProfilePost | null;
media?: Array<{ id?: string; url: string; altText?: string | null; mimeType?: string | null }>;
[key: string]: unknown;
};
export function mapRemoteProfilePost(post: RemoteProfilePost, remoteDomain: string): RemoteProfilePost {
const isAlreadySwarm = post.id.startsWith('swarm:');
const rawOriginalId = post.originalPostId || (isAlreadySwarm ? post.id.split(':').pop() || post.id : post.id);
const effectiveDomain = post.nodeDomain || remoteDomain;
return {
...post,
id: isAlreadySwarm ? post.id : `swarm:${effectiveDomain}:${rawOriginalId}`,
originalPostId: rawOriginalId,
isSwarm: true,
nodeDomain: effectiveDomain,
author: post.author ? {
...post.author,
id: post.author.id?.startsWith('swarm:')
? post.author.id
: `swarm:${effectiveDomain}:${post.author.handle.includes('@') ? post.author.handle : post.author.handle}`,
handle: post.author.handle.includes('@')
? post.author.handle
: `${post.author.handle}@${effectiveDomain}`,
} : post.author,
media: post.media?.map((item, index) => ({
...item,
id: item.id || `swarm:${effectiveDomain}:${rawOriginalId}:media:${index}`,
})),
repostOf: post.repostOf ? mapRemoteProfilePost(post.repostOf, remoteDomain) : post.repostOf,
replyTo: post.replyTo ? mapRemoteProfilePost(post.replyTo, remoteDomain) : post.replyTo,
};
}
+40
View File
@@ -0,0 +1,40 @@
export interface SwarmRepostTarget {
id: string;
nodeDomain: string;
originalPostId: string;
}
export async function getViewerSwarmRepostedPostIds(
targets: SwarmRepostTarget[],
viewerId: string
): Promise<Set<string>> {
const repostedIds = new Set<string>();
if (!targets.length || !viewerId) {
return repostedIds;
}
const { db, userSwarmReposts } = await import('@/db');
const { and, eq, inArray } = await import('drizzle-orm');
const domains = Array.from(new Set(targets.map((target) => target.nodeDomain)));
const originalPostIds = Array.from(new Set(targets.map((target) => target.originalPostId)));
const rows = await db.query.userSwarmReposts.findMany({
where: and(
eq(userSwarmReposts.userId, viewerId),
inArray(userSwarmReposts.nodeDomain, domains),
inArray(userSwarmReposts.originalPostId, originalPostIds),
),
});
const rowKeys = new Set(rows.map((row) => `${row.nodeDomain}:${row.originalPostId}`));
for (const target of targets) {
if (rowKeys.has(`${target.nodeDomain}:${target.originalPostId}`)) {
repostedIds.add(target.id);
}
}
return repostedIds;
}
+32 -9
View File
@@ -10,6 +10,7 @@ import crypto from 'crypto';
import { db, users } from '@/db';
import { eq } from 'drizzle-orm';
import { canonicalize } from '@/lib/crypto/user-signing';
import { isNodeBlocked, normalizeNodeDomain } from './node-blocklist';
/**
* Sign a payload with the node's private key
@@ -56,14 +57,21 @@ export function verifySignature(payload: any, signature: string, publicKey: stri
*/
export async function getNodePublicKey(domain: string): Promise<string | null> {
try {
const normalizedDomain = normalizeNodeDomain(domain);
if (await isNodeBlocked(normalizedDomain)) {
console.warn(`[Signature] Refusing public key fetch for blocked node ${normalizedDomain}`);
return null;
}
// Check if we have a cached node info
const protocol = domain.includes('localhost') ? 'http' : 'https';
const response = await fetch(`${protocol}://${domain}/api/node`, {
const protocol = normalizedDomain.includes('localhost') ? 'http' : 'https';
const response = await fetch(`${protocol}://${normalizedDomain}/api/node`, {
headers: { 'Accept': 'application/json' },
signal: AbortSignal.timeout(5000),
});
if (!response.ok) {
console.error(`[Signature] Failed to fetch node info from ${domain}: ${response.status}`);
console.error(`[Signature] Failed to fetch node info from ${normalizedDomain}: ${response.status}`);
return null;
}
@@ -88,8 +96,14 @@ export async function verifySwarmRequest(
signature: string,
senderDomain: string
): Promise<boolean> {
const normalizedDomain = normalizeNodeDomain(senderDomain);
if (await isNodeBlocked(normalizedDomain)) {
console.warn(`[Signature] Rejected blocked node ${normalizedDomain}`);
return false;
}
// Get the sender node's public key
const publicKey = await getNodePublicKey(senderDomain);
const publicKey = await getNodePublicKey(normalizedDomain);
if (!publicKey) {
console.error(`[Signature] Could not get public key for ${senderDomain}`);
@@ -118,8 +132,14 @@ export async function verifyUserInteraction(
userDomain: string
): Promise<boolean> {
try {
const normalizedDomain = normalizeNodeDomain(userDomain);
if (await isNodeBlocked(normalizedDomain)) {
console.warn(`[Signature] Rejected user interaction from blocked node ${normalizedDomain}`);
return false;
}
// Try to get cached user
const fullHandle = `${userHandle}@${userDomain}`;
const fullHandle = `${userHandle}@${normalizedDomain}`;
let user = await db?.query.users.findFirst({
where: eq(users.handle, fullHandle),
});
@@ -130,11 +150,14 @@ export async function verifyUserInteraction(
publicKey = user.publicKey;
} else {
// Fetch from remote node
const protocol = userDomain.includes('localhost') ? 'http' : 'https';
const response = await fetch(`${protocol}://${userDomain}/api/users/${userHandle}`);
const protocol = normalizedDomain.includes('localhost') ? 'http' : 'https';
const response = await fetch(`${protocol}://${normalizedDomain}/api/users/${userHandle}`, {
headers: { 'Accept': 'application/json' },
signal: AbortSignal.timeout(5000),
});
if (!response.ok) {
console.error(`[Signature] Failed to fetch user ${userHandle}@${userDomain}: ${response.status}`);
console.error(`[Signature] Failed to fetch user ${userHandle}@${normalizedDomain}: ${response.status}`);
return false;
}
@@ -144,7 +167,7 @@ export async function verifyUserInteraction(
// Cache the user if we don't have them
if (!user && publicKey && db) {
await db.insert(users).values({
did: userData.user?.did || `did:swarm:${userDomain}:${userHandle}`,
did: userData.user?.did || `did:swarm:${normalizedDomain}:${userHandle}`,
handle: fullHandle,
displayName: userData.user?.displayName || userHandle,
avatarUrl: userData.user?.avatarUrl,
+21 -12
View File
@@ -6,6 +6,8 @@
import { getActiveSwarmNodes } from './registry';
import type { SwarmPost } from '@/app/api/swarm/timeline/route';
import { filterBlockedDomains, isNodeBlocked, normalizeNodeDomain } from './node-blocklist';
import type { LinkPreviewData } from '@/lib/media/linkPreview';
interface TimelineResult {
posts: SwarmPost[];
@@ -41,12 +43,7 @@ function extractFirstUrl(content: string): string | null {
/**
* Fetch link preview for a URL
*/
async function fetchLinkPreview(url: string): Promise<{
url: string;
title: string | null;
description: string | null;
image: string | null;
} | null> {
async function fetchLinkPreview(url: string): Promise<LinkPreviewData | null> {
try {
const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost';
const protocol = nodeDomain === 'localhost' ? 'http' : 'https';
@@ -70,6 +67,9 @@ async function fetchLinkPreview(url: string): Promise<{
title: data.title || null,
description: data.description || null,
image: data.image || null,
type: data.type || null,
videoUrl: data.videoUrl || null,
media: data.media || null,
};
} catch {
return null;
@@ -101,6 +101,9 @@ async function enrichPostsWithPreviews(posts: SwarmPost[]): Promise<SwarmPost[]>
linkPreviewTitle: preview.title || undefined,
linkPreviewDescription: preview.description || undefined,
linkPreviewImage: preview.image || undefined,
linkPreviewType: preview.type || undefined,
linkPreviewVideoUrl: preview.videoUrl || undefined,
linkPreviewMedia: preview.media || undefined,
};
});
@@ -116,14 +119,19 @@ async function fetchNodeTimeline(
cursor?: string
): Promise<{ posts: SwarmPost[]; nodeIsNsfw?: boolean; error?: string }> {
try {
const normalizedDomain = normalizeNodeDomain(domain);
if (await isNodeBlocked(normalizedDomain)) {
return { posts: [], error: 'Blocked node' };
}
// Determine protocol - use http for localhost, https for everything else
let baseUrl: string;
if (domain.startsWith('http')) {
baseUrl = domain;
} else if (domain.startsWith('localhost') || domain.startsWith('127.0.0.1')) {
baseUrl = `http://${domain}`;
} else if (normalizedDomain.startsWith('localhost') || normalizedDomain.startsWith('127.0.0.1')) {
baseUrl = `http://${normalizedDomain}`;
} else {
baseUrl = `https://${domain}`;
baseUrl = `https://${normalizedDomain}`;
}
const url = `${baseUrl}/api/swarm/timeline?limit=${limit}${cursor ? `&cursor=${encodeURIComponent(cursor)}` : ''}`;
@@ -166,13 +174,14 @@ export async function fetchSwarmTimeline(
const nodes = await getActiveSwarmNodes(maxNodes);
// Always include our own posts
const ourDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost';
const ourDomain = normalizeNodeDomain(process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost');
// Always query all nodes - we filter posts, not nodes
const nodesToQuery = [
const candidateDomains = [
ourDomain,
...nodes.map(n => n.domain).filter(d => d !== ourDomain)
].slice(0, maxNodes);
];
const nodesToQuery = (await filterBlockedDomains(candidateDomains)).slice(0, maxNodes);
console.log(`[Swarm Timeline] Querying ${nodesToQuery.length} nodes: ${nodesToQuery.join(', ')}`);
console.log(`[Swarm Timeline] includeNsfw: ${includeNsfw}, cursor: ${cursor || 'none'}`);
+12
View File
@@ -40,6 +40,13 @@ export interface Attachment {
altText?: string | null;
}
export interface LinkPreviewMediaItem {
url: string;
width?: number | null;
height?: number | null;
mimeType?: string | null;
}
export interface Post {
id: string;
content: string;
@@ -53,8 +60,13 @@ export interface Post {
linkPreviewTitle?: string | null;
linkPreviewDescription?: string | null;
linkPreviewImage?: string | null;
linkPreviewType?: 'card' | 'image' | 'gallery' | 'video' | null;
linkPreviewVideoUrl?: string | null;
linkPreviewMedia?: LinkPreviewMediaItem[] | null;
replyTo?: Post | null;
replyToId?: string | null;
repostOf?: Post | null;
repostOfId?: string | null;
// Swarm reply info (when replying to a post on another node)
swarmReplyToId?: string | null;
swarmReplyToContent?: string | null;