NathMen12 commited on
Commit
9009925
·
verified ·
1 Parent(s): aecc6c6

Update server.js

Browse files
Files changed (1) hide show
  1. server.js +217 -376
server.js CHANGED
@@ -1,5 +1,5 @@
1
  // ==========================================
2
- // PIXELDRIVE v3.2 - SERVER.JS (ESM) - COMPLET
3
  // ==========================================
4
  import express from 'express';
5
  import session from 'express-session';
@@ -32,7 +32,6 @@ const IV_LEN = 12;
32
  const TAG_LEN = 16;
33
  const OVERHEAD = IV_LEN + TAG_LEN;
34
 
35
- // --- ENV ---
36
  const IMGBB_API_KEY = process.env.IMGBB_API_KEY;
37
  const SESSION_SECRET = process.env.SESSION_SECRET;
38
  const ADMIN_USER = process.env.ADMIN_USER || 'admin';
@@ -42,130 +41,50 @@ if (!IMGBB_API_KEY || !SESSION_SECRET) {
42
  process.exit(1);
43
  }
44
 
45
- // --- DATABASE INSTANCE ---
46
  const db = new Database(DB_PATH);
47
  db.pragma('journal_mode = WAL');
48
  db.pragma('busy_timeout = 5000');
49
 
50
- // --- CRYPTO UTILS ---
51
  const scryptAsync = promisify(scrypt);
52
  async function deriveKey(password, salt) { return scryptAsync(password, salt, KEY_LEN); }
53
- function encryptChunk(dataBuffer, key) {
54
- const iv = randomBytes(IV_LEN);
55
- const cipher = createCipheriv('aes-256-gcm', key, iv);
56
- const encrypted = Buffer.concat([cipher.update(dataBuffer), cipher.final()]);
57
- const tag = cipher.getAuthTag();
58
- return Buffer.concat([iv, encrypted, tag]);
 
 
 
 
 
 
59
  }
60
- function decryptChunk(encBuffer, key) {
61
- if (encBuffer.length < OVERHEAD) throw new Error('Buffer trop court');
62
- const iv = encBuffer.subarray(0, IV_LEN);
63
- const tag = encBuffer.subarray(encBuffer.length - TAG_LEN);
64
- const data = encBuffer.subarray(IV_LEN, encBuffer.length - TAG_LEN);
65
- const decipher = createDecipheriv('aes-256-gcm', key, iv);
66
- decipher.setAuthTag(tag);
67
- return Buffer.concat([decipher.update(data), decipher.final()]);
68
- }
69
-
70
- // --- PNG STEGANOGRAPHY (OPTIMISÉ) ---
71
- function bufferToPng(buffer) {
72
- const byteLen = buffer.length;
73
- const pixelsNeeded = Math.ceil(byteLen / 3);
74
- const width = Math.ceil(Math.sqrt(pixelsNeeded));
75
- const height = Math.ceil(pixelsNeeded / width);
76
- const rgbBuffer = Buffer.alloc(width * height * 3);
77
- buffer.copy(rgbBuffer);
78
- return sharp(rgbBuffer, { raw: { width, height, channels: 3 } })
79
- .png({ compressionLevel: 9, palette: false })
80
- .withMetadata(false)
81
- .toBuffer();
82
- }
83
-
84
- async function pngToBuffer(pngBuffer, expectedLen) {
85
- const { data, info } = await sharp(pngBuffer)
86
- .raw()
87
- .toBuffer({ resolveWithObject: true });
88
-
89
  const channels = info.channels;
90
- const totalPixels = info.width * info.height;
91
- const rgb = Buffer.alloc(totalPixels * 3);
92
-
93
- if (channels === 3) {
94
- data.copy(rgb, 0, 0, Math.min(data.length, rgb.length));
95
- } else if (channels === 4) {
96
- for (let i = 0, j = 0; i < data.length && j < rgb.length; i += 4, j += 3) {
97
- rgb[j] = data[i];
98
- rgb[j+1] = data[i+1];
99
- rgb[j+2] = data[i+2];
100
- }
101
- } else if (channels === 1) {
102
- for (let i = 0, j = 0; i < data.length && j < rgb.length; i++, j += 3) {
103
- rgb[j] = rgb[j+1] = rgb[j+2] = data[i];
104
- }
105
- }
106
-
107
- return rgb.subarray(0, expectedLen);
108
  }
109
 
110
  // ==========================================
111
- // SCHÉMA DE BASE
112
  // ==========================================
113
  db.exec(`
114
- CREATE TABLE IF NOT EXISTS users (
115
- id INTEGER PRIMARY KEY AUTOINCREMENT,
116
- username TEXT UNIQUE NOT NULL,
117
- password_hash BLOB NOT NULL,
118
- salt BLOB NOT NULL,
119
- is_admin INTEGER DEFAULT 0,
120
- tos_accepted INTEGER DEFAULT 0,
121
- created_at INTEGER DEFAULT (strftime('%s','now'))
122
- );
123
- CREATE TABLE IF NOT EXISTS folders (
124
- id INTEGER PRIMARY KEY AUTOINCREMENT,
125
- parent_id INTEGER REFERENCES folders(id) ON DELETE CASCADE,
126
- owner_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
127
- name TEXT NOT NULL,
128
- created_at INTEGER DEFAULT (strftime('%s','now')),
129
- UNIQUE(parent_id, owner_id, name)
130
- );
131
- CREATE TABLE IF NOT EXISTS files (
132
- id TEXT PRIMARY KEY,
133
- folder_id INTEGER REFERENCES folders(id) ON DELETE SET NULL,
134
- owner_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
135
- name TEXT NOT NULL,
136
- mime TEXT NOT NULL,
137
- size INTEGER NOT NULL,
138
- chunk_count INTEGER DEFAULT 1,
139
- status TEXT DEFAULT 'pending',
140
- progress REAL DEFAULT 0,
141
- error_msg TEXT,
142
- encryption_key_hash TEXT NOT NULL,
143
- master_key_enc BLOB,
144
- created_at INTEGER DEFAULT (strftime('%s','now')),
145
- updated_at INTEGER DEFAULT (strftime('%s','now'))
146
- );
147
- CREATE TABLE IF NOT EXISTS file_chunks (
148
- id INTEGER PRIMARY KEY AUTOINCREMENT,
149
- file_id TEXT NOT NULL REFERENCES files(id) ON DELETE CASCADE,
150
- chunk_index INTEGER NOT NULL,
151
- imgbb_url TEXT,
152
- imgbb_delete_url TEXT,
153
- imgbb_id TEXT,
154
- size INTEGER NOT NULL,
155
- encrypted_len INTEGER NOT NULL,
156
- UNIQUE(file_id, chunk_index)
157
- );
158
- CREATE TABLE IF NOT EXISTS share_links (
159
- token TEXT PRIMARY KEY,
160
- file_id TEXT NOT NULL REFERENCES files(id) ON DELETE CASCADE,
161
- owner_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
162
- type TEXT NOT NULL,
163
- created_at INTEGER DEFAULT (strftime('%s','now')),
164
- expires_at INTEGER
165
- );
166
  CREATE INDEX IF NOT EXISTS idx_files_owner ON files(owner_id);
167
  CREATE INDEX IF NOT EXISTS idx_chunks_file ON file_chunks(file_id);
168
  CREATE INDEX IF NOT EXISTS idx_share_owner ON share_links(owner_id);
 
169
  `);
