NathMen12 commited on
Commit
470cbff
·
verified ·
1 Parent(s): 1316c28

Update server.js

Browse files
Files changed (1) hide show
  1. server.js +87 -125
server.js CHANGED
@@ -1,5 +1,5 @@
1
  // ==========================================
2
- // PIXELDRIVE v4.5 - SERVER.JS (ESM) - QUOTA TRANSIT JOURNALIER
3
  // ==========================================
4
  import express from 'express';
5
  import session from 'express-session';
@@ -34,13 +34,10 @@ const IV_LEN = 12;
34
  const TAG_LEN = 16;
35
  const OVERHEAD = IV_LEN + TAG_LEN;
36
  const IS_PROD = process.env.NODE_ENV === 'production';
37
- const USER_QUOTA = parseInt(process.env.USER_QUOTA_GB || '50', 10) * 1024 * 1024 * 1024; // ✅ Stockage (50 GB défaut)
38
- const DAILY_TRANSFER = parseInt(process.env.DAILY_TRANSFER_GB || '100', 10) * 1024 * 1024 * 1024; // ✅ Transit/jour (100 GB défaut)
39
  const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
40
 
41
- // ==========================================
42
- // LOGS
43
- // ==========================================
44
  function log(level, msg) {
45
  const n = new Date();
46
  const ts = [n.getHours(), n.getMinutes(), n.getSeconds()].map(x => String(x).padStart(2, '0')).join(':');
@@ -50,15 +47,11 @@ function log(level, msg) {
50
  else console.log(line);
51
  }
52
 
53
- // --- ENV ---
54
  const IMGBB_API_KEY = process.env.IMGBB_API_KEY;
55
  const SESSION_SECRET = process.env.SESSION_SECRET;
56
  const ADMIN_USER = process.env.ADMIN_USER || 'admin';
57
 
58
- if (!IMGBB_API_KEY || !SESSION_SECRET) {
59
- log('FATAL ERROR', 'Variables manquantes: IMGBB_API_KEY, SESSION_SECRET');
60
- process.exit(1);
61
- }
62
 
63
  const MASTER_SECRET = process.env.MASTER_SECRET || SESSION_SECRET;
64
  if (process.env.MASTER_SECRET) log('INFO', 'MASTER_SECRET personnalisé actif');
@@ -66,17 +59,14 @@ else log('WARN', 'MASTER_SECRET non défini → clé maître dérivée de SESSIO
66
 
67
  const db = new Database(DB_PATH);
68
  db.pragma('journal_mode = WAL');
69
- db.pragma('busy_timeout = 5000');
70
 
71
- // --- CRYPTO ---
72
  const scryptAsync = promisify(scrypt);
73
  async function deriveKey(password, salt) { return scryptAsync(password, salt, KEY_LEN); }
74
  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]); }
75
  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()]); }
76
-
77
  function esc(s) { return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;').replace(/'/g,'&#39;'); }
78
 
79
- // --- PNG STEGO ---
80
  function bufferToPng(buffer) {
81
  const byteLen = buffer.length;
82
  const pixelsNeeded = Math.ceil(byteLen / 3);
@@ -96,9 +86,6 @@ async function pngToBuffer(pngBuffer, expectedLen) {
96
  return rgb.subarray(0, expectedLen);
97
  }
98
 
99
- // ==========================================
100
- // SCHÉMA (+ table transfer_daily)
101
- // ==========================================
102
  db.exec(`
103
  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')));
104
  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));
@@ -114,15 +101,11 @@ CREATE INDEX IF NOT EXISTS idx_ushares_with ON user_shares(shared_with);
114
  CREATE INDEX IF NOT EXISTS idx_transfer_day ON transfer_daily(user_id, day);
115
  `);
116
 
117
- // ==========================================
118
- // MIGRATIONS
119
- // ==========================================
120
  function runMigrations() {
121
  log('INFO', 'Vérification migrations BDD...');
122
  const usersCols = db.prepare("PRAGMA table_info(users)").all().map(c => c.name);
123
  if (!usersCols.includes('tos_accepted')) db.exec(`ALTER TABLE users ADD COLUMN tos_accepted INTEGER DEFAULT 0;`);
124
  if (!usersCols.includes('avatar')) db.exec(`ALTER TABLE users ADD COLUMN avatar BLOB;`);
125
-
126
  const filesCols = db.prepare("PRAGMA table_info(files)").all().map(c => c.name);
127
  const filesDefs = [
128
  { name: 'folder_id', def: 'INTEGER REFERENCES folders(id) ON DELETE SET NULL' },
@@ -143,13 +126,11 @@ function runMigrations() {
143
  }
144
  }
145
  if (updatedAdded) db.exec(`UPDATE files SET updated_at = strftime('%s','now') WHERE updated_at = 0;`);
146
-
147
  const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table'").all().map(t => t.name);
148
  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));`);
149
  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);`);
150
  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);`);
151
  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);`);
152
-
153
  const sessionsCols = tables.includes('sessions') ? db.prepare("PRAGMA table_info(sessions)").all().map(c => c.name) : [];
