NathMen12 commited on
Commit
7a6d5b8
·
verified ·
1 Parent(s): 3160b63

Update server.js

Browse files
Files changed (1) hide show
  1. server.js +178 -162
server.js CHANGED
@@ -1,11 +1,12 @@
1
  // ==========================================
2
- // PIXELDRIVE v5.1 - SERVER.JS (ESM) - DÉFINITIF
 
3
  // ==========================================
4
  import express from 'express';
5
  import session from 'express-session';
6
  import Database from 'better-sqlite3';
7
  import multer from 'multer';
8
- import { createHash, randomBytes, scrypt, createCipheriv, createDecipheriv } from 'crypto';
9
  import { promisify } from 'util';
10
  import { createReadStream, unlinkSync, existsSync, readdirSync, statSync, rmSync, copyFileSync, mkdirSync, readFileSync, writeFileSync, appendFileSync } from 'fs';
11
  import { join } from 'path';
@@ -91,7 +92,7 @@ CREATE TABLE IF NOT EXISTS folders (id INTEGER PRIMARY KEY AUTOINCREMENT, parent
91
  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')));
92
  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));
93
  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);
94
- 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')));
95
  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));
96
  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')));
97
  CREATE TABLE IF NOT EXISTS notifications (id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, type TEXT NOT NULL, detail TEXT, read INTEGER DEFAULT 0, created_at INTEGER DEFAULT (strftime('%s','now')));
@@ -109,6 +110,11 @@ function runMigrations() {
109
  const usersCols = db.prepare("PRAGMA table_info(users)").all().map(c => c.name);
110
  if (!usersCols.includes('tos_accepted')) db.exec(`ALTER TABLE users ADD COLUMN tos_accepted INTEGER DEFAULT 0;`);
111
  if (!usersCols.includes('avatar')) db.exec(`ALTER TABLE users ADD COLUMN avatar BLOB;`);
 
 
 
 
 
112
  const filesCols = db.prepare("PRAGMA table_info(files)").all().map(c => c.name);
113
  const filesDefs = [
114
  { name: 'folder_id', def: 'INTEGER REFERENCES folders(id) ON DELETE SET NULL' },
@@ -131,7 +137,7 @@ function runMigrations() {
131
  const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table'").all().map(t => t.name);
132
  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));`);
133
  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);`);
134
- 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);`);
135
  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);`);
