diff --git "a/server.js" "b/server.js" --- "a/server.js" +++ "b/server.js" @@ -1,7 +1,6 @@ // ========================================== -// PIXELDRIVE v4.8 - SERVER.JS (ESM) -// + password change, account delete, activity log, stats, -// share TTL, view/download counters +// PIXELDRIVE v4.9 - SERVER.JS (ESM) +// + Dossiers publics, ZIP streaming (pur Node), Recherche globale // ========================================== import express from 'express'; import session from 'express-session'; @@ -39,23 +38,21 @@ 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; -const TTL_ALLOWED = [1, 24, 168, 720]; // ✅ 1h / 24h / 7j / 30j +const TTL_ALLOWED = [1, 24, 168, 720]; 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 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'); @@ -69,35 +66,34 @@ async function deriveKey(password, salt) { return scryptAsync(password, salt, KE 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(); + 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); + 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); } // ========================================== -// SCHÉMA (+ activity, + compteurs views/downloads) +// SCHÉMA (share_links : file_id nullable + folder_id) // ========================================== 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, views INTEGER DEFAULT 0, downloads INTEGER DEFAULT 0); +CREATE TABLE IF NOT EXISTS share_links (token TEXT PRIMARY KEY, 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, type TEXT NOT NULL, created_at INTEGER DEFAULT (strftime('%s','now')), expires_at INTEGER, views INTEGER DEFAULT 0, downloads INTEGER DEFAULT 0); 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 TABLE IF NOT EXISTS activity (id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER, action TEXT NOT NULL, detail TEXT, created_at INTEGER DEFAULT (strftime('%s','now'))); @@ -110,48 +106,63 @@ CREATE INDEX IF NOT EXISTS idx_activity_user ON activity(user_id, created_at); `); 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;`); - // ✅ Compteurs de vues/téléchargements sur share_links - const slCols = db.prepare("PRAGMA table_info(share_links)").all().map(c => c.name); - if (!slCols.includes('views')) db.exec(`ALTER TABLE share_links ADD COLUMN views INTEGER DEFAULT 0;`); - if (!slCols.includes('downloads')) db.exec(`ALTER TABLE share_links ADD COLUMN downloads INTEGER DEFAULT 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, views INTEGER DEFAULT 0, downloads INTEGER DEFAULT 0);`); - 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);`); - if (!tables.includes('activity')) db.exec(`CREATE TABLE activity (id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER, action TEXT NOT NULL, detail TEXT, created_at INTEGER DEFAULT (strftime('%s','now'))); CREATE INDEX idx_activity_user ON activity(user_id, created_at);`); - 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', '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; } - log('INFO', 'Migrations terminées.'); + } + 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 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, type TEXT NOT NULL, created_at INTEGER DEFAULT (strftime('%s','now')), expires_at INTEGER, views INTEGER DEFAULT 0, downloads INTEGER DEFAULT 0);`); + 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);`); + if (!tables.includes('activity')) db.exec(`CREATE TABLE activity (id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER, action TEXT NOT NULL, detail TEXT, created_at INTEGER DEFAULT (strftime('%s','now'))); CREATE INDEX idx_activity_user ON activity(user_id, created_at);`); + + // ✅ share_links : reconstruction si ancienne structure (file_id NOT NULL / sans folder_id/views/downloads) + const slInfo = db.prepare("PRAGMA table_info(share_links)").all(); + const slCols = slInfo.map(c => c.name); + const slNeedsRebuild = !slCols.includes('folder_id') || !slCols.includes('views') || !slCols.includes('downloads') || (slInfo.find(c => c.name === 'file_id')?.notnull === 1); + if (slNeedsRebuild) { + log('INFO', 'Migration: share_links → folder_id + file_id nullable + compteurs'); + db.exec(` + CREATE TABLE share_links_new (token TEXT PRIMARY KEY, 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, type TEXT NOT NULL, created_at INTEGER DEFAULT (strftime('%s','now')), expires_at INTEGER, views INTEGER DEFAULT 0, downloads INTEGER DEFAULT 0); + INSERT INTO share_links_new(token, file_id, folder_id, owner_id, type, created_at, expires_at, views, downloads) + SELECT token, file_id, NULL, owner_id, type, created_at, expires_at, COALESCE(views,0), COALESCE(downloads,0) FROM share_links; + DROP TABLE share_links; + ALTER TABLE share_links_new RENAME TO share_links; + CREATE INDEX idx_share_owner ON share_links(owner_id); + `); + } + + 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(); @@ -164,58 +175,197 @@ CREATE TRIGGER update_file_ts AFTER UPDATE ON files BEGIN UPDATE files SET updat // --- 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) }; + 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}`); } + 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; } - -// ✅ JOURNAL D'ACTIVITÉ function logActivity(userId, action, detail) { - try { db.prepare('INSERT INTO activity(user_id, action, detail) VALUES(?,?,?)').run(userId, action, String(detail || '').slice(0, 200)); } catch (e) {} + try { db.prepare('INSERT INTO activity(user_id, action, detail) VALUES(?,?,?)').run(userId, action, String(detail || '').slice(0, 200)); } catch (e) {} +} + +// ========================================== +// ✅ ZIP WRITER (pur Node, méthode stored + data descriptor, streaming) +// ========================================== +const CRC_TABLE = (() => { + const t = new Uint32Array(256); + for (let n = 0; n < 256; n++) { + let c = n; + for (let k = 0; k < 8; k++) c = (c & 1) ? (0xEDB88320 ^ (c >>> 1)) : (c >>> 1); + t[n] = c >>> 0; + } + return t; +})(); +function crc32Update(crc, buf) { + let c = crc ^ 0xFFFFFFFF; + for (let i = 0; i < buf.length; i++) c = CRC_TABLE[(c ^ buf[i]) & 0xFF] ^ (c >>> 8); + return (c ^ 0xFFFFFFFF) >>> 0; +} +function zipSafeName(name) { + return String(name).replace(/[\\/:*?"<>|\u0000-\u001f]/g, '_').replace(/^\/+/, '').replace(/\.\.+/g, '.').slice(0, 150) || 'fichier'; +} +function dosDateTime(d = new Date()) { + const time = ((d.getHours() & 0x1F) << 11) | ((d.getMinutes() & 0x3F) << 5) | (Math.floor(d.getSeconds() / 2) & 0x1F); + const year = Math.max(1980, d.getFullYear()); + const date = (((year - 1980) & 0x7F) << 9) | (((d.getMonth() + 1) & 0xF) << 5) | (d.getDate() & 0x1F); + return { time, date }; +} +function uniqueZipName(state, name) { + if (!state.used) return name; + let candidate = name, i = 1; + const dot = name.lastIndexOf('.'); + const base = dot > 0 ? name.slice(0, dot) : name; + const ext = dot > 0 ? name.slice(dot) : ''; + while (state.used.has(candidate)) candidate = `${base} (${i++})${ext}`; + state.used.add(candidate); + return candidate; +} +function zipLocalHeader(name) { + const nameBuf = Buffer.from(name, 'utf8'); + const h = Buffer.alloc(30); + const { time, date } = dosDateTime(); + h.writeUInt32LE(0x04034b50, 0); + h.writeUInt16LE(20, 4); + h.writeUInt16LE(0x0808, 6); // data descriptor + UTF-8 + h.writeUInt16LE(0, 8); // stored + h.writeUInt16LE(time, 10); + h.writeUInt16LE(date, 12); + h.writeUInt32LE(0, 14); + h.writeUInt32LE(0, 18); + h.writeUInt32LE(0, 22); + h.writeUInt16LE(nameBuf.length, 26); + h.writeUInt16LE(0, 28); + return Buffer.concat([h, nameBuf]); +} +function zipDataDescriptor(crc, size) { + const h = Buffer.alloc(16); + h.writeUInt32LE(0x08074b50, 0); + h.writeUInt32LE(crc, 4); + h.writeUInt32LE(size, 8); + h.writeUInt32LE(size, 12); + return h; +} +function zipCentralEntry(name, crc, size, offset) { + const nameBuf = Buffer.from(name, 'utf8'); + const h = Buffer.alloc(46); + const { time, date } = dosDateTime(); + h.writeUInt32LE(0x02014b50, 0); + h.writeUInt16LE(20, 4); + h.writeUInt16LE(20, 6); + h.writeUInt16LE(0x0808, 8); + h.writeUInt16LE(0, 10); + h.writeUInt16LE(time, 12); + h.writeUInt16LE(date, 14); + h.writeUInt32LE(crc, 16); + h.writeUInt32LE(size, 20); + h.writeUInt32LE(size, 24); + h.writeUInt16LE(nameBuf.length, 28); + h.writeUInt16LE(0, 30); + h.writeUInt16LE(0, 32); + h.writeUInt16LE(0, 34); + h.writeUInt16LE(0, 36); + h.writeUInt32LE(0, 38); + h.writeUInt32LE(offset, 42); + return Buffer.concat([h, nameBuf]); +} +function zipEOCD(count, cdSize, cdOffset) { + const h = Buffer.alloc(22); + h.writeUInt32LE(0x06054b50, 0); + h.writeUInt16LE(0, 4); + h.writeUInt16LE(0, 6); + h.writeUInt16LE(count, 8); + h.writeUInt16LE(count, 10); + h.writeUInt32LE(cdSize, 12); + h.writeUInt32LE(cdOffset, 16); + h.writeUInt16LE(0, 20); + return h; +} +// Ajoute un fichier (déjà déchiffrable) au ZIP en streaming +async function zipAddFile(res, file, key, state) { + const name = uniqueZipName(state, zipSafeName(file.zipName || file.name)); + const localOffset = state.offset; + const header = zipLocalHeader(name); + state.offset += header.length; + if (!res.write(header)) await new Promise(r => res.once('drain', r)); + const chunks = db.prepare('SELECT chunk_index,imgbb_url,size FROM file_chunks WHERE file_id=? ORDER BY chunk_index').all(file.id); + let crc = 0, written = 0; + for (const c of chunks) { + const ck = `chunk:${file.id}:${c.chunk_index}`; + let dec = fileCache.get(ck)?.data; + if (!dec) { + const { data } = await axios.get(c.imgbb_url, { responseType: 'arraybuffer', timeout: 120000 }); + const enc = await pngToBuffer(Buffer.from(data), c.size + OVERHEAD); + dec = decryptChunk(enc, key); + fileCache.set(ck, { data: dec, size: dec.length }); + } + crc = crc32Update(crc, dec); + written += dec.length; + state.offset += dec.length; + if (!res.write(dec)) await new Promise(r => res.once('drain', r)); + } + const dd = zipDataDescriptor(crc, written); + state.offset += dd.length; + if (!res.write(dd)) await new Promise(r => res.once('drain', r)); + state.entries.push({ name, crc, size: written, offset: localOffset }); + return written; +} +function zipFinish(res, state) { + const cdStart = state.offset; + const cdBufs = state.entries.map(e => zipCentralEntry(e.name, e.crc, e.size, e.offset)); + const cdSize = cdBufs.reduce((s, b) => s + b.length, 0); + const eocd = zipEOCD(state.entries.length, cdSize, cdStart); + res.end(Buffer.concat([...cdBufs, eocd])); +} +// Collecte récursive des fichiers prêts d'un dossier (avec chemin relatif) +function collectFolderFiles(folderId, prefix, out, depth) { + if (depth > 10) return; + const files = db.prepare("SELECT * FROM files WHERE folder_id=? AND status='ready'").all(folderId); + for (const f of files) out.push({ ...f, zipName: prefix + f.name }); + const subs = db.prepare('SELECT * FROM folders WHERE parent_id=?').all(folderId); + for (const s of subs) collectFolderFiles(s.id, prefix + s.name + '/', out, depth + 1); } // --- 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}`); } + 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}`); } + 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 cleanHourly() { - try { - 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); - const a = db.prepare('DELETE FROM activity WHERE created_at < ?').run(Math.floor(now / 1000) - 30 * 86400); - db.prepare('DELETE FROM activity WHERE id NOT IN (SELECT id FROM activity ORDER BY id DESC LIMIT 2000)').run(); - const cutoff = Math.floor(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 (s.changes || l.changes || a.changes || olds.length) log('INFO', `Cleanup: ${s.changes} session(s), ${l.changes} lien(s), ${a.changes} activité(s), ${olds.length} fichier(s) erreur`); - } catch (e) { log('WARN', `Cleanup: ${e.message}`); } + try { + 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); + const a = db.prepare('DELETE FROM activity WHERE created_at < ?').run(Math.floor(now / 1000) - 30 * 86400); + db.prepare('DELETE FROM activity WHERE id NOT IN (SELECT id FROM activity ORDER BY id DESC LIMIT 2000)').run(); + const cutoff = Math.floor(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 (s.changes || l.changes || a.changes || olds.length) log('INFO', `Cleanup: ${s.changes} session(s), ${l.changes} lien(s), ${a.changes} activité(s), ${olds.length} fichier(s) erreur`); + } catch (e) { log('WARN', `Cleanup: ${e.message}`); } } mkdirSync(TEMP_DIR, { recursive: true }); mkdirSync(THUMB_DIR, { recursive: true }); @@ -226,17 +376,17 @@ 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 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' }); @@ -244,57 +394,56 @@ const registerLimit = rateLimit({ windowMs: 60*60000, max: 5, message: 'Trop de 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' }); +const zipLimit = rateLimit({ windowMs: 60*60000, max: 20, message: 'Trop de ZIP, patientez' }); -// --- 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(); + 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); } + 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 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(); + 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(); }; const requireAdmin = (req, res, next) => { if(!req.session.isAdmin) return res.status(403).json({error:'Admin only'}); next(); }; @@ -320,523 +469,623 @@ function decryptMasterKey(b){ const iv=b.subarray(0,12), tag=b.subarray(-16), d= 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; + 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; + 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}`); } + 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 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); } + 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 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}); } - cur += c.size; - if(cs > e) break; + 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); } } - res.end(); - if (counterUserId) addTransfer(counterUserId, sentBytes, 'down'); + 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); } } +function broadcast(fid,d){ const cs = sseClients.get(fid); if(cs){ const p = `data: ${JSON.stringify(d)} +`; for(const r of cs) r.write(p); } } let EMBED_TPL = null; function embedTpl() { - if (EMBED_TPL === null) { - try { EMBED_TPL = readFileSync(join(PUBLIC_DIR, 'embed.html'), 'utf8'); } - catch (e) { EMBED_TPL = false; log('WARN', 'embed.html introuvable'); } - } - return EMBED_TPL || null; + if (EMBED_TPL === null) { + try { EMBED_TPL = readFileSync(join(PUBLIC_DIR, 'embed.html'), 'utf8'); } + catch (e) { EMBED_TPL = false; log('WARN', 'embed.html introuvable'); } + } + return EMBED_TPL || null; } // ========================================== // 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}); + 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}`); - logActivity(usr.id, 'login', 'Connexion'); - res.json({ok:true,user:{id:usr.id,username:usr.username,isAdmin:!!usr.is_admin,tosAccepted:!!usr.tos_accepted}}); + 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}`); + logActivity(usr.id, 'login', 'Connexion'); + 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 }); + 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});}); -// ✅ CHANGER SON MOT DE PASSE app.post('/api/password', requireAuth, async(req,res)=>{ - const { current, next } = req.body; - if (typeof current !== 'string' || typeof next !== 'string') return res.status(400).json({error:'Champs manquants'}); - if (next.length < 6) return res.status(400).json({error:'Nouveau mot de passe trop court (6 min)'}); - const usr = db.prepare('SELECT * FROM users WHERE id=?').get(req.session.userId); - const hCur = await deriveKey(current, usr.salt); - if (!hCur.equals(usr.password_hash)) return res.status(403).json({error:'Mot de passe actuel invalide'}); - const salt = randomBytes(16); - const hNew = await deriveKey(next, salt); - db.prepare('UPDATE users SET password_hash=?, salt=? WHERE id=?').run(hNew, salt, usr.id); - log('INFO', `Mot de passe changé: ${usr.username}`); - logActivity(usr.id, 'password', 'Mot de passe modifié'); - res.json({ok:true}); + const { current, next } = req.body; + if (typeof current !== 'string' || typeof next !== 'string') return res.status(400).json({error:'Champs manquants'}); + if (next.length < 6) return res.status(400).json({error:'Nouveau mot de passe trop court (6 min)'}); + const usr = db.prepare('SELECT * FROM users WHERE id=?').get(req.session.userId); + const hCur = await deriveKey(current, usr.salt); + if (!hCur.equals(usr.password_hash)) return res.status(403).json({error:'Mot de passe actuel invalide'}); + const salt = randomBytes(16); + const hNew = await deriveKey(next, salt); + db.prepare('UPDATE users SET password_hash=?, salt=? WHERE id=?').run(hNew, salt, usr.id); + log('INFO', `Mot de passe changé: ${usr.username}`); + logActivity(usr.id, 'password', 'Mot de passe modifié'); + res.json({ok:true}); }); - -// ✅ SUPPRIMER SON COMPTE (self-service) app.delete('/api/account', requireAuth, async(req,res)=>{ - const { password } = req.body; - const usr = db.prepare('SELECT * FROM users WHERE id=?').get(req.session.userId); - const h = await deriveKey(String(password || ''), usr.salt); - if (!h.equals(usr.password_hash)) return res.status(403).json({error:'Mot de passe invalide'}); - const urls = db.prepare('SELECT fc.imgbb_delete_url FROM file_chunks fc JOIN files f ON f.id=fc.file_id WHERE f.owner_id=? AND fc.imgbb_delete_url IS NOT NULL').all(usr.id).map(r => r.imgbb_delete_url); - logActivity(usr.id, 'account_delete', 'Compte supprimé'); - db.prepare('DELETE FROM users WHERE id=?').run(usr.id); - log('ALERT', `Compte #${usr.id} (${usr.username}) auto-supprimé (${urls.length} chunks en nettoyage)`); - (async () => { for (const u of urls) await imgbbDelete(u); })(); - req.session.destroy(() => res.json({ok:true})); -}); - -// ✅ STATS PERSO (donut par type) -app.get('/api/stats', requireAuth, (req,res) => { - res.json(db.prepare(`SELECT CASE WHEN mime LIKE 'image/%' THEN 'image' WHEN mime LIKE 'video/%' THEN 'video' WHEN mime LIKE 'audio/%' THEN 'audio' ELSE 'autre' END as cat, COUNT(*) c, COALESCE(SUM(size),0) s - FROM files WHERE owner_id=? AND status!='error' GROUP BY cat`).all(req.session.userId)); + const { password } = req.body; + const usr = db.prepare('SELECT * FROM users WHERE id=?').get(req.session.userId); + const h = await deriveKey(String(password || ''), usr.salt); + if (!h.equals(usr.password_hash)) return res.status(403).json({error:'Mot de passe invalide'}); + const urls = db.prepare('SELECT fc.imgbb_delete_url FROM file_chunks fc JOIN files f ON f.id=fc.file_id WHERE f.owner_id=? AND fc.imgbb_delete_url IS NOT NULL').all(usr.id).map(r => r.imgbb_delete_url); + logActivity(usr.id, 'account_delete', 'Compte supprimé'); + db.prepare('DELETE FROM users WHERE id=?').run(usr.id); + log('ALERT', `Compte #${usr.id} (${usr.username}) auto-supprimé (${urls.length} chunks en nettoyage)`); + (async () => { for (const u of urls) await imgbbDelete(u); })(); + req.session.destroy(() => res.json({ok:true})); }); -// ✅ ACTIVITÉ PERSO +app.get('/api/stats', requireAuth, (req,res) => { + res.json(db.prepare(`SELECT CASE WHEN mime LIKE 'image/%' THEN 'image' WHEN mime LIKE 'video/%' THEN 'video' WHEN mime LIKE 'audio/%' THEN 'audio' ELSE 'autre' END as cat, COUNT(*) c, COALESCE(SUM(size),0) s + FROM files WHERE owner_id=? AND status!='error' GROUP BY cat`).all(req.session.userId)); +}); app.get('/api/activity', requireAuth, (req,res) => { - res.json(db.prepare('SELECT action, detail, created_at FROM activity WHERE user_id=? ORDER BY created_at DESC LIMIT 100').all(req.session.userId)); + res.json(db.prepare('SELECT action, detail, created_at FROM activity WHERE user_id=? ORDER BY created_at DESC LIMIT 100').all(req.session.userId)); }); -// ✅ ADMIN app.get('/api/admin/stats', requireAuth, requireAdmin, (req,res) => { - const users = db.prepare('SELECT COUNT(*) c FROM users').get().c; - const files = db.prepare('SELECT COUNT(*) c, COALESCE(SUM(size),0) s FROM files').get(); - const shares = db.prepare('SELECT COUNT(*) c FROM user_shares').get().c; - const links = db.prepare('SELECT COUNT(*) c FROM share_links').get().c; - const transferToday = db.prepare('SELECT COALESCE(SUM(up_bytes + down_bytes),0) t FROM transfer_daily WHERE day=?').get(todayKey()).t; - res.json({ users, filesCount: files.c, storage: files.s, shares, links, transferToday }); + const users = db.prepare('SELECT COUNT(*) c FROM users').get().c; + const files = db.prepare('SELECT COUNT(*) c, COALESCE(SUM(size),0) s FROM files').get(); + const shares = db.prepare('SELECT COUNT(*) c FROM user_shares').get().c; + const links = db.prepare('SELECT COUNT(*) c FROM share_links').get().c; + const transferToday = db.prepare('SELECT COALESCE(SUM(up_bytes + down_bytes),0) t FROM transfer_daily WHERE day=?').get(todayKey()).t; + res.json({ users, filesCount: files.c, storage: files.s, shares, links, transferToday }); }); app.get('/api/admin/users', requireAuth, requireAdmin, (req,res) => { - const rows = db.prepare('SELECT id, username, is_admin, tos_accepted, created_at FROM users ORDER BY id').all(); - res.json(rows.map(u => ({ - ...u, - storageUsed: db.prepare("SELECT COALESCE(SUM(size),0) t FROM files WHERE owner_id=? AND status!='error'").get(u.id).t, - filesCount: db.prepare('SELECT COUNT(*) c FROM files WHERE owner_id=?').get(u.id).c, - transferToday: getTransfer(u.id).total, - }))); + const rows = db.prepare('SELECT id, username, is_admin, tos_accepted, created_at FROM users ORDER BY id').all(); + res.json(rows.map(u => ({ + ...u, + storageUsed: db.prepare("SELECT COALESCE(SUM(size),0) t FROM files WHERE owner_id=? AND status!='error'").get(u.id).t, + filesCount: db.prepare('SELECT COUNT(*) c FROM files WHERE owner_id=?').get(u.id).c, + transferToday: getTransfer(u.id).total, + }))); }); app.get('/api/admin/activity', requireAuth, requireAdmin, (req,res) => { - res.json(db.prepare(`SELECT a.action, a.detail, a.created_at, COALESCE(u.username,'public') as username FROM activity a LEFT JOIN users u ON u.id=a.user_id ORDER BY a.created_at DESC LIMIT 100`).all()); + res.json(db.prepare(`SELECT a.action, a.detail, a.created_at, COALESCE(u.username,'public') as username FROM activity a LEFT JOIN users u ON u.id=a.user_id ORDER BY a.created_at DESC LIMIT 100`).all()); }); app.delete('/api/admin/users/:id', requireAuth, requireAdmin, (req,res) => { - const id = parseInt(req.params.id, 10); - if (!id) return res.status(400).json({error:'ID invalide'}); - if (id === req.session.userId) return res.status(400).json({error:'Impossible de supprimer votre propre compte'}); - const urls = db.prepare('SELECT fc.imgbb_delete_url FROM file_chunks fc JOIN files f ON f.id=fc.file_id WHERE f.owner_id=? AND fc.imgbb_delete_url IS NOT NULL').all(id).map(r => r.imgbb_delete_url); - db.prepare('DELETE FROM users WHERE id=?').run(id); - log('ALERT', `Admin ${req.session.username} a supprimé le compte #${id}`); - (async () => { for (const u of urls) await imgbbDelete(u); })(); - res.json({ok:true}); + const id = parseInt(req.params.id, 10); + if (!id) return res.status(400).json({error:'ID invalide'}); + if (id === req.session.userId) return res.status(400).json({error:'Impossible de supprimer votre propre compte'}); + const urls = db.prepare('SELECT fc.imgbb_delete_url FROM file_chunks fc JOIN files f ON f.id=fc.file_id WHERE f.owner_id=? AND fc.imgbb_delete_url IS NOT NULL').all(id).map(r => r.imgbb_delete_url); + db.prepare('DELETE FROM users WHERE id=?').run(id); + log('ALERT', `Admin ${req.session.username} a supprimé le compte #${id}`); + (async () => { for (const u of urls) await imgbbDelete(u); })(); + res.json({ok:true}); }); app.post('/api/admin/users/:id/toggle-admin', requireAuth, requireAdmin, (req,res) => { - const id = parseInt(req.params.id, 10); - if (!id) return res.status(400).json({error:'ID invalide'}); - if (id === req.session.userId) return res.status(400).json({error:'Vous ne pouvez pas changer votre propre rôle'}); - db.prepare('UPDATE users SET is_admin = 1 - is_admin WHERE id=?').run(id); - log('ALERT', `Admin ${req.session.username} a inversé le rôle admin du compte #${id}`); - res.json({ok:true}); + const id = parseInt(req.params.id, 10); + if (!id) return res.status(400).json({error:'ID invalide'}); + if (id === req.session.userId) return res.status(400).json({error:'Vous ne pouvez pas changer votre propre rôle'}); + db.prepare('UPDATE users SET is_admin = 1 - is_admin WHERE id=?').run(id); + log('ALERT', `Admin ${req.session.username} a inversé le rôle admin du compte #${id}`); + 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'}); } + 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); + 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); }); +// Folders 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)); + 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});}); 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); logActivity(req.session.userId,'rename',name.trim()); res.json({ok:true}); } - catch(e){ res.status(409).json({error:'Un dossier porte déjà ce nom'}); } + 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); logActivity(req.session.userId,'rename',name.trim()); res.json({ok:true}); } + catch(e){ res.status(409).json({error:'Un dossier porte déjà ce nom'}); } }); 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++; - } + 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); - logActivity(req.session.userId,'move',fo.name); - res.json({ok:true}); + } + db.prepare('UPDATE folders SET parent_id=? WHERE id=?').run(parent_id || null, req.params.id); + logActivity(req.session.userId,'move',fo.name); + 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)); +// ✅ LIEN PUBLIC D'UN DOSSIER +app.post('/api/folders/:id/share', requireAuth, shareLimit, (req,res)=>{ + const fid = parseInt(req.params.id, 10); + if (!fid) return res.status(400).json({error:'ID invalide'}); + const fo = db.prepare('SELECT * FROM folders WHERE id=? AND owner_id=?').get(fid, req.session.userId); + if (!fo) return res.status(404).json({error:'Introuvable'}); + const ttlReq = parseInt(req.body?.ttl, 10); + const ttl = TTL_ALLOWED.includes(ttlReq) ? ttlReq : 168; + const token = uuidv4(); + const expires = Date.now() + ttl * 3600000; + db.prepare('INSERT INTO share_links(token,file_id,folder_id,owner_id,type,expires_at) VALUES(?,?,?,?,?,?)').run(token, null, fid, req.session.userId, 'embed', expires); + logActivity(req.session.userId, 'share_link', 'Dossier ' + fo.name + ' (' + ttl + 'h)'); + res.json({ preview: '/s/' + token, expires, ttl }); +}); + +// ✅ ZIP D'UN DOSSIER (récursif) +app.get('/api/folders/:id/zip', requireAuth, zipLimit, async(req,res)=>{ + const fid = parseInt(req.params.id, 10); + if (!fid) return res.status(400).json({error:'ID invalide'}); + if (!canAccessFolder(req.session.userId, fid)) return res.status(404).json({error:'Introuvable'}); + if (transferBlocked(req.session.userId)) return res.status(429).json({error:'Quota de transfert journalier atteint, réessayez demain'}); + const folder = db.prepare('SELECT * FROM folders WHERE id=?').get(fid); + const out = []; + collectFolderFiles(fid, '', out, 0); + if (!out.length) return res.status(404).json({error:'Aucun fichier prêt dans ce dossier'}); + if (out.length > 500) out.length = 500; + const zipName = zipSafeName(folder ? folder.name : 'dossier') + '.zip'; + res.set({ 'Content-Type': 'application/zip', 'Content-Disposition': `attachment; filename="${encodeURIComponent(zipName)}"`, 'Cache-Control': 'no-store' }); + const state = { offset: 0, entries: [], used: new Set() }; + let totalSent = 0; + try { + for (const f of out) { + const key = await getMasterKey(f.id); + totalSent += await zipAddFile(res, f, key, state); + } + zipFinish(res, state); + addTransfer(req.session.userId, totalSent, 'down'); + logActivity(req.session.userId, 'zip', zipName + ' (' + out.length + ' fichiers)'); + log('INFO', `ZIP généré: ${zipName} (${out.length} fichiers, ${(totalSent/1048576).toFixed(1)} MB)`); + } catch (e) { log('ERROR', `ZIP ${zipName}: ${e.message}`); res.destroy(e); } +}); + +// ✅ ZIP D'UNE SÉLECTION DE FICHIERS +app.post('/api/files/zip', requireAuth, zipLimit, async(req,res)=>{ + const { ids } = req.body; + if (!Array.isArray(ids) || !ids.length) return res.status(400).json({error:'Sélection invalide'}); + if (ids.length > 200) return res.status(400).json({error:'Trop de fichiers (200 max)'}); + if (transferBlocked(req.session.userId)) return res.status(429).json({error:'Quota de transfert journalier atteint, réessayez demain'}); + const out = []; + for (const id of ids) { + if (!UUID_RE.test(String(id))) continue; + const f = canAccessFile(req.session.userId, id); + if (f && f.status === 'ready') out.push(f); + } + if (!out.length) return res.status(404).json({error:'Aucun fichier valide'}); + res.set({ 'Content-Type': 'application/zip', 'Content-Disposition': 'attachment; filename="selection.zip"', 'Cache-Control': 'no-store' }); + const state = { offset: 0, entries: [], used: new Set() }; + let totalSent = 0; + try { + for (const f of out) { + const key = await getMasterKey(f.id); + totalSent += await zipAddFile(res, f, key, state); } - res.json(db.prepare('SELECT * FROM files WHERE owner_id=? AND folder_id IS NULL ORDER BY name COLLATE NOCASE').all(req.session.userId)); + zipFinish(res, state); + addTransfer(req.session.userId, totalSent, 'down'); + logActivity(req.session.userId, 'zip', out.length + ' fichiers (sélection)'); + log('INFO', `ZIP sélection: ${out.length} fichiers (${(totalSent/1048576).toFixed(1)} MB)`); + } catch (e) { log('ERROR', `ZIP sélection: ${e.message}`); res.destroy(e); } }); +// Files +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)); +}); 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); - logActivity(req.session.userId,'rename',name.trim()); - res.json({ok:true}); + 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); + logActivity(req.session.userId,'rename',name.trim()); + res.json({ok:true}); }); 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); - logActivity(req.session.userId,'move',f.name); - res.json({ok:true}); + 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); + logActivity(req.session.userId,'move',f.name); + 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()}`); - logActivity(req.session.userId,'share_user',`${type} → ${username.trim()}`); - res.json({ok:true}); +// ✅ RECHERCHE GLOBALE (fichiers en entier + dossiers) +app.get('/api/search', requireAuth, (req,res)=>{ + const q = String(req.query.q || '').trim(); + if (!q) return res.json({ files: [], folders: [] }); + const clean = q.replace(/[%_]/g, ' '); + const like = '%' + clean + '%'; + const me = req.session.userId; + const files = db.prepare(` + SELECT f.*, u.username as owner_name FROM files f + LEFT JOIN users u ON u.id = f.owner_id + WHERE (f.owner_id = ? + OR f.id IN (SELECT file_id FROM user_shares WHERE shared_with = ? AND file_id IS NOT NULL) + OR f.folder_id IN (SELECT folder_id FROM user_shares WHERE shared_with = ? AND folder_id IS NOT NULL)) + AND f.name LIKE ? COLLATE NOCASE + ORDER BY f.updated_at DESC LIMIT 100 + `).all(me, me, me, like); + const folders = db.prepare(` + SELECT fo.*, u.username as owner_name FROM folders fo + LEFT JOIN users u ON u.id = fo.owner_id + WHERE (fo.owner_id = ? OR fo.id IN (SELECT folder_id FROM user_shares WHERE shared_with = ? AND folder_id IS NOT NULL)) + AND fo.name LIKE ? COLLATE NOCASE + ORDER BY fo.name COLLATE NOCASE LIMIT 50 + `).all(me, me, like); + res.json({ files, folders }); }); +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()}`); + logActivity(req.session.userId,'share_user',`${type} → ${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)); + 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)); + 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}); + 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.get('/api/share-links',requireAuth,(req,res)=>{ res.json(db.prepare(`SELECT sl.*, f.name as file_name, f.mime, f.size, fo.name as folder_name FROM share_links sl LEFT JOIN files f ON sl.file_id = f.id LEFT JOIN folders fo ON fo.id = sl.folder_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)); + 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'); - logActivity(uid, 'upload', path.split('/').pop()); - 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 (_) {} + 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'); + logActivity(uid, 'upload', path.split('/').pop()); + 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}); - logActivity(req.session.userId,'download',f.name); - const k = await getMasterKey(f.id); - await streamFile(res,f,k,req.headers.range,req.session.userId); + 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}); + logActivity(req.session.userId,'download',f.name); + const k = await getMasterKey(f.id); + await streamFile(res,f,k,req.headers.range,req.session.userId); }); - -// ✅ PARTAGE AVEC TTL AU CHOIX (1h / 24h / 7j / 30j) 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 ttlReq = parseInt(req.body.ttl, 10); - const ttl = TTL_ALLOWED.includes(ttlReq) ? ttlReq : 168; - const pt=uuidv4(), dt2=uuidv4(), expires=Date.now()+ttl*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); - logActivity(req.session.userId,'share_link',`${f.name} (${ttl}h)`); - res.json({preview:`/s/${pt}`,download:`/d/${dt2}`,expires,ttl}); -}); - -// ✅ /s/ : embed + compteur de vues + activité + 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 ttlReq = parseInt(req.body?.ttl, 10); + const ttl = TTL_ALLOWED.includes(ttlReq) ? ttlReq : 168; + const pt=uuidv4(), dt2=uuidv4(), expires=Date.now()+ttl*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); + logActivity(req.session.userId,'share_link',`${f.name} (${ttl}h)`); + res.json({preview:`/s/${pt}`,download:`/d/${dt2}`,expires,ttl}); +}); + +// ✅ /s/:token : fichier OU dossier public app.get('/s/:token',async(req,res)=>{ - const tpl = embedTpl(); - const serve = (status, data, og) => { - if (!tpl) return res.status(status).send('Lien invalide'); - const out = tpl.replace('__OG_META__', og || '').replace('__EMBED_DATA__', JSON.stringify(data).replace(/?)').get(req.params.token,'embed',Date.now()); - if (!l) return serve(404, { error: 'Lien invalide ou expiré' }); - const f = db.prepare('SELECT * FROM files WHERE id=?').get(l.file_id); - if (!f) return serve(404, { error: 'Fichier supprimé' }); + const tpl = embedTpl(); + const serve = (status, data, og) => { + if (!tpl) return res.status(status).send('Lien invalide'); + const out = tpl.replace('__OG_META__', og || '').replace('__EMBED_DATA__', JSON.stringify(data).replace(/?)').get(req.params.token,'embed',Date.now()); + if (!l) return serve(404, { error: 'Lien invalide ou expiré' }); + + // ✅ DOSSIER PUBLIC + if (l.folder_id) { + const folder = db.prepare('SELECT * FROM folders WHERE id=?').get(l.folder_id); + if (!folder) return serve(404, { error: 'Dossier supprimé' }); db.prepare('UPDATE share_links SET views = views + 1 WHERE token=?').run(req.params.token); - logActivity(f.owner_id, 'public_view', f.name); - 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 og = ``; - log('INFO', `Accès public /s/${req.params.token} (${f.name})`); - serve(200, { name: f.name, size: f.size, mime: f.mime, dl: '/d/' + dt }, og); + logActivity(l.owner_id, 'public_view', 'Dossier ' + folder.name); + const out = []; + collectFolderFiles(l.folder_id, '', out, 0); + const items = out.map(f => ({ id: f.id, name: f.name, size: f.size, mime: f.mime, dl: '/s/' + req.params.token + '/f/' + f.id })); + const og = ``; + log('INFO', `Accès public /s/${req.params.token} (dossier ${folder.name}, ${items.length} fichiers)`); + return serve(200, { type: 'folder', name: folder.name, files: items }, og); + } + + // FICHIER + const f = db.prepare('SELECT * FROM files WHERE id=?').get(l.file_id); + if (!f) return serve(404, { error: 'Fichier supprimé' }); + db.prepare('UPDATE share_links SET views = views + 1 WHERE token=?').run(req.params.token); + logActivity(f.owner_id, 'public_view', f.name); + 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 og = ``; + log('INFO', `Accès public /s/${req.params.token} (${f.name})`); + serve(200, { type: 'file', name: f.name, size: f.size, mime: f.mime, dl: '/d/' + dt }, og); +}); + +// ✅ Télécharger un fichier d'un dossier public +app.get('/s/:token/f/:fileId',async(req,res)=>{ + const l = db.prepare('SELECT * FROM share_links WHERE token=? AND type=? AND folder_id IS NOT NULL AND (expires_at IS NULL OR expires_at>?)').get(req.params.token,'embed',Date.now()); + if(!l)return res.status(404).json({error:'Invalide'}); + if(!UUID_RE.test(req.params.fileId)) return res.status(400).json({error:'ID invalide'}); + const f = db.prepare('SELECT * FROM files WHERE id=? AND status=?').get(req.params.fileId,'ready'); + if(!f)return res.status(404).json({error:'Indisponible'}); + const out = []; + collectFolderFiles(l.folder_id, '', out, 0); + if (!out.some(x => x.id === f.id)) return res.status(403).json({error:'Accès refusé'}); + if (transferBlocked(l.owner_id)) return res.status(429).json({error:'Quota journalier du propriétaire atteint'}); + db.prepare('UPDATE share_links SET downloads = downloads + 1 WHERE token=?').run(req.params.token); + logActivity(l.owner_id, 'public_download', f.name); + log('INFO', `Téléchargement dossier public /s/${req.params.token}/f/${f.id} (${f.name})`); + const mk = await getMasterKey(f.id); + await streamFile(res, f, mk, req.headers.range, l.owner_id); }); -// ✅ /d/ : compteur de téléchargements + activité 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'}); } - db.prepare('UPDATE share_links SET downloads = downloads + 1 WHERE token=?').run(req.params.token); - logActivity(f.owner_id, 'public_download', f.name); - 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); + 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'}); } + db.prepare('UPDATE share_links SET downloads = downloads + 1 WHERE token=?').run(req.params.token); + logActivity(f.owner_id, 'public_download', f.name); + 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(_){} - logActivity(req.session.userId,'delete',f.name); - res.json({ok:true}); + 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(_){} + logActivity(req.session.userId,'delete',f.name); + 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`); + 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)); + 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('/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')); + 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' }); + 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.8 démarré | stockage ${(USER_QUOTA/1073741824).toFixed(0)} GB | transit ${(DAILY_TRANSFER/1073741824).toFixed(0)} GB/j`)); +const server = app.listen(PORT, '0.0.0.0', () => log('INFO', `PixelDrive v4.9 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)); }); \ No newline at end of file