154
  if (tables.includes('sessions') && (!sessionsCols.includes('data') || !sessionsCols.includes('expires_at'))) {
155
  log('WARN', 'Table sessions invalide → reconstruction');
@@ -167,9 +148,7 @@ DROP TRIGGER IF EXISTS update_file_ts;
167
  CREATE TRIGGER update_file_ts AFTER UPDATE ON files BEGIN UPDATE files SET updated_at = strftime('%s','now') WHERE id = NEW.id; END;
168
  `);
169
 
170
- // ==========================================
171
- // ✅ QUOTA DE TRANSIT JOURNALIER
172
- // ==========================================
173
  function todayKey() { return new Date().toISOString().slice(0, 10); }
174
  function getTransfer(userId) {
175
  const r = db.prepare('SELECT up_bytes, down_bytes FROM transfer_daily WHERE user_id=? AND day=?').get(userId, todayKey());
@@ -184,20 +163,15 @@ function addTransfer(userId, bytes, dir) {
184
  .run(userId, todayKey(), dir === 'up' ? bytes : 0, dir === 'down' ? bytes : 0);
185
  } catch (e) { log('WARN', `addTransfer: ${e.message}`); }
186
  }
187
- function transferBlocked(userId) {
188
- return getTransfer(userId).total >= DAILY_TRANSFER;
189
- }
190
 
191
- // ==========================================
192
- // NETTOYAGE AUTO + BACKUPS
193
- // ==========================================
194
  function cleanTemp() {
195
  try {
196
  const now = Date.now();
197
  for (const f of readdirSync(TEMP_DIR)) {
198
  const p = join(TEMP_DIR, f);
199
- const st = statSync(p);
200
- if (now - st.mtimeMs > 24 * 3600 * 1000) { rmSync(p, { force: true }); log('INFO', `Temp nettoyé: ${f}`); }
201
  }
202
  } catch (e) { log('WARN', `Temp cleanup: ${e.message}`); }
203
  }
@@ -237,9 +211,7 @@ setInterval(() => { cleanTemp(); cleanErrorFiles(); cleanOldTransfers(); }, 3600
237
  setInterval(backupDb, 6 * 3600000);
238
  setTimeout(backupDb, 60000);
239
 
240
- // ==========================================
241
- // RATE LIMITER
242
- // ==========================================
243
  function rateLimit({ windowMs = 60000, max = 100, message = 'Trop de requêtes, réessayez plus tard' } = {}) {
244
  const hits = new Map();
245
  setInterval(() => { const now = Date.now(); for (const [k, v] of hits) if (now - v.start > windowMs) hits.delete(k); }, Math.max(windowMs, 60000));
@@ -260,24 +232,18 @@ const uploadLimit = rateLimit({ windowMs: 60*60000, max: 30, message: 'Quota u
260
  const shareLimit = rateLimit({ windowMs: 60*60000, max: 50, message: 'Trop de partages' });
261
  const avatarLimit = rateLimit({ windowMs: 60*60000, max: 10, message: 'Trop de changements d\'avatar' });
262
 
263
- // ==========================================
264
- // CSRF
265
- // ==========================================
266
  function csrfGuard(req, res, next) {
267
  if (['GET', 'HEAD', 'OPTIONS'].includes(req.method)) return next();
268
  const origin = req.headers.origin;
269
  if (origin) {
270
  let oHost;
271
  try { oHost = new URL(origin).host; } catch (e) { return res.status(403).json({ error: 'Origin invalide' }); }
272
- if (oHost !== req.get('host')) {
273
- log('WARN', `CSRF bloqué: origin=${origin} host=${req.get('host')}`);
274
- return res.status(403).json({ error: 'Requête cross-origin refusée' });
275
- }
276
  }
277
  next();
278
  }
279
 
280
- // --- SESSION STORE ---
281
  class SQLiteStore extends session.Store {
282
  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); } }
283
  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); } }
@@ -288,7 +254,6 @@ class SQLiteStore extends session.Store {
288
  }
289
  const sessionStore = new SQLiteStore();
290
 
291
- // --- ADMIN ---
292
  const adminRow = db.prepare('SELECT * FROM users WHERE username=?').get(ADMIN_USER);
293
  if (!adminRow) {
294
  const salt = randomBytes(16);
@@ -297,10 +262,8 @@ if (!adminRow) {
297
  log('INFO', `Compte admin créé: ${ADMIN_USER}`);
298
  }
299
 
300
- // --- EXPRESS ---
301
  const app = express();
302
  app.set('trust proxy', 1);
303
-
304
  app.use((req, res, next) => {
305
  res.set({
306
  'X-Content-Type-Options': 'nosniff',
@@ -310,28 +273,18 @@ app.use((req, res, next) => {
310
  'Cross-Origin-Opener-Policy': 'same-origin',
311
  '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'",
312
  });
313
- if (req.secure || req.get('x-forwarded-proto') === 'https') {
314
- res.set('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
315
- }
316
  next();
317
  });
318
-
319
  app.use(express.json({ limit: '50mb' }));
320
  app.use(express.urlencoded({ extended: true, limit: '50mb' }));
321
- app.use(session({
322
- secret: SESSION_SECRET,
323
- store: sessionStore,
324
- resave: false,
325
- saveUninitialized: false,
326
- cookie: { secure: IS_PROD, httpOnly: true, maxAge: 30*24*60*60*1000, sameSite: 'lax' }
327
- }));
328
  app.use('/api', csrfGuard);
329
  app.use('/api', globalLimit);
330
 
331
  const requireAuth = (req, res, next) => { if(!req.session.userId) return res.status(401).json({error:'Non authentifié'}); next(); };
332
  const requireTos = (req, res, next) => { if(!req.session.tosAccepted) return res.status(403).json({error:'CGU non acceptées', needTos:true}); next(); };
333
 
334
- // --- CACHE ---
335
  class ByteLRUCache extends LRUCache { constructor(){ super({maxSize:CACHE_MAX_BYTES, sizeCalculation:v=>v.size}); } }
336
  const fileCache = new ByteLRUCache();
337
  setInterval(()=>{ const m = process.memoryUsage(); if(m.rss > 13*1024*1024*1024){ log('ALERT', `RAM ${(m.rss/1e9).toFixed(1)}GB → vidage du cache`); fileCache.clear(); if(global.gc) global.gc(); } }, 30000);
@@ -343,27 +296,22 @@ setInterval(() => {
343
  if (s.changes || l.changes) log('INFO', `Cleanup: ${s.changes} session(s), ${l.changes} lien(s) expiré(s)`);
344
  }, 3600000);
345
 
346
- // --- NSFW ---
347
  let nsfwModel = null;
348
  async function loadNsfwModel(){ try{ await tf.setBackend('wasm'); tf.wasm.setWasmPaths('/app/tfjs_wasm/','tfjs-backend-wasm.wasm'); await tf.ready(); nsfwModel = await nsfwjs.load('/app/tfjs_wasm/',{type:'mobilenet_v2',size:224}); log('INFO', 'NSFW WASM chargé'); }catch(e){ log('WARN', `NSFW load fail: ${e.message}`); } }
349
  loadNsfwModel();
350
  async function checkNsfw(buf){ if(!nsfwModel) return {safe:true}; try{ const t=tf.node.decodeImage(buf,3).resizeNearestNeighbor([224,224]).expandDims(0); const p=await nsfwModel.classify(t); t.dispose(); const porn=p.find(x=>x.className==='Porn'||x.className==='Sexual Activity')?.probability||0; const hentai=p.find(x=>x.className==='Hentai')?.probability||0; if(porn>0.85||hentai>0.9) return {safe:false}; return {safe:true}; }catch(e){ return {safe:true}; } }
351
 
352
- // --- QUEUE ---
353
  class UploadQueue{ constructor(c){ this.c=c; this.r=0; this.q=[]; } add(t){ return new Promise((res,rej)=>{ this.q.push({t,res,rej}); this.proc(); }); } async proc(){ if(this.r>=this.c||!this.q.length) return; this.r++; const {t,res,rej}=this.q.shift(); try{ res(await t()); }catch(e){ rej(e); }finally{ this.r--; this.proc(); } } }
354
  const uploadQueue = new UploadQueue(MAX_CONCURRENT_UPLOADS);
355
 
356
- // --- IMGBB ---
357
  async function imgbbUpload(buf){ const f=new FormData(); f.append('image',buf.toString('base64')); const {data}=await axios.post(`https://api.imgbb.com/1/upload?key=${IMGBB_API_KEY}`,f,{headers:f.getHeaders(),timeout:120000,maxContentLength:Infinity,maxBodyLength:Infinity}); if(!data.success) throw new Error(data.error?.message||'ImgBB fail'); return {url:data.data.url,deleteUrl:data.data.delete_url,id:data.data.id}; }
358
  async function imgbbDelete(u){ try{await axios.get(u,{timeout:10000});}catch(_){} }
359
 
360
- // --- KEYS ---
361
  const SERVER_MASTER_KEY = createHash('sha256').update(MASTER_SECRET + '|PixelDriveMaster').digest();
362
  function encryptMasterKey(k){ const iv=randomBytes(12); const c=createCipheriv('aes-256-gcm',SERVER_MASTER_KEY,iv); return Buffer.concat([iv,c.update(k),c.final(),c.getAuthTag()]); }
363
  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()]); }