136
  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);`);
137
  if (!tables.includes('notifications')) db.exec(`CREATE TABLE notifications (id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, type TEXT NOT NULL, detail TEXT, read INTEGER DEFAULT 0, created_at INTEGER DEFAULT (strftime('%s','now'))); CREATE INDEX idx_notif_user ON notifications(user_id, read, created_at);`);
@@ -288,8 +294,33 @@ function encryptMasterKey(k){ const iv=randomBytes(12); const c=createCipheriv('
288
  function decryptMasterKey(b){ const iv=b.subarray(0,12), tag=b.subarray(-16), d=b.subarray(12,-16); const dc=createDecipheriv('aes-256-gcm',SERVER_MASTER_KEY,iv); dc.setAuthTag(tag); return Buffer.concat([dc.update(d),dc.final()]); }
289
  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); }
290
 
291
- 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; }
292
- 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; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
293
  function isEditable(f) { return (f.mime && (f.mime.startsWith('text/') || ['application/json','application/javascript','application/xml'].includes(f.mime))) || EDITABLE_RE.test(f.name || ''); }
294
 
295
  async function decryptWholeFile(f) { const k = await getMasterKey(f.id); const chunks = db.prepare('SELECT chunk_index,imgbb_url,size FROM file_chunks WHERE file_id=? ORDER BY chunk_index').all(f.id); const bufs = []; for (const c of chunks) { const ck = `chunk:${f.id}:${c.chunk_index}`; let dec = fileCache.get(ck)?.data; if (!dec) { const {data} = await axios.get(c.imgbb_url,{responseType:'arraybuffer',timeout:60000}); const enc = await pngToBuffer(Buffer.from(data), c.size + OVERHEAD); dec = decryptChunk(enc, k); fileCache.set(ck,{data:dec,size:dec.length}); } bufs.push(dec); } return Buffer.concat(bufs, f.size); }
@@ -311,164 +342,74 @@ const partsDir = (fid) => join(TEMP_DIR, 'up_' + fid);
311
  const assembledPath = (fid) => join(TEMP_DIR, 'assemble_' + fid);
312
  const pad6 = (i) => String(i).padStart(6, '0');
313
 
314
- app.post('/api/upload/init', requireAuth, requireTos, uploadLimit, (req,res) => {
315
- const { name, size, mime, folder_id } = req.body;
316
- if (!name || !size) return res.status(400).json({error:'Champs manquants'});
317
- const uid = req.session.userId;
318
- const tr = getTransfer(uid);
319
- const remaining = DAILY_TRANSFER - tr.total;
320
- if (remaining <= 0) return res.status(429).json({error:'Quota de transfert journalier atteint'});
321
- if (size > remaining) return res.status(413).json({error:`Quota journalier insuffisant (reste ${(remaining/1073741824).toFixed(1)} GB)`});
322
- const used = db.prepare("SELECT COALESCE(SUM(size),0) as t FROM files WHERE owner_id=? AND status!='error'").get(uid).t;
323
- if (used + size > USER_QUOTA) return res.status(413).json({error:`Quota de stockage dépassé (${(USER_QUOTA/1073741824).toFixed(0)} GB max)`});
324
- const fid = uuidv4();
325
- const mk = randomBytes(KEY_LEN);
326
- const kh = createHash('sha256').update(mk).digest('hex');
327
- const mke = encryptMasterKey(mk);
328
- const cc = Math.ceil(size / CHUNK_SIZE);
329
- db.prepare(`INSERT INTO files(id,folder_id,owner_id,name,mime,size,chunk_count,status,encryption_key_hash,master_key_enc) VALUES(?,?,?,?,?,?,?,'uploading',?,?)`).run(fid, folder_id||null, uid, name, mime||'application/octet-stream', size, cc, kh, mke);
330
- const stmt = db.prepare('INSERT INTO file_chunks(file_id,chunk_index,size,encrypted_len) VALUES(?,?,?,?)');
331
- const tx = db.transaction((arr)=>{for(const c of arr)stmt.run(...c);});
332
- const cd = [];
333
- for (let i=0;i<cc;i++){ const sz = Math.min(CHUNK_SIZE, size - i*CHUNK_SIZE); cd.push([fid,i,sz,sz+OVERHEAD]); }
334
- tx(cd);
335
- mkdirSync(partsDir(fid), { recursive: true });
336
- log('INFO', `Upload init ${fid} (${name}, ${(size/1048576).toFixed(2)} MB, ${Math.ceil(size/UP_CHUNK)} chunks transport)`);
337
- res.json({ uploadId: fid, chunkSize: UP_CHUNK });
338
- });
339
-
340
- const chunkUpload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 6*1024*1024 } });
341
- app.post('/api/upload/chunk', requireAuth, chunkUpload.single('chunk'), (req,res) => {
342
- const { uploadId, index } = req.body;
343
- const f = db.prepare('SELECT * FROM files WHERE id=? AND owner_id=?').get(uploadId, req.session.userId);
344
- if (!f) return res.status(404).json({error:'Introuvable'});
345
- if (f.status !== 'uploading') return res.status(409).json({error:'Upload non modifiable'});
346
- const i = parseInt(index, 10);
347
- const total = Math.ceil(f.size / UP_CHUNK);
348
- if (isNaN(i) || i < 0 || i >= total) return res.status(400).json({error:'Index invalide'});
349
- const expect = (i === total-1) ? (f.size - i*UP_CHUNK) : UP_CHUNK;
350
- if (!req.file || req.file.size !== expect) return res.status(400).json({error:'Taille de chunk invalide'});
351
- mkdirSync(partsDir(f.id), { recursive: true });
352
- writeFileSync(join(partsDir(f.id), pad6(i)), req.file.buffer);
353
- res.json({ ok: true, index: i });
354
- });
355
-
356
- app.get('/api/upload/status', requireAuth, (req,res) => {
357
- const fid = req.query.id;
358
- const f = db.prepare('SELECT id,size,status,progress FROM files WHERE id=? AND owner_id=?').get(fid, req.session.userId);
359
- if (!f) return res.status(404).json({error:'Introuvable'});
360
- const total = Math.ceil(f.size / UP_CHUNK);
361
- let received = [];
362
- try { received = readdirSync(partsDir(fid)).map(x=>parseInt(x,10)).filter(n=>!isNaN(n)); } catch(e){}
363
- res.json({ status: f.status, progress: f.progress, total, received });
364
- });
365
-
366
- function assembleParts(fid) {
367
- const f = db.prepare('SELECT size FROM files WHERE id=?').get(fid);
368
- const total = Math.ceil(f.size / UP_CHUNK);
369
- const dir = partsDir(fid);
370
- const out = assembledPath(fid);
371
- for (let i=0;i<total;i++) appendFileSync(out, readFileSync(join(dir, pad6(i))));
372
- rmSync(dir, { recursive: true, force: true });
373
- return out;
374
- }
375
- function queueProcess(fid) {
376
- const f = db.prepare('SELECT size, owner_id FROM files WHERE id=?').get(fid);
377
- if (!f) return;
378
- const uname = (db.prepare('SELECT username FROM users WHERE id=?').get(f.owner_id)||{}).username || '?';
379
- uploadQueue.add(() => processUpload(fid, assembledPath(fid), f.size, uname, f.owner_id));
380
- }
381
-
382
- app.post('/api/upload/complete', requireAuth, (req,res) => {
383
- const { uploadId } = req.body;
384
- const f = db.prepare('SELECT * FROM files WHERE id=? AND owner_id=?').get(uploadId, req.session.userId);
385
- if (!f) return res.status(404).json({error:'Introuvable'});
386
- if (f.status !== 'uploading') return res.status(409).json({error:'Déjà complété'});
387
- const total = Math.ceil(f.size / UP_CHUNK);
388
- let received = [];
389
- try { received = readdirSync(partsDir(f.id)).map(x=>parseInt(x,10)).filter(n=>!isNaN(n)); } catch(e){}
390
- const missing = []; for (let i=0;i<total;i++) if (!received.includes(i)) missing.push(i);
391
- if (missing.length) return res.status(400).json({error:'Chunks manquants', missing});
392
- try { assembleParts(f.id); } catch(e){ log('ERROR', `Assemblage ${f.id}: ${e.message}`); return res.status(500).json({error:'Erreur assemblage'}); }
393
- db.prepare("UPDATE files SET status='processing', progress=50 WHERE id=?").run(f.id);
394
- queueProcess(f.id);
395
- log('INFO', `Upload complete ${f.id} → traitement arrière-plan`);
396
- res.json({ ok: true });
397
- });
398
-
399
- function recoverStuckUploads() {
400
- const stuck = db.prepare("SELECT id, size, status FROM files WHERE status IN ('uploading','processing')").all();
401
- for (const f of stuck) {
402
- try {
403
- const assembled = assembledPath(f.id);
404
- if (existsSync(assembled)) { queueProcess(f.id); log('INFO', `Recovery: ${f.id} re-file (processing)`); continue; }
405
- const total = Math.ceil(f.size / UP_CHUNK);
406
- let n = 0; try { n = readdirSync(partsDir(f.id)).length; } catch(e){}
407
- if (total > 0 && n >= total) { assembleParts(f.id); queueProcess(f.id); log('INFO', `Recovery: ${f.id} assemblé + re-file`); }
408
- } catch(e) { log('WARN', `Recovery ${f.id}: ${e.message}`); }
409
  }
410
  }
411
 
412
- // Ancien upload monolithique (compatibilité)
413
- const upload = multer({dest:TEMP_DIR,limits:{fileSize:5*1024*1024*1024}});
414
- app.post('/api/files/upload', requireAuth, requireTos, uploadLimit, upload.single('file'), async(req,res)=>{
415
- if(!req.file)return res.status(400).json({error:'Fichier manquant'});
416
- const uid = req.session.userId; const fsize = req.file.size;
417
- const tr = getTransfer(uid); const remaining = DAILY_TRANSFER - tr.total;
418
- if (remaining <= 0) { try{unlinkSync(req.file.path);}catch(_){} return res.status(429).json({error:'Quota de transfert journalier atteint'}); }
419
- if (fsize > remaining) { try{unlinkSync(req.file.path);}catch(_){} return res.status(413).json({error:'Quota journalier insuffisant'}); }
420
- const used = db.prepare("SELECT COALESCE(SUM(size),0) as t FROM files WHERE owner_id=? AND status!='error'").get(uid).t;
421
- if (used + fsize > USER_QUOTA) { try{unlinkSync(req.file.path);}catch(_){} return res.status(413).json({error:'Quota de stockage dépassé'}); }
422
- 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;
423
- 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);
424
- const stmt=db.prepare('INSERT INTO file_chunks(file_id,chunk_index,size,encrypted_len) VALUES(?,?,?,?)');
425
- const tx=db.transaction((arr)=>{for(const c of arr)stmt.run(...c);});
426
- const cd=[]; for(let i=0;i<cc;i++){const sz=Math.min(CHUNK_SIZE,fsize-i*CHUNK_SIZE);cd.push([fid,i,sz,sz+OVERHEAD]);} tx(cd);
427
- res.json({fileId:fid,status:'pending',chunkCount:cc});
428
- uploadQueue.add(()=>processUpload(fid,fpath,fsize,req.session.username,uid));
429
- });
430
-
431
- async function processUpload(fid, path, fsize, username, uid){
432
  const t0 = Date.now();
433
  const mk = await getMasterKey(fid);
434
  const frow = db.prepare('SELECT mime FROM files WHERE id=?').get(fid);
435
  const mime = frow?.mime || 'application/octet-stream';
436
  const cc = Math.ceil(fsize / CHUNK_SIZE);
437
- log('INFO', `Traitement démarré: ${fid} par ${username} (${(fsize/1048576).toFixed(2)} MB, ${cc} chunk(s))`);
438
- try{
439
  db.prepare("UPDATE files SET status='uploading' WHERE id=?").run(fid);
440
- for(let i=0;i<cc;i++){
441
- const ci = db.prepare('SELECT size, encrypted_len, imgbb_url FROM file_chunks WHERE file_id=? AND chunk_index=?').get(fid,i);
442
- if (ci.imgbb_url) { const prog=((i+1)/cc)*50; db.prepare('UPDATE files SET progress=? WHERE id=?').run(prog,fid); broadcast(fid,{type:'progress',progress:prog,status:'uploading'}); continue; }
443
- const off=i*CHUNK_SIZE;
444
- const raw=await readChunk(path,off,ci.size);
445
- if (i === 0 && mime.startsWith('image/')) { const scan = await checkNsfw(raw); if (!scan.safe) { log('ALERT', `NSFW bloqué: ${fid} par ${username}`); throw new Error('Contenu bloqué par le filtre de modération'); } }
446
- const enc=encryptChunk(raw,mk);
447
- if(enc.length>ci.encrypted_len)throw new Error(`Overflow chunk ${i}`);
448
- const png=await bufferToPng(enc);
449
- const{url,deleteUrl,id}=await imgbbUpload(png);
450
- 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);
451
- const prog=((i+1)/cc)*50;
452
- db.prepare('UPDATE files SET progress=?,status=? WHERE id=?').run(prog,'uploading',fid);
453
- broadcast(fid,{type:'progress',progress:prog,status:'uploading'});
454
- if(i<cc-1)await new Promise(r=>setTimeout(r,3200));
455
  }
456
- try{ unlinkSync(path); }catch(_){}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
457
  db.prepare("UPDATE files SET status='processing',progress=50 WHERE id=?").run(fid);
458
- broadcast(fid,{type:'progress',progress:50,status:'processing'});
459
- await genThumb(fid,mime,mk);
460
  db.prepare("UPDATE files SET status='ready',progress=100 WHERE id=?").run(fid);
461
- broadcast(fid,{type:'progress',progress:100,status:'ready',type:'done'});
462
  addTransfer(uid, fsize, 'up');
463
  logActivity(uid, 'upload', path.split('/').pop());
464
- const secs = (Date.now()-t0)/1000;
465
  log('INFO', `Traitement terminé: ${fid} en ${secs.toFixed(1)}s (${(fsize/1048576/secs).toFixed(2)} MB/s)`);
466
- }catch(e){
467
  log('ERROR', `Traitement échoué ${fid}: ${e.message}`);
468
- db.prepare("UPDATE files SET status='error',error_msg=? WHERE id=?").run(e.message,fid);
469
- broadcast(fid,{type:'error',error:e.message});
470
- const cs=db.prepare('SELECT imgbb_delete_url FROM file_chunks WHERE file_id=?').all(fid);
471
- for(const c of cs)if(c.imgbb_delete_url)await imgbbDelete(c.imgbb_delete_url);
472
  }
473
  }
474
  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);});}
@@ -477,15 +418,15 @@ function readChunk(p,o,l){return new Promise((res,rej)=>{const s=createReadStrea
477
  // ROUTES
478
  // ==========================================
479
  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 invalide'}); if(typeof p !== 'string' || p.length < 6) return res.status(400).json({error:'Mot de passe trop court'}); 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}); });
480
- 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) { 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}}); });
481
  app.post('/api/logout',(req,res)=>req.session.destroy(()=>res.json({ok:true})));
482
  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 }); });
483
  app.get('/api/tos',(_,res)=>res.send(`PIXELDRIVE CGU\n1. RESPONSABILITÉ TOTALE\n2. AUCUNE GARANTIE (Projet test)\n3. INTERDITS: Illégal, NSFW, Abus\n4. CHIFFRÉ AES-256-GCM + Filtre NSFW Local\n5. ACCEPTATION = Responsabilité légale`));
484
  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});});
485
  app.get('/api/notifications', requireAuth, (req,res) => { res.json(db.prepare('SELECT id, type, detail, read, created_at FROM notifications WHERE user_id=? ORDER BY created_at DESC LIMIT 50').all(req.session.userId)); });
486
  app.post('/api/notifications/read', requireAuth, (req,res) => { db.prepare('UPDATE notifications SET read=1 WHERE user_id=?').run(req.session.userId); res.json({ok:true}); });
487
- 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:'Trop court'}); 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); logActivity(usr.id, 'password', 'Mot de passe modifié'); res.json({ok:true}); });
488
- 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); db.prepare('DELETE FROM users WHERE id=?').run(usr.id); (async () => { for (const u of urls) await imgbbDelete(u); })(); req.session.destroy(() => res.json({ok:true})); });
489
  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)); });
490
  app.get('/api/stats/advanced', requireAuth, (req,res) => { const days = []; for (let i = 6; i >= 0; i--) { const d = new Date(Date.now() - i*86400000).toISOString().slice(0,10); const r = db.prepare('SELECT COALESCE(SUM(up_bytes),0) u, COALESCE(SUM(down_bytes),0) d FROM transfer_daily WHERE user_id=? AND day=?').get(req.session.userId, d); const acts = db.prepare("SELECT COUNT(*) c FROM activity WHERE user_id=? AND strftime('%Y-%m-%d', created_at, 'unixepoch')=?").get(req.session.userId, d).c; days.push({ day: d.slice(5), up: r.u, down: r.d, acts }); } const top = db.prepare(`SELECT f.name, (SELECT COALESCE(SUM(downloads),0) FROM share_links sl WHERE sl.file_id=f.id) dl FROM files f WHERE f.owner_id=? AND f.status='ready' ORDER BY dl DESC LIMIT 5`).all(req.session.userId); res.json({ days, top }); });
491
  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)); });
@@ -496,32 +437,79 @@ app.delete('/api/admin/users/:id', requireAuth, requireAdmin, (req,res) => { con
496
  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:'Non'}); db.prepare('UPDATE users SET is_admin = 1 - is_admin WHERE id=?').run(id); res.json({ok:true}); });
497
  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:'Trop lourde'}); 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); res.json({ok:true}); } catch(e){ res.status(400).json({error:'Image invalide'}); } });
498
  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}); });
499
- 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); });
500
- 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)); });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
501
  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'});}});
502
  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});});
503
  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:'Nom déjà utilisé'}); } });
504
  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:'Cible invalide'}); let cur = target, depth = 0; while (cur && depth < 50) { if (cur.id === fo.id) return res.status(400).json({error:'Boucle détectée'}); 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}); });
505
- 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)); });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
506
  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}); });
507
  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:'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}); });
508
- 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 avec vous-même'}); const col = type === 'file' ? 'file_id' : 'folder_id'; const own = db.prepare(`SELECT id, name 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); notify(target.id, 'share', `${req.session.username} → ${own.name}`); logActivity(req.session.userId,'share_user',`${type} → ${username.trim()}`); res.json({ok:true}); });
509
- 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)); });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
510
  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}); });
