Initial CloudLink scaffold: E2E encrypted PWA with messaging and cloud drive
- Zero-knowledge architecture: all encryption/decryption client-side via Web Crypto API - AES-GCM 256 for messages, files, and key wrapping; PBKDF2 for password-based key derivation - Node.js/TypeScript backend: JWT auth with refresh rotation, argon2id passwords, TOTP 2FA - PostgreSQL schema: encrypted blobs only, server never stores plaintext - Real-time messaging via WebSocket relay (server routes ciphertext, cannot read it) - Encrypted file upload/download with per-file keys wrapped by user master key - React PWA frontend: Vite + Tailwind + service worker for offline support - Docker Compose: postgres + backend + frontend + nginx with TLS Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
commit
937bf8ad2d
36 files changed
+2325
No files matched your search
@@ -0,0 +1,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
|
||||
@@ -0,0 +1,7 @@
|
||||
.env
|
||||
data/
|
||||
node_modules/
|
||||
dist/
|
||||
build/
|
||||
*.local
|
||||
nginx/ssl/
|
||||
@@ -0,0 +1,15 @@
|
||||
FROM node:20-alpine AS builder
|
||||
WORKDIR /app
|
||||
COPY package*.json ./
|
||||
RUN npm ci
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
FROM node:20-alpine
|
||||
WORKDIR /app
|
||||
COPY package*.json ./
|
||||
RUN npm ci --omit=dev
|
||||
COPY --from=builder /app/dist ./dist
|
||||
RUN mkdir -p /data/uploads
|
||||
EXPOSE 3001
|
||||
CMD ["node", "dist/index.js"]
|
||||
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"name": "cloudlink-backend",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "tsx watch src/index.ts",
|
||||
"build": "tsc",
|
||||
"start": "node dist/index.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"argon2": "^0.41.1",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.4.5",
|
||||
"express": "^4.19.2",
|
||||
"express-rate-limit": "^7.3.1",
|
||||
"helmet": "^7.1.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"multer": "^1.4.5-lts.1",
|
||||
"otplib": "^12.0.1",
|
||||
"pg": "^8.12.0",
|
||||
"qrcode": "^1.5.4",
|
||||
"uuid": "^10.0.0",
|
||||
"ws": "^8.18.0",
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/cors": "^2.8.17",
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/jsonwebtoken": "^9.0.6",
|
||||
"@types/multer": "^1.4.11",
|
||||
"@types/node": "^20.14.11",
|
||||
"@types/pg": "^8.11.6",
|
||||
"@types/qrcode": "^1.5.5",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"@types/ws": "^8.5.11",
|
||||
"tsx": "^4.16.2",
|
||||
"typescript": "^5.5.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
CREATE EXTENSION IF NOT EXISTS "pgcrypto";
|
||||
|
||||
-- Users: minimal plaintext metadata, everything sensitive is client-encrypted
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
username TEXT UNIQUE NOT NULL,
|
||||
email TEXT UNIQUE NOT NULL,
|
||||
password_hash TEXT NOT NULL,
|
||||
-- Argon2id salt used client-side for key derivation (base64)
|
||||
kdf_salt TEXT NOT NULL,
|
||||
-- Client-side encrypted master key (base64 ciphertext + nonce)
|
||||
encrypted_master_key TEXT NOT NULL,
|
||||
-- TOTP secret (server-side, not part of user's encrypted data)
|
||||
totp_secret TEXT,
|
||||
totp_enabled BOOLEAN NOT NULL DEFAULT false,
|
||||
-- Backup codes for 2FA (hashed)
|
||||
totp_backup_codes TEXT[] DEFAULT '{}',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
last_seen_at TIMESTAMPTZ
|
||||
);
|
||||
|
||||
-- Refresh tokens for JWT rotation
|
||||
CREATE TABLE IF NOT EXISTS refresh_tokens (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
token_hash TEXT NOT NULL UNIQUE,
|
||||
expires_at TIMESTAMPTZ NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Direct message conversations
|
||||
CREATE TABLE IF NOT EXISTS conversations (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS conversation_members (
|
||||
conversation_id UUID NOT NULL REFERENCES conversations(id) ON DELETE CASCADE,
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
-- Per-conversation encrypted key (client encrypts shared key with each member's public key)
|
||||
encrypted_key TEXT NOT NULL,
|
||||
joined_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
PRIMARY KEY (conversation_id, user_id)
|
||||
);
|
||||
|
||||
-- Messages: ciphertext only — server cannot read content
|
||||
CREATE TABLE IF NOT EXISTS messages (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
conversation_id UUID NOT NULL REFERENCES conversations(id) ON DELETE CASCADE,
|
||||
sender_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
-- AES-GCM encrypted content (base64: nonce + ciphertext + tag)
|
||||
ciphertext TEXT NOT NULL,
|
||||
-- Optional: encrypted filename if message contains a file reference
|
||||
file_id UUID,
|
||||
sent_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_conversation ON messages(conversation_id, sent_at DESC);
|
||||
|
||||
-- Files: encrypted before upload, metadata stored here
|
||||
CREATE TABLE IF NOT EXISTS files (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
owner_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
-- Encrypted filename (client-side)
|
||||
encrypted_name TEXT NOT NULL,
|
||||
-- MIME type (not sensitive, kept plaintext for serving)
|
||||
mime_type TEXT NOT NULL DEFAULT 'application/octet-stream',
|
||||
size_bytes BIGINT NOT NULL,
|
||||
-- Path on disk (opaque UUID-based, not guessable)
|
||||
storage_path TEXT NOT NULL UNIQUE,
|
||||
-- AES-GCM file encryption key, encrypted with owner's master key
|
||||
encrypted_file_key TEXT NOT NULL,
|
||||
-- Optional: shared with a conversation
|
||||
conversation_id UUID REFERENCES conversations(id) ON DELETE SET NULL,
|
||||
uploaded_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
-- Soft delete
|
||||
deleted_at TIMESTAMPTZ
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_files_owner ON files(owner_id) WHERE deleted_at IS NULL;
|
||||
|
||||
-- Public keys for E2E key exchange
|
||||
CREATE TABLE IF NOT EXISTS user_keys (
|
||||
user_id UUID PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
|
||||
-- X25519 public key for ECDH (base64)
|
||||
public_key TEXT NOT NULL,
|
||||
-- Signed with user's identity key so others can verify
|
||||
key_signature TEXT NOT NULL,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
@@ -0,0 +1,102 @@
|
||||
import 'dotenv/config';
|
||||
import http from 'http';
|
||||
import express from 'express';
|
||||
import cors from 'cors';
|
||||
import helmet from 'helmet';
|
||||
import rateLimit from 'express-rate-limit';
|
||||
import { WebSocketServer, WebSocket } from 'ws';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { db } from './lib/db.js';
|
||||
import authRouter from './routes/auth.js';
|
||||
import messagesRouter from './routes/messages.js';
|
||||
import filesRouter from './routes/files.js';
|
||||
import usersRouter from './routes/users.js';
|
||||
|
||||
const app = express();
|
||||
|
||||
app.set('trust proxy', 1);
|
||||
app.use(helmet());
|
||||
app.use(cors({ origin: process.env.CORS_ORIGIN || '*', credentials: true }));
|
||||
app.use(express.json({ limit: '1mb' }));
|
||||
|
||||
const limiter = rateLimit({ windowMs: 15 * 60 * 1000, max: 200 });
|
||||
const authLimiter = rateLimit({ windowMs: 15 * 60 * 1000, max: 20 });
|
||||
|
||||
app.use('/api', limiter);
|
||||
app.use('/api/auth', authLimiter, authRouter);
|
||||
app.use('/api', messagesRouter);
|
||||
app.use('/api', filesRouter);
|
||||
app.use('/api', usersRouter);
|
||||
|
||||
app.get('/api/health', (_req, res) => res.json({ ok: true }));
|
||||
|
||||
// WebSocket: real-time messaging relay
|
||||
// Server never sees plaintext — it only routes encrypted payloads
|
||||
const server = http.createServer(app);
|
||||
const wss = new WebSocketServer({ server, path: '/ws' });
|
||||
|
||||
// userId → WebSocket
|
||||
const connections = new Map<string, WebSocket>();
|
||||
|
||||
wss.on('connection', (ws, req) => {
|
||||
const url = new URL(req.url!, `http://${req.headers.host}`);
|
||||
const token = url.searchParams.get('token');
|
||||
|
||||
let userId: string;
|
||||
try {
|
||||
const payload = jwt.verify(token!, process.env.JWT_SECRET!) as { sub: string };
|
||||
userId = payload.sub;
|
||||
} catch {
|
||||
ws.close(1008, 'Unauthorized');
|
||||
return;
|
||||
}
|
||||
|
||||
connections.set(userId, ws);
|
||||
|
||||
ws.on('message', async (raw) => {
|
||||
try {
|
||||
const msg = JSON.parse(raw.toString());
|
||||
|
||||
// Expected shape: { type: 'message', conversationId, ciphertext, fileId? }
|
||||
if (msg.type !== 'message') return;
|
||||
|
||||
// Persist
|
||||
const result = await db.query(
|
||||
`INSERT INTO messages (conversation_id, sender_id, ciphertext, file_id)
|
||||
VALUES ($1, $2, $3, $4) RETURNING id, sent_at`,
|
||||
[msg.conversationId, userId, msg.ciphertext, msg.fileId ?? null]
|
||||
);
|
||||
const saved = result.rows[0];
|
||||
|
||||
// Get conversation members to fan out
|
||||
const members = await db.query(
|
||||
'SELECT user_id FROM conversation_members WHERE conversation_id = $1',
|
||||
[msg.conversationId]
|
||||
);
|
||||
|
||||
const envelope = JSON.stringify({
|
||||
type: 'message',
|
||||
id: saved.id,
|
||||
conversationId: msg.conversationId,
|
||||
senderId: userId,
|
||||
ciphertext: msg.ciphertext,
|
||||
fileId: msg.fileId,
|
||||
sentAt: saved.sent_at,
|
||||
});
|
||||
|
||||
for (const { user_id } of members.rows) {
|
||||
const sock = connections.get(user_id);
|
||||
if (sock?.readyState === WebSocket.OPEN) {
|
||||
sock.send(envelope);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('WS message error', err);
|
||||
}
|
||||
});
|
||||
|
||||
ws.on('close', () => connections.delete(userId));
|
||||
});
|
||||
|
||||
const PORT = Number(process.env.PORT) || 3001;
|
||||
server.listen(PORT, () => console.log(`CloudLink backend on :${PORT}`));
|
||||
@@ -0,0 +1,7 @@
|
||||
import { Pool } from 'pg';
|
||||
|
||||
export const db = new Pool({
|
||||
connectionString: process.env.DATABASE_URL,
|
||||
max: 20,
|
||||
idleTimeoutMillis: 30000,
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import jwt from 'jsonwebtoken';
|
||||
|
||||
export interface AuthRequest extends Request {
|
||||
userId?: string;
|
||||
}
|
||||
|
||||
export function requireAuth(req: AuthRequest, res: Response, next: NextFunction) {
|
||||
const header = req.headers.authorization;
|
||||
if (!header?.startsWith('Bearer ')) {
|
||||
return res.status(401).json({ error: 'Missing token' });
|
||||
}
|
||||
const token = header.slice(7);
|
||||
try {
|
||||
const payload = jwt.verify(token, process.env.JWT_SECRET!) as { sub: string };
|
||||
req.userId = payload.sub;
|
||||
next();
|
||||
} catch {
|
||||
res.status(401).json({ error: 'Invalid token' });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
import { Router } from 'express';
|
||||
import argon2 from 'argon2';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { authenticator } from 'otplib';
|
||||
import QRCode from 'qrcode';
|
||||
import crypto from 'crypto';
|
||||
import { z } from 'zod';
|
||||
import { db } from '../lib/db.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
const RegisterSchema = z.object({
|
||||
username: z.string().min(3).max(32).regex(/^[a-zA-Z0-9_-]+$/),
|
||||
email: z.string().email(),
|
||||
password: z.string().min(12),
|
||||
kdfSalt: z.string(), // base64 — generated client-side
|
||||
encryptedMasterKey: z.string(), // base64 — encrypted client-side
|
||||
publicKey: z.string(), // base64 X25519 public key
|
||||
keySignature: z.string(),
|
||||
});
|
||||
|
||||
const LoginSchema = z.object({
|
||||
email: z.string().email(),
|
||||
password: z.string(),
|
||||
totpCode: z.string().optional(),
|
||||
});
|
||||
|
||||
function issueTokens(userId: string) {
|
||||
const accessToken = jwt.sign({ sub: userId }, process.env.JWT_SECRET!, {
|
||||
expiresIn: '15m',
|
||||
});
|
||||
const refreshToken = jwt.sign({ sub: userId }, process.env.JWT_REFRESH_SECRET!, {
|
||||
expiresIn: '30d',
|
||||
});
|
||||
return { accessToken, refreshToken };
|
||||
}
|
||||
|
||||
async function storeRefreshToken(userId: string, token: string) {
|
||||
const hash = crypto.createHash('sha256').update(token).digest('hex');
|
||||
const expiresAt = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000);
|
||||
await db.query(
|
||||
'INSERT INTO refresh_tokens (user_id, token_hash, expires_at) VALUES ($1, $2, $3)',
|
||||
[userId, hash, expiresAt]
|
||||
);
|
||||
}
|
||||
|
||||
router.post('/register', async (req, res) => {
|
||||
const parsed = RegisterSchema.safeParse(req.body);
|
||||
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
|
||||
|
||||
const { username, email, password, kdfSalt, encryptedMasterKey, publicKey, keySignature } =
|
||||
parsed.data;
|
||||
|
||||
try {
|
||||
const passwordHash = await argon2.hash(password, { type: argon2.argon2id });
|
||||
|
||||
const result = await db.query(
|
||||
`INSERT INTO users (username, email, password_hash, kdf_salt, encrypted_master_key)
|
||||
VALUES ($1, $2, $3, $4, $5) RETURNING id`,
|
||||
[username, email, passwordHash, kdfSalt, encryptedMasterKey]
|
||||
);
|
||||
const userId = result.rows[0].id;
|
||||
|
||||
await db.query(
|
||||
`INSERT INTO user_keys (user_id, public_key, key_signature) VALUES ($1, $2, $3)`,
|
||||
[userId, publicKey, keySignature]
|
||||
);
|
||||
|
||||
const { accessToken, refreshToken } = issueTokens(userId);
|
||||
await storeRefreshToken(userId, refreshToken);
|
||||
|
||||
res.status(201).json({ accessToken, refreshToken, userId });
|
||||
} catch (err: any) {
|
||||
if (err.code === '23505') {
|
||||
return res.status(409).json({ error: 'Username or email already taken' });
|
||||
}
|
||||
console.error(err);
|
||||
res.status(500).json({ error: 'Registration failed' });
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/login', async (req, res) => {
|
||||
const parsed = LoginSchema.safeParse(req.body);
|
||||
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
|
||||
|
||||
const { email, password, totpCode } = parsed.data;
|
||||
|
||||
const result = await db.query(
|
||||
`SELECT id, password_hash, totp_enabled, totp_secret, kdf_salt, encrypted_master_key
|
||||
FROM users WHERE email = $1`,
|
||||
[email]
|
||||
);
|
||||
const user = result.rows[0];
|
||||
if (!user) return res.status(401).json({ error: 'Invalid credentials' });
|
||||
|
||||
const valid = await argon2.verify(user.password_hash, password);
|
||||
if (!valid) return res.status(401).json({ error: 'Invalid credentials' });
|
||||
|
||||
if (user.totp_enabled) {
|
||||
if (!totpCode) return res.status(200).json({ require2fa: true });
|
||||
const ok = authenticator.verify({ token: totpCode, secret: user.totp_secret });
|
||||
if (!ok) return res.status(401).json({ error: 'Invalid 2FA code' });
|
||||
}
|
||||
|
||||
const { accessToken, refreshToken } = issueTokens(user.id);
|
||||
await storeRefreshToken(user.id, refreshToken);
|
||||
|
||||
res.json({
|
||||
accessToken,
|
||||
refreshToken,
|
||||
userId: user.id,
|
||||
kdfSalt: user.kdf_salt,
|
||||
encryptedMasterKey: user.encrypted_master_key,
|
||||
});
|
||||
});
|
||||
|
||||
router.post('/refresh', async (req, res) => {
|
||||
const { refreshToken } = req.body;
|
||||
if (!refreshToken) return res.status(400).json({ error: 'Missing token' });
|
||||
|
||||
try {
|
||||
const payload = jwt.verify(refreshToken, process.env.JWT_REFRESH_SECRET!) as { sub: string };
|
||||
const hash = crypto.createHash('sha256').update(refreshToken).digest('hex');
|
||||
|
||||
const result = await db.query(
|
||||
`DELETE FROM refresh_tokens WHERE token_hash = $1 AND expires_at > NOW() RETURNING user_id`,
|
||||
[hash]
|
||||
);
|
||||
if (!result.rows.length) return res.status(401).json({ error: 'Token expired or revoked' });
|
||||
|
||||
const { accessToken, refreshToken: newRefresh } = issueTokens(payload.sub);
|
||||
await storeRefreshToken(payload.sub, newRefresh);
|
||||
res.json({ accessToken, refreshToken: newRefresh });
|
||||
} catch {
|
||||
res.status(401).json({ error: 'Invalid token' });
|
||||
}
|
||||
});
|
||||
|
||||
// 2FA setup — returns QR code URI for authenticator app
|
||||
router.post('/2fa/setup', async (req, res) => {
|
||||
const userId = (req as any).userId;
|
||||
if (!userId) return res.status(401).json({ error: 'Unauthorized' });
|
||||
|
||||
const secret = authenticator.generateSecret();
|
||||
const userResult = await db.query('SELECT username FROM users WHERE id = $1', [userId]);
|
||||
const username = userResult.rows[0]?.username;
|
||||
|
||||
const otpauth = authenticator.keyuri(username, 'CloudLink', secret);
|
||||
const qrDataUrl = await QRCode.toDataURL(otpauth);
|
||||
|
||||
// Store secret temporarily — only persisted after verification
|
||||
await db.query('UPDATE users SET totp_secret = $1 WHERE id = $2', [secret, userId]);
|
||||
|
||||
res.json({ secret, qrDataUrl });
|
||||
});
|
||||
|
||||
// 2FA verify — confirms secret and enables 2FA
|
||||
router.post('/2fa/verify', async (req, res) => {
|
||||
const userId = (req as any).userId;
|
||||
if (!userId) return res.status(401).json({ error: 'Unauthorized' });
|
||||
|
||||
const { code } = req.body;
|
||||
const result = await db.query('SELECT totp_secret FROM users WHERE id = $1', [userId]);
|
||||
const secret = result.rows[0]?.totp_secret;
|
||||
if (!secret) return res.status(400).json({ error: '2FA not set up' });
|
||||
|
||||
const ok = authenticator.verify({ token: code, secret });
|
||||
if (!ok) return res.status(400).json({ error: 'Invalid code' });
|
||||
|
||||
// Generate backup codes
|
||||
const backupCodes = Array.from({ length: 8 }, () =>
|
||||
crypto.randomBytes(5).toString('hex').toUpperCase()
|
||||
);
|
||||
const hashedCodes = await Promise.all(backupCodes.map((c) => argon2.hash(c)));
|
||||
|
||||
await db.query(
|
||||
'UPDATE users SET totp_enabled = true, totp_backup_codes = $1 WHERE id = $2',
|
||||
[hashedCodes, userId]
|
||||
);
|
||||
|
||||
res.json({ backupCodes });
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,105 @@
|
||||
import { Router } from 'express';
|
||||
import multer from 'multer';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { z } from 'zod';
|
||||
import { db } from '../lib/db.js';
|
||||
import { requireAuth, AuthRequest } from '../middleware/auth.js';
|
||||
|
||||
const router = Router();
|
||||
router.use(requireAuth);
|
||||
|
||||
const UPLOAD_DIR = process.env.UPLOAD_DIR || './data/uploads';
|
||||
const MAX_BYTES = (Number(process.env.MAX_FILE_SIZE_MB) || 100) * 1024 * 1024;
|
||||
|
||||
// Files are already encrypted client-side — multer just stores the blob
|
||||
const storage = multer.diskStorage({
|
||||
destination: (_req, _file, cb) => cb(null, UPLOAD_DIR),
|
||||
filename: (_req, _file, cb) => cb(null, uuidv4()),
|
||||
});
|
||||
|
||||
const upload = multer({
|
||||
storage,
|
||||
limits: { fileSize: MAX_BYTES },
|
||||
});
|
||||
|
||||
router.post('/upload', upload.single('file'), async (req: AuthRequest, res) => {
|
||||
if (!req.file) return res.status(400).json({ error: 'No file' });
|
||||
|
||||
const { encryptedName, encryptedFileKey, mimeType, conversationId } = req.body;
|
||||
if (!encryptedName || !encryptedFileKey) {
|
||||
fs.unlinkSync(req.file.path);
|
||||
return res.status(400).json({ error: 'Missing encrypted metadata' });
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await db.query(
|
||||
`INSERT INTO files (owner_id, encrypted_name, mime_type, size_bytes, storage_path, encrypted_file_key, conversation_id)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING id, uploaded_at`,
|
||||
[
|
||||
req.userId,
|
||||
encryptedName,
|
||||
mimeType || 'application/octet-stream',
|
||||
req.file.size,
|
||||
req.file.filename,
|
||||
encryptedFileKey,
|
||||
conversationId || null,
|
||||
]
|
||||
);
|
||||
res.status(201).json(result.rows[0]);
|
||||
} catch (err) {
|
||||
fs.unlinkSync(req.file.path);
|
||||
console.error(err);
|
||||
res.status(500).json({ error: 'Upload failed' });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/files', async (req: AuthRequest, res) => {
|
||||
const result = await db.query(
|
||||
`SELECT id, encrypted_name, mime_type, size_bytes, encrypted_file_key, uploaded_at
|
||||
FROM files WHERE owner_id = $1 AND deleted_at IS NULL ORDER BY uploaded_at DESC`,
|
||||
[req.userId]
|
||||
);
|
||||
res.json(result.rows);
|
||||
});
|
||||
|
||||
router.get('/files/:id/download', async (req: AuthRequest, res) => {
|
||||
const result = await db.query(
|
||||
`SELECT storage_path, mime_type, owner_id FROM files
|
||||
WHERE id = $1 AND deleted_at IS NULL`,
|
||||
[req.params.id]
|
||||
);
|
||||
const file = result.rows[0];
|
||||
if (!file) return res.status(404).json({ error: 'Not found' });
|
||||
|
||||
// Allow owner or conversation members to download
|
||||
if (file.owner_id !== req.userId) {
|
||||
const shared = await db.query(
|
||||
`SELECT 1 FROM files f
|
||||
JOIN conversation_members cm ON cm.conversation_id = f.conversation_id
|
||||
WHERE f.id = $1 AND cm.user_id = $2`,
|
||||
[req.params.id, req.userId]
|
||||
);
|
||||
if (!shared.rows.length) return res.status(403).json({ error: 'Forbidden' });
|
||||
}
|
||||
|
||||
const filePath = path.join(UPLOAD_DIR, file.storage_path);
|
||||
if (!fs.existsSync(filePath)) return res.status(404).json({ error: 'File missing' });
|
||||
|
||||
res.setHeader('Content-Type', file.mime_type);
|
||||
res.setHeader('Content-Disposition', 'attachment');
|
||||
fs.createReadStream(filePath).pipe(res);
|
||||
});
|
||||
|
||||
router.delete('/files/:id', async (req: AuthRequest, res) => {
|
||||
const result = await db.query(
|
||||
`UPDATE files SET deleted_at = NOW()
|
||||
WHERE id = $1 AND owner_id = $2 AND deleted_at IS NULL RETURNING storage_path`,
|
||||
[req.params.id, req.userId]
|
||||
);
|
||||
if (!result.rows.length) return res.status(404).json({ error: 'Not found' });
|
||||
res.status(204).end();
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,131 @@
|
||||
import { Router } from 'express';
|
||||
import { z } from 'zod';
|
||||
import { db } from '../lib/db.js';
|
||||
import { requireAuth, AuthRequest } from '../middleware/auth.js';
|
||||
|
||||
const router = Router();
|
||||
router.use(requireAuth);
|
||||
|
||||
const CreateConversationSchema = z.object({
|
||||
recipientId: z.string().uuid(),
|
||||
encryptedKeys: z.record(z.string()), // { userId: encryptedKey }
|
||||
});
|
||||
|
||||
const SendMessageSchema = z.object({
|
||||
conversationId: z.string().uuid(),
|
||||
ciphertext: z.string(),
|
||||
fileId: z.string().uuid().optional(),
|
||||
});
|
||||
|
||||
// Get or create a DM conversation with another user
|
||||
router.post('/conversations', async (req: AuthRequest, res) => {
|
||||
const parsed = CreateConversationSchema.safeParse(req.body);
|
||||
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
|
||||
|
||||
const { recipientId, encryptedKeys } = parsed.data;
|
||||
const userId = req.userId!;
|
||||
|
||||
// Check if conversation already exists
|
||||
const existing = await db.query(
|
||||
`SELECT c.id FROM conversations c
|
||||
JOIN conversation_members cm1 ON cm1.conversation_id = c.id AND cm1.user_id = $1
|
||||
JOIN conversation_members cm2 ON cm2.conversation_id = c.id AND cm2.user_id = $2`,
|
||||
[userId, recipientId]
|
||||
);
|
||||
if (existing.rows.length) {
|
||||
return res.json({ conversationId: existing.rows[0].id });
|
||||
}
|
||||
|
||||
const client = await db.connect();
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
const conv = await client.query(
|
||||
'INSERT INTO conversations DEFAULT VALUES RETURNING id'
|
||||
);
|
||||
const conversationId = conv.rows[0].id;
|
||||
|
||||
for (const [uid, encKey] of Object.entries(encryptedKeys)) {
|
||||
await client.query(
|
||||
'INSERT INTO conversation_members (conversation_id, user_id, encrypted_key) VALUES ($1, $2, $3)',
|
||||
[conversationId, uid, encKey]
|
||||
);
|
||||
}
|
||||
|
||||
await client.query('COMMIT');
|
||||
res.status(201).json({ conversationId });
|
||||
} catch (err) {
|
||||
await client.query('ROLLBACK');
|
||||
console.error(err);
|
||||
res.status(500).json({ error: 'Failed to create conversation' });
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
});
|
||||
|
||||
// List conversations for current user
|
||||
router.get('/conversations', async (req: AuthRequest, res) => {
|
||||
const result = await db.query(
|
||||
`SELECT c.id, c.created_at, cm.encrypted_key,
|
||||
(SELECT m.sent_at FROM messages m WHERE m.conversation_id = c.id ORDER BY sent_at DESC LIMIT 1) AS last_message_at,
|
||||
json_agg(json_build_object('id', u.id, 'username', u.username)) AS members
|
||||
FROM conversations c
|
||||
JOIN conversation_members cm ON cm.conversation_id = c.id AND cm.user_id = $1
|
||||
JOIN conversation_members cm2 ON cm2.conversation_id = c.id
|
||||
JOIN users u ON u.id = cm2.user_id
|
||||
GROUP BY c.id, cm.encrypted_key
|
||||
ORDER BY last_message_at DESC NULLS LAST`,
|
||||
[req.userId]
|
||||
);
|
||||
res.json(result.rows);
|
||||
});
|
||||
|
||||
// Get messages in a conversation (paginated)
|
||||
router.get('/conversations/:id/messages', async (req: AuthRequest, res) => {
|
||||
const { id } = req.params;
|
||||
const before = req.query.before as string | undefined;
|
||||
const limit = Math.min(Number(req.query.limit) || 50, 100);
|
||||
|
||||
// Verify membership
|
||||
const member = await db.query(
|
||||
'SELECT 1 FROM conversation_members WHERE conversation_id = $1 AND user_id = $2',
|
||||
[id, req.userId]
|
||||
);
|
||||
if (!member.rows.length) return res.status(403).json({ error: 'Not a member' });
|
||||
|
||||
const result = await db.query(
|
||||
`SELECT m.id, m.sender_id, m.ciphertext, m.file_id, m.sent_at,
|
||||
u.username AS sender_username
|
||||
FROM messages m
|
||||
JOIN users u ON u.id = m.sender_id
|
||||
WHERE m.conversation_id = $1 ${before ? 'AND m.sent_at < $3' : ''}
|
||||
ORDER BY m.sent_at DESC
|
||||
LIMIT $2`,
|
||||
before ? [id, limit, before] : [id, limit]
|
||||
);
|
||||
|
||||
res.json(result.rows.reverse());
|
||||
});
|
||||
|
||||
// Send a message (REST fallback — WebSocket is primary)
|
||||
router.post('/messages', async (req: AuthRequest, res) => {
|
||||
const parsed = SendMessageSchema.safeParse(req.body);
|
||||
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
|
||||
|
||||
const { conversationId, ciphertext, fileId } = parsed.data;
|
||||
|
||||
const member = await db.query(
|
||||
'SELECT 1 FROM conversation_members WHERE conversation_id = $1 AND user_id = $2',
|
||||
[conversationId, req.userId]
|
||||
);
|
||||
if (!member.rows.length) return res.status(403).json({ error: 'Not a member' });
|
||||
|
||||
const result = await db.query(
|
||||
`INSERT INTO messages (conversation_id, sender_id, ciphertext, file_id)
|
||||
VALUES ($1, $2, $3, $4) RETURNING id, sent_at`,
|
||||
[conversationId, req.userId, ciphertext, fileId ?? null]
|
||||
);
|
||||
|
||||
res.status(201).json(result.rows[0]);
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,41 @@
|
||||
import { Router } from 'express';
|
||||
import { db } from '../lib/db.js';
|
||||
import { requireAuth, AuthRequest } from '../middleware/auth.js';
|
||||
|
||||
const router = Router();
|
||||
router.use(requireAuth);
|
||||
|
||||
router.get('/me', async (req: AuthRequest, res) => {
|
||||
const result = await db.query(
|
||||
`SELECT id, username, email, totp_enabled, created_at FROM users WHERE id = $1`,
|
||||
[req.userId]
|
||||
);
|
||||
if (!result.rows.length) return res.status(404).json({ error: 'Not found' });
|
||||
res.json(result.rows[0]);
|
||||
});
|
||||
|
||||
router.get('/users/search', async (req: AuthRequest, res) => {
|
||||
const q = (req.query.q as string)?.trim();
|
||||
if (!q || q.length < 2) return res.json([]);
|
||||
|
||||
const result = await db.query(
|
||||
`SELECT u.id, u.username, uk.public_key
|
||||
FROM users u
|
||||
LEFT JOIN user_keys uk ON uk.user_id = u.id
|
||||
WHERE u.username ILIKE $1 AND u.id != $2
|
||||
LIMIT 20`,
|
||||
[`${q}%`, req.userId]
|
||||
);
|
||||
res.json(result.rows);
|
||||
});
|
||||
|
||||
router.get('/users/:id/key', async (req: AuthRequest, res) => {
|
||||
const result = await db.query(
|
||||
`SELECT public_key, key_signature FROM user_keys WHERE user_id = $1`,
|
||||
[req.params.id]
|
||||
);
|
||||
if (!result.rows.length) return res.status(404).json({ error: 'No key found' });
|
||||
res.json(result.rows[0]);
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "CommonJS",
|
||||
"moduleResolution": "node",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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>
|
||||
@@ -0,0 +1,8 @@
|
||||
server {
|
||||
listen 80;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export default { plugins: { tailwindcss: {}, autoprefixer: {} } };
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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),
|
||||
}));
|
||||
@@ -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();
|
||||
@@ -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>
|
||||
);
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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: [],
|
||||
};
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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 },
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user