364
  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); }
365
 
366
- // --- CONTRÔLE D'ACCÈS ---
367
  function canAccessFolder(userId, folderId){
368
  let cur = db.prepare('SELECT * FROM folders WHERE id=?').get(folderId);
369
  let depth = 0;
@@ -384,7 +332,6 @@ function canAccessFile(userId, fileId){
384
  return null;
385
  }
386
 
387
- // --- THUMB ---
388
  async function genThumb(fid,mime,key){
389
  if(!mime.startsWith('image/')&&!mime.startsWith('video/')) return;
390
  try{
@@ -400,7 +347,6 @@ async function genThumb(fid,mime,key){
400
  }catch(e){ log('WARN', `Thumb ${fid}: ${e.message}`); }
401
  }
402
 
403
- // --- STREAM (✅ compte le transit download) ---
404
  async function streamFile(res,file,key,range,counterUserId){
405
  const chunks = db.prepare('SELECT chunk_index,imgbb_url,size FROM file_chunks WHERE file_id=? ORDER BY chunk_index').all(file.id);
406
  const total = file.size;
@@ -410,7 +356,7 @@ async function streamFile(res,file,key,range,counterUserId){
410
  const sentBytes = e - s + 1;
411
  res.set({'Content-Type':file.mime,'Content-Length':sentBytes,'Accept-Ranges':'bytes','Content-Disposition':`inline; filename="${encodeURIComponent(file.name)}"`});
412
  if(range){ res.status(206); res.set('Content-Range', `bytes ${s}-${e}/${total}`); }
413
- log('INFO', `Stream ${file.name} (${(sentBytes/1048576).toFixed(2)} MB, ${chunks.length} chunk(s))`);
414
  let cur = 0;
415
  for(const c of chunks){
416
  const cs = cur, ce = cur + c.size - 1;
@@ -423,10 +369,9 @@ async function streamFile(res,file,key,range,counterUserId){
423
  const t0 = Date.now();
424
  const {data} = await axios.get(c.imgbb_url,{responseType:'arraybuffer',timeout:120000});
425
  const t1 = Date.now();
426
- log('INFO', ` Chunk ${c.chunk_index}: ${(data.byteLength/1048576).toFixed(2)} MB en ${t1-t0}ms`);
427
  const enc = await pngToBuffer(Buffer.from(data), c.size + OVERHEAD);
428
  dec = decryptChunk(enc, key);
429
- log('INFO', ` Chunk ${c.chunk_index}: décodé+déchiffré en ${Date.now()-t1}ms`);
430
  fileCache.set(ck, {data: dec, size: dec.length});
431
  }
432
  const sl = dec.subarray(off, off + len);
@@ -437,10 +382,9 @@ async function streamFile(res,file,key,range,counterUserId){
437
  if(cs > e) break;
438
  }
439
  res.end();
440
- if (counterUserId) addTransfer(counterUserId, sentBytes, 'down'); // ✅ transit ↓
441
  }
442
 
443
- // --- SSE ---
444
  const sseClients = new Map();
445
  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); } }
446
 
@@ -453,8 +397,7 @@ app.post('/api/register', registerLimit, async(req,res)=>{
453
  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)'});
454
  if(typeof p !== 'string' || p.length < 6) return res.status(400).json({error:'Mot de passe trop court (6 caractères min)'});
455
  if(db.prepare('SELECT 1 FROM users WHERE username=?').get(u)) return res.status(409).json({error:'Existe'});
456
- const s = randomBytes(16);
457
- const h = await deriveKey(p, s);
458
  db.prepare('INSERT INTO users(username,password_hash,salt,tos_accepted) VALUES(?,?,?,1)').run(u,h,s);
459
  log('INFO', `Nouvel utilisateur: ${u}`);
460
  res.json({ok:true});
@@ -466,35 +409,23 @@ app.post('/api/login', loginLimit, async(req,res)=>{
466
  if(!usr) return res.status(401).json({error:'Invalide'});
467
  const h = await deriveKey(p, usr.salt);
468
  if(!h.equals(usr.password_hash)) return res.status(401).json({error:'Invalide'});
469
- try {
470
- await new Promise((ok, bad) => req.session.regenerate(e => e ? bad(e) : ok()));
471
- } catch(e) {
472
- log('ERROR', `Session regenerate échouée: ${e.message}`);
473
- return res.status(500).json({error:'Erreur session'});
474
- }
475
- req.session.userId = usr.id;
476
- req.session.isAdmin = !!usr.is_admin;
477
- req.session.tosAccepted = !!usr.tos_accepted;
478
- req.session.username = usr.username;
479
  log('INFO', `Connexion: ${u}`);
480
  res.json({ok:true,user:{id:usr.id,username:usr.username,isAdmin:!!usr.is_admin,tosAccepted:!!usr.tos_accepted}});
481
  });
482
  app.post('/api/logout',(req,res)=>req.session.destroy(()=>res.json({ok:true})));
483
 
484
- // ✅ /api/me AVEC QUOTAS (stockage + transit)
485
  app.get('/api/me',(req,res)=>{
486
  if(!req.session.userId) return res.status(401).json({error:'Non connecté'});
487
  const used = db.prepare("SELECT COALESCE(SUM(size),0) as t FROM files WHERE owner_id=? AND status!='error'").get(req.session.userId).t;
488
  const tr = getTransfer(req.session.userId);
489
- res.json({
490
- id:req.session.userId, username:req.session.username, isAdmin:req.session.isAdmin, tosAccepted:req.session.tosAccepted,
491
- quotaUsed:used, quotaMax:USER_QUOTA,
492
- transferUp:tr.up, transferDown:tr.down, transferUsed:tr.total, transferMax:DAILY_TRANSFER
493
- });
494
  });
495
  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});});
496
 
497
- // AVATARS
498
  app.post('/api/avatar', requireAuth, avatarLimit, async(req,res)=>{
499
  const { data } = req.body;
500
  if (!data || !data.startsWith('data:image/')) return res.status(400).json({error:'Image invalide'});
@@ -515,7 +446,6 @@ app.get('/api/users/:id/avatar',(req,res)=>{
515
  res.send(u.avatar);
516
  });
517
 
518
- // Folders
519
  app.get('/api/folders',requireAuth,(req,res)=>{
520
  const p = req.query.parent_id ? parseInt(req.query.parent_id) : null;
521
  if (p !== null) {
@@ -527,7 +457,36 @@ app.get('/api/folders',requireAuth,(req,res)=>{
527
  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'});}});
528
  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});});
529
 
530
- // Files
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
531
  app.get('/api/files',requireAuth,(req,res)=>{
532
  const f = req.query.folder_id ? parseInt(req.query.folder_id) : null;
533
  if (f !== null) {
@@ -537,7 +496,32 @@ app.get('/api/files',requireAuth,(req,res)=>{
537
  res.json(db.prepare('SELECT * FROM files WHERE owner_id=? AND folder_id IS NULL ORDER BY name COLLATE NOCASE').all(req.session.userId));
538
  });
539
 
540
- // Partage utilisateurs
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
541
  app.post('/api/share-user',requireAuth,requireTos,shareLimit,(req,res)=>{
542
  const { type, id, username } = req.body;
543
  if (!type || !id || !username) return res.status(400).json({error:'Champs manquants'});
@@ -572,18 +556,14 @@ app.delete('/api/my-shares/:id',requireAuth,(req,res)=>{
572
  res.json({ok:true});
573
  });
574
 
575
- // Liens publics
576
  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)); });
577
  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}); });