511
- 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)); });
512
  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'}); res.json({ok:true}); });
513
  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)); });
514
  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}); });
 
515
  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)) return res.status(429).json({error:'Quota journalier atteint'}); 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); });
516
  app.get('/api/files/:id/raw', requireAuth, async(req,res)=>{ if(!UUID_RE.test(req.params.id)) return res.status(400).json({error:'ID invalide'}); 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'}); if (f.size > 2*1024*1024) return res.status(413).json({error:'Trop lourd pour aperçu (2 MB max)'}); try { const all = await decryptWholeFile(f); res.set({'Content-Type': (f.mime && f.mime.startsWith('text/')) ? f.mime : 'text/plain; charset=utf-8'}); res.send(all); } catch (e) { res.status(500).json({error:'Erreur déchiffrement'}); } });
517
- app.post('/api/files/:id/edit', requireAuth, requireTos, async(req,res)=>{ if(!UUID_RE.test(req.params.id)) return res.status(400).json({error:'ID invalide'}); const { content } = req.body; if (typeof content !== 'string') return res.status(400).json({error:'Contenu manquant'}); 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'}); if (!isEditable(f)) return res.status(400).json({error:'Non éditable'}); const buf = Buffer.from(content, 'utf8'); if (buf.length > CHUNK_SIZE) return res.status(413).json({error:'Trop lourd (30 MB max)'}); try { const mk = decryptMasterKey(f.master_key_enc); const enc = encryptChunk(buf, mk); const png = await bufferToPng(enc); const {url, deleteUrl, id: imgId} = await imgbbUpload(png); const olds = db.prepare('SELECT imgbb_delete_url FROM file_chunks WHERE file_id=?').all(f.id); db.prepare('DELETE FROM file_chunks WHERE file_id=?').run(f.id); db.prepare('INSERT INTO file_chunks(file_id,chunk_index,size,encrypted_len) VALUES(?,?,?,?)').run(f.id,0,buf.length,enc.length); db.prepare('UPDATE files SET size=?, chunk_count=1, status=? WHERE id=?').run(buf.length,'ready',f.id); fileCache.delete(`chunk:${f.id}:0`); for (const o of olds) if (o.imgbb_delete_url) await imgbbDelete(o.imgbb_delete_url); logActivity(req.session.userId,'edit',f.name); res.json({ok:true, size: buf.length}); } catch (e) { res.status(500).json({error:"Erreur d'enregistrement"}); } });
518
  app.get('/api/files/:id/properties', requireAuth, async(req,res)=>{ if(!UUID_RE.test(req.params.id)) return res.status(400).json({error:'ID invalide'}); const f = canAccessFile(req.session.userId, req.params.id); if(!f) return res.status(404).json({error:'Introuvable'}); const chunks = db.prepare('SELECT imgbb_url, size FROM file_chunks WHERE file_id=? ORDER BY chunk_index').all(f.id); const hash = createHash('sha256'); for (const c of chunks) { try { const { data } = await axios.get(c.imgbb_url, { responseType:'arraybuffer', timeout:30000 }); hash.update(Buffer.from(data)); } catch (e) { return res.status(500).json({error:'Erreur de calcul'}); } } res.json({ id: f.id, name: f.name, size: f.size, mime: f.mime, status: f.status, created_at: f.created_at, updated_at: f.updated_at, chunk_count: f.chunk_count, encryption_key_hash: f.encryption_key_hash, sha256: hash.digest('hex'), owner_id: f.owner_id }); });
519
  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:'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}); });
520
  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(/</g, '\\u003c')); res.status(status).send(out); }; const l=db.prepare('SELECT * FROM share_links WHERE token=? AND type=? AND (expires_at IS NULL OR expires_at>?)').get(req.params.token,'embed',Date.now()); if (!l) return 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é' }); 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 = `<meta property="og:type" content="website"><meta property="og:site_name" content="PixelDrive"><meta property="og:title" content="${esc(f.name)}"><meta property="og:description" content="${(f.size/1e6).toFixed(1)} MB · Partage sécurisé PixelDrive"><meta property="og:image" content="${req.protocol}://${req.get('host')}/banner.png"><meta name="twitter:card" content="summary_large_image"><meta name="twitter:title" content="${esc(f.name)}"><meta name="twitter:image" content="${req.protocol}://${req.get('host')}/banner.png">`; serve(200, { name: f.name, size: f.size, mime: f.mime, dl: '/d/' + dt }, og); });
521
  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)) return res.status(429).json({error:'Quota 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); 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); });
522
  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}); });