170
 
171
  // ==========================================
@@ -173,12 +92,8 @@ CREATE INDEX IF NOT EXISTS idx_share_owner ON share_links(owner_id);
173
  // ==========================================
174
  function runMigrations() {
175
  console.log('🔍 Vérification migrations BDD...');
176
-
177
  const usersCols = db.prepare("PRAGMA table_info(users)").all().map(c => c.name);
178
- if (!usersCols.includes('tos_accepted')) {
179
- console.log('🔧 Migration: ALTER TABLE users ADD COLUMN tos_accepted');
180
- db.exec(`ALTER TABLE users ADD COLUMN tos_accepted INTEGER DEFAULT 0;`);
181
- }
182
 
183
  const filesCols = db.prepare("PRAGMA table_info(files)").all().map(c => c.name);
184
  const filesDefs = [
@@ -191,41 +106,26 @@ function runMigrations() {
191
  { name: 'chunk_count', def: 'INTEGER DEFAULT 1' },
192
  { name: 'updated_at', def: 'INTEGER DEFAULT 0' },
193
  ];
194
-
195
  let updatedAdded = false;
196
  for (const col of filesDefs) {
197
  if (!filesCols.includes(col.name)) {
198
- console.log(`🔧 Migration: ALTER TABLE files ADD COLUMN ${col.name} ${col.def}`);
199
  db.exec(`ALTER TABLE files ADD COLUMN ${col.name} ${col.def};`);
200
  if (col.name === 'updated_at') updatedAdded = true;
201
  }
202
  }
203
-
204
- if (updatedAdded) {
205
- console.log('🔧 Migration: Initialisation updated_at sur les lignes existantes');
206
- db.exec(`UPDATE files SET updated_at = strftime('%s','now') WHERE updated_at = 0;`);
207
- }
208
 
209
  const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table'").all().map(t => t.name);
210
-
211
- if (!tables.includes('folders')) {
212
- console.log('🔧 Migration: CREATE TABLE folders');
213
- 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));`);
214
- }
215
- if (!tables.includes('share_links')) {
216
- console.log('🔧 Migration: CREATE TABLE share_links');
217
- 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);`);
218
- }
219
 
220
  const sessionsCols = tables.includes('sessions') ? db.prepare("PRAGMA table_info(sessions)").all().map(c => c.name) : [];
221
  if (tables.includes('sessions') && (!sessionsCols.includes('data') || !sessionsCols.includes('expires_at'))) {
222
- console.log('🔧 Migration: Reconstruction table sessions (colonnes manquantes)');
223
  db.exec(`DROP TABLE sessions; CREATE TABLE sessions (sid TEXT PRIMARY KEY, data TEXT NOT NULL, expires_at INTEGER NOT NULL); CREATE INDEX idx_sessions_expires ON sessions(expires_at);`);
224
  } else if (!tables.includes('sessions')) {
225
- console.log('🔧 Migration: CREATE TABLE sessions');
226
- db.exec(`CREATE TABLE sessions (sid TEXT PRIMARY KEY, data TEXT NOT NULL, expires_at INTEGER NOT NULL); CREATE INDEX idx_sessions_expires ON sessions(expires_at);`);
227
  }
228
-
229
  console.log('✅ Migrations terminées.');
230
  }
231
  runMigrations();
@@ -233,32 +133,13 @@ runMigrations();
233
  db.exec(`
234
  CREATE INDEX IF NOT EXISTS idx_files_folder ON files(folder_id);
235
  DROP TRIGGER IF EXISTS update_file_ts;
236
- CREATE TRIGGER update_file_ts AFTER UPDATE ON files
237
- BEGIN
238
- UPDATE files SET updated_at = strftime('%s','now') WHERE id = NEW.id;
239
- END;
240
  `);
241
 
242
  // --- SESSION STORE ---