578
 
579
- // ✅ UPLOAD : quota stockage + quota transit + NSFW
580
  const upload = multer({dest:TEMP_DIR,limits:{fileSize:5*1024*1024*1024}});
581
  app.post('/api/files/upload',requireAuth,requireTos,uploadLimit,upload.single('file'),async(req,res)=>{
582
  if(!req.file)return res.status(400).json({error:'Fichier manquant'});
583
  const uid = req.session.userId;
584
  const fsize = req.file.size;
585
-
586
- // ✅ Quota transit journalier (upload)
587
  const tr = getTransfer(uid);
588
  const remaining = DAILY_TRANSFER - tr.total;
589
  if (remaining <= 0) {
@@ -595,14 +575,11 @@ app.post('/api/files/upload',requireAuth,requireTos,uploadLimit,upload.single('f
595
  try { unlinkSync(req.file.path); } catch (_) {}
596
  return res.status(413).json({error:`Quota journalier insuffisant (reste ${(remaining/1073741824).toFixed(1)} GB)`});
597
  }
598
-
599
- // Quota stockage
600
  const used = db.prepare("SELECT COALESCE(SUM(size),0) as t FROM files WHERE owner_id=? AND status!='error'").get(uid).t;
601
  if (used + fsize > USER_QUOTA) {
602
  try { unlinkSync(req.file.path); } catch (_) {}
603
  return res.status(413).json({error:`Quota de stockage dépassé (${(USER_QUOTA/1073741824).toFixed(0)} GB max)`});
604
  }
605
-
606
  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;
607
  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);
608
  const stmt=db.prepare('INSERT INTO file_chunks(file_id,chunk_index,size,encrypted_len) VALUES(?,?,?,?)');
@@ -625,10 +602,7 @@ async function processUpload(fid,path,mk,cc,mime,fsize,username,uid){
625
  const raw=await readChunk(path,off,ci.size);
626
  if (i === 0 && mime.startsWith('image/')) {
627
  const scan = await checkNsfw(raw);
628
- if (!scan.safe) {
629
- log('ALERT', `NSFW bloqué: ${fid} par ${username}`);
630
- throw new Error('Contenu bloqué par le filtre de modération');
631
- }
632
  }
633
  const enc=encryptChunk(raw,mk);
634
  if(enc.length>ci.encrypted_len)throw new Error(`Overflow chunk ${i}`);
@@ -646,10 +620,9 @@ async function processUpload(fid,path,mk,cc,mime,fsize,username,uid){
646
  await genThumb(fid,mime,mk);
647
  db.prepare("UPDATE files SET status='ready',progress=100 WHERE id=?").run(fid);
648
  broadcast(fid,{type:'progress',progress:100,status:'ready',type:'done'});
649
- addTransfer(uid, fsize, 'up'); // ✅ transit ↑
650
  const secs = (Date.now()-t0)/1000;
651
- const speed = (fsize/1048576)/secs;
652
- log('INFO', `Upload terminé: ${fid} en ${secs.toFixed(1)}s (${speed.toFixed(2)} MB/s)`);
653
  }catch(e){
654
  log('ERROR', `Upload échoué ${fid}: ${e.message}`);
655
  db.prepare("UPDATE files SET status='error',error_msg=? WHERE id=?").run(e.message,fid);
@@ -661,13 +634,9 @@ async function processUpload(fid,path,mk,cc,mime,fsize,username,uid){
661
  }
662
  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);});}
663
 
664
- // ✅ DOWNLOAD : quota transit du demandeur
665
  app.get('/api/files/:id/download',requireAuth,async(req,res)=>{
666
  if(!UUID_RE.test(req.params.id)) return res.status(400).json({error:'ID invalide'});
667
- if (transferBlocked(req.session.userId)) {
668
- log('ALERT', `Quota transit atteint (download bloqué): ${req.session.username}`);
669
- return res.status(429).json({error:'Quota de transfert journalier atteint, réessayez demain'});
670
- }
671
  const f = canAccessFile(req.session.userId, req.params.id);
672
  if(!f)return res.status(404).json({error:'Introuvable'});
673
  if(f.status!=='ready')return res.status(409).json({error:'Pas prêt',status:f.status});
@@ -684,7 +653,6 @@ app.post('/api/files/:id/share',requireAuth,shareLimit,async(req,res)=>{
684
  res.json({preview:`/s/${pt}`,download:`/d/${dt2}`,expires});
685
  });
686
 
687
- // PAGE PUBLIQUE
688
  app.get('/s/:token',async(req,res)=>{
689
  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());
690
  if(!l)return res.status(404).send(html('Lien invalide/expiré'));
@@ -703,19 +671,15 @@ app.get('/s/:token',async(req,res)=>{
703
  { title: name, desc: `${sizeMb} MB · PixelDrive — partage sécurisé`, image: ogImage }));
704
  });
705
 
706
- // ✅ DOWNLOAD PUBLIC : quota transit du PROPRIÉTAIRE
707
  app.get('/d/:token',async(req,res)=>{
708
  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());
709
  if(!l)return res.status(404).json({error:'Invalide'});
710
  const f=db.prepare('SELECT * FROM files WHERE id=?').get(l.file_id);
711
  if(!f||f.status!=='ready')return res.status(404).json({error:'Indisponible'});
712
- if (transferBlocked(f.owner_id)) {
713
- log('ALERT', `Quota transit propriétaire atteint (lien public bloqué): fichier ${f.id}`);
714
- return res.status(429).json({error:'Quota journalier du propriétaire atteint'});
715
- }
716
  log('INFO', `Téléchargement public /d/${req.params.token} (${f.name})`);
717
- const mk=decryptMasterKey(db.prepare('SELECT master_key_enc FROM files WHERE id=?').get(f.id).master_key_enc);
718
- await streamFile(res,f,k mk,req.headers.range,f.owner_id);
719
  });
720
 
721
  app.delete('/api/files/:id',requireAuth,async(req,res)=>{
@@ -755,7 +719,6 @@ app.get('/api/files/:id/progress',requireAuth,(req,res)=>{
755
  app.get('/api/admin/users',requireAuth,(req,res)=>{if(!req.session.isAdmin)return res.status(403).json({error:'Admin only'});res.json(db.prepare('SELECT id,username,is_admin,tos_accepted,created_at FROM users').all());});
756
  app.get('/health',(_,res)=>res.json({ok:true,cache:fileCache.size,mem:process.memoryUsage()}));
757
 
758
- // STATIQUE
759
  app.use(express.static(PUBLIC_DIR, { dotfiles: 'deny', index: false }));
760
  app.get('/manifest.json', (req,res) => res.sendFile(join(__dirname, 'manifest.json')));
761
  app.get('*', (req, res) => {
@@ -763,14 +726,13 @@ app.get('*', (req, res) => {
763
  res.sendFile(join(PUBLIC_DIR, 'index.html'));
764
  });
765
 
766
- // ERROR HANDLER
767
  app.use((err, req, res, next) => {
768
  if (err.type === 'entity.parse.failed' || err instanceof SyntaxError) return res.status(400).json({ error: 'Bad Request' });
769
  log('ERROR', `Server: ${err.message}`);
770
  res.status(500).json({ error: 'Internal Server Error' });
771
  });
772
 
773
- const server = app.listen(PORT, '0.0.0.0', () => log('INFO', `PixelDrive v4.5 démarré | stockage ${(USER_QUOTA/1073741824).toFixed(0)} GB/user | transit ${(DAILY_TRANSFER/1073741824).toFixed(0)} GB/jour/user`));
774
  process.on('SIGTERM', () => { log('WARN', 'SIGTERM reçu, arrêt...'); server.close(() => process.exit(0)); });
775
 
776
  function html(b, og) {
 
1
  // ==========================================
2
+ // PIXELDRIVE v4.6 - SERVER.JS (ESM) - RENAME/MOVE + QUOTAS
3
  // ==========================================
4
  import express from 'express';
5
  import session from 'express-session';
 
34
  const TAG_LEN = 16;
35
  const OVERHEAD = IV_LEN + TAG_LEN;
36
  const IS_PROD = process.env.NODE_ENV === 'production';
37
+ const USER_QUOTA = parseInt(process.env.USER_QUOTA_GB || '50', 10) * 1024 * 1024 * 1024;
38
+ const DAILY_TRANSFER = parseInt(process.env.DAILY_TRANSFER_GB || '100', 10) * 1024 * 1024 * 1024;
39
  const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
40
 
 
 
 
41
  function log(level, msg) {
42
  const n = new Date();
43
  const ts = [n.getHours(), n.getMinutes(), n.getSeconds()].map(x => String(x).padStart(2, '0')).join(':');
 
47
  else console.log(line);
48
  }
49
 
 
50
  const IMGBB_API_KEY = process.env.IMGBB_API_KEY;
51
  const SESSION_SECRET = process.env.SESSION_SECRET;
52
  const ADMIN_USER = process.env.ADMIN_USER || 'admin';
53
 
54
+ if (!IMGBB_API_KEY || !SESSION_SECRET) { log('FATAL ERROR', 'Variables manquantes: IMGBB_API_KEY, SESSION_SECRET'); process.exit(1); }
 
 
 
55
 
56
  const MASTER_SECRET = process.env.MASTER_SECRET || SESSION_SECRET;
57
  if (process.env.MASTER_SECRET) log('INFO', 'MASTER_SECRET personnalisé actif');
 
59
 
60
  const db = new Database(DB_PATH);
61
  db.pragma('journal_mode = WAL');
62
+ db.pragma('busy_timeout = 5000);'.slice(0,0) || db.pragma('busy_timeout = 5000');
63
 
 
64
  const scryptAsync = promisify(scrypt);
65
  async function deriveKey(password, salt) { return scryptAsync(password, salt, KEY_LEN); }
66
  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]); }
67
  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()]); }
 
68
  function esc(s) { return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;').replace(/'/g,'&#39;'); }
69
 
 
70
  function bufferToPng(buffer) {
71
  const byteLen = buffer.length;
72
  const pixelsNeeded = Math.ceil(byteLen / 3);
 
86
  return rgb.subarray(0, expectedLen);
87
  }
88
 
 
 
 
89
  db.exec(`
90
  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')));
91
  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));
 
101
  CREATE INDEX IF NOT EXISTS idx_transfer_day ON transfer_daily(user_id, day);
102
  `);
103
 
 
 
 
104
  function runMigrations() {
105
  log('INFO', 'Vérification migrations BDD...');
106
  const usersCols = db.prepare("PRAGMA table_info(users)").all().map(c => c.name);
107
  if (!usersCols.includes('tos_accepted')) db.exec(`ALTER TABLE users ADD COLUMN tos_accepted INTEGER DEFAULT 0;`);
108
  if (!usersCols.includes('avatar')) db.exec(`ALTER TABLE users ADD COLUMN avatar BLOB;`);
 
109
  const filesCols = db.prepare("PRAGMA table_info(files)").all().map(c => c.name);
110
  const filesDefs = [
111
  { name: 'folder_id', def: 'INTEGER REFERENCES folders(id) ON DELETE SET NULL' },
 
126
  }
127
  }
128
  if (updatedAdded) db.exec(`UPDATE files SET updated_at = strftime('%s','now') WHERE updated_at = 0;`);
 
129
  const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table'").all().map(t => t.name);
130
  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));`);
131
  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);`);
