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 <noreply@anthropic.com>
This commit is contained in:
commit
937bf8ad2d
36 files changed
+2325
No files matched your search
@@ -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"]
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
);
|
||||
@@ -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<string, WebSocket>();
|
||||
|
||||
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}`));
|
||||
@@ -0,0 +1,7 @@
|
||||
import { Pool } from 'pg';
|
||||
|
||||
export const db = new Pool({
|
||||
connectionString: process.env.DATABASE_URL,
|
||||
max: 20,
|
||||
idleTimeoutMillis: 30000,
|
||||
});
|
||||
@@ -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' });
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "CommonJS",
|
||||
"moduleResolution": "node",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
Reference in new issue
Block a user