243
  class SQLiteStore extends session.Store {
244
- get(sid, cb) {
245
- try {
246
- const r = db.prepare('SELECT data, expires_at FROM sessions WHERE sid=?').get(sid);
247
- if(!r) return cb(null, null);
248
- if(r.expires_at < Date.now()){
249
- db.prepare('DELETE FROM sessions WHERE sid=?').run(sid);
250
- return cb(null, null);
251
- }
252
- cb(null, JSON.parse(r.data));
253
- } catch(e){ cb(e); }
254
- }
255
- set(sid, sess, cb) {
256
- try {
257
- const exp = sess.cookie?.expires ? new Date(sess.cookie.expires).getTime() : Date.now() + 30*24*60*60*1000;
258
- db.prepare('INSERT OR REPLACE INTO sessions(sid,data,expires_at) VALUES(?,?,?)').run(sid, JSON.stringify(sess), exp);
259
- cb(null);
260
- } catch(e){ cb(e); }
261
- }
262
  destroy(sid, cb) { try { db.prepare('DELETE FROM sessions WHERE sid=?').run(sid); cb(null); } catch(e){ cb(e); } }
263
  clear(cb) { try { db.prepare('DELETE FROM sessions').run(); cb(null); } catch(e){ cb(e); } }
264
  length(cb) { try { const r = db.prepare('SELECT COUNT(*) as c FROM sessions').get(); cb(null, r.c); } catch(e){ cb(e); } }
@@ -266,13 +147,13 @@ class SQLiteStore extends session.Store {
266
  }
267
  const sessionStore = new SQLiteStore();
268
 
269
- // --- ADMIN INIT ---
270
  const adminRow = db.prepare('SELECT * FROM users WHERE username=?').get(ADMIN_USER);
271
- if (!adminRow) {
272
- const salt = randomBytes(16);
273
- const hash = await deriveKey(process.env.ADMIN_PASS || 'ChangeMeNow!', salt);
274
- db.prepare('INSERT INTO users(username,password_hash,salt,is_admin,tos_accepted) VALUES(?,?,?,1,1)').run(ADMIN_USER, hash, salt);
275
- console.log(`👑 Admin: ${ADMIN_USER} / Pass: ${process.env.ADMIN_PASS || 'ChangeMeNow!'}`);
276
  }
277
 
278
  // --- EXPRESS ---
@@ -280,210 +161,118 @@ const app = express();
280
  app.set('trust proxy', 1);
281
  app.use(express.json({ limit: '50mb' }));
282
  app.use(express.urlencoded({ extended: true, limit: '50mb' }));
283
- app.use(session({
284
- secret: SESSION_SECRET,
285
- store: sessionStore,
286
- resave: false,
287
- saveUninitialized: false,
288
- cookie: { secure: false, httpOnly: true, maxAge: 30*24*60*60*1000, sameSite: 'lax' }
289
- }));
290
 
291
  const requireAuth = (req, res, next) => { if(!req.session.userId) return res.status(401).json({error:'Non authentifié'}); next(); };
292
  const requireTos = (req, res, next) => { if(!req.session.tosAccepted) return res.status(403).json({error:'CGU non acceptées', needTos:true}); next(); };
293
 
294
- // --- CACHE LRU ---
295
- class ByteLRUCache extends LRUCache {
296
- constructor(){ super({maxSize:CACHE_MAX_BYTES, sizeCalculation:v=>v.size}); }
297
- async getOrLoad(key, loader){
298
- if(this.has(key)) return this.get(key).data;
299
- const data = await loader();
300
- this.set(key, {data, size: data.length, time: Date.now()});
301
- return data;
302
- }
303
- }
304
  const fileCache = new ByteLRUCache();
305
- setInterval(()=>{
306
- const m = process.memoryUsage();
307
- if(m.rss > 13*1024*1024*1024){
308
- console.warn(`⚠️ RAM ${(m.rss/1e9).toFixed(1)}GB, vidage cache`);
309
- fileCache.clear();
310
- if(global.gc) global.gc();
311
- }
312
- }, 30000);
313
-
314
- // --- NSFW WASM ---
315
  let nsfwModel = null;
316
- async function loadNsfwModel(){
317
- try{
318
- await tf.setBackend('wasm');
319
- tf.wasm.setWasmPaths('/app/tfjs_wasm/','tfjs-backend-wasm.wasm');
320
- await tf.ready();
321
- nsfwModel = await nsfwjs.load('/app/tfjs_wasm/',{type:'mobilenet_v2',size:224});
322
- console.log('🛡️ NSFW WASM chargé');
323
- }catch(e){ console.error('❌ NSFW load fail:',e.message); }
324
- }
325
  loadNsfwModel();
 
326
 
327
- async function checkNsfw(buf){
328
- if(!nsfwModel) return {safe:true};
329
- try{
330
- const t = tf.node.decodeImage(buf,3).resizeNearestNeighbor([224,224]).expandDims(0);
331
- const p = await nsfwModel.classify(t);
332
- t.dispose();
333
- const porn = p.find(x=>x.className==='Porn'||x.className==='Sexual Activity')?.probability||0;
334
- const hentai = p.find(x=>x.className==='Hentai')?.probability||0;
335
- if(porn>0.85||hentai>0.9) return {safe:false,reason:`NSFW (P:${porn.toFixed(2)} H:${hentai.toFixed(2)})`};
336
- return {safe:true};
337
- }catch(e){ return {safe:true}; }
338
- }
339
-
340
- // --- UPLOAD QUEUE ---
341
- class UploadQueue{
342
- constructor(c){ this.c=c; this.r=0; this.q=[]; }
343
- add(t){ return new Promise((res,rej)=>{ this.q.push({t,res,rej}); this.proc(); }); }
344
- async proc(){
345
- if(this.r>=this.c||!this.q.length) return;
346
- this.r++;
347
- const {t,res,rej} = this.q.shift();
348
- try{ res(await t()); }catch(e){ rej(e); }finally{ this.r--; this.proc(); }
349
- }
350
- }
351
  const uploadQueue = new UploadQueue(MAX_CONCURRENT_UPLOADS);
352
 
353
  // --- IMGBB ---
354
- async function imgbbUpload(buf){
355
- const f = new FormData();
356
- f.append('image',buf.toString('base64'));
357
- const {data} = await axios.post(
358
- `https://api.imgbb.com/1/upload?key=${IMGBB_API_KEY}`,
359
- f,
360
- {
361
- headers: f.getHeaders(),
362
- timeout: 120000,
363
- maxContentLength: Infinity,
364
- maxBodyLength: Infinity
365
- }
366
- );
367
- if(!data.success) throw new Error(data.error?.message||'ImgBB fail');
368
- return {url:data.data.url,deleteUrl:data.data.delete_url,id:data.data.id};
369
- }
370
  async function imgbbDelete(u){ try{await axios.get(u,{timeout:10000});}catch(_){} }
371
 
372
- // --- KEY MGMT ---
373
  const SERVER_MASTER_KEY = createHash('sha256').update(SESSION_SECRET+'|PixelDriveMaster').digest();
374
- function encryptMasterKey(k){
375
- const iv = randomBytes(12);
376
- const c = createCipheriv('aes-256-gcm',SERVER_MASTER_KEY,iv);
377
- return Buffer.concat([iv,c.update(k),c.final(),c.getAuthTag()]);
378
- }
379
- function decryptMasterKey(b){
380
- const iv = b.subarray(0,12), tag = b.subarray(-16), d = b.subarray(12,-16);
381
- const dc = createDecipheriv('aes-256-gcm',SERVER_MASTER_KEY,iv);
382
- dc.setAuthTag(tag);
383
- return Buffer.concat([dc.update(d),dc.final()]);
 
 
 
 
 
 
 
384
  }
385
- async function getMasterKey(uid,fid){
386
- const r = db.prepare('SELECT master_key_enc FROM files WHERE id=? AND owner_id=?').get(fid,uid);
387
- if(!r?.master_key_enc) throw new Error('Clé manquante');
388
- return decryptMasterKey(r.master_key_enc);
 
 
 
389
  }
390
 
391
  // --- THUMB ---
392
- async function genThumb(fid,mime,key){
393
- if(!mime.startsWith('image/')&&!mime.startsWith('video/')) return;
394
- try{
395
- const c = db.prepare('SELECT imgbb_url, size FROM file_chunks WHERE file_id=? AND chunk_index=0').get(fid);
396
- if(!c) return;
397
- const {data} = await axios.get(c.imgbb_url,{responseType:'arraybuffer',timeout:30000});
398
- const enc = await pngToBuffer(Buffer.from(data), c.size + OVERHEAD);
399
- const dec = decryptChunk(enc,key);
400
- let buf;
401
- if(mime.startsWith('image/')) buf = await sharp(dec).rotate().resize(256,256,{fit:'inside'}).jpeg({quality:80}).toBuffer();
402
- else buf = await sharp({create:{width:256,height:144,channels:3,background:'#1e1e2f'}}).jpeg().toBuffer();
403
- await sharp(buf).toFile(join(THUMB_DIR,`${fid}.jpg`));
404
- }catch(e){ console.warn(`Thumb ${fid}:`,e.message); }
405
  }
406
 
407
- // --- STREAM DOWNLOAD (OPTIMISÉ) ---
408
- async function streamFile(res,file,key,range){
409
- const chunks = db.prepare('SELECT chunk_index,imgbb_url,size FROM file_chunks WHERE file_id=? ORDER BY chunk_index').all(file.id);
410
- const total = file.size;
411
- let s = 0, e = total - 1;
412
-
413
- if(range){
414
- const p = range.replace('bytes=','').split('-');
415
- s = parseInt(p[0]);
416
- e = p[1] ? parseInt(p[1]) : total - 1;
417
- }
418
-
419
- if(s >= total || e >= total) return res.status(416).send('Range Not Satisfiable');
420
-
421
- res.set({
422
- 'Content-Type': file.mime,
423
- 'Content-Length': e - s + 1,
424
- 'Accept-Ranges': 'bytes',
425
- 'Content-Disposition': `inline; filename="${encodeURIComponent(file.name)}"`
426
- });
427
-
428
- if(range){
429
- res.status(206);
430
- res.set('Content-Range', `bytes ${s}-${e}/${total}`);
431
- }
432
-
433
  console.log(`📥 Stream ${file.name} (${((e-s+1)/1024/1024).toFixed(2)} MB, ${chunks.length} chunk(s))`);
434
-
435
- let cur = 0;
436
- for(const c of chunks){
437
- const cs = cur, ce = cur + c.size - 1;
438
- if(ce >= s && cs <= e){
439
- const off = Math.max(0, s - cs);
440
- const len = Math.min(c.size - off, e - Math.max(s, cs) + 1);
441
- try{
442
- const ck = `chunk:${file.id}:${c.chunk_index}`;
443
- let dec = fileCache.get(ck)?.data;
444
-
445
- if(!dec){
446
  const t0 = Date.now();
447
- const {data} = await axios.get(c.imgbb_url,{
448
- responseType:'arraybuffer',
449
- timeout:120000
450
- });
451
  const t1 = Date.now();
452
  console.log(` ⬇️ Chunk ${c.chunk_index}: ${(data.byteLength/1024/1024).toFixed(2)} MB en ${t1-t0}ms`);
453
-
454
- const enc = await pngToBuffer(Buffer.from(data), c.size + OVERHEAD);
455
  const t2 = Date.now();
456
  console.log(` 🖼️ PNG→Buffer: ${t2-t1}ms`);
457
-
458
- dec = decryptChunk(enc, key);
459
- const t3 = Date.now();
460
- console.log(` 🔓 Decrypt: ${t3-t2}ms`);
461
-
462
- fileCache.set(ck, {data: dec, size: dec.length});
463
- }
464
-
465
- const sl = dec.subarray(off, off + len);
466
- if(!res.write(sl)) await new Promise(r => res.once('drain', r));
467
- }catch(err){
468
- console.error(`❌ Stream chunk ${c.chunk_index}:`, err.message);
469
- return res.destroy(err);
470
- }
471
- }
472
- cur += c.size;
473
- if(cs > e) break;
474
- }
475
- res.end();
476
  }
477
 
478
  // --- SSE ---
479
- const sseClients = new Map();
480
- function broadcast(fid,d){
481
- const cs = sseClients.get(fid);
482
- if(cs){
483
- const p = `data: ${JSON.stringify(d)}\n\n`;
484
- for(const r of cs) r.write(p);
485
- }
486
- }
487
 
488
  // ==========================================
489
  // ROUTES
@@ -495,31 +284,80 @@ app.get('/api/me',(req,res)=>req.session.userId?res.json({id:req.session.userId,
495
  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`));
496
  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});});
497
 
498
- app.get('/api/folders',requireAuth,(req,res)=>{const p=req.query.parent_id?parseInt(req.query.parent_id):null;res.json(db.prepare('SELECT * FROM folders WHERE owner_id=? AND parent_id IS ?').all(req.session.userId,p));});
 
 
 
 
 
 
 
 
499
  app.post('/api/folders',requireAuth,requireTos,(req,res)=>{const{n,p}=req.body;if(!n)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'});}});
500
  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});});
501
 
502
- app.get('/api/files',requireAuth,(req,res)=>{const f=req.query.folder_id?parseInt(req.query.folder_id):null;res.json(db.prepare('SELECT * FROM files WHERE owner_id=? AND folder_id IS ? ORDER BY name COLLATE NOCASE').all(req.session.userId,f));});
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
503
 
504
- // ✅ NOUVELLE ROUTE : Liste des liens de partage de l'utilisateur
505
- app.get('/api/share-links',requireAuth,(req,res)=>{
506
- const links = db.prepare(`
507
- SELECT sl.*, f.name as file_name, f.mime, f.size
508
- FROM share_links sl
509
- LEFT JOIN files f ON sl.file_id = f.id
510
- WHERE sl.owner_id = ?
511
- ORDER BY sl.created_at DESC
512
- `).all(req.session.userId);
513
- res.json(links);
 
 
 
 
 
 
 
 
514
  });
515
 
516
- // NOUVELLE ROUTE : Supprimer un lien de partage
 
 
 
517
  app.delete('/api/share-links/:token',requireAuth,(req,res)=>{
518
- const result = db.prepare('DELETE FROM share_links WHERE token=? AND owner_id=?').run(req.params.token, req.session.userId);
519
- if (result.changes === 0) return res.status(404).json({error:'Lien introuvable'});
520
  res.json({ok:true});
521
  });
522
 
 
523
  const upload = multer({dest:TEMP_DIR,limits:{fileSize:5*1024*1024*1024}});
524
  app.post('/api/files/upload',requireAuth,requireTos,upload.single('file'),async(req,res)=>{
525
  if(!req.file)return res.status(400).json({error:'Fichier manquant'});
@@ -565,27 +403,29 @@ async function processUpload(fid,path,mk,cc,mime){
565
  for(const c of cs)if(c.imgbb_delete_url)await imgbbDelete(c.imgbb_delete_url);
566
  }
567
  }
568
-
569
  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);});}
570
 
571
- app.get('/api/files/:id/download',requireAuth,async(req,res)=>{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(f.status!=='ready')return res.status(409).json({error:'Pas prêt',status:f.status});const k=await getMasterKey(req.session.userId,f.id);await streamFile(res,f,k,req.headers.range);});
 
 
 
 
 
 
 
572
 
573
- // ✅ Génère TOUS les liens de partage d'un coup
574
  app.post('/api/files/:id/share',requireAuth,async(req,res)=>{
575
  const f=db.prepare('SELECT * FROM files WHERE id=? AND owner_id=?').get(req.params.id,req.session.userId);
576
  if(!f||f.status!=='ready') return res.status(404).json({error:'Fichier non disponible'});
577
- const previewToken=uuidv4();
578
- const downloadToken=uuidv4();
579
- const expires=Date.now()+7*24*3600000;
580
- db.prepare('INSERT INTO share_links(token,file_id,owner_id,type,expires_at) VALUES(?,?,?,?,?)').run(previewToken,f.id,req.session.userId,'embed',expires);
581
- db.prepare('INSERT INTO share_links(token,file_id,owner_id,type,expires_at) VALUES(?,?,?,?,?)').run(downloadToken,f.id,req.session.userId,'download',expires);
582
- res.json({preview:`/s/${previewToken}`,download:`/d/${downloadToken}`,expires:expires});
583
  });
584
 
585
  app.get('/api/files/:id/preview',requireAuth,async(req,res)=>{
586
  const f=db.prepare('SELECT * FROM files WHERE id=? AND owner_id=?').get(req.params.id,req.session.userId);
587
  if(!f||f.status!=='ready')return res.status(404).json({error:'Pas prêt'});
588
- if(!f.mime.startsWith('image/')&&!f.mime.startsWith('video/')&&!f.mime.startsWith('audio/')&&f.mime!=='application/pdf')return res.status(400).json({error:'Type non prévisualisable'});
589
  const t=uuidv4();
590
  db.prepare('INSERT INTO share_links(token,file_id,owner_id,type,expires_at) VALUES(?,?,?,?,?)').run(t,f.id,req.session.userId,'embed',Date.now()+3600000);
591
  res.json({url:`/s/${t}`});
@@ -613,32 +453,33 @@ app.get('/d/:token',async(req,res)=>{
613
 
614
  app.delete('/api/files/:id',requireAuth,async(req,res)=>{const f=db.prepare('SELECT * FROM files WHERE id=? AND owner_id=?').get(req.params.id,req.session.userId);if(!f)return res.status(404).json({error:'Introuvable'});const cs=db.prepare('SELECT imgbb_delete_url FROM file_chunks WHERE file_id=?').all(f.id);for(const c of cs)if(c.imgbb_delete_url)await imgbbDelete(c.imgbb_delete_url);db.prepare('DELETE FROM files WHERE id=?').run(f.id);fileCache.delete(`chunk:${f.id}:0`);try{unlinkSync(join(THUMB_DIR,`${f.id}.jpg`));}catch(_){}res.json({ok:true});});
615
 
616
- app.get('/api/files/:id/thumb',requireAuth,(req,res)=>{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>`);});
 
 
 
 
 
 
617
 
618
  app.get('/api/files/:id/progress',requireAuth,(req,res)=>{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));});
