1053 lines
33 KiB
JavaScript
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);
|
|
}
|
|
};
|