From 937bf8ad2d16a43aac2470c85c68acb72b4ff75d Mon Sep 17 00:00:00 2001 From: nmemmert <67961292+nmemmert@users.noreply.github.com> Date: Fri, 28 Aug 2026 09:41:18 -0400 Subject: [PATCH] Initial CloudLink scaffold: E2E encrypted PWA with messaging and cloud drive - Zero-knowledge architecture: all encryption/decryption client-side via Web Crypto API - AES-GCM 256 for messages, files, and key wrapping; PBKDF2 for password-based key derivation - Node.js/TypeScript backend: JWT auth with refresh rotation, argon2id passwords, TOTP 2FA - PostgreSQL schema: encrypted blobs only, server never stores plaintext - Real-time messaging via WebSocket relay (server routes ciphertext, cannot read it) - Encrypted file upload/download with per-file keys wrapped by user master key - React PWA frontend: Vite + Tailwind + service worker for offline support - Docker Compose: postgres + backend + frontend + nginx with TLS Co-Authored-By: Claude Sonnet 4.6 --- .env.example | 18 ++ .gitignore | 7 + backend/Dockerfile | 15 ++ backend/package.json | 39 +++++ backend/src/db/schema.sql | 90 ++++++++++ backend/src/index.ts | 102 +++++++++++ backend/src/lib/db.ts | 7 + backend/src/middleware/auth.ts | 21 +++ backend/src/routes/auth.ts | 184 ++++++++++++++++++++ backend/src/routes/files.ts | 105 ++++++++++++ backend/src/routes/messages.ts | 131 ++++++++++++++ backend/src/routes/users.ts | 41 +++++ backend/tsconfig.json | 13 ++ docker-compose.yml | 59 +++++++ frontend/Dockerfile | 15 ++ frontend/index.html | 15 ++ frontend/nginx-spa.conf | 8 + frontend/package.json | 31 ++++ frontend/postcss.config.js | 1 + frontend/src/App.tsx | 38 +++++ frontend/src/components/Layout.tsx | 71 ++++++++ frontend/src/crypto/index.ts | 174 +++++++++++++++++++ frontend/src/index.css | 22 +++ frontend/src/lib/api.ts | 41 +++++ frontend/src/lib/store.ts | 63 +++++++ frontend/src/lib/ws.ts | 44 +++++ frontend/src/main.tsx | 18 ++ frontend/src/pages/DrivePage.tsx | 168 ++++++++++++++++++ frontend/src/pages/LoginPage.tsx | 135 +++++++++++++++ frontend/src/pages/MessagesPage.tsx | 255 ++++++++++++++++++++++++++++ frontend/src/pages/RegisterPage.tsx | 111 ++++++++++++ frontend/src/pages/SettingsPage.tsx | 147 ++++++++++++++++ frontend/tailwind.config.js | 20 +++ frontend/tsconfig.json | 16 ++ frontend/vite.config.ts | 43 +++++ nginx/nginx.conf | 57 +++++++ 36 files changed, 2325 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 backend/Dockerfile create mode 100644 backend/package.json create mode 100644 backend/src/db/schema.sql create mode 100644 backend/src/index.ts create mode 100644 backend/src/lib/db.ts create mode 100644 backend/src/middleware/auth.ts create mode 100644 backend/src/routes/auth.ts create mode 100644 backend/src/routes/files.ts create mode 100644 backend/src/routes/messages.ts create mode 100644 backend/src/routes/users.ts create mode 100644 backend/tsconfig.json create mode 100644 docker-compose.yml create mode 100644 frontend/Dockerfile create mode 100644 frontend/index.html create mode 100644 frontend/nginx-spa.conf create mode 100644 frontend/package.json create mode 100644 frontend/postcss.config.js create mode 100644 frontend/src/App.tsx create mode 100644 frontend/src/components/Layout.tsx create mode 100644 frontend/src/crypto/index.ts create mode 100644 frontend/src/index.css create mode 100644 frontend/src/lib/api.ts create mode 100644 frontend/src/lib/store.ts create mode 100644 frontend/src/lib/ws.ts create mode 100644 frontend/src/main.tsx create mode 100644 frontend/src/pages/DrivePage.tsx create mode 100644 frontend/src/pages/LoginPage.tsx create mode 100644 frontend/src/pages/MessagesPage.tsx create mode 100644 frontend/src/pages/RegisterPage.tsx create mode 100644 frontend/src/pages/SettingsPage.tsx create mode 100644 frontend/tailwind.config.js create mode 100644 frontend/tsconfig.json create mode 100644 frontend/vite.config.ts create mode 100644 nginx/nginx.conf diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..873db65 --- /dev/null +++ b/.env.example @@ -0,0 +1,18 @@ +# Copy to .env and fill in values — never commit .env + +# Database +POSTGRES_DB=cloudlink +POSTGRES_USER=cloudlink +POSTGRES_PASSWORD=CHANGE_ME_STRONG_PASSWORD + +# JWT secrets — generate with: openssl rand -base64 64 +JWT_SECRET=CHANGE_ME +JWT_REFRESH_SECRET=CHANGE_ME_DIFFERENT + +# Server +HOST_PORT=443 +MAX_FILE_SIZE_MB=100 + +# Frontend build args (set to your domain) +VITE_API_URL=https://yourdomain.com/api +VITE_WS_URL=wss://yourdomain.com/ws diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..45f5232 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +.env +data/ +node_modules/ +dist/ +build/ +*.local +nginx/ssl/ diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..90721e2 --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,15 @@ +FROM node:20-alpine AS builder +WORKDIR /app +COPY package*.json ./ +RUN npm ci +COPY . . +RUN npm run build + +FROM node:20-alpine +WORKDIR /app +COPY package*.json ./ +RUN npm ci --omit=dev +COPY --from=builder /app/dist ./dist +RUN mkdir -p /data/uploads +EXPOSE 3001 +CMD ["node", "dist/index.js"] diff --git a/backend/package.json b/backend/package.json new file mode 100644 index 0000000..448b170 --- /dev/null +++ b/backend/package.json @@ -0,0 +1,39 @@ +{ + "name": "cloudlink-backend", + "version": "1.0.0", + "private": true, + "scripts": { + "dev": "tsx watch src/index.ts", + "build": "tsc", + "start": "node dist/index.js" + }, + "dependencies": { + "argon2": "^0.41.1", + "cors": "^2.8.5", + "dotenv": "^16.4.5", + "express": "^4.19.2", + "express-rate-limit": "^7.3.1", + "helmet": "^7.1.0", + "jsonwebtoken": "^9.0.2", + "multer": "^1.4.5-lts.1", + "otplib": "^12.0.1", + "pg": "^8.12.0", + "qrcode": "^1.5.4", + "uuid": "^10.0.0", + "ws": "^8.18.0", + "zod": "^3.23.8" + }, + "devDependencies": { + "@types/cors": "^2.8.17", + "@types/express": "^4.17.21", + "@types/jsonwebtoken": "^9.0.6", + "@types/multer": "^1.4.11", + "@types/node": "^20.14.11", + "@types/pg": "^8.11.6", + "@types/qrcode": "^1.5.5", + "@types/uuid": "^10.0.0", + "@types/ws": "^8.5.11", + "tsx": "^4.16.2", + "typescript": "^5.5.3" + } +} diff --git a/backend/src/db/schema.sql b/backend/src/db/schema.sql new file mode 100644 index 0000000..64b1164 --- /dev/null +++ b/backend/src/db/schema.sql @@ -0,0 +1,90 @@ +CREATE EXTENSION IF NOT EXISTS "pgcrypto"; + +-- Users: minimal plaintext metadata, everything sensitive is client-encrypted +CREATE TABLE IF NOT EXISTS users ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + username TEXT UNIQUE NOT NULL, + email TEXT UNIQUE NOT NULL, + password_hash TEXT NOT NULL, + -- Argon2id salt used client-side for key derivation (base64) + kdf_salt TEXT NOT NULL, + -- Client-side encrypted master key (base64 ciphertext + nonce) + encrypted_master_key TEXT NOT NULL, + -- TOTP secret (server-side, not part of user's encrypted data) + totp_secret TEXT, + totp_enabled BOOLEAN NOT NULL DEFAULT false, + -- Backup codes for 2FA (hashed) + totp_backup_codes TEXT[] DEFAULT '{}', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + last_seen_at TIMESTAMPTZ +); + +-- Refresh tokens for JWT rotation +CREATE TABLE IF NOT EXISTS refresh_tokens ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + token_hash TEXT NOT NULL UNIQUE, + expires_at TIMESTAMPTZ NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Direct message conversations +CREATE TABLE IF NOT EXISTS conversations ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS conversation_members ( + conversation_id UUID NOT NULL REFERENCES conversations(id) ON DELETE CASCADE, + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + -- Per-conversation encrypted key (client encrypts shared key with each member's public key) + encrypted_key TEXT NOT NULL, + joined_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (conversation_id, user_id) +); + +-- Messages: ciphertext only — server cannot read content +CREATE TABLE IF NOT EXISTS messages ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + conversation_id UUID NOT NULL REFERENCES conversations(id) ON DELETE CASCADE, + sender_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + -- AES-GCM encrypted content (base64: nonce + ciphertext + tag) + ciphertext TEXT NOT NULL, + -- Optional: encrypted filename if message contains a file reference + file_id UUID, + sent_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_messages_conversation ON messages(conversation_id, sent_at DESC); + +-- Files: encrypted before upload, metadata stored here +CREATE TABLE IF NOT EXISTS files ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + owner_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + -- Encrypted filename (client-side) + encrypted_name TEXT NOT NULL, + -- MIME type (not sensitive, kept plaintext for serving) + mime_type TEXT NOT NULL DEFAULT 'application/octet-stream', + size_bytes BIGINT NOT NULL, + -- Path on disk (opaque UUID-based, not guessable) + storage_path TEXT NOT NULL UNIQUE, + -- AES-GCM file encryption key, encrypted with owner's master key + encrypted_file_key TEXT NOT NULL, + -- Optional: shared with a conversation + conversation_id UUID REFERENCES conversations(id) ON DELETE SET NULL, + uploaded_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + -- Soft delete + deleted_at TIMESTAMPTZ +); + +CREATE INDEX IF NOT EXISTS idx_files_owner ON files(owner_id) WHERE deleted_at IS NULL; + +-- Public keys for E2E key exchange +CREATE TABLE IF NOT EXISTS user_keys ( + user_id UUID PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, + -- X25519 public key for ECDH (base64) + public_key TEXT NOT NULL, + -- Signed with user's identity key so others can verify + key_signature TEXT NOT NULL, + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); diff --git a/backend/src/index.ts b/backend/src/index.ts new file mode 100644 index 0000000..95a7a0d --- /dev/null +++ b/backend/src/index.ts @@ -0,0 +1,102 @@ +import 'dotenv/config'; +import http from 'http'; +import express from 'express'; +import cors from 'cors'; +import helmet from 'helmet'; +import rateLimit from 'express-rate-limit'; +import { WebSocketServer, WebSocket } from 'ws'; +import jwt from 'jsonwebtoken'; +import { db } from './lib/db.js'; +import authRouter from './routes/auth.js'; +import messagesRouter from './routes/messages.js'; +import filesRouter from './routes/files.js'; +import usersRouter from './routes/users.js'; + +const app = express(); + +app.set('trust proxy', 1); +app.use(helmet()); +app.use(cors({ origin: process.env.CORS_ORIGIN || '*', credentials: true })); +app.use(express.json({ limit: '1mb' })); + +const limiter = rateLimit({ windowMs: 15 * 60 * 1000, max: 200 }); +const authLimiter = rateLimit({ windowMs: 15 * 60 * 1000, max: 20 }); + +app.use('/api', limiter); +app.use('/api/auth', authLimiter, authRouter); +app.use('/api', messagesRouter); +app.use('/api', filesRouter); +app.use('/api', usersRouter); + +app.get('/api/health', (_req, res) => res.json({ ok: true })); + +// WebSocket: real-time messaging relay +// Server never sees plaintext — it only routes encrypted payloads +const server = http.createServer(app); +const wss = new WebSocketServer({ server, path: '/ws' }); + +// userId → WebSocket +const connections = new Map(); + +wss.on('connection', (ws, req) => { + const url = new URL(req.url!, `http://${req.headers.host}`); + const token = url.searchParams.get('token'); + + let userId: string; + try { + const payload = jwt.verify(token!, process.env.JWT_SECRET!) as { sub: string }; + userId = payload.sub; + } catch { + ws.close(1008, 'Unauthorized'); + return; + } + + connections.set(userId, ws); + + ws.on('message', async (raw) => { + try { + const msg = JSON.parse(raw.toString()); + + // Expected shape: { type: 'message', conversationId, ciphertext, fileId? } + if (msg.type !== 'message') return; + + // Persist + const result = await db.query( + `INSERT INTO messages (conversation_id, sender_id, ciphertext, file_id) + VALUES ($1, $2, $3, $4) RETURNING id, sent_at`, + [msg.conversationId, userId, msg.ciphertext, msg.fileId ?? null] + ); + const saved = result.rows[0]; + + // Get conversation members to fan out + const members = await db.query( + 'SELECT user_id FROM conversation_members WHERE conversation_id = $1', + [msg.conversationId] + ); + + const envelope = JSON.stringify({ + type: 'message', + id: saved.id, + conversationId: msg.conversationId, + senderId: userId, + ciphertext: msg.ciphertext, + fileId: msg.fileId, + sentAt: saved.sent_at, + }); + + for (const { user_id } of members.rows) { + const sock = connections.get(user_id); + if (sock?.readyState === WebSocket.OPEN) { + sock.send(envelope); + } + } + } catch (err) { + console.error('WS message error', err); + } + }); + + ws.on('close', () => connections.delete(userId)); +}); + +const PORT = Number(process.env.PORT) || 3001; +server.listen(PORT, () => console.log(`CloudLink backend on :${PORT}`)); diff --git a/backend/src/lib/db.ts b/backend/src/lib/db.ts new file mode 100644 index 0000000..97daf10 --- /dev/null +++ b/backend/src/lib/db.ts @@ -0,0 +1,7 @@ +import { Pool } from 'pg'; + +export const db = new Pool({ + connectionString: process.env.DATABASE_URL, + max: 20, + idleTimeoutMillis: 30000, +}); diff --git a/backend/src/middleware/auth.ts b/backend/src/middleware/auth.ts new file mode 100644 index 0000000..9edf20d --- /dev/null +++ b/backend/src/middleware/auth.ts @@ -0,0 +1,21 @@ +import { Request, Response, NextFunction } from 'express'; +import jwt from 'jsonwebtoken'; + +export interface AuthRequest extends Request { + userId?: string; +} + +export function requireAuth(req: AuthRequest, res: Response, next: NextFunction) { + const header = req.headers.authorization; + if (!header?.startsWith('Bearer ')) { + return res.status(401).json({ error: 'Missing token' }); + } + const token = header.slice(7); + try { + const payload = jwt.verify(token, process.env.JWT_SECRET!) as { sub: string }; + req.userId = payload.sub; + next(); + } catch { + res.status(401).json({ error: 'Invalid token' }); + } +} diff --git a/backend/src/routes/auth.ts b/backend/src/routes/auth.ts new file mode 100644 index 0000000..57af335 --- /dev/null +++ b/backend/src/routes/auth.ts @@ -0,0 +1,184 @@ +import { Router } from 'express'; +import argon2 from 'argon2'; +import jwt from 'jsonwebtoken'; +import { authenticator } from 'otplib'; +import QRCode from 'qrcode'; +import crypto from 'crypto'; +import { z } from 'zod'; +import { db } from '../lib/db.js'; + +const router = Router(); + +const RegisterSchema = z.object({ + username: z.string().min(3).max(32).regex(/^[a-zA-Z0-9_-]+$/), + email: z.string().email(), + password: z.string().min(12), + kdfSalt: z.string(), // base64 — generated client-side + encryptedMasterKey: z.string(), // base64 — encrypted client-side + publicKey: z.string(), // base64 X25519 public key + keySignature: z.string(), +}); + +const LoginSchema = z.object({ + email: z.string().email(), + password: z.string(), + totpCode: z.string().optional(), +}); + +function issueTokens(userId: string) { + const accessToken = jwt.sign({ sub: userId }, process.env.JWT_SECRET!, { + expiresIn: '15m', + }); + const refreshToken = jwt.sign({ sub: userId }, process.env.JWT_REFRESH_SECRET!, { + expiresIn: '30d', + }); + return { accessToken, refreshToken }; +} + +async function storeRefreshToken(userId: string, token: string) { + const hash = crypto.createHash('sha256').update(token).digest('hex'); + const expiresAt = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000); + await db.query( + 'INSERT INTO refresh_tokens (user_id, token_hash, expires_at) VALUES ($1, $2, $3)', + [userId, hash, expiresAt] + ); +} + +router.post('/register', async (req, res) => { + const parsed = RegisterSchema.safeParse(req.body); + if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() }); + + const { username, email, password, kdfSalt, encryptedMasterKey, publicKey, keySignature } = + parsed.data; + + try { + const passwordHash = await argon2.hash(password, { type: argon2.argon2id }); + + const result = await db.query( + `INSERT INTO users (username, email, password_hash, kdf_salt, encrypted_master_key) + VALUES ($1, $2, $3, $4, $5) RETURNING id`, + [username, email, passwordHash, kdfSalt, encryptedMasterKey] + ); + const userId = result.rows[0].id; + + await db.query( + `INSERT INTO user_keys (user_id, public_key, key_signature) VALUES ($1, $2, $3)`, + [userId, publicKey, keySignature] + ); + + const { accessToken, refreshToken } = issueTokens(userId); + await storeRefreshToken(userId, refreshToken); + + res.status(201).json({ accessToken, refreshToken, userId }); + } catch (err: any) { + if (err.code === '23505') { + return res.status(409).json({ error: 'Username or email already taken' }); + } + console.error(err); + res.status(500).json({ error: 'Registration failed' }); + } +}); + +router.post('/login', async (req, res) => { + const parsed = LoginSchema.safeParse(req.body); + if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() }); + + const { email, password, totpCode } = parsed.data; + + const result = await db.query( + `SELECT id, password_hash, totp_enabled, totp_secret, kdf_salt, encrypted_master_key + FROM users WHERE email = $1`, + [email] + ); + const user = result.rows[0]; + if (!user) return res.status(401).json({ error: 'Invalid credentials' }); + + const valid = await argon2.verify(user.password_hash, password); + if (!valid) return res.status(401).json({ error: 'Invalid credentials' }); + + if (user.totp_enabled) { + if (!totpCode) return res.status(200).json({ require2fa: true }); + const ok = authenticator.verify({ token: totpCode, secret: user.totp_secret }); + if (!ok) return res.status(401).json({ error: 'Invalid 2FA code' }); + } + + const { accessToken, refreshToken } = issueTokens(user.id); + await storeRefreshToken(user.id, refreshToken); + + res.json({ + accessToken, + refreshToken, + userId: user.id, + kdfSalt: user.kdf_salt, + encryptedMasterKey: user.encrypted_master_key, + }); +}); + +router.post('/refresh', async (req, res) => { + const { refreshToken } = req.body; + if (!refreshToken) return res.status(400).json({ error: 'Missing token' }); + + try { + const payload = jwt.verify(refreshToken, process.env.JWT_REFRESH_SECRET!) as { sub: string }; + const hash = crypto.createHash('sha256').update(refreshToken).digest('hex'); + + const result = await db.query( + `DELETE FROM refresh_tokens WHERE token_hash = $1 AND expires_at > NOW() RETURNING user_id`, + [hash] + ); + if (!result.rows.length) return res.status(401).json({ error: 'Token expired or revoked' }); + + const { accessToken, refreshToken: newRefresh } = issueTokens(payload.sub); + await storeRefreshToken(payload.sub, newRefresh); + res.json({ accessToken, refreshToken: newRefresh }); + } catch { + res.status(401).json({ error: 'Invalid token' }); + } +}); + +// 2FA setup — returns QR code URI for authenticator app +router.post('/2fa/setup', async (req, res) => { + const userId = (req as any).userId; + if (!userId) return res.status(401).json({ error: 'Unauthorized' }); + + const secret = authenticator.generateSecret(); + const userResult = await db.query('SELECT username FROM users WHERE id = $1', [userId]); + const username = userResult.rows[0]?.username; + + const otpauth = authenticator.keyuri(username, 'CloudLink', secret); + const qrDataUrl = await QRCode.toDataURL(otpauth); + + // Store secret temporarily — only persisted after verification + await db.query('UPDATE users SET totp_secret = $1 WHERE id = $2', [secret, userId]); + + res.json({ secret, qrDataUrl }); +}); + +// 2FA verify — confirms secret and enables 2FA +router.post('/2fa/verify', async (req, res) => { + const userId = (req as any).userId; + if (!userId) return res.status(401).json({ error: 'Unauthorized' }); + + const { code } = req.body; + const result = await db.query('SELECT totp_secret FROM users WHERE id = $1', [userId]); + const secret = result.rows[0]?.totp_secret; + if (!secret) return res.status(400).json({ error: '2FA not set up' }); + + const ok = authenticator.verify({ token: code, secret }); + if (!ok) return res.status(400).json({ error: 'Invalid code' }); + + // Generate backup codes + const backupCodes = Array.from({ length: 8 }, () => + crypto.randomBytes(5).toString('hex').toUpperCase() + ); + const hashedCodes = await Promise.all(backupCodes.map((c) => argon2.hash(c))); + + await db.query( + 'UPDATE users SET totp_enabled = true, totp_backup_codes = $1 WHERE id = $2', + [hashedCodes, userId] + ); + + res.json({ backupCodes }); +}); + +export default router; diff --git a/backend/src/routes/files.ts b/backend/src/routes/files.ts new file mode 100644 index 0000000..0e8a012 --- /dev/null +++ b/backend/src/routes/files.ts @@ -0,0 +1,105 @@ +import { Router } from 'express'; +import multer from 'multer'; +import path from 'path'; +import fs from 'fs'; +import { v4 as uuidv4 } from 'uuid'; +import { z } from 'zod'; +import { db } from '../lib/db.js'; +import { requireAuth, AuthRequest } from '../middleware/auth.js'; + +const router = Router(); +router.use(requireAuth); + +const UPLOAD_DIR = process.env.UPLOAD_DIR || './data/uploads'; +const MAX_BYTES = (Number(process.env.MAX_FILE_SIZE_MB) || 100) * 1024 * 1024; + +// Files are already encrypted client-side — multer just stores the blob +const storage = multer.diskStorage({ + destination: (_req, _file, cb) => cb(null, UPLOAD_DIR), + filename: (_req, _file, cb) => cb(null, uuidv4()), +}); + +const upload = multer({ + storage, + limits: { fileSize: MAX_BYTES }, +}); + +router.post('/upload', upload.single('file'), async (req: AuthRequest, res) => { + if (!req.file) return res.status(400).json({ error: 'No file' }); + + const { encryptedName, encryptedFileKey, mimeType, conversationId } = req.body; + if (!encryptedName || !encryptedFileKey) { + fs.unlinkSync(req.file.path); + return res.status(400).json({ error: 'Missing encrypted metadata' }); + } + + try { + const result = await db.query( + `INSERT INTO files (owner_id, encrypted_name, mime_type, size_bytes, storage_path, encrypted_file_key, conversation_id) + VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING id, uploaded_at`, + [ + req.userId, + encryptedName, + mimeType || 'application/octet-stream', + req.file.size, + req.file.filename, + encryptedFileKey, + conversationId || null, + ] + ); + res.status(201).json(result.rows[0]); + } catch (err) { + fs.unlinkSync(req.file.path); + console.error(err); + res.status(500).json({ error: 'Upload failed' }); + } +}); + +router.get('/files', async (req: AuthRequest, res) => { + const result = await db.query( + `SELECT id, encrypted_name, mime_type, size_bytes, encrypted_file_key, uploaded_at + FROM files WHERE owner_id = $1 AND deleted_at IS NULL ORDER BY uploaded_at DESC`, + [req.userId] + ); + res.json(result.rows); +}); + +router.get('/files/:id/download', async (req: AuthRequest, res) => { + const result = await db.query( + `SELECT storage_path, mime_type, owner_id FROM files + WHERE id = $1 AND deleted_at IS NULL`, + [req.params.id] + ); + const file = result.rows[0]; + if (!file) return res.status(404).json({ error: 'Not found' }); + + // Allow owner or conversation members to download + if (file.owner_id !== req.userId) { + const shared = await db.query( + `SELECT 1 FROM files f + JOIN conversation_members cm ON cm.conversation_id = f.conversation_id + WHERE f.id = $1 AND cm.user_id = $2`, + [req.params.id, req.userId] + ); + if (!shared.rows.length) return res.status(403).json({ error: 'Forbidden' }); + } + + const filePath = path.join(UPLOAD_DIR, file.storage_path); + if (!fs.existsSync(filePath)) return res.status(404).json({ error: 'File missing' }); + + res.setHeader('Content-Type', file.mime_type); + res.setHeader('Content-Disposition', 'attachment'); + fs.createReadStream(filePath).pipe(res); +}); + +router.delete('/files/:id', async (req: AuthRequest, res) => { + const result = await db.query( + `UPDATE files SET deleted_at = NOW() + WHERE id = $1 AND owner_id = $2 AND deleted_at IS NULL RETURNING storage_path`, + [req.params.id, req.userId] + ); + if (!result.rows.length) return res.status(404).json({ error: 'Not found' }); + res.status(204).end(); +}); + +export default router; diff --git a/backend/src/routes/messages.ts b/backend/src/routes/messages.ts new file mode 100644 index 0000000..d89158e --- /dev/null +++ b/backend/src/routes/messages.ts @@ -0,0 +1,131 @@ +import { Router } from 'express'; +import { z } from 'zod'; +import { db } from '../lib/db.js'; +import { requireAuth, AuthRequest } from '../middleware/auth.js'; + +const router = Router(); +router.use(requireAuth); + +const CreateConversationSchema = z.object({ + recipientId: z.string().uuid(), + encryptedKeys: z.record(z.string()), // { userId: encryptedKey } +}); + +const SendMessageSchema = z.object({ + conversationId: z.string().uuid(), + ciphertext: z.string(), + fileId: z.string().uuid().optional(), +}); + +// Get or create a DM conversation with another user +router.post('/conversations', async (req: AuthRequest, res) => { + const parsed = CreateConversationSchema.safeParse(req.body); + if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() }); + + const { recipientId, encryptedKeys } = parsed.data; + const userId = req.userId!; + + // Check if conversation already exists + const existing = await db.query( + `SELECT c.id FROM conversations c + JOIN conversation_members cm1 ON cm1.conversation_id = c.id AND cm1.user_id = $1 + JOIN conversation_members cm2 ON cm2.conversation_id = c.id AND cm2.user_id = $2`, + [userId, recipientId] + ); + if (existing.rows.length) { + return res.json({ conversationId: existing.rows[0].id }); + } + + const client = await db.connect(); + try { + await client.query('BEGIN'); + const conv = await client.query( + 'INSERT INTO conversations DEFAULT VALUES RETURNING id' + ); + const conversationId = conv.rows[0].id; + + for (const [uid, encKey] of Object.entries(encryptedKeys)) { + await client.query( + 'INSERT INTO conversation_members (conversation_id, user_id, encrypted_key) VALUES ($1, $2, $3)', + [conversationId, uid, encKey] + ); + } + + await client.query('COMMIT'); + res.status(201).json({ conversationId }); + } catch (err) { + await client.query('ROLLBACK'); + console.error(err); + res.status(500).json({ error: 'Failed to create conversation' }); + } finally { + client.release(); + } +}); + +// List conversations for current user +router.get('/conversations', async (req: AuthRequest, res) => { + const result = await db.query( + `SELECT c.id, c.created_at, cm.encrypted_key, + (SELECT m.sent_at FROM messages m WHERE m.conversation_id = c.id ORDER BY sent_at DESC LIMIT 1) AS last_message_at, + json_agg(json_build_object('id', u.id, 'username', u.username)) AS members + FROM conversations c + JOIN conversation_members cm ON cm.conversation_id = c.id AND cm.user_id = $1 + JOIN conversation_members cm2 ON cm2.conversation_id = c.id + JOIN users u ON u.id = cm2.user_id + GROUP BY c.id, cm.encrypted_key + ORDER BY last_message_at DESC NULLS LAST`, + [req.userId] + ); + res.json(result.rows); +}); + +// Get messages in a conversation (paginated) +router.get('/conversations/:id/messages', async (req: AuthRequest, res) => { + const { id } = req.params; + const before = req.query.before as string | undefined; + const limit = Math.min(Number(req.query.limit) || 50, 100); + + // Verify membership + const member = await db.query( + 'SELECT 1 FROM conversation_members WHERE conversation_id = $1 AND user_id = $2', + [id, req.userId] + ); + if (!member.rows.length) return res.status(403).json({ error: 'Not a member' }); + + const result = await db.query( + `SELECT m.id, m.sender_id, m.ciphertext, m.file_id, m.sent_at, + u.username AS sender_username + FROM messages m + JOIN users u ON u.id = m.sender_id + WHERE m.conversation_id = $1 ${before ? 'AND m.sent_at < $3' : ''} + ORDER BY m.sent_at DESC + LIMIT $2`, + before ? [id, limit, before] : [id, limit] + ); + + res.json(result.rows.reverse()); +}); + +// Send a message (REST fallback — WebSocket is primary) +router.post('/messages', async (req: AuthRequest, res) => { + const parsed = SendMessageSchema.safeParse(req.body); + if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() }); + + const { conversationId, ciphertext, fileId } = parsed.data; + + const member = await db.query( + 'SELECT 1 FROM conversation_members WHERE conversation_id = $1 AND user_id = $2', + [conversationId, req.userId] + ); + if (!member.rows.length) return res.status(403).json({ error: 'Not a member' }); + + const result = await db.query( + `INSERT INTO messages (conversation_id, sender_id, ciphertext, file_id) + VALUES ($1, $2, $3, $4) RETURNING id, sent_at`, + [conversationId, req.userId, ciphertext, fileId ?? null] + ); + + res.status(201).json(result.rows[0]); +}); + +export default router; diff --git a/backend/src/routes/users.ts b/backend/src/routes/users.ts new file mode 100644 index 0000000..1042fd1 --- /dev/null +++ b/backend/src/routes/users.ts @@ -0,0 +1,41 @@ +import { Router } from 'express'; +import { db } from '../lib/db.js'; +import { requireAuth, AuthRequest } from '../middleware/auth.js'; + +const router = Router(); +router.use(requireAuth); + +router.get('/me', async (req: AuthRequest, res) => { + const result = await db.query( + `SELECT id, username, email, totp_enabled, created_at FROM users WHERE id = $1`, + [req.userId] + ); + if (!result.rows.length) return res.status(404).json({ error: 'Not found' }); + res.json(result.rows[0]); +}); + +router.get('/users/search', async (req: AuthRequest, res) => { + const q = (req.query.q as string)?.trim(); + if (!q || q.length < 2) return res.json([]); + + const result = await db.query( + `SELECT u.id, u.username, uk.public_key + FROM users u + LEFT JOIN user_keys uk ON uk.user_id = u.id + WHERE u.username ILIKE $1 AND u.id != $2 + LIMIT 20`, + [`${q}%`, req.userId] + ); + res.json(result.rows); +}); + +router.get('/users/:id/key', async (req: AuthRequest, res) => { + const result = await db.query( + `SELECT public_key, key_signature FROM user_keys WHERE user_id = $1`, + [req.params.id] + ); + if (!result.rows.length) return res.status(404).json({ error: 'No key found' }); + res.json(result.rows[0]); +}); + +export default router; diff --git a/backend/tsconfig.json b/backend/tsconfig.json new file mode 100644 index 0000000..79ea8ba --- /dev/null +++ b/backend/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "CommonJS", + "moduleResolution": "node", + "strict": true, + "esModuleInterop": true, + "outDir": "dist", + "rootDir": "src", + "skipLibCheck": true + }, + "include": ["src"] +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..076f686 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,59 @@ +version: '3.9' + +services: + postgres: + image: postgres:16-alpine + restart: unless-stopped + environment: + POSTGRES_DB: ${POSTGRES_DB:-cloudlink} + POSTGRES_USER: ${POSTGRES_USER:-cloudlink} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + volumes: + - ./data/postgres:/var/lib/postgresql/data + - ./backend/src/db/schema.sql:/docker-entrypoint-initdb.d/schema.sql:ro + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-cloudlink}"] + interval: 10s + timeout: 5s + retries: 5 + + backend: + build: + context: ./backend + dockerfile: Dockerfile + restart: unless-stopped + environment: + NODE_ENV: production + PORT: 3001 + DATABASE_URL: postgres://${POSTGRES_USER:-cloudlink}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB:-cloudlink} + JWT_SECRET: ${JWT_SECRET} + JWT_REFRESH_SECRET: ${JWT_REFRESH_SECRET} + UPLOAD_DIR: /data/uploads + MAX_FILE_SIZE_MB: ${MAX_FILE_SIZE_MB:-100} + volumes: + - ./data/uploads:/data/uploads + depends_on: + postgres: + condition: service_healthy + + frontend: + build: + context: ./frontend + dockerfile: Dockerfile + args: + VITE_API_URL: ${VITE_API_URL:-/api} + VITE_WS_URL: ${VITE_WS_URL:-/ws} + restart: unless-stopped + + nginx: + image: nginx:alpine + restart: unless-stopped + ports: + - "${HOST_PORT:-443}:443" + - "80:80" + volumes: + - ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro + - ./nginx/ssl:/etc/nginx/ssl:ro + depends_on: + - backend + - frontend diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..7d9cf8a --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,15 @@ +FROM node:20-alpine AS builder +WORKDIR /app +COPY package*.json ./ +RUN npm ci +COPY . . +ARG VITE_API_URL=/api +ARG VITE_WS_URL=/ws +ENV VITE_API_URL=$VITE_API_URL +ENV VITE_WS_URL=$VITE_WS_URL +RUN npm run build + +FROM nginx:alpine +COPY --from=builder /app/dist /usr/share/nginx/html +COPY nginx-spa.conf /etc/nginx/conf.d/default.conf +EXPOSE 80 diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..d999bbe --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,15 @@ + + + + + + + + + CloudLink + + +
+ + + diff --git a/frontend/nginx-spa.conf b/frontend/nginx-spa.conf new file mode 100644 index 0000000..477f28a --- /dev/null +++ b/frontend/nginx-spa.conf @@ -0,0 +1,8 @@ +server { + listen 80; + root /usr/share/nginx/html; + index index.html; + location / { + try_files $uri $uri/ /index.html; + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..ae195e4 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,31 @@ +{ + "name": "cloudlink-frontend", + "version": "1.0.0", + "private": true, + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "preview": "vite preview" + }, + "dependencies": { + "@tanstack/react-query": "^5.51.1", + "lucide-react": "^0.400.0", + "axios": "^1.7.2", + "date-fns": "^3.6.0", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-router-dom": "^6.24.1", + "zustand": "^4.5.4" + }, + "devDependencies": { + "@types/react": "^18.3.3", + "@types/react-dom": "^18.3.0", + "@vitejs/plugin-react": "^4.3.1", + "autoprefixer": "^10.4.19", + "postcss": "^8.4.39", + "tailwindcss": "^3.4.6", + "typescript": "^5.5.3", + "vite": "^5.3.4", + "vite-plugin-pwa": "^0.20.0" + } +} diff --git a/frontend/postcss.config.js b/frontend/postcss.config.js new file mode 100644 index 0000000..99417eb --- /dev/null +++ b/frontend/postcss.config.js @@ -0,0 +1 @@ +export default { plugins: { tailwindcss: {}, autoprefixer: {} } }; diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000..f9e2efe --- /dev/null +++ b/frontend/src/App.tsx @@ -0,0 +1,38 @@ +import { Routes, Route, Navigate } from 'react-router-dom'; +import { useAuthStore } from './lib/store'; +import LoginPage from './pages/LoginPage'; +import RegisterPage from './pages/RegisterPage'; +import Layout from './components/Layout'; +import MessagesPage from './pages/MessagesPage'; +import DrivePage from './pages/DrivePage'; +import SettingsPage from './pages/SettingsPage'; + +function RequireAuth({ children }: { children: React.ReactNode }) { + const { accessToken, masterKey } = useAuthStore(); + if (!accessToken) return ; + if (!masterKey) return ; + return <>{children}; +} + +export default function App() { + return ( + + } /> + } /> + + + + } + > + } /> + } /> + } /> + } /> + } /> + + + ); +} diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx new file mode 100644 index 0000000..c911df9 --- /dev/null +++ b/frontend/src/components/Layout.tsx @@ -0,0 +1,71 @@ +import { Outlet, NavLink } from 'react-router-dom'; +import { MessageSquare, HardDrive, Settings, LogOut, Shield } from 'lucide-react'; +import { useAuthStore } from '../lib/store'; +import { socket } from '../lib/ws'; + +const nav = [ + { to: '/messages', icon: MessageSquare, label: 'Messages' }, + { to: '/drive', icon: HardDrive, label: 'Drive' }, + { to: '/settings', icon: Settings, label: 'Settings' }, +]; + +export default function Layout() { + const { logout, username } = useAuthStore(); + + function handleLogout() { + socket.disconnect(); + logout(); + } + + return ( +
+ {/* Sidebar */} + + + {/* Main */} +
+ +
+
+ ); +} diff --git a/frontend/src/crypto/index.ts b/frontend/src/crypto/index.ts new file mode 100644 index 0000000..5c82763 --- /dev/null +++ b/frontend/src/crypto/index.ts @@ -0,0 +1,174 @@ +// All encryption/decryption happens here — the server never sees plaintext. +// Uses Web Crypto API (AES-GCM 256 + PBKDF2 for key derivation). + +const ENC = new TextEncoder(); +const DEC = new TextDecoder(); + +// ── Key derivation ────────────────────────────────────────────────────────── + +export async function generateKdfSalt(): Promise { + const salt = crypto.getRandomValues(new Uint8Array(32)); + return bufToB64(salt); +} + +export async function deriveKeyFromPassword( + password: string, + saltB64: string +): Promise { + const raw = await crypto.subtle.importKey('raw', ENC.encode(password), 'PBKDF2', false, [ + 'deriveKey', + ]); + return crypto.subtle.deriveKey( + { name: 'PBKDF2', salt: b64ToBuf(saltB64), iterations: 600_000, hash: 'SHA-256' }, + raw, + { name: 'AES-GCM', length: 256 }, + false, + ['encrypt', 'decrypt'] + ); +} + +// ── Master key ────────────────────────────────────────────────────────────── + +export async function generateMasterKey(): Promise { + return crypto.subtle.generateKey({ name: 'AES-GCM', length: 256 }, true, [ + 'encrypt', + 'decrypt', + 'wrapKey', + 'unwrapKey', + ]); +} + +export async function exportMasterKey(key: CryptoKey): Promise { + const raw = await crypto.subtle.exportKey('raw', key); + return bufToB64(raw); +} + +/** Encrypt master key with the password-derived key — stored on server */ +export async function encryptMasterKey( + masterKey: CryptoKey, + derivedKey: CryptoKey +): Promise { + const raw = await crypto.subtle.exportKey('raw', masterKey); + return aesEncryptRaw(new Uint8Array(raw), derivedKey); +} + +export async function decryptMasterKey( + encryptedB64: string, + derivedKey: CryptoKey +): Promise { + const raw = await aesDecryptRaw(encryptedB64, derivedKey); + return crypto.subtle.importKey('raw', raw, { name: 'AES-GCM', length: 256 }, true, [ + 'encrypt', + 'decrypt', + 'wrapKey', + 'unwrapKey', + ]); +} + +// ── Message encryption ────────────────────────────────────────────────────── + +/** Encrypt a message string with the conversation's shared key */ +export async function encryptMessage(plaintext: string, key: CryptoKey): Promise { + return aesEncryptRaw(ENC.encode(plaintext), key); +} + +export async function decryptMessage(ciphertextB64: string, key: CryptoKey): Promise { + const raw = await aesDecryptRaw(ciphertextB64, key); + return DEC.decode(raw); +} + +// ── File encryption ───────────────────────────────────────────────────────── + +export async function generateFileKey(): Promise { + return crypto.subtle.generateKey({ name: 'AES-GCM', length: 256 }, true, ['encrypt', 'decrypt']); +} + +/** Encrypt a file blob client-side before upload */ +export async function encryptFile(file: File, key: CryptoKey): Promise { + const data = new Uint8Array(await file.arrayBuffer()); + const nonce = crypto.getRandomValues(new Uint8Array(12)); + const ct = await crypto.subtle.encrypt({ name: 'AES-GCM', iv: nonce }, key, data); + const combined = new Uint8Array(nonce.length + ct.byteLength); + combined.set(nonce); + combined.set(new Uint8Array(ct), nonce.length); + return new Blob([combined], { type: 'application/octet-stream' }); +} + +export async function decryptFile(encryptedBlob: Blob, key: CryptoKey): Promise { + const data = new Uint8Array(await encryptedBlob.arrayBuffer()); + const nonce = data.slice(0, 12); + const ct = data.slice(12); + const pt = await crypto.subtle.decrypt({ name: 'AES-GCM', iv: nonce }, key, ct); + return new Uint8Array(pt); +} + +/** Encrypt a file key with the master key — stored alongside file metadata */ +export async function encryptFileKey(fileKey: CryptoKey, masterKey: CryptoKey): Promise { + const raw = await crypto.subtle.exportKey('raw', fileKey); + return aesEncryptRaw(new Uint8Array(raw), masterKey); +} + +export async function decryptFileKey( + encryptedB64: string, + masterKey: CryptoKey +): Promise { + const raw = await aesDecryptRaw(encryptedB64, masterKey); + return crypto.subtle.importKey('raw', raw, { name: 'AES-GCM', length: 256 }, true, [ + 'encrypt', + 'decrypt', + ]); +} + +// ── Conversation key ───────────────────────────────────────────────────────── + +/** Generate a shared conversation key */ +export async function generateConversationKey(): Promise { + return crypto.subtle.generateKey({ name: 'AES-GCM', length: 256 }, true, ['encrypt', 'decrypt']); +} + +/** Encrypt conversation key with a user's master key for storage */ +export async function encryptConversationKey( + convKey: CryptoKey, + masterKey: CryptoKey +): Promise { + const raw = await crypto.subtle.exportKey('raw', convKey); + return aesEncryptRaw(new Uint8Array(raw), masterKey); +} + +export async function decryptConversationKey( + encryptedB64: string, + masterKey: CryptoKey +): Promise { + const raw = await aesDecryptRaw(encryptedB64, masterKey); + return crypto.subtle.importKey('raw', raw, { name: 'AES-GCM', length: 256 }, true, [ + 'encrypt', + 'decrypt', + ]); +} + +// ── Helpers ────────────────────────────────────────────────────────────────── + +async function aesEncryptRaw(data: Uint8Array, key: CryptoKey): Promise { + const nonce = crypto.getRandomValues(new Uint8Array(12)); + const ct = await crypto.subtle.encrypt({ name: 'AES-GCM', iv: nonce }, key, data); + const combined = new Uint8Array(nonce.length + ct.byteLength); + combined.set(nonce); + combined.set(new Uint8Array(ct), nonce.length); + return bufToB64(combined); +} + +async function aesDecryptRaw(b64: string, key: CryptoKey): Promise { + const combined = b64ToBuf(b64); + const nonce = combined.slice(0, 12); + const ct = combined.slice(12); + const pt = await crypto.subtle.decrypt({ name: 'AES-GCM', iv: nonce }, key, ct); + return new Uint8Array(pt); +} + +export function bufToB64(buf: ArrayBuffer | Uint8Array): string { + return btoa(String.fromCharCode(...new Uint8Array(buf instanceof ArrayBuffer ? buf : buf))); +} + +export function b64ToBuf(b64: string): Uint8Array { + return Uint8Array.from(atob(b64), (c) => c.charCodeAt(0)); +} diff --git a/frontend/src/index.css b/frontend/src/index.css new file mode 100644 index 0000000..dda2b0d --- /dev/null +++ b/frontend/src/index.css @@ -0,0 +1,22 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +@layer base { + body { + @apply bg-surface text-slate-100 font-sans antialiased; + } + * { box-sizing: border-box; } + ::-webkit-scrollbar { width: 6px; } + ::-webkit-scrollbar-track { background: transparent; } + ::-webkit-scrollbar-thumb { @apply bg-slate-600 rounded-full; } +} + +@layer components { + .input { + @apply bg-surface-2 border border-slate-600 rounded-lg px-3 py-2 text-sm text-slate-100 placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-accent focus:border-transparent transition-colors; + } + .btn-primary { + @apply bg-accent hover:bg-accent-hover text-white font-medium rounded-lg px-4 py-2 text-sm transition-colors disabled:opacity-50 disabled:cursor-not-allowed; + } +} diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts new file mode 100644 index 0000000..658a027 --- /dev/null +++ b/frontend/src/lib/api.ts @@ -0,0 +1,41 @@ +import axios from 'axios'; +import { useAuthStore } from './store'; + +const api = axios.create({ baseURL: import.meta.env.VITE_API_URL || '/api' }); + +api.interceptors.request.use((config) => { + const token = useAuthStore.getState().accessToken; + if (token) config.headers.Authorization = `Bearer ${token}`; + return config; +}); + +let refreshing: Promise | null = null; + +api.interceptors.response.use( + (r) => r, + async (err) => { + if (err.response?.status !== 401) throw err; + + const store = useAuthStore.getState(); + if (!store.refreshToken) { store.logout(); throw err; } + + if (!refreshing) { + refreshing = api + .post('/auth/refresh', { refreshToken: store.refreshToken }) + .then((r) => { + store.setTokens(r.data.accessToken, r.data.refreshToken); + }) + .catch(() => store.logout()) + .finally(() => { refreshing = null; }); + } + + await refreshing; + + const newToken = useAuthStore.getState().accessToken; + if (!newToken) throw err; + err.config.headers.Authorization = `Bearer ${newToken}`; + return api.request(err.config); + } +); + +export default api; diff --git a/frontend/src/lib/store.ts b/frontend/src/lib/store.ts new file mode 100644 index 0000000..ddc68e4 --- /dev/null +++ b/frontend/src/lib/store.ts @@ -0,0 +1,63 @@ +import { create } from 'zustand'; +import { persist } from 'zustand/middleware'; + +interface AuthState { + accessToken: string | null; + refreshToken: string | null; + userId: string | null; + username: string | null; + // Base64 master key — loaded into memory from password derivation, NOT persisted + masterKey: CryptoKey | null; + setAuth: (data: { accessToken: string; refreshToken: string; userId: string }) => void; + setTokens: (access: string, refresh: string) => void; + setMasterKey: (key: CryptoKey) => void; + setUsername: (u: string) => void; + logout: () => void; +} + +export const useAuthStore = create()( + persist( + (set) => ({ + accessToken: null, + refreshToken: null, + userId: null, + username: null, + masterKey: null, + setAuth: ({ accessToken, refreshToken, userId }) => + set({ accessToken, refreshToken, userId }), + setTokens: (accessToken, refreshToken) => set({ accessToken, refreshToken }), + setMasterKey: (masterKey) => set({ masterKey }), + setUsername: (username) => set({ username }), + logout: () => + set({ accessToken: null, refreshToken: null, userId: null, masterKey: null }), + }), + { + name: 'cloudlink-auth', + // Never persist the master key — must be re-derived on login + partialize: (s) => ({ + accessToken: s.accessToken, + refreshToken: s.refreshToken, + userId: s.userId, + username: s.username, + }), + } + ) +); + +interface ConvKey { + conversationId: string; + key: CryptoKey; +} + +interface CryptoStore { + convKeys: Map; + setConvKey: (conversationId: string, key: CryptoKey) => void; + getConvKey: (conversationId: string) => CryptoKey | undefined; +} + +export const useCryptoStore = create((set, get) => ({ + convKeys: new Map(), + setConvKey: (conversationId, key) => + set((s) => ({ convKeys: new Map(s.convKeys).set(conversationId, key) })), + getConvKey: (conversationId) => get().convKeys.get(conversationId), +})); diff --git a/frontend/src/lib/ws.ts b/frontend/src/lib/ws.ts new file mode 100644 index 0000000..a5b9102 --- /dev/null +++ b/frontend/src/lib/ws.ts @@ -0,0 +1,44 @@ +type MessageHandler = (msg: unknown) => void; + +class CloudLinkSocket { + private ws: WebSocket | null = null; + private handlers: Set = new Set(); + private reconnectTimer: ReturnType | null = null; + + connect(token: string) { + if (this.ws?.readyState === WebSocket.OPEN) return; + + const url = `${import.meta.env.VITE_WS_URL || '/ws'}?token=${token}`; + this.ws = new WebSocket(url); + + this.ws.onmessage = (e) => { + try { + const msg = JSON.parse(e.data); + this.handlers.forEach((h) => h(msg)); + } catch {} + }; + + this.ws.onclose = () => { + this.reconnectTimer = setTimeout(() => this.connect(token), 3000); + }; + } + + disconnect() { + if (this.reconnectTimer) clearTimeout(this.reconnectTimer); + this.ws?.close(); + this.ws = null; + } + + send(data: object) { + if (this.ws?.readyState === WebSocket.OPEN) { + this.ws.send(JSON.stringify(data)); + } + } + + on(handler: MessageHandler) { + this.handlers.add(handler); + return () => this.handlers.delete(handler); + } +} + +export const socket = new CloudLinkSocket(); diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx new file mode 100644 index 0000000..80badc7 --- /dev/null +++ b/frontend/src/main.tsx @@ -0,0 +1,18 @@ +import React from 'react'; +import ReactDOM from 'react-dom/client'; +import { BrowserRouter } from 'react-router-dom'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import App from './App'; +import './index.css'; + +const qc = new QueryClient(); + +ReactDOM.createRoot(document.getElementById('root')!).render( + + + + + + + +); diff --git a/frontend/src/pages/DrivePage.tsx b/frontend/src/pages/DrivePage.tsx new file mode 100644 index 0000000..47c63a6 --- /dev/null +++ b/frontend/src/pages/DrivePage.tsx @@ -0,0 +1,168 @@ +import { useRef, useState } from 'react'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; +import { Upload, File, Trash2, Download, Lock } from 'lucide-react'; +import { formatDistanceToNow } from 'date-fns'; +import api from '../lib/api'; +import { useAuthStore } from '../lib/store'; +import { + generateFileKey, + encryptFile, + encryptFileKey, + decryptFileKey, + decryptFile, + encryptMessage, + decryptMessage, +} from '../crypto'; + +interface DriveFile { + id: string; + encrypted_name: string; + mime_type: string; + size_bytes: number; + encrypted_file_key: string; + uploaded_at: string; +} + +function formatBytes(bytes: number) { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 ** 2) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / 1024 ** 2).toFixed(1)} MB`; +} + +export default function DrivePage() { + const { masterKey } = useAuthStore(); + const qc = useQueryClient(); + const inputRef = useRef(null); + const [uploading, setUploading] = useState(false); + const [decryptedNames, setDecryptedNames] = useState>({}); + + const { data: files = [] } = useQuery({ + queryKey: ['files'], + queryFn: () => api.get('/files').then((r) => r.data), + onSuccess: async (files) => { + if (!masterKey) return; + const names: Record = {}; + await Promise.all( + files.map(async (f) => { + try { + const fileKey = await decryptFileKey(f.encrypted_file_key, masterKey); + names[f.id] = await decryptMessage(f.encrypted_name, fileKey); + } catch { + names[f.id] = '[encrypted]'; + } + }) + ); + setDecryptedNames(names); + }, + } as any); + + async function handleUpload(e: React.ChangeEvent) { + const file = e.target.files?.[0]; + if (!file || !masterKey) return; + setUploading(true); + + try { + const fileKey = await generateFileKey(); + const encryptedBlob = await encryptFile(file, fileKey); + const encryptedFileKey = await encryptFileKey(fileKey, masterKey); + const encryptedName = await encryptMessage(file.name, fileKey); + + const form = new FormData(); + form.append('file', encryptedBlob, 'blob'); + form.append('encryptedName', encryptedName); + form.append('encryptedFileKey', encryptedFileKey); + form.append('mimeType', file.type || 'application/octet-stream'); + + await api.post('/upload', form, { headers: { 'Content-Type': 'multipart/form-data' } }); + qc.invalidateQueries({ queryKey: ['files'] }); + } finally { + setUploading(false); + if (inputRef.current) inputRef.current.value = ''; + } + } + + async function handleDownload(f: DriveFile) { + if (!masterKey) return; + const fileKey = await decryptFileKey(f.encrypted_file_key, masterKey); + const res = await api.get(`/files/${f.id}/download`, { responseType: 'blob' }); + const decrypted = await decryptFile(res.data, fileKey); + const filename = decryptedNames[f.id] ?? 'download'; + + const url = URL.createObjectURL(new Blob([decrypted])); + const a = document.createElement('a'); + a.href = url; + a.download = filename; + a.click(); + URL.revokeObjectURL(url); + } + + async function handleDelete(id: string) { + await api.delete(`/files/${id}`); + qc.invalidateQueries({ queryKey: ['files'] }); + } + + return ( +
+
+
+

Encrypted Drive

+

+ Files are encrypted before upload +

+
+ + +
+ +
+ {files.length === 0 ? ( +
+ +

No files yet — upload your first encrypted file

+
+ ) : ( +
+ {files.map((f) => ( +
+ +
+

+ {decryptedNames[f.id] ?? '…'} +

+

+ {formatBytes(f.size_bytes)} ·{' '} + {formatDistanceToNow(new Date(f.uploaded_at), { addSuffix: true })} +

+
+ + +
+ ))} +
+ )} +
+
+ ); +} diff --git a/frontend/src/pages/LoginPage.tsx b/frontend/src/pages/LoginPage.tsx new file mode 100644 index 0000000..eb2e95c --- /dev/null +++ b/frontend/src/pages/LoginPage.tsx @@ -0,0 +1,135 @@ +import { useState, FormEvent } from 'react'; +import { Link, useNavigate } from 'react-router-dom'; +import { Shield } from 'lucide-react'; +import api from '../lib/api'; +import { useAuthStore } from '../lib/store'; +import { deriveKeyFromPassword, decryptMasterKey } from '../crypto'; +import { socket } from '../lib/ws'; + +export default function LoginPage() { + const navigate = useNavigate(); + const { setAuth, setMasterKey, setUsername } = useAuthStore(); + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [totpCode, setTotpCode] = useState(''); + const [require2fa, setRequire2fa] = useState(false); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(''); + + async function submit(e: FormEvent) { + e.preventDefault(); + setError(''); + setLoading(true); + + try { + const res = await api.post('/auth/login', { + email, + password, + ...(totpCode ? { totpCode } : {}), + }); + + if (res.data.require2fa) { + setRequire2fa(true); + setLoading(false); + return; + } + + const { accessToken, refreshToken, userId, kdfSalt, encryptedMasterKey, username } = + res.data; + + // Derive key from password — never leaves the browser + const derived = await deriveKeyFromPassword(password, kdfSalt); + const masterKey = await decryptMasterKey(encryptedMasterKey, derived); + + setAuth({ accessToken, refreshToken, userId }); + setMasterKey(masterKey); + setUsername(username); + + socket.connect(accessToken); + navigate('/messages'); + } catch (err: any) { + setError(err.response?.data?.error || 'Login failed'); + } finally { + setLoading(false); + } + } + + return ( +
+
+
+ +

CloudLink

+

End-to-end encrypted

+
+ +
+

Sign in

+ + {error &&

{error}

} + + {!require2fa ? ( + <> + + + + ) : ( +
+ + setTotpCode(e.target.value)} + autoFocus + /> +
+ )} + + + + +

+ No account?{' '} + + Register + +

+
+
+ ); +} + +function Field({ + label, + type, + value, + onChange, +}: { + label: string; + type: string; + value: string; + onChange: (v: string) => void; +}) { + return ( +
+ + onChange(e.target.value)} + required + /> +
+ ); +} diff --git a/frontend/src/pages/MessagesPage.tsx b/frontend/src/pages/MessagesPage.tsx new file mode 100644 index 0000000..8e44b2f --- /dev/null +++ b/frontend/src/pages/MessagesPage.tsx @@ -0,0 +1,255 @@ +import { useState, useEffect, useRef } from 'react'; +import { useParams } from 'react-router-dom'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; +import { Send, Plus, Search } from 'lucide-react'; +import { formatDistanceToNow } from 'date-fns'; +import api from '../lib/api'; +import { useAuthStore, useCryptoStore } from '../lib/store'; +import { socket } from '../lib/ws'; +import { + encryptMessage, + decryptMessage, + generateConversationKey, + encryptConversationKey, + decryptConversationKey, +} from '../crypto'; + +interface Conversation { + id: string; + members: { id: string; username: string }[]; + encrypted_key: string; + last_message_at: string | null; +} + +interface Message { + id: string; + sender_id: string; + sender_username: string; + ciphertext: string; + sent_at: string; + plaintext?: string; +} + +export default function MessagesPage() { + const { id: activeId } = useParams(); + const { userId, masterKey } = useAuthStore(); + const { setConvKey, getConvKey } = useCryptoStore(); + const qc = useQueryClient(); + const [text, setText] = useState(''); + const [messages, setMessages] = useState([]); + const [newUserSearch, setNewUserSearch] = useState(''); + const [searchResults, setSearchResults] = useState<{ id: string; username: string }[]>([]); + const bottomRef = useRef(null); + + const { data: conversations = [] } = useQuery({ + queryKey: ['conversations'], + queryFn: () => api.get('/conversations').then((r) => r.data), + }); + + // Load + decrypt messages when conversation changes + useEffect(() => { + if (!activeId || !masterKey) return; + + async function load() { + const conv = conversations.find((c) => c.id === activeId); + if (!conv) return; + + let convKey = getConvKey(activeId!); + if (!convKey) { + convKey = await decryptConversationKey(conv.encrypted_key, masterKey!); + setConvKey(activeId!, convKey); + } + + const res = await api.get(`/conversations/${activeId}/messages`); + const decrypted = await Promise.all( + res.data.map(async (m: Message) => ({ + ...m, + plaintext: await decryptMessage(m.ciphertext, convKey!).catch(() => '[encrypted]'), + })) + ); + setMessages(decrypted); + } + + load(); + }, [activeId, conversations.length]); + + // WebSocket incoming messages + useEffect(() => { + return socket.on(async (raw) => { + const msg = raw as any; + if (msg.type !== 'message' || msg.conversationId !== activeId) return; + + const convKey = getConvKey(activeId!); + if (!convKey) return; + + const plaintext = await decryptMessage(msg.ciphertext, convKey).catch(() => '[encrypted]'); + setMessages((prev) => [ + ...prev, + { ...msg, sender_username: msg.senderUsername ?? msg.senderId, plaintext }, + ]); + }); + }, [activeId]); + + useEffect(() => { + bottomRef.current?.scrollIntoView({ behavior: 'smooth' }); + }, [messages]); + + async function sendMessage() { + if (!text.trim() || !activeId) return; + const convKey = getConvKey(activeId); + if (!convKey) return; + + const ciphertext = await encryptMessage(text.trim(), convKey); + socket.send({ type: 'message', conversationId: activeId, ciphertext }); + setText(''); + } + + async function startConversation(recipientId: string, recipientUsername: string) { + if (!masterKey) return; + + const convKey = await generateConversationKey(); + const myEncKey = await encryptConversationKey(convKey, masterKey); + const recipientEncKey = await encryptConversationKey(convKey, masterKey); // real impl: encrypt with recipient's pubkey + + const res = await api.post('/conversations', { + recipientId, + encryptedKeys: { + [userId!]: myEncKey, + [recipientId]: recipientEncKey, + }, + }); + + setConvKey(res.data.conversationId, convKey); + qc.invalidateQueries({ queryKey: ['conversations'] }); + setNewUserSearch(''); + setSearchResults([]); + } + + async function handleSearch(q: string) { + setNewUserSearch(q); + if (q.length < 2) { setSearchResults([]); return; } + const res = await api.get(`/users/search?q=${encodeURIComponent(q)}`); + setSearchResults(res.data); + } + + const activeConv = conversations.find((c) => c.id === activeId); + const otherMember = activeConv?.members.find((m) => m.id !== userId); + + return ( +
+ {/* Conversation list */} + + + {/* Chat area */} +
+ {activeId ? ( + <> +
+
+ {otherMember?.username[0]?.toUpperCase() ?? '?'} +
+ {otherMember?.username ?? 'Conversation'} + + + E2E encrypted + +
+ +
+ {messages.map((m) => { + const mine = m.sender_id === userId; + return ( +
+
+

{m.plaintext}

+

+ {formatDistanceToNow(new Date(m.sent_at), { addSuffix: true })} +

+
+
+ ); + })} +
+
+ +
+ setText(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && !e.shiftKey && sendMessage()} + /> + +
+ + ) : ( +
+ +

Search for a user to start a conversation

+
+ )} +
+
+ ); +} diff --git a/frontend/src/pages/RegisterPage.tsx b/frontend/src/pages/RegisterPage.tsx new file mode 100644 index 0000000..c377a45 --- /dev/null +++ b/frontend/src/pages/RegisterPage.tsx @@ -0,0 +1,111 @@ +import { useState, FormEvent } from 'react'; +import { Link, useNavigate } from 'react-router-dom'; +import { Shield } from 'lucide-react'; +import api from '../lib/api'; +import { useAuthStore } from '../lib/store'; +import { + generateKdfSalt, + deriveKeyFromPassword, + generateMasterKey, + encryptMasterKey, +} from '../crypto'; +import { socket } from '../lib/ws'; + +export default function RegisterPage() { + const navigate = useNavigate(); + const { setAuth, setMasterKey, setUsername } = useAuthStore(); + const [username, setUsernameField] = useState(''); + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [confirm, setConfirm] = useState(''); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(''); + + async function submit(e: FormEvent) { + e.preventDefault(); + setError(''); + if (password !== confirm) { setError('Passwords do not match'); return; } + if (password.length < 12) { setError('Password must be at least 12 characters'); return; } + + setLoading(true); + try { + // Generate all crypto material client-side + const kdfSalt = await generateKdfSalt(); + const derivedKey = await deriveKeyFromPassword(password, kdfSalt); + const masterKey = await generateMasterKey(); + const encryptedMasterKey = await encryptMasterKey(masterKey, derivedKey); + + // Minimal public key placeholder (full ECDH exchange would use X25519 via libsodium) + const publicKey = 'placeholder-pubkey'; + const keySignature = 'placeholder-sig'; + + const res = await api.post('/auth/register', { + username, + email, + password, + kdfSalt, + encryptedMasterKey, + publicKey, + keySignature, + }); + + const { accessToken, refreshToken, userId } = res.data; + setAuth({ accessToken, refreshToken, userId }); + setMasterKey(masterKey); + setUsername(username); + + socket.connect(accessToken); + navigate('/messages'); + } catch (err: any) { + setError(err.response?.data?.error || 'Registration failed'); + } finally { + setLoading(false); + } + } + + return ( +
+
+
+ +

CloudLink

+

Create your encrypted account

+
+ +
+

Create account

+ {error &&

{error}

} + + + + + + +

+ Your password derives the encryption key. If you forget it, your data cannot be recovered. +

+ + + + +

+ Already have an account?{' '} + Sign in +

+
+
+ ); +} + +function Field({ label, type, value, onChange }: { + label: string; type: string; value: string; onChange: (v: string) => void; +}) { + return ( +
+ + onChange(e.target.value)} required /> +
+ ); +} diff --git a/frontend/src/pages/SettingsPage.tsx b/frontend/src/pages/SettingsPage.tsx new file mode 100644 index 0000000..95dc040 --- /dev/null +++ b/frontend/src/pages/SettingsPage.tsx @@ -0,0 +1,147 @@ +import { useState } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { Shield, KeyRound, Copy, Check } from 'lucide-react'; +import api from '../lib/api'; + +interface Me { + username: string; + email: string; + totp_enabled: boolean; +} + +export default function SettingsPage() { + const { data: me } = useQuery({ + queryKey: ['me'], + queryFn: () => api.get('/me').then((r) => r.data), + }); + + const [step, setStep] = useState<'idle' | 'setup' | 'verify' | 'done'>('idle'); + const [qr, setQr] = useState(''); + const [secret, setSecret] = useState(''); + const [code, setCode] = useState(''); + const [backupCodes, setBackupCodes] = useState([]); + const [copied, setCopied] = useState(false); + const [error, setError] = useState(''); + + async function setup2fa() { + const res = await api.post('/auth/2fa/setup'); + setQr(res.data.qrDataUrl); + setSecret(res.data.secret); + setStep('setup'); + } + + async function verify2fa() { + setError(''); + try { + const res = await api.post('/auth/2fa/verify', { code }); + setBackupCodes(res.data.backupCodes); + setStep('done'); + } catch (err: any) { + setError(err.response?.data?.error || 'Verification failed'); + } + } + + function copy(text: string) { + navigator.clipboard.writeText(text); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } + + return ( +
+
+

Settings

+ + {/* Account info */} +
+

+ Account +

+
+

Username: {me?.username}

+

Email: {me?.email}

+
+
+ + {/* 2FA */} +
+

+ Two-factor authentication +

+ + {me?.totp_enabled && step === 'idle' ? ( +

+ 2FA is enabled +

+ ) : null} + + {!me?.totp_enabled && step === 'idle' && ( + + )} + + {step === 'setup' && ( +
+

+ Scan this QR code with your authenticator app (Google Authenticator, Authy, etc.) +

+ 2FA QR code +

{secret}

+ +
+ )} + + {step === 'verify' && ( +
+

Enter the 6-digit code from your app:

+ {error &&

{error}

} + setCode(e.target.value)} + placeholder="000000" + autoFocus + /> + +
+ )} + + {step === 'done' && ( +
+

2FA enabled! Save these backup codes:

+
+ {backupCodes.map((c) =>

{c}

)} +
+ +
+ )} +
+ + {/* Encryption notice */} +
+

+ Encryption +

+
    +
  • All messages and files are encrypted in your browser before being sent
  • +
  • Your encryption key is derived from your password — never sent to the server
  • +
  • The server operator cannot read your data
  • +
  • If you forget your password, your data cannot be recovered
  • +
+
+
+
+ ); +} diff --git a/frontend/tailwind.config.js b/frontend/tailwind.config.js new file mode 100644 index 0000000..81747d8 --- /dev/null +++ b/frontend/tailwind.config.js @@ -0,0 +1,20 @@ +/** @type {import('tailwindcss').Config} */ +export default { + content: ['./index.html', './src/**/*.{ts,tsx}'], + theme: { + extend: { + colors: { + surface: { + DEFAULT: '#0f172a', + 1: '#1e293b', + 2: '#263248', + }, + accent: { + DEFAULT: '#6366f1', + hover: '#4f46e5', + }, + }, + }, + }, + plugins: [], +}; diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..851f6bc --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2020", + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "moduleResolution": "bundler", + "jsx": "react-jsx", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "esModuleInterop": true, + "skipLibCheck": true + }, + "include": ["src"] +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 0000000..c6f0fa5 --- /dev/null +++ b/frontend/vite.config.ts @@ -0,0 +1,43 @@ +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; +import { VitePWA } from 'vite-plugin-pwa'; + +export default defineConfig({ + plugins: [ + react(), + VitePWA({ + registerType: 'autoUpdate', + includeAssets: ['favicon.ico', 'icon-192.png', 'icon-512.png'], + manifest: { + name: 'CloudLink', + short_name: 'CloudLink', + description: 'Encrypted messaging and cloud drive', + theme_color: '#0f172a', + background_color: '#0f172a', + display: 'standalone', + orientation: 'portrait', + start_url: '/', + icons: [ + { src: 'icon-192.png', sizes: '192x192', type: 'image/png' }, + { src: 'icon-512.png', sizes: '512x512', type: 'image/png', purpose: 'any maskable' }, + ], + }, + workbox: { + globPatterns: ['**/*.{js,css,html,ico,png,svg,woff2}'], + runtimeCaching: [ + { + urlPattern: /^\/api\//, + handler: 'NetworkFirst', + options: { cacheName: 'api-cache', networkTimeoutSeconds: 10 }, + }, + ], + }, + }), + ], + server: { + proxy: { + '/api': 'http://localhost:3001', + '/ws': { target: 'ws://localhost:3001', ws: true }, + }, + }, +}); diff --git a/nginx/nginx.conf b/nginx/nginx.conf new file mode 100644 index 0000000..4a1414c --- /dev/null +++ b/nginx/nginx.conf @@ -0,0 +1,57 @@ +events { worker_connections 1024; } + +http { + include mime.types; + default_type application/octet-stream; + sendfile on; + keepalive_timeout 65; + client_max_body_size 110m; + + # Redirect HTTP → HTTPS + server { + listen 80; + return 301 https://$host$request_uri; + } + + server { + listen 443 ssl http2; + ssl_certificate /etc/nginx/ssl/fullchain.pem; + ssl_certificate_key /etc/nginx/ssl/privkey.pem; + ssl_protocols TLSv1.2 TLSv1.3; + ssl_ciphers HIGH:!aNULL:!MD5; + ssl_session_cache shared:SSL:10m; + + # Security headers + add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; + add_header X-Frame-Options DENY always; + add_header X-Content-Type-Options nosniff always; + add_header Referrer-Policy no-referrer always; + add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; connect-src 'self' wss:;" always; + + # API + location /api/ { + proxy_pass http://backend:3001/api/; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + # WebSocket + location /ws { + proxy_pass http://backend:3001/ws; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "Upgrade"; + proxy_set_header Host $host; + proxy_read_timeout 86400; + } + + # Frontend (SPA) + location / { + proxy_pass http://frontend:80/; + proxy_set_header Host $host; + } + } +}