523
- 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(`<svg xmlns="http://www.w3.org/2000/svg" width="256" height="144"><rect fill="#2a2a3e" width="100%" height="100%"/><text x="50%" y="50%" dominant-baseline="middle" text-anchor="middle" fill="#666" font-family="sans-serif" font-size="16">FILE</text></svg>`); });
524
- 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)); });
525
  app.get('/health',(_,res)=>res.json({ok:true,cache:fileCache.size,mem:process.memoryUsage()}));
526
 
527
  app.use(express.static(PUBLIC_DIR, { dotfiles: 'deny', index: false }));
@@ -531,5 +519,33 @@ app.get('*', (req, res) => { if (/\.[a-zA-Z0-9]+$/.test(req.path)) return res.st
531
  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' }); });
532
 
533
  recoverStuckUploads();
534
- const server = app.listen(PORT, '0.0.0.0', () => log('INFO', `PixelDrive v5.1 démarré | upload chunké ${UP_CHUNK/1048576} MB | stockage ${(USER_QUOTA/1073741824).toFixed(0)} GB | transit ${(DAILY_TRANSFER/1073741824).toFixed(0)} GB/j`));
535
- process.on('SIGTERM', () => { log('WARN', 'SIGTERM reçu, arrêt...'); server.close(() => process.exit(0)); });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  // ==========================================
2
+ // PIXELDRIVE v5.3 - SERVER.JS (ESM)
3
+ // Permissions (read/write) + Fixes
4
  // ==========================================
5
  import express from 'express';
6
  import session from 'express-session';
7
  import Database from 'better-sqlite3';
8
  import multer from 'multer';
9
+ import { createHash, randomBytes, scrypt, createCipheriv, createDecipheriv, timingSafeEqual } from 'crypto';
10
  import { promisify } from 'util';
11
  import { createReadStream, unlinkSync, existsSync, readdirSync, statSync, rmSync, copyFileSync, mkdirSync, readFileSync, writeFileSync, appendFileSync } from 'fs';
12
  import { join } from 'path';
 
92
  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')));
93
  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));
