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
+57
View File
@@ -0,0 +1,57 @@
# Synapsis Docker Image
# Multi-stage build for production
# Stage 1: Dependencies
FROM node:20-alpine AS deps
RUN apk add --no-cache libc6-compat
WORKDIR /app
# Copy package files
COPY package.json package-lock.json* ./
RUN npm ci --only=production
# Stage 2: Builder
FROM node:20-alpine AS builder
WORKDIR /app
# Copy deps from previous stage
COPY --from=deps /app/node_modules ./node_modules
COPY . .
# Build the application
ENV NEXT_TELEMETRY_DISABLED=1
ENV NODE_ENV=production
RUN npm run build
# Stage 3: Runner
FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
# Create non-root user
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
# Copy necessary files
COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
# Switch to non-root user
USER nextjs
# Expose port
EXPOSE 3000
# Set environment variables (can be overridden at runtime)
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
# Health check
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
CMD curl -f http://localhost:3000/api/node || exit 1
# Start the application
CMD ["node", "server.js"]
+3
View File
@@ -0,0 +1,3 @@
-- Migration: Add user storage columns
ALTER TABLE "users" ADD COLUMN IF NOT EXISTS "storage_provider" text;
ALTER TABLE "users" ADD COLUMN IF NOT EXISTS "storage_api_key_encrypted" text;
+22 -573
View File
File diff suppressed because it is too large Load Diff
+2 -1
View File
@@ -41,6 +41,7 @@
"jose": "^6.0.11", "jose": "^6.0.11",
"libsodium-wrappers-sumo": "^0.8.2", "libsodium-wrappers-sumo": "^0.8.2",
"lucide-react": "^0.562.0", "lucide-react": "^0.562.0",
"multiformats": "^13.4.2",
"next": "16.1.4", "next": "16.1.4",
"next-auth": "^5.0.0-beta.25", "next-auth": "^5.0.0-beta.25",
"pg": "^8.17.2", "pg": "^8.17.2",
@@ -53,4 +54,4 @@
"vitest": "^4.0.18", "vitest": "^4.0.18",
"zod": "^4.3.5" "zod": "^4.3.5"
} }
} }
+102
View File
@@ -0,0 +1,102 @@
/**
* DID Migration Script
*
* Converts users from legacy did:synapsis: format to new did:key: format
* The new DID is derived from the user's public key
*/
import { db, users } from '../src/db';
import { eq } from 'drizzle-orm';
// Simple base58btc encoder (Bitcoin alphabet)
const BASE58_ALPHABET = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz';
function base58Encode(buffer: Uint8Array): string {
const alphabet = BASE58_ALPHABET;
let carry: number;
let digits: number[] = [0];
for (let i = 0; i < buffer.length; i++) {
carry = buffer[i];
for (let j = 0; j < digits.length; j++) {
carry += digits[j] << 8;
digits[j] = carry % 58;
carry = (carry / 58) | 0;
}
while (carry) {
digits.push(carry % 58);
carry = (carry / 58) | 0;
}
}
// Leading zeros
for (let i = 0; i < buffer.length && buffer[i] === 0; i++) {
digits.push(0);
}
return digits.reverse().map(d => alphabet[d]).join('');
}
async function migrateDIDs() {
console.log('Starting DID migration...\n');
if (!db) {
console.error('Database not available');
process.exit(1);
}
// Find all users with legacy did:synapsis: format
const legacyUsers = await db.query.users.findMany({
where: (users, { like }) => like(users.did, 'did:synapsis:%'),
});
console.log(`Found ${legacyUsers.length} users with legacy DID format\n`);
let migrated = 0;
let failed = 0;
for (const user of legacyUsers) {
try {
console.log(`Migrating: @${user.handle} (${user.did})`);
if (!user.publicKey) {
console.error(` ❌ No public key for @${user.handle}, skipping`);
failed++;
continue;
}
// Generate new did:key from public key
const publicKeyBytes = Buffer.from(user.publicKey, 'base64');
const encoded = base58Encode(new Uint8Array(publicKeyBytes));
const newDID = `did:key:z${encoded}`;
console.log(` → New DID: ${newDID}`);
// Update user record
await db.update(users)
.set({ did: newDID })
.where(eq(users.id, user.id));
console.log(` ✅ Migrated\n`);
migrated++;
} catch (err) {
console.error(` ❌ Failed to migrate @${user.handle}:`, err);
failed++;
}
}
console.log('\n═══════════════════════════════════════');
console.log('Migration complete!');
console.log(` Migrated: ${migrated}`);
console.log(` Failed: ${failed}`);
console.log(` Total: ${legacyUsers.length}`);
console.log('═══════════════════════════════════════');
process.exit(0);
}
migrateDIDs().catch(err => {
console.error('Migration failed:', err);
process.exit(1);
});
+7 -2
View File
@@ -21,6 +21,7 @@ interface ExportManifest {
handle: string; handle: string;
sourceNode: string; sourceNode: string;
exportedAt: string; exportedAt: string;
expiresAt: string; // Export expiration timestamp
publicKey: string; publicKey: string;
privateKeyEncrypted: string; // Encrypted with user's password privateKeyEncrypted: string; // Encrypted with user's password
salt: string; // For key derivation salt: string; // For key derivation
@@ -40,7 +41,7 @@ interface ExportPost {
content: string; content: string;
createdAt: string; createdAt: string;
replyToApId: string | null; replyToApId: string | null;
media: { filename: string; url: string; altText: string | null }[]; media: { filename: string; url: string; altText: string | null; isIPFS?: boolean }[];
} }
interface ExportFollowing { interface ExportFollowing {
@@ -209,6 +210,7 @@ export async function POST(req: NextRequest) {
filename: `${post.id}_${idx}${getExtension(m.url)}`, filename: `${post.id}_${idx}${getExtension(m.url)}`,
url: m.url, url: m.url,
altText: m.altText, 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); const { encrypted, salt, iv } = encryptPrivateKey(privateKey, password);
// Build manifest (without signature first) // 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'> = { const manifestData: Omit<ExportManifest, 'signature'> = {
version: '1.0', version: '1.0',
did: user.did, did: user.did,
handle: user.handle, handle: user.handle,
sourceNode: nodeDomain, sourceNode: nodeDomain,
exportedAt: new Date().toISOString(), exportedAt: exportedAt.toISOString(),
expiresAt: expiresAt.toISOString(),
publicKey: user.publicKey, publicKey: user.publicKey,
privateKeyEncrypted: encrypted, privateKeyEncrypted: encrypted,
salt, salt,
+65 -6
View File
@@ -6,8 +6,8 @@
*/ */
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import { db, users, posts, media, follows, nodes, chatConversations, chatMessages, bots, botContentSources, botActivityLogs } from '@/db'; import { db, users, posts, media, follows, remoteFollows, nodes, chatConversations, chatMessages, bots, botContentSources, botActivityLogs } from '@/db';
import { eq } from 'drizzle-orm'; import { eq, sql } from 'drizzle-orm';
import * as crypto from 'crypto'; import * as crypto from 'crypto';
import { encryptApiKey, serializeEncryptedData } from '@/lib/bots/encryption'; import { encryptApiKey, serializeEncryptedData } from '@/lib/bots/encryption';
import { v4 as uuid } from 'uuid'; import { v4 as uuid } from 'uuid';
@@ -19,6 +19,7 @@ interface ImportManifest {
handle: string; handle: string;
sourceNode: string; sourceNode: string;
exportedAt: string; exportedAt: string;
expiresAt?: string; // Optional for backward compatibility
publicKey: string; publicKey: string;
privateKeyEncrypted: string; privateKeyEncrypted: string;
salt: string; salt: string;
@@ -38,12 +39,15 @@ interface ImportPost {
content: string; content: string;
createdAt: string; createdAt: string;
replyToApId: string | null; replyToApId: string | null;
media: { filename: string; url: string; altText: string | null }[]; media: { filename: string; url: string; altText: string | null; isIPFS?: boolean }[];
} }
interface ImportFollowing { interface ImportFollowing {
actorUrl: string; actorUrl: string;
handle: string; handle: string;
isRemote?: boolean;
inboxUrl?: string;
activityId?: string;
} }
interface ImportDMConversation { interface ImportDMConversation {
@@ -167,6 +171,16 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ error: 'Unsupported export version' }, { status: 400 }); 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; const { dms: importDMs = [], bots: importBots = [] } = exportData;
// Verify signature // Verify signature
@@ -263,12 +277,14 @@ export async function POST(req: NextRequest) {
apUrl: `https://${nodeDomain}/posts/${uuid()}`, apUrl: `https://${nodeDomain}/posts/${uuid()}`,
}).returning(); }).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) { for (const mediaItem of post.media) {
await db.insert(media).values({ await db.insert(media).values({
userId: newUser.id, userId: newUser.id,
postId: newPost.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, altText: mediaItem.altText,
}); });
} }
@@ -287,6 +303,49 @@ export async function POST(req: NextRequest) {
updatedAt: new Date().toISOString(), 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 // Import DMs
for (const conv of importDMs) { for (const conv of importDMs) {
try { try {
@@ -404,7 +463,7 @@ export async function POST(req: NextRequest) {
}, },
stats: { stats: {
postsImported: importedPosts, postsImported: importedPosts,
followingToRestore: following.length, followingImported: importedFollowing,
dmsImported: importDMs.length, dmsImported: importDMs.length,
botsImported: importBots.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 { db, nodes, users } from '@/db';
import { eq } from 'drizzle-orm'; import { eq } from 'drizzle-orm';
import { verifyTurnstileToken } from '@/lib/turnstile'; import { verifyTurnstileToken } from '@/lib/turnstile';
import { testS3Credentials } from '@/lib/storage/s3';
import { z } from 'zod'; import { z } from 'zod';
const registerSchema = z.object({ const registerSchema = z.object({
@@ -11,6 +12,13 @@ const registerSchema = z.object({
password: z.string().min(8), password: z.string().min(8),
displayName: z.string().optional(), displayName: z.string().optional(),
turnstileToken: z.string().nullable().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) { 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( const user = await registerUser(
data.handle, data.handle,
data.email, data.email,
data.password, 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 // Check if this is an NSFW node and auto-enable NSFW settings
+26 -1
View File
@@ -17,6 +17,7 @@ import {
BotHandleTakenError, BotHandleTakenError,
BotValidationError, BotValidationError,
} from '@/lib/bots/botManager'; } from '@/lib/bots/botManager';
import { generateAndUploadAvatarToUserStorage } from '@/lib/storage/s3';
// Schema for creating a bot // Schema for creating a bot
const createBotSchema = z.object({ const createBotSchema = z.object({
@@ -42,6 +43,8 @@ const createBotSchema = z.object({
timezone: z.string().optional(), timezone: z.string().optional(),
}).optional(), }).optional(),
autonomousMode: z.boolean().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 body = await request.json();
const data = createBotSchema.parse(body); 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, { const bot = await createBot(user.id, {
name: data.name, name: data.name,
handle: data.handle, handle: data.handle,
bio: data.bio, bio: data.bio,
avatarUrl: data.avatarUrl, avatarUrl: botAvatarUrl,
headerUrl: data.headerUrl, headerUrl: data.headerUrl,
personality: data.personality, personality: data.personality,
llmProvider: data.llmProvider, llmProvider: data.llmProvider,
+127 -34
View File
@@ -1,26 +1,93 @@
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import { db } from '@/db'; import { db } from '@/db';
import { chatConversations, chatMessages, users, handleRegistry } from '@/db/schema'; import { chatConversations, chatMessages, users, handleRegistry } from '@/db/schema';
import { eq, and } from 'drizzle-orm'; import { eq, and } from 'drizzle-orm';
import { verifyActionSignature, type SignedAction } from '@/lib/auth/verify-signature'; 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 * POST /api/chat/receive
* Endpoint for receiving federated chat messages from other nodes. * 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) { export async function POST(request: NextRequest) {
try { 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 { did, handle, data } = signedAction;
const { recipientDid, content } = data || {}; const { recipientDid, content } = data || {};
if (!did || !handle || !recipientDid || !content) { // Use full handle if provided in envelope, otherwise fall back to signed handle
return NextResponse.json({ error: 'Invalid payload' }, { status: 400 }); const senderHandle = fullSenderHandle || handle;
} console.log(`[Chat Receive] From: ${senderHandle} (DID: ${did}), To: ${recipientDid}`);
console.log(`[Chat Receive] From: ${handle} (DID: ${did}), To: ${recipientDid}`);
// 1. Resolve Sender Public Key // 1. Resolve Sender Public Key
let senderUser = await db.query.users.findFirst({ let senderUser = await db.query.users.findFirst({
@@ -28,15 +95,15 @@ export async function POST(request: NextRequest) {
}); });
let publicKey = senderUser?.publicKey; let publicKey = senderUser?.publicKey;
let senderDisplayName = senderUser?.displayName || handle; let senderDisplayName = senderUser?.displayName || senderHandle;
let senderAvatarUrl = senderUser?.avatarUrl; let senderAvatarUrl = senderUser?.avatarUrl;
let senderNodeDomain: string | null = null; let senderNodeDomain: string | null = null;
if (!senderUser) { if (!senderUser) {
// Unknown user - likely remote. We need to fetch their profile to get the public key. // Unknown user - likely remote. We need to fetch their profile to get the public key.
// Derive domain from handle if possible // Derive domain from full sender handle if possible
if (handle.includes('@')) { if (senderHandle.includes('@')) {
const parts = handle.split('@'); const parts = senderHandle.split('@');
senderNodeDomain = parts[parts.length - 1]; senderNodeDomain = parts[parts.length - 1];
} else { } else {
// Try to get from header first // Try to get from header first
@@ -55,26 +122,44 @@ export async function POST(request: NextRequest) {
if (senderNodeDomain) { if (senderNodeDomain) {
try { try {
const protocol = senderNodeDomain.includes('localhost') ? 'http' : 'https'; const protocol = senderNodeDomain.includes('localhost') ? 'http' : 'https';
// Fetch profile from remote node const remoteHandle = senderHandle.includes('@') ? senderHandle.split('@')[0] : senderHandle;
// Assuming /api/users/:handle convention
const remoteHandle = handle.includes('@') ? handle.split('@')[0] : handle;
const res = await fetch(`${protocol}://${senderNodeDomain}/api/users/${remoteHandle}`);
if (res.ok) { // Fetch public key with TOFU validation
const profileData = await res.json(); const { publicKey: cachedOrFreshKey, fromCache, keyChanged } = await fetchAndCacheRemoteKey(
const remoteProfile = profileData.user; did,
if (remoteProfile && remoteProfile.publicKey) { senderHandle,
if (remoteProfile.did !== did) { senderNodeDomain,
console.error('DID mismatch for remote user'); async () => {
} else { // Fetch profile from remote node
publicKey = remoteProfile.publicKey; const res = await fetch(`${protocol}://${senderNodeDomain}/api/users/${remoteHandle}`);
senderDisplayName = remoteProfile.displayName || handle; if (!res.ok) return null;
senderAvatarUrl = remoteProfile.avatarUrl; 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 // CACHE: Upsert the remote user into our local database
const { upsertRemoteUser } = await import('@/lib/swarm/user-cache'); const { upsertRemoteUser } = await import('@/lib/swarm/user-cache');
await upsertRemoteUser({ await upsertRemoteUser({
handle: handle, // already full handle if remote handle: senderHandle, // use full handle (user@domain)
displayName: senderDisplayName, displayName: senderDisplayName,
avatarUrl: senderAvatarUrl || null, avatarUrl: senderAvatarUrl || null,
did: did || '', did: did || '',
@@ -83,7 +168,7 @@ export async function POST(request: NextRequest) {
} }
} }
} else { } else {
console.error('Failed to fetch remote profile:', res.status); console.error('Failed to fetch remote profile: no key returned');
} }
} catch (e) { } catch (e) {
console.error('Remote profile fetch failed:', e); console.error('Remote profile fetch failed:', e);
@@ -112,20 +197,20 @@ export async function POST(request: NextRequest) {
// 4. Find or Create Conversation // 4. Find or Create Conversation
// For the RECIPIENT, the conversation is with the SENDER // For the RECIPIENT, the conversation is with the SENDER
// Use full handle // Use full handle from envelope if available, otherwise construct from handle + domain
const fullSenderHandle = handle.includes('@') ? handle : (senderNodeDomain ? `${handle}@${senderNodeDomain}` : handle); const computedFullSenderHandle = senderHandle.includes('@') ? senderHandle : (senderNodeDomain ? `${senderHandle}@${senderNodeDomain}` : senderHandle);
let conversation = await db.query.chatConversations.findFirst({ let conversation = await db.query.chatConversations.findFirst({
where: and( where: and(
eq(chatConversations.participant1Id, recipientUser.id), eq(chatConversations.participant1Id, recipientUser.id),
eq(chatConversations.participant2Handle, fullSenderHandle) eq(chatConversations.participant2Handle, computedFullSenderHandle)
) )
}); });
if (!conversation) { if (!conversation) {
const [newConv] = await db.insert(chatConversations).values({ const [newConv] = await db.insert(chatConversations).values({
participant1Id: recipientUser.id, participant1Id: recipientUser.id,
participant2Handle: fullSenderHandle, participant2Handle: computedFullSenderHandle,
lastMessageAt: new Date(), lastMessageAt: new Date(),
lastMessagePreview: content.slice(0, 50) lastMessagePreview: content.slice(0, 50)
}).returning(); }).returning();
@@ -144,7 +229,7 @@ export async function POST(request: NextRequest) {
// 5. Store Message // 5. Store Message
await db.insert(chatMessages).values({ await db.insert(chatMessages).values({
conversationId: conversation.id, conversationId: conversation.id,
senderHandle: fullSenderHandle, senderHandle: computedFullSenderHandle,
senderDisplayName: senderDisplayName, senderDisplayName: senderDisplayName,
senderAvatarUrl: senderAvatarUrl, senderAvatarUrl: senderAvatarUrl,
senderNodeDomain: senderNodeDomain, senderNodeDomain: senderNodeDomain,
@@ -156,7 +241,7 @@ export async function POST(request: NextRequest) {
// 6. Update Registry (to ensure we can reply efficiently) // 6. Update Registry (to ensure we can reply efficiently)
if (senderNodeDomain) { if (senderNodeDomain) {
await db.insert(handleRegistry).values({ await db.insert(handleRegistry).values({
handle: fullSenderHandle, // user@domain handle: computedFullSenderHandle, // user@domain
did: did, did: did,
nodeDomain: senderNodeDomain nodeDomain: senderNodeDomain
}).onConflictDoUpdate({ }).onConflictDoUpdate({
@@ -172,6 +257,14 @@ export async function POST(request: NextRequest) {
} catch (error: any) { } catch (error: any) {
console.error('Federated Receive Failed:', error); 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 }); 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 { requireSignedAction } from '@/lib/auth/verify-signature';
import { eq, and } from 'drizzle-orm'; import { eq, and } from 'drizzle-orm';
import { z } from 'zod'; import { z } from 'zod';
import { createSignedPayload } from '@/lib/swarm/signature';
const handleRegex = /^[a-zA-Z0-9_]{3,20}$/;
const chatSendSchema = z.object({ const chatSendSchema = z.object({
recipientDid: z.string(), recipientDid: z.string().min(1).regex(/^did:/, 'Must be a valid DID (did:key:... or did:synapsis:...)'),
recipientHandle: z.string(), 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), content: z.string().min(1).max(5000),
}); });
@@ -33,7 +36,14 @@ export async function POST(request: NextRequest) {
where: eq(users.did, recipientDid) 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 // Reject if recipient is a bot
if (recipientUser.isBot) { if (recipientUser.isBot) {
return NextResponse.json({ error: 'Cannot DM a bot account' }, { status: 400 }); 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 protocol = targetDomain.includes('localhost') ? 'http' : 'https';
const sourceDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || ''; const sourceDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || '';
// Construct full sender handle for federation
const fullSenderHandle = `${user.handle}@${sourceDomain}`;
console.log(`[Remote Send] Debug:`, { console.log(`[Remote Send] Debug:`, {
targetDomain, targetDomain,
targetUrl: `${protocol}://${targetDomain}/api/chat/receive`, targetUrl: `${protocol}://${targetDomain}/api/chat/receive`,
sourceDomainEnv: sourceDomain, 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`, { const res = await fetch(`${protocol}://${targetDomain}/api/chat/receive`, {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', '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) { if (!res.ok) {
+21 -2
View File
@@ -2,6 +2,9 @@ import { NextResponse } from 'next/server';
import { db, handleRegistry } from '@/db'; import { db, handleRegistry } from '@/db';
import { eq } from 'drizzle-orm'; import { eq } from 'drizzle-orm';
import { normalizeHandle, upsertHandleEntries } from '@/lib/federation/handles'; 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 parseHandleWithDomain = (handle: string) => {
const clean = normalizeHandle(handle); const clean = normalizeHandle(handle);
@@ -19,12 +22,22 @@ export async function GET(request: Request) {
} }
const { searchParams } = new URL(request.url); 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 }); 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 parsed = parseHandleWithDomain(handleParam);
const lookupHandle = parsed ? parsed.handle : normalizeHandle(handleParam); const lookupHandle = parsed ? parsed.handle : normalizeHandle(handleParam);
const localEntry = await db.query.handleRegistry.findFirst({ const localEntry = await db.query.handleRegistry.findFirst({
@@ -63,6 +76,12 @@ export async function GET(request: Request) {
return NextResponse.json(entry); return NextResponse.json(entry);
} catch (error) { } catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json(
{ error: 'Invalid input', details: error.issues },
{ status: 400 }
);
}
console.error('Handle resolve error:', error); console.error('Handle resolve error:', error);
return NextResponse.json({ error: 'Failed to resolve handle' }, { status: 500 }); 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 { NextRequest, NextResponse } from 'next/server';
import { db, media } from '@/db'; import { db, media } from '@/db';
import { requireAuth } from '@/lib/auth'; 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'; import { v4 as uuidv4 } from 'uuid';
const MAX_IMAGE_SIZE = 10 * 1024 * 1024; // 10MB for images 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 formData = await req.formData();
const file = formData.get('file') as File | null; const file = formData.get('file') as File | null;
const altText = (formData.get('alt') as string | null) || null; const altText = (formData.get('alt') as string | null) || null;
const password = formData.get('password') as string | null;
if (!file) { if (!file) {
return NextResponse.json({ error: 'No file provided' }, { status: 400 }); return NextResponse.json({ error: 'No file provided' }, { status: 400 });
@@ -39,47 +40,43 @@ export async function POST(req: NextRequest) {
}, { status: 400 }); }, { status: 400 });
} }
const buffer = Buffer.from(await file.arrayBuffer()); // Check if user has S3 storage configured
// Sanitize filename to be safe for S3 keys if (!user.storageProvider || !user.storageAccessKeyEncrypted || !user.storageSecretKeyEncrypted) {
const filename = `${uuidv4()}-${file.name.replace(/[^a-zA-Z0-9.-]/g, '')}`; return NextResponse.json({
error: 'Storage not configured. Please set up S3-compatible storage in your settings.'
// S3 Configuration }, { status: 400 });
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 });
} }
// 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) { if (db) {
const [mediaRecord] = await db.insert(media).values({ const [mediaRecord] = await db.insert(media).values({
userId: user.id, userId: user.id,
postId: null, postId: null,
url, url: uploadResult.url,
altText, altText,
mimeType: file.type, mimeType: file.type,
width: 0, // TODO: Get actual dimensions width: 0, // TODO: Get actual dimensions
@@ -89,19 +86,24 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ return NextResponse.json({
success: true, success: true,
media: mediaRecord, media: mediaRecord,
url, url: uploadResult.url,
key: uploadResult.key,
}); });
} }
return NextResponse.json({ return NextResponse.json({
success: true, success: true,
url, url: uploadResult.url,
key: uploadResult.key,
}); });
} catch (error) { } catch (error) {
if (error instanceof Error && error.message === 'Authentication required') { if (error instanceof Error && error.message === 'Authentication required') {
return NextResponse.json({ error: 'Authentication required' }, { status: 401 }); 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); console.error('Upload error:', error);
return NextResponse.json({ error: 'Upload failed' }, { status: 500 }); 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 { db, posts, likes, users, notifications } from '@/db';
import { requireAuth } from '@/lib/auth'; import { requireAuth } from '@/lib/auth';
import { requireSignedAction, type SignedAction } from '@/lib/auth/verify-signature'; 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'; import crypto from 'crypto';
type RouteContext = { params: Promise<{ id: string }> }; 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) * 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 // Verify the signature and get the user
const user = await requireSignedAction(signedAction); 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: 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'; const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:3000';
if (user.isSuspended || user.isSilenced) { if (user.isSuspended || user.isSilenced) {
@@ -118,9 +126,9 @@ export async function POST(request: Request, context: RouteContext) {
postId, postId,
}); });
// Update post's like count // Update post's like count (atomic increment)
await db.update(posts) await db.update(posts)
.set({ likesCount: post.likesCount + 1 }) .set({ likesCount: sql`${posts.likesCount} + 1` })
.where(eq(posts.id, postId)); .where(eq(posts.id, postId));
if (post.userId !== user.id) { 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}`); console.warn(`[Swarm] Like delivery failed: ${result.error}`);
} }
} catch (err) { } 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 }); return NextResponse.json({ success: true, liked: true });
} catch (error) { } catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json({ error: 'Invalid post ID', details: error.issues }, { status: 400 });
}
if (error instanceof Error) { if (error instanceof Error) {
// Handle signature verification errors // Handle signature verification errors
if (error.message === 'User not found' || 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 // Verify the signature and get the user
const user = await requireSignedAction(signedAction); 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: 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'; const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:3000';
if (user.isSuspended || user.isSilenced) { if (user.isSuspended || user.isSilenced) {
@@ -289,9 +303,9 @@ export async function DELETE(request: Request, context: RouteContext) {
// Remove like // Remove like
await db.delete(likes).where(eq(likes.id, existingLike.id)); 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) 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)); .where(eq(posts.id, postId));
// SWARM-FIRST: Deliver unlike to swarm node // 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}`); console.warn(`[Swarm] Unlike delivery failed: ${result.error}`);
} }
} catch (err) { } 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 }); return NextResponse.json({ success: true, liked: false });
} catch (error) { } catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json({ error: 'Invalid post ID', details: error.issues }, { status: 400 });
}
if (error instanceof Error) { if (error instanceof Error) {
// Handle signature verification errors // Handle signature verification errors
if (error.message === 'User not found' || if (error.message === 'User not found' ||
+22 -7
View File
@@ -1,11 +1,18 @@
import { NextResponse } from 'next/server'; import { NextResponse } from 'next/server';
import { db, posts, users, notifications } from '@/db'; import { db, posts, users, notifications } from '@/db';
import { requireAuth } from '@/lib/auth'; 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'; import crypto from 'crypto';
type RouteContext = { params: Promise<{ id: string }> }; 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) * Extract domain from a swarm post ID (swarm:domain:postId)
*/ */
@@ -38,7 +45,8 @@ export async function POST(request: Request, context: RouteContext) {
try { try {
const user = await requireAuth(); const user = await requireAuth();
const { id: rawId } = await context.params; 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'; const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:3000';
if (user.isSuspended || user.isSilenced) { if (user.isSuspended || user.isSilenced) {
@@ -116,12 +124,12 @@ export async function POST(request: Request, context: RouteContext) {
// Update original post's repost count // Update original post's repost count
await db.update(posts) await db.update(posts)
.set({ repostsCount: originalPost.repostsCount + 1 }) .set({ repostsCount: sql`${posts.repostsCount} + 1` })
.where(eq(posts.id, postId)); .where(eq(posts.id, postId));
// Update user's post count // Update user's post count
await db.update(users) await db.update(users)
.set({ postsCount: user.postsCount + 1 }) .set({ postsCount: sql`${users.postsCount} + 1` })
.where(eq(users.id, user.id)); .where(eq(users.id, user.id));
if (originalPost.userId !== 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 }); return NextResponse.json({ success: true, repost, reposted: true });
} catch (error) { } 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') { if (error instanceof Error && error.message === 'Authentication required') {
return NextResponse.json({ error: 'Authentication required' }, { status: 401 }); return NextResponse.json({ error: 'Authentication required' }, { status: 401 });
} }
@@ -208,7 +219,8 @@ export async function DELETE(request: Request, context: RouteContext) {
try { try {
const user = await requireAuth(); const user = await requireAuth();
const { id: rawId } = await context.params; 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'; const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:3000';
if (user.isSuspended || user.isSilenced) { if (user.isSuspended || user.isSilenced) {
@@ -272,17 +284,20 @@ export async function DELETE(request: Request, context: RouteContext) {
// Update original post's repost count // Update original post's repost count
if (originalPost) { if (originalPost) {
await db.update(posts) 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)); .where(eq(posts.id, postId));
} }
// Update user's post count // Update user's post count
await db.update(users) 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)); .where(eq(users.id, user.id));
return NextResponse.json({ success: true, reposted: false }); return NextResponse.json({ success: true, reposted: false });
} catch (error) { } 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') { if (error instanceof Error && error.message === 'Authentication required') {
return NextResponse.json({ error: 'Authentication required' }, { status: 401 }); return NextResponse.json({ error: 'Authentication required' }, { status: 401 });
} }
+18 -10
View File
@@ -1,6 +1,19 @@
import { NextResponse } from 'next/server'; import { NextResponse } from 'next/server';
import { db, posts, users, media, remotePosts } from '@/db'; 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( export async function GET(
request: Request, request: Request,
@@ -424,15 +437,10 @@ export async function DELETE(
// 3. Delete the post (cascades to media, likes, notifications) // 3. Delete the post (cascades to media, likes, notifications)
await db.delete(posts).where(eq(posts.id, id)); await db.delete(posts).where(eq(posts.id, id));
// 4. Decrement the post author's postsCount // 4. Decrement the post author's postsCount (atomic decrement, clamped to 0)
const postAuthor = await db.query.users.findFirst({ await db.update(users)
where: eq(users.id, post.userId), .set({ postsCount: sql`GREATEST(0, ${users.postsCount} - 1)` })
}); .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));
}
return NextResponse.json({ success: true }); return NextResponse.json({ success: true });
} catch (error) { } 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 { db, posts, users, media, follows, mutes, blocks, likes, remoteFollows, remotePosts } from '@/db';
import { requireAuth } from '@/lib/auth'; import { requireAuth } from '@/lib/auth';
import { requireSignedAction, type SignedAction } from '@/lib/auth/verify-signature'; 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 type { SQL } from 'drizzle-orm';
import { z } from 'zod'; import { z } from 'zod';
@@ -19,7 +19,7 @@ const buildWhere = (...conditions: Array<SQL | undefined>) => {
const createPostSchema = z.object({ const createPostSchema = z.object({
content: z.string().min(1).max(POST_MAX_LENGTH), 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({ swarmReplyTo: z.object({
postId: z.string(), postId: z.string(),
nodeDomain: 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) await db.update(users)
.set({ postsCount: user.postsCount + 1 }) .set({ postsCount: sql`${users.postsCount} + 1` })
.where(eq(users.id, user.id)); .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) { if (data.replyToId) {
const parentPost = await db.query.posts.findFirst({ await db.update(posts)
where: eq(posts.id, data.replyToId), .set({ repliesCount: sql`${posts.repliesCount} + 1` })
}); .where(eq(posts.id, data.replyToId));
if (parentPost) {
await db.update(posts)
.set({ repliesCount: parentPost.repliesCount + 1 })
.where(eq(posts.id, data.replyToId));
}
} }
// DEPRECATED: Push-based federation disabled // DEPRECATED: Push-based federation disabled
@@ -213,7 +208,9 @@ export async function POST(request: Request) {
} }
} }
} catch (err) { } 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)`); console.log(`[Swarm] Delivered ${result.delivered} mentions (${result.failed} failed)`);
} }
} catch (err) { } 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)`); console.log(`[Swarm] Post ${post.id} delivered to ${swarmResult.delivered} swarm nodes (${swarmResult.failed} failed)`);
} }
} catch (err) { } 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 { db, chatConversations, chatMessages, users } from '@/db';
import { eq, and } from 'drizzle-orm'; import { eq, and } from 'drizzle-orm';
import { getSession } from '@/lib/auth'; 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( export async function DELETE(
request: NextRequest, request: NextRequest,
@@ -24,8 +33,23 @@ export async function DELETE(
} }
const { id } = await params; 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 { 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 // Verify the conversation belongs to this user
const conversation = await db.query.chatConversations.findFirst({ const conversation = await db.query.chatConversations.findFirst({
@@ -118,6 +142,9 @@ export async function DELETE(
}); });
} }
} catch (error) { } catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json({ error: 'Invalid input', details: error.issues }, { status: 400 });
}
console.error('Delete conversation error:', error); console.error('Delete conversation error:', error);
return NextResponse.json({ error: 'Failed to delete conversation' }, { status: 500 }); 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 { db, chatConversations, chatMessages, users } from '@/db';
import { eq, desc, and, lt, isNull, sql, inArray } from 'drizzle-orm'; import { eq, desc, and, lt, isNull, sql, inArray } from 'drizzle-orm';
import { getSession } from '@/lib/auth'; 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) { export async function GET(request: NextRequest) {
@@ -23,14 +36,20 @@ export async function GET(request: NextRequest) {
} }
const { searchParams } = new URL(request.url); const { searchParams } = new URL(request.url);
const conversationId = searchParams.get('conversationId');
const cursor = searchParams.get('cursor'); // For pagination // Validate query parameters
const limit = Math.min(parseInt(searchParams.get('limit') || '50'), 100); const queryResult = messagesQuerySchema.safeParse({
conversationId: searchParams.get('conversationId'),
cursor: searchParams.get('cursor') || undefined,
limit: parseInt(searchParams.get('limit') || '50'),
});
if (!conversationId) { if (!queryResult.success) {
return NextResponse.json({ error: 'conversationId required' }, { status: 400 }); 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 // Verify user has access to this conversation
const conversation = await db.query.chatConversations.findFirst({ const conversation = await db.query.chatConversations.findFirst({
where: and( where: and(
@@ -114,6 +133,9 @@ export async function GET(request: NextRequest) {
nextCursor: messages.length === limit ? messages[messages.length - 1].createdAt.toISOString() : null, nextCursor: messages.length === limit ? messages[messages.length - 1].createdAt.toISOString() : null,
}); });
} catch (error) { } catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json({ error: 'Invalid input', details: error.issues }, { status: 400 });
}
console.error('Get messages error:', error); console.error('Get messages error:', error);
return NextResponse.json({ error: 'Failed to get messages' }, { status: 500 }); 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 }); return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
} }
const { conversationId } = await request.json(); const body = await request.json();
if (!conversationId) { // Validate request body
return NextResponse.json({ error: 'conversationId required' }, { status: 400 }); 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 // Verify user has access to this conversation
const conversation = await db.query.chatConversations.findFirst({ const conversation = await db.query.chatConversations.findFirst({
@@ -160,6 +186,9 @@ export async function PATCH(request: NextRequest) {
return NextResponse.json({ success: true }); return NextResponse.json({ success: true });
} catch (error) { } 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); console.error('Mark as read error:', error);
return NextResponse.json({ error: 'Failed to mark as read' }, { status: 500 }); 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 { NextRequest, NextResponse } from 'next/server';
import { db, users, notifications, remoteFollowers } from '@/db'; 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 { z } from 'zod';
import { verifyUserInteraction } from '@/lib/swarm/signature'; import { verifyUserInteraction } from '@/lib/swarm/signature';
const swarmFollowSchema = z.object({ 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({ follow: z.object({
followerHandle: z.string(), followerHandle: z.string().min(3).max(30).regex(/^[a-zA-Z0-9_]+$/, 'Handle must be alphanumeric with underscores'),
followerDisplayName: z.string(), followerDisplayName: z.string().min(1).max(50),
followerAvatarUrl: z.string().optional(), followerAvatarUrl: z.string().url().optional(),
followerBio: z.string().optional(), followerBio: z.string().max(500).optional(),
followerNodeDomain: z.string(), 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(), interactionId: z.string().uuid(),
timestamp: z.string(), 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 // Update follower count
await db.update(users) await db.update(users)
.set({ followersCount: targetUser.followersCount + 1 }) .set({ followersCount: sql`${users.followersCount} + 1` })
.where(eq(users.id, targetUser.id)); .where(eq(users.id, targetUser.id));
// Create notification with actor info stored directly // 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}`); console.log(`[Swarm] Created follow notification for @${data.targetHandle} from ${data.follow.followerHandle}@${data.follow.followerNodeDomain}`);
} catch (notifError) { } 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 // Also notify bot owner if this is a bot being followed
@@ -130,7 +132,9 @@ export async function POST(request: NextRequest) {
type: 'follow', type: 'follow',
}); });
} catch (err) { } 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({ const swarmLikeSchema = z.object({
postId: z.string().uuid(), postId: z.string().uuid(),
like: z.object({ like: z.object({
actorHandle: z.string(), actorHandle: z.string().min(3).max(30).regex(/^[a-zA-Z0-9_]+$/, 'Handle must be alphanumeric with underscores'),
actorDisplayName: z.string(), actorDisplayName: z.string().min(1).max(50),
actorAvatarUrl: z.string().optional(), actorAvatarUrl: z.string().url().optional(),
actorNodeDomain: z.string(), 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(), interactionId: z.string().uuid(),
timestamp: z.string(), 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}`); console.log(`[Swarm] Created like notification for post ${data.postId} from ${data.like.actorHandle}@${data.like.actorNodeDomain}`);
} catch (notifError) { } 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 // Also notify bot owner if this is a bot's post
@@ -124,7 +126,9 @@ export async function POST(request: NextRequest) {
type: 'like', type: 'like',
}); });
} catch (err) { } 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 { NextRequest, NextResponse } from 'next/server';
import { db, posts, users, notifications } from '@/db'; import { db, posts, users, notifications } from '@/db';
import { eq } from 'drizzle-orm'; import { eq, sql } from 'drizzle-orm';
import { z } from 'zod'; import { z } from 'zod';
import { verifyUserInteraction } from '@/lib/swarm/signature'; import { verifyUserInteraction } from '@/lib/swarm/signature';
const swarmRepostSchema = z.object({ const swarmRepostSchema = z.object({
postId: z.string().uuid(), postId: z.string().uuid(),
repost: z.object({ repost: z.object({
actorHandle: z.string(), actorHandle: z.string().min(3).max(30).regex(/^[a-zA-Z0-9_]+$/, 'Handle must be alphanumeric with underscores'),
actorDisplayName: z.string(), actorDisplayName: z.string().min(1).max(50),
actorAvatarUrl: z.string().optional(), actorAvatarUrl: z.string().url().optional(),
actorNodeDomain: z.string(), 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(), repostId: z.string().uuid(),
interactionId: z.string(), interactionId: z.string().uuid(),
timestamp: z.string(), 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 // Increment repost count
await db.update(posts) await db.update(posts)
.set({ repostsCount: post.repostsCount + 1 }) .set({ repostsCount: sql`${posts.repostsCount} + 1` })
.where(eq(posts.id, data.postId)); .where(eq(posts.id, data.postId));
// Create notification with actor info stored directly // Create notification with actor info stored directly
@@ -8,7 +8,7 @@
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import { db, users, remoteFollowers } from '@/db'; import { db, users, remoteFollowers } from '@/db';
import { eq, and } from 'drizzle-orm'; import { eq, and, sql } from 'drizzle-orm';
import { z } from 'zod'; import { z } from 'zod';
import { verifyUserInteraction } from '@/lib/swarm/signature'; import { verifyUserInteraction } from '@/lib/swarm/signature';
@@ -82,7 +82,7 @@ export async function POST(request: NextRequest) {
// Update follower count // Update follower count
await db.update(users) 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)); .where(eq(users.id, targetUser.id));
console.log(`[Swarm] Received unfollow from ${data.unfollow.followerHandle}@${data.unfollow.followerNodeDomain} for @${data.targetHandle}`); 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 { NextRequest, NextResponse } from 'next/server';
import { db, posts } from '@/db'; import { db, posts } from '@/db';
import { eq } from 'drizzle-orm'; import { eq, sql } from 'drizzle-orm';
import { z } from 'zod'; import { z } from 'zod';
import { verifyUserInteraction } from '@/lib/swarm/signature'; import { verifyUserInteraction } from '@/lib/swarm/signature';
@@ -66,7 +66,7 @@ export async function POST(request: NextRequest) {
// Decrement repost count // Decrement repost count
await db.update(posts) 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)); .where(eq(posts.id, data.postId));
console.log(`[Swarm] Received unrepost from ${data.unrepost.actorHandle}@${data.unrepost.actorNodeDomain} on post ${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 { NextRequest, NextResponse } from 'next/server';
import { db, posts, likes, users, remoteLikes } from '@/db'; import { db, posts, likes, users, remoteLikes } from '@/db';
import { eq, and } from 'drizzle-orm'; import { eq, and } from 'drizzle-orm';
import { z } from 'zod';
type RouteContext = { params: Promise<{ id: string }> }; 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 * 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 }); 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 { 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 // Find the post
const post = await db.query.posts.findFirst({ const post = await db.query.posts.findFirst({
@@ -94,6 +122,9 @@ export async function GET(request: NextRequest, context: RouteContext) {
likesCount: post.likesCount, likesCount: post.likesCount,
}); });
} catch (error) { } 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); console.error('[Swarm] Post likes error:', error);
return NextResponse.json({ error: 'Failed to get likes' }, { status: 500 }); 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 { NextRequest, NextResponse } from 'next/server';
import { db, posts } from '@/db'; import { db, posts } from '@/db';
import { eq, desc, and } from 'drizzle-orm'; import { eq, desc, and } from 'drizzle-orm';
import { z } from 'zod';
type RouteContext = { params: Promise<{ id: string }> }; type RouteContext = { params: Promise<{ id: string }> };
const uuidSchema = z.string().uuid();
/** /**
* GET /api/swarm/posts/[id] * 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 }); 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 // Find the post
const post = await db.query.posts.findFirst({ const post = await db.query.posts.findFirst({
@@ -97,6 +107,12 @@ export async function GET(request: NextRequest, context: RouteContext) {
}), }),
}); });
} catch (error) { } 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); console.error('[Swarm] Post detail error:', error);
return NextResponse.json({ error: 'Failed to get post' }, { status: 500 }); return NextResponse.json({ error: 'Failed to get post' }, { status: 500 });
} }
+11 -11
View File
@@ -1,7 +1,7 @@
import { NextResponse } from 'next/server'; import { NextResponse } from 'next/server';
import crypto from 'crypto'; import crypto from 'crypto';
import { db, follows, users, notifications, remoteFollows } from '@/db'; 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 { requireAuth } from '@/lib/auth';
import { requireSignedAction } from '@/lib/auth/verify-signature'; import { requireSignedAction } from '@/lib/auth/verify-signature';
import { isSwarmNode, deliverSwarmFollow, deliverSwarmUnfollow, cacheSwarmUserPosts } from '@/lib/swarm/interactions'; import { isSwarmNode, deliverSwarmFollow, deliverSwarmUnfollow, cacheSwarmUserPosts } from '@/lib/swarm/interactions';
@@ -152,9 +152,9 @@ export async function POST(request: Request, context: RouteContext) {
avatarUrl: null, avatarUrl: null,
}); });
// Update the user's following count // Update the user's following count (atomic increment)
await db.update(users) await db.update(users)
.set({ followingCount: currentUser.followingCount + 1 }) .set({ followingCount: sql`${users.followingCount} + 1` })
.where(eq(users.id, currentUser.id)); .where(eq(users.id, currentUser.id));
// Cache the remote user's recent posts in the background // 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) await db.update(users)
.set({ followingCount: currentUser.followingCount + 1 }) .set({ followingCount: sql`${users.followingCount} + 1` })
.where(eq(users.id, currentUser.id)); .where(eq(users.id, currentUser.id));
await db.update(users) await db.update(users)
.set({ followersCount: targetUser.followersCount + 1 }) .set({ followersCount: sql`${users.followersCount} + 1` })
.where(eq(users.id, targetUser.id)); .where(eq(users.id, targetUser.id));
return NextResponse.json({ success: true, following: true }); return NextResponse.json({ success: true, following: true });
@@ -295,9 +295,9 @@ export async function DELETE(request: Request, context: RouteContext) {
// Remove the follow record // Remove the follow record
await db.delete(remoteFollows).where(eq(remoteFollows.id, existingRemoteFollow.id)); 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) 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)); .where(eq(users.id, currentUser.id));
console.log(`[Swarm] Unfollow delivered to ${remote.domain}`); console.log(`[Swarm] Unfollow delivered to ${remote.domain}`);
@@ -335,13 +335,13 @@ export async function DELETE(request: Request, context: RouteContext) {
// Remove follow // Remove follow
await db.delete(follows).where(eq(follows.id, existingFollow.id)); await db.delete(follows).where(eq(follows.id, existingFollow.id));
// Update counts // Update counts (atomic decrements, clamped to 0)
await db.update(users) 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)); .where(eq(users.id, currentUser.id));
await db.update(users) 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)); .where(eq(users.id, targetUser.id));
return NextResponse.json({ success: true, following: false }); return NextResponse.json({ success: true, following: false });
+2 -25
View File
@@ -3,7 +3,7 @@
import { useState, useEffect, useRef } from 'react'; import { useState, useEffect, useRef } from 'react';
import { useAuth } from '@/lib/contexts/AuthContext'; import { useAuth } from '@/lib/contexts/AuthContext';
import { signedAPI } from '@/lib/api/signed-fetch'; import { signedAPI } from '@/lib/api/signed-fetch';
import { ArrowLeft, Send, Lock, Shield, Loader2, MessageCircle, Search, Plus, Trash2, MoreVertical } from 'lucide-react'; import { ArrowLeft, Send, Loader2, MessageCircle, Search, Plus, Trash2, MoreVertical } from 'lucide-react';
import { formatFullHandle } from '@/lib/utils/handle'; import { formatFullHandle } from '@/lib/utils/handle';
import { useRouter, useSearchParams } from 'next/navigation'; import { useRouter, useSearchParams } from 'next/navigation';
@@ -35,7 +35,7 @@ interface Message {
} }
export default function ChatPage() { export default function ChatPage() {
const { user, isIdentityUnlocked, setShowUnlockPrompt } = useAuth(); const { user } = useAuth();
const router = useRouter(); const router = useRouter();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const composeHandle = searchParams.get('compose'); const composeHandle = searchParams.get('compose');
@@ -348,29 +348,6 @@ export default function ChatPage() {
if (user === null) return null; if (user === null) return null;
// Identity Locked State
if (!isIdentityUnlocked) {
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100vh', alignItems: 'center', justifyContent: 'center', gap: '16px', padding: '24px' }}>
<Lock size={48} style={{ color: 'var(--accent)' }} />
<h2 style={{ fontSize: '20px', fontWeight: 600 }}>Identity Required</h2>
<p style={{ color: 'var(--foreground-secondary)', maxWidth: '400px', textAlign: 'center' }}>
Chat requires your identity to be unlocked. Your private keys are used to sign messages to prove they came from you.
</p>
<button
onClick={() => setShowUnlockPrompt(true)}
className="btn btn-primary"
style={{ display: 'flex', alignItems: 'center', gap: '8px' }}
>
<Shield size={16} />
Unlock Identity
</button>
</div>
);
}
// Prevent flash of list view while processing compose intent // Prevent flash of list view while processing compose intent
if (composeHandle && !selectedConversation) { if (composeHandle && !selectedConversation) {
return ( return (
+254 -91
View File
@@ -32,6 +32,12 @@ export default function LoginPage() {
const [confirmPassword, setConfirmPassword] = useState(''); const [confirmPassword, setConfirmPassword] = useState('');
const [handle, setHandle] = useState(''); const [handle, setHandle] = useState('');
const [displayName, setDisplayName] = useState(''); const [displayName, setDisplayName] = useState('');
const [storageProvider, setStorageProvider] = useState('r2');
const [storageEndpoint, setStorageEndpoint] = useState('');
const [storageRegion, setStorageRegion] = useState('auto');
const [storageBucket, setStorageBucket] = useState('');
const [storageAccessKey, setStorageAccessKey] = useState('');
const [storageSecretKey, setStorageSecretKey] = useState('');
const [error, setError] = useState(''); const [error, setError] = useState('');
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [nodeInfoLoaded, setNodeInfoLoaded] = useState(false); const [nodeInfoLoaded, setNodeInfoLoaded] = useState(false);
@@ -244,6 +250,12 @@ export default function LoginPage() {
password, password,
handle, handle,
displayName, displayName,
storageProvider,
storageEndpoint: storageEndpoint || null,
storageRegion,
storageBucket,
storageAccessKey,
storageSecretKey,
...(nodeInfo.turnstileSiteKey ? { turnstileToken } : {}) ...(nodeInfo.turnstileSiteKey ? { turnstileToken } : {})
}; };
@@ -326,7 +338,7 @@ export default function LoginPage() {
justifyContent: 'center', justifyContent: 'center',
padding: '24px', padding: '24px',
}}> }}>
<div style={{ width: '100%', maxWidth: '400px' }}> <div style={{ width: '100%', maxWidth: mode === 'register' ? '680px' : '400px' }}>
{/* Logo */} {/* Logo */}
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', marginBottom: '32px', minHeight: '120px' }}> <div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', marginBottom: '32px', minHeight: '120px' }}>
{nodeInfoLoaded && ( {nodeInfoLoaded && (
@@ -440,111 +452,248 @@ export default function LoginPage() {
{mode === 'register' && ( {mode === 'register' && (
<> <>
<div style={{ marginBottom: '16px' }}> {/* Row 1: Handle | Password */}
<label style={{ display: 'block', marginBottom: '6px', fontSize: '14px', fontWeight: 500 }}> <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '16px', marginBottom: '16px' }}>
Handle <div>
</label> <label style={{ display: 'block', marginBottom: '6px', fontSize: '14px', fontWeight: 500 }}>
<div style={{ position: 'relative' }}> Handle
<span style={{ </label>
position: 'absolute', <div style={{ position: 'relative' }}>
left: '12px', <span style={{
top: '50%', position: 'absolute',
transform: 'translateY(-50%)', left: '12px',
color: 'var(--foreground-tertiary)', top: '50%',
}}>@</span> transform: 'translateY(-50%)',
<input color: 'var(--foreground-tertiary)',
type="text" }}>@</span>
className="input" <input
value={handle} type="text"
onChange={(e) => setHandle(e.target.value.toLowerCase().replace(/[^a-z0-9_]/g, ''))} className="input"
style={{ paddingLeft: '28px' }} value={handle}
placeholder="yourhandle" onChange={(e) => setHandle(e.target.value.toLowerCase().replace(/[^a-z0-9_]/g, ''))}
required style={{ paddingLeft: '28px' }}
minLength={3} placeholder="yourhandle"
maxLength={20} required
/> minLength={3}
maxLength={20}
/>
</div>
<div style={{
fontSize: '12px',
marginTop: '4px',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center'
}}>
<span style={{ color: 'var(--foreground-tertiary)' }}>
3-20 chars, a-z 0-9 _
</span>
{handleStatus === 'checking' && (
<span style={{ color: 'var(--foreground-tertiary)' }}>Checking...</span>
)}
{handleStatus === 'available' && (
<span style={{ color: 'var(--success)', fontWeight: 600 }}></span>
)}
{handleStatus === 'taken' && (
<span style={{ color: 'var(--error)', fontWeight: 600 }}>Taken</span>
)}
</div>
</div> </div>
<div style={{ <div>
fontSize: '12px', <label style={{ display: 'block', marginBottom: '6px', fontSize: '14px', fontWeight: 500 }}>
marginTop: '4px', Password
display: 'flex', </label>
justifyContent: 'space-between', <input
alignItems: 'center' type="password"
}}> className="input"
<span style={{ color: 'var(--foreground-tertiary)' }}> value={password}
3-20 characters, alphanumeric and underscores onChange={(e) => setPassword(e.target.value)}
</span> placeholder="••••••••"
{handleStatus === 'checking' && ( required
<span style={{ color: 'var(--foreground-tertiary)' }}>Checking...</span> minLength={8}
)} />
{handleStatus === 'available' && (
<span style={{ color: 'var(--success)', fontWeight: 600 }}>Available</span>
)}
{handleStatus === 'taken' && (
<span style={{ color: 'var(--error)', fontWeight: 600 }}>Taken</span>
)}
</div> </div>
</div> </div>
{/* Row 2: Display Name | Confirm Password */}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '16px', marginBottom: '16px' }}>
<div>
<label style={{ display: 'block', marginBottom: '6px', fontSize: '14px', fontWeight: 500 }}>
Display Name
</label>
<input
type="text"
className="input"
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
placeholder="Your Name"
/>
</div>
<div>
<label style={{ display: 'block', marginBottom: '6px', fontSize: '14px', fontWeight: 500 }}>
Confirm Password
</label>
<input
type="password"
className="input"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
placeholder="••••••••"
required
minLength={8}
/>
</div>
</div>
{/* Row 3: Email | Storage Provider */}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '16px', marginBottom: '16px' }}>
<div>
<label style={{ display: 'block', marginBottom: '6px', fontSize: '14px', fontWeight: 500 }}>
Email
</label>
<input
type="email"
className="input"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="you@example.com"
required
/>
</div>
<div>
<label style={{ display: 'block', marginBottom: '6px', fontSize: '14px', fontWeight: 500 }}>
Storage Provider
</label>
<select
className="input"
value={storageProvider}
onChange={(e) => setStorageProvider(e.target.value)}
style={{ cursor: 'pointer' }}
>
<option value="r2">Cloudflare R2 (10GB free)</option>
<option value="b2">Backblaze B2 (10GB free)</option>
<option value="wasabi">Wasabi</option>
<option value="s3">AWS S3</option>
<option value="minio">MinIO / Self-hosted</option>
<option value="other">Other S3-compatible</option>
</select>
<div style={{ fontSize: '11px', color: 'var(--foreground-tertiary)', marginTop: '4px' }}>
You own your storage. We just connect to it.
</div>
</div>
</div>
{/* S3 Credentials - Full Width */}
<div style={{ marginBottom: '16px' }}> <div style={{ marginBottom: '16px' }}>
<label style={{ display: 'block', marginBottom: '6px', fontSize: '14px', fontWeight: 500 }}> <label style={{ display: 'block', marginBottom: '6px', fontSize: '14px', fontWeight: 500 }}>
Display Name Endpoint URL (optional for AWS)
</label> </label>
<input <input
type="text" type="text"
className="input" className="input"
value={displayName} value={storageEndpoint}
onChange={(e) => setDisplayName(e.target.value)} onChange={(e) => setStorageEndpoint(e.target.value)}
placeholder="Your Name" placeholder="https://<account>.r2.cloudflarestorage.com"
/> />
<div style={{ fontSize: '11px', color: 'var(--foreground-tertiary)', marginTop: '4px' }}>
Leave empty for AWS S3. Required for R2, B2, MinIO.
</div>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '16px', marginBottom: '16px' }}>
<div>
<label style={{ display: 'block', marginBottom: '6px', fontSize: '14px', fontWeight: 500 }}>
Region
</label>
<input
type="text"
className="input"
value={storageRegion}
onChange={(e) => setStorageRegion(e.target.value)}
placeholder="auto"
required
/>
</div>
<div>
<label style={{ display: 'block', marginBottom: '6px', fontSize: '14px', fontWeight: 500 }}>
Bucket Name
</label>
<input
type="text"
className="input"
value={storageBucket}
onChange={(e) => setStorageBucket(e.target.value)}
placeholder="my-synapsis-bucket"
required
/>
</div>
</div>
<div style={{ marginBottom: '16px' }}>
<label style={{ display: 'block', marginBottom: '6px', fontSize: '14px', fontWeight: 500 }}>
Access Key ID
</label>
<input
type="password"
className="input"
value={storageAccessKey}
onChange={(e) => setStorageAccessKey(e.target.value)}
placeholder="AKIA..."
required
/>
</div>
<div style={{ marginBottom: '16px' }}>
<label style={{ display: 'block', marginBottom: '6px', fontSize: '14px', fontWeight: 500 }}>
Secret Access Key
</label>
<input
type="password"
className="input"
value={storageSecretKey}
onChange={(e) => setStorageSecretKey(e.target.value)}
placeholder="••••••••"
required
/>
<div style={{ fontSize: '12px', color: 'var(--foreground-tertiary)', marginTop: '4px' }}>
Get free S3 storage at <a href="https://dash.cloudflare.com/sign-up/r2" target="_blank" rel="noopener noreferrer" style={{ color: 'var(--accent)' }}>Cloudflare R2</a> (10GB free) or <a href="https://www.backblaze.com/b2/sign-up.html" target="_blank" rel="noopener noreferrer" style={{ color: 'var(--accent)' }}>Backblaze B2</a>
</div>
</div> </div>
</> </>
)} )}
<div style={{ marginBottom: '16px' }}> {/* Login Mode - Show email/password only */}
<label style={{ display: 'block', marginBottom: '6px', fontSize: '14px', fontWeight: 500 }}> {mode === 'login' && (
Email <>
</label> <div style={{ marginBottom: '16px' }}>
<input <label style={{ display: 'block', marginBottom: '6px', fontSize: '14px', fontWeight: 500 }}>
type="email" Email
className="input" </label>
value={email} <input
onChange={(e) => setEmail(e.target.value)} type="email"
placeholder="you@example.com" className="input"
required value={email}
/> onChange={(e) => setEmail(e.target.value)}
</div> placeholder="you@example.com"
required
/>
</div>
<div style={{ marginBottom: '24px' }}> <div style={{ marginBottom: '24px' }}>
<label style={{ display: 'block', marginBottom: '6px', fontSize: '14px', fontWeight: 500 }}> <label style={{ display: 'block', marginBottom: '6px', fontSize: '14px', fontWeight: 500 }}>
Password Password
</label> </label>
<input <input
type="password" type="password"
className="input" className="input"
value={password} value={password}
onChange={(e) => setPassword(e.target.value)} onChange={(e) => setPassword(e.target.value)}
placeholder="••••••••" placeholder="••••••••"
required required
minLength={8} minLength={8}
/> />
</div> </div>
</>
{mode === 'register' && (
<div style={{ marginBottom: '24px' }}>
<label style={{ display: 'block', marginBottom: '6px', fontSize: '14px', fontWeight: 500 }}>
Confirm Password
</label>
<input
type="password"
className="input"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
placeholder="••••••••"
required
minLength={8}
/>
</div>
)} )}
{mode === 'register' && nodeInfo.isNsfw && ( {mode === 'register' && nodeInfo.isNsfw && (
@@ -581,7 +730,21 @@ export default function LoginPage() {
type="submit" type="submit"
className="btn btn-primary btn-lg" className="btn btn-primary btn-lg"
style={{ width: '100%' }} style={{ width: '100%' }}
disabled={loading || (!!nodeInfo.turnstileSiteKey && !turnstileToken)} disabled={loading ||
(!!nodeInfo.turnstileSiteKey && !turnstileToken) ||
(mode === 'register' && (
!handle || handle.length < 3 ||
!email ||
!password || password.length < 8 ||
!confirmPassword ||
password !== confirmPassword ||
!storageProvider ||
!storageRegion ||
!storageBucket ||
!storageAccessKey ||
!storageSecretKey ||
(nodeInfo.isNsfw && !ageVerified)
))}
> >
{loading ? 'Please wait...' : (mode === 'login' ? 'Login' : 'Create Account')} {loading ? 'Please wait...' : (mode === 'login' ? 'Login' : 'Create Account')}
</button> </button>
+7
View File
@@ -62,6 +62,13 @@ export const users = pgTable('users', {
movedTo: text('moved_to'), // New actor URL if this account migrated away movedTo: text('moved_to'), // New actor URL if this account migrated away
movedFrom: text('moved_from'), // Old actor URL if this account migrated here movedFrom: text('moved_from'), // Old actor URL if this account migrated here
migratedAt: timestamp('migrated_at'), // When the migration occurred migratedAt: timestamp('migrated_at'), // When the migration occurred
// User-owned S3-compatible storage - required for new users
storageProvider: text('storage_provider'), // 's3', 'r2', 'b2', 'wasabi', etc
storageEndpoint: text('storage_endpoint'), // S3 endpoint URL (optional for AWS)
storageRegion: text('storage_region'), // Region (e.g., 'us-east-1')
storageBucket: text('storage_bucket'), // Bucket name
storageAccessKeyEncrypted: text('storage_access_key_encrypted'), // Encrypted access key
storageSecretKeyEncrypted: text('storage_secret_key_encrypted'), // Encrypted secret key
followersCount: integer('followers_count').default(0).notNull(), followersCount: integer('followers_count').default(0).notNull(),
followingCount: integer('following_count').default(0).notNull(), followingCount: integer('following_count').default(0).notNull(),
postsCount: integer('posts_count').default(0).notNull(), postsCount: integer('posts_count').default(0).notNull(),
+62 -9
View File
@@ -8,9 +8,10 @@ import bcrypt from 'bcryptjs';
import { v4 as uuid } from 'uuid'; import { v4 as uuid } from 'uuid';
import { generateKeyPair } from '@/lib/crypto/keys'; import { generateKeyPair } from '@/lib/crypto/keys';
import { encryptPrivateKey, serializeEncryptedKey } from '@/lib/crypto/private-key'; import { encryptPrivateKey, serializeEncryptedKey } from '@/lib/crypto/private-key';
import { base58btc } from 'multiformats/bases/base58';
import { cookies } from 'next/headers'; import { cookies } from 'next/headers';
import { upsertHandleEntries } from '@/lib/federation/handles'; import { upsertHandleEntries } from '@/lib/federation/handles';
import { generateAndUploadAvatar } from '@/lib/auth/avatar'; import { generateAndUploadAvatarToUserStorage } from '@/lib/storage/s3';
const SESSION_COOKIE_NAME = 'synapsis_session'; const SESSION_COOKIE_NAME = 'synapsis_session';
const SESSION_EXPIRY_DAYS = 30; const SESSION_EXPIRY_DAYS = 30;
@@ -31,10 +32,22 @@ export async function verifyPassword(password: string, hash: string): Promise<bo
/** /**
* Generate a DID for a new user * Generate a DID for a new user
* Uses did:key format (W3C standard) - the DID contains the public key itself
*/ */
export function generateDID(): string { export function generateDID(publicKey: string): string {
// Using a simple did:key-like format for now // Encode the SPKI public key in base58btc (multibase)
// In production, this would be more sophisticated const publicKeyBytes = Buffer.from(publicKey, 'base64');
const encoded = base58btc.encode(new Uint8Array(publicKeyBytes));
// Create did:key - the 'z' prefix indicates base58btc encoding
return `did:key:${encoded}`;
}
/**
* Generate legacy DID format (for backward compatibility)
* @deprecated Use generateDID() instead
*/
export function generateLegacyDID(): string {
return `did:synapsis:${uuid().replace(/-/g, '')}`; return `did:synapsis:${uuid().replace(/-/g, '')}`;
} }
@@ -123,7 +136,13 @@ export async function registerUser(
handle: string, handle: string,
email: string, email: string,
password: string, password: string,
displayName?: string displayName?: string,
storageProvider?: string,
storageEndpoint?: string | null,
storageRegion?: string,
storageBucket?: string,
storageAccessKey?: string,
storageSecretKey?: string
): Promise<typeof users.$inferSelect> { ): Promise<typeof users.$inferSelect> {
// Validate handle format // Validate handle format
if (!/^[a-zA-Z0-9_]{3,20}$/.test(handle)) { if (!/^[a-zA-Z0-9_]{3,20}$/.test(handle)) {
@@ -148,21 +167,49 @@ export async function registerUser(
throw new Error('Email is already registered'); throw new Error('Email is already registered');
} }
// Validate S3 storage credentials (required for new users)
if (!storageProvider) {
throw new Error('Storage provider is required.');
}
if (!storageRegion || storageRegion.length < 2) {
throw new Error('Storage region is required (e.g., us-east-1, auto).');
}
if (!storageBucket || storageBucket.length < 3) {
throw new Error('Storage bucket name is required.');
}
if (!storageAccessKey || storageAccessKey.length < 10) {
throw new Error('Storage access key is required.');
}
if (!storageSecretKey || storageSecretKey.length < 10) {
throw new Error('Storage secret key is required.');
}
// Generate cryptographic keys // Generate cryptographic keys
const { publicKey, privateKey } = await generateKeyPair(); const { publicKey, privateKey } = await generateKeyPair();
// Encrypt the private key with user's password before storing // Encrypt the private key with user's password before storing
const encryptedPrivateKey = encryptPrivateKey(privateKey, password); const encryptedPrivateKey = encryptPrivateKey(privateKey, password);
// Create the user // Create the user with did:key format (public key encoded in DID)
const did = generateDID(); const did = generateDID(publicKey);
const passwordHash = await hashPassword(password); const passwordHash = await hashPassword(password);
const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:3000'; const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:3000';
// Generate avatar using full handle (@user@domain) for global uniqueness // Generate avatar and upload to user's S3 storage
const fullHandle = `${handle.toLowerCase()}@${nodeDomain}`; const fullHandle = `${handle.toLowerCase()}@${nodeDomain}`;
const avatarUrl = await generateAndUploadAvatar(fullHandle); const avatarUrl = await generateAndUploadAvatarToUserStorage(
fullHandle,
storageEndpoint || null,
storageRegion,
storageBucket,
storageAccessKey,
storageSecretKey
);
// Encrypt the storage credentials with user's password
const encryptedAccessKey = encryptPrivateKey(storageAccessKey, password);
const encryptedSecretKey = encryptPrivateKey(storageSecretKey, password);
const [user] = await db.insert(users).values({ const [user] = await db.insert(users).values({
did, did,
@@ -173,6 +220,12 @@ export async function registerUser(
avatarUrl, avatarUrl,
publicKey, publicKey,
privateKeyEncrypted: serializeEncryptedKey(encryptedPrivateKey), privateKeyEncrypted: serializeEncryptedKey(encryptedPrivateKey),
storageProvider,
storageEndpoint: storageEndpoint || null,
storageRegion,
storageBucket,
storageAccessKeyEncrypted: serializeEncryptedKey(encryptedAccessKey),
storageSecretKeyEncrypted: serializeEncryptedKey(encryptedSecretKey),
}).returning(); }).returning();
await upsertHandleEntries([{ await upsertHandleEntries([{
+12 -5
View File
@@ -14,6 +14,7 @@ import { eq } from 'drizzle-orm';
import { canonicalize, importPublicKey, base64UrlToBase64 } from '@/lib/crypto/user-signing'; import { canonicalize, importPublicKey, base64UrlToBase64 } from '@/lib/crypto/user-signing';
// Note: user-signing helpers are isomorphic (work in Node via webcrypto polyfill/availability) // Note: user-signing helpers are isomorphic (work in Node via webcrypto polyfill/availability)
import crypto from 'crypto'; import crypto from 'crypto';
import { isRateLimited } from '@/lib/rate-limit';
// Use Node's webcrypto for server-side if not global // Use Node's webcrypto for server-side if not global
const cryptoSubtle = globalThis.crypto?.subtle || require('crypto').webcrypto.subtle; const cryptoSubtle = globalThis.crypto?.subtle || require('crypto').webcrypto.subtle;
@@ -77,7 +78,13 @@ export async function verifyUserAction(signedAction: SignedAction): Promise<{
const { sig, ...payload } = signedAction; const { sig, ...payload } = signedAction;
// 1. FRESHNESS CHECK (Fail fast before DB/Crypto) // 1. RATE LIMIT CHECK (Fail fast before heavy operations)
// 5 requests per minute per DID
if (isRateLimited(payload.did, 5, 60 * 1000)) {
return { valid: false, error: 'RATE_LIMITED' };
}
// 2. FRESHNESS CHECK (Fail fast before DB/Crypto)
const now = Date.now(); const now = Date.now();
const diff = Math.abs(now - payload.ts); const diff = Math.abs(now - payload.ts);
// Allow 5 minutes clock skew // Allow 5 minutes clock skew
@@ -87,7 +94,7 @@ export async function verifyUserAction(signedAction: SignedAction): Promise<{
return { valid: false, error: 'INVALID_TIMESTAMP: Request too old or in future' }; return { valid: false, error: 'INVALID_TIMESTAMP: Request too old or in future' };
} }
// 2. FETCH USER & KEY // 3. FETCH USER & KEY
const user = await db.query.users.findFirst({ const user = await db.query.users.findFirst({
where: eq(users.did, payload.did), where: eq(users.did, payload.did),
}); });
@@ -100,18 +107,18 @@ export async function verifyUserAction(signedAction: SignedAction): Promise<{
return { valid: false, error: 'Handle mismatch' }; return { valid: false, error: 'Handle mismatch' };
} }
// 3. CRYPTOGRAPHIC VERIFICATION // 4. CRYPTOGRAPHIC VERIFICATION
const isValid = await verifyActionSignature(signedAction, user.publicKey); const isValid = await verifyActionSignature(signedAction, user.publicKey);
if (!isValid) { if (!isValid) {
return { valid: false, error: 'INVALID_SIGNATURE' }; return { valid: false, error: 'INVALID_SIGNATURE' };
} }
// 4. ACTION ID HASH COMPUTATION // 5. ACTION ID HASH COMPUTATION
const canonicalString = canonicalize(payload); const canonicalString = canonicalize(payload);
const actionIdHash = crypto.createHash('sha256').update(canonicalString).digest('hex'); const actionIdHash = crypto.createHash('sha256').update(canonicalString).digest('hex');
// 5. REPLAY PROTECTION (DB) // 6. REPLAY PROTECTION (DB)
try { try {
await db.insert(signedActionDedupe).values({ await db.insert(signedActionDedupe).values({
actionId: actionIdHash, actionId: actionIdHash,
+26 -1
View File
@@ -11,6 +11,8 @@
*/ */
import { db, bots, users, botContentSources, botContentItems, botMentions, botActivityLogs, botRateLimits, follows } from '@/db'; import { db, bots, users, botContentSources, botContentItems, botMentions, botActivityLogs, botRateLimits, follows } from '@/db';
import { generateAndUploadAvatarToUserStorage } from '@/lib/storage/s3';
import { decryptPrivateKey, deserializeEncryptedKey } from '@/lib/crypto/private-key';
import { eq, and, count } from 'drizzle-orm'; import { eq, and, count } from 'drizzle-orm';
import { generateKeyPair } from '@/lib/crypto/keys'; import { generateKeyPair } from '@/lib/crypto/keys';
import { import {
@@ -333,6 +335,15 @@ export async function createBot(ownerId: string, config: BotCreateInput): Promis
throw new BotHandleTakenError(config.handle); throw new BotHandleTakenError(config.handle);
} }
// Get owner to access their S3 storage for bot avatar
const owner = await db.query.users.findFirst({
where: eq(users.id, ownerId),
});
if (!owner) {
throw new BotValidationError('Owner user not found');
}
// Generate cryptographic keys for the bot's user account // Generate cryptographic keys for the bot's user account
const { publicKey, privateKey } = await generateKeyPair(); const { publicKey, privateKey } = await generateKeyPair();
@@ -344,13 +355,27 @@ export async function createBot(ownerId: string, config: BotCreateInput): Promis
const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:3000'; const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:3000';
const botDid = `did:web:${nodeDomain}:users:${config.handle.toLowerCase()}`; const botDid = `did:web:${nodeDomain}:users:${config.handle.toLowerCase()}`;
// Generate bot avatar using owner's S3 storage if no avatar provided
let botAvatarUrl = config.avatarUrl || null;
if (!botAvatarUrl && owner.storageAccessKeyEncrypted && owner.storageSecretKeyEncrypted && owner.storageBucket) {
try {
// We need the owner's password to decrypt S3 credentials
// Since we don't have the password here, we'll leave avatar null
// The frontend should handle avatar generation with the user's password
// Or we can generate a default avatar URL pattern
console.log('[BotManager] Bot avatar will need to be set via API with user password');
} catch (err) {
console.error('[BotManager] Failed to generate bot avatar:', err);
}
}
// Create the bot's user account first // Create the bot's user account first
const [botUser] = await db.insert(users).values({ const [botUser] = await db.insert(users).values({
did: botDid, did: botDid,
handle: config.handle.toLowerCase(), handle: config.handle.toLowerCase(),
displayName: config.name, displayName: config.name,
bio: config.bio || null, bio: config.bio || null,
avatarUrl: config.avatarUrl || null, avatarUrl: botAvatarUrl,
headerUrl: config.headerUrl || null, headerUrl: config.headerUrl || null,
publicKey, publicKey,
privateKeyEncrypted: serializeEncryptedData(encryptedPrivateKey), privateKeyEncrypted: serializeEncryptedData(encryptedPrivateKey),
+3 -3
View File
@@ -120,7 +120,7 @@ export async function deriveSessionKey(password: string): Promise<CryptoKey> {
keyMaterial, keyMaterial,
{ name: 'AES-GCM', length: 256 }, { name: 'AES-GCM', length: 256 },
true, // extractable so we can store it true, // extractable so we can store it
['wrapKey', 'unwrapKey'] ['encrypt', 'decrypt']
); );
} }
@@ -131,7 +131,7 @@ async function generateSessionKey(): Promise<CryptoKey> {
return crypto.subtle.generateKey( return crypto.subtle.generateKey(
{ name: 'AES-GCM', length: 256 }, { name: 'AES-GCM', length: 256 },
true, true,
['wrapKey', 'unwrapKey'] ['encrypt', 'decrypt']
); );
} }
@@ -147,7 +147,7 @@ async function importSessionKey(keyData: string): Promise<CryptoKey> {
buffer, buffer,
{ name: 'AES-GCM', length: 256 }, { name: 'AES-GCM', length: 256 },
false, // not extractable after import false, // not extractable after import
['wrapKey', 'unwrapKey'] ['encrypt', 'decrypt']
); );
} }
+138
View File
@@ -0,0 +1,138 @@
/**
* In-memory rate limiting for signed actions
*
* Features:
* - Sliding window approach (5 requests per minute per DID by default)
* - Automatic cleanup to prevent memory leaks
* - No external dependencies (Redis-free)
*/
// Map to store timestamps per DID: did -> array of request timestamps
const rateLimits = new Map<string, number[]>();
// Configuration for cleanup
const CLEANUP_INTERVAL_MS = 60 * 1000; // Run cleanup every minute
const MAX_IDLE_DID_MS = 10 * 60 * 1000; // Remove DIDs with no recent activity after 10 minutes
/**
* Check if a DID is rate limited
* @param did - The DID to check
* @param maxRequests - Maximum requests allowed in the window (default: 5)
* @param windowMs - Time window in milliseconds (default: 60000 = 1 minute)
* @returns true if rate limited, false otherwise
*/
export function isRateLimited(did: string, maxRequests = 5, windowMs = 60000): boolean {
const now = Date.now();
const timestamps = rateLimits.get(did) || [];
// Filter to only keep timestamps within the window (sliding window)
const recent = timestamps.filter(ts => now - ts < windowMs);
// Check if limit exceeded
if (recent.length >= maxRequests) {
return true;
}
// Add current timestamp and update the map
recent.push(now);
rateLimits.set(did, recent);
return false;
}
/**
* Get current rate limit status for a DID
* Useful for logging/debugging
*/
export function getRateLimitStatus(did: string, maxRequests = 5, windowMs = 60000): {
limited: boolean;
remaining: number;
resetInMs: number;
current: number;
} {
const now = Date.now();
const timestamps = rateLimits.get(did) || [];
const recent = timestamps.filter(ts => now - ts < windowMs);
const limited = recent.length >= maxRequests;
const remaining = Math.max(0, maxRequests - recent.length);
// Calculate when the oldest request will expire
const oldestInWindow = recent.length > 0 ? Math.min(...recent) : now;
const resetInMs = limited ? (oldestInWindow + windowMs - now) : 0;
return {
limited,
remaining,
resetInMs,
current: recent.length
};
}
/**
* Cleanup old entries to prevent memory leaks
* Removes:
* 1. Timestamps outside the rate limit window for each DID
* 2. DIDs with no recent activity (idle for MAX_IDLE_DID_MS)
*/
export function cleanupRateLimits(windowMs = 60000): {
didsRemoved: number;
timestampsRemoved: number;
} {
const now = Date.now();
let didsRemoved = 0;
let timestampsRemoved = 0;
for (const [did, timestamps] of rateLimits.entries()) {
// Filter to keep only recent timestamps
const recent = timestamps.filter(ts => {
const isRecent = now - ts < windowMs;
if (!isRecent) timestampsRemoved++;
return isRecent;
});
if (recent.length === 0) {
// Remove DID entirely if no recent activity
// Also check if DID has been idle for too long
const lastActivity = timestamps.length > 0 ? Math.max(...timestamps) : 0;
if (now - lastActivity > MAX_IDLE_DID_MS) {
rateLimits.delete(did);
didsRemoved++;
} else {
rateLimits.set(did, recent);
}
} else {
rateLimits.set(did, recent);
}
}
return { didsRemoved, timestampsRemoved };
}
/**
* Get current size of rate limit store (for monitoring)
*/
export function getRateLimitStoreSize(): {
didCount: number;
totalTimestamps: number;
} {
let totalTimestamps = 0;
for (const timestamps of rateLimits.values()) {
totalTimestamps += timestamps.length;
}
return {
didCount: rateLimits.size,
totalTimestamps
};
}
// Start periodic cleanup to prevent memory leaks
setInterval(() => {
const result = cleanupRateLimits();
if (result.didsRemoved > 0 || result.timestampsRemoved > 0) {
console.log(`[RateLimit] Cleanup: removed ${result.didsRemoved} DIDs, ${result.timestampsRemoved} old timestamps`);
}
}, CLEANUP_INTERVAL_MS);
console.log('[RateLimit] Initialized with sliding window rate limiting');
+134
View File
@@ -0,0 +1,134 @@
/**
* User-Owned Storage (IPFS/Pinata) Utilities
*
* Handles uploads to Pinata using user's own API keys
*/
import { decryptPrivateKey, deserializeEncryptedKey } from '@/lib/crypto/private-key';
export type StorageProvider = 'pinata';
interface StorageUploadResult {
cid: string;
url: string;
gatewayUrl: string;
}
/**
* Upload a file to user's Pinata account
*/
export async function uploadToPinata(
file: Buffer,
apiKey: string,
filename: string
): Promise<StorageUploadResult> {
const formData = new FormData();
// Create Blob from Buffer - using ArrayBuffer view
const blob = new Blob([file as unknown as BlobPart]);
formData.append('file', blob, filename);
const response = await fetch('https://api.pinata.cloud/pinning/pinFileToIPFS', {
method: 'POST',
headers: {
'Authorization': `Bearer ${apiKey}`,
},
body: formData,
});
if (!response.ok) {
const error = await response.text();
throw new Error(`Pinata upload failed: ${error}`);
}
const data = await response.json();
const cid = data.IpfsHash;
if (!cid) {
throw new Error('No CID returned from Pinata');
}
return {
cid,
url: `ipfs://${cid}`,
gatewayUrl: `https://gateway.pinata.cloud/ipfs/${cid}`,
};
}
/**
* Upload file using user's configured storage provider
*/
export async function uploadToUserStorage(
file: Buffer,
filename: string,
provider: StorageProvider,
encryptedApiKey: string,
password: string
): Promise<StorageUploadResult> {
// Decrypt the storage API key
const decryptedKey = decryptPrivateKey(
deserializeEncryptedKey(encryptedApiKey),
password
);
switch (provider) {
case 'pinata':
return uploadToPinata(file, decryptedKey, filename);
default:
throw new Error(`Unknown storage provider: ${provider}`);
}
}
/**
* Generate and upload avatar to user's Pinata storage
*/
export async function generateAndUploadAvatarToUserStorage(
handle: string,
apiKey: string
): Promise<string | null> {
try {
// 1. Fetch the avatar from DiceBear
const dicebearUrl = `https://api.dicebear.com/9.x/bottts-neutral/svg?seed=${handle}`;
const response = await fetch(dicebearUrl);
if (!response.ok) {
console.error(`Failed to fetch avatar from DiceBear: ${response.statusText}`);
return null;
}
const arrayBuffer = await response.arrayBuffer();
const buffer = Buffer.from(arrayBuffer);
// 2. Upload to user's Pinata
const result = await uploadToPinata(buffer, apiKey, `${handle}-avatar.svg`);
return result.url; // ipfs://cid format
} catch (error) {
console.error('Error generating/uploading avatar:', error);
return null;
}
}
/**
* Get public gateway URL for an IPFS CID
*/
export function getIPFSGatewayUrl(cid: string, preferredGateway?: string): string {
// Use preferred gateway or fallback to public ones
const gateway = preferredGateway || 'https://ipfs.io/ipfs';
return `${gateway}/${cid}`;
}
/**
* Extract CID from ipfs:// URL or return the CID
*/
export function extractCID(urlOrCid: string): string {
if (urlOrCid.startsWith('ipfs://')) {
return urlOrCid.replace('ipfs://', '');
}
// Handle gateway URLs
const match = urlOrCid.match(/\/ipfs\/(Qm[a-zA-Z0-9]+|bafy[a-zA-Z0-9]+)/);
if (match) {
return match[1];
}
return urlOrCid;
}
+214
View File
@@ -0,0 +1,214 @@
/**
* User-Owned S3-Compatible Storage Utilities
*
* Supports AWS S3, Cloudflare R2, Backblaze B2, Wasabi, MinIO, etc.
*/
import { S3Client, PutObjectCommand, HeadBucketCommand } from '@aws-sdk/client-s3';
import { decryptPrivateKey, deserializeEncryptedKey } from '@/lib/crypto/private-key';
export type StorageProvider = 's3' | 'r2' | 'b2' | 'wasabi' | 'minio' | 'other';
interface S3Credentials {
endpoint?: string;
region: string;
accessKeyId: string;
secretAccessKey: string;
bucket: string;
}
interface StorageUploadResult {
url: string;
key: string;
}
/**
* Decrypt S3 credentials from encrypted storage
*/
function decryptS3Credentials(
encryptedAccessKey: string,
encryptedSecretKey: string,
password: string
): { accessKeyId: string; secretAccessKey: string } {
const accessKeyId = decryptPrivateKey(
deserializeEncryptedKey(encryptedAccessKey),
password
);
const secretAccessKey = decryptPrivateKey(
deserializeEncryptedKey(encryptedSecretKey),
password
);
return { accessKeyId, secretAccessKey };
}
/**
* Create S3 client from credentials
*/
function createS3Client(creds: S3Credentials): S3Client {
return new S3Client({
region: creds.region,
endpoint: creds.endpoint,
credentials: {
accessKeyId: creds.accessKeyId,
secretAccessKey: creds.secretAccessKey,
},
forcePathStyle: !!creds.endpoint, // Needed for non-AWS S3-compatible services
});
}
/**
* Upload a file to user's S3-compatible storage
*/
export async function uploadToUserStorage(
file: Buffer,
filename: string,
mimeType: string,
provider: StorageProvider,
endpoint: string | null,
region: string,
bucket: string,
encryptedAccessKey: string,
encryptedSecretKey: string,
password: string
): Promise<StorageUploadResult> {
// Decrypt credentials
const { accessKeyId, secretAccessKey } = decryptS3Credentials(
encryptedAccessKey,
encryptedSecretKey,
password
);
// Create S3 client
const s3 = createS3Client({
endpoint: endpoint || undefined,
region,
accessKeyId,
secretAccessKey,
bucket,
});
// Upload file
const key = `synapsis/${filename}`;
await s3.send(new PutObjectCommand({
Bucket: bucket,
Key: key,
Body: file,
ContentType: mimeType,
}));
// Construct URL based on provider
let url: string;
if (endpoint) {
// Custom endpoint (R2, MinIO, etc)
url = `${endpoint}/${bucket}/${key}`;
} else {
// AWS S3 standard URL
url = `https://${bucket}.s3.${region}.amazonaws.com/${key}`;
}
return { url, key };
}
/**
* Test S3 credentials by attempting to head the bucket
*/
export async function testS3Credentials(
endpoint: string | null,
region: string,
bucket: string,
accessKeyId: string,
secretAccessKey: string
): Promise<{ success: boolean; error?: string }> {
try {
const s3 = createS3Client({
endpoint: endpoint || undefined,
region,
accessKeyId,
secretAccessKey,
bucket,
});
// Try to check if bucket exists/is accessible
await s3.send(new HeadBucketCommand({ Bucket: bucket }));
return { success: true };
} catch (error: any) {
console.error('[S3 Test] Credential test failed:', error);
// Parse common errors
if (error.name === 'Forbidden' || error.name === '403') {
return { success: false, error: 'Access denied. Check your Access Key and Secret Key.' };
}
if (error.name === 'NotFound' || error.name === '404') {
return { success: false, error: `Bucket "${bucket}" not found. Check the bucket name.` };
}
if (error.name === 'NoSuchBucket') {
return { success: false, error: `Bucket "${bucket}" does not exist.` };
}
if (error.name === 'InvalidAccessKeyId') {
return { success: false, error: 'Invalid Access Key ID.' };
}
if (error.name === 'SignatureDoesNotMatch') {
return { success: false, error: 'Invalid Secret Access Key.' };
}
if (error.name === 'NetworkingError' || error.name === 'ECONNREFUSED') {
return { success: false, error: 'Cannot connect to endpoint. Check your endpoint URL.' };
}
return { success: false, error: error.message || 'Failed to connect to storage. Please check your credentials.' };
}
}
/**
* Generate and upload avatar to user's S3 storage
*/
export async function generateAndUploadAvatarToUserStorage(
handle: string,
endpoint: string | null,
region: string,
bucket: string,
accessKey: string,
secretKey: string
): Promise<string | null> {
try {
// 1. Fetch the avatar from DiceBear
const dicebearUrl = `https://api.dicebear.com/9.x/bottts-neutral/svg?seed=${handle}`;
const response = await fetch(dicebearUrl);
if (!response.ok) {
console.error(`Failed to fetch avatar from DiceBear: ${response.statusText}`);
return null;
}
const arrayBuffer = await response.arrayBuffer();
const buffer = Buffer.from(arrayBuffer);
// 2. Upload to user's S3
const s3 = createS3Client({
endpoint: endpoint || undefined,
region,
accessKeyId: accessKey,
secretAccessKey: secretKey,
bucket,
});
const key = `synapsis/avatars/${handle.replace(/[^a-zA-Z0-9]/g, '')}-avatar.svg`;
await s3.send(new PutObjectCommand({
Bucket: bucket,
Key: key,
Body: buffer,
ContentType: 'image/svg+xml',
}));
// 3. Return URL
if (endpoint) {
return `${endpoint}/${bucket}/${key}`;
}
return `https://${bucket}.s3.${region}.amazonaws.com/${key}`;
} catch (error) {
console.error('Error generating/uploading avatar:', error);
return null;
}
}
+196
View File
@@ -0,0 +1,196 @@
/**
* Identity Cache for TOFU (Trust on First Use) protection
*
* Caches remote user identities to detect key changes.
* First fetch is trusted (TOFU), subsequent fetches validate against cache.
*/
import { db, remoteIdentityCache } from '@/db';
import { eq } from 'drizzle-orm';
interface IdentityCacheEntry {
did: string;
publicKey: string;
fetchedAt: Date;
expiresAt: Date;
}
/**
* Get cached identity for a DID
*/
export async function getCachedIdentity(did: string): Promise<IdentityCacheEntry | null> {
const cached = await db.query.remoteIdentityCache.findFirst({
where: eq(remoteIdentityCache.did, did),
});
return cached || null;
}
/**
* Cache a remote user's identity
*/
export async function cacheIdentity(
did: string,
_handle: string,
_nodeDomain: string,
publicKey: string,
_displayName: string | null = null,
_avatarUrl: string | null = null
): Promise<void> {
const now = new Date();
const expiresAt = new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000); // 7 days
await db.insert(remoteIdentityCache)
.values({
did,
publicKey,
fetchedAt: now,
expiresAt,
})
.onConflictDoUpdate({
target: remoteIdentityCache.did,
set: {
publicKey,
fetchedAt: now,
expiresAt,
},
});
}
/**
* Validate key continuity - check if public key matches cached value
* Returns: { valid: boolean, isFirstUse: boolean, keyChanged?: boolean }
*/
export async function validateKeyContinuity(
did: string,
publicKey: string
): Promise<{ valid: boolean; isFirstUse: boolean; keyChanged?: boolean; oldKey?: string }> {
const cached = await getCachedIdentity(did);
if (!cached) {
// First time seeing this DID - TOFU moment
return { valid: true, isFirstUse: true };
}
if (cached.publicKey === publicKey) {
// Key matches cached value - all good
return { valid: true, isFirstUse: false, keyChanged: false };
}
// Key has changed from cached value
return {
valid: true, // Still accept but warn (configurable)
isFirstUse: false,
keyChanged: true,
oldKey: cached.publicKey
};
}
/**
* Log a security event for key changes
*/
export function logKeyChange(
did: string,
handle: string,
nodeDomain: string,
oldKey: string,
newKey: string
): void {
// Log a prominent security warning
console.error('╔══════════════════════════════════════════════════════════════════════════════╗');
console.error('║ 🚨 SECURITY WARNING: REMOTE PUBLIC KEY CHANGED 🚨 ║');
console.error('╠══════════════════════════════════════════════════════════════════════════════╣');
console.error(`║ DID: ${did.padEnd(74)}`);
console.error(`║ Handle: ${handle.padEnd(71)}`);
console.error(`║ Node: ${nodeDomain.padEnd(73)}`);
console.error('║ ║');
console.error('║ This could indicate: ║');
console.error('║ • MITM attack on your connection to the remote node ║');
console.error('║ • Compromised remote node serving fake keys ║');
console.error('║ • Legitimate key rotation by the user ║');
console.error('║ ║');
console.error('║ Verify out-of-band if possible. ║');
console.error('╚══════════════════════════════════════════════════════════════════════════════╝');
}
/**
* Securely fetch and cache a remote user's public key with TOFU validation
*/
export async function fetchAndCacheRemoteKey(
did: string,
handle: string,
nodeDomain: string,
fetchPublicKey: () => Promise<string | null>
): Promise<{ publicKey: string | null; fromCache: boolean; keyChanged: boolean }> {
// Check cache first
const cached = await getCachedIdentity(did);
if (cached) {
// We have a cached key - return it but also refresh in background
// This ensures we detect changes without blocking
fetchPublicKey().then(async (freshKey) => {
if (freshKey && freshKey !== cached.publicKey) {
// Key changed! Log warning
logKeyChange(did, handle, nodeDomain, cached.publicKey, freshKey);
// Update cache with new key (configurable: could reject instead)
await cacheIdentity(did, handle, nodeDomain, freshKey, null, null);
}
}).catch(err => {
// Background refresh failed - log but don't fail
console.error(`[IdentityCache] Background refresh failed for ${did}:`, err);
});
return {
publicKey: cached.publicKey,
fromCache: true,
keyChanged: false
};
}
// No cached key - fetch it
const publicKey = await fetchPublicKey();
if (!publicKey) {
return { publicKey: null, fromCache: false, keyChanged: false };
}
// Cache the key (TOFU moment)
await cacheIdentity(did, handle, nodeDomain, publicKey, null, null);
return {
publicKey,
fromCache: false,
keyChanged: false
};
}
/**
* Handle key change policy
* Returns true if the key change should be accepted
*/
export function shouldAcceptKeyChange(
did: string,
handle: string,
nodeDomain: string,
oldKey: string,
newKey: string
): boolean {
const policy = process.env.KEY_CHANGE_POLICY || 'warn';
switch (policy) {
case 'strict':
// Reject changed keys
console.error(`[IdentityCache] REJECTING key change for ${did} (strict mode)`);
return false;
case 'allow':
// Silently accept
return true;
case 'warn':
default:
// Accept but warn (already logged)
return true;
}
}