619
 
620
  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());});
621
-
622
  app.get('/health',(_,res)=>res.json({ok:true,cache:fileCache.size,mem:process.memoryUsage()}));
623
 
624
  app.use(express.static(__dirname));
625
- app.get('*',(_,res)=>res.sendFile(join(__dirname,'public.html')));
 
 
 
626
 
627
- // ==========================================
628
- // ERROR HANDLER (Anti-pollution logs)
629
- // ==========================================
630
  app.use((err, req, res, next) => {
631
- if (err.type === 'entity.parse.failed' || err instanceof SyntaxError) {
632
- return res.status(400).json({ error: 'Bad Request' });
633
- }
634
  console.error('❌ Server Error:', err.message);
635
  res.status(500).json({ error: 'Internal Server Error' });
636
  });
637
 
638
- // ==========================================
639
- // START
640
- // ==========================================
641
- const server = app.listen(PORT, '0.0.0.0', () => console.log(`🚀 PixelDrive v3.2 on http://0.0.0.0:${PORT} | Cache: 12GB LRU`));
642
  process.on('SIGTERM', () => { console.log('SIGTERM'); server.close(() => process.exit(0)); });
643
 
644
  function html(b){return `<!DOCTYPE html><html><head><meta charset="utf-8"/><meta name="viewport" content="width=device-width, initial-scale=1"/><title>PixelDrive</title><style>body{margin:0;font-family:system-ui,sans-serif;background:#0d0d12;color:#eee;display:flex;align-items:center;justify-content:center;height:100vh}</style></head><body>${b}</body></html>`;}
 