132
  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);`);
133
  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);`);
 
134
  const sessionsCols = tables.includes('sessions') ? db.prepare("PRAGMA table_info(sessions)").all().map(c => c.name) : [];
135
  if (tables.includes('sessions') && (!sessionsCols.includes('data') || !sessionsCols.includes('expires_at'))) {
136
  log('WARN', 'Table sessions invalide → reconstruction');
 
148
  CREATE TRIGGER update_file_ts AFTER UPDATE ON files BEGIN UPDATE files SET updated_at = strftime('%s','now') WHERE id = NEW.id; END;
149
  `);
150
 
151
+ // --- QUOTA TRANSIT ---
 
 
152
  function todayKey() { return new Date().toISOString().slice(0, 10); }
153
  function getTransfer(userId) {
154
  const r = db.prepare('SELECT up_bytes, down_bytes FROM transfer_daily WHERE user_id=? AND day=?').get(userId, todayKey());
 
163
  .run(userId, todayKey(), dir === 'up' ? bytes : 0, dir === 'down' ? bytes : 0);
164
  } catch (e) { log('WARN', `addTransfer: ${e.message}`); }
165
  }
166
+ function transferBlocked(userId) { return getTransfer(userId).total >= DAILY_TRANSFER; }
 
 
167
 
168
+ // --- NETTOYAGE + BACKUPS ---
 
 
169
  function cleanTemp() {
170
  try {
171
  const now = Date.now();
172
  for (const f of readdirSync(TEMP_DIR)) {
173
  const p = join(TEMP_DIR, f);
174
+ if (now - statSync(p).mtimeMs > 24 * 3600 * 1000) { rmSync(p, { force: true }); log('INFO', `Temp nettoyé: ${f}`); }
 
175
  }
176
  } catch (e) { log('WARN', `Temp cleanup: ${e.message}`); }
177
  }
 
211
  setInterval(backupDb, 6 * 3600000);
212
  setTimeout(backupDb, 60000);
213
 
214
+ // --- RATE LIMITER ---
 
 
215
  function rateLimit({ windowMs = 60000, max = 100, message = 'Trop de requêtes, réessayez plus tard' } = {}) {
216
  const hits = new Map();
217
  setInterval(() => { const now = Date.now(); for (const [k, v] of hits) if (now - v.start > windowMs) hits.delete(k); }, Math.max(windowMs, 60000));
 
232
  const shareLimit = rateLimit({ windowMs: 60*60000, max: 50, message: 'Trop de partages' });
233
  const avatarLimit = rateLimit({ windowMs: 60*60000, max: 10, message: 'Trop de changements d\'avatar' });
234
 
235
+ // --- CSRF ---
 
 
236
  function csrfGuard(req, res, next) {
237
  if (['GET', 'HEAD', 'OPTIONS'].includes(req.method)) return next();
238
  const origin = req.headers.origin;
239
  if (origin) {
240
  let oHost;
241
  try { oHost = new URL(origin).host; } catch (e) { return res.status(403).json({ error: 'Origin invalide' }); }
242
+ if (oHost !== req.get('host')) { log('WARN', `CSRF bloqué: origin=${origin}`); return res.status(403).json({ error: 'Requête cross-origin refusée' }); }
 
 
 
243
  }
244
  next();
245
  }
246
 
 
247
  class SQLiteStore extends session.Store {
248
  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); } }
249
  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); } }
 
254
  }
255
  const sessionStore = new SQLiteStore();
256
 
 
257
  const adminRow = db.prepare('SELECT * FROM users WHERE username=?').get(ADMIN_USER);
258
  if (!adminRow) {
259
  const salt = randomBytes(16);
 
262
  log('INFO', `Compte admin créé: ${ADMIN_USER}`);
263
  }
264
 
 
265
  const app = express();
266
  app.set('trust proxy', 1);
 
267
  app.use((req, res, next) => {
268
  res.set({
269
  'X-Content-Type-Options': 'nosniff',
 
273
  'Cross-Origin-Opener-Policy': 'same-origin',
274
  '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'",
275
  });
276
+ if (req.secure || req.get('x-forwarded-proto') === 'https') res.set('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
 
 
277
  next();
278
  });
 
279
  app.use(express.json({ limit: '50mb' }));
280
  app.use(express.urlencoded({ extended: true, limit: '50mb' }));
281
+ 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' } }));
 
 
 
 
 
 
282
  app.use('/api', csrfGuard);
283
  app.use('/api', globalLimit);
284
 
285
  const requireAuth = (req, res, next) => { if(!req.session.userId) return res.status(401).json({error:'Non authentifié'}); next(); };
286
  const requireTos = (req, res, next) => { if(!req.session.tosAccepted) return res.status(403).json({error:'CGU non acceptées', needTos:true}); next(); };
287
 
 
288
  class ByteLRUCache extends LRUCache { constructor(){ super({maxSize:CACHE_MAX_BYTES, sizeCalculation:v=>v.size}); } }
289
  const fileCache = new ByteLRUCache();
290
  setInterval(()=>{ const m = process.memoryUsage(); if(m.rss > 13*1024*1024*1024){ log('ALERT', `RAM ${(m.rss/1e9).toFixed(1)}GB → vidage du cache`); fileCache.clear(); if(global.gc) global.gc(); } }, 30000);
 
296
  if (s.changes || l.changes) log('INFO', `Cleanup: ${s.changes} session(s), ${l.changes} lien(s) expiré(s)`);
297
  }, 3600000);
298
 
 
299
  let nsfwModel = null;
300
  async function loadNsfwModel(){ try{ await tf.setBackend('wasm'); tf.wasm.setWasmPaths('/app/tfjs_wasm/','tfjs-backend-wasm.wasm'); await tf.ready(); nsfwModel = await nsfwjs.load('/app/tfjs_wasm/',{type:'mobilenet_v2',size:224}); log('INFO', 'NSFW WASM chargé'); }catch(e){ log('WARN', `NSFW load fail: ${e.message}`); } }
301
  loadNsfwModel();
302
  async function checkNsfw(buf){ if(!nsfwModel) return {safe:true}; try{ const t=tf.node.decodeImage(buf,3).resizeNearestNeighbor([224,224]).expandDims(0); const p=await nsfwModel.classify(t); t.dispose(); const porn=p.find(x=>x.className==='Porn'||x.className==='Sexual Activity')?.probability||0; const hentai=p.find(x=>x.className==='Hentai')?.probability||0; if(porn>0.85||hentai>0.9) return {safe:false}; return {safe:true}; }catch(e){ return {safe:true}; } }
303
 
 
304
  class UploadQueue{ constructor(c){ this.c=c; this.r=0; this.q=[]; } add(t){ return new Promise((res,rej)=>{ this.q.push({t,res,rej}); this.proc(); }); } async proc(){ if(this.r>=this.c||!this.q.length) return; this.r++; const {t,res,rej}=this.q.shift(); try{ res(await t()); }catch(e){ rej(e); }finally{ this.r--; this.proc(); } } }
305
  const uploadQueue = new UploadQueue(MAX_CONCURRENT_UPLOADS);
306
 
 
307
  async function imgbbUpload(buf){ const f=new FormData(); f.append('image',buf.toString('base64')); const {data}=await axios.post(`https://api.imgbb.com/1/upload?key=${IMGBB_API_KEY}`,f,{headers:f.getHeaders(),timeout:120000,maxContentLength:Infinity,maxBodyLength:Infinity}); if(!data.success) throw new Error(data.error?.message||'ImgBB fail'); return {url:data.data.url,deleteUrl:data.data.delete_url,id:data.data.id}; }
308
  async function imgbbDelete(u){ try{await axios.get(u,{timeout:10000});}catch(_){} }
309
 
 
310
  const SERVER_MASTER_KEY = createHash('sha256').update(MASTER_SECRET + '|PixelDriveMaster').digest();
311
  function encryptMasterKey(k){ const iv=randomBytes(12); const c=createCipheriv('aes-256-gcm',SERVER_MASTER_KEY,iv); return Buffer.concat([iv,c.update(k),c.final(),c.getAuthTag()]); }
312
  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()]); }
