feat: user-owned S3-compatible storage with credential verification

This commit is contained in:
Christomatt
2026-02-01 02:51:52 +01:00
parent a8252a1809
commit f55dec9a60
38 changed files with 1845 additions and 902 deletions
+7 -2
View File
@@ -21,6 +21,7 @@ interface ExportManifest {
handle: string;
sourceNode: string;
exportedAt: string;
expiresAt: string; // Export expiration timestamp
publicKey: string;
privateKeyEncrypted: string; // Encrypted with user's password
salt: string; // For key derivation
@@ -40,7 +41,7 @@ interface ExportPost {
content: string;
createdAt: string;
replyToApId: string | null;
media: { filename: string; url: string; altText: string | null }[];
media: { filename: string; url: string; altText: string | null; isIPFS?: boolean }[];
}
interface ExportFollowing {
@@ -209,6 +210,7 @@ export async function POST(req: NextRequest) {
filename: `${post.id}_${idx}${getExtension(m.url)}`,
url: m.url,
altText: m.altText,
isIPFS: m.url?.startsWith('ipfs://') || false,
})),
}));
@@ -314,12 +316,15 @@ export async function POST(req: NextRequest) {
const { encrypted, salt, iv } = encryptPrivateKey(privateKey, password);
// Build manifest (without signature first)
const exportedAt = new Date();
const expiresAt = new Date(exportedAt.getTime() + 30 * 24 * 60 * 60 * 1000); // 30 days
const manifestData: Omit<ExportManifest, 'signature'> = {
version: '1.0',
did: user.did,
handle: user.handle,
sourceNode: nodeDomain,
exportedAt: new Date().toISOString(),
exportedAt: exportedAt.toISOString(),
expiresAt: expiresAt.toISOString(),
publicKey: user.publicKey,
privateKeyEncrypted: encrypted,
salt,
+65 -6
View File
@@ -6,8 +6,8 @@
*/
import { NextRequest, NextResponse } from 'next/server';
import { db, users, posts, media, follows, nodes, chatConversations, chatMessages, bots, botContentSources, botActivityLogs } from '@/db';
import { eq } from 'drizzle-orm';
import { db, users, posts, media, follows, remoteFollows, nodes, chatConversations, chatMessages, bots, botContentSources, botActivityLogs } from '@/db';
import { eq, sql } from 'drizzle-orm';
import * as crypto from 'crypto';
import { encryptApiKey, serializeEncryptedData } from '@/lib/bots/encryption';
import { v4 as uuid } from 'uuid';
@@ -19,6 +19,7 @@ interface ImportManifest {
handle: string;
sourceNode: string;
exportedAt: string;
expiresAt?: string; // Optional for backward compatibility
publicKey: string;
privateKeyEncrypted: string;
salt: string;
@@ -38,12 +39,15 @@ interface ImportPost {
content: string;
createdAt: string;
replyToApId: string | null;
media: { filename: string; url: string; altText: string | null }[];
media: { filename: string; url: string; altText: string | null; isIPFS?: boolean }[];
}
interface ImportFollowing {
actorUrl: string;
handle: string;
isRemote?: boolean;
inboxUrl?: string;
activityId?: string;
}
interface ImportDMConversation {
@@ -167,6 +171,16 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ error: 'Unsupported export version' }, { status: 400 });
}
// Check if export has expired
if (manifest.expiresAt) {
const expiresAt = new Date(manifest.expiresAt);
if (expiresAt < new Date()) {
return NextResponse.json({
error: 'Export has expired. Please create a new export from your old node.'
}, { status: 400 });
}
}
const { dms: importDMs = [], bots: importBots = [] } = exportData;
// Verify signature
@@ -263,12 +277,14 @@ export async function POST(req: NextRequest) {
apUrl: `https://${nodeDomain}/posts/${uuid()}`,
}).returning();
// Import media references (URLs point to old location for now)
// Import media references
// For IPFS media (ipfs://hash), the URL works on any node
// For legacy S3 media, URL points to old node (may break if old node goes down)
for (const mediaItem of post.media) {
await db.insert(media).values({
userId: newUser.id,
postId: newPost.id,
url: mediaItem.url, // Original URL - might need re-uploading
url: mediaItem.url, // IPFS URLs are portable, S3 URLs are not
altText: mediaItem.altText,
});
}
@@ -287,6 +303,49 @@ export async function POST(req: NextRequest) {
updatedAt: new Date().toISOString(),
}]);
// Import following list
let importedFollowing = 0;
for (const follow of following) {
try {
if (follow.isRemote) {
// Remote follow - add to remoteFollows table
await db.insert(remoteFollows).values({
followerId: newUser.id,
targetHandle: follow.handle,
targetActorUrl: follow.actorUrl || `https://${follow.handle.split('@')[1]}/users/${follow.handle.split('@')[0]}`,
inboxUrl: follow.inboxUrl || `https://${follow.handle.split('@')[1]}/inbox`,
activityId: follow.activityId || `migrate-${uuid()}`,
});
} else {
// Local follow - look up user and add to follows table
const targetUser = await db.query.users.findFirst({
where: eq(users.handle, follow.handle.toLowerCase()),
});
if (targetUser) {
await db.insert(follows).values({
followerId: newUser.id,
followingId: targetUser.id,
});
// Increment following count on target user
await db.update(users)
.set({ followersCount: sql`${users.followersCount} + 1` })
.where(eq(users.id, targetUser.id));
} else {
// Local user not found, convert to remote follow
console.log(`[Import] Local user @${follow.handle} not found, skipping follow`);
}
}
importedFollowing++;
} catch (error) {
console.error(`[Import] Failed to restore follow for @${follow.handle}:`, error);
}
}
// Update user's following count
await db.update(users)
.set({ followingCount: importedFollowing })
.where(eq(users.id, newUser.id));
// Import DMs
for (const conv of importDMs) {
try {
@@ -404,7 +463,7 @@ export async function POST(req: NextRequest) {
},
stats: {
postsImported: importedPosts,
followingToRestore: following.length,
followingImported: importedFollowing,
dmsImported: importDMs.length,
botsImported: importBots.length,
},
+35 -1
View File
@@ -3,6 +3,7 @@ import { registerUser, createSession } from '@/lib/auth';
import { db, nodes, users } from '@/db';
import { eq } from 'drizzle-orm';
import { verifyTurnstileToken } from '@/lib/turnstile';
import { testS3Credentials } from '@/lib/storage/s3';
import { z } from 'zod';
const registerSchema = z.object({
@@ -11,6 +12,13 @@ const registerSchema = z.object({
password: z.string().min(8),
displayName: z.string().optional(),
turnstileToken: z.string().nullable().optional(),
// S3-compatible storage credentials
storageProvider: z.string().min(1),
storageEndpoint: z.string().nullable().optional(),
storageRegion: z.string().min(1),
storageBucket: z.string().min(1),
storageAccessKey: z.string().min(10),
storageSecretKey: z.string().min(10),
});
export async function POST(request: Request) {
@@ -36,11 +44,37 @@ export async function POST(request: Request) {
}
}
// Test S3 credentials before creating account
console.log('[Register] Testing S3 credentials...');
const s3Test = await testS3Credentials(
data.storageEndpoint || null,
data.storageRegion,
data.storageBucket,
data.storageAccessKey,
data.storageSecretKey
);
if (!s3Test.success) {
console.error('[Register] S3 credential test failed:', s3Test.error);
return NextResponse.json(
{ error: `Storage connection failed: ${s3Test.error}` },
{ status: 400 }
);
}
console.log('[Register] S3 credentials verified successfully');
const user = await registerUser(
data.handle,
data.email,
data.password,
data.displayName
data.displayName,
data.storageProvider,
data.storageEndpoint || null,
data.storageRegion,
data.storageBucket,
data.storageAccessKey,
data.storageSecretKey
);
// Check if this is an NSFW node and auto-enable NSFW settings
+26 -1
View File
@@ -17,6 +17,7 @@ import {
BotHandleTakenError,
BotValidationError,
} from '@/lib/bots/botManager';
import { generateAndUploadAvatarToUserStorage } from '@/lib/storage/s3';
// Schema for creating a bot
const createBotSchema = z.object({
@@ -42,6 +43,8 @@ const createBotSchema = z.object({
timezone: z.string().optional(),
}).optional(),
autonomousMode: z.boolean().optional(),
// Optional: password to generate avatar using owner's S3 storage
ownerPassword: z.string().optional(),
});
/**
@@ -58,11 +61,33 @@ export async function POST(request: Request) {
const body = await request.json();
const data = createBotSchema.parse(body);
// Generate bot avatar using owner's S3 storage if password provided and no avatar URL
let botAvatarUrl = data.avatarUrl;
if (!botAvatarUrl && data.ownerPassword && user.storageAccessKeyEncrypted && user.storageSecretKeyEncrypted && user.storageBucket) {
try {
const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:3000';
const botHandle = `${data.handle.toLowerCase()}@${nodeDomain}`;
botAvatarUrl = await generateAndUploadAvatarToUserStorage(
botHandle,
user.storageEndpoint,
user.storageRegion || 'auto',
user.storageBucket,
user.storageAccessKeyEncrypted,
user.storageSecretKeyEncrypted,
data.ownerPassword
);
} catch (err) {
console.error('[Bot API] Failed to generate bot avatar:', err);
// Continue without avatar - user can set it later
}
}
const bot = await createBot(user.id, {
name: data.name,
handle: data.handle,
bio: data.bio,
avatarUrl: data.avatarUrl,
avatarUrl: botAvatarUrl,
headerUrl: data.headerUrl,
personality: data.personality,
llmProvider: data.llmProvider,
+127 -34
View File
@@ -1,26 +1,93 @@
import { NextRequest, NextResponse } from 'next/server';
import { db } from '@/db';
import { chatConversations, chatMessages, users, handleRegistry } from '@/db/schema';
import { eq, and } from 'drizzle-orm';
import { verifyActionSignature, type SignedAction } from '@/lib/auth/verify-signature';
import { verifySwarmRequest } from '@/lib/swarm/signature';
import { fetchAndCacheRemoteKey, logKeyChange } from '@/lib/swarm/identity-cache';
import { z } from 'zod';
// Schema for direct signed action (legacy)
const chatReceiveSchema = z.object({
did: z.string().regex(/^did:/, 'Must be a valid DID'),
handle: z.string().min(3).max(30),
data: z.object({
recipientDid: z.string().regex(/^did:/, 'Must be a valid DID'),
content: z.string().min(1).max(5000),
}),
signature: z.string(),
timestamp: z.number().optional(),
});
// Schema for federated envelope
const federatedEnvelopeSchema = z.object({
userAction: chatReceiveSchema,
fullSenderHandle: z.string().min(3).max(60),
sourceDomain: z.string().min(1),
ts: z.number(),
});
/**
* POST /api/chat/receive
* Endpoint for receiving federated chat messages from other nodes.
* Expects a SignedAction payload from the sender.
* Expects either:
* 1. A SignedAction payload from the sender (legacy, for backward compatibility)
* 2. A federated envelope with node's signature and user's signed action
*/
export async function POST(request: NextRequest) {
try {
const signedAction: SignedAction = await request.json();
const body = await request.json();
// Check if this is a federated envelope (node-signed)
const swarmSignature = request.headers.get('X-Swarm-Signature');
const sourceDomain = request.headers.get('X-Swarm-Source-Domain');
let signedAction: SignedAction;
let fullSenderHandle: string | null = null;
if (swarmSignature && sourceDomain && body.userAction) {
// Federated envelope format - validate and verify node signature
const envelopeValidation = federatedEnvelopeSchema.safeParse(body);
if (!envelopeValidation.success) {
return NextResponse.json(
{ error: 'Invalid envelope payload', details: envelopeValidation.error.issues },
{ status: 400 }
);
}
const isValidNodeSig = await verifySwarmRequest(
{ userAction: body.userAction, fullSenderHandle: body.fullSenderHandle, sourceDomain: body.sourceDomain, ts: body.ts },
swarmSignature,
sourceDomain
);
if (!isValidNodeSig) {
console.error('[Chat Receive] Invalid node signature from:', sourceDomain);
return NextResponse.json({ error: 'Invalid node signature' }, { status: 403 });
}
// Extract user's signed action and full handle from envelope
signedAction = body.userAction;
fullSenderHandle = body.fullSenderHandle;
console.log(`[Chat Receive] Federated envelope from node: ${sourceDomain}, full handle: ${fullSenderHandle}`);
} else {
// Legacy format - direct user signed action
const actionValidation = chatReceiveSchema.safeParse(body);
if (!actionValidation.success) {
return NextResponse.json(
{ error: 'Invalid action payload', details: actionValidation.error.issues },
{ status: 400 }
);
}
signedAction = body;
}
const { did, handle, data } = signedAction;
const { recipientDid, content } = data || {};
if (!did || !handle || !recipientDid || !content) {
return NextResponse.json({ error: 'Invalid payload' }, { status: 400 });
}
console.log(`[Chat Receive] From: ${handle} (DID: ${did}), To: ${recipientDid}`);
// Use full handle if provided in envelope, otherwise fall back to signed handle
const senderHandle = fullSenderHandle || handle;
console.log(`[Chat Receive] From: ${senderHandle} (DID: ${did}), To: ${recipientDid}`);
// 1. Resolve Sender Public Key
let senderUser = await db.query.users.findFirst({
@@ -28,15 +95,15 @@ export async function POST(request: NextRequest) {
});
let publicKey = senderUser?.publicKey;
let senderDisplayName = senderUser?.displayName || handle;
let senderDisplayName = senderUser?.displayName || senderHandle;
let senderAvatarUrl = senderUser?.avatarUrl;
let senderNodeDomain: string | null = null;
if (!senderUser) {
// Unknown user - likely remote. We need to fetch their profile to get the public key.
// Derive domain from handle if possible
if (handle.includes('@')) {
const parts = handle.split('@');
// Derive domain from full sender handle if possible
if (senderHandle.includes('@')) {
const parts = senderHandle.split('@');
senderNodeDomain = parts[parts.length - 1];
} else {
// Try to get from header first
@@ -55,26 +122,44 @@ export async function POST(request: NextRequest) {
if (senderNodeDomain) {
try {
const protocol = senderNodeDomain.includes('localhost') ? 'http' : 'https';
// Fetch profile from remote node
// Assuming /api/users/:handle convention
const remoteHandle = handle.includes('@') ? handle.split('@')[0] : handle;
const res = await fetch(`${protocol}://${senderNodeDomain}/api/users/${remoteHandle}`);
const remoteHandle = senderHandle.includes('@') ? senderHandle.split('@')[0] : senderHandle;
if (res.ok) {
const profileData = await res.json();
const remoteProfile = profileData.user;
if (remoteProfile && remoteProfile.publicKey) {
if (remoteProfile.did !== did) {
console.error('DID mismatch for remote user');
} else {
publicKey = remoteProfile.publicKey;
senderDisplayName = remoteProfile.displayName || handle;
senderAvatarUrl = remoteProfile.avatarUrl;
// Fetch public key with TOFU validation
const { publicKey: cachedOrFreshKey, fromCache, keyChanged } = await fetchAndCacheRemoteKey(
did,
senderHandle,
senderNodeDomain,
async () => {
// Fetch profile from remote node
const res = await fetch(`${protocol}://${senderNodeDomain}/api/users/${remoteHandle}`);
if (!res.ok) return null;
const profileData = await res.json();
const remoteProfile = profileData.user;
if (remoteProfile?.did !== did) {
console.error('[Chat Receive] DID mismatch for remote user');
return null;
}
return remoteProfile?.publicKey || null;
}
);
if (cachedOrFreshKey) {
publicKey = cachedOrFreshKey;
console.log(`[Chat Receive] Using ${fromCache ? 'cached' : 'fetched'} public key for ${senderHandle}${keyChanged ? ' (KEY CHANGED!)' : ''}`);
// Also fetch display name/avatar if not from cache (or if we need fresh data)
if (!fromCache || keyChanged) {
const res = await fetch(`${protocol}://${senderNodeDomain}/api/users/${remoteHandle}`);
if (res.ok) {
const profileData = await res.json();
const remoteProfile = profileData.user;
senderDisplayName = remoteProfile?.displayName || senderHandle;
senderAvatarUrl = remoteProfile?.avatarUrl;
// CACHE: Upsert the remote user into our local database
const { upsertRemoteUser } = await import('@/lib/swarm/user-cache');
await upsertRemoteUser({
handle: handle, // already full handle if remote
handle: senderHandle, // use full handle (user@domain)
displayName: senderDisplayName,
avatarUrl: senderAvatarUrl || null,
did: did || '',
@@ -83,7 +168,7 @@ export async function POST(request: NextRequest) {
}
}
} else {
console.error('Failed to fetch remote profile:', res.status);
console.error('Failed to fetch remote profile: no key returned');
}
} catch (e) {
console.error('Remote profile fetch failed:', e);
@@ -112,20 +197,20 @@ export async function POST(request: NextRequest) {
// 4. Find or Create Conversation
// For the RECIPIENT, the conversation is with the SENDER
// Use full handle
const fullSenderHandle = handle.includes('@') ? handle : (senderNodeDomain ? `${handle}@${senderNodeDomain}` : handle);
// Use full handle from envelope if available, otherwise construct from handle + domain
const computedFullSenderHandle = senderHandle.includes('@') ? senderHandle : (senderNodeDomain ? `${senderHandle}@${senderNodeDomain}` : senderHandle);
let conversation = await db.query.chatConversations.findFirst({
where: and(
eq(chatConversations.participant1Id, recipientUser.id),
eq(chatConversations.participant2Handle, fullSenderHandle)
eq(chatConversations.participant2Handle, computedFullSenderHandle)
)
});
if (!conversation) {
const [newConv] = await db.insert(chatConversations).values({
participant1Id: recipientUser.id,
participant2Handle: fullSenderHandle,
participant2Handle: computedFullSenderHandle,
lastMessageAt: new Date(),
lastMessagePreview: content.slice(0, 50)
}).returning();
@@ -144,7 +229,7 @@ export async function POST(request: NextRequest) {
// 5. Store Message
await db.insert(chatMessages).values({
conversationId: conversation.id,
senderHandle: fullSenderHandle,
senderHandle: computedFullSenderHandle,
senderDisplayName: senderDisplayName,
senderAvatarUrl: senderAvatarUrl,
senderNodeDomain: senderNodeDomain,
@@ -156,7 +241,7 @@ export async function POST(request: NextRequest) {
// 6. Update Registry (to ensure we can reply efficiently)
if (senderNodeDomain) {
await db.insert(handleRegistry).values({
handle: fullSenderHandle, // user@domain
handle: computedFullSenderHandle, // user@domain
did: did,
nodeDomain: senderNodeDomain
}).onConflictDoUpdate({
@@ -172,6 +257,14 @@ export async function POST(request: NextRequest) {
} catch (error: any) {
console.error('Federated Receive Failed:', error);
if (error instanceof z.ZodError) {
return NextResponse.json(
{ error: 'Invalid input', details: error.issues },
{ status: 400 }
);
}
return NextResponse.json({ error: error.message }, { status: 500 });
}
}
+31 -5
View File
@@ -4,10 +4,13 @@ import { chatConversations, chatMessages, users, handleRegistry, follows } from
import { requireSignedAction } from '@/lib/auth/verify-signature';
import { eq, and } from 'drizzle-orm';
import { z } from 'zod';
import { createSignedPayload } from '@/lib/swarm/signature';
const handleRegex = /^[a-zA-Z0-9_]{3,20}$/;
const chatSendSchema = z.object({
recipientDid: z.string(),
recipientHandle: z.string(),
recipientDid: z.string().min(1).regex(/^did:/, 'Must be a valid DID (did:key:... or did:synapsis:...)'),
recipientHandle: z.string().min(3).max(30).regex(handleRegex, 'Handle must be 3-20 characters, alphanumeric and underscores only'),
content: z.string().min(1).max(5000),
});
@@ -33,7 +36,14 @@ export async function POST(request: NextRequest) {
where: eq(users.did, recipientDid)
});
if (recipientUser) {
// Check if recipient is a local user (not remote/swarm cached)
// Remote users have handles with @domain or IDs starting with "swarm:"
const isRemoteUser = recipientUser && (
recipientUser.handle.includes('@') ||
recipientUser.id.startsWith('swarm:')
);
if (recipientUser && !isRemoteUser) {
// Reject if recipient is a bot
if (recipientUser.isBot) {
return NextResponse.json({ error: 'Cannot DM a bot account' }, { status: 400 });
@@ -165,19 +175,35 @@ export async function POST(request: NextRequest) {
const protocol = targetDomain.includes('localhost') ? 'http' : 'https';
const sourceDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || '';
// Construct full sender handle for federation
const fullSenderHandle = `${user.handle}@${sourceDomain}`;
console.log(`[Remote Send] Debug:`, {
targetDomain,
targetUrl: `${protocol}://${targetDomain}/api/chat/receive`,
sourceDomainEnv: sourceDomain,
fullSenderHandle,
});
// Create a federated envelope with node's signature
// This wraps the user's signed action with the full handle
const federatedPayload = {
userAction: signedAction,
fullSenderHandle,
sourceDomain,
ts: Date.now(),
};
const { payload, signature } = await createSignedPayload(federatedPayload);
const res = await fetch(`${protocol}://${targetDomain}/api/chat/receive`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Swarm-Source-Domain': sourceDomain
'X-Swarm-Source-Domain': sourceDomain,
'X-Swarm-Signature': signature,
},
body: JSON.stringify(signedAction) // Forward the user's signed intent
body: JSON.stringify(payload)
});
if (!res.ok) {
+21 -2
View File
@@ -2,6 +2,9 @@ import { NextResponse } from 'next/server';
import { db, handleRegistry } from '@/db';
import { eq } from 'drizzle-orm';
import { normalizeHandle, upsertHandleEntries } from '@/lib/federation/handles';
import { z } from 'zod';
const handleParamSchema = z.string().min(3).max(40).regex(/^[a-zA-Z0-9_]+(@[a-zA-Z0-9][a-zA-Z0-9-]{1,61}[a-zA-Z0-9]\.[a-zA-Z]{2,})?$/, 'Invalid handle format');
const parseHandleWithDomain = (handle: string) => {
const clean = normalizeHandle(handle);
@@ -19,12 +22,22 @@ export async function GET(request: Request) {
}
const { searchParams } = new URL(request.url);
const handleParam = searchParams.get('handle');
const handleParamRaw = searchParams.get('handle');
if (!handleParam) {
if (!handleParamRaw) {
return NextResponse.json({ error: 'Handle is required' }, { status: 400 });
}
// Validate handle format
const handleValidation = handleParamSchema.safeParse(handleParamRaw);
if (!handleValidation.success) {
return NextResponse.json(
{ error: 'Invalid handle format', details: handleValidation.error.issues },
{ status: 400 }
);
}
const handleParam = handleValidation.data;
const parsed = parseHandleWithDomain(handleParam);
const lookupHandle = parsed ? parsed.handle : normalizeHandle(handleParam);
const localEntry = await db.query.handleRegistry.findFirst({
@@ -63,6 +76,12 @@ export async function GET(request: Request) {
return NextResponse.json(entry);
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json(
{ error: 'Invalid input', details: error.issues },
{ status: 400 }
);
}
console.error('Handle resolve error:', error);
return NextResponse.json({ error: 'Failed to resolve handle' }, { status: 500 });
}
+40 -38
View File
@@ -1,7 +1,7 @@
import { NextRequest, NextResponse } from 'next/server';
import { db, media } from '@/db';
import { requireAuth } from '@/lib/auth';
import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3';
import { uploadToUserStorage } from '@/lib/storage/s3';
import { v4 as uuidv4 } from 'uuid';
const MAX_IMAGE_SIZE = 10 * 1024 * 1024; // 10MB for images
@@ -16,6 +16,7 @@ export async function POST(req: NextRequest) {
const formData = await req.formData();
const file = formData.get('file') as File | null;
const altText = (formData.get('alt') as string | null) || null;
const password = formData.get('password') as string | null;
if (!file) {
return NextResponse.json({ error: 'No file provided' }, { status: 400 });
@@ -39,47 +40,43 @@ export async function POST(req: NextRequest) {
}, { status: 400 });
}
const buffer = Buffer.from(await file.arrayBuffer());
// Sanitize filename to be safe for S3 keys
const filename = `${uuidv4()}-${file.name.replace(/[^a-zA-Z0-9.-]/g, '')}`;
// S3 Configuration
const s3 = new S3Client({
region: process.env.STORAGE_REGION || 'us-east-1',
endpoint: process.env.STORAGE_ENDPOINT,
credentials: {
accessKeyId: process.env.STORAGE_ACCESS_KEY || '',
secretAccessKey: process.env.STORAGE_SECRET_KEY || '',
},
forcePathStyle: true, // Needed for many S3-compatible providers
});
const bucket = process.env.STORAGE_BUCKET || 'synapsis';
await s3.send(new PutObjectCommand({
Bucket: bucket,
Key: filename,
Body: buffer,
ContentType: file.type,
ACL: 'public-read',
}));
// Construct Public URL
let url = '';
if (process.env.STORAGE_PUBLIC_BASE_URL) {
url = `${process.env.STORAGE_PUBLIC_BASE_URL}/${filename}`;
} else if (process.env.STORAGE_ENDPOINT) {
url = `${process.env.STORAGE_ENDPOINT}/${bucket}/${filename}`;
} else {
return NextResponse.json({ error: 'Storage not configured - missing STORAGE_PUBLIC_BASE_URL or STORAGE_ENDPOINT' }, { status: 500 });
// Check if user has S3 storage configured
if (!user.storageProvider || !user.storageAccessKeyEncrypted || !user.storageSecretKeyEncrypted) {
return NextResponse.json({
error: 'Storage not configured. Please set up S3-compatible storage in your settings.'
}, { status: 400 });
}
// Store media record
// Require password to decrypt storage credentials
if (!password) {
return NextResponse.json({
error: 'Password required to upload media. Your storage credentials are encrypted and need your password to decrypt.'
}, { status: 401 });
}
const buffer = Buffer.from(await file.arrayBuffer());
const filename = `${uuidv4()}-${file.name.replace(/[^a-zA-Z0-9.-]/g, '')}`;
// Upload to user's own S3-compatible storage
const uploadResult = await uploadToUserStorage(
buffer,
filename,
file.type,
user.storageProvider as any,
user.storageEndpoint,
user.storageRegion || 'us-east-1',
user.storageBucket || '',
user.storageAccessKeyEncrypted,
user.storageSecretKeyEncrypted,
password
);
// Store media record with S3 URL
if (db) {
const [mediaRecord] = await db.insert(media).values({
userId: user.id,
postId: null,
url,
url: uploadResult.url,
altText,
mimeType: file.type,
width: 0, // TODO: Get actual dimensions
@@ -89,19 +86,24 @@ export async function POST(req: NextRequest) {
return NextResponse.json({
success: true,
media: mediaRecord,
url,
url: uploadResult.url,
key: uploadResult.key,
});
}
return NextResponse.json({
success: true,
url,
url: uploadResult.url,
key: uploadResult.key,
});
} catch (error) {
if (error instanceof Error && error.message === 'Authentication required') {
return NextResponse.json({ error: 'Authentication required' }, { status: 401 });
}
if (error instanceof Error && error.message.includes('Storage')) {
return NextResponse.json({ error: error.message }, { status: 400 });
}
console.error('Upload error:', error);
return NextResponse.json({ error: 'Upload failed' }, { status: 500 });
}
+30 -11
View File
@@ -2,11 +2,18 @@ import { NextResponse } from 'next/server';
import { db, posts, likes, users, notifications } from '@/db';
import { requireAuth } from '@/lib/auth';
import { requireSignedAction, type SignedAction } from '@/lib/auth/verify-signature';
import { eq, and } from 'drizzle-orm';
import { eq, and, sql } from 'drizzle-orm';
import { z } from 'zod';
import crypto from 'crypto';
type RouteContext = { params: Promise<{ id: string }> };
// UUID or swarm post ID format (swarm:domain:uuid)
const postIdSchema = z.union([
z.string().uuid(),
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'),
]);
/**
* Extract domain from a swarm post ID (swarm:domain:postId)
*/
@@ -43,9 +50,10 @@ export async function POST(request: Request, context: RouteContext) {
// Verify the signature and get the user
const user = await requireSignedAction(signedAction);
// Extract postId from the signed action data
// Extract and validate postId from the signed action data
const { postId: rawId } = signedAction.data;
const postId = decodeURIComponent(rawId);
const decodedId = decodeURIComponent(rawId);
const postId = postIdSchema.parse(decodedId);
const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:3000';
if (user.isSuspended || user.isSilenced) {
@@ -118,9 +126,9 @@ export async function POST(request: Request, context: RouteContext) {
postId,
});
// Update post's like count
// Update post's like count (atomic increment)
await db.update(posts)
.set({ likesCount: post.likesCount + 1 })
.set({ likesCount: sql`${posts.likesCount} + 1` })
.where(eq(posts.id, postId));
if (post.userId !== user.id) {
@@ -187,7 +195,9 @@ export async function POST(request: Request, context: RouteContext) {
console.warn(`[Swarm] Like delivery failed: ${result.error}`);
}
} catch (err) {
console.error('[Swarm] Error delivering like:', err);
// Log error with context but don't fail the request - swarm delivery is best-effort
console.error('[Like] Error delivering like to swarm:', err);
console.error('[Like] Context:', { postId: originalPostId, userId: user.id, targetDomain });
}
})();
}
@@ -197,6 +207,9 @@ export async function POST(request: Request, context: RouteContext) {
return NextResponse.json({ success: true, liked: true });
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json({ error: 'Invalid post ID', details: error.issues }, { status: 400 });
}
if (error instanceof Error) {
// Handle signature verification errors
if (error.message === 'User not found' ||
@@ -222,9 +235,10 @@ export async function DELETE(request: Request, context: RouteContext) {
// Verify the signature and get the user
const user = await requireSignedAction(signedAction);
// Extract postId from the signed action data
// Extract and validate postId from the signed action data
const { postId: rawId } = signedAction.data;
const postId = decodeURIComponent(rawId);
const decodedId = decodeURIComponent(rawId);
const postId = postIdSchema.parse(decodedId);
const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:3000';
if (user.isSuspended || user.isSilenced) {
@@ -289,9 +303,9 @@ export async function DELETE(request: Request, context: RouteContext) {
// Remove like
await db.delete(likes).where(eq(likes.id, existingLike.id));
// Update post's like count
// Update post's like count (atomic decrement, clamped to 0)
await db.update(posts)
.set({ likesCount: Math.max(0, post.likesCount - 1) })
.set({ likesCount: sql`GREATEST(0, ${posts.likesCount} - 1)` })
.where(eq(posts.id, postId));
// SWARM-FIRST: Deliver unlike to swarm node
@@ -320,7 +334,9 @@ export async function DELETE(request: Request, context: RouteContext) {
console.warn(`[Swarm] Unlike delivery failed: ${result.error}`);
}
} catch (err) {
console.error('[Swarm] Error delivering unlike:', err);
// Log error with context but don't fail the request - swarm delivery is best-effort
console.error('[Like] Error delivering unlike to swarm:', err);
console.error('[Like] Context:', { postId: originalPostId, userId: user.id, targetDomain });
}
})();
}
@@ -328,6 +344,9 @@ export async function DELETE(request: Request, context: RouteContext) {
return NextResponse.json({ success: true, liked: false });
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json({ error: 'Invalid post ID', details: error.issues }, { status: 400 });
}
if (error instanceof Error) {
// Handle signature verification errors
if (error.message === 'User not found' ||
+22 -7
View File
@@ -1,11 +1,18 @@
import { NextResponse } from 'next/server';
import { db, posts, users, notifications } from '@/db';
import { requireAuth } from '@/lib/auth';
import { eq, and } from 'drizzle-orm';
import { eq, and, sql } from 'drizzle-orm';
import { z } from 'zod';
import crypto from 'crypto';
type RouteContext = { params: Promise<{ id: string }> };
// UUID or swarm post ID format (swarm:domain:uuid)
const postIdSchema = z.union([
z.string().uuid(),
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'),
]);
/**
* Extract domain from a swarm post ID (swarm:domain:postId)
*/
@@ -38,7 +45,8 @@ export async function POST(request: Request, context: RouteContext) {
try {
const user = await requireAuth();
const { id: rawId } = await context.params;
const postId = decodeURIComponent(rawId);
const decodedId = decodeURIComponent(rawId);
const postId = postIdSchema.parse(decodedId);
const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:3000';
if (user.isSuspended || user.isSilenced) {
@@ -116,12 +124,12 @@ export async function POST(request: Request, context: RouteContext) {
// Update original post's repost count
await db.update(posts)
.set({ repostsCount: originalPost.repostsCount + 1 })
.set({ repostsCount: sql`${posts.repostsCount} + 1` })
.where(eq(posts.id, postId));
// Update user's post count
await db.update(users)
.set({ postsCount: user.postsCount + 1 })
.set({ postsCount: sql`${users.postsCount} + 1` })
.where(eq(users.id, user.id));
if (originalPost.userId !== user.id) {
@@ -196,6 +204,9 @@ export async function POST(request: Request, context: RouteContext) {
return NextResponse.json({ success: true, repost, reposted: true });
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json({ error: 'Invalid post ID', details: error.issues }, { status: 400 });
}
if (error instanceof Error && error.message === 'Authentication required') {
return NextResponse.json({ error: 'Authentication required' }, { status: 401 });
}
@@ -208,7 +219,8 @@ export async function DELETE(request: Request, context: RouteContext) {
try {
const user = await requireAuth();
const { id: rawId } = await context.params;
const postId = decodeURIComponent(rawId);
const decodedId = decodeURIComponent(rawId);
const postId = postIdSchema.parse(decodedId);
const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:3000';
if (user.isSuspended || user.isSilenced) {
@@ -272,17 +284,20 @@ export async function DELETE(request: Request, context: RouteContext) {
// Update original post's repost count
if (originalPost) {
await db.update(posts)
.set({ repostsCount: Math.max(0, originalPost.repostsCount - 1) })
.set({ repostsCount: sql`GREATEST(0, ${posts.repostsCount} - 1)` })
.where(eq(posts.id, postId));
}
// Update user's post count
await db.update(users)
.set({ postsCount: Math.max(0, user.postsCount - 1) })
.set({ postsCount: sql`GREATEST(0, ${users.postsCount} - 1)` })
.where(eq(users.id, user.id));
return NextResponse.json({ success: true, reposted: false });
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json({ error: 'Invalid post ID', details: error.issues }, { status: 400 });
}
if (error instanceof Error && error.message === 'Authentication required') {
return NextResponse.json({ error: 'Authentication required' }, { status: 401 });
}
+18 -10
View File
@@ -1,6 +1,19 @@
import { NextResponse } from 'next/server';
import { db, posts, users, media, remotePosts } from '@/db';
import { eq, desc, and } from 'drizzle-orm';
import { eq, desc, and, sql } from 'drizzle-orm';
import { z } from 'zod';
// Schema for local post ID (UUID)
const localPostIdSchema = z.string().uuid('Invalid post ID format');
// Schema for swarm post ID (swarm:domain:uuid)
const swarmPostIdSchema = z.string().regex(
/^swarm:[^:]+:[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i,
'Invalid swarm post ID format'
);
// Combined schema that accepts either format
const postIdSchema = z.union([localPostIdSchema, swarmPostIdSchema]);
export async function GET(
request: Request,
@@ -424,15 +437,10 @@ export async function DELETE(
// 3. Delete the post (cascades to media, likes, notifications)
await db.delete(posts).where(eq(posts.id, id));
// 4. Decrement the post author's postsCount
const postAuthor = await db.query.users.findFirst({
where: eq(users.id, post.userId),
});
if (postAuthor && postAuthor.postsCount > 0) {
await db.update(users)
.set({ postsCount: postAuthor.postsCount - 1 })
.where(eq(users.id, post.userId));
}
// 4. Decrement the post author's postsCount (atomic decrement, clamped to 0)
await db.update(users)
.set({ postsCount: sql`GREATEST(0, ${users.postsCount} - 1)` })
.where(eq(users.id, post.userId));
return NextResponse.json({ success: true });
} catch (error) {
+17 -16
View File
@@ -2,7 +2,7 @@ import { NextResponse } from 'next/server';
import { db, posts, users, media, follows, mutes, blocks, likes, remoteFollows, remotePosts } 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 } from 'drizzle-orm';
import { eq, desc, and, inArray, isNull, isNotNull, or, lt, sql } from 'drizzle-orm';
import type { SQL } from 'drizzle-orm';
import { z } from 'zod';
@@ -19,7 +19,7 @@ const buildWhere = (...conditions: Array<SQL | undefined>) => {
const createPostSchema = z.object({
content: z.string().min(1).max(POST_MAX_LENGTH),
replyToId: z.string().optional(), // Can be UUID or swarm:domain:uuid
replyToId: z.string().uuid().optional(), // Must be UUID (swarm replies use separate field)
swarmReplyTo: z.object({
postId: z.string(),
nodeDomain: z.string(),
@@ -105,21 +105,16 @@ export async function POST(request: Request) {
});
}
// Update user's post count
// Update user's post count (atomic increment to prevent race conditions)
await db.update(users)
.set({ postsCount: user.postsCount + 1 })
.set({ postsCount: sql`${users.postsCount} + 1` })
.where(eq(users.id, user.id));
// If this is a reply, update the parent's reply count
// If this is a reply, update the parent's reply count (atomic increment)
if (data.replyToId) {
const parentPost = await db.query.posts.findFirst({
where: eq(posts.id, data.replyToId),
});
if (parentPost) {
await db.update(posts)
.set({ repliesCount: parentPost.repliesCount + 1 })
.where(eq(posts.id, data.replyToId));
}
await db.update(posts)
.set({ repliesCount: sql`${posts.repliesCount} + 1` })
.where(eq(posts.id, data.replyToId));
}
// DEPRECATED: Push-based federation disabled
@@ -213,7 +208,9 @@ export async function POST(request: Request) {
}
}
} catch (err) {
console.error('[Local] Error creating mention notifications:', err);
// Log error with context but don't fail the request - mention notifications are best-effort
console.error('[Posts] Error creating mention notifications:', err);
console.error('[Posts] Context:', { postId: post.id, userId: user.id, content: data.content?.slice(0, 100) });
}
})();
@@ -237,7 +234,9 @@ export async function POST(request: Request) {
console.log(`[Swarm] Delivered ${result.delivered} mentions (${result.failed} failed)`);
}
} catch (err) {
console.error('[Swarm] Error delivering mentions:', err);
// Log error with context but don't fail the request - swarm delivery is best-effort
console.error('[Posts] Error delivering swarm mentions:', err);
console.error('[Posts] Context:', { postId: post.id, userId: user.id, nodeDomain });
}
})();
@@ -264,7 +263,9 @@ export async function POST(request: Request) {
console.log(`[Swarm] Post ${post.id} delivered to ${swarmResult.delivered} swarm nodes (${swarmResult.failed} failed)`);
}
} catch (err) {
console.error('[Swarm] Error delivering post:', err);
// Log error with context but don't fail the request - swarm delivery is best-effort
console.error('[Posts] Error delivering post to swarm followers:', err);
console.error('[Posts] Context:', { postId: post.id, userId: user.id, nodeDomain });
}
})();
@@ -8,6 +8,15 @@ 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 { z } from 'zod';
// Schema for conversation ID parameter
const conversationIdSchema = z.string().uuid('Invalid conversation ID format');
// Schema for delete query parameter
const deleteQuerySchema = z.object({
deleteFor: z.enum(['self', 'both']).optional(),
});
export async function DELETE(
request: NextRequest,
@@ -24,8 +33,23 @@ export async function DELETE(
}
const { id } = await params;
// Validate conversation ID
const idResult = conversationIdSchema.safeParse(id);
if (!idResult.success) {
return NextResponse.json({ error: 'Invalid conversation ID', details: idResult.error.issues }, { status: 400 });
}
const { searchParams } = new URL(request.url);
const deleteFor = searchParams.get('deleteFor'); // 'self' or 'both'
const deleteForRaw = searchParams.get('deleteFor'); // 'self' or 'both'
// Validate deleteFor parameter
const deleteForResult = deleteQuerySchema.safeParse({ deleteFor: deleteForRaw || undefined });
if (!deleteForResult.success) {
return NextResponse.json({ error: 'Invalid deleteFor parameter', details: deleteForResult.error.issues }, { status: 400 });
}
const { deleteFor } = deleteForResult.data;
// Verify the conversation belongs to this user
const conversation = await db.query.chatConversations.findFirst({
@@ -118,6 +142,9 @@ export async function DELETE(
});
}
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json({ error: 'Invalid input', details: error.issues }, { status: 400 });
}
console.error('Delete conversation error:', error);
return NextResponse.json({ error: 'Failed to delete conversation' }, { status: 500 });
}
+38 -9
View File
@@ -9,6 +9,19 @@ import { NextRequest, NextResponse } from 'next/server';
import { db, chatConversations, chatMessages, users } from '@/db';
import { eq, desc, and, lt, isNull, sql, inArray } from 'drizzle-orm';
import { getSession } from '@/lib/auth';
import { z } from 'zod';
// Schema for query parameters
const messagesQuerySchema = z.object({
conversationId: z.string().uuid(),
cursor: z.string().datetime().optional(),
limit: z.number().min(1).max(100).default(50),
});
// Schema for PATCH request body
const markReadSchema = z.object({
conversationId: z.string().uuid(),
});
export async function GET(request: NextRequest) {
@@ -23,14 +36,20 @@ export async function GET(request: NextRequest) {
}
const { searchParams } = new URL(request.url);
const conversationId = searchParams.get('conversationId');
const cursor = searchParams.get('cursor'); // For pagination
const limit = Math.min(parseInt(searchParams.get('limit') || '50'), 100);
// Validate query parameters
const queryResult = messagesQuerySchema.safeParse({
conversationId: searchParams.get('conversationId'),
cursor: searchParams.get('cursor') || undefined,
limit: parseInt(searchParams.get('limit') || '50'),
});
if (!conversationId) {
return NextResponse.json({ error: 'conversationId required' }, { status: 400 });
if (!queryResult.success) {
return NextResponse.json({ error: 'Invalid query parameters', details: queryResult.error.issues }, { status: 400 });
}
const { conversationId, cursor, limit } = queryResult.data;
// Verify user has access to this conversation
const conversation = await db.query.chatConversations.findFirst({
where: and(
@@ -114,6 +133,9 @@ export async function GET(request: NextRequest) {
nextCursor: messages.length === limit ? messages[messages.length - 1].createdAt.toISOString() : null,
});
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json({ error: 'Invalid input', details: error.issues }, { status: 400 });
}
console.error('Get messages error:', error);
return NextResponse.json({ error: 'Failed to get messages' }, { status: 500 });
}
@@ -130,11 +152,15 @@ export async function PATCH(request: NextRequest) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const { conversationId } = await request.json();
if (!conversationId) {
return NextResponse.json({ error: 'conversationId required' }, { status: 400 });
const body = await request.json();
// Validate request body
const bodyResult = markReadSchema.safeParse(body);
if (!bodyResult.success) {
return NextResponse.json({ error: 'Invalid request body', details: bodyResult.error.issues }, { status: 400 });
}
const { conversationId } = bodyResult.data;
// Verify user has access to this conversation
const conversation = await db.query.chatConversations.findFirst({
@@ -160,6 +186,9 @@ export async function PATCH(request: NextRequest) {
return NextResponse.json({ success: true });
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json({ error: 'Invalid input', details: error.issues }, { status: 400 });
}
console.error('Mark as read error:', error);
return NextResponse.json({ error: 'Failed to mark as read' }, { status: 500 });
}
+17 -13
View File
@@ -11,22 +11,22 @@
import { NextRequest, NextResponse } from 'next/server';
import { db, users, notifications, remoteFollowers } from '@/db';
import { eq, and } from 'drizzle-orm';
import { eq, and, sql } from 'drizzle-orm';
import { z } from 'zod';
import { verifyUserInteraction } from '@/lib/swarm/signature';
const swarmFollowSchema = z.object({
targetHandle: z.string(),
targetHandle: z.string().min(3).max(30).regex(/^[a-zA-Z0-9_]+$/, 'Handle must be alphanumeric with underscores'),
follow: z.object({
followerHandle: z.string(),
followerDisplayName: z.string(),
followerAvatarUrl: z.string().optional(),
followerBio: z.string().optional(),
followerNodeDomain: z.string(),
interactionId: z.string(),
timestamp: z.string(),
followerHandle: z.string().min(3).max(30).regex(/^[a-zA-Z0-9_]+$/, 'Handle must be alphanumeric with underscores'),
followerDisplayName: z.string().min(1).max(50),
followerAvatarUrl: z.string().url().optional(),
followerBio: z.string().max(500).optional(),
followerNodeDomain: z.string().min(1).regex(/^[a-zA-Z0-9][a-zA-Z0-9-]{1,61}[a-zA-Z0-9]\.[a-zA-Z]{2,}$/, 'Invalid domain format'),
interactionId: z.string().uuid(),
timestamp: z.string().datetime(),
}),
signature: z.string(),
signature: z.string().min(1),
});
/**
@@ -100,7 +100,7 @@ export async function POST(request: NextRequest) {
// Update follower count
await db.update(users)
.set({ followersCount: targetUser.followersCount + 1 })
.set({ followersCount: sql`${users.followersCount} + 1` })
.where(eq(users.id, targetUser.id));
// Create notification with actor info stored directly
@@ -115,7 +115,9 @@ export async function POST(request: NextRequest) {
});
console.log(`[Swarm] Created follow notification for @${data.targetHandle} from ${data.follow.followerHandle}@${data.follow.followerNodeDomain}`);
} catch (notifError) {
console.error(`[Swarm] Failed to create notification:`, notifError);
// Log error with context but don't fail the request - notification creation is best-effort
console.error('[Swarm Follow] Failed to create notification:', notifError);
console.error('[Swarm Follow] Context:', { targetHandle: data.targetHandle, userId: targetUser.id, actor: data.follow.followerHandle });
}
// Also notify bot owner if this is a bot being followed
@@ -130,7 +132,9 @@ export async function POST(request: NextRequest) {
type: 'follow',
});
} catch (err) {
console.error('[Swarm] Failed to notify bot owner:', err);
// Log error with context but don't fail the request - bot owner notification is best-effort
console.error('[Swarm Follow] Failed to notify bot owner:', err);
console.error('[Swarm Follow] Context:', { targetHandle: data.targetHandle, botOwnerId: targetUser.botOwnerId, actor: data.follow.followerHandle });
}
}
+13 -9
View File
@@ -15,14 +15,14 @@ import { verifyUserInteraction } from '@/lib/swarm/signature';
const swarmLikeSchema = z.object({
postId: z.string().uuid(),
like: z.object({
actorHandle: z.string(),
actorDisplayName: z.string(),
actorAvatarUrl: z.string().optional(),
actorNodeDomain: z.string(),
interactionId: z.string(),
timestamp: z.string(),
actorHandle: z.string().min(3).max(30).regex(/^[a-zA-Z0-9_]+$/, 'Handle must be alphanumeric with underscores'),
actorDisplayName: z.string().min(1).max(50),
actorAvatarUrl: z.string().url().optional(),
actorNodeDomain: z.string().min(1).regex(/^[a-zA-Z0-9][a-zA-Z0-9-]{1,61}[a-zA-Z0-9]\.[a-zA-Z]{2,}$/, 'Invalid domain format'),
interactionId: z.string().uuid(),
timestamp: z.string().datetime(),
}),
signature: z.string(),
signature: z.string().min(1),
});
/**
@@ -106,7 +106,9 @@ export async function POST(request: NextRequest) {
});
console.log(`[Swarm] Created like notification for post ${data.postId} from ${data.like.actorHandle}@${data.like.actorNodeDomain}`);
} catch (notifError) {
console.error(`[Swarm] Failed to create like notification:`, notifError);
// Log error with context but don't fail the request - notification creation is best-effort
console.error('[Swarm Like] Failed to create notification:', notifError);
console.error('[Swarm Like] Context:', { postId: data.postId, userId: post.userId, actor: data.like.actorHandle });
}
// Also notify bot owner if this is a bot's post
@@ -124,7 +126,9 @@ export async function POST(request: NextRequest) {
type: 'like',
});
} catch (err) {
console.error('[Swarm] Failed to notify bot owner:', err);
// Log error with context but don't fail the request - bot owner notification is best-effort
console.error('[Swarm Like] Failed to notify bot owner:', err);
console.error('[Swarm Like] Context:', { postId: data.postId, botOwnerId: author.botOwnerId, actor: data.like.actorHandle });
}
}
+10 -10
View File
@@ -8,22 +8,22 @@
import { NextRequest, NextResponse } from 'next/server';
import { db, posts, users, notifications } from '@/db';
import { eq } from 'drizzle-orm';
import { eq, sql } from 'drizzle-orm';
import { z } from 'zod';
import { verifyUserInteraction } from '@/lib/swarm/signature';
const swarmRepostSchema = z.object({
postId: z.string().uuid(),
repost: z.object({
actorHandle: z.string(),
actorDisplayName: z.string(),
actorAvatarUrl: z.string().optional(),
actorNodeDomain: z.string(),
repostId: z.string(),
interactionId: z.string(),
timestamp: z.string(),
actorHandle: z.string().min(3).max(30).regex(/^[a-zA-Z0-9_]+$/, 'Handle must be alphanumeric with underscores'),
actorDisplayName: z.string().min(1).max(50),
actorAvatarUrl: z.string().url().optional(),
actorNodeDomain: z.string().min(1).regex(/^[a-zA-Z0-9][a-zA-Z0-9-]{1,61}[a-zA-Z0-9]\.[a-zA-Z]{2,}$/, 'Invalid domain format'),
repostId: z.string().uuid(),
interactionId: z.string().uuid(),
timestamp: z.string().datetime(),
}),
signature: z.string(),
signature: z.string().min(1),
});
/**
@@ -70,7 +70,7 @@ export async function POST(request: NextRequest) {
// Increment repost count
await db.update(posts)
.set({ repostsCount: post.repostsCount + 1 })
.set({ repostsCount: sql`${posts.repostsCount} + 1` })
.where(eq(posts.id, data.postId));
// Create notification with actor info stored directly
@@ -8,7 +8,7 @@
import { NextRequest, NextResponse } from 'next/server';
import { db, users, remoteFollowers } from '@/db';
import { eq, and } from 'drizzle-orm';
import { eq, and, sql } from 'drizzle-orm';
import { z } from 'zod';
import { verifyUserInteraction } from '@/lib/swarm/signature';
@@ -82,7 +82,7 @@ export async function POST(request: NextRequest) {
// Update follower count
await db.update(users)
.set({ followersCount: Math.max(0, targetUser.followersCount - 1) })
.set({ followersCount: sql`GREATEST(0, ${users.followersCount} - 1)` })
.where(eq(users.id, targetUser.id));
console.log(`[Swarm] Received unfollow from ${data.unfollow.followerHandle}@${data.unfollow.followerNodeDomain} for @${data.targetHandle}`);
@@ -8,7 +8,7 @@
import { NextRequest, NextResponse } from 'next/server';
import { db, posts } from '@/db';
import { eq } from 'drizzle-orm';
import { eq, sql } from 'drizzle-orm';
import { z } from 'zod';
import { verifyUserInteraction } from '@/lib/swarm/signature';
@@ -66,7 +66,7 @@ export async function POST(request: NextRequest) {
// Decrement repost count
await db.update(posts)
.set({ repostsCount: Math.max(0, post.repostsCount - 1) })
.set({ repostsCount: sql`GREATEST(0, ${posts.repostsCount} - 1)` })
.where(eq(posts.id, data.postId));
console.log(`[Swarm] Received unrepost from ${data.unrepost.actorHandle}@${data.unrepost.actorNodeDomain} on post ${data.postId}`);
+34 -3
View File
@@ -7,9 +7,19 @@
import { NextRequest, NextResponse } from 'next/server';
import { db, posts, likes, users, remoteLikes } from '@/db';
import { eq, and } from 'drizzle-orm';
import { z } from 'zod';
type RouteContext = { params: Promise<{ id: string }> };
// Schema for post ID parameter
const postIdSchema = z.string().uuid('Invalid post ID format');
// Schema for query parameters
const likesQuerySchema = z.object({
checkHandle: z.string().min(3).max(30).optional(),
checkDomain: z.string().min(1).max(100).optional(),
});
/**
* GET /api/swarm/posts/[id]/likes
*
@@ -24,10 +34,28 @@ export async function GET(request: NextRequest, context: RouteContext) {
return NextResponse.json({ error: 'Database not available' }, { status: 503 });
}
const { id: postId } = await context.params;
const { id: rawId } = await context.params;
// Validate post ID
const idResult = postIdSchema.safeParse(rawId);
if (!idResult.success) {
return NextResponse.json({ error: 'Invalid post ID', details: idResult.error.issues }, { status: 400 });
}
const postId = idResult.data;
const { searchParams } = new URL(request.url);
const checkHandle = searchParams.get('checkHandle');
const checkDomain = searchParams.get('checkDomain');
// Validate query parameters
const queryResult = likesQuerySchema.safeParse({
checkHandle: searchParams.get('checkHandle') || undefined,
checkDomain: searchParams.get('checkDomain') || undefined,
});
if (!queryResult.success) {
return NextResponse.json({ error: 'Invalid query parameters', details: queryResult.error.issues }, { status: 400 });
}
const { checkHandle, checkDomain } = queryResult.data;
// Find the post
const post = await db.query.posts.findFirst({
@@ -94,6 +122,9 @@ export async function GET(request: NextRequest, context: RouteContext) {
likesCount: post.likesCount,
});
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json({ error: 'Invalid input', details: error.issues }, { status: 400 });
}
console.error('[Swarm] Post likes error:', error);
return NextResponse.json({ error: 'Failed to get likes' }, { status: 500 });
}
+17 -1
View File
@@ -7,9 +7,12 @@
import { NextRequest, NextResponse } from 'next/server';
import { db, posts } from '@/db';
import { eq, desc, and } from 'drizzle-orm';
import { z } from 'zod';
type RouteContext = { params: Promise<{ id: string }> };
const uuidSchema = z.string().uuid();
/**
* GET /api/swarm/posts/[id]
*
@@ -21,7 +24,14 @@ export async function GET(request: NextRequest, context: RouteContext) {
return NextResponse.json({ error: 'Database not available' }, { status: 503 });
}
const { id: postId } = await context.params;
const { id: postIdRaw } = await context.params;
// Validate postId is a valid UUID
const postIdValidation = uuidSchema.safeParse(postIdRaw);
if (!postIdValidation.success) {
return NextResponse.json({ error: 'Invalid post ID format' }, { status: 400 });
}
const postId = postIdValidation.data;
// Find the post
const post = await db.query.posts.findFirst({
@@ -97,6 +107,12 @@ export async function GET(request: NextRequest, context: RouteContext) {
}),
});
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json(
{ error: 'Invalid input', details: error.issues },
{ status: 400 }
);
}
console.error('[Swarm] Post detail error:', error);
return NextResponse.json({ error: 'Failed to get post' }, { status: 500 });
}
+11 -11
View File
@@ -1,7 +1,7 @@
import { NextResponse } from 'next/server';
import crypto from 'crypto';
import { db, follows, users, notifications, remoteFollows } from '@/db';
import { eq, and } from 'drizzle-orm';
import { eq, and, sql } from 'drizzle-orm';
import { requireAuth } from '@/lib/auth';
import { requireSignedAction } from '@/lib/auth/verify-signature';
import { isSwarmNode, deliverSwarmFollow, deliverSwarmUnfollow, cacheSwarmUserPosts } from '@/lib/swarm/interactions';
@@ -152,9 +152,9 @@ export async function POST(request: Request, context: RouteContext) {
avatarUrl: null,
});
// Update the user's following count
// Update the user's following count (atomic increment)
await db.update(users)
.set({ followingCount: currentUser.followingCount + 1 })
.set({ followingCount: sql`${users.followingCount} + 1` })
.where(eq(users.id, currentUser.id));
// Cache the remote user's recent posts in the background
@@ -231,13 +231,13 @@ export async function POST(request: Request, context: RouteContext) {
}
}
// Update counts
// Update counts (atomic increments)
await db.update(users)
.set({ followingCount: currentUser.followingCount + 1 })
.set({ followingCount: sql`${users.followingCount} + 1` })
.where(eq(users.id, currentUser.id));
await db.update(users)
.set({ followersCount: targetUser.followersCount + 1 })
.set({ followersCount: sql`${users.followersCount} + 1` })
.where(eq(users.id, targetUser.id));
return NextResponse.json({ success: true, following: true });
@@ -295,9 +295,9 @@ export async function DELETE(request: Request, context: RouteContext) {
// Remove the follow record
await db.delete(remoteFollows).where(eq(remoteFollows.id, existingRemoteFollow.id));
// Update the user's following count
// Update the user's following count (atomic decrement, clamped to 0)
await db.update(users)
.set({ followingCount: Math.max(0, currentUser.followingCount - 1) })
.set({ followingCount: sql`GREATEST(0, ${users.followingCount} - 1)` })
.where(eq(users.id, currentUser.id));
console.log(`[Swarm] Unfollow delivered to ${remote.domain}`);
@@ -335,13 +335,13 @@ export async function DELETE(request: Request, context: RouteContext) {
// Remove follow
await db.delete(follows).where(eq(follows.id, existingFollow.id));
// Update counts
// Update counts (atomic decrements, clamped to 0)
await db.update(users)
.set({ followingCount: Math.max(0, currentUser.followingCount - 1) })
.set({ followingCount: sql`GREATEST(0, ${users.followingCount} - 1)` })
.where(eq(users.id, currentUser.id));
await db.update(users)
.set({ followersCount: Math.max(0, targetUser.followersCount - 1) })
.set({ followersCount: sql`GREATEST(0, ${users.followersCount} - 1)` })
.where(eq(users.id, targetUser.id));
return NextResponse.json({ success: true, following: false });