1
  // ==========================================
2
+ // PIXELDRIVE v3.3 - SERVER.JS (ESM)
3
  // ==========================================
4
  import express from 'express';
5
  import session from 'express-session';
 
32
  const TAG_LEN = 16;
33
  const OVERHEAD = IV_LEN + TAG_LEN;
34
 
 
35
  const IMGBB_API_KEY = process.env.IMGBB_API_KEY;
36
  const SESSION_SECRET = process.env.SESSION_SECRET;
37
  const ADMIN_USER = process.env.ADMIN_USER || 'admin';
 
41
  process.exit(1);
42
  }
43
 
 
44
  const db = new Database(DB_PATH);
45
  db.pragma('journal_mode = WAL');
46
  db.pragma('busy_timeout = 5000');
47
 
48
+ // --- CRYPTO ---
49
  const scryptAsync = promisify(scrypt);
50
  async function deriveKey(password, salt) { return scryptAsync(password, salt, KEY_LEN); }
51
+ 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]); }
52
+ 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()]); }
53
+
54
+ // --- PNG STEGO (OPTIMISÉ) ---
55
+ function bufferToPng(buffer) {
56
+ const byteLen = buffer.length;
57
+ const pixelsNeeded = Math.ceil(byteLen / 3);
58
+ const width = Math.ceil(Math.sqrt(pixelsNeeded));
59
+ const height = Math.ceil(pixelsNeeded / width);
60
+ const rgbBuffer = Buffer.alloc(width * height * 3);
61
+ buffer.copy(rgbBuffer);
62
+ return sharp(rgbBuffer, { raw: { width, height, channels: 3 } }).png({ compressionLevel: 9, palette: false }).withMetadata(false).toBuffer();
63
  }
64
+ async function pngToBuffer(pngBuffer, expectedLen) {
65
+ const { data, info } = await sharp(pngBuffer).raw().toBuffer({ resolveWithObject: true });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
66
  const channels = info.channels;
67
+ const rgb = Buffer.alloc(info.width * info.height * 3);
68
+ if (channels === 3) { data.copy(rgb, 0, 0, Math.min(data.length, rgb.length)); }
69
+ else if (channels === 4) { for (let i = 0, j = 0; i < data.length && j < rgb.length; i += 4, j += 3) { rgb[j] = data[i]; rgb[j+1] = data[i+1]; rgb[j+2] = data[i+2]; } }
70
+ else if (channels === 1) { for (let i = 0, j = 0; i < data.length && j < rgb.length; i++, j += 3) { rgb[j] = rgb[j+1] = rgb[j+2] = data[i]; } }
71
+ return rgb.subarray(0, expectedLen);
 
 
 
 
 
 
 
 
 
 
 
 
 
72
  }
73
 
74
  // ==========================================
75
+ // SCHÉMA
76
  // ==========================================
77
  db.exec(`
78
+ 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, created_at INTEGER DEFAULT (strftime('%s','now')));
79
+ 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));
80
+ 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')));
81
+ 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));
82
+ 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);
83
+ 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')));
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
84
  CREATE INDEX IF NOT EXISTS idx_files_owner ON files(owner_id);
85
  CREATE INDEX IF NOT EXISTS idx_chunks_file ON file_chunks(file_id);
86
  CREATE INDEX IF NOT EXISTS idx_share_owner ON share_links(owner_id);
87
+ CREATE INDEX IF NOT EXISTS idx_ushares_with ON user_shares(shared_with);
88
  `);
89
 
90
  // ==========================================
 
92
  // ==========================================
