feat: user-owned S3-compatible storage with credential verification
This commit is contained in:
+57
@@ -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"]
|
||||
@@ -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;
|
||||
Generated
+22
-573
File diff suppressed because it is too large
Load Diff
+2
-1
@@ -41,6 +41,7 @@
|
||||
"jose": "^6.0.11",
|
||||
"libsodium-wrappers-sumo": "^0.8.2",
|
||||
"lucide-react": "^0.562.0",
|
||||
"multiformats": "^13.4.2",
|
||||
"next": "16.1.4",
|
||||
"next-auth": "^5.0.0-beta.25",
|
||||
"pg": "^8.17.2",
|
||||
@@ -53,4 +54,4 @@
|
||||
"vitest": "^4.0.18",
|
||||
"zod": "^4.3.5"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -21,6 +21,7 @@ interface ExportManifest {
|
||||
handle: string;
|
||||
sourceNode: string;
|
||||
exportedAt: string;
|
||||
expiresAt: string; // Export expiration timestamp
|
||||
publicKey: string;
|
||||
privateKeyEncrypted: string; // Encrypted with user's password
|
||||
salt: string; // For key derivation
|
||||
@@ -40,7 +41,7 @@ interface ExportPost {
|
||||
content: string;
|
||||
createdAt: string;
|
||||
replyToApId: string | null;
|
||||
media: { filename: string; url: string; altText: string | null }[];
|
||||
media: { filename: string; url: string; altText: string | null; isIPFS?: boolean }[];
|
||||
}
|
||||
|
||||
interface ExportFollowing {
|
||||
@@ -209,6 +210,7 @@ export async function POST(req: NextRequest) {
|
||||
filename: `${post.id}_${idx}${getExtension(m.url)}`,
|
||||
url: m.url,
|
||||
altText: m.altText,
|
||||
isIPFS: m.url?.startsWith('ipfs://') || false,
|
||||
})),
|
||||
}));
|
||||
|
||||
@@ -314,12 +316,15 @@ export async function POST(req: NextRequest) {
|
||||
const { encrypted, salt, iv } = encryptPrivateKey(privateKey, password);
|
||||
|
||||
// Build manifest (without signature first)
|
||||
const exportedAt = new Date();
|
||||
const expiresAt = new Date(exportedAt.getTime() + 30 * 24 * 60 * 60 * 1000); // 30 days
|
||||
const manifestData: Omit<ExportManifest, 'signature'> = {
|
||||
version: '1.0',
|
||||
did: user.did,
|
||||
handle: user.handle,
|
||||
sourceNode: nodeDomain,
|
||||
exportedAt: new Date().toISOString(),
|
||||
exportedAt: exportedAt.toISOString(),
|
||||
expiresAt: expiresAt.toISOString(),
|
||||
publicKey: user.publicKey,
|
||||
privateKeyEncrypted: encrypted,
|
||||
salt,
|
||||
|
||||
@@ -6,8 +6,8 @@
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { db, users, posts, media, follows, nodes, chatConversations, chatMessages, bots, botContentSources, botActivityLogs } from '@/db';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { db, users, posts, media, follows, remoteFollows, nodes, chatConversations, chatMessages, bots, botContentSources, botActivityLogs } from '@/db';
|
||||
import { eq, sql } from 'drizzle-orm';
|
||||
import * as crypto from 'crypto';
|
||||
import { encryptApiKey, serializeEncryptedData } from '@/lib/bots/encryption';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
@@ -19,6 +19,7 @@ interface ImportManifest {
|
||||
handle: string;
|
||||
sourceNode: string;
|
||||
exportedAt: string;
|
||||
expiresAt?: string; // Optional for backward compatibility
|
||||
publicKey: string;
|
||||
privateKeyEncrypted: string;
|
||||
salt: string;
|
||||
@@ -38,12 +39,15 @@ interface ImportPost {
|
||||
content: string;
|
||||
createdAt: string;
|
||||
replyToApId: string | null;
|
||||
media: { filename: string; url: string; altText: string | null }[];
|
||||
media: { filename: string; url: string; altText: string | null; isIPFS?: boolean }[];
|
||||
}
|
||||
|
||||
interface ImportFollowing {
|
||||
actorUrl: string;
|
||||
handle: string;
|
||||
isRemote?: boolean;
|
||||
inboxUrl?: string;
|
||||
activityId?: string;
|
||||
}
|
||||
|
||||
interface ImportDMConversation {
|
||||
@@ -167,6 +171,16 @@ export async function POST(req: NextRequest) {
|
||||
return NextResponse.json({ error: 'Unsupported export version' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Check if export has expired
|
||||
if (manifest.expiresAt) {
|
||||
const expiresAt = new Date(manifest.expiresAt);
|
||||
if (expiresAt < new Date()) {
|
||||
return NextResponse.json({
|
||||
error: 'Export has expired. Please create a new export from your old node.'
|
||||
}, { status: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
const { dms: importDMs = [], bots: importBots = [] } = exportData;
|
||||
|
||||
// Verify signature
|
||||
@@ -263,12 +277,14 @@ export async function POST(req: NextRequest) {
|
||||
apUrl: `https://${nodeDomain}/posts/${uuid()}`,
|
||||
}).returning();
|
||||
|
||||
// Import media references (URLs point to old location for now)
|
||||
// Import media references
|
||||
// For IPFS media (ipfs://hash), the URL works on any node
|
||||
// For legacy S3 media, URL points to old node (may break if old node goes down)
|
||||
for (const mediaItem of post.media) {
|
||||
await db.insert(media).values({
|
||||
userId: newUser.id,
|
||||
postId: newPost.id,
|
||||
url: mediaItem.url, // Original URL - might need re-uploading
|
||||
url: mediaItem.url, // IPFS URLs are portable, S3 URLs are not
|
||||
altText: mediaItem.altText,
|
||||
});
|
||||
}
|
||||
@@ -287,6 +303,49 @@ export async function POST(req: NextRequest) {
|
||||
updatedAt: new Date().toISOString(),
|
||||
}]);
|
||||
|
||||
// Import following list
|
||||
let importedFollowing = 0;
|
||||
for (const follow of following) {
|
||||
try {
|
||||
if (follow.isRemote) {
|
||||
// Remote follow - add to remoteFollows table
|
||||
await db.insert(remoteFollows).values({
|
||||
followerId: newUser.id,
|
||||
targetHandle: follow.handle,
|
||||
targetActorUrl: follow.actorUrl || `https://${follow.handle.split('@')[1]}/users/${follow.handle.split('@')[0]}`,
|
||||
inboxUrl: follow.inboxUrl || `https://${follow.handle.split('@')[1]}/inbox`,
|
||||
activityId: follow.activityId || `migrate-${uuid()}`,
|
||||
});
|
||||
} else {
|
||||
// Local follow - look up user and add to follows table
|
||||
const targetUser = await db.query.users.findFirst({
|
||||
where: eq(users.handle, follow.handle.toLowerCase()),
|
||||
});
|
||||
if (targetUser) {
|
||||
await db.insert(follows).values({
|
||||
followerId: newUser.id,
|
||||
followingId: targetUser.id,
|
||||
});
|
||||
// Increment following count on target user
|
||||
await db.update(users)
|
||||
.set({ followersCount: sql`${users.followersCount} + 1` })
|
||||
.where(eq(users.id, targetUser.id));
|
||||
} else {
|
||||
// Local user not found, convert to remote follow
|
||||
console.log(`[Import] Local user @${follow.handle} not found, skipping follow`);
|
||||
}
|
||||
}
|
||||
importedFollowing++;
|
||||
} catch (error) {
|
||||
console.error(`[Import] Failed to restore follow for @${follow.handle}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
// Update user's following count
|
||||
await db.update(users)
|
||||
.set({ followingCount: importedFollowing })
|
||||
.where(eq(users.id, newUser.id));
|
||||
|
||||
// Import DMs
|
||||
for (const conv of importDMs) {
|
||||
try {
|
||||
@@ -404,7 +463,7 @@ export async function POST(req: NextRequest) {
|
||||
},
|
||||
stats: {
|
||||
postsImported: importedPosts,
|
||||
followingToRestore: following.length,
|
||||
followingImported: importedFollowing,
|
||||
dmsImported: importDMs.length,
|
||||
botsImported: importBots.length,
|
||||
},
|
||||
|
||||
@@ -3,6 +3,7 @@ import { registerUser, createSession } from '@/lib/auth';
|
||||
import { db, nodes, users } from '@/db';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { verifyTurnstileToken } from '@/lib/turnstile';
|
||||
import { testS3Credentials } from '@/lib/storage/s3';
|
||||
import { z } from 'zod';
|
||||
|
||||
const registerSchema = z.object({
|
||||
@@ -11,6 +12,13 @@ const registerSchema = z.object({
|
||||
password: z.string().min(8),
|
||||
displayName: z.string().optional(),
|
||||
turnstileToken: z.string().nullable().optional(),
|
||||
// S3-compatible storage credentials
|
||||
storageProvider: z.string().min(1),
|
||||
storageEndpoint: z.string().nullable().optional(),
|
||||
storageRegion: z.string().min(1),
|
||||
storageBucket: z.string().min(1),
|
||||
storageAccessKey: z.string().min(10),
|
||||
storageSecretKey: z.string().min(10),
|
||||
});
|
||||
|
||||
export async function POST(request: Request) {
|
||||
@@ -36,11 +44,37 @@ export async function POST(request: Request) {
|
||||
}
|
||||
}
|
||||
|
||||
// Test S3 credentials before creating account
|
||||
console.log('[Register] Testing S3 credentials...');
|
||||
const s3Test = await testS3Credentials(
|
||||
data.storageEndpoint || null,
|
||||
data.storageRegion,
|
||||
data.storageBucket,
|
||||
data.storageAccessKey,
|
||||
data.storageSecretKey
|
||||
);
|
||||
|
||||
if (!s3Test.success) {
|
||||
console.error('[Register] S3 credential test failed:', s3Test.error);
|
||||
return NextResponse.json(
|
||||
{ error: `Storage connection failed: ${s3Test.error}` },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
console.log('[Register] S3 credentials verified successfully');
|
||||
|
||||
const user = await registerUser(
|
||||
data.handle,
|
||||
data.email,
|
||||
data.password,
|
||||
data.displayName
|
||||
data.displayName,
|
||||
data.storageProvider,
|
||||
data.storageEndpoint || null,
|
||||
data.storageRegion,
|
||||
data.storageBucket,
|
||||
data.storageAccessKey,
|
||||
data.storageSecretKey
|
||||
);
|
||||
|
||||
// Check if this is an NSFW node and auto-enable NSFW settings
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
BotHandleTakenError,
|
||||
BotValidationError,
|
||||
} from '@/lib/bots/botManager';
|
||||
import { generateAndUploadAvatarToUserStorage } from '@/lib/storage/s3';
|
||||
|
||||
// Schema for creating a bot
|
||||
const createBotSchema = z.object({
|
||||
@@ -42,6 +43,8 @@ const createBotSchema = z.object({
|
||||
timezone: z.string().optional(),
|
||||
}).optional(),
|
||||
autonomousMode: z.boolean().optional(),
|
||||
// Optional: password to generate avatar using owner's S3 storage
|
||||
ownerPassword: z.string().optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -58,11 +61,33 @@ export async function POST(request: Request) {
|
||||
const body = await request.json();
|
||||
const data = createBotSchema.parse(body);
|
||||
|
||||
// Generate bot avatar using owner's S3 storage if password provided and no avatar URL
|
||||
let botAvatarUrl = data.avatarUrl;
|
||||
if (!botAvatarUrl && data.ownerPassword && user.storageAccessKeyEncrypted && user.storageSecretKeyEncrypted && user.storageBucket) {
|
||||
try {
|
||||
const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:3000';
|
||||
const botHandle = `${data.handle.toLowerCase()}@${nodeDomain}`;
|
||||
|
||||
botAvatarUrl = await generateAndUploadAvatarToUserStorage(
|
||||
botHandle,
|
||||
user.storageEndpoint,
|
||||
user.storageRegion || 'auto',
|
||||
user.storageBucket,
|
||||
user.storageAccessKeyEncrypted,
|
||||
user.storageSecretKeyEncrypted,
|
||||
data.ownerPassword
|
||||
);
|
||||
} catch (err) {
|
||||
console.error('[Bot API] Failed to generate bot avatar:', err);
|
||||
// Continue without avatar - user can set it later
|
||||
}
|
||||
}
|
||||
|
||||
const bot = await createBot(user.id, {
|
||||
name: data.name,
|
||||
handle: data.handle,
|
||||
bio: data.bio,
|
||||
avatarUrl: data.avatarUrl,
|
||||
avatarUrl: botAvatarUrl,
|
||||
headerUrl: data.headerUrl,
|
||||
personality: data.personality,
|
||||
llmProvider: data.llmProvider,
|
||||
|
||||
@@ -1,26 +1,93 @@
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { db } from '@/db';
|
||||
import { chatConversations, chatMessages, users, handleRegistry } from '@/db/schema';
|
||||
import { eq, and } from 'drizzle-orm';
|
||||
import { verifyActionSignature, type SignedAction } from '@/lib/auth/verify-signature';
|
||||
import { verifySwarmRequest } from '@/lib/swarm/signature';
|
||||
import { fetchAndCacheRemoteKey, logKeyChange } from '@/lib/swarm/identity-cache';
|
||||
import { z } from 'zod';
|
||||
|
||||
// Schema for direct signed action (legacy)
|
||||
const chatReceiveSchema = z.object({
|
||||
did: z.string().regex(/^did:/, 'Must be a valid DID'),
|
||||
handle: z.string().min(3).max(30),
|
||||
data: z.object({
|
||||
recipientDid: z.string().regex(/^did:/, 'Must be a valid DID'),
|
||||
content: z.string().min(1).max(5000),
|
||||
}),
|
||||
signature: z.string(),
|
||||
timestamp: z.number().optional(),
|
||||
});
|
||||
|
||||
// Schema for federated envelope
|
||||
const federatedEnvelopeSchema = z.object({
|
||||
userAction: chatReceiveSchema,
|
||||
fullSenderHandle: z.string().min(3).max(60),
|
||||
sourceDomain: z.string().min(1),
|
||||
ts: z.number(),
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/chat/receive
|
||||
* Endpoint for receiving federated chat messages from other nodes.
|
||||
* Expects a SignedAction payload from the sender.
|
||||
* Expects either:
|
||||
* 1. A SignedAction payload from the sender (legacy, for backward compatibility)
|
||||
* 2. A federated envelope with node's signature and user's signed action
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const signedAction: SignedAction = await request.json();
|
||||
const body = await request.json();
|
||||
|
||||
// Check if this is a federated envelope (node-signed)
|
||||
const swarmSignature = request.headers.get('X-Swarm-Signature');
|
||||
const sourceDomain = request.headers.get('X-Swarm-Source-Domain');
|
||||
|
||||
let signedAction: SignedAction;
|
||||
let fullSenderHandle: string | null = null;
|
||||
|
||||
if (swarmSignature && sourceDomain && body.userAction) {
|
||||
// Federated envelope format - validate and verify node signature
|
||||
const envelopeValidation = federatedEnvelopeSchema.safeParse(body);
|
||||
if (!envelopeValidation.success) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid envelope payload', details: envelopeValidation.error.issues },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const isValidNodeSig = await verifySwarmRequest(
|
||||
{ userAction: body.userAction, fullSenderHandle: body.fullSenderHandle, sourceDomain: body.sourceDomain, ts: body.ts },
|
||||
swarmSignature,
|
||||
sourceDomain
|
||||
);
|
||||
|
||||
if (!isValidNodeSig) {
|
||||
console.error('[Chat Receive] Invalid node signature from:', sourceDomain);
|
||||
return NextResponse.json({ error: 'Invalid node signature' }, { status: 403 });
|
||||
}
|
||||
|
||||
// Extract user's signed action and full handle from envelope
|
||||
signedAction = body.userAction;
|
||||
fullSenderHandle = body.fullSenderHandle;
|
||||
console.log(`[Chat Receive] Federated envelope from node: ${sourceDomain}, full handle: ${fullSenderHandle}`);
|
||||
} else {
|
||||
// Legacy format - direct user signed action
|
||||
const actionValidation = chatReceiveSchema.safeParse(body);
|
||||
if (!actionValidation.success) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid action payload', details: actionValidation.error.issues },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
signedAction = body;
|
||||
}
|
||||
|
||||
const { did, handle, data } = signedAction;
|
||||
const { recipientDid, content } = data || {};
|
||||
|
||||
if (!did || !handle || !recipientDid || !content) {
|
||||
return NextResponse.json({ error: 'Invalid payload' }, { status: 400 });
|
||||
}
|
||||
|
||||
console.log(`[Chat Receive] From: ${handle} (DID: ${did}), To: ${recipientDid}`);
|
||||
// Use full handle if provided in envelope, otherwise fall back to signed handle
|
||||
const senderHandle = fullSenderHandle || handle;
|
||||
console.log(`[Chat Receive] From: ${senderHandle} (DID: ${did}), To: ${recipientDid}`);
|
||||
|
||||
// 1. Resolve Sender Public Key
|
||||
let senderUser = await db.query.users.findFirst({
|
||||
@@ -28,15 +95,15 @@ export async function POST(request: NextRequest) {
|
||||
});
|
||||
|
||||
let publicKey = senderUser?.publicKey;
|
||||
let senderDisplayName = senderUser?.displayName || handle;
|
||||
let senderDisplayName = senderUser?.displayName || senderHandle;
|
||||
let senderAvatarUrl = senderUser?.avatarUrl;
|
||||
let senderNodeDomain: string | null = null;
|
||||
|
||||
if (!senderUser) {
|
||||
// Unknown user - likely remote. We need to fetch their profile to get the public key.
|
||||
// Derive domain from handle if possible
|
||||
if (handle.includes('@')) {
|
||||
const parts = handle.split('@');
|
||||
// Derive domain from full sender handle if possible
|
||||
if (senderHandle.includes('@')) {
|
||||
const parts = senderHandle.split('@');
|
||||
senderNodeDomain = parts[parts.length - 1];
|
||||
} else {
|
||||
// Try to get from header first
|
||||
@@ -55,26 +122,44 @@ export async function POST(request: NextRequest) {
|
||||
if (senderNodeDomain) {
|
||||
try {
|
||||
const protocol = senderNodeDomain.includes('localhost') ? 'http' : 'https';
|
||||
// Fetch profile from remote node
|
||||
// Assuming /api/users/:handle convention
|
||||
const remoteHandle = handle.includes('@') ? handle.split('@')[0] : handle;
|
||||
const res = await fetch(`${protocol}://${senderNodeDomain}/api/users/${remoteHandle}`);
|
||||
const remoteHandle = senderHandle.includes('@') ? senderHandle.split('@')[0] : senderHandle;
|
||||
|
||||
if (res.ok) {
|
||||
const profileData = await res.json();
|
||||
const remoteProfile = profileData.user;
|
||||
if (remoteProfile && remoteProfile.publicKey) {
|
||||
if (remoteProfile.did !== did) {
|
||||
console.error('DID mismatch for remote user');
|
||||
} else {
|
||||
publicKey = remoteProfile.publicKey;
|
||||
senderDisplayName = remoteProfile.displayName || handle;
|
||||
senderAvatarUrl = remoteProfile.avatarUrl;
|
||||
// Fetch public key with TOFU validation
|
||||
const { publicKey: cachedOrFreshKey, fromCache, keyChanged } = await fetchAndCacheRemoteKey(
|
||||
did,
|
||||
senderHandle,
|
||||
senderNodeDomain,
|
||||
async () => {
|
||||
// Fetch profile from remote node
|
||||
const res = await fetch(`${protocol}://${senderNodeDomain}/api/users/${remoteHandle}`);
|
||||
if (!res.ok) return null;
|
||||
const profileData = await res.json();
|
||||
const remoteProfile = profileData.user;
|
||||
if (remoteProfile?.did !== did) {
|
||||
console.error('[Chat Receive] DID mismatch for remote user');
|
||||
return null;
|
||||
}
|
||||
return remoteProfile?.publicKey || null;
|
||||
}
|
||||
);
|
||||
|
||||
if (cachedOrFreshKey) {
|
||||
publicKey = cachedOrFreshKey;
|
||||
console.log(`[Chat Receive] Using ${fromCache ? 'cached' : 'fetched'} public key for ${senderHandle}${keyChanged ? ' (KEY CHANGED!)' : ''}`);
|
||||
|
||||
// Also fetch display name/avatar if not from cache (or if we need fresh data)
|
||||
if (!fromCache || keyChanged) {
|
||||
const res = await fetch(`${protocol}://${senderNodeDomain}/api/users/${remoteHandle}`);
|
||||
if (res.ok) {
|
||||
const profileData = await res.json();
|
||||
const remoteProfile = profileData.user;
|
||||
senderDisplayName = remoteProfile?.displayName || senderHandle;
|
||||
senderAvatarUrl = remoteProfile?.avatarUrl;
|
||||
|
||||
// CACHE: Upsert the remote user into our local database
|
||||
const { upsertRemoteUser } = await import('@/lib/swarm/user-cache');
|
||||
await upsertRemoteUser({
|
||||
handle: handle, // already full handle if remote
|
||||
handle: senderHandle, // use full handle (user@domain)
|
||||
displayName: senderDisplayName,
|
||||
avatarUrl: senderAvatarUrl || null,
|
||||
did: did || '',
|
||||
@@ -83,7 +168,7 @@ export async function POST(request: NextRequest) {
|
||||
}
|
||||
}
|
||||
} else {
|
||||
console.error('Failed to fetch remote profile:', res.status);
|
||||
console.error('Failed to fetch remote profile: no key returned');
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Remote profile fetch failed:', e);
|
||||
@@ -112,20 +197,20 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
// 4. Find or Create Conversation
|
||||
// For the RECIPIENT, the conversation is with the SENDER
|
||||
// Use full handle
|
||||
const fullSenderHandle = handle.includes('@') ? handle : (senderNodeDomain ? `${handle}@${senderNodeDomain}` : handle);
|
||||
// Use full handle from envelope if available, otherwise construct from handle + domain
|
||||
const computedFullSenderHandle = senderHandle.includes('@') ? senderHandle : (senderNodeDomain ? `${senderHandle}@${senderNodeDomain}` : senderHandle);
|
||||
|
||||
let conversation = await db.query.chatConversations.findFirst({
|
||||
where: and(
|
||||
eq(chatConversations.participant1Id, recipientUser.id),
|
||||
eq(chatConversations.participant2Handle, fullSenderHandle)
|
||||
eq(chatConversations.participant2Handle, computedFullSenderHandle)
|
||||
)
|
||||
});
|
||||
|
||||
if (!conversation) {
|
||||
const [newConv] = await db.insert(chatConversations).values({
|
||||
participant1Id: recipientUser.id,
|
||||
participant2Handle: fullSenderHandle,
|
||||
participant2Handle: computedFullSenderHandle,
|
||||
lastMessageAt: new Date(),
|
||||
lastMessagePreview: content.slice(0, 50)
|
||||
}).returning();
|
||||
@@ -144,7 +229,7 @@ export async function POST(request: NextRequest) {
|
||||
// 5. Store Message
|
||||
await db.insert(chatMessages).values({
|
||||
conversationId: conversation.id,
|
||||
senderHandle: fullSenderHandle,
|
||||
senderHandle: computedFullSenderHandle,
|
||||
senderDisplayName: senderDisplayName,
|
||||
senderAvatarUrl: senderAvatarUrl,
|
||||
senderNodeDomain: senderNodeDomain,
|
||||
@@ -156,7 +241,7 @@ export async function POST(request: NextRequest) {
|
||||
// 6. Update Registry (to ensure we can reply efficiently)
|
||||
if (senderNodeDomain) {
|
||||
await db.insert(handleRegistry).values({
|
||||
handle: fullSenderHandle, // user@domain
|
||||
handle: computedFullSenderHandle, // user@domain
|
||||
did: did,
|
||||
nodeDomain: senderNodeDomain
|
||||
}).onConflictDoUpdate({
|
||||
@@ -172,6 +257,14 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
} catch (error: any) {
|
||||
console.error('Federated Receive Failed:', error);
|
||||
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid input', details: error.issues },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,10 +4,13 @@ import { chatConversations, chatMessages, users, handleRegistry, follows } from
|
||||
import { requireSignedAction } from '@/lib/auth/verify-signature';
|
||||
import { eq, and } from 'drizzle-orm';
|
||||
import { z } from 'zod';
|
||||
import { createSignedPayload } from '@/lib/swarm/signature';
|
||||
|
||||
const handleRegex = /^[a-zA-Z0-9_]{3,20}$/;
|
||||
|
||||
const chatSendSchema = z.object({
|
||||
recipientDid: z.string(),
|
||||
recipientHandle: z.string(),
|
||||
recipientDid: z.string().min(1).regex(/^did:/, 'Must be a valid DID (did:key:... or did:synapsis:...)'),
|
||||
recipientHandle: z.string().min(3).max(30).regex(handleRegex, 'Handle must be 3-20 characters, alphanumeric and underscores only'),
|
||||
content: z.string().min(1).max(5000),
|
||||
});
|
||||
|
||||
@@ -33,7 +36,14 @@ export async function POST(request: NextRequest) {
|
||||
where: eq(users.did, recipientDid)
|
||||
});
|
||||
|
||||
if (recipientUser) {
|
||||
// Check if recipient is a local user (not remote/swarm cached)
|
||||
// Remote users have handles with @domain or IDs starting with "swarm:"
|
||||
const isRemoteUser = recipientUser && (
|
||||
recipientUser.handle.includes('@') ||
|
||||
recipientUser.id.startsWith('swarm:')
|
||||
);
|
||||
|
||||
if (recipientUser && !isRemoteUser) {
|
||||
// Reject if recipient is a bot
|
||||
if (recipientUser.isBot) {
|
||||
return NextResponse.json({ error: 'Cannot DM a bot account' }, { status: 400 });
|
||||
@@ -165,19 +175,35 @@ export async function POST(request: NextRequest) {
|
||||
const protocol = targetDomain.includes('localhost') ? 'http' : 'https';
|
||||
const sourceDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || '';
|
||||
|
||||
// Construct full sender handle for federation
|
||||
const fullSenderHandle = `${user.handle}@${sourceDomain}`;
|
||||
|
||||
console.log(`[Remote Send] Debug:`, {
|
||||
targetDomain,
|
||||
targetUrl: `${protocol}://${targetDomain}/api/chat/receive`,
|
||||
sourceDomainEnv: sourceDomain,
|
||||
fullSenderHandle,
|
||||
});
|
||||
|
||||
// Create a federated envelope with node's signature
|
||||
// This wraps the user's signed action with the full handle
|
||||
const federatedPayload = {
|
||||
userAction: signedAction,
|
||||
fullSenderHandle,
|
||||
sourceDomain,
|
||||
ts: Date.now(),
|
||||
};
|
||||
|
||||
const { payload, signature } = await createSignedPayload(federatedPayload);
|
||||
|
||||
const res = await fetch(`${protocol}://${targetDomain}/api/chat/receive`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Swarm-Source-Domain': sourceDomain
|
||||
'X-Swarm-Source-Domain': sourceDomain,
|
||||
'X-Swarm-Signature': signature,
|
||||
},
|
||||
body: JSON.stringify(signedAction) // Forward the user's signed intent
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
|
||||
@@ -2,6 +2,9 @@ import { NextResponse } from 'next/server';
|
||||
import { db, handleRegistry } from '@/db';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { normalizeHandle, upsertHandleEntries } from '@/lib/federation/handles';
|
||||
import { z } from 'zod';
|
||||
|
||||
const handleParamSchema = z.string().min(3).max(40).regex(/^[a-zA-Z0-9_]+(@[a-zA-Z0-9][a-zA-Z0-9-]{1,61}[a-zA-Z0-9]\.[a-zA-Z]{2,})?$/, 'Invalid handle format');
|
||||
|
||||
const parseHandleWithDomain = (handle: string) => {
|
||||
const clean = normalizeHandle(handle);
|
||||
@@ -19,12 +22,22 @@ export async function GET(request: Request) {
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const handleParam = searchParams.get('handle');
|
||||
const handleParamRaw = searchParams.get('handle');
|
||||
|
||||
if (!handleParam) {
|
||||
if (!handleParamRaw) {
|
||||
return NextResponse.json({ error: 'Handle is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Validate handle format
|
||||
const handleValidation = handleParamSchema.safeParse(handleParamRaw);
|
||||
if (!handleValidation.success) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid handle format', details: handleValidation.error.issues },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
const handleParam = handleValidation.data;
|
||||
|
||||
const parsed = parseHandleWithDomain(handleParam);
|
||||
const lookupHandle = parsed ? parsed.handle : normalizeHandle(handleParam);
|
||||
const localEntry = await db.query.handleRegistry.findFirst({
|
||||
@@ -63,6 +76,12 @@ export async function GET(request: Request) {
|
||||
|
||||
return NextResponse.json(entry);
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid input', details: error.issues },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
console.error('Handle resolve error:', error);
|
||||
return NextResponse.json({ error: 'Failed to resolve handle' }, { status: 500 });
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { db, media } from '@/db';
|
||||
import { requireAuth } from '@/lib/auth';
|
||||
import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3';
|
||||
import { uploadToUserStorage } from '@/lib/storage/s3';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
const MAX_IMAGE_SIZE = 10 * 1024 * 1024; // 10MB for images
|
||||
@@ -16,6 +16,7 @@ export async function POST(req: NextRequest) {
|
||||
const formData = await req.formData();
|
||||
const file = formData.get('file') as File | null;
|
||||
const altText = (formData.get('alt') as string | null) || null;
|
||||
const password = formData.get('password') as string | null;
|
||||
|
||||
if (!file) {
|
||||
return NextResponse.json({ error: 'No file provided' }, { status: 400 });
|
||||
@@ -39,47 +40,43 @@ export async function POST(req: NextRequest) {
|
||||
}, { status: 400 });
|
||||
}
|
||||
|
||||
const buffer = Buffer.from(await file.arrayBuffer());
|
||||
// Sanitize filename to be safe for S3 keys
|
||||
const filename = `${uuidv4()}-${file.name.replace(/[^a-zA-Z0-9.-]/g, '')}`;
|
||||
|
||||
// S3 Configuration
|
||||
const s3 = new S3Client({
|
||||
region: process.env.STORAGE_REGION || 'us-east-1',
|
||||
endpoint: process.env.STORAGE_ENDPOINT,
|
||||
credentials: {
|
||||
accessKeyId: process.env.STORAGE_ACCESS_KEY || '',
|
||||
secretAccessKey: process.env.STORAGE_SECRET_KEY || '',
|
||||
},
|
||||
forcePathStyle: true, // Needed for many S3-compatible providers
|
||||
});
|
||||
|
||||
const bucket = process.env.STORAGE_BUCKET || 'synapsis';
|
||||
|
||||
await s3.send(new PutObjectCommand({
|
||||
Bucket: bucket,
|
||||
Key: filename,
|
||||
Body: buffer,
|
||||
ContentType: file.type,
|
||||
ACL: 'public-read',
|
||||
}));
|
||||
|
||||
// Construct Public URL
|
||||
let url = '';
|
||||
if (process.env.STORAGE_PUBLIC_BASE_URL) {
|
||||
url = `${process.env.STORAGE_PUBLIC_BASE_URL}/${filename}`;
|
||||
} else if (process.env.STORAGE_ENDPOINT) {
|
||||
url = `${process.env.STORAGE_ENDPOINT}/${bucket}/${filename}`;
|
||||
} else {
|
||||
return NextResponse.json({ error: 'Storage not configured - missing STORAGE_PUBLIC_BASE_URL or STORAGE_ENDPOINT' }, { status: 500 });
|
||||
// Check if user has S3 storage configured
|
||||
if (!user.storageProvider || !user.storageAccessKeyEncrypted || !user.storageSecretKeyEncrypted) {
|
||||
return NextResponse.json({
|
||||
error: 'Storage not configured. Please set up S3-compatible storage in your settings.'
|
||||
}, { status: 400 });
|
||||
}
|
||||
|
||||
// Store media record
|
||||
// Require password to decrypt storage credentials
|
||||
if (!password) {
|
||||
return NextResponse.json({
|
||||
error: 'Password required to upload media. Your storage credentials are encrypted and need your password to decrypt.'
|
||||
}, { status: 401 });
|
||||
}
|
||||
|
||||
const buffer = Buffer.from(await file.arrayBuffer());
|
||||
const filename = `${uuidv4()}-${file.name.replace(/[^a-zA-Z0-9.-]/g, '')}`;
|
||||
|
||||
// Upload to user's own S3-compatible storage
|
||||
const uploadResult = await uploadToUserStorage(
|
||||
buffer,
|
||||
filename,
|
||||
file.type,
|
||||
user.storageProvider as any,
|
||||
user.storageEndpoint,
|
||||
user.storageRegion || 'us-east-1',
|
||||
user.storageBucket || '',
|
||||
user.storageAccessKeyEncrypted,
|
||||
user.storageSecretKeyEncrypted,
|
||||
password
|
||||
);
|
||||
|
||||
// Store media record with S3 URL
|
||||
if (db) {
|
||||
const [mediaRecord] = await db.insert(media).values({
|
||||
userId: user.id,
|
||||
postId: null,
|
||||
url,
|
||||
url: uploadResult.url,
|
||||
altText,
|
||||
mimeType: file.type,
|
||||
width: 0, // TODO: Get actual dimensions
|
||||
@@ -89,19 +86,24 @@ export async function POST(req: NextRequest) {
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
media: mediaRecord,
|
||||
url,
|
||||
url: uploadResult.url,
|
||||
key: uploadResult.key,
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
url,
|
||||
url: uploadResult.url,
|
||||
key: uploadResult.key,
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message === 'Authentication required') {
|
||||
return NextResponse.json({ error: 'Authentication required' }, { status: 401 });
|
||||
}
|
||||
if (error instanceof Error && error.message.includes('Storage')) {
|
||||
return NextResponse.json({ error: error.message }, { status: 400 });
|
||||
}
|
||||
console.error('Upload error:', error);
|
||||
return NextResponse.json({ error: 'Upload failed' }, { status: 500 });
|
||||
}
|
||||
|
||||
@@ -2,11 +2,18 @@ import { NextResponse } from 'next/server';
|
||||
import { db, posts, likes, users, notifications } from '@/db';
|
||||
import { requireAuth } from '@/lib/auth';
|
||||
import { requireSignedAction, type SignedAction } from '@/lib/auth/verify-signature';
|
||||
import { eq, and } from 'drizzle-orm';
|
||||
import { eq, and, sql } from 'drizzle-orm';
|
||||
import { z } from 'zod';
|
||||
import crypto from 'crypto';
|
||||
|
||||
type RouteContext = { params: Promise<{ id: string }> };
|
||||
|
||||
// UUID or swarm post ID format (swarm:domain:uuid)
|
||||
const postIdSchema = z.union([
|
||||
z.string().uuid(),
|
||||
z.string().regex(/^swarm:[^:]+:[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/, 'Invalid swarm post ID format'),
|
||||
]);
|
||||
|
||||
/**
|
||||
* Extract domain from a swarm post ID (swarm:domain:postId)
|
||||
*/
|
||||
@@ -43,9 +50,10 @@ export async function POST(request: Request, context: RouteContext) {
|
||||
// Verify the signature and get the user
|
||||
const user = await requireSignedAction(signedAction);
|
||||
|
||||
// Extract postId from the signed action data
|
||||
// Extract and validate postId from the signed action data
|
||||
const { postId: rawId } = signedAction.data;
|
||||
const postId = decodeURIComponent(rawId);
|
||||
const decodedId = decodeURIComponent(rawId);
|
||||
const postId = postIdSchema.parse(decodedId);
|
||||
const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:3000';
|
||||
|
||||
if (user.isSuspended || user.isSilenced) {
|
||||
@@ -118,9 +126,9 @@ export async function POST(request: Request, context: RouteContext) {
|
||||
postId,
|
||||
});
|
||||
|
||||
// Update post's like count
|
||||
// Update post's like count (atomic increment)
|
||||
await db.update(posts)
|
||||
.set({ likesCount: post.likesCount + 1 })
|
||||
.set({ likesCount: sql`${posts.likesCount} + 1` })
|
||||
.where(eq(posts.id, postId));
|
||||
|
||||
if (post.userId !== user.id) {
|
||||
@@ -187,7 +195,9 @@ export async function POST(request: Request, context: RouteContext) {
|
||||
console.warn(`[Swarm] Like delivery failed: ${result.error}`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[Swarm] Error delivering like:', err);
|
||||
// Log error with context but don't fail the request - swarm delivery is best-effort
|
||||
console.error('[Like] Error delivering like to swarm:', err);
|
||||
console.error('[Like] Context:', { postId: originalPostId, userId: user.id, targetDomain });
|
||||
}
|
||||
})();
|
||||
}
|
||||
@@ -197,6 +207,9 @@ export async function POST(request: Request, context: RouteContext) {
|
||||
|
||||
return NextResponse.json({ success: true, liked: true });
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json({ error: 'Invalid post ID', details: error.issues }, { status: 400 });
|
||||
}
|
||||
if (error instanceof Error) {
|
||||
// Handle signature verification errors
|
||||
if (error.message === 'User not found' ||
|
||||
@@ -222,9 +235,10 @@ export async function DELETE(request: Request, context: RouteContext) {
|
||||
// Verify the signature and get the user
|
||||
const user = await requireSignedAction(signedAction);
|
||||
|
||||
// Extract postId from the signed action data
|
||||
// Extract and validate postId from the signed action data
|
||||
const { postId: rawId } = signedAction.data;
|
||||
const postId = decodeURIComponent(rawId);
|
||||
const decodedId = decodeURIComponent(rawId);
|
||||
const postId = postIdSchema.parse(decodedId);
|
||||
const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:3000';
|
||||
|
||||
if (user.isSuspended || user.isSilenced) {
|
||||
@@ -289,9 +303,9 @@ export async function DELETE(request: Request, context: RouteContext) {
|
||||
// Remove like
|
||||
await db.delete(likes).where(eq(likes.id, existingLike.id));
|
||||
|
||||
// Update post's like count
|
||||
// Update post's like count (atomic decrement, clamped to 0)
|
||||
await db.update(posts)
|
||||
.set({ likesCount: Math.max(0, post.likesCount - 1) })
|
||||
.set({ likesCount: sql`GREATEST(0, ${posts.likesCount} - 1)` })
|
||||
.where(eq(posts.id, postId));
|
||||
|
||||
// SWARM-FIRST: Deliver unlike to swarm node
|
||||
@@ -320,7 +334,9 @@ export async function DELETE(request: Request, context: RouteContext) {
|
||||
console.warn(`[Swarm] Unlike delivery failed: ${result.error}`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[Swarm] Error delivering unlike:', err);
|
||||
// Log error with context but don't fail the request - swarm delivery is best-effort
|
||||
console.error('[Like] Error delivering unlike to swarm:', err);
|
||||
console.error('[Like] Context:', { postId: originalPostId, userId: user.id, targetDomain });
|
||||
}
|
||||
})();
|
||||
}
|
||||
@@ -328,6 +344,9 @@ export async function DELETE(request: Request, context: RouteContext) {
|
||||
|
||||
return NextResponse.json({ success: true, liked: false });
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json({ error: 'Invalid post ID', details: error.issues }, { status: 400 });
|
||||
}
|
||||
if (error instanceof Error) {
|
||||
// Handle signature verification errors
|
||||
if (error.message === 'User not found' ||
|
||||
|
||||
@@ -1,11 +1,18 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { db, posts, users, notifications } from '@/db';
|
||||
import { requireAuth } from '@/lib/auth';
|
||||
import { eq, and } from 'drizzle-orm';
|
||||
import { eq, and, sql } from 'drizzle-orm';
|
||||
import { z } from 'zod';
|
||||
import crypto from 'crypto';
|
||||
|
||||
type RouteContext = { params: Promise<{ id: string }> };
|
||||
|
||||
// UUID or swarm post ID format (swarm:domain:uuid)
|
||||
const postIdSchema = z.union([
|
||||
z.string().uuid(),
|
||||
z.string().regex(/^swarm:[^:]+:[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/, 'Invalid swarm post ID format'),
|
||||
]);
|
||||
|
||||
/**
|
||||
* Extract domain from a swarm post ID (swarm:domain:postId)
|
||||
*/
|
||||
@@ -38,7 +45,8 @@ export async function POST(request: Request, context: RouteContext) {
|
||||
try {
|
||||
const user = await requireAuth();
|
||||
const { id: rawId } = await context.params;
|
||||
const postId = decodeURIComponent(rawId);
|
||||
const decodedId = decodeURIComponent(rawId);
|
||||
const postId = postIdSchema.parse(decodedId);
|
||||
const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:3000';
|
||||
|
||||
if (user.isSuspended || user.isSilenced) {
|
||||
@@ -116,12 +124,12 @@ export async function POST(request: Request, context: RouteContext) {
|
||||
|
||||
// Update original post's repost count
|
||||
await db.update(posts)
|
||||
.set({ repostsCount: originalPost.repostsCount + 1 })
|
||||
.set({ repostsCount: sql`${posts.repostsCount} + 1` })
|
||||
.where(eq(posts.id, postId));
|
||||
|
||||
// Update user's post count
|
||||
await db.update(users)
|
||||
.set({ postsCount: user.postsCount + 1 })
|
||||
.set({ postsCount: sql`${users.postsCount} + 1` })
|
||||
.where(eq(users.id, user.id));
|
||||
|
||||
if (originalPost.userId !== user.id) {
|
||||
@@ -196,6 +204,9 @@ export async function POST(request: Request, context: RouteContext) {
|
||||
|
||||
return NextResponse.json({ success: true, repost, reposted: true });
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json({ error: 'Invalid post ID', details: error.issues }, { status: 400 });
|
||||
}
|
||||
if (error instanceof Error && error.message === 'Authentication required') {
|
||||
return NextResponse.json({ error: 'Authentication required' }, { status: 401 });
|
||||
}
|
||||
@@ -208,7 +219,8 @@ export async function DELETE(request: Request, context: RouteContext) {
|
||||
try {
|
||||
const user = await requireAuth();
|
||||
const { id: rawId } = await context.params;
|
||||
const postId = decodeURIComponent(rawId);
|
||||
const decodedId = decodeURIComponent(rawId);
|
||||
const postId = postIdSchema.parse(decodedId);
|
||||
const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:3000';
|
||||
|
||||
if (user.isSuspended || user.isSilenced) {
|
||||
@@ -272,17 +284,20 @@ export async function DELETE(request: Request, context: RouteContext) {
|
||||
// Update original post's repost count
|
||||
if (originalPost) {
|
||||
await db.update(posts)
|
||||
.set({ repostsCount: Math.max(0, originalPost.repostsCount - 1) })
|
||||
.set({ repostsCount: sql`GREATEST(0, ${posts.repostsCount} - 1)` })
|
||||
.where(eq(posts.id, postId));
|
||||
}
|
||||
|
||||
// Update user's post count
|
||||
await db.update(users)
|
||||
.set({ postsCount: Math.max(0, user.postsCount - 1) })
|
||||
.set({ postsCount: sql`GREATEST(0, ${users.postsCount} - 1)` })
|
||||
.where(eq(users.id, user.id));
|
||||
|
||||
return NextResponse.json({ success: true, reposted: false });
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json({ error: 'Invalid post ID', details: error.issues }, { status: 400 });
|
||||
}
|
||||
if (error instanceof Error && error.message === 'Authentication required') {
|
||||
return NextResponse.json({ error: 'Authentication required' }, { status: 401 });
|
||||
}
|
||||
|
||||
@@ -1,6 +1,19 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { db, posts, users, media, remotePosts } from '@/db';
|
||||
import { eq, desc, and } from 'drizzle-orm';
|
||||
import { eq, desc, and, sql } from 'drizzle-orm';
|
||||
import { z } from 'zod';
|
||||
|
||||
// Schema for local post ID (UUID)
|
||||
const localPostIdSchema = z.string().uuid('Invalid post ID format');
|
||||
|
||||
// Schema for swarm post ID (swarm:domain:uuid)
|
||||
const swarmPostIdSchema = z.string().regex(
|
||||
/^swarm:[^:]+:[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i,
|
||||
'Invalid swarm post ID format'
|
||||
);
|
||||
|
||||
// Combined schema that accepts either format
|
||||
const postIdSchema = z.union([localPostIdSchema, swarmPostIdSchema]);
|
||||
|
||||
export async function GET(
|
||||
request: Request,
|
||||
@@ -424,15 +437,10 @@ export async function DELETE(
|
||||
// 3. Delete the post (cascades to media, likes, notifications)
|
||||
await db.delete(posts).where(eq(posts.id, id));
|
||||
|
||||
// 4. Decrement the post author's postsCount
|
||||
const postAuthor = await db.query.users.findFirst({
|
||||
where: eq(users.id, post.userId),
|
||||
});
|
||||
if (postAuthor && postAuthor.postsCount > 0) {
|
||||
await db.update(users)
|
||||
.set({ postsCount: postAuthor.postsCount - 1 })
|
||||
.where(eq(users.id, post.userId));
|
||||
}
|
||||
// 4. Decrement the post author's postsCount (atomic decrement, clamped to 0)
|
||||
await db.update(users)
|
||||
.set({ postsCount: sql`GREATEST(0, ${users.postsCount} - 1)` })
|
||||
.where(eq(users.id, post.userId));
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
|
||||
+17
-16
@@ -2,7 +2,7 @@ import { NextResponse } from 'next/server';
|
||||
import { db, posts, users, media, follows, mutes, blocks, likes, remoteFollows, remotePosts } from '@/db';
|
||||
import { requireAuth } from '@/lib/auth';
|
||||
import { requireSignedAction, type SignedAction } from '@/lib/auth/verify-signature';
|
||||
import { eq, desc, and, inArray, isNull, isNotNull, or, lt } from 'drizzle-orm';
|
||||
import { eq, desc, and, inArray, isNull, isNotNull, or, lt, sql } from 'drizzle-orm';
|
||||
import type { SQL } from 'drizzle-orm';
|
||||
import { z } from 'zod';
|
||||
|
||||
@@ -19,7 +19,7 @@ const buildWhere = (...conditions: Array<SQL | undefined>) => {
|
||||
|
||||
const createPostSchema = z.object({
|
||||
content: z.string().min(1).max(POST_MAX_LENGTH),
|
||||
replyToId: z.string().optional(), // Can be UUID or swarm:domain:uuid
|
||||
replyToId: z.string().uuid().optional(), // Must be UUID (swarm replies use separate field)
|
||||
swarmReplyTo: z.object({
|
||||
postId: z.string(),
|
||||
nodeDomain: z.string(),
|
||||
@@ -105,21 +105,16 @@ export async function POST(request: Request) {
|
||||
});
|
||||
}
|
||||
|
||||
// Update user's post count
|
||||
// Update user's post count (atomic increment to prevent race conditions)
|
||||
await db.update(users)
|
||||
.set({ postsCount: user.postsCount + 1 })
|
||||
.set({ postsCount: sql`${users.postsCount} + 1` })
|
||||
.where(eq(users.id, user.id));
|
||||
|
||||
// If this is a reply, update the parent's reply count
|
||||
// If this is a reply, update the parent's reply count (atomic increment)
|
||||
if (data.replyToId) {
|
||||
const parentPost = await db.query.posts.findFirst({
|
||||
where: eq(posts.id, data.replyToId),
|
||||
});
|
||||
if (parentPost) {
|
||||
await db.update(posts)
|
||||
.set({ repliesCount: parentPost.repliesCount + 1 })
|
||||
.where(eq(posts.id, data.replyToId));
|
||||
}
|
||||
await db.update(posts)
|
||||
.set({ repliesCount: sql`${posts.repliesCount} + 1` })
|
||||
.where(eq(posts.id, data.replyToId));
|
||||
}
|
||||
|
||||
// DEPRECATED: Push-based federation disabled
|
||||
@@ -213,7 +208,9 @@ export async function POST(request: Request) {
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[Local] Error creating mention notifications:', err);
|
||||
// Log error with context but don't fail the request - mention notifications are best-effort
|
||||
console.error('[Posts] Error creating mention notifications:', err);
|
||||
console.error('[Posts] Context:', { postId: post.id, userId: user.id, content: data.content?.slice(0, 100) });
|
||||
}
|
||||
})();
|
||||
|
||||
@@ -237,7 +234,9 @@ export async function POST(request: Request) {
|
||||
console.log(`[Swarm] Delivered ${result.delivered} mentions (${result.failed} failed)`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[Swarm] Error delivering mentions:', err);
|
||||
// Log error with context but don't fail the request - swarm delivery is best-effort
|
||||
console.error('[Posts] Error delivering swarm mentions:', err);
|
||||
console.error('[Posts] Context:', { postId: post.id, userId: user.id, nodeDomain });
|
||||
}
|
||||
})();
|
||||
|
||||
@@ -264,7 +263,9 @@ export async function POST(request: Request) {
|
||||
console.log(`[Swarm] Post ${post.id} delivered to ${swarmResult.delivered} swarm nodes (${swarmResult.failed} failed)`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[Swarm] Error delivering post:', err);
|
||||
// Log error with context but don't fail the request - swarm delivery is best-effort
|
||||
console.error('[Posts] Error delivering post to swarm followers:', err);
|
||||
console.error('[Posts] Context:', { postId: post.id, userId: user.id, nodeDomain });
|
||||
}
|
||||
})();
|
||||
|
||||
|
||||
@@ -8,6 +8,15 @@ import { NextRequest, NextResponse } from 'next/server';
|
||||
import { db, chatConversations, chatMessages, users } from '@/db';
|
||||
import { eq, and } from 'drizzle-orm';
|
||||
import { getSession } from '@/lib/auth';
|
||||
import { z } from 'zod';
|
||||
|
||||
// Schema for conversation ID parameter
|
||||
const conversationIdSchema = z.string().uuid('Invalid conversation ID format');
|
||||
|
||||
// Schema for delete query parameter
|
||||
const deleteQuerySchema = z.object({
|
||||
deleteFor: z.enum(['self', 'both']).optional(),
|
||||
});
|
||||
|
||||
export async function DELETE(
|
||||
request: NextRequest,
|
||||
@@ -24,8 +33,23 @@ export async function DELETE(
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
|
||||
// Validate conversation ID
|
||||
const idResult = conversationIdSchema.safeParse(id);
|
||||
if (!idResult.success) {
|
||||
return NextResponse.json({ error: 'Invalid conversation ID', details: idResult.error.issues }, { status: 400 });
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const deleteFor = searchParams.get('deleteFor'); // 'self' or 'both'
|
||||
const deleteForRaw = searchParams.get('deleteFor'); // 'self' or 'both'
|
||||
|
||||
// Validate deleteFor parameter
|
||||
const deleteForResult = deleteQuerySchema.safeParse({ deleteFor: deleteForRaw || undefined });
|
||||
if (!deleteForResult.success) {
|
||||
return NextResponse.json({ error: 'Invalid deleteFor parameter', details: deleteForResult.error.issues }, { status: 400 });
|
||||
}
|
||||
|
||||
const { deleteFor } = deleteForResult.data;
|
||||
|
||||
// Verify the conversation belongs to this user
|
||||
const conversation = await db.query.chatConversations.findFirst({
|
||||
@@ -118,6 +142,9 @@ export async function DELETE(
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json({ error: 'Invalid input', details: error.issues }, { status: 400 });
|
||||
}
|
||||
console.error('Delete conversation error:', error);
|
||||
return NextResponse.json({ error: 'Failed to delete conversation' }, { status: 500 });
|
||||
}
|
||||
|
||||
@@ -9,6 +9,19 @@ import { NextRequest, NextResponse } from 'next/server';
|
||||
import { db, chatConversations, chatMessages, users } from '@/db';
|
||||
import { eq, desc, and, lt, isNull, sql, inArray } from 'drizzle-orm';
|
||||
import { getSession } from '@/lib/auth';
|
||||
import { z } from 'zod';
|
||||
|
||||
// Schema for query parameters
|
||||
const messagesQuerySchema = z.object({
|
||||
conversationId: z.string().uuid(),
|
||||
cursor: z.string().datetime().optional(),
|
||||
limit: z.number().min(1).max(100).default(50),
|
||||
});
|
||||
|
||||
// Schema for PATCH request body
|
||||
const markReadSchema = z.object({
|
||||
conversationId: z.string().uuid(),
|
||||
});
|
||||
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
@@ -23,14 +36,20 @@ export async function GET(request: NextRequest) {
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const conversationId = searchParams.get('conversationId');
|
||||
const cursor = searchParams.get('cursor'); // For pagination
|
||||
const limit = Math.min(parseInt(searchParams.get('limit') || '50'), 100);
|
||||
|
||||
// Validate query parameters
|
||||
const queryResult = messagesQuerySchema.safeParse({
|
||||
conversationId: searchParams.get('conversationId'),
|
||||
cursor: searchParams.get('cursor') || undefined,
|
||||
limit: parseInt(searchParams.get('limit') || '50'),
|
||||
});
|
||||
|
||||
if (!conversationId) {
|
||||
return NextResponse.json({ error: 'conversationId required' }, { status: 400 });
|
||||
if (!queryResult.success) {
|
||||
return NextResponse.json({ error: 'Invalid query parameters', details: queryResult.error.issues }, { status: 400 });
|
||||
}
|
||||
|
||||
const { conversationId, cursor, limit } = queryResult.data;
|
||||
|
||||
// Verify user has access to this conversation
|
||||
const conversation = await db.query.chatConversations.findFirst({
|
||||
where: and(
|
||||
@@ -114,6 +133,9 @@ export async function GET(request: NextRequest) {
|
||||
nextCursor: messages.length === limit ? messages[messages.length - 1].createdAt.toISOString() : null,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json({ error: 'Invalid input', details: error.issues }, { status: 400 });
|
||||
}
|
||||
console.error('Get messages error:', error);
|
||||
return NextResponse.json({ error: 'Failed to get messages' }, { status: 500 });
|
||||
}
|
||||
@@ -130,11 +152,15 @@ export async function PATCH(request: NextRequest) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const { conversationId } = await request.json();
|
||||
|
||||
if (!conversationId) {
|
||||
return NextResponse.json({ error: 'conversationId required' }, { status: 400 });
|
||||
const body = await request.json();
|
||||
|
||||
// Validate request body
|
||||
const bodyResult = markReadSchema.safeParse(body);
|
||||
if (!bodyResult.success) {
|
||||
return NextResponse.json({ error: 'Invalid request body', details: bodyResult.error.issues }, { status: 400 });
|
||||
}
|
||||
|
||||
const { conversationId } = bodyResult.data;
|
||||
|
||||
// Verify user has access to this conversation
|
||||
const conversation = await db.query.chatConversations.findFirst({
|
||||
@@ -160,6 +186,9 @@ export async function PATCH(request: NextRequest) {
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json({ error: 'Invalid input', details: error.issues }, { status: 400 });
|
||||
}
|
||||
console.error('Mark as read error:', error);
|
||||
return NextResponse.json({ error: 'Failed to mark as read' }, { status: 500 });
|
||||
}
|
||||
|
||||
@@ -11,22 +11,22 @@
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { db, users, notifications, remoteFollowers } from '@/db';
|
||||
import { eq, and } from 'drizzle-orm';
|
||||
import { eq, and, sql } from 'drizzle-orm';
|
||||
import { z } from 'zod';
|
||||
import { verifyUserInteraction } from '@/lib/swarm/signature';
|
||||
|
||||
const swarmFollowSchema = z.object({
|
||||
targetHandle: z.string(),
|
||||
targetHandle: z.string().min(3).max(30).regex(/^[a-zA-Z0-9_]+$/, 'Handle must be alphanumeric with underscores'),
|
||||
follow: z.object({
|
||||
followerHandle: z.string(),
|
||||
followerDisplayName: z.string(),
|
||||
followerAvatarUrl: z.string().optional(),
|
||||
followerBio: z.string().optional(),
|
||||
followerNodeDomain: z.string(),
|
||||
interactionId: z.string(),
|
||||
timestamp: z.string(),
|
||||
followerHandle: z.string().min(3).max(30).regex(/^[a-zA-Z0-9_]+$/, 'Handle must be alphanumeric with underscores'),
|
||||
followerDisplayName: z.string().min(1).max(50),
|
||||
followerAvatarUrl: z.string().url().optional(),
|
||||
followerBio: z.string().max(500).optional(),
|
||||
followerNodeDomain: z.string().min(1).regex(/^[a-zA-Z0-9][a-zA-Z0-9-]{1,61}[a-zA-Z0-9]\.[a-zA-Z]{2,}$/, 'Invalid domain format'),
|
||||
interactionId: z.string().uuid(),
|
||||
timestamp: z.string().datetime(),
|
||||
}),
|
||||
signature: z.string(),
|
||||
signature: z.string().min(1),
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -100,7 +100,7 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
// Update follower count
|
||||
await db.update(users)
|
||||
.set({ followersCount: targetUser.followersCount + 1 })
|
||||
.set({ followersCount: sql`${users.followersCount} + 1` })
|
||||
.where(eq(users.id, targetUser.id));
|
||||
|
||||
// Create notification with actor info stored directly
|
||||
@@ -115,7 +115,9 @@ export async function POST(request: NextRequest) {
|
||||
});
|
||||
console.log(`[Swarm] Created follow notification for @${data.targetHandle} from ${data.follow.followerHandle}@${data.follow.followerNodeDomain}`);
|
||||
} catch (notifError) {
|
||||
console.error(`[Swarm] Failed to create notification:`, notifError);
|
||||
// Log error with context but don't fail the request - notification creation is best-effort
|
||||
console.error('[Swarm Follow] Failed to create notification:', notifError);
|
||||
console.error('[Swarm Follow] Context:', { targetHandle: data.targetHandle, userId: targetUser.id, actor: data.follow.followerHandle });
|
||||
}
|
||||
|
||||
// Also notify bot owner if this is a bot being followed
|
||||
@@ -130,7 +132,9 @@ export async function POST(request: NextRequest) {
|
||||
type: 'follow',
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[Swarm] Failed to notify bot owner:', err);
|
||||
// Log error with context but don't fail the request - bot owner notification is best-effort
|
||||
console.error('[Swarm Follow] Failed to notify bot owner:', err);
|
||||
console.error('[Swarm Follow] Context:', { targetHandle: data.targetHandle, botOwnerId: targetUser.botOwnerId, actor: data.follow.followerHandle });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -15,14 +15,14 @@ import { verifyUserInteraction } from '@/lib/swarm/signature';
|
||||
const swarmLikeSchema = z.object({
|
||||
postId: z.string().uuid(),
|
||||
like: z.object({
|
||||
actorHandle: z.string(),
|
||||
actorDisplayName: z.string(),
|
||||
actorAvatarUrl: z.string().optional(),
|
||||
actorNodeDomain: z.string(),
|
||||
interactionId: z.string(),
|
||||
timestamp: z.string(),
|
||||
actorHandle: z.string().min(3).max(30).regex(/^[a-zA-Z0-9_]+$/, 'Handle must be alphanumeric with underscores'),
|
||||
actorDisplayName: z.string().min(1).max(50),
|
||||
actorAvatarUrl: z.string().url().optional(),
|
||||
actorNodeDomain: z.string().min(1).regex(/^[a-zA-Z0-9][a-zA-Z0-9-]{1,61}[a-zA-Z0-9]\.[a-zA-Z]{2,}$/, 'Invalid domain format'),
|
||||
interactionId: z.string().uuid(),
|
||||
timestamp: z.string().datetime(),
|
||||
}),
|
||||
signature: z.string(),
|
||||
signature: z.string().min(1),
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -106,7 +106,9 @@ export async function POST(request: NextRequest) {
|
||||
});
|
||||
console.log(`[Swarm] Created like notification for post ${data.postId} from ${data.like.actorHandle}@${data.like.actorNodeDomain}`);
|
||||
} catch (notifError) {
|
||||
console.error(`[Swarm] Failed to create like notification:`, notifError);
|
||||
// Log error with context but don't fail the request - notification creation is best-effort
|
||||
console.error('[Swarm Like] Failed to create notification:', notifError);
|
||||
console.error('[Swarm Like] Context:', { postId: data.postId, userId: post.userId, actor: data.like.actorHandle });
|
||||
}
|
||||
|
||||
// Also notify bot owner if this is a bot's post
|
||||
@@ -124,7 +126,9 @@ export async function POST(request: NextRequest) {
|
||||
type: 'like',
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[Swarm] Failed to notify bot owner:', err);
|
||||
// Log error with context but don't fail the request - bot owner notification is best-effort
|
||||
console.error('[Swarm Like] Failed to notify bot owner:', err);
|
||||
console.error('[Swarm Like] Context:', { postId: data.postId, botOwnerId: author.botOwnerId, actor: data.like.actorHandle });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,22 +8,22 @@
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { db, posts, users, notifications } from '@/db';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { eq, sql } from 'drizzle-orm';
|
||||
import { z } from 'zod';
|
||||
import { verifyUserInteraction } from '@/lib/swarm/signature';
|
||||
|
||||
const swarmRepostSchema = z.object({
|
||||
postId: z.string().uuid(),
|
||||
repost: z.object({
|
||||
actorHandle: z.string(),
|
||||
actorDisplayName: z.string(),
|
||||
actorAvatarUrl: z.string().optional(),
|
||||
actorNodeDomain: z.string(),
|
||||
repostId: z.string(),
|
||||
interactionId: z.string(),
|
||||
timestamp: z.string(),
|
||||
actorHandle: z.string().min(3).max(30).regex(/^[a-zA-Z0-9_]+$/, 'Handle must be alphanumeric with underscores'),
|
||||
actorDisplayName: z.string().min(1).max(50),
|
||||
actorAvatarUrl: z.string().url().optional(),
|
||||
actorNodeDomain: z.string().min(1).regex(/^[a-zA-Z0-9][a-zA-Z0-9-]{1,61}[a-zA-Z0-9]\.[a-zA-Z]{2,}$/, 'Invalid domain format'),
|
||||
repostId: z.string().uuid(),
|
||||
interactionId: z.string().uuid(),
|
||||
timestamp: z.string().datetime(),
|
||||
}),
|
||||
signature: z.string(),
|
||||
signature: z.string().min(1),
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -70,7 +70,7 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
// Increment repost count
|
||||
await db.update(posts)
|
||||
.set({ repostsCount: post.repostsCount + 1 })
|
||||
.set({ repostsCount: sql`${posts.repostsCount} + 1` })
|
||||
.where(eq(posts.id, data.postId));
|
||||
|
||||
// Create notification with actor info stored directly
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { db, users, remoteFollowers } from '@/db';
|
||||
import { eq, and } from 'drizzle-orm';
|
||||
import { eq, and, sql } from 'drizzle-orm';
|
||||
import { z } from 'zod';
|
||||
import { verifyUserInteraction } from '@/lib/swarm/signature';
|
||||
|
||||
@@ -82,7 +82,7 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
// Update follower count
|
||||
await db.update(users)
|
||||
.set({ followersCount: Math.max(0, targetUser.followersCount - 1) })
|
||||
.set({ followersCount: sql`GREATEST(0, ${users.followersCount} - 1)` })
|
||||
.where(eq(users.id, targetUser.id));
|
||||
|
||||
console.log(`[Swarm] Received unfollow from ${data.unfollow.followerHandle}@${data.unfollow.followerNodeDomain} for @${data.targetHandle}`);
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { db, posts } from '@/db';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { eq, sql } from 'drizzle-orm';
|
||||
import { z } from 'zod';
|
||||
import { verifyUserInteraction } from '@/lib/swarm/signature';
|
||||
|
||||
@@ -66,7 +66,7 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
// Decrement repost count
|
||||
await db.update(posts)
|
||||
.set({ repostsCount: Math.max(0, post.repostsCount - 1) })
|
||||
.set({ repostsCount: sql`GREATEST(0, ${posts.repostsCount} - 1)` })
|
||||
.where(eq(posts.id, data.postId));
|
||||
|
||||
console.log(`[Swarm] Received unrepost from ${data.unrepost.actorHandle}@${data.unrepost.actorNodeDomain} on post ${data.postId}`);
|
||||
|
||||
@@ -7,9 +7,19 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { db, posts, likes, users, remoteLikes } from '@/db';
|
||||
import { eq, and } from 'drizzle-orm';
|
||||
import { z } from 'zod';
|
||||
|
||||
type RouteContext = { params: Promise<{ id: string }> };
|
||||
|
||||
// Schema for post ID parameter
|
||||
const postIdSchema = z.string().uuid('Invalid post ID format');
|
||||
|
||||
// Schema for query parameters
|
||||
const likesQuerySchema = z.object({
|
||||
checkHandle: z.string().min(3).max(30).optional(),
|
||||
checkDomain: z.string().min(1).max(100).optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/swarm/posts/[id]/likes
|
||||
*
|
||||
@@ -24,10 +34,28 @@ export async function GET(request: NextRequest, context: RouteContext) {
|
||||
return NextResponse.json({ error: 'Database not available' }, { status: 503 });
|
||||
}
|
||||
|
||||
const { id: postId } = await context.params;
|
||||
const { id: rawId } = await context.params;
|
||||
|
||||
// Validate post ID
|
||||
const idResult = postIdSchema.safeParse(rawId);
|
||||
if (!idResult.success) {
|
||||
return NextResponse.json({ error: 'Invalid post ID', details: idResult.error.issues }, { status: 400 });
|
||||
}
|
||||
const postId = idResult.data;
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const checkHandle = searchParams.get('checkHandle');
|
||||
const checkDomain = searchParams.get('checkDomain');
|
||||
|
||||
// Validate query parameters
|
||||
const queryResult = likesQuerySchema.safeParse({
|
||||
checkHandle: searchParams.get('checkHandle') || undefined,
|
||||
checkDomain: searchParams.get('checkDomain') || undefined,
|
||||
});
|
||||
|
||||
if (!queryResult.success) {
|
||||
return NextResponse.json({ error: 'Invalid query parameters', details: queryResult.error.issues }, { status: 400 });
|
||||
}
|
||||
|
||||
const { checkHandle, checkDomain } = queryResult.data;
|
||||
|
||||
// Find the post
|
||||
const post = await db.query.posts.findFirst({
|
||||
@@ -94,6 +122,9 @@ export async function GET(request: NextRequest, context: RouteContext) {
|
||||
likesCount: post.likesCount,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json({ error: 'Invalid input', details: error.issues }, { status: 400 });
|
||||
}
|
||||
console.error('[Swarm] Post likes error:', error);
|
||||
return NextResponse.json({ error: 'Failed to get likes' }, { status: 500 });
|
||||
}
|
||||
|
||||
@@ -7,9 +7,12 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { db, posts } from '@/db';
|
||||
import { eq, desc, and } from 'drizzle-orm';
|
||||
import { z } from 'zod';
|
||||
|
||||
type RouteContext = { params: Promise<{ id: string }> };
|
||||
|
||||
const uuidSchema = z.string().uuid();
|
||||
|
||||
/**
|
||||
* GET /api/swarm/posts/[id]
|
||||
*
|
||||
@@ -21,7 +24,14 @@ export async function GET(request: NextRequest, context: RouteContext) {
|
||||
return NextResponse.json({ error: 'Database not available' }, { status: 503 });
|
||||
}
|
||||
|
||||
const { id: postId } = await context.params;
|
||||
const { id: postIdRaw } = await context.params;
|
||||
|
||||
// Validate postId is a valid UUID
|
||||
const postIdValidation = uuidSchema.safeParse(postIdRaw);
|
||||
if (!postIdValidation.success) {
|
||||
return NextResponse.json({ error: 'Invalid post ID format' }, { status: 400 });
|
||||
}
|
||||
const postId = postIdValidation.data;
|
||||
|
||||
// Find the post
|
||||
const post = await db.query.posts.findFirst({
|
||||
@@ -97,6 +107,12 @@ export async function GET(request: NextRequest, context: RouteContext) {
|
||||
}),
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid input', details: error.issues },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
console.error('[Swarm] Post detail error:', error);
|
||||
return NextResponse.json({ error: 'Failed to get post' }, { status: 500 });
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import crypto from 'crypto';
|
||||
import { db, follows, users, notifications, remoteFollows } from '@/db';
|
||||
import { eq, and } from 'drizzle-orm';
|
||||
import { eq, and, sql } from 'drizzle-orm';
|
||||
import { requireAuth } from '@/lib/auth';
|
||||
import { requireSignedAction } from '@/lib/auth/verify-signature';
|
||||
import { isSwarmNode, deliverSwarmFollow, deliverSwarmUnfollow, cacheSwarmUserPosts } from '@/lib/swarm/interactions';
|
||||
@@ -152,9 +152,9 @@ export async function POST(request: Request, context: RouteContext) {
|
||||
avatarUrl: null,
|
||||
});
|
||||
|
||||
// Update the user's following count
|
||||
// Update the user's following count (atomic increment)
|
||||
await db.update(users)
|
||||
.set({ followingCount: currentUser.followingCount + 1 })
|
||||
.set({ followingCount: sql`${users.followingCount} + 1` })
|
||||
.where(eq(users.id, currentUser.id));
|
||||
|
||||
// Cache the remote user's recent posts in the background
|
||||
@@ -231,13 +231,13 @@ export async function POST(request: Request, context: RouteContext) {
|
||||
}
|
||||
}
|
||||
|
||||
// Update counts
|
||||
// Update counts (atomic increments)
|
||||
await db.update(users)
|
||||
.set({ followingCount: currentUser.followingCount + 1 })
|
||||
.set({ followingCount: sql`${users.followingCount} + 1` })
|
||||
.where(eq(users.id, currentUser.id));
|
||||
|
||||
await db.update(users)
|
||||
.set({ followersCount: targetUser.followersCount + 1 })
|
||||
.set({ followersCount: sql`${users.followersCount} + 1` })
|
||||
.where(eq(users.id, targetUser.id));
|
||||
|
||||
return NextResponse.json({ success: true, following: true });
|
||||
@@ -295,9 +295,9 @@ export async function DELETE(request: Request, context: RouteContext) {
|
||||
// Remove the follow record
|
||||
await db.delete(remoteFollows).where(eq(remoteFollows.id, existingRemoteFollow.id));
|
||||
|
||||
// Update the user's following count
|
||||
// Update the user's following count (atomic decrement, clamped to 0)
|
||||
await db.update(users)
|
||||
.set({ followingCount: Math.max(0, currentUser.followingCount - 1) })
|
||||
.set({ followingCount: sql`GREATEST(0, ${users.followingCount} - 1)` })
|
||||
.where(eq(users.id, currentUser.id));
|
||||
|
||||
console.log(`[Swarm] Unfollow delivered to ${remote.domain}`);
|
||||
@@ -335,13 +335,13 @@ export async function DELETE(request: Request, context: RouteContext) {
|
||||
// Remove follow
|
||||
await db.delete(follows).where(eq(follows.id, existingFollow.id));
|
||||
|
||||
// Update counts
|
||||
// Update counts (atomic decrements, clamped to 0)
|
||||
await db.update(users)
|
||||
.set({ followingCount: Math.max(0, currentUser.followingCount - 1) })
|
||||
.set({ followingCount: sql`GREATEST(0, ${users.followingCount} - 1)` })
|
||||
.where(eq(users.id, currentUser.id));
|
||||
|
||||
await db.update(users)
|
||||
.set({ followersCount: Math.max(0, targetUser.followersCount - 1) })
|
||||
.set({ followersCount: sql`GREATEST(0, ${users.followersCount} - 1)` })
|
||||
.where(eq(users.id, targetUser.id));
|
||||
|
||||
return NextResponse.json({ success: true, following: false });
|
||||
|
||||
+2
-25
@@ -3,7 +3,7 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { useAuth } from '@/lib/contexts/AuthContext';
|
||||
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 { useRouter, useSearchParams } from 'next/navigation';
|
||||
|
||||
@@ -35,7 +35,7 @@ interface Message {
|
||||
}
|
||||
|
||||
export default function ChatPage() {
|
||||
const { user, isIdentityUnlocked, setShowUnlockPrompt } = useAuth();
|
||||
const { user } = useAuth();
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const composeHandle = searchParams.get('compose');
|
||||
@@ -348,29 +348,6 @@ export default function ChatPage() {
|
||||
|
||||
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
|
||||
if (composeHandle && !selectedConversation) {
|
||||
return (
|
||||
|
||||
+254
-91
@@ -32,6 +32,12 @@ export default function LoginPage() {
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
const [handle, setHandle] = 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 [loading, setLoading] = useState(false);
|
||||
const [nodeInfoLoaded, setNodeInfoLoaded] = useState(false);
|
||||
@@ -244,6 +250,12 @@ export default function LoginPage() {
|
||||
password,
|
||||
handle,
|
||||
displayName,
|
||||
storageProvider,
|
||||
storageEndpoint: storageEndpoint || null,
|
||||
storageRegion,
|
||||
storageBucket,
|
||||
storageAccessKey,
|
||||
storageSecretKey,
|
||||
...(nodeInfo.turnstileSiteKey ? { turnstileToken } : {})
|
||||
};
|
||||
|
||||
@@ -326,7 +338,7 @@ export default function LoginPage() {
|
||||
justifyContent: 'center',
|
||||
padding: '24px',
|
||||
}}>
|
||||
<div style={{ width: '100%', maxWidth: '400px' }}>
|
||||
<div style={{ width: '100%', maxWidth: mode === 'register' ? '680px' : '400px' }}>
|
||||
{/* Logo */}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', marginBottom: '32px', minHeight: '120px' }}>
|
||||
{nodeInfoLoaded && (
|
||||
@@ -440,111 +452,248 @@ export default function LoginPage() {
|
||||
|
||||
{mode === 'register' && (
|
||||
<>
|
||||
<div style={{ marginBottom: '16px' }}>
|
||||
<label style={{ display: 'block', marginBottom: '6px', fontSize: '14px', fontWeight: 500 }}>
|
||||
Handle
|
||||
</label>
|
||||
<div style={{ position: 'relative' }}>
|
||||
<span style={{
|
||||
position: 'absolute',
|
||||
left: '12px',
|
||||
top: '50%',
|
||||
transform: 'translateY(-50%)',
|
||||
color: 'var(--foreground-tertiary)',
|
||||
}}>@</span>
|
||||
<input
|
||||
type="text"
|
||||
className="input"
|
||||
value={handle}
|
||||
onChange={(e) => setHandle(e.target.value.toLowerCase().replace(/[^a-z0-9_]/g, ''))}
|
||||
style={{ paddingLeft: '28px' }}
|
||||
placeholder="yourhandle"
|
||||
required
|
||||
minLength={3}
|
||||
maxLength={20}
|
||||
/>
|
||||
{/* Row 1: Handle | Password */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '16px', marginBottom: '16px' }}>
|
||||
<div>
|
||||
<label style={{ display: 'block', marginBottom: '6px', fontSize: '14px', fontWeight: 500 }}>
|
||||
Handle
|
||||
</label>
|
||||
<div style={{ position: 'relative' }}>
|
||||
<span style={{
|
||||
position: 'absolute',
|
||||
left: '12px',
|
||||
top: '50%',
|
||||
transform: 'translateY(-50%)',
|
||||
color: 'var(--foreground-tertiary)',
|
||||
}}>@</span>
|
||||
<input
|
||||
type="text"
|
||||
className="input"
|
||||
value={handle}
|
||||
onChange={(e) => setHandle(e.target.value.toLowerCase().replace(/[^a-z0-9_]/g, ''))}
|
||||
style={{ paddingLeft: '28px' }}
|
||||
placeholder="yourhandle"
|
||||
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 style={{
|
||||
fontSize: '12px',
|
||||
marginTop: '4px',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center'
|
||||
}}>
|
||||
<span style={{ color: 'var(--foreground-tertiary)' }}>
|
||||
3-20 characters, alphanumeric and underscores
|
||||
</span>
|
||||
{handleStatus === 'checking' && (
|
||||
<span style={{ color: 'var(--foreground-tertiary)' }}>Checking...</span>
|
||||
)}
|
||||
{handleStatus === 'available' && (
|
||||
<span style={{ color: 'var(--success)', fontWeight: 600 }}>Available</span>
|
||||
)}
|
||||
{handleStatus === 'taken' && (
|
||||
<span style={{ color: 'var(--error)', fontWeight: 600 }}>Taken</span>
|
||||
)}
|
||||
<div>
|
||||
<label style={{ display: 'block', marginBottom: '6px', fontSize: '14px', fontWeight: 500 }}>
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
className="input"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="••••••••"
|
||||
required
|
||||
minLength={8}
|
||||
/>
|
||||
</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' }}>
|
||||
<label style={{ display: 'block', marginBottom: '6px', fontSize: '14px', fontWeight: 500 }}>
|
||||
Display Name
|
||||
Endpoint URL (optional for AWS)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
className="input"
|
||||
value={displayName}
|
||||
onChange={(e) => setDisplayName(e.target.value)}
|
||||
placeholder="Your Name"
|
||||
value={storageEndpoint}
|
||||
onChange={(e) => setStorageEndpoint(e.target.value)}
|
||||
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 style={{ marginBottom: '16px' }}>
|
||||
<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>
|
||||
{/* Login Mode - Show email/password only */}
|
||||
{mode === 'login' && (
|
||||
<>
|
||||
<div style={{ marginBottom: '16px' }}>
|
||||
<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 style={{ marginBottom: '24px' }}>
|
||||
<label style={{ display: 'block', marginBottom: '6px', fontSize: '14px', fontWeight: 500 }}>
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
className="input"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="••••••••"
|
||||
required
|
||||
minLength={8}
|
||||
/>
|
||||
</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>
|
||||
<div style={{ marginBottom: '24px' }}>
|
||||
<label style={{ display: 'block', marginBottom: '6px', fontSize: '14px', fontWeight: 500 }}>
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
className="input"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="••••••••"
|
||||
required
|
||||
minLength={8}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{mode === 'register' && nodeInfo.isNsfw && (
|
||||
@@ -581,7 +730,21 @@ export default function LoginPage() {
|
||||
type="submit"
|
||||
className="btn btn-primary btn-lg"
|
||||
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')}
|
||||
</button>
|
||||
|
||||
@@ -62,6 +62,13 @@ export const users = pgTable('users', {
|
||||
movedTo: text('moved_to'), // New actor URL if this account migrated away
|
||||
movedFrom: text('moved_from'), // Old actor URL if this account migrated here
|
||||
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(),
|
||||
followingCount: integer('following_count').default(0).notNull(),
|
||||
postsCount: integer('posts_count').default(0).notNull(),
|
||||
|
||||
+62
-9
@@ -8,9 +8,10 @@ import bcrypt from 'bcryptjs';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import { generateKeyPair } from '@/lib/crypto/keys';
|
||||
import { encryptPrivateKey, serializeEncryptedKey } from '@/lib/crypto/private-key';
|
||||
import { base58btc } from 'multiformats/bases/base58';
|
||||
import { cookies } from 'next/headers';
|
||||
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_EXPIRY_DAYS = 30;
|
||||
@@ -31,10 +32,22 @@ export async function verifyPassword(password: string, hash: string): Promise<bo
|
||||
|
||||
/**
|
||||
* Generate a DID for a new user
|
||||
* Uses did:key format (W3C standard) - the DID contains the public key itself
|
||||
*/
|
||||
export function generateDID(): string {
|
||||
// Using a simple did:key-like format for now
|
||||
// In production, this would be more sophisticated
|
||||
export function generateDID(publicKey: string): string {
|
||||
// Encode the SPKI public key in base58btc (multibase)
|
||||
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, '')}`;
|
||||
}
|
||||
|
||||
@@ -123,7 +136,13 @@ export async function registerUser(
|
||||
handle: string,
|
||||
email: string,
|
||||
password: string,
|
||||
displayName?: string
|
||||
displayName?: string,
|
||||
storageProvider?: string,
|
||||
storageEndpoint?: string | null,
|
||||
storageRegion?: string,
|
||||
storageBucket?: string,
|
||||
storageAccessKey?: string,
|
||||
storageSecretKey?: string
|
||||
): Promise<typeof users.$inferSelect> {
|
||||
// Validate handle format
|
||||
if (!/^[a-zA-Z0-9_]{3,20}$/.test(handle)) {
|
||||
@@ -148,21 +167,49 @@ export async function registerUser(
|
||||
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
|
||||
const { publicKey, privateKey } = await generateKeyPair();
|
||||
|
||||
// Encrypt the private key with user's password before storing
|
||||
const encryptedPrivateKey = encryptPrivateKey(privateKey, password);
|
||||
|
||||
// Create the user
|
||||
const did = generateDID();
|
||||
// Create the user with did:key format (public key encoded in DID)
|
||||
const did = generateDID(publicKey);
|
||||
const passwordHash = await hashPassword(password);
|
||||
|
||||
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 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({
|
||||
did,
|
||||
@@ -173,6 +220,12 @@ export async function registerUser(
|
||||
avatarUrl,
|
||||
publicKey,
|
||||
privateKeyEncrypted: serializeEncryptedKey(encryptedPrivateKey),
|
||||
storageProvider,
|
||||
storageEndpoint: storageEndpoint || null,
|
||||
storageRegion,
|
||||
storageBucket,
|
||||
storageAccessKeyEncrypted: serializeEncryptedKey(encryptedAccessKey),
|
||||
storageSecretKeyEncrypted: serializeEncryptedKey(encryptedSecretKey),
|
||||
}).returning();
|
||||
|
||||
await upsertHandleEntries([{
|
||||
|
||||
@@ -14,6 +14,7 @@ import { eq } from 'drizzle-orm';
|
||||
import { canonicalize, importPublicKey, base64UrlToBase64 } from '@/lib/crypto/user-signing';
|
||||
// Note: user-signing helpers are isomorphic (work in Node via webcrypto polyfill/availability)
|
||||
import crypto from 'crypto';
|
||||
import { isRateLimited } from '@/lib/rate-limit';
|
||||
|
||||
// Use Node's webcrypto for server-side if not global
|
||||
const cryptoSubtle = globalThis.crypto?.subtle || require('crypto').webcrypto.subtle;
|
||||
@@ -77,7 +78,13 @@ export async function verifyUserAction(signedAction: SignedAction): Promise<{
|
||||
|
||||
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 diff = Math.abs(now - payload.ts);
|
||||
// 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' };
|
||||
}
|
||||
|
||||
// 2. FETCH USER & KEY
|
||||
// 3. FETCH USER & KEY
|
||||
const user = await db.query.users.findFirst({
|
||||
where: eq(users.did, payload.did),
|
||||
});
|
||||
@@ -100,18 +107,18 @@ export async function verifyUserAction(signedAction: SignedAction): Promise<{
|
||||
return { valid: false, error: 'Handle mismatch' };
|
||||
}
|
||||
|
||||
// 3. CRYPTOGRAPHIC VERIFICATION
|
||||
// 4. CRYPTOGRAPHIC VERIFICATION
|
||||
const isValid = await verifyActionSignature(signedAction, user.publicKey);
|
||||
|
||||
if (!isValid) {
|
||||
return { valid: false, error: 'INVALID_SIGNATURE' };
|
||||
}
|
||||
|
||||
// 4. ACTION ID HASH COMPUTATION
|
||||
// 5. ACTION ID HASH COMPUTATION
|
||||
const canonicalString = canonicalize(payload);
|
||||
const actionIdHash = crypto.createHash('sha256').update(canonicalString).digest('hex');
|
||||
|
||||
// 5. REPLAY PROTECTION (DB)
|
||||
// 6. REPLAY PROTECTION (DB)
|
||||
try {
|
||||
await db.insert(signedActionDedupe).values({
|
||||
actionId: actionIdHash,
|
||||
|
||||
@@ -11,6 +11,8 @@
|
||||
*/
|
||||
|
||||
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 { generateKeyPair } from '@/lib/crypto/keys';
|
||||
import {
|
||||
@@ -333,6 +335,15 @@ export async function createBot(ownerId: string, config: BotCreateInput): Promis
|
||||
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
|
||||
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 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
|
||||
const [botUser] = await db.insert(users).values({
|
||||
did: botDid,
|
||||
handle: config.handle.toLowerCase(),
|
||||
displayName: config.name,
|
||||
bio: config.bio || null,
|
||||
avatarUrl: config.avatarUrl || null,
|
||||
avatarUrl: botAvatarUrl,
|
||||
headerUrl: config.headerUrl || null,
|
||||
publicKey,
|
||||
privateKeyEncrypted: serializeEncryptedData(encryptedPrivateKey),
|
||||
|
||||
@@ -120,7 +120,7 @@ export async function deriveSessionKey(password: string): Promise<CryptoKey> {
|
||||
keyMaterial,
|
||||
{ name: 'AES-GCM', length: 256 },
|
||||
true, // extractable so we can store it
|
||||
['wrapKey', 'unwrapKey']
|
||||
['encrypt', 'decrypt']
|
||||
);
|
||||
}
|
||||
|
||||
@@ -131,7 +131,7 @@ async function generateSessionKey(): Promise<CryptoKey> {
|
||||
return crypto.subtle.generateKey(
|
||||
{ name: 'AES-GCM', length: 256 },
|
||||
true,
|
||||
['wrapKey', 'unwrapKey']
|
||||
['encrypt', 'decrypt']
|
||||
);
|
||||
}
|
||||
|
||||
@@ -147,7 +147,7 @@ async function importSessionKey(keyData: string): Promise<CryptoKey> {
|
||||
buffer,
|
||||
{ name: 'AES-GCM', length: 256 },
|
||||
false, // not extractable after import
|
||||
['wrapKey', 'unwrapKey']
|
||||
['encrypt', 'decrypt']
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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');
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user