const express = require('express'); const fs = require('fs'); const path = require('path'); const crypto = require('crypto'); const Database = require('better-sqlite3'); const router = express.Router(); const DATA_DIR = path.resolve(__dirname, '../../data'); const DB_PATH = process.env.PSC_DB_PATH || path.join(DATA_DIR, 'psc.db'); console.log('PSC DB Path:', DB_PATH); const SECRETS_DIR = path.join(DATA_DIR, 'psc_secrets'); const CHUNKS_DIR = path.join(DATA_DIR, 'psc_chunks'); const VAULT_FILE = path.join(SECRETS_DIR, 'vault.json'); const PSC_CONFIG_FILE = path.join(process.cwd(), 'config', 'psc.json'); // Ensure dirs exist if (!fs.existsSync(CHUNKS_DIR)) fs.mkdirSync(CHUNKS_DIR, { recursive: true }); let _db = null; function getDb() { if (!_db) { _db = new Database(DB_PATH); _db.pragma('journal_mode = WAL'); initDb(_db); } return _db; } function initDb(db) { db.exec(` CREATE TABLE IF NOT EXISTS users ( id TEXT PRIMARY KEY, username TEXT, master_hash TEXT, salt TEXT, created_at INTEGER ); CREATE TABLE IF NOT EXISTS sessions ( token TEXT PRIMARY KEY, created_at INTEGER, last_seen INTEGER, expires_at INTEGER ); CREATE TABLE IF NOT EXISTS files ( id TEXT PRIMARY KEY, name TEXT, size INTEGER, chunk_size INTEGER, salt TEXT, dir TEXT, uploaded_at INTEGER, status TEXT ); CREATE TABLE IF NOT EXISTS directories ( path TEXT PRIMARY KEY, name TEXT, parent TEXT, created_at INTEGER, updated_at INTEGER ); CREATE TABLE IF NOT EXISTS shares ( id TEXT PRIMARY KEY, fileId TEXT, wrappedKeyB64 TEXT, shareSaltB64 TEXT, ivB64 TEXT, hash TEXT, ver INTEGER, created_at TEXT, expires_at TEXT ); CREATE TABLE IF NOT EXISTS logs ( id INTEGER PRIMARY KEY AUTOINCREMENT, category TEXT, action TEXT, details TEXT, ip TEXT, created_at TEXT ); CREATE TABLE IF NOT EXISTS file_slices ( fileId TEXT PRIMARY KEY, volumeMB INTEGER, created_at TEXT ); `); try { db.prepare('ALTER TABLE directories ADD COLUMN created_at INTEGER').run(); } catch (e) {} try { db.prepare('ALTER TABLE directories ADD COLUMN updated_at INTEGER').run(); } catch (e) {} try { db.prepare('ALTER TABLE directories ADD COLUMN parent TEXT').run(); } catch (e) {} try { db.prepare('ALTER TABLE directories ADD COLUMN name TEXT').run(); } catch (e) {} try { db.prepare('ALTER TABLE files ADD COLUMN uploaded_at INTEGER').run(); } catch (e) {} try { db.prepare('ALTER TABLE files ADD COLUMN status TEXT').run(); } catch (e) {} try { db.prepare('ALTER TABLE files ADD COLUMN chunk_size INTEGER').run(); } catch (e) {} try { db.prepare('ALTER TABLE files ADD COLUMN salt TEXT').run(); } catch (e) {} try { db.prepare('ALTER TABLE files ADD COLUMN dir TEXT').run(); } catch (e) {} try { db.prepare('ALTER TABLE files ADD COLUMN size INTEGER').run(); } catch (e) {} try { db.prepare('ALTER TABLE files ADD COLUMN name TEXT').run(); } catch (e) {} try { db.prepare('ALTER TABLE shares ADD COLUMN code TEXT').run(); } catch (e) {} try { db.prepare('ALTER TABLE shares ADD COLUMN password_hash TEXT').run(); } catch (e) {} try { db.prepare('ALTER TABLE shares ADD COLUMN salt TEXT').run(); } catch (e) {} try { db.prepare('ALTER TABLE shares ADD COLUMN expire_at INTEGER').run(); } catch (e) {} try { db.prepare('ALTER TABLE sessions ADD COLUMN created_at INTEGER').run(); } catch (e) {} try { db.prepare('ALTER TABLE sessions ADD COLUMN last_seen INTEGER').run(); } catch (e) {} try { db.prepare('ALTER TABLE sessions ADD COLUMN expires_at INTEGER').run(); } catch (e) {} } function logAction(req, category, action, details) { try { const db = getDb(); const ip = req.ip || req.socket.remoteAddress || 'unknown'; db.prepare('INSERT INTO logs (category, action, details, ip, created_at) VALUES (?, ?, ?, ?, ?)').run( category, action, JSON.stringify(details || {}), ip, new Date().toISOString() ); } catch (e) { console.error('Log failed', e); } } // Load Vault (from data vault.json + config/psc.json) let vault = {}; try { if (fs.existsSync(VAULT_FILE)) { vault = JSON.parse(fs.readFileSync(VAULT_FILE, 'utf8')); } } catch (e) { console.error('Failed to load vault.json', e); } try { if (fs.existsSync(PSC_CONFIG_FILE)) { const cfgRaw = fs.readFileSync(PSC_CONFIG_FILE, 'utf8') || '{}'; const cfg = JSON.parse(cfgRaw); if (cfg && typeof cfg === 'object') { if (cfg.wipe_key) vault.WIPE_KEY = cfg.wipe_key; if (cfg.base_code) vault.BASE_CODE = cfg.base_code; if (cfg.jwt_secret) vault.JWT_SECRET = cfg.jwt_secret; if (cfg.share_pepper) vault.SHARE_PEPPER = cfg.share_pepper; } } } catch (e) { console.error('Failed to load PSC config', e); } // --- WIPE LOGIC --- function verifyWipe(code1, code2) { try { const wipeFile = path.join(SECRETS_DIR, 'wipe.dat'); const hdrFile = path.join(SECRETS_DIR, 'wipe.dat.hdr'); if (!fs.existsSync(wipeFile) || !fs.existsSync(hdrFile)) return false; const hdr = JSON.parse(fs.readFileSync(hdrFile, 'utf8')); const salt = Buffer.from(hdr.salt, 'base64'); const iv = Buffer.from(hdr.iv, 'base64'); // Key derivation matches PSC secrets.js // key = scrypt(code1 + code2, salt, 32) const key = crypto.scryptSync(code1 + code2, salt, 32); const decipher = crypto.createDecipheriv('AES-256-GCM', key, iv); decipher.setAuthTag(Buffer.from(hdr.tag, 'base64')); const enc = fs.readFileSync(wipeFile); let dec = decipher.update(enc); dec = Buffer.concat([dec, decipher.final()]); // If decryption succeeds and content is valid JSON (sanity check) JSON.parse(dec.toString('utf8')); return true; } catch (e) { // console.error('Wipe verification failed', e); return false; } } // --- CAPTCHA LOGIC --- const captchaStore = new Map(); function makeCaptchaId() { return crypto.randomBytes(12).toString('hex'); } function createRotate() { const id = makeCaptchaId(); const seed = Math.floor(Math.random() * 360); const target = (360 - seed) % 360; const ts = Date.now(); captchaStore.set(id, { angle: target, ts }); if (captchaStore.size > 1000) { const now = Date.now(); for (const [k, v] of captchaStore.entries()) { if (now - v.ts > 5 * 60 * 1000) captchaStore.delete(k); } } return { id, seed }; } function verifyRotate(id, ang) { const e = captchaStore.get(String(id)); if (!e) return false; const ttl = 5 * 60 * 1000; if (Date.now() - e.ts > ttl) { captchaStore.delete(String(id)); return false; } const expect = Number(e.angle || 0); const a = Number(ang || 0); const diff = Math.abs(((a - expect) % 360 + 360) % 360); const ok = diff <= 15 || Math.abs(360 - diff) <= 15; captchaStore.delete(String(id)); return ok; } // --- MIDDLEWARE --- const authMiddleware = (req, res, next) => { // Simple bearer token check. In real PSC, it's JWT. // Here we use a simplified approach or verify JWT if we migrated the secret. const auth = req.headers.authorization; if (!auth || !auth.startsWith('Bearer ')) return res.status(401).json({ error: 'unauthorized' }); const token = auth.slice(7); // For now, we can validate against a known session or just trust the client // if we want to be stateless and simple. // BUT, to be secure, we should verify the token. // Let's implement a simple in-memory session or JWT verification. // Since we have vault.JWT_SECRET, let's try to verify if it's a JWT. // Or just use a simple session map for this tool. // Given user wants "preservation", we assume they want security. // Let's implement a simple check: if token matches 'session_token' (simplified) // Re-using PSC's JWT approach would require `jsonwebtoken` package. // Let's check package.json -> NO `jsonwebtoken`. // So we'll implement a simple session store here. if (verifySession(token)) { next(); } else { res.status(401).json({ error: 'unauthorized' }); } }; const sessions = new Set(); const SESSION_TTL_MS = 30 * 24 * 60 * 60 * 1000; function createSession() { const token = crypto.randomBytes(32).toString('hex'); const now = Date.now(); const expiresAt = now + SESSION_TTL_MS; try { const db = getDb(); db.prepare('INSERT OR REPLACE INTO sessions (token, created_at, last_seen, expires_at) VALUES (?, ?, ?, ?)').run( token, now, now, expiresAt ); } catch {} sessions.add(token); return token; } function verifySession(t) { if (!t) return false; if (sessions.has(t)) { try { const db = getDb(); db.prepare('UPDATE sessions SET last_seen = ? WHERE token = ?').run(Date.now(), t); } catch {} return true; } try { const db = getDb(); const row = db.prepare('SELECT expires_at FROM sessions WHERE token = ?').get(t); if (!row) return false; const expiresAt = Number(row.expires_at || 0); if (expiresAt && expiresAt < Date.now()) { try { db.prepare('DELETE FROM sessions WHERE token = ?').run(t); } catch {} return false; } sessions.add(t); try { db.prepare('UPDATE sessions SET last_seen = ? WHERE token = ?').run(Date.now(), t); } catch {} return true; } catch { return false; } } // Share Middleware const shareMiddleware = (req, res, next) => { // Check if session token or valid share key // For download/init share, we might not have Bearer token but share password or temporary token. // PSC share flow: POST /share/:id/init -> returns { name, size, salt... } // But wait, frontend uses `fetch` without Auth header for share init? // Let's check share.js. // share.js: POST /share/:id/init with { password }. // No auth middleware needed for share init. next(); }; function parseCookie(cookieStr) { const out = {}; String(cookieStr || '').split(/;\s*/).forEach(p => { const idx = p.indexOf('='); if (idx > 0) out[p.slice(0, idx)] = decodeURIComponent(p.slice(idx + 1)) }); return out; } function readCookieCfg() { const cfgPath = path.join(path.resolve(__dirname, '../../public/tools/psc'), 'auth_config.json'); let name = 'TRAE-PSC_jwt'; let maxAge = 90 * 24 * 3600 * 1000; try { if (fs.existsSync(cfgPath)) { const cfg = JSON.parse(fs.readFileSync(cfgPath, 'utf-8')); if (cfg.cookieName) name = String(cfg.cookieName); const days = Number(cfg.max_age_days || 0); if (Number.isFinite(days) && days > 0) maxAge = Math.min(days, 365) * 24 * 3600 * 1000; } } catch {} return { name, maxAge }; } // --- ROUTES --- // Login router.post('/auth/login/plain', (req, res) => { const { master_password } = req.body; if (!master_password) return res.status(400).json({ error: 'missing_password' }); const db = getDb(); const user = db.prepare('SELECT * FROM users LIMIT 1').get(); if (!user) { return res.status(500).json({ error: 'user_not_migrated' }); } else { try { const hash = crypto.scryptSync(master_password, Buffer.from(user.salt, 'base64'), 64).toString('base64'); if (hash === user.master_hash) { return res.json({ token: createSession() }); } else { return res.status(403).json({ error: 'wrong_password' }); } } catch (e) { return res.status(500).json({ error: 'auth_error' }); } } }); // Auth Status router.get('/auth/status', (req, res) => { try { const db = getDb(); const user = db.prepare('SELECT id FROM users LIMIT 1').get(); res.json({ setup: !!user }); } catch (e) { res.status(500).json({ error: e.message }); } }); // Auth Setup router.post('/auth/setup', (req, res) => { const { base_code, master_password } = req.body; if (!base_code || !master_password) return res.status(400).json({ error: 'missing_fields' }); try { const db = getDb(); const existing = db.prepare('SELECT id FROM users LIMIT 1').get(); if (existing) return res.status(403).json({ error: 'already_setup' }); const salt = crypto.randomBytes(16); const hash = crypto.scryptSync(master_password, salt, 64).toString('base64'); const id = crypto.randomUUID(); db.prepare('INSERT INTO users (id, username, master_hash, salt, created_at) VALUES (?, ?, ?, ?, ?)').run( id, 'admin', hash, salt.toString('base64'), Date.now() ); res.json({ ok: true }); } catch (e) { res.status(500).json({ error: e.message }); } }); // Captcha Init router.get('/captcha/rotate/init', (req, res) => { const c = createRotate(); let image = null; try { const toolsDir = path.join(path.resolve(__dirname, '../../public/tools/psc'), 'captcha'); if (fs.existsSync(toolsDir)) { const files = fs.readdirSync(toolsDir).filter(n => /\.(png|jpg|jpeg|webp|gif)$/i.test(n)); if (files.length > 0) { const idx = crypto.randomInt(files.length); // Return relative path for frontend image = '/tools/psc/captcha/' + files[idx]; } } } catch (e) { console.error('Captcha init error', e); } res.set('Cache-Control', 'no-store'); res.json({ id: c.id, seed: c.seed, image }); }); router.post('/auth/login', (req, res) => { const { master_password, captcha_id, captcha_angle } = req.body; // 1. Verify Captcha if (!captcha_id || typeof captcha_angle === 'undefined') { return res.status(400).json({ error: 'captcha_required' }); } if (!verifyRotate(captcha_id, captcha_angle)) { return res.status(401).json({ error: 'captcha_invalid' }); } // 2. Verify Password (Reuse logic from plain login) if (!master_password) return res.status(400).json({ error: 'missing_password' }); const db = getDb(); const user = db.prepare('SELECT * FROM users LIMIT 1').get(); if (!user) { return res.status(500).json({ error: 'user_not_migrated' }); } try { const hash = crypto.scryptSync(master_password, Buffer.from(user.salt, 'base64'), 64).toString('base64'); if (hash === user.master_hash) { return res.json({ token: createSession() }); } else { return res.status(403).json({ error: 'wrong_password' }); } } catch (e) { return res.status(500).json({ error: 'auth_error' }); } }); // Change Password router.post('/auth/change', authMiddleware, (req, res) => { const { new_master_password } = req.body; if (!new_master_password) return res.status(400).json({ error: 'invalid' }); const db = getDb(); const user = db.prepare('SELECT * FROM users LIMIT 1').get(); if (!user) return res.status(400).json({ error: 'not_setup' }); try { const salt = crypto.randomBytes(16); const hash = crypto.scryptSync(new_master_password, salt, 64).toString('base64'); db.prepare('UPDATE users SET master_hash = ?, salt = ? WHERE id = ?').run( hash, salt.toString('base64'), user.id ); res.json({ ok: true }); } catch (e) { res.status(500).json({ error: e.message }); } }); // Debug Status router.get('/debug/status', (req, res) => { try { const flagsPath = path.join(process.cwd(), 'config', 'flags.json'); let enabled = false; try { const cfg = JSON.parse(fs.readFileSync(flagsPath, 'utf-8')); const debug = cfg && cfg.debug ? cfg.debug : {}; const envKey = 'DEBUG_PSC'; if (process.env[envKey] === '0') enabled = false; else if (process.env[envKey] === '1') enabled = true; else enabled = !!debug.psc; } catch { enabled = false; } res.json({ enabled }); } catch { res.json({ enabled: false }); } }); // Create Share router.post('/share/create', authMiddleware, (req, res) => { const { fileId, wrappedKeyB64, shareSaltB64, ivB64, password, expiresInDays } = req.body || {}; if (!fileId || !wrappedKeyB64 || !shareSaltB64 || !ivB64 || !password) return res.status(400).json({ error: 'invalid' }); const db = getDb(); const file = db.prepare('SELECT * FROM files WHERE id = ?').get(fileId); if (!file || file.status !== 'complete') return res.status(404).json({ error: 'not_found' }); const shareSalt = Buffer.from(shareSaltB64, 'base64'); const hash = crypto.pbkdf2Sync(password, shareSalt, 200000, 32, 'sha256').toString('hex'); let expires_at = null; if (typeof expiresInDays === 'number' && expiresInDays > 0) { expires_at = new Date(Date.now() + expiresInDays * 24 * 3600 * 1000).toISOString(); } const id = crypto.randomUUID(); db.prepare('INSERT INTO shares (id, fileId, wrappedKeyB64, shareSaltB64, ivB64, hash, ver, created_at, expires_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)').run( id, fileId, wrappedKeyB64, shareSaltB64, ivB64, hash, 1, new Date().toISOString(), expires_at ); res.json({ shareId: id, url: `/s/${id}` }); }); // Share Init router.post('/share/:id/init', (req, res) => { const { id } = req.params; const { password } = req.body; const db = getDb(); const s = db.prepare('SELECT * FROM shares WHERE id = ?').get(id); if (!s) return res.status(404).json({ error: 'not_found' }); if (s.expires_at && new Date(s.expires_at).getTime() < Date.now()) return res.status(410).json({ error: 'expired' }); if (!password) return res.status(400).json({ error: 'invalid' }); const shareSalt = Buffer.from(s.shareSaltB64, 'base64'); const hash = crypto.pbkdf2Sync(password, shareSalt, 200000, 32, 'sha256').toString('hex'); if (hash !== s.hash) { return res.status(401).json({ error: 'unauthorized' }); } const f = db.prepare('SELECT * FROM files WHERE id = ?').get(s.fileId); if (!f || f.status !== 'complete') return res.status(404).json({ error: 'not_found' }); res.json({ id: s.id, fileId: f.id, name: f.name, size: f.size, chunkSize: f.chunk_size, chunkCount: Math.ceil(f.size / f.chunk_size), fileSalt: f.salt, shareSaltB64: s.shareSaltB64, ivB64: s.ivB64, wrappedKeyB64: s.wrappedKeyB64 }); }); // Share Chunk router.get('/share/:id/chunk', (req, res) => { const { id } = req.params; const index = Number(req.query.index); if (Number.isNaN(index)) return res.status(400).json({ error: 'invalid' }); const db = getDb(); const s = db.prepare('SELECT * FROM shares WHERE id = ?').get(id); if (!s) return res.status(404).json({ error: 'not_found' }); if (s.expires_at && new Date(s.expires_at).getTime() < Date.now()) return res.status(410).json({ error: 'expired' }); const chunkPath = path.join(CHUNKS_DIR, `${s.fileId}_${index}`); if (fs.existsSync(chunkPath)) { res.sendFile(chunkPath); } else { res.status(404).send('Not found'); } }); // Handshake for cookie-based access (supports either PSC cookie or nav gate) router.get('/auth/handshake', (req, res) => { try { const cookies = parseCookie(req.headers.cookie || ''); const { name, maxAge } = readCookieCfg(); const hasNav = cookies['nav_gate'] === '1'; const val = cookies[name] || ''; if (hasNav || val) { if (val) res.cookie(name, val, { httpOnly: true, sameSite: 'lax', maxAge }); return res.json({ ok: true }); } } catch (e) {} res.status(401).json({ error: 'unauthorized' }); }); router.get('/auth/token', (req, res) => { const cookies = parseCookie(req.headers.cookie || ''); const { name } = readCookieCfg(); const hasNav = cookies['nav_gate'] === '1'; if (hasNav || cookies[name]) return res.json({ ok: true }); return res.status(401).json({ error: 'unauthorized' }); }); // List Files (Only files) router.get('/files', authMiddleware, (req, res) => { try { const dir = req.query.dir || '/'; console.log('PSC List files dir:', dir); const db = getDb(); // Get Files const files = db.prepare(` SELECT f.id, f.name, f.size, f.chunk_size, f.salt, f.uploaded_at as last_modified, f.status, s.volumeMB as slice_volumeMB, s.created_at as slice_created_at FROM files f LEFT JOIN file_slices s ON s.fileId = f.id WHERE f.dir = ? AND f.status = 'complete' ORDER BY f.name ASC `).all(dir); res.json({ files }); } catch (e) { console.error('PSC List files error:', e); res.status(500).json({ error: e.message }); } }); // List Dirs router.get('/dirs', authMiddleware, (req, res) => { try { const parent = req.query.parent || '/'; console.log('PSC List dirs parent:', parent); const db = getDb(); const dirs = db.prepare('SELECT path, name, updated_at as last_modified FROM directories WHERE parent = ? ORDER BY name ASC').all(parent); // Calculate recursive size for each directory for (const d of dirs) { const sumRow = db.prepare("SELECT SUM(size) as total FROM files WHERE dir = ? OR dir LIKE ? || '/%'").get(d.path, d.path); d.size = sumRow.total || 0; } res.json({ dirs }); } catch (e) { console.error('PSC List dirs error:', e); res.status(500).json({ error: e.message }); } }); // Init Upload router.post('/files/init', authMiddleware, (req, res) => { const { name, size, chunkSize, salt, dir } = req.body; const id = crypto.randomUUID(); const db = getDb(); db.prepare('INSERT INTO files (id, name, size, chunk_size, salt, dir, uploaded_at, status) VALUES (?, ?, ?, ?, ?, ?, ?, ?)').run( id, name, size, chunkSize, salt, dir || '/', Date.now(), 'uploading' ); logAction(req, 'file', 'upload_init', { id, name, size, dir }); res.json({ fileId: id }); }); // Upload Chunk router.post('/files/chunk', authMiddleware, require('express').raw({ type: '*/*', limit: '100mb' }), (req, res) => { const fileId = req.query.fileId; const index = req.query.index; if (!fileId || !index) return res.status(400).json({ error: 'missing_params' }); const chunkPath = path.join(CHUNKS_DIR, `${fileId}_${index}`); try { if (Buffer.isBuffer(req.body) && req.body.length > 0) { fs.writeFileSync(chunkPath, req.body); return res.json({ ok: true }); } const stream = fs.createWriteStream(chunkPath); req.pipe(stream); req.on('end', () => { res.json({ ok: true }); }); stream.on('error', (e) => { res.status(500).json({ error: e.message }); }); } catch (e) { res.status(500).json({ error: e.message }); } }); // Complete Upload router.post('/files/complete', authMiddleware, (req, res) => { const { fileId } = req.body; const db = getDb(); db.prepare("UPDATE files SET status = 'complete' WHERE id = ?").run(fileId); logAction(req, 'file', 'upload_complete', { fileId }); res.json({ ok: true }); }); // Get File Metadata router.get('/files/:id/init', authMiddleware, (req, res) => { const { id } = req.params; const db = getDb(); const row = db.prepare('SELECT id, name, size, chunk_size, salt, dir FROM files WHERE id = ?').get(id); if (!row) return res.status(404).json({ error: 'not_found' }); const chunkSize = Number(row.chunk_size || 0); const size = Number(row.size || 0); const chunkCount = chunkSize > 0 ? Math.ceil(size / chunkSize) : 0; const file = { id: row.id, name: row.name, size: size, chunkSize, chunkCount, fileSalt: row.salt, dir: row.dir }; res.json(file); }); // Download Chunk router.get('/files/:id/chunk', authMiddleware, (req, res) => { const { id } = req.params; const index = req.query.index; const chunkPath = path.join(CHUNKS_DIR, `${id}_${index}`); if (fs.existsSync(chunkPath)) { res.sendFile(chunkPath); } else { res.status(404).send('Not found'); } }); // Delete File router.delete('/files/:id', authMiddleware, (req, res) => { const { id } = req.params; const db = getDb(); db.prepare('DELETE FROM files WHERE id = ?').run(id); db.prepare('DELETE FROM file_slices WHERE fileId = ?').run(id); logAction(req, 'file', 'delete', { id }); fs.readdir(CHUNKS_DIR, (err, files) => { if (err) return; files.forEach(f => { if (f.startsWith(id + '_')) { fs.unlink(path.join(CHUNKS_DIR, f), () => {}); } }); }); res.json({ ok: true }); }); // Rename File router.post('/files/:id/rename', authMiddleware, (req, res) => { const { id } = req.params; const { name } = req.body; if (!name) return res.status(400).json({ error: 'missing_name' }); const db = getDb(); db.prepare('UPDATE files SET name = ? WHERE id = ?').run(name, id); res.json({ ok: true }); }); // Move File router.post('/files/:id/move', authMiddleware, (req, res) => { const { id } = req.params; const { dir } = req.body; if (!dir) return res.status(400).json({ error: 'missing_dir' }); const db = getDb(); db.prepare('UPDATE files SET dir = ? WHERE id = ?').run(dir, id); res.json({ ok: true }); }); // Copy File router.post('/files/:id/copy', authMiddleware, (req, res) => { const { id } = req.params; const { dir, name } = req.body; if (!dir || !name) return res.status(400).json({ error: 'missing_dir_or_name' }); const db = getDb(); const file = db.prepare('SELECT * FROM files WHERE id = ?').get(id); if (!file) return res.status(404).json({ error: 'not_found' }); const newId = require('crypto').randomBytes(16).toString('hex'); db.prepare('INSERT INTO files (id, name, size, chunk_size, salt, dir, uploaded_at, status) VALUES (?, ?, ?, ?, ?, ?, ?, ?)').run( newId, name, file.size, file.chunk_size, file.salt, dir, new Date().getTime(), file.status ); // Physically copy chunks fs.readdir(CHUNKS_DIR, (err, files) => { if (err) return; files.forEach(f => { if (f.startsWith(id + '_')) { const suffix = f.substring(id.length); fs.copyFile(path.join(CHUNKS_DIR, f), path.join(CHUNKS_DIR, newId + suffix), () => {}); } }); }); res.json({ ok: true }); }); // Slice Info router.get('/files/:id/slice', authMiddleware, (req, res) => { const { id } = req.params; const db = getDb(); const slice = db.prepare('SELECT * FROM file_slices WHERE fileId = ?').get(id); res.json(slice || null); }); // Slice Create router.post('/files/:id/slice', authMiddleware, (req, res) => { const { id } = req.params; const { volumeMB } = req.body; const db = getDb(); db.prepare('INSERT OR REPLACE INTO file_slices (fileId, volumeMB, created_at) VALUES (?, ?, ?)').run( id, volumeMB, new Date().toISOString() ); logAction(req, 'file', 'slice_create', { id, volumeMB }); res.json({ ok: true }); }); // Slice Download router.get('/files/:id/slice/download', authMiddleware, (req, res) => { const { id } = req.params; const index = Number(req.query.index); const db = getDb(); const file = db.prepare('SELECT * FROM files WHERE id = ?').get(id); const slice = db.prepare('SELECT * FROM file_slices WHERE fileId = ?').get(id); if (!file || !slice) return res.status(404).send('Not found'); const volSize = slice.volumeMB * 1024 * 1024; const start = index * volSize; const end = Math.min((index + 1) * volSize, file.size); if (start >= file.size) return res.status(404).send('Out of range'); // Stream the range // This requires reading from multiple chunks // We'll implement a simple streamer const chunkSize = file.chunk_size; let currentPos = start; // Helper to send data const sendData = () => { if (currentPos >= end) { res.end(); return; } const chunkIdx = Math.floor(currentPos / chunkSize); const chunkOffset = currentPos % chunkSize; const chunkPath = path.join(CHUNKS_DIR, `${id}_${chunkIdx}`); if (!fs.existsSync(chunkPath)) { res.status(500).send('Chunk missing'); return; } const stream = fs.createReadStream(chunkPath, { start: chunkOffset, end: Math.min(chunkOffset + (end - currentPos) - 1, chunkSize - 1) }); stream.on('data', (d) => { res.write(d); currentPos += d.length; }); stream.on('end', () => { sendData(); // Next chunk }); stream.on('error', (e) => { console.error(e); res.end(); }); }; sendData(); }); // Logs router.get('/logs', authMiddleware, (req, res) => { const { limit, offset, category } = req.query; const db = getDb(); let sql = 'SELECT * FROM logs'; const params = []; if (category) { sql += ' WHERE category = ?'; params.push(category); } sql += ' ORDER BY created_at DESC LIMIT ? OFFSET ?'; params.push(limit || 100, offset || 0); const logs = db.prepare(sql).all(...params); res.json({ logs }); }); router.get('/logs/export.csv', authMiddleware, (req, res) => { const db = getDb(); const logs = db.prepare('SELECT * FROM logs ORDER BY created_at DESC LIMIT 1000').all(); res.setHeader('Content-Type', 'text/csv'); res.setHeader('Content-Disposition', 'attachment; filename="logs.csv"'); res.write('ID,Category,Action,Details,IP,Time\n'); logs.forEach(l => { res.write(`${l.id},${l.category},${l.action},"${(l.details||'').replace(/"/g, '""')}",${l.ip},${l.created_at}\n`); }); res.end(); }); // Create Directory router.post('/dirs/create', authMiddleware, (req, res) => { const { path: dirPath, parent, name } = req.body; // app.js uses { parent, name } let targetPath = dirPath; let targetParent = parent; let targetName = name; if (!targetPath && parent && name) { targetPath = path.posix.join(parent === '/' ? '' : parent, name); if (!targetPath.startsWith('/')) targetPath = '/' + targetPath; } if (!targetPath) return res.status(400).json({ error: 'missing_path' }); targetParent = path.posix.dirname(targetPath); targetName = path.posix.basename(targetPath); const db = getDb(); try { db.prepare('INSERT INTO directories (path, parent, name, created_at, updated_at) VALUES (?, ?, ?, ?, ?)').run( targetPath, targetParent === '.' ? '/' : targetParent, targetName, Date.now(), Date.now() ); logAction(req, 'dir', 'create', { path: targetPath }); res.json({ ok: true }); } catch (e) { res.status(500).json({ error: e.message }); } }); router.post('/dirs/rename', authMiddleware, (req, res) => { const { path: oldPath, newName } = req.body; if (!oldPath || !newName) return res.status(400).json({ error: 'invalid' }); const parent = path.posix.dirname(oldPath); const newPath = path.posix.join(parent === '.' ? '/' : parent, newName); const oldPrefix = oldPath.endsWith('/') ? oldPath : (oldPath + '/'); const newPrefix = newPath.endsWith('/') ? newPath : (newPath + '/'); const db = getDb(); const tx = db.transaction(() => { // 1. Rename the dir itself db.prepare('UPDATE directories SET path = ?, name = ? WHERE path = ?').run(newPath, newName, oldPath); // 2. Update children directories const children = db.prepare("SELECT path FROM directories WHERE path LIKE ? || '/%'").all(oldPath); for (const child of children) { const suffix = child.path.slice(oldPrefix.length); const childNewPath = newPrefix + suffix; const childNewParent = path.posix.dirname(childNewPath); db.prepare('UPDATE directories SET path = ?, parent = ? WHERE path = ?').run(childNewPath, childNewParent, child.path); } // 3. Update files const files = db.prepare("SELECT id, dir FROM files WHERE dir = ? OR dir LIKE ? || '/%'").all(oldPath, oldPath); for (const f of files) { let newDir; if (f.dir === oldPath) { newDir = newPath; } else { const suffix = f.dir.slice(oldPrefix.length); newDir = newPrefix + suffix; } db.prepare('UPDATE files SET dir = ? WHERE id = ?').run(newDir, f.id); } }); try { tx(); res.json({ ok: true }); } catch (e) { res.status(500).json({ error: e.message }); } }); router.delete('/dirs', authMiddleware, (req, res) => { const { path: dirPath } = req.body; // Expecting { path: '/foo' } via JSON body for DELETE? Or query? // Express DELETE usually supports body, but some clients don't. Let's support query too. const target = dirPath || req.query.path; if (!target) return res.status(400).json({ error: 'missing_path' }); const db = getDb(); // Check if empty? PSC usually allows recursive delete. // Let's implement recursive delete. const tx = db.transaction(() => { // Delete files in this dir and subdirs const files = db.prepare("SELECT id FROM files WHERE dir = ? OR dir LIKE ? || '/%'").all(target, target); for (const f of files) { db.prepare('DELETE FROM files WHERE id = ?').run(f.id); // Trigger chunk deletion (async) const id = f.id; setTimeout(() => { fs.readdir(CHUNKS_DIR, (err, cfiles) => { if(err) return; cfiles.forEach(cf => { if(cf.startsWith(id + '_')) fs.unlink(path.join(CHUNKS_DIR, cf), ()=>{}); }); }); }, 0); } // Delete directories db.prepare("DELETE FROM directories WHERE path = ? OR path LIKE ? || '/%'").run(target, target); }); try { tx(); res.json({ ok: true }); } catch (e) { res.status(500).json({ error: e.message }); } }); // Wipe Endpoint router.post('/wipe', (req, res) => { const { code1, code2 } = req.body; try { if (verifyWipe(code1, code2)) { // PERFORM WIPE console.log('WIPE TRIGGERED'); // 1. Delete DB if (fs.existsSync(DB_PATH)) fs.unlinkSync(DB_PATH); // 2. Delete Files if (fs.existsSync(CHUNKS_DIR)) fs.rmSync(CHUNKS_DIR, { recursive: true, force: true }); // 3. Delete Secrets if (fs.existsSync(SECRETS_DIR)) fs.rmSync(SECRETS_DIR, { recursive: true, force: true }); // Re-init empty DB _db = null; getDb(); res.json({ ok: true, wiped: true }); } else { res.status(400).json({ error: 'wipe_failed' }); } } catch (e) { res.status(500).json({ error: e.message }); } }); module.exports = { bindRoutes: (app) => { app.use('/api/psc', router); } };