313
  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); }
314
 
 
315
  function canAccessFolder(userId, folderId){
316
  let cur = db.prepare('SELECT * FROM folders WHERE id=?').get(folderId);
317
  let depth = 0;
 
332
  return null;
333
  }
334
 
 
335
  async function genThumb(fid,mime,key){
336
  if(!mime.startsWith('image/')&&!mime.startsWith('video/')) return;
337
  try{
 
347
  }catch(e){ log('WARN', `Thumb ${fid}: ${e.message}`); }
348
  }
349
 
 
350
  async function streamFile(res,file,key,range,counterUserId){
351
  const chunks = db.prepare('SELECT chunk_index,imgbb_url,size FROM file_chunks WHERE file_id=? ORDER BY chunk_index').all(file.id);
352
  const total = file.size;
 
356
  const sentBytes = e - s + 1;
357
  res.set({'Content-Type':file.mime,'Content-Length':sentBytes,'Accept-Ranges':'bytes','Content-Disposition':`inline; filename="${encodeURIComponent(file.name)}"`});
358
  if(range){ res.status(206); res.set('Content-Range', `bytes ${s}-${e}/${total}`); }
359
+ log('INFO', `Stream ${file.name} (${(sentBytes/1048576).toFixed(2)} MB)`);
360
  let cur = 0;
361
  for(const c of chunks){
362
  const cs = cur, ce = cur + c.size - 1;
 
369
  const t0 = Date.now();
370
  const {data} = await axios.get(c.imgbb_url,{responseType:'arraybuffer',timeout:120000});
371
  const t1 = Date.now();
 
372
  const enc = await pngToBuffer(Buffer.from(data), c.size + OVERHEAD);
373
  dec = decryptChunk(enc, key);
374
+ log('INFO', ` Chunk ${c.chunk_index}: ${(data.byteLength/1048576).toFixed(2)} MB en ${Date.now()-t0}ms`);
375
  fileCache.set(ck, {data: dec, size: dec.length});
376
  }
377
  const sl = dec.subarray(off, off + len);
 
382
  if(cs > e) break;
383
  }