94
  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);
95
+ 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, permission TEXT DEFAULT 'read', created_at INTEGER DEFAULT (strftime('%s','now')));
96
  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));
97
  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')));
98
  CREATE TABLE IF NOT EXISTS notifications (id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, type TEXT NOT NULL, detail TEXT, read INTEGER DEFAULT 0, created_at INTEGER DEFAULT (strftime('%s','now')));
 
110
  const usersCols = db.prepare("PRAGMA table_info(users)").all().map(c => c.name);
111
  if (!usersCols.includes('tos_accepted')) db.exec(`ALTER TABLE users ADD COLUMN tos_accepted INTEGER DEFAULT 0;`);
112
  if (!usersCols.includes('avatar')) db.exec(`ALTER TABLE users ADD COLUMN avatar BLOB;`);
113
+
114
+ // ✅ Migration permission
115
+ const usCols = db.prepare("PRAGMA table_info(user_shares)").all().map(c => c.name);
116
+ if (!usCols.includes('permission')) db.exec(`ALTER TABLE user_shares ADD COLUMN permission TEXT DEFAULT 'read';`);
117
+
118
  const filesCols = db.prepare("PRAGMA table_info(files)").all().map(c => c.name);
119
  const filesDefs = [
120
  { name: 'folder_id', def: 'INTEGER REFERENCES folders(id) ON DELETE SET NULL' },
 
137
  const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table'").all().map(t => t.name);
138
  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));`);
139
  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);`);
140
+ 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, permission TEXT DEFAULT 'read', created_at INTEGER DEFAULT (strftime('%s','now'))); CREATE INDEX idx_ushares_with ON user_shares(shared_with);`);
141
  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);`);