93
  function runMigrations() {
94
  console.log('🔍 Vérification migrations BDD...');
 
95
  const usersCols = db.prepare("PRAGMA table_info(users)").all().map(c => c.name);
96
+ if (!usersCols.includes('tos_accepted')) db.exec(`ALTER TABLE users ADD COLUMN tos_accepted INTEGER DEFAULT 0;`);
 
 
 
97
 
98
  const filesCols = db.prepare("PRAGMA table_info(files)").all().map(c => c.name);
99
  const filesDefs = [
 
106
  { name: 'chunk_count', def: 'INTEGER DEFAULT 1' },
107
  { name: 'updated_at', def: 'INTEGER DEFAULT 0' },
108
  ];
 
109
  let updatedAdded = false;
110
  for (const col of filesDefs) {
111
  if (!filesCols.includes(col.name)) {
 
112
  db.exec(`ALTER TABLE files ADD COLUMN ${col.name} ${col.def};`);
113
  if (col.name === 'updated_at') updatedAdded = true;
114
  }
115
  }
116
+ if (updatedAdded) db.exec(`UPDATE files SET updated_at = strftime('%s','now') WHERE updated_at = 0;`);
 
 
 
 
117
 
118
  const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table'").all().map(t => t.name);
119
+ 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));`);
120
+ 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);`);
121
+ 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);`);
 
 
 
 
 
 
122
 
123
  const sessionsCols = tables.includes('sessions') ? db.prepare("PRAGMA table_info(sessions)").all().map(c => c.name) : [];
124
  if (tables.includes('sessions') && (!sessionsCols.includes('data') || !sessionsCols.includes('expires_at'))) {
 
125
  db.exec(`DROP TABLE sessions; CREATE TABLE sessions (sid TEXT PRIMARY KEY, data TEXT NOT NULL, expires_at INTEGER NOT NULL); CREATE INDEX idx_sessions_expires ON sessions(expires_at);`);
126
  } else if (!tables.includes('sessions')) {
127
+ db.exec(`CREATE TABLE sessions (sid TEXT PRIMARY KEY, data TEXT NOT NULL, expires_at INTEGER NOT NULL); CREATE INDEX idx_sessions_expires ON sessions(expires_at);`);
 
128
  }
 
129
  console.log('✅ Migrations terminées.');
130
  }
131
  runMigrations();
 
133
  db.exec(`
134
  CREATE INDEX IF NOT EXISTS idx_files_folder ON files(folder_id);
135
  DROP TRIGGER IF EXISTS update_file_ts;
136
+ CREATE TRIGGER update_file_ts AFTER UPDATE ON files BEGIN UPDATE files SET updated_at = strftime('%s','now') WHERE id = NEW.id; END;
 
 
 
137
  `);
138
 
139
  // --- SESSION STORE ---
140
  class SQLiteStore extends session.Store {
141
+ 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); } }
142
+ 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); } }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
143
  destroy(sid, cb) { try { db.prepare('DELETE FROM sessions WHERE sid=?').run(sid); cb(null); } catch(e){ cb(e); } }
144
  clear(cb) { try { db.prepare('DELETE FROM sessions').run(); cb(null); } catch(e){ cb(e); } }
145
  length(cb) { try { const r = db.prepare('SELECT COUNT(*) as c FROM sessions').get(); cb(null, r.c); } catch(e){ cb(e); } }
 
147
  }
148
  const sessionStore = new SQLiteStore();
149
 
150
+ // --- ADMIN ---
151
  const adminRow = db.prepare('SELECT * FROM users WHERE username=?').get(ADMIN_USER);
152
+ if (!adminRow) {
153
+ const salt = randomBytes(16);
154
+ const hash = await deriveKey(process.env.ADMIN_PASS || 'ChangeMeNow!', salt);
155
+ db.prepare('INSERT INTO users(username,password_hash,salt,is_admin,tos_accepted) VALUES(?,?,?,1,1)').run(ADMIN_USER, hash, salt);
156
+ console.log(`👑 Admin: ${ADMIN_USER} / Pass: ${process.env.ADMIN_PASS || 'ChangeMeNow!'}`);
157
  }
158
 
159
  // --- EXPRESS ---
 
161
  app.set('trust proxy', 1);
162
  app.use(express.json({ limit: '50mb' }));
163
  app.use(express.urlencoded({ extended: true, limit: '50mb' }));
164
+ app.use(session({ secret: SESSION_SECRET, store: sessionStore, resave: false, saveUninitialized: false, cookie: { secure: false, httpOnly: true, maxAge: 30*24*60*60*1000, sameSite: 'lax' } }));
 
 
 
 
 
 
165
 
166
  const requireAuth = (req, res, next) => { if(!req.session.userId) return res.status(401).json({error:'Non authentifié'}); next(); };
167
  const requireTos = (req, res, next) => { if(!req.session.tosAccepted) return res.status(403).json({error:'CGU non acceptées', needTos:true}); next(); };
168
 
169
+ // --- CACHE ---
170
+ class ByteLRUCache extends LRUCache { constructor(){ super({maxSize:CACHE_MAX_BYTES, sizeCalculation:v=>v.size}); } }
 
 
 
 
 
 
 
 
171
  const fileCache = new ByteLRUCache();
172
+ setInterval(()=>{ const m = process.memoryUsage(); if(m.rss > 13*1024*1024*1024){ fileCache.clear(); if(global.gc) global.gc(); } }, 30000);
173
+
174
+ // --- NSFW ---
 
 
 
 
 
 
 
175
  let nsfwModel = null;
176
+ 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}); console.log('🛡️ NSFW WASM chargé'); }catch(e){ console.error('❌ NSFW load fail:',e.message); } }
 
 
 
 
 
 
 
 
177
  loadNsfwModel();
178
+ 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}; } }
179
 
180
+ // --- QUEUE ---
181
+ 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(); } } }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
182
  const uploadQueue = new UploadQueue(MAX_CONCURRENT_UPLOADS);
183
 
184
  // --- IMGBB ---
185
+ 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}; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
186
  async function imgbbDelete(u){ try{await axios.get(u,{timeout:10000});}catch(_){} }
187
 
188
+ // --- KEYS ---
189
  const SERVER_MASTER_KEY = createHash('sha256').update(SESSION_SECRET+'|PixelDriveMaster').digest();
190
+ 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()]); }
191
+ 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()]); }
192
+ 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); }
193
+
194
+ // ==========================================
195
+ // ✅ CONTRÔLE D'ACCÈS (partage entre utilisateurs)
196
+ // ==========================================
197
+ function canAccessFolder(userId, folderId){
198
+ let cur = db.prepare('SELECT * FROM folders WHERE id=?').get(folderId);
199
+ let depth = 0;
200
+ while (cur && depth < 50) {
201
+ if (cur.owner_id === userId) return true;
202
+ if (db.prepare('SELECT 1 FROM user_shares WHERE folder_id=? AND shared_with=?').get(cur.id, userId)) return true;
203
+ cur = cur.parent_id ? db.prepare('SELECT * FROM folders WHERE id=?').get(cur.parent_id) : null;
204
+ depth++;
205
+ }
206
+ return false;
207
  }
208
+ function canAccessFile(userId, fileId){
209
+ const f = db.prepare('SELECT * FROM files WHERE id=?').get(fileId);
210
+ if (!f) return null;
211
+ if (f.owner_id === userId) return f;
212
+ if (db.prepare('SELECT 1 FROM user_shares WHERE file_id=? AND shared_with=?').get(fileId, userId)) return f;
213
+ if (f.folder_id && canAccessFolder(userId, f.folder_id)) return f;
214
+ return null;
215
  }
216
 
217
  // --- THUMB ---
218
+ async function genThumb(fid,mime,key){
219
+ if(!mime.startsWith('image/')&&!mime.startsWith('video/')) return;
220
+ try{
221
+ const c = db.prepare('SELECT imgbb_url, size FROM file_chunks WHERE file_id=? AND chunk_index=0').get(fid);
222
+ if(!c) return;
223
+ const {data} = await axios.get(c.imgbb_url,{responseType:'arraybuffer',timeout:30000});
224
+ const enc = await pngToBuffer(Buffer.from(data), c.size + OVERHEAD);
225
+ const dec = decryptChunk(enc,key);
226
+ let buf;
227
+ if(mime.startsWith('image/')) buf = await sharp(dec).rotate().resize(256,256,{fit:'inside'}).jpeg({quality:80}).toBuffer();
228
+ else buf = await sharp({create:{width:256,height:144,channels:3,background:'#1e1e2f'}}).jpeg().toBuffer();
229
+ await sharp(buf).toFile(join(THUMB_DIR,`${fid}.jpg`));
230
+ }catch(e){ console.warn(`Thumb ${fid}:`,e.message); }
231
  }
232
 
233
+ // --- STREAM ---
234
+ async function streamFile(res,file,key,range){
235
+ const chunks = db.prepare('SELECT chunk_index,imgbb_url,size FROM file_chunks WHERE file_id=? ORDER BY chunk_index').all(file.id);
236
+ const total = file.size;
237
+ let s = 0, e = total - 1;
238
+ if(range){ const p = range.replace('bytes=','').split('-'); s = parseInt(p[0]); e = p[1] ? parseInt(p[1]) : total - 1; }
239
+ if(s >= total || e >= total) return res.status(416).send('Range Not Satisfiable');
240
+ res.set({'Content-Type':file.mime,'Content-Length':e-s+1,'Accept-Ranges':'bytes','Content-Disposition':`inline; filename="${encodeURIComponent(file.name)}"`});
241
+ if(range){ res.status(206); res.set('Content-Range', `bytes ${s}-${e}/${total}`); }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
242
  console.log(`📥 Stream ${file.name} (${((e-s+1)/1024/1024).toFixed(2)} MB, ${chunks.length} chunk(s))`);
243
+ let cur = 0;
244
+ for(const c of chunks){
245
+ const cs = cur, ce = cur + c.size - 1;
246
+ if(ce >= s && cs <= e){
247
+ const off = Math.max(0, s - cs), len = Math.min(c.size - off, e - Math.max(s, cs) + 1);
248
+ try{
249
+ const ck = `chunk:${file.id}:${c.chunk_index}`;
250
+ let dec = fileCache.get(ck)?.data;
251
+ if(!dec){
 
 
 
252
  const t0 = Date.now();
253
+ const {data} = await axios.get(c.imgbb_url,{responseType:'arraybuffer',timeout:120000});
 
 
 
254
  const t1 = Date.now();
255
  console.log(` ⬇️ Chunk ${c.chunk_index}: ${(data.byteLength/1024/1024).toFixed(2)} MB en ${t1-t0}ms`);
256
+ const enc = await pngToBuffer(Buffer.from(data), c.size + OVERHEAD);
 
257
  const t2 = Date.now();
258
  console.log(` 🖼️ PNG→Buffer: ${t2-t1}ms`);
259
+ dec = decryptChunk(enc, key);
260
+ console.log(` 🔓 Decrypt: ${Date.now()-t2}ms`);
261
+ fileCache.set(ck, {data: dec, size: dec.length});
262
+ }
263
+ const sl = dec.subarray(off, off + len);
264
+ if(!res.write(sl)) await new Promise(r => res.once('drain', r));
265
+ }catch(err){ console.error(`❌ Stream chunk ${c.chunk_index}:`, err.message); return res.destroy(err); }
266
+ }
267
+ cur += c.size;
268
+ if(cs > e) break;
269
+ }
270
+ res.end();
 
 
 
 
 
 
 
271
  }
272
 
273
  // --- SSE ---
274
+ const sseClients = new Map();
275
+ 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); } }
 
 
 
 
 
 
276
 
277
  // ==========================================
278
  // ROUTES
 
284
  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`));
