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