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:
nmemmertandClaude Sonnet 4.6 committed 2026-08-28 09:41:18 -04:00
commit 937bf8ad2d
36 files changed
+2325

No files matched your search

+15
View File
@@ -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"]
+39
View File
@@ -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"
}
}
+90
View File
@@ -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()
);
+102
View File
@@ -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}`));
+7
View File
@@ -0,0 +1,7 @@
import { Pool } from 'pg';
export const db = new Pool({
connectionString: process.env.DATABASE_URL,
max: 20,
idleTimeoutMillis: 30000,
});
+21
View File
@@ -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' });
}
}
+184
View File
@@ -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;
+105
View File
@@ -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;
+131
View File
@@ -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;
+41
View File
@@ -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;
+13
View File
@@ -0,0 +1,13 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "CommonJS",
"moduleResolution": "node",
"strict": true,
"esModuleInterop": true,
"outDir": "dist",
"rootDir": "src",
"skipLibCheck": true
},
"include": ["src"]
}