285
  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});});
286
 
287
+ // Folders (avec accès partagé)
288
+ app.get('/api/folders',requireAuth,(req,res)=>{
289
+ const p = req.query.parent_id ? parseInt(req.query.parent_id) : null;
290
+ if (p !== null) {
291
+ if (!canAccessFolder(req.session.userId, p)) return res.status(403).json({error:'Accès refusé'});
292
+ return res.json(db.prepare('SELECT * FROM folders WHERE parent_id=? ORDER BY name COLLATE NOCASE').all(p));
293
+ }
294
+ res.json(db.prepare('SELECT * FROM folders WHERE owner_id=? AND parent_id IS NULL ORDER BY name COLLATE NOCASE').all(req.session.userId));
295
+ });
296
  app.post('/api/folders',requireAuth,requireTos,(req,res)=>{const{n,p}=req.body;if(!n)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'});}});
297
  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});});
298
 
299
+ // Files (avec accès partagé)
300
+ app.get('/api/files',requireAuth,(req,res)=>{
301
+ const f = req.query.folder_id ? parseInt(req.query.folder_id) : null;
302
+ if (f !== null) {
303
+ if (!canAccessFolder(req.session.userId, f)) return res.status(403).json({error:'Accès refusé'});
304
+ return res.json(db.prepare('SELECT * FROM files WHERE folder_id=? ORDER BY name COLLATE NOCASE').all(f));
305
+ }
306
+ res.json(db.prepare('SELECT * FROM files WHERE owner_id=? AND folder_id IS NULL ORDER BY name COLLATE NOCASE').all(req.session.userId));
307
+ });
308
+
309
+ // ✅ PARTAGE ENTRE UTILISATEURS
310
+ app.post('/api/share-user',requireAuth,requireTos,(req,res)=>{
311
+ const { type, id, username } = req.body;
312
+ if (!type || !id || !username) return res.status(400).json({error:'Champs manquants'});
313
+ const target = db.prepare('SELECT id FROM users WHERE username=?').get(username.trim());
314
+ if (!target) return res.status(404).json({error:'Utilisateur introuvable'});
315
+ if (target.id === req.session.userId) return res.status(400).json({error:'Impossible de partager avec vous-même'});
316
+ if (type === 'file') {
317
+ const f = db.prepare('SELECT id FROM files WHERE id=? AND owner_id=?').get(id, req.session.userId);
318
+ if (!f) return res.status(404).json({error:'Fichier introuvable'});
319
+ if (db.prepare('SELECT 1 FROM user_shares WHERE file_id=? AND shared_with=?').get(id, target.id)) return res.status(409).json({error:'Déjà partagé'});
320
+ db.prepare('INSERT INTO user_shares(file_id,owner_id,shared_with) VALUES(?,?,?)').run(id, req.session.userId, target.id);
321
+ } else {
322
+ const fo = db.prepare('SELECT id FROM folders WHERE id=? AND owner_id=?').get(id, req.session.userId);
323
+ if (!fo) return res.status(404).json({error:'Dossier introuvable'});
324
+ if (db.prepare('SELECT 1 FROM user_shares WHERE folder_id=? AND shared_with=?').get(id, target.id)) return res.status(409).json({error:'Déjà partagé'});
325
+ db.prepare('INSERT INTO user_shares(folder_id,owner_id,shared_with) VALUES(?,?,?)').run(id, req.session.userId, target.id);
326
+ }
327
+ res.json({ok:true});
328
+ });
329
 