384
  res.end();
385
+ if (counterUserId) addTransfer(counterUserId, sentBytes, 'down');
386
  }
387
 
 
388
  const sseClients = new Map();
389
  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); } }
390
 
 
397
  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)'});
398
  if(typeof p !== 'string' || p.length < 6) return res.status(400).json({error:'Mot de passe trop court (6 caractères min)'});
399
  if(db.prepare('SELECT 1 FROM users WHERE username=?').get(u)) return res.status(409).json({error:'Existe'});
400
+ const s = randomBytes(16); const h = await deriveKey(p, s);
 
401
  db.prepare('INSERT INTO users(username,password_hash,salt,tos_accepted) VALUES(?,?,?,1)').run(u,h,s);
402
  log('INFO', `Nouvel utilisateur: ${u}`);
403
  res.json({ok:true});
 
409
  if(!usr) return res.status(401).json({error:'Invalide'});
410
  const h = await deriveKey(p, usr.salt);
411
  if(!h.equals(usr.password_hash)) return res.status(401).json({error:'Invalide'});
412
+ try { await new Promise((ok, bad) => req.session.regenerate(e => e ? bad(e) : ok())); }
413
+ catch(e) { log('ERROR', `Session regenerate échouée: ${e.message}`); return res.status(500).json({error:'Erreur session'}); }
414
+ req.session.userId = usr.id; req.session.isAdmin = !!usr.is_admin; req.session.tosAccepted = !!usr.tos_accepted; req.session.username = usr.username;
 
 
 
 
 
 
 
415
  log('INFO', `Connexion: ${u}`);
416
  res.json({ok:true,user:{id:usr.id,username:usr.username,isAdmin:!!usr.is_admin,tosAccepted:!!usr.tos_accepted}});
417
  });
418
  app.post('/api/logout',(req,res)=>req.session.destroy(()=>res.json({ok:true})));
419
 
 
420
  app.get('/api/me',(req,res)=>{
421
  if(!req.session.userId) return res.status(401).json({error:'Non connecté'});
422
  const used = db.prepare("SELECT COALESCE(SUM(size),0) as t FROM files WHERE owner_id=? AND status!='error'").get(req.session.userId).t;
423
  const tr = getTransfer(req.session.userId);
424
+ res.json({ id:req.session.userId, username:req.session.username, isAdmin:req.session.isAdmin, tosAccepted:req.session.tosAccepted,
425
+ quotaUsed:used, quotaMax:USER_QUOTA, transferUp:tr.up, transferDown:tr.down, transferUsed:tr.total, transferMax:DAILY_TRANSFER });
 
 
 
426
  });
427
  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});});
428
 
 
429
  app.post('/api/avatar', requireAuth, avatarLimit, async(req,res)=>{
430
  const { data } = req.body;
431
  if (!data || !data.startsWith('data:image/')) return res.status(400).json({error:'Image invalide'});
 
446
  res.send(u.avatar);
447
  });
448
 
 
449
  app.get('/api/folders',requireAuth,(req,res)=>{
450
  const p = req.query.parent_id ? parseInt(req.query.parent_id) : null;
451
  if (p !== null) {
 
457
  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'});}});
458
  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});});
459
 
460
+ // ✅ RENAMER DOSSIER
461
+ app.post('/api/folders/:id/rename',requireAuth,(req,res)=>{
462
+ const { name } = req.body;
463
+ if(!name || typeof name !== 'string' || !name.trim() || name.length > 100) return res.status(400).json({error:'Nom invalide'});
464
+ const fo = db.prepare('SELECT id FROM folders WHERE id=? AND owner_id=?').get(req.params.id, req.session.userId);
465
+ if(!fo) return res.status(404).json({error:'Introuvable'});
466
+ try { db.prepare('UPDATE folders SET name=? WHERE id=?').run(name.trim(), req.params.id); res.json({ok:true}); }
467
+ catch(e){ res.status(409).json({error:'Un dossier porte déjà ce nom'}); }
468
+ });
469
+
470
+ // ✅ DÉPLACER DOSSIER (anti-cycle)
471
+ app.post('/api/folders/:id/move',requireAuth,(req,res)=>{
472
+ const { parent_id } = req.body;
473
+ const fo = db.prepare('SELECT * FROM folders WHERE id=? AND owner_id=?').get(req.params.id, req.session.userId);
474
+ if(!fo) return res.status(404).json({error:'Introuvable'});
475
+ if (parent_id) {
476
+ const target = db.prepare('SELECT * FROM folders WHERE id=?').get(parent_id);
477
+ if (!target || target.owner_id !== req.session.userId) return res.status(403).json({error:'Dossier cible invalide'});
478
+ let cur = target, depth = 0;
479
+ while (cur && depth < 50) {
480
+ if (cur.id === fo.id) return res.status(400).json({error:'Impossible de déplacer un dossier dans lui-même'});
481
+ cur = cur.parent_id ? db.prepare('SELECT * FROM folders WHERE id=?').get(cur.parent_id) : null;
482
+ depth++;
483
+ }
484
+ }
485
+ db.prepare('UPDATE folders SET parent_id=? WHERE id=?').run(parent_id || null, req.params.id);
486
+ log('INFO', `Dossier ${req.params.id} déplacé → ${parent_id || 'racine'}`);
487
+ res.json({ok:true});
488
+ });
489
+
490
  app.get('/api/files',requireAuth,(req,res)=>{
491
  const f = req.query.folder_id ? parseInt(req.query.folder_id) : null;
492
  if (f !== null) {
 
496
  res.json(db.prepare('SELECT * FROM files WHERE owner_id=? AND folder_id IS NULL ORDER BY name COLLATE NOCASE').all(req.session.userId));
497
  });
498
 
499
+ // RENAMER FICHIER
500
+ app.post('/api/files/:id/rename',requireAuth,(req,res)=>{
501
+ if(!UUID_RE.test(req.params.id)) return res.status(400).json({error:'ID invalide'});
502
+ const { name } = req.body;
503
+ if(!name || typeof name !== 'string' || !name.trim() || name.length > 200) return res.status(400).json({error:'Nom invalide'});
504
+ const f = db.prepare('SELECT id FROM files WHERE id=? AND owner_id=?').get(req.params.id, req.session.userId);
505
+ if(!f) return res.status(404).json({error:'Introuvable'});
506
+ db.prepare('UPDATE files SET name=? WHERE id=?').run(name.trim(), req.params.id);
507
+ res.json({ok:true});
508
+ });
509
+
510
+ // ✅ DÉPLACER FICHIER
511
+ app.post('/api/files/:id/move',requireAuth,(req,res)=>{
512
+ if(!UUID_RE.test(req.params.id)) return res.status(400).json({error:'ID invalide'});
513
+ const { folder_id } = req.body;
514
+ const f = db.prepare('SELECT id FROM files WHERE id=? AND owner_id=?').get(req.params.id, req.session.userId);
515
+ if(!f) return res.status(404).json({error:'Introuvable'});
516
+ if (folder_id) {
517
+ const target = db.prepare('SELECT owner_id FROM folders WHERE id=?').get(folder_id);
518
+ if (!target || target.owner_id !== req.session.userId) return res.status(403).json({error:'Dossier cible invalide'});
519
+ }
520
+ db.prepare('UPDATE files SET folder_id=? WHERE id=?').run(folder_id || null, req.params.id);
521
+ log('INFO', `Fichier ${req.params.id} déplacé → ${folder_id || 'racine'}`);
522
+ res.json({ok:true});
523
+ });
524
+
525
  app.post('/api/share-user',requireAuth,requireTos,shareLimit,(req,res)=>{
526
  const { type, id, username } = req.body;
527
  if (!type || !id || !username) return res.status(400).json({error:'Champs manquants'});
 
556
  res.json({ok:true});
557
  });
558
 
 
559
  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)); });