142
  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);`);
143
  if (!tables.includes('notifications')) db.exec(`CREATE TABLE notifications (id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, type TEXT NOT NULL, detail TEXT, read INTEGER DEFAULT 0, created_at INTEGER DEFAULT (strftime('%s','now'))); CREATE INDEX idx_notif_user ON notifications(user_id, read, created_at);`);
 
294
  function decryptMasterKey(b){ const iv=b.subarray(0,12), tag=b.subarray(-16), d=b.subarray(12,-16); const dc=createDecipheriv('aes-256-gcm',SERVER_MASTER_KEY,iv); dc.setAuthTag(tag); return Buffer.concat([dc.update(d),dc.final()]); }
295
  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); }
296
 
297
+ // LOGIQUE PERMISSIONS
298
+ function canAccessFolder(userId, folderId) {
299
+ let cur = db.prepare('SELECT * FROM folders WHERE id=?').get(folderId);
300
+ let depth = 0;
301
+ while (cur && depth < 50) {
302
+ if (cur.owner_id === userId) return { access: true, perm: 'write' };
303
+ const share = db.prepare('SELECT permission FROM user_shares WHERE folder_id=? AND shared_with=?').get(cur.id, userId);
304
+ if (share) return { access: true, perm: share.permission };
305
+ cur = cur.parent_id ? db.prepare('SELECT * FROM folders WHERE id=?').get(cur.parent_id) : null;
306
+ depth++;
307
+ }
308
+ return { access: false, perm: null };
309
+ }
310
+
311
+ function canAccessFile(userId, fileId) {
312
+ const f = db.prepare('SELECT * FROM files WHERE id=?').get(fileId);
313
+ if (!f) return null;
314
+ if (f.owner_id === userId) return { ...f, perm: 'write' };
315
+ const share = db.prepare('SELECT permission FROM user_shares WHERE file_id=? AND shared_with=?').get(fileId, userId);
316
+ if (share) return { ...f, perm: share.permission };
317
+ if (f.folder_id) {
318
+ const folderAccess = canAccessFolder(userId, f.folder_id);
319
+ if (folderAccess.access) return { ...f, perm: folderAccess.perm };
320
+ }
321
+ return null;
322
+ }
323
+
324
  function isEditable(f) { return (f.mime && (f.mime.startsWith('text/') || ['application/json','application/javascript','application/xml'].includes(f.mime))) || EDITABLE_RE.test(f.name || ''); }
325
 
326
  async function decryptWholeFile(f) { const k = await getMasterKey(f.id); const chunks = db.prepare('SELECT chunk_index,imgbb_url,size FROM file_chunks WHERE file_id=? ORDER BY chunk_index').all(f.id); const bufs = []; for (const c of chunks) { const ck = `chunk:${f.id}:${c.chunk_index}`; let dec = fileCache.get(ck)?.data; if (!dec) { const {data} = await axios.get(c.imgbb_url,{responseType:'arraybuffer',timeout:60000}); const enc = await pngToBuffer(Buffer.from(data), c.size + OVERHEAD); dec = decryptChunk(enc, k); fileCache.set(ck,{data:dec,size:dec.length}); } bufs.push(dec); } return Buffer.concat(bufs, f.size); }
 
342
  const assembledPath = (fid) => join(TEMP_DIR, 'assemble_' + fid);
343
  const pad6 = (i) => String(i).padStart(6, '0');
344
 
345
+ async function imgbbUploadRetry(buf, tries = 4) {
346
+ for (let i = 0; i < tries; i++) {
347
+ try { return await imgbbUpload(buf); }
348
+ catch (e) {
349
+ const status = e.response?.status;
350
+ if (i === tries - 1) throw e;
351
+ const wait = status === 429 ? 4000 * (i + 1) : 1200 * (i + 1);
352
+ log('WARN', `ImgBB retry ${i+1} (HTTP ${status || 'net'}) dans ${wait}ms`);
353
+ await new Promise(r => setTimeout(r, wait));
354
+ }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
355
  }
356
  }
357
 
358
+ async function processUpload(fid, path, fsize, username, uid) {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
359
  const t0 = Date.now();
360
  const mk = await getMasterKey(fid);
361
  const frow = db.prepare('SELECT mime FROM files WHERE id=?').get(fid);
362
  const mime = frow?.mime || 'application/octet-stream';
363
  const cc = Math.ceil(fsize / CHUNK_SIZE);
364
+ log('INFO', `Traitement démarré: ${fid} par ${username} (${(fsize/1048576).toFixed(2)} MB, ${cc} chunk(s)) — parallélisme x2`);
365
+ try {
366
  db.prepare("UPDATE files SET status='uploading' WHERE id=?").run(fid);
367
+ if (mime.startsWith('image/')) {
368
+ const c0 = db.prepare('SELECT size FROM file_chunks WHERE file_id=? AND chunk_index=0').get(fid);
369
+ const raw0 = await readChunk(path, 0, c0.size);
370
+ const scan = await checkNsfw(raw0);
371
+ if (!scan.safe) { log('ALERT', `NSFW bloqué: ${fid}`); throw new Error('Contenu bloqué par le filtre de modération'); }
 
 
 
 
 
 
 
 
 
 
372
  }
373
+ let next = 0, done = 0, err = null;
374
+ const CONC = 2;
375
+ async function worker() {
376
+ while (true) {
377
+ if (err) return;
378
+ const i = next++;
379
+ if (i >= cc) return;
380
+ const ci = db.prepare('SELECT size, encrypted_len, imgbb_url FROM file_chunks WHERE file_id=? AND chunk_index=?').get(fid, i);
381
+ if (ci.imgbb_url) { done++; const prog = (done / cc) * 50; db.prepare('UPDATE files SET progress=? WHERE id=?').run(prog, fid); broadcast(fid, {type:'progress', progress:prog, status:'uploading'}); continue; }
382
+ try {
383
+ const raw = await readChunk(path, i * CHUNK_SIZE, ci.size);
384
+ const enc = encryptChunk(raw, mk);
385
+ const png = await bufferToPng(enc);
386
+ const up = await imgbbUploadRetry(png);
387
+ db.prepare('UPDATE file_chunks SET imgbb_url=?,imgbb_delete_url=?,imgbb_id=? WHERE file_id=? AND chunk_index=?').run(up.url, up.deleteUrl, up.id, fid, i);
388
+ done++;
389
+ const prog = (done / cc) * 50;
390
+ db.prepare('UPDATE files SET progress=?,status=? WHERE id=?').run(prog, 'uploading', fid);
391
+ broadcast(fid, {type:'progress', progress:prog, status:'uploading'});
392
+ } catch (e) { err = e; return; }
393
+ }
394
+ }
395
+ await Promise.all(Array.from({ length: Math.min(CONC, cc) }, () => worker()));
396
+ if (err) throw err;
397
+ try { unlinkSync(path); } catch (_) {}
398
  db.prepare("UPDATE files SET status='processing',progress=50 WHERE id=?").run(fid);
399
+ broadcast(fid, {type:'progress', progress:50, status:'processing'});
400
+ await genThumb(fid, mime, mk);
401
  db.prepare("UPDATE files SET status='ready',progress=100 WHERE id=?").run(fid);
402
+ broadcast(fid, {type:'progress', progress:100, status:'ready', type:'done'});
403
  addTransfer(uid, fsize, 'up');
404
  logActivity(uid, 'upload', path.split('/').pop());
405
+ const secs = (Date.now() - t0) / 1000;
406
  log('INFO', `Traitement terminé: ${fid} en ${secs.toFixed(1)}s (${(fsize/1048576/secs).toFixed(2)} MB/s)`);
407
+ } catch (e) {
408
  log('ERROR', `Traitement échoué ${fid}: ${e.message}`);
409
+ db.prepare("UPDATE files SET status='error',error_msg=? WHERE id=?").run(e.message, fid);
410
+ broadcast(fid, {type:'error', error:e.message});
411
+ const cs = db.prepare('SELECT imgbb_delete_url FROM file_chunks WHERE file_id=?').all(fid);
412
+ for (const c of cs) if (c.imgbb_delete_url) await imgbbDelete(c.imgbb_delete_url);
413
  }
414
  }
415
  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);});}
 
418
  // ROUTES
419
  // ==========================================
420
  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 invalide'}); if(typeof p !== 'string' || p.length < 6) return res.status(400).json({error:'Mot de passe trop court'}); 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}); });
421
+ 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(!timingSafeEqual(h, 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) { 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}}); });
422
  app.post('/api/logout',(req,res)=>req.session.destroy(()=>res.json({ok:true})));
423
  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 }); });
424
  app.get('/api/tos',(_,res)=>res.send(`PIXELDRIVE CGU\n1. RESPONSABILITÉ TOTALE\n2. AUCUNE GARANTIE (Projet test)\n3. INTERDITS: Illégal, NSFW, Abus\n4. CHIFFRÉ AES-256-GCM + Filtre NSFW Local\n5. ACCEPTATION = Responsabilité légale`));
425
  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});});
426
  app.get('/api/notifications', requireAuth, (req,res) => { res.json(db.prepare('SELECT id, type, detail, read, created_at FROM notifications WHERE user_id=? ORDER BY created_at DESC LIMIT 50').all(req.session.userId)); });
427
  app.post('/api/notifications/read', requireAuth, (req,res) => { db.prepare('UPDATE notifications SET read=1 WHERE user_id=?').run(req.session.userId); res.json({ok:true}); });
428
+ 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:'Trop court'}); const usr = db.prepare('SELECT * FROM users WHERE id=?').get(req.session.userId); const hCur = await deriveKey(current, usr.salt); if (!timingSafeEqual(hCur, 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); logActivity(usr.id, 'password', 'Mot de passe modifié'); res.json({ok:true}); });
429
+ 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 (!timingSafeEqual(h, 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); db.prepare('DELETE FROM users WHERE id=?').run(usr.id); (async () => { for (const u of urls) await imgbbDelete(u); })(); req.session.destroy(() => res.json({ok:true})); });
430
  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)); });
431
  app.get('/api/stats/advanced', requireAuth, (req,res) => { const days = []; for (let i = 6; i >= 0; i--) { const d = new Date(Date.now() - i*86400000).toISOString().slice(0,10); const r = db.prepare('SELECT COALESCE(SUM(up_bytes),0) u, COALESCE(SUM(down_bytes),0) d FROM transfer_daily WHERE user_id=? AND day=?').get(req.session.userId, d); const acts = db.prepare("SELECT COUNT(*) c FROM activity WHERE user_id=? AND strftime('%Y-%m-%d', created_at, 'unixepoch')=?").get(req.session.userId, d).c; days.push({ day: d.slice(5), up: r.u, down: r.d, acts }); } const top = db.prepare(`SELECT f.name, (SELECT COALESCE(SUM(downloads),0) FROM share_links sl WHERE sl.file_id=f.id) dl FROM files f WHERE f.owner_id=? AND f.status='ready' ORDER BY dl DESC LIMIT 5`).all(req.session.userId); res.json({ days, top }); });
432
  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)); });
 
437
  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:'Non'}); db.prepare('UPDATE users SET is_admin = 1 - is_admin WHERE id=?').run(id); res.json({ok:true}); });
438
  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:'Trop lourde'}); 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); res.json({ok:true}); } catch(e){ res.status(400).json({error:'Image invalide'}); } });
439
  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}); });
440
+ app.get('/api/users/:id/avatar', requireAuth, (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); });
441
+
442
+ // ✅ DOSSIERS (avec permissions)
443
+ app.get('/api/folders',requireAuth,(req,res)=>{
444
+ const p = req.query.parent_id ? parseInt(req.query.parent_id) : null;
445
+ if (p !== null) {
446
+ const access = canAccessFolder(req.session.userId, p);
447
+ if (!access.access) return res.status(403).json({error:'Accès refusé'});
448
+ return res.json(db.prepare('SELECT * FROM folders WHERE parent_id=? ORDER BY name COLLATE NOCASE').all(p));
449
+ }
450
+ // Racine : mes dossiers + dossiers partagés à la racine
451
+ const mine = db.prepare('SELECT *, \'write\' as perm FROM folders WHERE owner_id=? AND parent_id IS NULL ORDER BY name COLLATE NOCASE').all(req.session.userId);
452
+ const shared = db.prepare('SELECT f.*, us.permission as perm, u.username as owner_name FROM folders f JOIN user_shares us ON us.folder_id=f.id JOIN users u ON u.id=f.owner_id WHERE us.shared_with=? AND f.parent_id IS NULL ORDER BY f.name COLLATE NOCASE').all(req.session.userId);
453
+ res.json([...mine, ...shared]);
454
+ });
455
+
456
  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'});}});
457
  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});});
458
  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:'Nom déjà utilisé'}); } });
459
  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:'Cible invalide'}); let cur = target, depth = 0; while (cur && depth < 50) { if (cur.id === fo.id) return res.status(400).json({error:'Boucle détectée'}); 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}); });
460
+
461
+ // ✅ FICHIERS (avec permissions)
462
+ app.get('/api/files',requireAuth,(req,res)=>{
463
+ const f = req.query.folder_id ? parseInt(req.query.folder_id) : null;
464
+ if (f !== null) {
465
+ const access = canAccessFolder(req.session.userId, f);
466
+ if (!access.access) return res.status(403).json({error:'Accès refusé'});
467
+ return res.json(db.prepare('SELECT * FROM files WHERE folder_id=? ORDER BY name COLLATE NOCASE').all(f));
468
+ }
469
+ // Racine : mes fichiers + fichiers partagés à la racine
470
+ const mine = db.prepare('SELECT *, \'write\' as perm FROM files WHERE owner_id=? AND folder_id IS NULL ORDER BY name COLLATE NOCASE').all(req.session.userId);
471
+ const shared = db.prepare('SELECT f.*, us.permission as perm, u.username as owner_name FROM files f JOIN user_shares us ON us.file_id=f.id JOIN users u ON u.id=f.owner_id WHERE us.shared_with=? AND f.folder_id IS NULL ORDER BY f.name COLLATE NOCASE').all(req.session.userId);
472
+ res.json([...mine, ...shared]);
473
+ });
474
+
475
  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}); });
476
  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:'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}); });
477
+
478
+ // PARTAGE UTILISATEUR (avec permission)
479
+ app.post('/api/share-user',requireAuth,requireTos,shareLimit,(req,res)=>{
480
+ const { type, id, username, permission } = req.body;
481
+ if (!type || !id || !username) return res.status(400).json({error:'Champs manquants'});
482
+ const perm = (permission === 'write') ? 'write' : 'read';
483
+ const target = db.prepare('SELECT id FROM users WHERE username=?').get(String(username).trim());
484
+ if (!target) return res.status(404).json({error:'Utilisateur introuvable'});
485
+ if (target.id === req.session.userId) return res.status(400).json({error:'Impossible avec vous-même'});
486
+ const col = type === 'file' ? 'file_id' : 'folder_id';
487
+ const own = db.prepare(`SELECT id, name FROM ${type === 'file' ? 'files' : 'folders'} WHERE id=? AND owner_id=?`).get(id, req.session.userId);
488
+ if (!own) return res.status(404).json({error:'Introuvable'});
489
+ 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é'});
490
+ db.prepare(`INSERT INTO user_shares(${col},owner_id,shared_with,permission) VALUES(?,?,?,?)`).run(id, req.session.userId, target.id, perm);
491
+ notify(target.id, 'share', `${req.session.username} → ${own.name} (${perm})`);
492
+ logActivity(req.session.userId,'share_user',`${type} → ${username.trim()} (${perm})`);
493
+ res.json({ok:true});
494
+ });
495
+
496
+ 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, us.permission, 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)); });
497
  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}); });
498
+ app.get('/api/my-shares',requireAuth,(req,res)=>{ res.json(db.prepare(`SELECT us.id, us.created_at, us.permission, 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)); });
499
  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'}); res.json({ok:true}); });
