Files

1053 lines
33 KiB
JavaScript

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);
}
};