// ========================================== // PIXELDRIVE v4.6 - SERVER.JS (ESM) - RENAME/MOVE + QUOTAS // ========================================== import express from 'express'; import session from 'express-session'; import Database from 'better-sqlite3'; import multer from 'multer'; import { createHash, randomBytes, scrypt, createCipheriv, createDecipheriv } from 'crypto'; import { promisify } from 'util'; import { createReadStream, unlinkSync, existsSync, readdirSync, statSync, rmSync, copyFileSync, mkdirSync } from 'fs'; import { join } from 'path'; import { v4 as uuidv4 } from 'uuid'; import LRUCache from 'quick-lru'; import sharp from 'sharp'; import * as tf from '@tensorflow/tfjs'; import * as nsfwjs from 'nsfwjs'; import axios from 'axios'; import FormData from 'form-data'; import { fileURLToPath } from 'url'; const __dirname = fileURLToPath(new URL('.', import.meta.url)); const PUBLIC_DIR = join(__dirname, 'public'); const PORT = 7860; const DATA_DIR = '/data'; const TEMP_DIR = join(DATA_DIR, 'temp'); const THUMB_DIR = join(DATA_DIR, 'thumbs'); const BACKUP_DIR = join(DATA_DIR, 'backup'); const DB_PATH = join(DATA_DIR, 'pixeldrive.db'); const CHUNK_SIZE = 30 * 1024 * 1024; const MAX_CONCURRENT_UPLOADS = 2; const CACHE_MAX_BYTES = 12 * 1024 * 1024 * 1024; const KEY_LEN = 32; const IV_LEN = 12; const TAG_LEN = 16; const OVERHEAD = IV_LEN + TAG_LEN; const IS_PROD = process.env.NODE_ENV === 'production'; const USER_QUOTA = parseInt(process.env.USER_QUOTA_GB || '50', 10) * 1024 * 1024 * 1024; const DAILY_TRANSFER = parseInt(process.env.DAILY_TRANSFER_GB || '100', 10) * 1024 * 1024 * 1024; const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; function log(level, msg) { const n = new Date(); const ts = [n.getHours(), n.getMinutes(), n.getSeconds()].map(x => String(x).padStart(2, '0')).join(':'); const line = `[${ts}] [${level}] ${msg}`; if (level === 'ERROR' || level === 'FATAL ERROR') console.error(line); else if (level === 'WARN' || level === 'ALERT') console.warn(line); else console.log(line); } const IMGBB_API_KEY = process.env.IMGBB_API_KEY; const SESSION_SECRET = process.env.SESSION_SECRET; const ADMIN_USER = process.env.ADMIN_USER || 'admin'; if (!IMGBB_API_KEY || !SESSION_SECRET) { log('FATAL ERROR', 'Variables manquantes: IMGBB_API_KEY, SESSION_SECRET'); process.exit(1); } const MASTER_SECRET = process.env.MASTER_SECRET || SESSION_SECRET; if (process.env.MASTER_SECRET) log('INFO', 'MASTER_SECRET personnalisé actif'); else log('WARN', 'MASTER_SECRET non défini → clé maître dérivée de SESSION_SECRET'); const db = new Database(DB_PATH); db.pragma('journal_mode = WAL'); db.pragma('busy_timeout = 5000'); const scryptAsync = promisify(scrypt); async function deriveKey(password, salt) { return scryptAsync(password, salt, KEY_LEN); } function encryptChunk(dataBuffer, key) { const iv = randomBytes(IV_LEN); const cipher = createCipheriv('aes-256-gcm', key, iv); const encrypted = Buffer.concat([cipher.update(dataBuffer), cipher.final()]); const tag = cipher.getAuthTag(); return Buffer.concat([iv, encrypted, tag]); } function decryptChunk(encBuffer, key) { if (encBuffer.length < OVERHEAD) throw new Error('Buffer trop court'); const iv = encBuffer.subarray(0, IV_LEN); const tag = encBuffer.subarray(encBuffer.length - TAG_LEN); const data = encBuffer.subarray(IV_LEN, encBuffer.length - TAG_LEN); const decipher = createDecipheriv('aes-256-gcm', key, iv); decipher.setAuthTag(tag); return Buffer.concat([decipher.update(data), decipher.final()]); } function esc(s) { return String(s).replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"').replace(/'/g,'''); } function bufferToPng(buffer) { const byteLen = buffer.length; const pixelsNeeded = Math.ceil(byteLen / 3); const width = Math.ceil(Math.sqrt(pixelsNeeded)); const height = Math.ceil(pixelsNeeded / width); const rgbBuffer = Buffer.alloc(width * height * 3); buffer.copy(rgbBuffer); return sharp(rgbBuffer, { raw: { width, height, channels: 3 } }).png({ compressionLevel: 9, palette: false }).withMetadata(false).toBuffer(); } async function pngToBuffer(pngBuffer, expectedLen) { const { data, info } = await sharp(pngBuffer).raw().toBuffer({ resolveWithObject: true }); const channels = info.channels; const rgb = Buffer.alloc(info.width * info.height * 3); if (channels === 3) data.copy(rgb, 0, 0, Math.min(data.length, rgb.length)); else if (channels === 4) { for (let i = 0, j = 0; i < data.length && j < rgb.length; i += 4, j += 3) { rgb[j] = data[i]; rgb[j+1] = data[i+1]; rgb[j+2] = data[i+2]; } } else if (channels === 1) { for (let i = 0, j = 0; i < data.length && j < rgb.length; i++, j += 3) { rgb[j] = rgb[j+1] = rgb[j+2] = data[i]; } } return rgb.subarray(0, expectedLen); } db.exec(` CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT UNIQUE NOT NULL, password_hash BLOB NOT NULL, salt BLOB NOT NULL, is_admin INTEGER DEFAULT 0, tos_accepted INTEGER DEFAULT 0, avatar BLOB, created_at INTEGER DEFAULT (strftime('%s','now'))); CREATE TABLE IF NOT EXISTS folders (id INTEGER PRIMARY KEY AUTOINCREMENT, parent_id INTEGER REFERENCES folders(id) ON DELETE CASCADE, owner_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, name TEXT NOT NULL, created_at INTEGER DEFAULT (strftime('%s','now')), UNIQUE(parent_id, owner_id, name)); CREATE TABLE IF NOT EXISTS files (id TEXT PRIMARY KEY, folder_id INTEGER REFERENCES folders(id) ON DELETE SET NULL, owner_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, name TEXT NOT NULL, mime TEXT NOT NULL, size INTEGER NOT NULL, chunk_count INTEGER DEFAULT 1, status TEXT DEFAULT 'pending', progress REAL DEFAULT 0, error_msg TEXT, encryption_key_hash TEXT NOT NULL, master_key_enc BLOB, created_at INTEGER DEFAULT (strftime('%s','now')), updated_at INTEGER DEFAULT (strftime('%s','now'))); CREATE TABLE IF NOT EXISTS file_chunks (id INTEGER PRIMARY KEY AUTOINCREMENT, file_id TEXT NOT NULL REFERENCES files(id) ON DELETE CASCADE, chunk_index INTEGER NOT NULL, imgbb_url TEXT, imgbb_delete_url TEXT, imgbb_id TEXT, size INTEGER NOT NULL, encrypted_len INTEGER NOT NULL, UNIQUE(file_id, chunk_index)); CREATE TABLE IF NOT EXISTS share_links (token TEXT PRIMARY KEY, file_id TEXT NOT NULL REFERENCES files(id) ON DELETE CASCADE, owner_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, type TEXT NOT NULL, created_at INTEGER DEFAULT (strftime('%s','now')), expires_at INTEGER); CREATE TABLE IF NOT EXISTS user_shares (id INTEGER PRIMARY KEY AUTOINCREMENT, file_id TEXT REFERENCES files(id) ON DELETE CASCADE, folder_id INTEGER REFERENCES folders(id) ON DELETE CASCADE, owner_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, shared_with INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, created_at INTEGER DEFAULT (strftime('%s','now'))); CREATE TABLE IF NOT EXISTS transfer_daily (id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, day TEXT NOT NULL, up_bytes INTEGER DEFAULT 0, down_bytes INTEGER DEFAULT 0, UNIQUE(user_id, day)); CREATE INDEX IF NOT EXISTS idx_files_owner ON files(owner_id); CREATE INDEX IF NOT EXISTS idx_chunks_file ON file_chunks(file_id); CREATE INDEX IF NOT EXISTS idx_share_owner ON share_links(owner_id); CREATE INDEX IF NOT EXISTS idx_ushares_with ON user_shares(shared_with); CREATE INDEX IF NOT EXISTS idx_transfer_day ON transfer_daily(user_id, day); `); function runMigrations() { log('INFO', 'Vérification migrations BDD...'); const usersCols = db.prepare("PRAGMA table_info(users)").all().map(c => c.name); if (!usersCols.includes('tos_accepted')) db.exec(`ALTER TABLE users ADD COLUMN tos_accepted INTEGER DEFAULT 0;`); if (!usersCols.includes('avatar')) db.exec(`ALTER TABLE users ADD COLUMN avatar BLOB;`); const filesCols = db.prepare("PRAGMA table_info(files)").all().map(c => c.name); const filesDefs = [ { name: 'folder_id', def: 'INTEGER REFERENCES folders(id) ON DELETE SET NULL' }, { name: 'master_key_enc', def: 'BLOB' }, { name: 'encryption_key_hash', def: 'TEXT' }, { name: 'status', def: "TEXT DEFAULT 'pending'" }, { name: 'progress', def: 'REAL DEFAULT 0' }, { name: 'error_msg', def: 'TEXT' }, { name: 'chunk_count', def: 'INTEGER DEFAULT 1' }, { name: 'updated_at', def: 'INTEGER DEFAULT 0' }, ]; let updatedAdded = false; for (const col of filesDefs) { if (!filesCols.includes(col.name)) { log('INFO', `Migration: ALTER TABLE files ADD COLUMN ${col.name}`); db.exec(`ALTER TABLE files ADD COLUMN ${col.name} ${col.def};`); if (col.name === 'updated_at') updatedAdded = true; } } if (updatedAdded) db.exec(`UPDATE files SET updated_at = strftime('%s','now') WHERE updated_at = 0;`); const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table'").all().map(t => t.name); if (!tables.includes('folders')) db.exec(`CREATE TABLE folders (id INTEGER PRIMARY KEY AUTOINCREMENT, parent_id INTEGER REFERENCES folders(id) ON DELETE CASCADE, owner_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, name TEXT NOT NULL, created_at INTEGER DEFAULT (strftime('%s','now')), UNIQUE(parent_id, owner_id, name));`); if (!tables.includes('share_links')) db.exec(`CREATE TABLE share_links (token TEXT PRIMARY KEY, file_id TEXT NOT NULL REFERENCES files(id) ON DELETE CASCADE, owner_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, type TEXT NOT NULL, created_at INTEGER DEFAULT (strftime('%s','now')), expires_at INTEGER);`); if (!tables.includes('user_shares')) db.exec(`CREATE TABLE user_shares (id INTEGER PRIMARY KEY AUTOINCREMENT, file_id TEXT REFERENCES files(id) ON DELETE CASCADE, folder_id INTEGER REFERENCES folders(id) ON DELETE CASCADE, owner_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, shared_with INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, created_at INTEGER DEFAULT (strftime('%s','now'))); CREATE INDEX idx_ushares_with ON user_shares(shared_with);`); if (!tables.includes('transfer_daily')) db.exec(`CREATE TABLE transfer_daily (id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, day TEXT NOT NULL, up_bytes INTEGER DEFAULT 0, down_bytes INTEGER DEFAULT 0, UNIQUE(user_id, day)); CREATE INDEX idx_transfer_day ON transfer_daily(user_id, day);`); const sessionsCols = tables.includes('sessions') ? db.prepare("PRAGMA table_info(sessions)").all().map(c => c.name) : []; if (tables.includes('sessions') && (!sessionsCols.includes('data') || !sessionsCols.includes('expires_at'))) { log('WARN', 'Table sessions invalide → reconstruction'); db.exec(`DROP TABLE sessions; CREATE TABLE sessions (sid TEXT PRIMARY KEY, data TEXT NOT NULL, expires_at INTEGER NOT NULL); CREATE INDEX idx_sessions_expires ON sessions(expires_at);`); } else if (!tables.includes('sessions')) { db.exec(`CREATE TABLE sessions (sid TEXT PRIMARY KEY, data TEXT NOT NULL, expires_at INTEGER NOT NULL); CREATE INDEX idx_sessions_expires ON sessions(expires_at);`); } log('INFO', 'Migrations terminées.'); } runMigrations(); db.exec(` CREATE INDEX IF NOT EXISTS idx_files_folder ON files(folder_id); DROP TRIGGER IF EXISTS update_file_ts; CREATE TRIGGER update_file_ts AFTER UPDATE ON files BEGIN UPDATE files SET updated_at = strftime('%s','now') WHERE id = NEW.id; END; `); // --- QUOTA TRANSIT --- function todayKey() { return new Date().toISOString().slice(0, 10); } function getTransfer(userId) { const r = db.prepare('SELECT up_bytes, down_bytes FROM transfer_daily WHERE user_id=? AND day=?').get(userId, todayKey()); return { up: r?.up_bytes || 0, down: r?.down_bytes || 0, total: (r?.up_bytes || 0) + (r?.down_bytes || 0) }; } function addTransfer(userId, bytes, dir) { if (!userId || !bytes || bytes <= 0) return; const col = dir === 'up' ? 'up_bytes' : 'down_bytes'; try { db.prepare(`INSERT INTO transfer_daily(user_id, day, up_bytes, down_bytes) VALUES(?,?,?,?) ON CONFLICT(user_id, day) DO UPDATE SET ${col} = ${col} + excluded.${col}`) .run(userId, todayKey(), dir === 'up' ? bytes : 0, dir === 'down' ? bytes : 0); } catch (e) { log('WARN', `addTransfer: ${e.message}`); } } function transferBlocked(userId) { return getTransfer(userId).total >= DAILY_TRANSFER; } // --- NETTOYAGE + BACKUPS --- function cleanTemp() { try { const now = Date.now(); for (const f of readdirSync(TEMP_DIR)) { const p = join(TEMP_DIR, f); if (now - statSync(p).mtimeMs > 24 * 3600 * 1000) { rmSync(p, { force: true }); log('INFO', `Temp nettoyé: ${f}`); } } } catch (e) { log('WARN', `Temp cleanup: ${e.message}`); } } function backupDb() { try { mkdirSync(BACKUP_DIR, { recursive: true }); db.pragma('wal_checkpoint(TRUNCATE)'); const name = `pixeldrive-${Date.now()}.db`; copyFileSync(DB_PATH, join(BACKUP_DIR, name)); const olds = readdirSync(BACKUP_DIR).filter(f => f.startsWith('pixeldrive-') && f.endsWith('.db')).sort(); while (olds.length > 3) rmSync(join(BACKUP_DIR, olds.shift()), { force: true }); log('INFO', `Backup BDD: ${name}`); } catch (e) { log('ERROR', `Backup échoué: ${e.message}`); } } function cleanErrorFiles() { try { const cutoff = Math.floor(Date.now() / 1000) - 7 * 86400; const olds = db.prepare("SELECT id FROM files WHERE status='error' AND updated_at < ?").all(cutoff); for (const f of olds) { db.prepare('DELETE FROM files WHERE id=?').run(f.id); try { unlinkSync(join(THUMB_DIR, `${f.id}.jpg`)); } catch (_) {} } if (olds.length) log('INFO', `Cleanup: ${olds.length} fichier(s) en erreur supprimé(s)`); } catch (e) { log('WARN', `Error-files cleanup: ${e.message}`); } } function cleanOldTransfers() { try { const old = new Date(Date.now() - 3 * 86400000).toISOString().slice(0, 10); const r = db.prepare('DELETE FROM transfer_daily WHERE day < ?').run(old); if (r.changes) log('INFO', `Cleanup: ${r.changes} ligne(s) transfer_daily`); } catch (e) { log('WARN', `Transfer cleanup: ${e.message}`); } } mkdirSync(TEMP_DIR, { recursive: true }); mkdirSync(THUMB_DIR, { recursive: true }); cleanTemp(); setInterval(() => { cleanTemp(); cleanErrorFiles(); cleanOldTransfers(); }, 3600000); setInterval(backupDb, 6 * 3600000); setTimeout(backupDb, 60000); // --- RATE LIMITER --- function rateLimit({ windowMs = 60000, max = 100, message = 'Trop de requêtes, réessayez plus tard' } = {}) { const hits = new Map(); setInterval(() => { const now = Date.now(); for (const [k, v] of hits) if (now - v.start > windowMs) hits.delete(k); }, Math.max(windowMs, 60000)); return (req, res, next) => { const key = (req.session && req.session.userId ? 'u' + req.session.userId : 'ip' + req.ip) + '|' + req.path; const now = Date.now(); let h = hits.get(key); if (!h || now - h.start > windowMs) { h = { start: now, count: 0 }; hits.set(key, h); } h.count++; if (h.count > max) { log('ALERT', `Rate limit dépassé: ${key} (${h.count}/${max})`); return res.status(429).json({ error: message }); } next(); }; } const globalLimit = rateLimit({ windowMs: 60000, max: 600 }); const loginLimit = rateLimit({ windowMs: 15*60000, max: 10, message: 'Trop de tentatives de connexion' }); const registerLimit = rateLimit({ windowMs: 60*60000, max: 5, message: 'Trop de comptes créés' }); const uploadLimit = rateLimit({ windowMs: 60*60000, max: 30, message: 'Quota upload dépassé' }); const shareLimit = rateLimit({ windowMs: 60*60000, max: 50, message: 'Trop de partages' }); const avatarLimit = rateLimit({ windowMs: 60*60000, max: 10, message: 'Trop de changements d\'avatar' }); // --- CSRF --- function csrfGuard(req, res, next) { if (['GET', 'HEAD', 'OPTIONS'].includes(req.method)) return next(); const origin = req.headers.origin; if (origin) { let oHost; try { oHost = new URL(origin).host; } catch (e) { return res.status(403).json({ error: 'Origin invalide' }); } if (oHost !== req.get('host')) { log('WARN', `CSRF bloqué: origin=${origin}`); return res.status(403).json({ error: 'Requête cross-origin refusée' }); } } next(); } class SQLiteStore extends session.Store { get(sid, cb) { try { const r = db.prepare('SELECT data, expires_at FROM sessions WHERE sid=?').get(sid); if(!r) return cb(null, null); if(r.expires_at < Date.now()){ db.prepare('DELETE FROM sessions WHERE sid=?').run(sid); return cb(null, null); } cb(null, JSON.parse(r.data)); } catch(e){ cb(e); } } set(sid, sess, cb) { try { const exp = sess.cookie?.expires ? new Date(sess.cookie.expires).getTime() : Date.now() + 30*24*60*60*1000; db.prepare('INSERT OR REPLACE INTO sessions(sid,data,expires_at) VALUES(?,?,?)').run(sid, JSON.stringify(sess), exp); cb(null); } catch(e){ cb(e); } } destroy(sid, cb) { try { db.prepare('DELETE FROM sessions WHERE sid=?').run(sid); cb(null); } catch(e){ cb(e); } } clear(cb) { try { db.prepare('DELETE FROM sessions').run(); cb(null); } catch(e){ cb(e); } } length(cb) { try { cb(null, db.prepare('SELECT COUNT(*) as c FROM sessions').get().c); } catch(e){ cb(e); } } touch(sid, sess, cb) { this.set(sid, sess, cb); } } const sessionStore = new SQLiteStore(); const adminRow = db.prepare('SELECT * FROM users WHERE username=?').get(ADMIN_USER); if (!adminRow) { const salt = randomBytes(16); const hash = await deriveKey(process.env.ADMIN_PASS || 'ChangeMeNow!', salt); db.prepare('INSERT INTO users(username,password_hash,salt,is_admin,tos_accepted) VALUES(?,?,?,1,1)').run(ADMIN_USER, hash, salt); log('INFO', `Compte admin créé: ${ADMIN_USER}`); } const app = express(); app.set('trust proxy', 1); app.use((req, res, next) => { res.set({ 'X-Content-Type-Options': 'nosniff', 'X-Frame-Options': 'SAMEORIGIN', 'Referrer-Policy': 'strict-origin-when-cross-origin', 'Permissions-Policy': 'camera=(), microphone=(), geolocation=()', 'Cross-Origin-Opener-Policy': 'same-origin', 'Content-Security-Policy': "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; media-src 'self'; connect-src 'self'; frame-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'", }); if (req.secure || req.get('x-forwarded-proto') === 'https') res.set('Strict-Transport-Security', 'max-age=31536000; includeSubDomains'); next(); }); app.use(express.json({ limit: '50mb' })); app.use(express.urlencoded({ extended: true, limit: '50mb' })); app.use(session({ secret: SESSION_SECRET, store: sessionStore, resave: false, saveUninitialized: false, cookie: { secure: IS_PROD, httpOnly: true, maxAge: 30*24*60*60*1000, sameSite: 'lax' } })); app.use('/api', csrfGuard); app.use('/api', globalLimit); const requireAuth = (req, res, next) => { if(!req.session.userId) return res.status(401).json({error:'Non authentifié'}); next(); }; const requireTos = (req, res, next) => { if(!req.session.tosAccepted) return res.status(403).json({error:'CGU non acceptées', needTos:true}); next(); }; class ByteLRUCache extends LRUCache { constructor(){ super({maxSize:CACHE_MAX_BYTES, sizeCalculation:v=>v.size}); } } const fileCache = new ByteLRUCache(); setInterval(()=>{ const m = process.memoryUsage(); if(m.rss > 13*1024*1024*1024){ log('ALERT', `RAM ${(m.rss/1e9).toFixed(1)}GB → vidage du cache`); fileCache.clear(); if(global.gc) global.gc(); } }, 30000); setInterval(() => { const now = Date.now(); const s = db.prepare('DELETE FROM sessions WHERE expires_at < ?').run(now); const l = db.prepare('DELETE FROM share_links WHERE expires_at IS NOT NULL AND expires_at < ?').run(now); if (s.changes || l.changes) log('INFO', `Cleanup: ${s.changes} session(s), ${l.changes} lien(s) expiré(s)`); }, 3600000); let nsfwModel = null; async function loadNsfwModel(){ try{ await tf.setBackend('wasm'); tf.wasm.setWasmPaths('/app/tfjs_wasm/','tfjs-backend-wasm.wasm'); await tf.ready(); nsfwModel = await nsfwjs.load('/app/tfjs_wasm/',{type:'mobilenet_v2',size:224}); log('INFO', 'NSFW WASM chargé'); }catch(e){ log('WARN', `NSFW load fail: ${e.message}`); } } loadNsfwModel(); async function checkNsfw(buf){ if(!nsfwModel) return {safe:true}; try{ const t=tf.node.decodeImage(buf,3).resizeNearestNeighbor([224,224]).expandDims(0); const p=await nsfwModel.classify(t); t.dispose(); const porn=p.find(x=>x.className==='Porn'||x.className==='Sexual Activity')?.probability||0; const hentai=p.find(x=>x.className==='Hentai')?.probability||0; if(porn>0.85||hentai>0.9) return {safe:false}; return {safe:true}; }catch(e){ return {safe:true}; } } class UploadQueue{ constructor(c){ this.c=c; this.r=0; this.q=[]; } add(t){ return new Promise((res,rej)=>{ this.q.push({t,res,rej}); this.proc(); }); } async proc(){ if(this.r>=this.c||!this.q.length) return; this.r++; const {t,res,rej}=this.q.shift(); try{ res(await t()); }catch(e){ rej(e); }finally{ this.r--; this.proc(); } } } const uploadQueue = new UploadQueue(MAX_CONCURRENT_UPLOADS); async function imgbbUpload(buf){ const f=new FormData(); f.append('image',buf.toString('base64')); const {data}=await axios.post(`https://api.imgbb.com/1/upload?key=${IMGBB_API_KEY}`,f,{headers:f.getHeaders(),timeout:120000,maxContentLength:Infinity,maxBodyLength:Infinity}); if(!data.success) throw new Error(data.error?.message||'ImgBB fail'); return {url:data.data.url,deleteUrl:data.data.delete_url,id:data.data.id}; } async function imgbbDelete(u){ try{await axios.get(u,{timeout:10000});}catch(_){} } const SERVER_MASTER_KEY = createHash('sha256').update(MASTER_SECRET + '|PixelDriveMaster').digest(); function encryptMasterKey(k){ const iv=randomBytes(12); const c=createCipheriv('aes-256-gcm',SERVER_MASTER_KEY,iv); return Buffer.concat([iv,c.update(k),c.final(),c.getAuthTag()]); } function decryptMasterKey(b){ const iv=b.subarray(0,12), tag=b.subarray(-16), d=b.subarray(12,-16); const dc=createDecipheriv('aes-256-gcm',SERVER_MASTER_KEY,iv); dc.setAuthTag(tag); return Buffer.concat([dc.update(d),dc.final()]); } async function getMasterKey(fid){ const r=db.prepare('SELECT master_key_enc FROM files WHERE id=?').get(fid); if(!r?.master_key_enc) throw new Error('Clé manquante'); return decryptMasterKey(r.master_key_enc); } function canAccessFolder(userId, folderId){ let cur = db.prepare('SELECT * FROM folders WHERE id=?').get(folderId); let depth = 0; while (cur && depth < 50) { if (cur.owner_id === userId) return true; if (db.prepare('SELECT 1 FROM user_shares WHERE folder_id=? AND shared_with=?').get(cur.id, userId)) return true; cur = cur.parent_id ? db.prepare('SELECT * FROM folders WHERE id=?').get(cur.parent_id) : null; depth++; } return false; } function canAccessFile(userId, fileId){ const f = db.prepare('SELECT * FROM files WHERE id=?').get(fileId); if (!f) return null; if (f.owner_id === userId) return f; if (db.prepare('SELECT 1 FROM user_shares WHERE file_id=? AND shared_with=?').get(fileId, userId)) return f; if (f.folder_id && canAccessFolder(userId, f.folder_id)) return f; return null; } async function genThumb(fid,mime,key){ if(!mime.startsWith('image/')&&!mime.startsWith('video/')) return; try{ const c = db.prepare('SELECT imgbb_url, size FROM file_chunks WHERE file_id=? AND chunk_index=0').get(fid); if(!c) return; const {data} = await axios.get(c.imgbb_url,{responseType:'arraybuffer',timeout:30000}); const enc = await pngToBuffer(Buffer.from(data), c.size + OVERHEAD); const dec = decryptChunk(enc,key); let buf; if(mime.startsWith('image/')) buf = await sharp(dec).rotate().resize(256,256,{fit:'inside'}).jpeg({quality:80}).toBuffer(); else buf = await sharp({create:{width:256,height:144,channels:3,background:'#1e1e2f'}}).jpeg().toBuffer(); await sharp(buf).toFile(join(THUMB_DIR,`${fid}.jpg`)); }catch(e){ log('WARN', `Thumb ${fid}: ${e.message}`); } } async function streamFile(res,file,key,range,counterUserId){ const chunks = db.prepare('SELECT chunk_index,imgbb_url,size FROM file_chunks WHERE file_id=? ORDER BY chunk_index').all(file.id); const total = file.size; let s = 0, e = total - 1; if(range){ const p = range.replace('bytes=','').split('-'); s = parseInt(p[0]); e = p[1] ? parseInt(p[1]) : total - 1; } if(s >= total || e >= total) return res.status(416).send('Range Not Satisfiable'); const sentBytes = e - s + 1; res.set({'Content-Type':file.mime,'Content-Length':sentBytes,'Accept-Ranges':'bytes','Content-Disposition':`inline; filename="${encodeURIComponent(file.name)}"`}); if(range){ res.status(206); res.set('Content-Range', `bytes ${s}-${e}/${total}`); } log('INFO', `Stream ${file.name} (${(sentBytes/1048576).toFixed(2)} MB)`); let cur = 0; for(const c of chunks){ const cs = cur, ce = cur + c.size - 1; if(ce >= s && cs <= e){ const off = Math.max(0, s - cs), len = Math.min(c.size - off, e - Math.max(s, cs) + 1); try{ const ck = `chunk:${file.id}:${c.chunk_index}`; let dec = fileCache.get(ck)?.data; if(!dec){ const t0 = Date.now(); const {data} = await axios.get(c.imgbb_url,{responseType:'arraybuffer',timeout:120000}); const t1 = Date.now(); const enc = await pngToBuffer(Buffer.from(data), c.size + OVERHEAD); dec = decryptChunk(enc, key); log('INFO', ` Chunk ${c.chunk_index}: ${(data.byteLength/1048576).toFixed(2)} MB en ${Date.now()-t0}ms`); fileCache.set(ck, {data: dec, size: dec.length}); } const sl = dec.subarray(off, off + len); if(!res.write(sl)) await new Promise(r => res.once('drain', r)); }catch(err){ log('ERROR', `Stream chunk ${c.chunk_index}: ${err.message}`); return res.destroy(err); } } cur += c.size; if(cs > e) break; } res.end(); if (counterUserId) addTransfer(counterUserId, sentBytes, 'down'); } const sseClients = new Map(); function broadcast(fid,d){ const cs = sseClients.get(fid); if(cs){ const p = `data: ${JSON.stringify(d)}\n\n`; for(const r of cs) r.write(p); } } // ========================================== // ROUTES // ========================================== app.post('/api/register', registerLimit, async(req,res)=>{ const {u,p,t} = req.body; if(!u||!p||!t) return res.status(400).json({error:'Champs manquants'}); if(typeof u !== 'string' || !/^[a-zA-Z0-9_.\-]{3,20}$/.test(u)) return res.status(400).json({error:'Nom d\'utilisateur invalide (3-20 caractères alphanumériques)'}); if(typeof p !== 'string' || p.length < 6) return res.status(400).json({error:'Mot de passe trop court (6 caractères min)'}); if(db.prepare('SELECT 1 FROM users WHERE username=?').get(u)) return res.status(409).json({error:'Existe'}); const s = randomBytes(16); const h = await deriveKey(p, s); db.prepare('INSERT INTO users(username,password_hash,salt,tos_accepted) VALUES(?,?,?,1)').run(u,h,s); log('INFO', `Nouvel utilisateur: ${u}`); res.json({ok:true}); }); app.post('/api/login', loginLimit, async(req,res)=>{ const {u,p} = req.body; const usr = db.prepare('SELECT * FROM users WHERE username=?').get(u); if(!usr) return res.status(401).json({error:'Invalide'}); const h = await deriveKey(p, usr.salt); if(!h.equals(usr.password_hash)) return res.status(401).json({error:'Invalide'}); try { await new Promise((ok, bad) => req.session.regenerate(e => e ? bad(e) : ok())); } catch(e) { log('ERROR', `Session regenerate échouée: ${e.message}`); return res.status(500).json({error:'Erreur session'}); } req.session.userId = usr.id; req.session.isAdmin = !!usr.is_admin; req.session.tosAccepted = !!usr.tos_accepted; req.session.username = usr.username; log('INFO', `Connexion: ${u}`); res.json({ok:true,user:{id:usr.id,username:usr.username,isAdmin:!!usr.is_admin,tosAccepted:!!usr.tos_accepted}}); }); app.post('/api/logout',(req,res)=>req.session.destroy(()=>res.json({ok:true}))); app.get('/api/me',(req,res)=>{ if(!req.session.userId) return res.status(401).json({error:'Non connecté'}); const used = db.prepare("SELECT COALESCE(SUM(size),0) as t FROM files WHERE owner_id=? AND status!='error'").get(req.session.userId).t; const tr = getTransfer(req.session.userId); res.json({ id:req.session.userId, username:req.session.username, isAdmin:req.session.isAdmin, tosAccepted:req.session.tosAccepted, quotaUsed:used, quotaMax:USER_QUOTA, transferUp:tr.up, transferDown:tr.down, transferUsed:tr.total, transferMax:DAILY_TRANSFER }); }); app.post('/api/tos/accept',requireAuth,(req,res)=>{db.prepare('UPDATE users SET tos_accepted=1 WHERE id=?').run(req.session.userId);req.session.tosAccepted=true;res.json({ok:true});}); app.post('/api/avatar', requireAuth, avatarLimit, async(req,res)=>{ const { data } = req.body; if (!data || !data.startsWith('data:image/')) return res.status(400).json({error:'Image invalide'}); try { const buf = Buffer.from(data.split(',')[1], 'base64'); if (buf.length > 5*1024*1024) return res.status(400).json({error:'Image trop lourde (5 MB max)'}); const jpeg = await sharp(buf).resize(128,128,{fit:'cover'}).jpeg({quality:82}).toBuffer(); db.prepare('UPDATE users SET avatar=? WHERE id=?').run(jpeg, req.session.userId); log('INFO', `Avatar mis à jour: ${req.session.username}`); res.json({ok:true}); } catch(e){ res.status(400).json({error:'Image invalide'}); } }); app.delete('/api/avatar', requireAuth, avatarLimit, (req,res)=>{ db.prepare('UPDATE users SET avatar=NULL WHERE id=?').run(req.session.userId); res.json({ok:true}); }); app.get('/api/users/:id/avatar',(req,res)=>{ const u = db.prepare('SELECT avatar FROM users WHERE id=?').get(req.params.id); if (!u || !u.avatar) return res.status(404).json({error:"Pas d'avatar"}); res.set({'Content-Type':'image/jpeg','Cache-Control':'public, max-age=3600'}); res.send(u.avatar); }); app.get('/api/folders',requireAuth,(req,res)=>{ const p = req.query.parent_id ? parseInt(req.query.parent_id) : null; if (p !== null) { if (!canAccessFolder(req.session.userId, p)) return res.status(403).json({error:'Accès refusé'}); return res.json(db.prepare('SELECT * FROM folders WHERE parent_id=? ORDER BY name COLLATE NOCASE').all(p)); } res.json(db.prepare('SELECT * FROM folders WHERE owner_id=? AND parent_id IS NULL ORDER BY name COLLATE NOCASE').all(req.session.userId)); }); app.post('/api/folders',requireAuth,requireTos,(req,res)=>{const{n,p}=req.body;if(!n||typeof n!=='string'||n.length>100)return res.status(400).json({error:'Nom requis'});try{const i=db.prepare('INSERT INTO folders(name,parent_id,owner_id) VALUES(?,?,?)').run(n,p||null,req.session.userId);res.json({id:i.lastInsertRowid,name:n,parent_id:p});}catch(e){res.status(409).json({error:'Existe'});}}); app.delete('/api/folders/:id',requireAuth,(req,res)=>{db.prepare('DELETE FROM folders WHERE id=? AND owner_id=?').run(req.params.id,req.session.userId);res.json({ok:true});}); // ✅ RENAMER DOSSIER app.post('/api/folders/:id/rename',requireAuth,(req,res)=>{ const { name } = req.body; if(!name || typeof name !== 'string' || !name.trim() || name.length > 100) return res.status(400).json({error:'Nom invalide'}); const fo = db.prepare('SELECT id FROM folders WHERE id=? AND owner_id=?').get(req.params.id, req.session.userId); if(!fo) return res.status(404).json({error:'Introuvable'}); try { db.prepare('UPDATE folders SET name=? WHERE id=?').run(name.trim(), req.params.id); res.json({ok:true}); } catch(e){ res.status(409).json({error:'Un dossier porte déjà ce nom'}); } }); // ✅ DÉPLACER DOSSIER (anti-cycle) app.post('/api/folders/:id/move',requireAuth,(req,res)=>{ const { parent_id } = req.body; const fo = db.prepare('SELECT * FROM folders WHERE id=? AND owner_id=?').get(req.params.id, req.session.userId); if(!fo) return res.status(404).json({error:'Introuvable'}); if (parent_id) { const target = db.prepare('SELECT * FROM folders WHERE id=?').get(parent_id); if (!target || target.owner_id !== req.session.userId) return res.status(403).json({error:'Dossier cible invalide'}); let cur = target, depth = 0; while (cur && depth < 50) { if (cur.id === fo.id) return res.status(400).json({error:'Impossible de déplacer un dossier dans lui-même'}); cur = cur.parent_id ? db.prepare('SELECT * FROM folders WHERE id=?').get(cur.parent_id) : null; depth++; } } db.prepare('UPDATE folders SET parent_id=? WHERE id=?').run(parent_id || null, req.params.id); log('INFO', `Dossier ${req.params.id} déplacé → ${parent_id || 'racine'}`); res.json({ok:true}); }); app.get('/api/files',requireAuth,(req,res)=>{ const f = req.query.folder_id ? parseInt(req.query.folder_id) : null; if (f !== null) { if (!canAccessFolder(req.session.userId, f)) return res.status(403).json({error:'Accès refusé'}); return res.json(db.prepare('SELECT * FROM files WHERE folder_id=? ORDER BY name COLLATE NOCASE').all(f)); } res.json(db.prepare('SELECT * FROM files WHERE owner_id=? AND folder_id IS NULL ORDER BY name COLLATE NOCASE').all(req.session.userId)); }); // ✅ RENAMER FICHIER app.post('/api/files/:id/rename',requireAuth,(req,res)=>{ if(!UUID_RE.test(req.params.id)) return res.status(400).json({error:'ID invalide'}); const { name } = req.body; if(!name || typeof name !== 'string' || !name.trim() || name.length > 200) return res.status(400).json({error:'Nom invalide'}); const f = db.prepare('SELECT id FROM files WHERE id=? AND owner_id=?').get(req.params.id, req.session.userId); if(!f) return res.status(404).json({error:'Introuvable'}); db.prepare('UPDATE files SET name=? WHERE id=?').run(name.trim(), req.params.id); res.json({ok:true}); }); // ✅ DÉPLACER FICHIER app.post('/api/files/:id/move',requireAuth,(req,res)=>{ if(!UUID_RE.test(req.params.id)) return res.status(400).json({error:'ID invalide'}); const { folder_id } = req.body; const f = db.prepare('SELECT id FROM files WHERE id=? AND owner_id=?').get(req.params.id, req.session.userId); if(!f) return res.status(404).json({error:'Introuvable'}); if (folder_id) { const target = db.prepare('SELECT owner_id FROM folders WHERE id=?').get(folder_id); if (!target || target.owner_id !== req.session.userId) return res.status(403).json({error:'Dossier cible invalide'}); } db.prepare('UPDATE files SET folder_id=? WHERE id=?').run(folder_id || null, req.params.id); log('INFO', `Fichier ${req.params.id} déplacé → ${folder_id || 'racine'}`); res.json({ok:true}); }); app.post('/api/share-user',requireAuth,requireTos,shareLimit,(req,res)=>{ const { type, id, username } = req.body; if (!type || !id || !username) return res.status(400).json({error:'Champs manquants'}); const target = db.prepare('SELECT id FROM users WHERE username=?').get(String(username).trim()); if (!target) return res.status(404).json({error:'Utilisateur introuvable'}); if (target.id === req.session.userId) return res.status(400).json({error:'Impossible de partager avec vous-même'}); const col = type === 'file' ? 'file_id' : 'folder_id'; const own = db.prepare(`SELECT id FROM ${type === 'file' ? 'files' : 'folders'} WHERE id=? AND owner_id=?`).get(id, req.session.userId); if (!own) return res.status(404).json({error:'Introuvable'}); if (db.prepare(`SELECT 1 FROM user_shares WHERE ${col}=? AND shared_with=?`).get(id, target.id)) return res.status(409).json({error:'Déjà partagé'}); db.prepare(`INSERT INTO user_shares(${col},owner_id,shared_with) VALUES(?,?,?)`).run(id, req.session.userId, target.id); log('INFO', `Partage ${type} ${id} → ${username.trim()}`); res.json({ok:true}); }); app.get('/api/shared-with-me',requireAuth,(req,res)=>{ res.json(db.prepare(`SELECT us.id as share_id, us.created_at as shared_at, u.username as owner_name, us.owner_id, us.file_id, us.folder_id, f.name as file_name, f.mime, f.size, f.status, fo.name as folder_name FROM user_shares us JOIN users u ON u.id = us.owner_id LEFT JOIN files f ON f.id = us.file_id LEFT JOIN folders fo ON fo.id = us.folder_id WHERE us.shared_with = ? ORDER BY us.created_at DESC`).all(req.session.userId)); }); app.delete('/api/shared-with-me/:id',requireAuth,(req,res)=>{ db.prepare('DELETE FROM user_shares WHERE id=? AND shared_with=?').run(req.params.id, req.session.userId); res.json({ok:true}); }); app.get('/api/my-shares',requireAuth,(req,res)=>{ res.json(db.prepare(`SELECT us.id, us.created_at, u.username as shared_with_name, us.file_id, us.folder_id, f.name as file_name, f.mime, f.size, fo.name as folder_name FROM user_shares us JOIN users u ON u.id = us.shared_with LEFT JOIN files f ON f.id = us.file_id LEFT JOIN folders fo ON fo.id = us.folder_id WHERE us.owner_id = ? ORDER BY us.created_at DESC`).all(req.session.userId)); }); app.delete('/api/my-shares/:id',requireAuth,(req,res)=>{ const r = db.prepare('DELETE FROM user_shares WHERE id=? AND owner_id=?').run(req.params.id, req.session.userId); if (!r.changes) return res.status(404).json({error:'Introuvable'}); log('INFO', `Partage révoqué: #${req.params.id}`); res.json({ok:true}); }); app.get('/api/share-links',requireAuth,(req,res)=>{ res.json(db.prepare(`SELECT sl.*, f.name as file_name, f.mime, f.size FROM share_links sl LEFT JOIN files f ON sl.file_id = f.id WHERE sl.owner_id = ? ORDER BY sl.created_at DESC`).all(req.session.userId)); }); app.delete('/api/share-links/:token',requireAuth,(req,res)=>{ const r = db.prepare('DELETE FROM share_links WHERE token=? AND owner_id=?').run(req.params.token, req.session.userId); if (!r.changes) return res.status(404).json({error:'Introuvable'}); res.json({ok:true}); }); const upload = multer({dest:TEMP_DIR,limits:{fileSize:5*1024*1024*1024}}); app.post('/api/files/upload',requireAuth,requireTos,uploadLimit,upload.single('file'),async(req,res)=>{ if(!req.file)return res.status(400).json({error:'Fichier manquant'}); const uid = req.session.userId; const fsize = req.file.size; const tr = getTransfer(uid); const remaining = DAILY_TRANSFER - tr.total; if (remaining <= 0) { try { unlinkSync(req.file.path); } catch (_) {} log('ALERT', `Quota transit atteint (upload bloqué): ${req.session.username}`); return res.status(429).json({error:'Quota de transfert journalier atteint, réessayez demain'}); } if (fsize > remaining) { try { unlinkSync(req.file.path); } catch (_) {} return res.status(413).json({error:`Quota journalier insuffisant (reste ${(remaining/1073741824).toFixed(1)} GB)`}); } const used = db.prepare("SELECT COALESCE(SUM(size),0) as t FROM files WHERE owner_id=? AND status!='error'").get(uid).t; if (used + fsize > USER_QUOTA) { try { unlinkSync(req.file.path); } catch (_) {} return res.status(413).json({error:`Quota de stockage dépassé (${(USER_QUOTA/1073741824).toFixed(0)} GB max)`}); } const fid=uuidv4(),fpath=req.file.path,cc=Math.ceil(fsize/CHUNK_SIZE),mk=randomBytes(KEY_LEN),kh=createHash('sha256').update(mk).digest('hex'),mke=encryptMasterKey(mk),fidParam=req.body.folder_id?parseInt(req.body.folder_id):null; db.prepare(`INSERT INTO files(id,folder_id,owner_id,name,mime,size,chunk_count,status,encryption_key_hash,master_key_enc) VALUES(?,?,?,?,?,?,?,'pending',?,?)`).run(fid,fidParam,uid,req.file.originalname,req.file.mimetype,fsize,cc,kh,mke); const stmt=db.prepare('INSERT INTO file_chunks(file_id,chunk_index,size,encrypted_len) VALUES(?,?,?,?)'); const tx=db.transaction((arr)=>{for(const c of arr)stmt.run(...c);}); const cd=[]; for(let i=0;iprocessUpload(fid,fpath,mk,cc,req.file.mimetype,fsize,req.session.username,uid)); }); async function processUpload(fid,path,mk,cc,mime,fsize,username,uid){ const t0 = Date.now(); log('INFO', `Upload démarré: "${path.split('/').pop()}" par ${username} (${(fsize/1048576).toFixed(2)} MB, ${cc} chunk(s))`); try{ db.prepare("UPDATE files SET status='uploading' WHERE id=?").run(fid); for(let i=0;ici.encrypted_len)throw new Error(`Overflow chunk ${i}`); const png=await bufferToPng(enc); const{url,deleteUrl,id}=await imgbbUpload(png); db.prepare('UPDATE file_chunks SET imgbb_url=?,imgbb_delete_url=?,imgbb_id=? WHERE file_id=? AND chunk_index=?').run(url,deleteUrl,id,fid,i); const prog=((i+1)/cc)*50; db.prepare('UPDATE files SET progress=?,status=? WHERE id=?').run(prog,'uploading',fid); broadcast(fid,{type:'progress',progress:prog,status:'uploading'}); if(isetTimeout(r,3200)); } unlinkSync(path); db.prepare("UPDATE files SET status='processing',progress=50 WHERE id=?").run(fid); broadcast(fid,{type:'progress',progress:50,status:'processing'}); await genThumb(fid,mime,mk); db.prepare("UPDATE files SET status='ready',progress=100 WHERE id=?").run(fid); broadcast(fid,{type:'progress',progress:100,status:'ready',type:'done'}); addTransfer(uid, fsize, 'up'); const secs = (Date.now()-t0)/1000; log('INFO', `Upload terminé: ${fid} en ${secs.toFixed(1)}s (${(fsize/1048576/secs).toFixed(2)} MB/s)`); }catch(e){ log('ERROR', `Upload échoué ${fid}: ${e.message}`); db.prepare("UPDATE files SET status='error',error_msg=? WHERE id=?").run(e.message,fid); broadcast(fid,{type:'error',error:e.message}); const cs=db.prepare('SELECT imgbb_delete_url FROM file_chunks WHERE file_id=?').all(fid); for(const c of cs)if(c.imgbb_delete_url)await imgbbDelete(c.imgbb_delete_url); try { unlinkSync(path); } catch (_) {} } } function readChunk(p,o,l){return new Promise((res,rej)=>{const s=createReadStream(p,{start:o,end:o+l-1}),c=[];s.on('data',d=>c.push(d)).on('end',()=>res(Buffer.concat(c,l))).on('error',rej);});} app.get('/api/files/:id/download',requireAuth,async(req,res)=>{ if(!UUID_RE.test(req.params.id)) return res.status(400).json({error:'ID invalide'}); if (transferBlocked(req.session.userId)) { log('ALERT', `Quota transit atteint (download bloqué): ${req.session.username}`); return res.status(429).json({error:'Quota de transfert journalier atteint, réessayez demain'}); } const f = canAccessFile(req.session.userId, req.params.id); if(!f)return res.status(404).json({error:'Introuvable'}); if(f.status!=='ready')return res.status(409).json({error:'Pas prêt',status:f.status}); const k = await getMasterKey(f.id); await streamFile(res,f,k,req.headers.range,req.session.userId); }); app.post('/api/files/:id/share',requireAuth,shareLimit,async(req,res)=>{ const f=db.prepare('SELECT * FROM files WHERE id=? AND owner_id=?').get(req.params.id,req.session.userId); if(!f||f.status!=='ready') return res.status(404).json({error:'Fichier non disponible'}); const pt=uuidv4(), dt2=uuidv4(), expires=Date.now()+7*24*3600000; db.prepare('INSERT INTO share_links(token,file_id,owner_id,type,expires_at) VALUES(?,?,?,?,?)').run(pt,f.id,req.session.userId,'embed',expires); db.prepare('INSERT INTO share_links(token,file_id,owner_id,type,expires_at) VALUES(?,?,?,?,?)').run(dt2,f.id,req.session.userId,'download',expires); res.json({preview:`/s/${pt}`,download:`/d/${dt2}`,expires}); }); app.get('/s/:token',async(req,res)=>{ const l=db.prepare('SELECT * FROM share_links WHERE token=? AND type=? AND (expires_at IS NULL OR expires_at>?)').get(req.params.token,'embed',Date.now()); if(!l)return res.status(404).send(html('Lien invalide/expiré')); const f=db.prepare('SELECT * FROM files WHERE id=?').get(l.file_id); if(!f)return res.status(404).send(html('Fichier supprimé')); let dt; const existing = db.prepare('SELECT token FROM share_links WHERE file_id=? AND type=? AND owner_id=? AND (expires_at IS NULL OR expires_at>?)').get(f.id,'download',l.owner_id,Date.now()); if (existing) dt = existing.token; else { dt = uuidv4(); db.prepare('INSERT INTO share_links(token,file_id,owner_id,type,expires_at) VALUES(?,?,?,?,?)').run(dt,f.id,l.owner_id,'download',Date.now()+3600000); } const v=f.mime.startsWith('video/'),a=f.mime.startsWith('audio/'),i=f.mime.startsWith('image/'),p=f.mime==='application/pdf'; const name = esc(f.name); const sizeMb = (f.size/1e6).toFixed(1); const ogImage = req.protocol + '://' + req.get('host') + '/banner.png'; log('INFO', `Accès public /s/${req.params.token} (${f.name})`); res.send(html(`
${v?``:''}${a?``:''}${i?`${name}`:''}${p?``:''}
${name} • ${sizeMb} MB • Télécharger
`, { title: name, desc: `${sizeMb} MB · PixelDrive — partage sécurisé`, image: ogImage })); }); app.get('/d/:token',async(req,res)=>{ const l=db.prepare('SELECT * FROM share_links WHERE token=? AND type=? AND (expires_at IS NULL OR expires_at>?)').get(req.params.token,'download',Date.now()); if(!l)return res.status(404).json({error:'Invalide'}); const f=db.prepare('SELECT * FROM files WHERE id=?').get(l.file_id); if(!f||f.status!=='ready')return res.status(404).json({error:'Indisponible'}); if (transferBlocked(f.owner_id)) { log('ALERT', `Quota transit propriétaire atteint: ${f.id}`); return res.status(429).json({error:'Quota journalier du propriétaire atteint'}); } log('INFO', `Téléchargement public /d/${req.params.token} (${f.name})`); const mk = decryptMasterKey(db.prepare('SELECT master_key_enc FROM files WHERE id=?').get(f.id).master_key_enc); await streamFile(res,f,mk,req.headers.range,f.owner_id); }); app.delete('/api/files/:id',requireAuth,async(req,res)=>{ if(!UUID_RE.test(req.params.id)) return res.status(400).json({error:'ID invalide'}); const f=db.prepare('SELECT * FROM files WHERE id=? AND owner_id=?').get(req.params.id,req.session.userId); if(!f)return res.status(404).json({error:'Introuvable'}); const cs=db.prepare('SELECT imgbb_delete_url FROM file_chunks WHERE file_id=?').all(f.id); for(const c of cs)if(c.imgbb_delete_url)await imgbbDelete(c.imgbb_delete_url); db.prepare('DELETE FROM files WHERE id=?').run(f.id); fileCache.delete(`chunk:${f.id}:0`); try{unlinkSync(join(THUMB_DIR,`${f.id}.jpg`));}catch(_){} res.json({ok:true}); }); app.get('/api/files/:id/thumb',requireAuth,(req,res)=>{ if(!UUID_RE.test(req.params.id)) return res.status(400).json({error:'ID invalide'}); if(!canAccessFile(req.session.userId, req.params.id)) return res.status(404).json({error:'Introuvable'}); const p=join(THUMB_DIR,`${req.params.id}.jpg`); if(existsSync(p))return res.sendFile(p); res.set('Content-Type','image/svg+xml'); res.send(`FILE`); }); app.get('/api/files/:id/progress',requireAuth,(req,res)=>{ if(!UUID_RE.test(req.params.id)) return res.status(400).json({error:'ID invalide'}); if(!canAccessFile(req.session.userId, req.params.id)) return res.status(404).json({error:'Introuvable'}); const set = sseClients.get(req.params.id); if (set && set.size >= 5) return res.status(429).json({error:'Trop de connexions'}); res.set({'Content-Type':'text/event-stream','Cache-Control':'no-cache','Connection':'keep-alive'}); res.flushHeaders(); const id=req.params.id; if(!sseClients.has(id))sseClients.set(id,new Set()); sseClients.get(id).add(res); req.on('close',()=>sseClients.get(id)?.delete(res)); }); app.get('/api/admin/users',requireAuth,(req,res)=>{if(!req.session.isAdmin)return res.status(403).json({error:'Admin only'});res.json(db.prepare('SELECT id,username,is_admin,tos_accepted,created_at FROM users').all());}); app.get('/health',(_,res)=>res.json({ok:true,cache:fileCache.size,mem:process.memoryUsage()})); app.use(express.static(PUBLIC_DIR, { dotfiles: 'deny', index: false })); app.get('/manifest.json', (req,res) => res.sendFile(join(__dirname, 'manifest.json'))); app.get('*', (req, res) => { if (/\.[a-zA-Z0-9]+$/.test(req.path)) return res.status(404).send('Not Found'); res.sendFile(join(PUBLIC_DIR, 'index.html')); }); app.use((err, req, res, next) => { if (err.type === 'entity.parse.failed' || err instanceof SyntaxError) return res.status(400).json({ error: 'Bad Request' }); log('ERROR', `Server: ${err.message}`); res.status(500).json({ error: 'Internal Server Error' }); }); const server = app.listen(PORT, '0.0.0.0', () => log('INFO', `PixelDrive v4.6 démarré | stockage ${(USER_QUOTA/1073741824).toFixed(0)} GB | transit ${(DAILY_TRANSFER/1073741824).toFixed(0)} GB/j`)); process.on('SIGTERM', () => { log('WARN', 'SIGTERM reçu, arrêt...'); server.close(() => process.exit(0)); }); function html(b, og) { const meta = og ? `` : ''; return `PixelDrive${meta}${b}`; }