500
  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)); });
501
  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}); });
502
+
503
  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)) return res.status(429).json({error:'Quota journalier atteint'}); 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); });
504
  app.get('/api/files/:id/raw', requireAuth, async(req,res)=>{ if(!UUID_RE.test(req.params.id)) return res.status(400).json({error:'ID invalide'}); 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'}); if (f.size > 2*1024*1024) return res.status(413).json({error:'Trop lourd pour aperçu (2 MB max)'}); try { const all = await decryptWholeFile(f); res.set({'Content-Type': (f.mime && f.mime.startsWith('text/')) ? f.mime : 'text/plain; charset=utf-8'}); res.send(all); } catch (e) { res.status(500).json({error:'Erreur déchiffrement'}); } });
505
+ app.post('/api/files/:id/edit', requireAuth, requireTos, async(req,res)=>{ if(!UUID_RE.test(req.params.id)) return res.status(400).json({error:'ID invalide'}); const { content } = req.body; if (typeof content !== 'string') return res.status(400).json({error:'Contenu manquant'}); const f = canAccessFile(req.session.userId, req.params.id); if(!f) return res.status(404).json({error:'Introuvable'}); if (f.perm !== 'write') return res.status(403).json({error:'Lecture seule'}); if (!isEditable(f)) return res.status(400).json({error:'Non éditable'}); const buf = Buffer.from(content, 'utf8'); if (buf.length > CHUNK_SIZE) return res.status(413).json({error:'Trop lourd (30 MB max)'}); try { const mk = decryptMasterKey(f.master_key_enc); const enc = encryptChunk(buf, mk); const png = await bufferToPng(enc); const {url, deleteUrl, id: imgId} = await imgbbUpload(png); const olds = db.prepare('SELECT imgbb_delete_url FROM file_chunks WHERE file_id=?').all(f.id); db.prepare('DELETE FROM file_chunks WHERE file_id=?').run(f.id); db.prepare('INSERT INTO file_chunks(file_id,chunk_index,size,encrypted_len) VALUES(?,?,?,?)').run(f.id,0,buf.length,enc.length); db.prepare('UPDATE files SET size=?, chunk_count=1, status=? WHERE id=?').run(buf.length,'ready',f.id); fileCache.delete(`chunk:${f.id}:0`); for (const o of olds) if (o.imgbb_delete_url) await imgbbDelete(o.imgbb_delete_url); logActivity(req.session.userId,'edit',f.name); res.json({ok:true, size: buf.length}); } catch (e) { res.status(500).json({error:"Erreur d'enregistrement"}); } });
506
  app.get('/api/files/:id/properties', requireAuth, async(req,res)=>{ if(!UUID_RE.test(req.params.id)) return res.status(400).json({error:'ID invalide'}); const f = canAccessFile(req.session.userId, req.params.id); if(!f) return res.status(404).json({error:'Introuvable'}); const chunks = db.prepare('SELECT imgbb_url, size FROM file_chunks WHERE file_id=? ORDER BY chunk_index').all(f.id); const hash = createHash('sha256'); for (const c of chunks) { try { const { data } = await axios.get(c.imgbb_url, { responseType:'arraybuffer', timeout:30000 }); hash.update(Buffer.from(data)); } catch (e) { return res.status(500).json({error:'Erreur de calcul'}); } } res.json({ id: f.id, name: f.name, size: f.size, mime: f.mime, status: f.status, created_at: f.created_at, updated_at: f.updated_at, chunk_count: f.chunk_count, encryption_key_hash: f.encryption_key_hash, sha256: hash.digest('hex'), owner_id: f.owner_id }); });
507
  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:'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}); });
508
  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(/</g, '\\u003c')); res.status(status).send(out); }; const l=db.prepare('SELECT * FROM share_links WHERE token=? AND type=? AND (expires_at IS NULL OR expires_at>?)').get(req.params.token,'embed',Date.now()); if (!l) return 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é' }); 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 = `<meta property="og:type" content="website"><meta property="og:site_name" content="PixelDrive"><meta property="og:title" content="${esc(f.name)}"><meta property="og:description" content="${(f.size/1e6).toFixed(1)} MB · Partage sécurisé PixelDrive"><meta property="og:image" content="${req.protocol}://${req.get('host')}/banner.png"><meta name="twitter:card" content="summary_large_image"><meta name="twitter:title" content="${esc(f.name)}"><meta name="twitter:image" content="${req.protocol}://${req.get('host')}/banner.png">`; serve(200, { name: f.name, size: f.size, mime: f.mime, dl: '/d/' + dt }, og); });
