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

+18
View File
@@ -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
+7
View File
@@ -0,0 +1,7 @@
.env
data/
node_modules/
dist/
build/
*.local
nginx/ssl/
+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"]
}
+59
View File
@@ -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
+15
View File
@@ -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
+15
View File
@@ -0,0 +1,15 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#0f172a" />
<link rel="icon" href="/favicon.ico" />
<link rel="apple-touch-icon" href="/icon-192.png" />
<title>CloudLink</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+8
View File
@@ -0,0 +1,8 @@
server {
listen 80;
root /usr/share/nginx/html;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
}
+31
View File
@@ -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"
}
}
+1
View File
@@ -0,0 +1 @@
export default { plugins: { tailwindcss: {}, autoprefixer: {} } };
+38
View File
@@ -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 <Navigate to="/login" replace />;
if (!masterKey) return <Navigate to="/login" state={{ reauth: true }} replace />;
return <>{children}</>;
}
export default function App() {
return (
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route path="/register" element={<RegisterPage />} />
<Route
path="/*"
element={
<RequireAuth>
<Layout />
</RequireAuth>
}
>
<Route index element={<Navigate to="/messages" replace />} />
<Route path="messages" element={<MessagesPage />} />
<Route path="messages/:id" element={<MessagesPage />} />
<Route path="drive" element={<DrivePage />} />
<Route path="settings" element={<SettingsPage />} />
</Route>
</Routes>
);
}
+71
View File
@@ -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 (
<div className="flex h-screen overflow-hidden">
{/* Sidebar */}
<aside className="w-16 md:w-56 flex flex-col bg-surface-1 border-r border-slate-700 shrink-0">
<div className="flex items-center gap-2 px-4 py-5 border-b border-slate-700">
<Shield className="text-accent w-6 h-6 shrink-0" />
<span className="hidden md:block font-bold text-lg tracking-tight">CloudLink</span>
</div>
<nav className="flex-1 py-4 space-y-1 px-2">
{nav.map(({ to, icon: Icon, label }) => (
<NavLink
key={to}
to={to}
className={({ isActive }) =>
`flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-colors ${
isActive
? 'bg-accent text-white'
: 'text-slate-400 hover:text-white hover:bg-surface-2'
}`
}
>
<Icon className="w-5 h-5 shrink-0" />
<span className="hidden md:block">{label}</span>
</NavLink>
))}
</nav>
<div className="p-3 border-t border-slate-700">
<div className="flex items-center gap-3 mb-2 px-1">
<div className="w-7 h-7 rounded-full bg-accent flex items-center justify-center text-xs font-bold shrink-0">
{username?.[0]?.toUpperCase() ?? '?'}
</div>
<span className="hidden md:block text-sm text-slate-300 truncate">{username}</span>
</div>
<button
onClick={handleLogout}
className="flex items-center gap-3 w-full px-3 py-2 rounded-lg text-sm text-slate-400 hover:text-white hover:bg-surface-2 transition-colors"
>
<LogOut className="w-4 h-4 shrink-0" />
<span className="hidden md:block">Sign out</span>
</button>
</div>
</aside>
{/* Main */}
<main className="flex-1 overflow-hidden">
<Outlet />
</main>
</div>
);
}
+174
View File
@@ -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<string> {
const salt = crypto.getRandomValues(new Uint8Array(32));
return bufToB64(salt);
}
export async function deriveKeyFromPassword(
password: string,
saltB64: string
): Promise<CryptoKey> {
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<CryptoKey> {
return crypto.subtle.generateKey({ name: 'AES-GCM', length: 256 }, true, [
'encrypt',
'decrypt',
'wrapKey',
'unwrapKey',
]);
}
export async function exportMasterKey(key: CryptoKey): Promise<string> {
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<string> {
const raw = await crypto.subtle.exportKey('raw', masterKey);
return aesEncryptRaw(new Uint8Array(raw), derivedKey);
}
export async function decryptMasterKey(
encryptedB64: string,
derivedKey: CryptoKey
): Promise<CryptoKey> {
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<string> {
return aesEncryptRaw(ENC.encode(plaintext), key);
}
export async function decryptMessage(ciphertextB64: string, key: CryptoKey): Promise<string> {
const raw = await aesDecryptRaw(ciphertextB64, key);
return DEC.decode(raw);
}
// ── File encryption ─────────────────────────────────────────────────────────
export async function generateFileKey(): Promise<CryptoKey> {
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<Blob> {
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<Uint8Array> {
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<string> {
const raw = await crypto.subtle.exportKey('raw', fileKey);
return aesEncryptRaw(new Uint8Array(raw), masterKey);
}
export async function decryptFileKey(
encryptedB64: string,
masterKey: CryptoKey
): Promise<CryptoKey> {
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<CryptoKey> {
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<string> {
const raw = await crypto.subtle.exportKey('raw', convKey);
return aesEncryptRaw(new Uint8Array(raw), masterKey);
}
export async function decryptConversationKey(
encryptedB64: string,
masterKey: CryptoKey
): Promise<CryptoKey> {
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<string> {
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<Uint8Array> {
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));
}
+22
View File
@@ -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;
}
}
+41
View File
@@ -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<void> | 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;
+63
View File
@@ -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<AuthState>()(
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<string, CryptoKey>;
setConvKey: (conversationId: string, key: CryptoKey) => void;
getConvKey: (conversationId: string) => CryptoKey | undefined;
}
export const useCryptoStore = create<CryptoStore>((set, get) => ({
convKeys: new Map(),
setConvKey: (conversationId, key) =>
set((s) => ({ convKeys: new Map(s.convKeys).set(conversationId, key) })),
getConvKey: (conversationId) => get().convKeys.get(conversationId),
}));
+44
View File
@@ -0,0 +1,44 @@
type MessageHandler = (msg: unknown) => void;
class CloudLinkSocket {
private ws: WebSocket | null = null;
private handlers: Set<MessageHandler> = new Set();
private reconnectTimer: ReturnType<typeof setTimeout> | 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();
+18
View File
@@ -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(
<React.StrictMode>
<QueryClientProvider client={qc}>
<BrowserRouter>
<App />
</BrowserRouter>
</QueryClientProvider>
</React.StrictMode>
);
+168
View File
@@ -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<HTMLInputElement>(null);
const [uploading, setUploading] = useState(false);
const [decryptedNames, setDecryptedNames] = useState<Record<string, string>>({});
const { data: files = [] } = useQuery<DriveFile[]>({
queryKey: ['files'],
queryFn: () => api.get('/files').then((r) => r.data),
onSuccess: async (files) => {
if (!masterKey) return;
const names: Record<string, string> = {};
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<HTMLInputElement>) {
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 (
<div className="h-full flex flex-col">
<header className="px-6 py-4 border-b border-slate-700 flex items-center justify-between">
<div>
<h1 className="text-lg font-semibold">Encrypted Drive</h1>
<p className="text-xs text-slate-500 flex items-center gap-1 mt-0.5">
<Lock className="w-3 h-3" /> Files are encrypted before upload
</p>
</div>
<button
onClick={() => inputRef.current?.click()}
disabled={uploading}
className="btn-primary flex items-center gap-2"
>
<Upload className="w-4 h-4" />
{uploading ? 'Encrypting…' : 'Upload'}
</button>
<input ref={inputRef} type="file" className="hidden" onChange={handleUpload} />
</header>
<div className="flex-1 overflow-y-auto p-6">
{files.length === 0 ? (
<div className="flex flex-col items-center justify-center h-48 text-slate-500 gap-3">
<File className="w-10 h-10" />
<p>No files yet — upload your first encrypted file</p>
</div>
) : (
<div className="grid grid-cols-1 gap-2">
{files.map((f) => (
<div
key={f.id}
className="flex items-center gap-4 bg-surface-1 rounded-lg px-4 py-3 border border-slate-700"
>
<File className="w-5 h-5 text-accent shrink-0" />
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">
{decryptedNames[f.id] ?? '…'}
</p>
<p className="text-xs text-slate-500">
{formatBytes(f.size_bytes)} ·{' '}
{formatDistanceToNow(new Date(f.uploaded_at), { addSuffix: true })}
</p>
</div>
<button
onClick={() => handleDownload(f)}
className="p-1.5 text-slate-400 hover:text-white transition-colors"
title="Download"
>
<Download className="w-4 h-4" />
</button>
<button
onClick={() => handleDelete(f.id)}
className="p-1.5 text-slate-400 hover:text-red-400 transition-colors"
title="Delete"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
))}
</div>
)}
</div>
</div>
);
}
+135
View File
@@ -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 (
<div className="min-h-screen flex items-center justify-center p-4">
<div className="w-full max-w-sm">
<div className="flex flex-col items-center mb-8">
<Shield className="w-12 h-12 text-accent mb-3" />
<h1 className="text-2xl font-bold">CloudLink</h1>
<p className="text-slate-400 text-sm mt-1">End-to-end encrypted</p>
</div>
<form onSubmit={submit} className="bg-surface-1 rounded-xl p-6 space-y-4 border border-slate-700">
<h2 className="font-semibold text-lg">Sign in</h2>
{error && <p className="text-red-400 text-sm">{error}</p>}
{!require2fa ? (
<>
<Field label="Email" type="email" value={email} onChange={setEmail} />
<Field label="Password" type="password" value={password} onChange={setPassword} />
</>
) : (
<div>
<label className="block text-sm text-slate-400 mb-1">Authenticator code</label>
<input
className="input w-full"
type="text"
inputMode="numeric"
maxLength={6}
placeholder="000000"
value={totpCode}
onChange={(e) => setTotpCode(e.target.value)}
autoFocus
/>
</div>
)}
<button
type="submit"
disabled={loading}
className="btn-primary w-full"
>
{loading ? 'Signing in…' : require2fa ? 'Verify' : 'Sign in'}
</button>
</form>
<p className="text-center text-sm text-slate-400 mt-4">
No account?{' '}
<Link to="/register" className="text-accent hover:underline">
Register
</Link>
</p>
</div>
</div>
);
}
function Field({
label,
type,
value,
onChange,
}: {
label: string;
type: string;
value: string;
onChange: (v: string) => void;
}) {
return (
<div>
<label className="block text-sm text-slate-400 mb-1">{label}</label>
<input
className="input w-full"
type={type}
value={value}
onChange={(e) => onChange(e.target.value)}
required
/>
</div>
);
}
+255
View File
@@ -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<Message[]>([]);
const [newUserSearch, setNewUserSearch] = useState('');
const [searchResults, setSearchResults] = useState<{ id: string; username: string }[]>([]);
const bottomRef = useRef<HTMLDivElement>(null);
const { data: conversations = [] } = useQuery<Conversation[]>({
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 (
<div className="flex h-full">
{/* Conversation list */}
<aside className="w-64 border-r border-slate-700 flex flex-col bg-surface-1 shrink-0">
<div className="p-3 border-b border-slate-700">
<div className="relative">
<Search className="absolute left-2.5 top-2.5 w-4 h-4 text-slate-500" />
<input
className="input w-full pl-8 text-sm"
placeholder="Find or start chat…"
value={newUserSearch}
onChange={(e) => handleSearch(e.target.value)}
/>
</div>
{searchResults.length > 0 && (
<div className="mt-1 bg-surface-2 rounded-lg border border-slate-600 overflow-hidden">
{searchResults.map((u) => (
<button
key={u.id}
onClick={() => startConversation(u.id, u.username)}
className="w-full text-left px-3 py-2 text-sm hover:bg-slate-600 transition-colors"
>
{u.username}
</button>
))}
</div>
)}
</div>
<div className="flex-1 overflow-y-auto">
{conversations.map((conv) => {
const other = conv.members.find((m) => m.id !== userId);
return (
<a
key={conv.id}
href={`/messages/${conv.id}`}
className={`flex items-center gap-3 px-3 py-3 border-b border-slate-700/50 hover:bg-surface-2 transition-colors ${
activeId === conv.id ? 'bg-surface-2' : ''
}`}
>
<div className="w-8 h-8 rounded-full bg-accent flex items-center justify-center text-xs font-bold shrink-0">
{other?.username[0]?.toUpperCase() ?? '?'}
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">{other?.username ?? 'Unknown'}</p>
{conv.last_message_at && (
<p className="text-xs text-slate-500">
{formatDistanceToNow(new Date(conv.last_message_at), { addSuffix: true })}
</p>
)}
</div>
</a>
);
})}
</div>
</aside>
{/* Chat area */}
<div className="flex-1 flex flex-col">
{activeId ? (
<>
<header className="px-4 py-3 border-b border-slate-700 bg-surface-1 flex items-center gap-3">
<div className="w-8 h-8 rounded-full bg-accent flex items-center justify-center text-sm font-bold">
{otherMember?.username[0]?.toUpperCase() ?? '?'}
</div>
<span className="font-medium">{otherMember?.username ?? 'Conversation'}</span>
<span className="ml-auto text-xs text-green-400 flex items-center gap-1">
<span className="w-1.5 h-1.5 rounded-full bg-green-400 inline-block" />
E2E encrypted
</span>
</header>
<div className="flex-1 overflow-y-auto px-4 py-4 space-y-3">
{messages.map((m) => {
const mine = m.sender_id === userId;
return (
<div key={m.id} className={`flex ${mine ? 'justify-end' : 'justify-start'}`}>
<div
className={`max-w-xs lg:max-w-md px-4 py-2 rounded-2xl text-sm ${
mine
? 'bg-accent text-white rounded-br-sm'
: 'bg-surface-1 text-slate-100 rounded-bl-sm'
}`}
>
<p>{m.plaintext}</p>
<p className={`text-xs mt-1 ${mine ? 'text-indigo-200' : 'text-slate-500'}`}>
{formatDistanceToNow(new Date(m.sent_at), { addSuffix: true })}
</p>
</div>
</div>
);
})}
<div ref={bottomRef} />
</div>
<div className="px-4 py-3 border-t border-slate-700 flex gap-2">
<input
className="input flex-1"
placeholder="Message…"
value={text}
onChange={(e) => setText(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && !e.shiftKey && sendMessage()}
/>
<button onClick={sendMessage} className="btn-primary px-4">
<Send className="w-4 h-4" />
</button>
</div>
</>
) : (
<div className="flex-1 flex flex-col items-center justify-center text-slate-500 gap-3">
<Plus className="w-10 h-10" />
<p>Search for a user to start a conversation</p>
</div>
)}
</div>
</div>
);
}
+111
View File
@@ -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 (
<div className="min-h-screen flex items-center justify-center p-4">
<div className="w-full max-w-sm">
<div className="flex flex-col items-center mb-8">
<Shield className="w-12 h-12 text-accent mb-3" />
<h1 className="text-2xl font-bold">CloudLink</h1>
<p className="text-slate-400 text-sm mt-1">Create your encrypted account</p>
</div>
<form onSubmit={submit} className="bg-surface-1 rounded-xl p-6 space-y-4 border border-slate-700">
<h2 className="font-semibold text-lg">Create account</h2>
{error && <p className="text-red-400 text-sm">{error}</p>}
<Field label="Username" type="text" value={username} onChange={setUsernameField} />
<Field label="Email" type="email" value={email} onChange={setEmail} />
<Field label="Password (min 12 chars)" type="password" value={password} onChange={setPassword} />
<Field label="Confirm password" type="password" value={confirm} onChange={setConfirm} />
<p className="text-xs text-slate-500">
Your password derives the encryption key. If you forget it, your data cannot be recovered.
</p>
<button type="submit" disabled={loading} className="btn-primary w-full">
{loading ? 'Creating account…' : 'Create account'}
</button>
</form>
<p className="text-center text-sm text-slate-400 mt-4">
Already have an account?{' '}
<Link to="/login" className="text-accent hover:underline">Sign in</Link>
</p>
</div>
</div>
);
}
function Field({ label, type, value, onChange }: {
label: string; type: string; value: string; onChange: (v: string) => void;
}) {
return (
<div>
<label className="block text-sm text-slate-400 mb-1">{label}</label>
<input className="input w-full" type={type} value={value} onChange={(e) => onChange(e.target.value)} required />
</div>
);
}
+147
View File
@@ -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<Me>({
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<string[]>([]);
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 (
<div className="h-full overflow-y-auto">
<div className="max-w-xl mx-auto p-6 space-y-6">
<h1 className="text-xl font-semibold">Settings</h1>
{/* Account info */}
<section className="bg-surface-1 rounded-xl border border-slate-700 p-5 space-y-3">
<h2 className="font-medium flex items-center gap-2">
<Shield className="w-4 h-4 text-accent" /> Account
</h2>
<div className="text-sm space-y-1">
<p className="text-slate-400">Username: <span className="text-slate-100">{me?.username}</span></p>
<p className="text-slate-400">Email: <span className="text-slate-100">{me?.email}</span></p>
</div>
</section>
{/* 2FA */}
<section className="bg-surface-1 rounded-xl border border-slate-700 p-5 space-y-4">
<h2 className="font-medium flex items-center gap-2">
<KeyRound className="w-4 h-4 text-accent" /> Two-factor authentication
</h2>
{me?.totp_enabled && step === 'idle' ? (
<p className="text-sm text-green-400 flex items-center gap-2">
<Check className="w-4 h-4" /> 2FA is enabled
</p>
) : null}
{!me?.totp_enabled && step === 'idle' && (
<button onClick={setup2fa} className="btn-primary text-sm">
Set up 2FA
</button>
)}
{step === 'setup' && (
<div className="space-y-4">
<p className="text-sm text-slate-400">
Scan this QR code with your authenticator app (Google Authenticator, Authy, etc.)
</p>
<img src={qr} alt="2FA QR code" className="w-48 h-48 rounded-lg" />
<p className="text-xs text-slate-500 font-mono break-all">{secret}</p>
<button onClick={() => setStep('verify')} className="btn-primary text-sm">
I've scanned it
</button>
</div>
)}
{step === 'verify' && (
<div className="space-y-3">
<p className="text-sm text-slate-400">Enter the 6-digit code from your app:</p>
{error && <p className="text-red-400 text-sm">{error}</p>}
<input
className="input w-40 font-mono text-lg tracking-widest"
type="text"
inputMode="numeric"
maxLength={6}
value={code}
onChange={(e) => setCode(e.target.value)}
placeholder="000000"
autoFocus
/>
<button onClick={verify2fa} className="btn-primary text-sm">Verify</button>
</div>
)}
{step === 'done' && (
<div className="space-y-3">
<p className="text-sm text-green-400">2FA enabled! Save these backup codes:</p>
<div className="bg-surface-2 rounded-lg p-3 font-mono text-sm space-y-1">
{backupCodes.map((c) => <p key={c}>{c}</p>)}
</div>
<button
onClick={() => copy(backupCodes.join('\n'))}
className="flex items-center gap-2 text-sm text-slate-400 hover:text-white transition-colors"
>
{copied ? <Check className="w-4 h-4" /> : <Copy className="w-4 h-4" />}
{copied ? 'Copied' : 'Copy codes'}
</button>
</div>
)}
</section>
{/* Encryption notice */}
<section className="bg-surface-1 rounded-xl border border-slate-700 p-5">
<h2 className="font-medium flex items-center gap-2 mb-3">
<Shield className="w-4 h-4 text-accent" /> Encryption
</h2>
<ul className="text-sm text-slate-400 space-y-1.5 list-disc list-inside">
<li>All messages and files are encrypted in your browser before being sent</li>
<li>Your encryption key is derived from your password — never sent to the server</li>
<li>The server operator cannot read your data</li>
<li>If you forget your password, your data cannot be recovered</li>
</ul>
</section>
</div>
</div>
);
}
+20
View File
@@ -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: [],
};
+16
View File
@@ -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"]
}
+43
View File
@@ -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 },
},
},
});
+57
View File
@@ -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;
}
}
}