560
  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}); });
561
 
 
562
  const upload = multer({dest:TEMP_DIR,limits:{fileSize:5*1024*1024*1024}});
563
  app.post('/api/files/upload',requireAuth,requireTos,uploadLimit,upload.single('file'),async(req,res)=>{
564
  if(!req.file)return res.status(400).json({error:'Fichier manquant'});
565
  const uid = req.session.userId;
566
  const fsize = req.file.size;
 
 
567
  const tr = getTransfer(uid);
568
  const remaining = DAILY_TRANSFER - tr.total;
569
  if (remaining <= 0) {
 
575
  try { unlinkSync(req.file.path); } catch (_) {}
576
  return res.status(413).json({error:`Quota journalier insuffisant (reste ${(remaining/1073741824).toFixed(1)} GB)`});
577
  }
 
 
578
  const used = db.prepare("SELECT COALESCE(SUM(size),0) as t FROM files WHERE owner_id=? AND status!='error'").get(uid).t;
579
  if (used + fsize > USER_QUOTA) {
580
  try { unlinkSync(req.file.path); } catch (_) {}
581
  return res.status(413).json({error:`Quota de stockage dépassé (${(USER_QUOTA/1073741824).toFixed(0)} GB max)`});
582
  }
 
583
  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;
584
  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);
585
  const stmt=db.prepare('INSERT INTO file_chunks(file_id,chunk_index,size,encrypted_len) VALUES(?,?,?,?)');
 
602
  const raw=await readChunk(path,off,ci.size);
603
  if (i === 0 && mime.startsWith('image/')) {
604
  const scan = await checkNsfw(raw);
605
+ if (!scan.safe) { log('ALERT', `NSFW bloqué: ${fid} par ${username}`); throw new Error('Contenu bloqué par le filtre de modération'); }
 
 
 
606
  }
607
  const enc=encryptChunk(raw,mk);
608
  if(enc.length>ci.encrypted_len)throw new Error(`Overflow chunk ${i}`);
 
620
  await genThumb(fid,mime,mk);
621
  db.prepare("UPDATE files SET status='ready',progress=100 WHERE id=?").run(fid);
622
  broadcast(fid,{type:'progress',progress:100,status:'ready',type:'done'});
623
+ addTransfer(uid, fsize, 'up');
624
  const secs = (Date.now()-t0)/1000;
625
+ log('INFO', `Upload terminé: ${fid} en ${secs.toFixed(1)}s (${(fsize/1048576/secs).toFixed(2)} MB/s)`);
 
626
  }catch(e){
627
  log('ERROR', `Upload échoué ${fid}: ${e.message}`);
628
  db.prepare("UPDATE files SET status='error',error_msg=? WHERE id=?").run(e.message,fid);
 
634
  }
635
  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);});}
636
 
 
637
  app.get('/api/files/:id/download',requireAuth,async(req,res)=>{
638
  if(!UUID_RE.test(req.params.id)) return res.status(400).json({error:'ID invalide'});
639
+ 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'}); }
 
 
 
640
  const f = canAccessFile(req.session.userId, req.params.id);
641
  if(!f)return res.status(404).json({error:'Introuvable'});
642
  if(f.status!=='ready')return res.status(409).json({error:'Pas prêt',status:f.status});
 
653
  res.json({preview:`/s/${pt}`,download:`/d/${dt2}`,expires});
654
  });
655
 
 
656
  app.get('/s/:token',async(req,res)=>{
657
  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());
658
  if(!l)return res.status(404).send(html('Lien invalide/expiré'));
 
671
  { title: name, desc: `${sizeMb} MB · PixelDrive — partage sécurisé`, image: ogImage }));
672
  });
673
 
 
674
  app.get('/d/:token',async(req,res)=>{
675
  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());
676
  if(!l)return res.status(404).json({error:'Invalide'});
677
  const f=db.prepare('SELECT * FROM files WHERE id=?').get(l.file_id);
678
  if(!f||f.status!=='ready')return res.status(404).json({error:'Indisponible'});
679
+ 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'}); }
 
 
 
680
  log('INFO', `Téléchargement public /d/${req.params.token} (${f.name})`);
681
+ const mk = decryptMasterKey(db.prepare('SELECT master_key_enc FROM files WHERE id=?').get(f.id).master_key_enc);
682
+ await streamFile(res,f,mk,req.headers.range,f.owner_id);
683
  });
684
 
685
  app.delete('/api/files/:id',requireAuth,async(req,res)=>{
 
719
  app.get('/api/admin/users',requireAuth,(req,res)=>{if(!req.session.isAdmin)return res.status(403).json({error:'Admin only'});res.json(db.prepare('SELECT id,username,is_admin,tos_accepted,created_at FROM users').all());});
720
  app.get('/health',(_,res)=>res.json({ok:true,cache:fileCache.size,mem:process.memoryUsage()}));
721
 
 
722
  app.use(express.static(PUBLIC_DIR, { dotfiles: 'deny', index: false }));
723
  app.get('/manifest.json', (req,res) => res.sendFile(join(__dirname, 'manifest.json')));
724
  app.get('*', (req, res) => {
 
726
  res.sendFile(join(PUBLIC_DIR, 'index.html'));
727
  });
728
 
 
729
  app.use((err, req, res, next) => {
730
  if (err.type === 'entity.parse.failed' || err instanceof SyntaxError) return res.status(400).json({ error: 'Bad Request' });
731
  log('ERROR', `Server: ${err.message}`);
732
  res.status(500).json({ error: 'Internal Server Error' });
733
  });
734
 
735
+ const server = app.listen(PORT, '0.0.0.0', () => log('INFO', `PixelDrive v4.6 démarré | stockage ${(USER_QUOTA/1073741824).toFixed(0)} GB | transit ${(DAILY_TRANSFER/1073741824).toFixed(0)} GB/j`));
736
  process.on('SIGTERM', () => { log('WARN', 'SIGTERM reçu, arrêt...'); server.close(() => process.exit(0)); });
737
 
738
  function html(b, og) {