509
  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)) return res.status(429).json({error:'Quota 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); 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); });
510
  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}); });
511
+ app.get('/api/files/:id/thumb',requireAuth,(req,res)=>{ if(!UUID_RE.test(req.params.id)) return res.status(400).json({error:'ID invalide'}); const f = canAccessFile(req.session.userId, req.params.id); if(!f) 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(`<svg xmlns="http://www.w3.org/2000/svg" width="256" height="144"><rect fill="#2a2a3e" width="100%" height="100%"/><text x="50%" y="50%" dominant-baseline="middle" text-anchor="middle" fill="#666" font-family="sans-serif" font-size="16">FILE</text></svg>`); });
512
+ app.get('/api/files/:id/progress',requireAuth,(req,res)=>{ if(!UUID_RE.test(req.params.id)) return res.status(400).json({error:'ID invalide'}); const f = canAccessFile(req.session.userId, req.params.id); if(!f) 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)); });
513
  app.get('/health',(_,res)=>res.json({ok:true,cache:fileCache.size,mem:process.memoryUsage()}));
514
 
515
  app.use(express.static(PUBLIC_DIR, { dotfiles: 'deny', index: false }));
 
519
  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' }); });
520
 
521
  recoverStuckUploads();
522
+ const server = app.listen(PORT, '0.0.0.0', () => log('INFO', `PixelDrive v5.3 démarré | upload chunké ${UP_CHUNK/1048576} MB | stockage ${(USER_QUOTA/1073741824).toFixed(0)} GB | transit ${(DAILY_TRANSFER/1073741824).toFixed(0)} GB/j`));
523
+ process.on('SIGTERM', () => { log('WARN', 'SIGTERM reçu, arrêt...'); server.close(() => process.exit(0)); });
524
+
525
+ function recoverStuckUploads() {
526
+ const stuck = db.prepare("SELECT id, size, status FROM files WHERE status IN ('uploading','processing')").all();
527
+ for (const f of stuck) {
528
+ try {
529
+ const assembled = assembledPath(f.id);
530
+ if (existsSync(assembled)) { queueProcess(f.id); log('INFO', `Recovery: ${f.id} re-file (processing)`); continue; }
531
+ const total = Math.ceil(f.size / UP_CHUNK);
532
+ let n = 0; try { n = readdirSync(partsDir(f.id)).length; } catch(e){}
533
+ if (total > 0 && n >= total) { assembleParts(f.id); queueProcess(f.id); log('INFO', `Recovery: ${f.id} assemblé + re-file`); }
534
+ } catch(e) { log('WARN', `Recovery ${f.id}: ${e.message}`); }
535
+ }
536
+ }
537
+ function assembleParts(fid) {
538
+ const f = db.prepare('SELECT size FROM files WHERE id=?').get(fid);
539
+ const total = Math.ceil(f.size / UP_CHUNK);
540
+ const dir = partsDir(fid);
541
+ const out = assembledPath(fid);
542
+ for (let i=0;i<total;i++) appendFileSync(out, readFileSync(join(dir, pad6(i))));
543
+ rmSync(dir, { recursive: true, force: true });
544
+ return out;
545
+ }
546
+ function queueProcess(fid) {
547
+ const f = db.prepare('SELECT size, owner_id FROM files WHERE id=?').get(fid);
548
+ if (!f) return;
549
+ const uname = (db.prepare('SELECT username FROM users WHERE id=?').get(f.owner_id)||{}).username || '?';
550
+ uploadQueue.add(() => processUpload(fid, assembledPath(fid), f.size, uname, f.owner_id));
551
+ }