330
+ app.get('/api/shared-with-me',requireAuth,(req,res)=>{
331
+ res.json(db.prepare(`
332
+ SELECT us.id as share_id, us.created_at as shared_at, u.username as owner_name,
333
+ us.file_id, us.folder_id,
334
+ f.name as file_name, f.mime, f.size, f.status,
335
+ fo.name as folder_name
336
+ FROM user_shares us
337
+ JOIN users u ON u.id = us.owner_id
338
+ LEFT JOIN files f ON f.id = us.file_id
339
+ LEFT JOIN folders fo ON fo.id = us.folder_id
340
+ WHERE us.shared_with = ?
341
+ ORDER BY us.created_at DESC
342
+ `).all(req.session.userId));
343
+ });
344
+
345
+ app.delete('/api/shared-with-me/:id',requireAuth,(req,res)=>{
346
+ db.prepare('DELETE FROM user_shares WHERE id=? AND shared_with=?').run(req.params.id, req.session.userId);
347
+ res.json({ok:true});
348
  });
349
 
350
+ // Liens publics
351
+ app.get('/api/share-links',requireAuth,(req,res)=>{
352
+ 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));
353
+ });
354
  app.delete('/api/share-links/:token',requireAuth,(req,res)=>{
355
+ const r = db.prepare('DELETE FROM share_links WHERE token=? AND owner_id=?').run(req.params.token, req.session.userId);
356
+ if (r.changes === 0) return res.status(404).json({error:'Lien introuvable'});
357
  res.json({ok:true});
358
  });
359
 
360
+ // Upload
361
  const upload = multer({dest:TEMP_DIR,limits:{fileSize:5*1024*1024*1024}});
362
  app.post('/api/files/upload',requireAuth,requireTos,upload.single('file'),async(req,res)=>{
363
  if(!req.file)return res.status(400).json({error:'Fichier manquant'});
 
403
  for(const c of cs)if(c.imgbb_delete_url)await imgbbDelete(c.imgbb_delete_url);
404
  }
405
  }
 
406
  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);});}
407
 
408
+ // Download (accès partagé autorisé)
409
+ app.get('/api/files/:id/download',requireAuth,async(req,res)=>{
410
+ const f = canAccessFile(req.session.userId, req.params.id);
411
+ if(!f)return res.status(404).json({error:'Introuvable'});
412
+ if(f.status!=='ready')return res.status(409).json({error:'Pas prêt',status:f.status});
413
+ const k = await getMasterKey(f.id);
414
+ await streamFile(res,f,k,req.headers.range);
415
+ });
416
 
 
417
  app.post('/api/files/:id/share',requireAuth,async(req,res)=>{
418
  const f=db.prepare('SELECT * FROM files WHERE id=? AND owner_id=?').get(req.params.id,req.session.userId);
419
  if(!f||f.status!=='ready') return res.status(404).json({error:'Fichier non disponible'});
420
+ const pt=uuidv4(), dt2=uuidv4(), expires=Date.now()+7*24*3600000;
421
+ db.prepare('INSERT INTO share_links(token,file_id,owner_id,type,expires_at) VALUES(?,?,?,?,?)').run(pt,f.id,req.session.userId,'embed',expires);
422
+ db.prepare('INSERT INTO share_links(token,file_id,owner_id,type,expires_at) VALUES(?,?,?,?,?)').run(dt2,f.id,req.session.userId,'download',expires);
423
+ res.json({preview:`/s/${pt}`,download:`/d/${dt2}`,expires});
 
 
424
  });
425
 
426
  app.get('/api/files/:id/preview',requireAuth,async(req,res)=>{
427
  const f=db.prepare('SELECT * FROM files WHERE id=? AND owner_id=?').get(req.params.id,req.session.userId);
428
  if(!f||f.status!=='ready')return res.status(404).json({error:'Pas prêt'});
 
429
  const t=uuidv4();
430
  db.prepare('INSERT INTO share_links(token,file_id,owner_id,type,expires_at) VALUES(?,?,?,?,?)').run(t,f.id,req.session.userId,'embed',Date.now()+3600000);
431
  res.json({url:`/s/${t}`});
 
453
 
454
  app.delete('/api/files/:id',requireAuth,async(req,res)=>{const f=db.prepare('SELECT * FROM files WHERE id=? AND owner_id=?').get(req.params.id,req.session.userId);if(!f)return res.status(404).json({error:'Introuvable'});const cs=db.prepare('SELECT imgbb_delete_url FROM file_chunks WHERE file_id=?').all(f.id);for(const c of cs)if(c.imgbb_delete_url)await imgbbDelete(c.imgbb_delete_url);db.prepare('DELETE FROM files WHERE id=?').run(f.id);fileCache.delete(`chunk:${f.id}:0`);try{unlinkSync(join(THUMB_DIR,`${f.id}.jpg`));}catch(_){}res.json({ok:true});});
455
 
456
+ app.get('/api/files/:id/thumb',requireAuth,(req,res)=>{
457
+ if(!canAccessFile(req.session.userId, req.params.id)) return res.status(404).json({error:'Introuvable'});
458
+ const p=join(THUMB_DIR,`${req.params.id}.jpg`);
459
+ if(existsSync(p))return res.sendFile(p);
460
+ res.set('Content-Type','image/svg+xml');
461
+ 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>`);
462
+ });
463
 
464
  app.get('/api/files/:id/progress',requireAuth,(req,res)=>{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));});
465
 
466
  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());});
 
467
  app.get('/health',(_,res)=>res.json({ok:true,cache:fileCache.size,mem:process.memoryUsage()}));
468
 
469
  app.use(express.static(__dirname));
470
+ app.get('*', (req, res) => {
471
+ if (/\.[a-zA-Z0-9]+$/.test(req.path)) return res.status(404).send('Not Found');
472
+ res.sendFile(join(__dirname, 'public.html'));
473
+ });
474
 
475
+ // ERROR HANDLER
 
 
476
  app.use((err, req, res, next) => {
477
+ if (err.type === 'entity.parse.failed' || err instanceof SyntaxError) return res.status(400).json({ error: 'Bad Request' });
 
 
478
  console.error('❌ Server Error:', err.message);
479
  res.status(500).json({ error: 'Internal Server Error' });
480
  });
481
 
482
+ const server = app.listen(PORT, '0.0.0.0', () => console.log(`🚀 PixelDrive v3.3 on http://0.0.0.0:${PORT} | Cache: 12GB LRU`));
 
 
 
483
  process.on('SIGTERM', () => { console.log('SIGTERM'); server.close(() => process.exit(0)); });
484
 
485
  function html(b){return `<!DOCTYPE html><html><head><meta charset="utf-8"/><meta name="viewport" content="width=device-width, initial-scale=1"/><title>PixelDrive</title><style>body{margin:0;font-family:system-ui,sans-serif;background:#0d0d12;color:#eee;display:flex;align-items:center;justify-content:center;height:100vh}</style></head><body>${b}</body></html>`;}