NathMen12 commited on
Commit
3490909
·
verified ·
1 Parent(s): 56ac6d9

Update server.js

Browse files
Files changed (1) hide show
  1. server.js +322 -117
server.js CHANGED
@@ -32,6 +32,7 @@ const IV_LEN = 12;
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,51 +42,113 @@ if (!IMGBB_API_KEY || !SESSION_SECRET) {
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
  const scryptAsync = promisify(scrypt);
49
  async function deriveKey(password, salt) { return scryptAsync(password, salt, KEY_LEN); }
50
  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]); }
51
  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()]); }
52
 
53
- function bufferToPng(buffer) {
54
- const byteLen = buffer.length;
55
- const pixelsNeeded = Math.ceil(byteLen / 3);
56
- const width = Math.ceil(Math.sqrt(pixelsNeeded));
57
- const height = Math.ceil(pixelsNeeded / width);
58
- const rgbBuffer = Buffer.alloc(width * height * 3);
59
- buffer.copy(rgbBuffer);
60
- return sharp(rgbBuffer, { raw: { width, height, channels: 3 } }).png({ compressionLevel: 9, palette: false }).withMetadata(false).toBuffer();
 
 
61
  }
62
- async function pngToBuffer(pngBuffer, expectedLen) {
63
- const { data, info } = await sharp(pngBuffer).ensureAlpha().raw().toBuffer({ resolveWithObject: true });
64
- const rgb = Buffer.alloc(info.width * info.height * 3);
65
- let j = 0;
66
- for (let i = 0; i < data.length; i += 4) {
67
- if (j + 2 < rgb.length) { rgb[j++] = data[i]; rgb[j++] = data[i+1]; rgb[j++] = data[i+2]; }
68
- }
69
- return rgb.subarray(0, expectedLen);
 
70
  }
71
 
 
 
 
72
  db.exec(`
73
- 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')));
74
- 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));
75
- 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')));
76
- 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));
77
- 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);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
78
  CREATE INDEX IF NOT EXISTS idx_files_owner ON files(owner_id);
79
  CREATE INDEX IF NOT EXISTS idx_chunks_file ON file_chunks(file_id);
80
  `);
81
 
 
 
 
82
  function runMigrations() {
83
  console.log('🔍 Vérification migrations BDD...');
 
 
84
  const usersCols = db.prepare("PRAGMA table_info(users)").all().map(c => c.name);
85
  if (!usersCols.includes('tos_accepted')) {
86
  console.log('🔧 Migration: ALTER TABLE users ADD COLUMN tos_accepted');
87
  db.exec(`ALTER TABLE users ADD COLUMN tos_accepted INTEGER DEFAULT 0;`);
88
  }
 
 
89
  const filesCols = db.prepare("PRAGMA table_info(files)").all().map(c => c.name);
90
  const filesDefs = [
91
  { name: 'folder_id', def: 'INTEGER REFERENCES folders(id) ON DELETE SET NULL' },
@@ -95,8 +158,9 @@ function runMigrations() {
95
  { name: 'progress', def: 'REAL DEFAULT 0' },
96
  { name: 'error_msg', def: 'TEXT' },
97
  { name: 'chunk_count', def: 'INTEGER DEFAULT 1' },
98
- { name: 'updated_at', def: 'INTEGER DEFAULT 0' },
99
  ];
 
100
  let updatedAdded = false;
101
  for (const col of filesDefs) {
102
  if (!filesCols.includes(col.name)) {
@@ -106,190 +170,321 @@ function runMigrations() {
106
  }
107
  }
108
  if (updatedAdded) {
 
109
  db.exec(`UPDATE files SET updated_at = strftime('%s','now') WHERE updated_at = 0;`);
110
  }
 
 
111
  const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table'").all().map(t => t.name);
112
- 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));`);
113
- 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);`);
 
 
 
 
 
 
 
 
 
114
  const sessionsCols = tables.includes('sessions') ? db.prepare("PRAGMA table_info(sessions)").all().map(c => c.name) : [];
115
  if (tables.includes('sessions') && (!sessionsCols.includes('data') || !sessionsCols.includes('expires_at'))) {
 
116
  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);`);
117
  } else if (!tables.includes('sessions')) {
118
- 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);`);
 
119
  }
 
120
  console.log('✅ Migrations terminées.');
121
  }
122
  runMigrations();
123
 
 
 
 
124
  db.exec(`
125
  CREATE INDEX IF NOT EXISTS idx_files_folder ON files(folder_id);
126
  DROP TRIGGER IF EXISTS update_file_ts;
127
- CREATE TRIGGER update_file_ts AFTER UPDATE ON files BEGIN UPDATE files SET updated_at = strftime('%s','now') WHERE id = NEW.id; END;
 
 
 
128
  `);
129
 
 
 
130
  class SQLiteStore extends session.Store {
131
- 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); } }
132
- 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); } }
133
- destroy(sid, cb) { try { db.prepare('DELETE FROM sessions WHERE sid=?').run(sid); cb(null); } catch(e){ cb(e); } }
134
- clear(cb) { try { db.prepare('DELETE FROM sessions').run(); cb(null); } catch(e){ cb(e); } }
135
- length(cb) { try { const r = db.prepare('SELECT COUNT(*) as c FROM sessions').get(); cb(null, r.c); } catch(e){ cb(e); } }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
136
  touch(sid, sess, cb) { this.set(sid, sess, cb); }
137
  }
138
  const sessionStore = new SQLiteStore();
139
 
 
140
  const adminRow = db.prepare('SELECT * FROM users WHERE username=?').get(ADMIN_USER);
141
- if (!adminRow) {
142
- const salt = randomBytes(16);
143
- const hash = await deriveKey(process.env.ADMIN_PASS || 'ChangeMeNow!', salt);
144
- db.prepare('INSERT INTO users(username,password_hash,salt,is_admin,tos_accepted) VALUES(?,?,?,1,1)').run(ADMIN_USER, hash, salt);
145
- console.log(`👑 Admin: ${ADMIN_USER} / Pass: ${process.env.ADMIN_PASS || 'ChangeMeNow!'}`);
146
  }
147
 
 
148
  const app = express();
149
  app.set('trust proxy', 1);
150
  app.use(express.json({ limit: '50mb' }));
151
  app.use(express.urlencoded({ extended: true, limit: '50mb' }));
152
- 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' } }));
153
-
154
- const requireAuth = (req, res, next) => { if(!req.session.userId) return res.status(401).json({error:'Non authentifié'}); next(); };
155
- const requireTos = (req, res, next) => { if(!req.session.tosAccepted) return res.status(403).json({error:'CGU non acceptées', needTos:true}); next(); };
156
-
157
- class ByteLRUCache extends LRUCache { constructor(){ super({maxSize:CACHE_MAX_BYTES, sizeCalculation:v=>v.size}); } }
 
 
 
 
 
 
 
 
 
 
158
  const fileCache = new ByteLRUCache();
159
- setInterval(()=>{ const m=process.memoryUsage(); if(m.rss>13*1024*1024*1024){ fileCache.clear(); if(global.gc) global.gc(); } }, 30000);
160
-
161
- let nsfwModel=null;
162
- 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); } }
 
 
 
 
 
 
 
 
 
163
  loadNsfwModel();
164
 
165
- 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(); } } }
166
- const uploadQueue=new UploadQueue(MAX_CONCURRENT_UPLOADS);
 
 
 
 
 
 
 
 
 
 
167
 
168
- 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}; }
 
 
 
 
 
 
 
 
 
 
 
169
  async function imgbbDelete(u){ try{await axios.get(u,{timeout:10000});}catch(_){} }
170
 
 
171
  const SERVER_MASTER_KEY=createHash('sha256').update(SESSION_SECRET+'|PixelDriveMaster').digest();
172
  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()]); }
173
  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()]); }
174
  async function getMasterKey(uid,fid){ const r=db.prepare('SELECT master_key_enc FROM files WHERE id=? AND owner_id=?').get(fid,uid); if(!r?.master_key_enc) throw new Error('Clé manquante'); return decryptMasterKey(r.master_key_enc); }
175
 
176
- async function genThumb(fid,mime,key){
177
- if(!mime.startsWith('image/')&&!mime.startsWith('video/')) return;
178
- try{
179
- const c=db.prepare('SELECT imgbb_url, size FROM file_chunks WHERE file_id=? AND chunk_index=0').get(fid);
180
- if(!c) return;
181
- const {data}=await axios.get(c.imgbb_url,{responseType:'arraybuffer',timeout:30000});
182
- const enc=await pngToBuffer(Buffer.from(data), c.size + OVERHEAD);
183
- const dec=decryptChunk(enc,key);
184
- let buf;
185
- if(mime.startsWith('image/')) buf=await sharp(dec).rotate().resize(256,256,{fit:'inside'}).jpeg({quality:80}).toBuffer();
186
- else buf=await sharp({create:{width:256,height:144,channels:3,background:'#1e1e2f'}}).jpeg().toBuffer();
187
- await sharp(buf).toFile(join(THUMB_DIR,`${fid}.jpg`));
188
- }catch(e){ console.warn(`Thumb ${fid}:`,e.message); }
 
 
189
  }
190
 
191
- async function streamFile(res,file,key,range){
192
- const chunks=db.prepare('SELECT chunk_index,imgbb_url,size FROM file_chunks WHERE file_id=? ORDER BY chunk_index').all(file.id);
193
- const total=file.size;
194
- let s=0,e=total-1;
195
- if(range){ const p=range.replace('bytes=','').split('-'); s=parseInt(p[0]); e=p[1]?parseInt(p[1]):total-1; }
196
- if(s >=total||e >=total) return res.status(416).send('Range Not Satisfiable');
197
- res.set({'Content-Type':file.mime,'Content-Length':e-s+1,'Accept-Ranges':'bytes','Content-Disposition': `inline; filename="${encodeURIComponent(file.name)}"` });
198
- if(range){res.status(206);res.set('Content-Range', `bytes ${s}-${e}/${total}`);}
199
- let cur=0;
200
- for(const c of chunks){
201
- const cs=cur, ce=cur+c.size-1;
202
- if(ce >=s && cs <=e){
203
- const off=Math.max(0,s-cs), len=Math.min(c.size-off,e-Math.max(s,cs)+1);
204
- try{
205
- const ck=`chunk:${file.id}:${c.chunk_index}`;
206
- let dec=fileCache.get(ck)?.data;
207
- if(!dec){
208
- const {data}=await axios.get(c.imgbb_url,{responseType:'arraybuffer',timeout:60000});
209
- const enc=await pngToBuffer(Buffer.from(data), c.size + OVERHEAD);
210
- dec=decryptChunk(enc,key);
211
- fileCache.set(ck,{data:dec,size:dec.length});
212
- }
213
- const sl=dec.subarray(off,off+len);
214
- if(!res.write(sl)) await new Promise(r=>res.once('drain',r));
215
- }catch(err){ console.error(`Stream chunk ${c.chunk_index}`,err); return res.destroy(err); }
216
- }
217
- cur+=c.size;
218
- if(cs >e) break;
219
- }
220
- res.end();
 
 
 
 
 
 
221
  }
222
 
223
- const sseClients=new Map();
 
224
  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); } }
225
 
226
  // ==========================================
227
  // ROUTES
228
  // ==========================================
 
229
  app.post('/api/register',async(req,res)=>{const{u,p,t}=req.body;if(!u||!p||!t)return res.status(400).json({error:'Champs manquants'});if(db.prepare('SELECT 1 FROM users WHERE username=?').get(u))return res.status(409).json({error:'Existe'});const s=randomBytes(16);const h=await deriveKey(p,s);db.prepare('INSERT INTO users(username,password_hash,salt,tos_accepted) VALUES(?,?,?,1)').run(u,h,s);res.json({ok:true});});
230
  app.post('/api/login',async(req,res)=>{const{u,p}=req.body;const usr=db.prepare('SELECT * FROM users WHERE username=?').get(u);if(!usr)return res.status(401).json({error:'Invalide'});const h=await deriveKey(p,usr.salt);if(!h.equals(usr.password_hash))return res.status(401).json({error:'Invalide'});req.session.userId=usr.id;req.session.isAdmin=!!usr.is_admin;req.session.tosAccepted=!!usr.tos_accepted;req.session.username=usr.username;res.json({ok:true,user:{id:usr.id,username:usr.username,isAdmin:!!usr.is_admin,tosAccepted:!!usr.tos_accepted}});});
231
- app.post('/api/logout', (req,res)=>req.session.destroy(()=>res.json({ok:true})));
232
  app.get('/api/me',(req,res)=>req.session.userId?res.json({id:req.session.userId,username:req.session.username,isAdmin:req.session.isAdmin,tosAccepted:req.session.tosAccepted}):res.status(401).json({error:'Non connecté'}));
233
  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`));
234
  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});});
235
 
 
236
  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));});
237
  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'});}});
238
  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});});
239
 
 
240
  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));});
241
 
 
242
  const upload=multer({dest:TEMP_DIR,limits:{fileSize:5*1024*1024*1024}});
243
- app.post('/api/files/upload',requireAuth,requireTos,upload.single('file'),async(req,res)=>{if(!req.file)return res.status(400).json({error:'Fichier manquant'});const fid=uuidv4(),fpath=req.file.path,fsize=req.file.size,cc=Math.ceil(fsize/CHUNK_SIZE),mk=randomBytes(KEY_LEN),kh=createHash('sha256').update(mk).digest('hex'),mke=encryptMasterKey(mk),fidParam=req.body.folder_id?parseInt(req.body.folder_id):null;db.prepare(`INSERT INTO files(id,folder_id,owner_id,name,mime,size,chunk_count,status,encryption_key_hash,master_key_enc) VALUES(?,?,?,?,?,?,?,'pending',?,?)`).run(fid,fidParam,req.session.userId,req.file.originalname,req.file.mimetype,fsize,cc,kh,mke);const stmt=db.prepare('INSERT INTO file_chunks(file_id,chunk_index,size,encrypted_len) VALUES(?,?,?,?)');const tx=db.transaction((arr)=>{for(const c of arr)stmt.run(...c);});const cd=[];for(let i=0;i<cc;i++){const sz=Math.min(CHUNK_SIZE,fsize-i*CHUNK_SIZE);cd.push([fid,i,sz,sz+OVERHEAD]);}tx(cd);res.json({fileId:fid,status:'pending',chunkCount:cc});uploadQueue.add(()=>processUpload(fid,fpath,mk,cc,req.file.mimetype));});
 
 
 
 
 
 
 
 
 
 
 
244
 
245
- async function processUpload(fid,path,mk,cc,mime){try{db.prepare("UPDATE files SET status='uploading' WHERE id=?").run(fid);for(let i=0;i<cc;i++){const off=i*CHUNK_SIZE;const ci=db.prepare('SELECT size,encrypted_len FROM file_chunks WHERE file_id=? AND chunk_index=?').get(fid,i);const raw=await readChunk(path,off,ci.size);const enc=encryptChunk(raw,mk);if(enc.length>ci.encrypted_len)throw new Error(`Overflow chunk ${i}`);const png=await bufferToPng(enc);const{url,deleteUrl,id}=await imgbbUpload(png);db.prepare('UPDATE file_chunks SET imgbb_url=?,imgbb_delete_url=?,imgbb_id=? WHERE file_id=? AND chunk_index=?').run(url,deleteUrl,id,fid,i);const prog=((i+1)/cc)*50;db.prepare('UPDATE files SET progress=?,status=? WHERE id=?').run(prog,'uploading',fid);broadcast(fid,{type:'progress',progress:prog,status:'uploading'});if(i<cc-1)await new Promise(r=>setTimeout(r,3200));}unlinkSync(path);db.prepare("UPDATE files SET status='processing',progress=50 WHERE id=?").run(fid);broadcast(fid,{type:'progress',progress:50,status:'processing'});await genThumb(fid,mime,mk);db.prepare("UPDATE files SET status='ready',progress=100 WHERE id=?").run(fid);broadcast(fid,{type:'progress',progress:100,status:'ready',type:'done'});}catch(e){console.error(`Upload ${fid}`,e);db.prepare("UPDATE files SET status='error',error_msg=? WHERE id=?").run(e.message,fid);broadcast(fid,{type:'error',error:e.message});const cs=db.prepare('SELECT imgbb_delete_url FROM file_chunks WHERE file_id=?').all(fid);for(const c of cs)if(c.imgbb_delete_url)await imgbbDelete(c.imgbb_delete_url);}}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
246
 
247
  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);});}
248
 
 
249
  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);});
250
 
251
- // ✅ NOUVELLE ROUTE : Génère tous les liens de partage d'un coup
252
- app.post('/api/files/:id/share', requireAuth, async(req,res)=>{
253
  const f=db.prepare('SELECT * FROM files WHERE id=? AND owner_id=?').get(req.params.id,req.session.userId);
254
  if(!f||f.status!=='ready') return res.status(404).json({error:'Fichier non disponible'});
255
-
256
- const previewToken = uuidv4();
257
- const downloadToken = uuidv4();
258
- const expires = Date.now() + 7*24*3600000; // 7 jours
259
-
260
- // CORRECTION SQL : Utilisation de paramètres au lieu de guillemets doubles
261
- db.prepare("INSERT INTO share_links(token,file_id,owner_id,type,expires_at) VALUES(?,?,?,?,?)").run(previewToken, f.id, req.session.userId, 'embed', expires);
262
- db.prepare("INSERT INTO share_links(token,file_id,owner_id,type,expires_at) VALUES(?,?,?,?,?)").run(downloadToken, f.id, req.session.userId, 'download', expires);
263
-
264
- res.json({
265
- preview: `/s/${previewToken}`,
266
- download: `/d/${downloadToken}`,
267
- expires: expires
268
- });
269
  });
270
 
271
- // Ancienne route preview conservée pour compatibilité
272
  app.get('/api/files/:id/preview',requireAuth,async(req,res)=>{
273
  const f=db.prepare('SELECT * FROM files WHERE id=? AND owner_id=?').get(req.params.id,req.session.userId);
274
  if(!f||f.status!=='ready')return res.status(404).json({error:'Pas prêt'});
 
275
  const t=uuidv4();
276
- 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);
277
  res.json({url:`/s/${t}`});
278
  });
279
 
280
- // CORRECTION SQL : WHERE type='embed' avec paramètre
281
  app.get('/s/:token',async(req,res)=>{
282
  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());
283
  if(!l)return res.status(404).send(html('Lien invalide/expiré'));
284
  const f=db.prepare('SELECT * FROM files WHERE id=?').get(l.file_id);
285
  if(!f)return res.status(404).send(html('Fichier supprimé'));
286
  const dt=uuidv4();
287
- db.prepare("INSERT INTO share_links(token,file_id,owner_id,type,expires_at) VALUES(?,?,?,?,?)").run(dt,f.id,l.owner_id,'download',Date.now()+3600000);
288
  const v=f.mime.startsWith('video/'),a=f.mime.startsWith('audio/'),i=f.mime.startsWith('image/'),p=f.mime==='application/pdf';
289
  res.send(html(`<title>${f.name}</title><style>body{margin:0;background:#000;color:#fff;display:flex;align-items:center;justify-content:center;height:100vh;font-family:sans-serif;overflow:hidden}.wrap{max-width:90vw;max-height:90vh;text-align:center}video,audio{max-width:100%;max-height:80vh;background:#111}img{max-width:100%;max-height:80vh}.info{margin-top:1rem;opacity:.8}a{color:#4da3ff}</style><div class="wrap">${v?`<video controls autoplay playsinline src="/d/${dt}"></video>`:''}${a?`<audio controls src="/d/${dt}"></audio>`:''}${i?`<img src="/d/${dt}" alt="${f.name}">`:''}${p?`<iframe src="/d/${dt}" style="width:100%;height:80vh;border:none"></iframe>`:''}<div class="info">${f.name} • ${(f.size/1e6).toFixed(1)} MB • <a href="/d/${dt}" download>Télécharger</a></div></div>`));
290
  });
291
 
292
- // CORRECTION SQL : WHERE type='download' avec paramètre
293
  app.get('/d/:token',async(req,res)=>{
294
  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());
295
  if(!l)return res.status(404).json({error:'Invalide'});
@@ -299,18 +494,28 @@ app.get('/d/:token',async(req,res)=>{
299
  await streamFile(res,f,mk,req.headers.range);
300
  });
301
 
 
302
  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});});
303
 
 
304
  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>`);});
305
 
 
306
  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));});
307
 
 
308
  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());});
 
 
309
  app.get('/health',(_,res)=>res.json({ok:true,cache:fileCache.size,mem:process.memoryUsage()}));
310
 
 
311
  app.use(express.static(__dirname));
312
  app.get('*',(_,res)=>res.sendFile(join(__dirname,'public.html')));
313
 
 
 
 
314
  const server=app.listen(PORT,'0.0.0.0',()=>console.log(`🚀 PixelDrive v3.2 on http://0.0.0.0:${PORT} | Cache: 12GB LRU`));
315
  process.on('SIGTERM',()=>{console.log('SIGTERM');server.close(()=>process.exit(0));});
316
 
 
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
  process.exit(1);
43
  }
44
 
45
+ // --- DATABASE INSTANCE (DÉCLARÉE ICI, AVANT TOUT USAGE) ---
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) { 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]); }
54
  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()]); }
55
 
56
+ // --- PNG STEGANOGRAPHY ---
57
+ function bufferToPng(buffer) {
58
+ const byteLen = buffer.length;
59
+ const pixelsNeeded = Math.ceil(byteLen / 3);
60
+ const width = Math.ceil(Math.sqrt(pixelsNeeded));
61
+ const height = Math.ceil(pixelsNeeded / width);
62
+ const rgbBuffer = Buffer.alloc(width * height * 3);
63
+ buffer.copy(rgbBuffer);
64
+ // ✅ withMetadata(false) : empêche sharp d'injecter des profils ICC qui corrompraient les octets
65
+ return sharp(rgbBuffer, { raw: { width, height, channels: 3 } }).png({ compressionLevel: 9, palette: false }).withMetadata(false).toBuffer();
66
  }
67
+ async function pngToBuffer(pngBuffer, expectedLen) {
68
+ const { data, info } = await sharp(pngBuffer).ensureAlpha().raw().toBuffer({ resolveWithObject: true });
69
+ const rgb = Buffer.alloc(info.width * info.height * 3);
70
+ let j = 0;
71
+ for (let i = 0; i < data.length; i += 4) {
72
+ if (j + 2 < rgb.length) { rgb[j++] = data[i]; rgb[j++] = data[i+1]; rgb[j++] = data[i+2]; }
73
+ }
74
+ // ✅ On retourne EXACTEMENT la longueur attendue, en ignorant le padding de l'image
75
+ return rgb.subarray(0, expectedLen);
76
  }
77
 
78
+ // ==========================================
79
+ // SCHÉMA DE BASE (Tables seulement, PAS d'index sur folder_id)
80
+ // ==========================================
81
  db.exec(`
82
+ CREATE TABLE IF NOT EXISTS users (
83
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
84
+ username TEXT UNIQUE NOT NULL,
85
+ password_hash BLOB NOT NULL,
86
+ salt BLOB NOT NULL,
87
+ is_admin INTEGER DEFAULT 0,
88
+ tos_accepted INTEGER DEFAULT 0,
89
+ created_at INTEGER DEFAULT (strftime('%s','now'))
90
+ );
91
+ CREATE TABLE IF NOT EXISTS folders (
92
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
93
+ parent_id INTEGER REFERENCES folders(id) ON DELETE CASCADE,
94
+ owner_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
95
+ name TEXT NOT NULL,
96
+ created_at INTEGER DEFAULT (strftime('%s','now')),
97
+ UNIQUE(parent_id, owner_id, name)
98
+ );
99
+ CREATE TABLE IF NOT EXISTS files (
100
+ id TEXT PRIMARY KEY,
101
+ folder_id INTEGER REFERENCES folders(id) ON DELETE SET NULL,
102
+ owner_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
103
+ name TEXT NOT NULL,
104
+ mime TEXT NOT NULL,
105
+ size INTEGER NOT NULL,
106
+ chunk_count INTEGER DEFAULT 1,
107
+ status TEXT DEFAULT 'pending',
108
+ progress REAL DEFAULT 0,
109
+ error_msg TEXT,
110
+ encryption_key_hash TEXT NOT NULL,
111
+ master_key_enc BLOB,
112
+ created_at INTEGER DEFAULT (strftime('%s','now')),
113
+ updated_at INTEGER DEFAULT (strftime('%s','now'))
114
+ );
115
+ CREATE TABLE IF NOT EXISTS file_chunks (
116
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
117
+ file_id TEXT NOT NULL REFERENCES files(id) ON DELETE CASCADE,
118
+ chunk_index INTEGER NOT NULL,
119
+ imgbb_url TEXT,
120
+ imgbb_delete_url TEXT,
121
+ imgbb_id TEXT,
122
+ size INTEGER NOT NULL,
123
+ encrypted_len INTEGER NOT NULL,
124
+ UNIQUE(file_id, chunk_index)
125
+ );
126
+ CREATE TABLE IF NOT EXISTS share_links (
127
+ token TEXT PRIMARY KEY,
128
+ file_id TEXT NOT NULL REFERENCES files(id) ON DELETE CASCADE,
129
+ owner_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
130
+ type TEXT NOT NULL,
131
+ created_at INTEGER DEFAULT (strftime('%s','now')),
132
+ expires_at INTEGER
133
+ );
134
  CREATE INDEX IF NOT EXISTS idx_files_owner ON files(owner_id);
135
  CREATE INDEX IF NOT EXISTS idx_chunks_file ON file_chunks(file_id);
136
  `);
137
 
138
+ // ==========================================
139
+ // MIGRATIONS (Ajoute colonnes manquantes SI table existe déjà)
140
+ // ==========================================
141
  function runMigrations() {
142
  console.log('🔍 Vérification migrations BDD...');
143
+
144
+ // --- MIGRATION TABLE USERS (tos_accepted) ---
145
  const usersCols = db.prepare("PRAGMA table_info(users)").all().map(c => c.name);
146
  if (!usersCols.includes('tos_accepted')) {
147
  console.log('🔧 Migration: ALTER TABLE users ADD COLUMN tos_accepted');
148
  db.exec(`ALTER TABLE users ADD COLUMN tos_accepted INTEGER DEFAULT 0;`);
149
  }
150
+
151
+ // --- MIGRATION TABLE FILES ---
152
  const filesCols = db.prepare("PRAGMA table_info(files)").all().map(c => c.name);
153
  const filesDefs = [
154
  { name: 'folder_id', def: 'INTEGER REFERENCES folders(id) ON DELETE SET NULL' },
 
158
  { name: 'progress', def: 'REAL DEFAULT 0' },
159
  { name: 'error_msg', def: 'TEXT' },
160
  { name: 'chunk_count', def: 'INTEGER DEFAULT 1' },
161
+ { name: 'updated_at', def: 'INTEGER DEFAULT 0' }, // ✅ Constante (SQLite refuse une expression dans ALTER TABLE)
162
  ];
163
+
164
  let updatedAdded = false;
165
  for (const col of filesDefs) {
166
  if (!filesCols.includes(col.name)) {
 
170
  }
171
  }
172
  if (updatedAdded) {
173
+ console.log('🔧 Migration: Initialisation updated_at sur les lignes existantes');
174
  db.exec(`UPDATE files SET updated_at = strftime('%s','now') WHERE updated_at = 0;`);
175
  }
176
+
177
+ // --- TABLES MANQUANTES ---
178
  const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table'").all().map(t => t.name);
179
+
180
+ if (!tables.includes('folders')) {
181
+ console.log('🔧 Migration: CREATE TABLE folders');
182
+ 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));`);
183
+ }
184
+ if (!tables.includes('share_links')) {
185
+ console.log('🔧 Migration: CREATE TABLE share_links');
186
+ 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);`);
187
+ }
188
+
189
+ // --- CORRECTION TABLE SESSIONS (ancien schéma incompatible) ---
190
  const sessionsCols = tables.includes('sessions') ? db.prepare("PRAGMA table_info(sessions)").all().map(c => c.name) : [];
191
  if (tables.includes('sessions') && (!sessionsCols.includes('data') || !sessionsCols.includes('expires_at'))) {
192
+ console.log('🔧 Migration: Reconstruction table sessions (colonnes manquantes)');
193
  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);`);
194
  } else if (!tables.includes('sessions')) {
195
+ console.log('🔧 Migration: CREATE TABLE sessions');
196
+ 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);`);
197
  }
198
+
199
  console.log('✅ Migrations terminées.');
200
  }
201
  runMigrations();
202
 
203
+ // ==========================================
204
+ // INDEX & TRIGGERS DÉPENDANTS (Maintenant que folder_id existe)
205
+ // ==========================================
206
  db.exec(`
207
  CREATE INDEX IF NOT EXISTS idx_files_folder ON files(folder_id);
208
  DROP TRIGGER IF EXISTS update_file_ts;
209
+ CREATE TRIGGER update_file_ts AFTER UPDATE ON files
210
+ BEGIN
211
+ UPDATE files SET updated_at = strftime('%s','now') WHERE id = NEW.id;
212
+ END;
213
  `);
214
 
215
+ // --- SESSION STORE (Native) ---
216
+ // ✅ Hérite de session.Store (EventEmitter) => fournit .on() requis par express-session
217
  class SQLiteStore extends session.Store {
218
+ get(sid, cb) {
219
+ try {
220
+ const r = db.prepare('SELECT data, expires_at FROM sessions WHERE sid=?').get(sid);
221
+ if (!r) return cb(null, null);
222
+ if (r.expires_at < Date.now()) {
223
+ db.prepare('DELETE FROM sessions WHERE sid=?').run(sid);
224
+ return cb(null, null);
225
+ }
226
+ cb(null, JSON.parse(r.data));
227
+ } catch (e) { cb(e); }
228
+ }
229
+ set(sid, sess, cb) {
230
+ try {
231
+ const exp = sess.cookie?.expires ? new Date(sess.cookie.expires).getTime() : Date.now() + 30*24*60*60*1000;
232
+ db.prepare('INSERT OR REPLACE INTO sessions(sid,data,expires_at) VALUES(?,?,?)').run(sid, JSON.stringify(sess), exp);
233
+ cb(null);
234
+ } catch (e) { cb(e); }
235
+ }
236
+ destroy(sid, cb) { try { db.prepare('DELETE FROM sessions WHERE sid=?').run(sid); cb(null); } catch (e) { cb(e); } }
237
+ clear(cb) { try { db.prepare('DELETE FROM sessions').run(); cb(null); } catch (e) { cb(e); } }
238
+ length(cb) { try { const r = db.prepare('SELECT COUNT(*) as c FROM sessions').get(); cb(null, r.c); } catch (e) { cb(e); } }
239
  touch(sid, sess, cb) { this.set(sid, sess, cb); }
240
  }
241
  const sessionStore = new SQLiteStore();
242
 
243
+ // --- ADMIN INIT ---
244
  const adminRow = db.prepare('SELECT * FROM users WHERE username=?').get(ADMIN_USER);
245
+ if (!adminRow) {
246
+ const salt = randomBytes(16);
247
+ const hash = await deriveKey(process.env.ADMIN_PASS || 'ChangeMeNow!', salt);
248
+ db.prepare('INSERT INTO users(username,password_hash,salt,is_admin,tos_accepted) VALUES(?,?,?,1,1)').run(ADMIN_USER, hash, salt);
249
+ console.log(`👑 Admin: ${ADMIN_USER} / Pass: ${process.env.ADMIN_PASS || 'ChangeMeNow!'}`);
250
  }
251
 
252
+ // --- EXPRESS ---
253
  const app = express();
254
  app.set('trust proxy', 1);
255
  app.use(express.json({ limit: '50mb' }));
256
  app.use(express.urlencoded({ extended: true, limit: '50mb' }));
257
+ app.use(session({
258
+ secret: SESSION_SECRET,
259
+ store: sessionStore,
260
+ resave: false,
261
+ saveUninitialized: false,
262
+ cookie: { secure: false, httpOnly: true, maxAge: 30*24*60*60*1000, sameSite: 'lax' }
263
+ }));
264
+
265
+ const requireAuth = (req, res, next) => { if (!req.session.userId) return res.status(401).json({error:'Non authentifié'}); next(); };
266
+ const requireTos = (req, res, next) => { if (!req.session.tosAccepted) return res.status(403).json({error:'CGU non acceptées', needTos:true}); next(); };
267
+
268
+ // --- CACHE LRU ---
269
+ class ByteLRUCache extends LRUCache {
270
+ constructor(){ super({maxSize:CACHE_MAX_BYTES, sizeCalculation:v=>v.size}); }
271
+ async getOrLoad(key, loader){ if(this.has(key)) return this.get(key).data; const data=await loader(); this.set(key,{data,size:data.length,time:Date.now()}); return data; }
272
+ }
273
  const fileCache = new ByteLRUCache();
274
+ setInterval(()=>{ const m=process.memoryUsage(); if(m.rss>13*1024*1024*1024){ console.warn(`⚠️ RAM ${(m.rss/1e9).toFixed(1)}GB, vidage cache`); fileCache.clear(); if(global.gc) global.gc(); } }, 30000);
275
+
276
+ // --- NSFW WASM ---
277
+ let nsfwModel = null;
278
+ async function loadNsfwModel(){
279
+ try{
280
+ await tf.setBackend('wasm');
281
+ tf.wasm.setWasmPaths('/app/tfjs_wasm/','tfjs-backend-wasm.wasm');
282
+ await tf.ready();
283
+ nsfwModel = await nsfwjs.load('/app/tfjs_wasm/',{type:'mobilenet_v2',size:224});
284
+ console.log('🛡️ NSFW WASM chargé');
285
+ }catch(e){ console.error('❌ NSFW load fail:',e.message); }
286
+ }
287
  loadNsfwModel();
288
 
289
+ async function checkNsfw(buf){
290
+ if(!nsfwModel) return {safe:true};
291
+ try{
292
+ const t=tf.node.decodeImage(buf,3).resizeNearestNeighbor([224,224]).expandDims(0);
293
+ const p=await nsfwModel.classify(t);
294
+ t.dispose();
295
+ const porn=p.find(x=>x.className==='Porn'||x.className==='Sexual Activity')?.probability||0;
296
+ const hentai=p.find(x=>x.className==='Hentai')?.probability||0;
297
+ if(porn>0.85||hentai>0.9) return {safe:false,reason:`NSFW (P:${porn.toFixed(2)} H:${hentai.toFixed(2)})`};
298
+ return {safe:true};
299
+ }catch(e){ return {safe:true}; }
300
+ }
301
 
302
+ // --- UPLOAD QUEUE ---
303
+ 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(); } } }
304
+ const uploadQueue = new UploadQueue(MAX_CONCURRENT_UPLOADS);
305
+
306
+ // --- IMGBB ---
307
+ async function imgbbUpload(buf){
308
+ const f=new FormData();
309
+ f.append('image',buf.toString('base64'));
310
+ 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});
311
+ if(!data.success) throw new Error(data.error?.message||'ImgBB fail');
312
+ return {url:data.data.url,deleteUrl:data.data.delete_url,id:data.data.id};
313
+ }
314
  async function imgbbDelete(u){ try{await axios.get(u,{timeout:10000});}catch(_){} }
315
 
316
+ // --- KEY MGMT ---
317
  const SERVER_MASTER_KEY=createHash('sha256').update(SESSION_SECRET+'|PixelDriveMaster').digest();
318
  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()]); }
319
  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()]); }
320
  async function getMasterKey(uid,fid){ const r=db.prepare('SELECT master_key_enc FROM files WHERE id=? AND owner_id=?').get(fid,uid); if(!r?.master_key_enc) throw new Error('Clé manquante'); return decryptMasterKey(r.master_key_enc); }
321
 
322
+ // --- THUMB ---
323
+ async function genThumb(fid,mime,key){
324
+ if(!mime.startsWith('image/')&&!mime.startsWith('video/')) return;
325
+ try{
326
+ // ✅ CORRECTION : longueur exacte du chiffré = size + OVERHEAD (ignore le padding PNG)
327
+ const c=db.prepare('SELECT imgbb_url, size FROM file_chunks WHERE file_id=? AND chunk_index=0').get(fid);
328
+ if(!c) return;
329
+ const {data}=await axios.get(c.imgbb_url,{responseType:'arraybuffer',timeout:30000});
330
+ const enc=await pngToBuffer(Buffer.from(data), c.size + OVERHEAD);
331
+ const dec=decryptChunk(enc,key);
332
+ let buf;
333
+ if(mime.startsWith('image/')) buf=await sharp(dec).rotate().resize(256,256,{fit:'inside'}).jpeg({quality:80}).toBuffer();
334
+ else buf=await sharp({create:{width:256,height:144,channels:3,background:'#1e1e2f'}}).jpeg().toBuffer();
335
+ await sharp(buf).toFile(join(THUMB_DIR,`${fid}.jpg`));
336
+ }catch(e){ console.warn(`Thumb ${fid}:`,e.message); }
337
  }
338
 
339
+ // --- STREAM DOWNLOAD ---
340
+ async function streamFile(res,file,key,range){
341
+ // ✅ CORRECTION : on utilise 'size' + OVERHEAD, plus la colonne encrypted_len (paddée)
342
+ const chunks=db.prepare('SELECT chunk_index,imgbb_url,size FROM file_chunks WHERE file_id=? ORDER BY chunk_index').all(file.id);
343
+ const total=file.size;
344
+ let s=0,e=total-1;
345
+ if(range){
346
+ const p=range.replace('bytes=','').split('-');
347
+ s=parseInt(p[0]);
348
+ e=p[1]?parseInt(p[1]):total-1;
349
+ }
350
+ if(s>=total||e>=total) return res.status(416).send('Range Not Satisfiable');
351
+ res.set({'Content-Type':file.mime,'Content-Length':e-s+1,'Accept-Ranges':'bytes','Content-Disposition':`inline; filename="${encodeURIComponent(file.name)}"`});
352
+ if(range){res.status(206);res.set('Content-Range',`bytes ${s}-${e}/${total}`);}
353
+ let cur=0;
354
+ for(const c of chunks){
355
+ const cs=cur, ce=cur+c.size-1;
356
+ if(ce>=s && cs<=e){
357
+ const off=Math.max(0,s-cs), len=Math.min(c.size-off,e-Math.max(s,cs)+1);
358
+ try{
359
+ const ck=`chunk:${file.id}:${c.chunk_index}`;
360
+ let dec=fileCache.get(ck)?.data;
361
+ if(!dec){
362
+ const {data}=await axios.get(c.imgbb_url,{responseType:'arraybuffer',timeout:60000});
363
+ const enc=await pngToBuffer(Buffer.from(data), c.size + OVERHEAD);
364
+ dec=decryptChunk(enc,key);
365
+ fileCache.set(ck,{data:dec,size:dec.length});
366
+ }
367
+ const sl=dec.subarray(off,off+len);
368
+ if(!res.write(sl)) await new Promise(r=>res.once('drain',r));
369
+ }catch(err){ console.error(`Stream chunk ${c.chunk_index}`,err); return res.destroy(err); }
370
+ }
371
+ cur+=c.size;
372
+ if(cs>e) break;
373
+ }
374
+ res.end();
375
  }
376
 
377
+ // --- SSE ---
378
+ const sseClients=new Map();
379
  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); } }
380
 
381
  // ==========================================
382
  // ROUTES
383
  // ==========================================
384
+ // Auth
385
  app.post('/api/register',async(req,res)=>{const{u,p,t}=req.body;if(!u||!p||!t)return res.status(400).json({error:'Champs manquants'});if(db.prepare('SELECT 1 FROM users WHERE username=?').get(u))return res.status(409).json({error:'Existe'});const s=randomBytes(16);const h=await deriveKey(p,s);db.prepare('INSERT INTO users(username,password_hash,salt,tos_accepted) VALUES(?,?,?,1)').run(u,h,s);res.json({ok:true});});
386
  app.post('/api/login',async(req,res)=>{const{u,p}=req.body;const usr=db.prepare('SELECT * FROM users WHERE username=?').get(u);if(!usr)return res.status(401).json({error:'Invalide'});const h=await deriveKey(p,usr.salt);if(!h.equals(usr.password_hash))return res.status(401).json({error:'Invalide'});req.session.userId=usr.id;req.session.isAdmin=!!usr.is_admin;req.session.tosAccepted=!!usr.tos_accepted;req.session.username=usr.username;res.json({ok:true,user:{id:usr.id,username:usr.username,isAdmin:!!usr.is_admin,tosAccepted:!!usr.tos_accepted}});});
387
+ app.post('/api/logout',(req,res)=>req.session.destroy(()=>res.json({ok:true})));
388
  app.get('/api/me',(req,res)=>req.session.userId?res.json({id:req.session.userId,username:req.session.username,isAdmin:req.session.isAdmin,tosAccepted:req.session.tosAccepted}):res.status(401).json({error:'Non connecté'}));
389
  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`));
390
  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});});
391
 
392
+ // Folders
393
  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));});
394
  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'});}});
395
  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});});
396
 
397
+ // Files
398
  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));});
399
 
400
+ // Upload
401
  const upload=multer({dest:TEMP_DIR,limits:{fileSize:5*1024*1024*1024}});
402
+ app.post('/api/files/upload',requireAuth,requireTos,upload.single('file'),async(req,res)=>{
403
+ if(!req.file)return res.status(400).json({error:'Fichier manquant'});
404
+ const fid=uuidv4(),fpath=req.file.path,fsize=req.file.size,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;
405
+ 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,req.session.userId,req.file.originalname,req.file.mimetype,fsize,cc,kh,mke);
406
+ const stmt=db.prepare('INSERT INTO file_chunks(file_id,chunk_index,size,encrypted_len) VALUES(?,?,?,?)');
407
+ const tx=db.transaction((arr)=>{for(const c of arr)stmt.run(...c);});
408
+ const cd=[];
409
+ for(let i=0;i<cc;i++){const sz=Math.min(CHUNK_SIZE,fsize-i*CHUNK_SIZE);cd.push([fid,i,sz,sz+OVERHEAD]);} // ✅ encrypted_len = longueur exacte
410
+ tx(cd);
411
+ res.json({fileId:fid,status:'pending',chunkCount:cc});
412
+ uploadQueue.add(()=>processUpload(fid,fpath,mk,cc,req.file.mimetype));
413
+ });
414
 
415
+ async function processUpload(fid,path,mk,cc,mime){
416
+ try{
417
+ db.prepare("UPDATE files SET status='uploading' WHERE id=?").run(fid);
418
+ for(let i=0;i<cc;i++){
419
+ const off=i*CHUNK_SIZE;
420
+ const ci=db.prepare('SELECT size,encrypted_len FROM file_chunks WHERE file_id=? AND chunk_index=?').get(fid,i);
421
+ const raw=await readChunk(path,off,ci.size);
422
+ const enc=encryptChunk(raw,mk);
423
+ if(enc.length>ci.encrypted_len)throw new Error(`Overflow chunk ${i}`);
424
+ const png=await bufferToPng(enc);
425
+ const{url,deleteUrl,id}=await imgbbUpload(png);
426
+ db.prepare('UPDATE file_chunks SET imgbb_url=?,imgbb_delete_url=?,imgbb_id=? WHERE file_id=? AND chunk_index=?').run(url,deleteUrl,id,fid,i);
427
+ const prog=((i+1)/cc)*50;
428
+ db.prepare('UPDATE files SET progress=?,status=? WHERE id=?').run(prog,'uploading',fid);
429
+ broadcast(fid,{type:'progress',progress:prog,status:'uploading'});
430
+ if(i<cc-1)await new Promise(r=>setTimeout(r,3200));
431
+ }
432
+ unlinkSync(path);
433
+ db.prepare("UPDATE files SET status='processing',progress=50 WHERE id=?").run(fid);
434
+ broadcast(fid,{type:'progress',progress:50,status:'processing'});
435
+ await genThumb(fid,mime,mk);
436
+ db.prepare("UPDATE files SET status='ready',progress=100 WHERE id=?").run(fid);
437
+ broadcast(fid,{type:'progress',progress:100,status:'ready',type:'done'});
438
+ }catch(e){
439
+ console.error(`Upload ${fid}`,e);
440
+ db.prepare("UPDATE files SET status='error',error_msg=? WHERE id=?").run(e.message,fid);
441
+ broadcast(fid,{type:'error',error:e.message});
442
+ const cs=db.prepare('SELECT imgbb_delete_url FROM file_chunks WHERE file_id=?').all(fid);
443
+ for(const c of cs)if(c.imgbb_delete_url)await imgbbDelete(c.imgbb_delete_url);
444
+ }
445
+ }
446
 
447
  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);});}
448
 
449
+ // Download
450
  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);});
451
 
452
+ // ✅ NOUVELLE ROUTE : génère TOUS les liens de partage d'un coup (modale)
453
+ app.post('/api/files/:id/share',requireAuth,async(req,res)=>{
454
  const f=db.prepare('SELECT * FROM files WHERE id=? AND owner_id=?').get(req.params.id,req.session.userId);
455
  if(!f||f.status!=='ready') return res.status(404).json({error:'Fichier non disponible'});
456
+ const previewToken=uuidv4();
457
+ const downloadToken=uuidv4();
458
+ const expires=Date.now()+7*24*3600000; // 7 jours
459
+ // CORRECTION SQL : types passés en PARAMÈTRES (jamais de guillemets doubles = colonnes)
460
+ db.prepare('INSERT INTO share_links(token,file_id,owner_id,type,expires_at) VALUES(?,?,?,?,?)').run(previewToken,f.id,req.session.userId,'embed',expires);
461
+ db.prepare('INSERT INTO share_links(token,file_id,owner_id,type,expires_at) VALUES(?,?,?,?,?)').run(downloadToken,f.id,req.session.userId,'download',expires);
462
+ res.json({preview:`/s/${previewToken}`,download:`/d/${downloadToken}`,expires:expires});
 
 
 
 
 
 
 
463
  });
464
 
465
+ // Preview (compatibilité)
466
  app.get('/api/files/:id/preview',requireAuth,async(req,res)=>{
467
  const f=db.prepare('SELECT * FROM files WHERE id=? AND owner_id=?').get(req.params.id,req.session.userId);
468
  if(!f||f.status!=='ready')return res.status(404).json({error:'Pas prêt'});
469
+ 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'});
470
  const t=uuidv4();
471
+ 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);
472
  res.json({url:`/s/${t}`});
473
  });
474
 
475
+ // Public Embed
476
  app.get('/s/:token',async(req,res)=>{
477
  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());
478
  if(!l)return res.status(404).send(html('Lien invalide/expiré'));
479
  const f=db.prepare('SELECT * FROM files WHERE id=?').get(l.file_id);
480
  if(!f)return res.status(404).send(html('Fichier supprimé'));
481
  const dt=uuidv4();
482
+ db.prepare('INSERT INTO share_links(token,file_id,owner_id,type,expires_at) VALUES(?,?,?,?,?)').run(dt,f.id,l.owner_id,'download',Date.now()+3600000);
483
  const v=f.mime.startsWith('video/'),a=f.mime.startsWith('audio/'),i=f.mime.startsWith('image/'),p=f.mime==='application/pdf';
484
  res.send(html(`<title>${f.name}</title><style>body{margin:0;background:#000;color:#fff;display:flex;align-items:center;justify-content:center;height:100vh;font-family:sans-serif;overflow:hidden}.wrap{max-width:90vw;max-height:90vh;text-align:center}video,audio{max-width:100%;max-height:80vh;background:#111}img{max-width:100%;max-height:80vh}.info{margin-top:1rem;opacity:.8}a{color:#4da3ff}</style><div class="wrap">${v?`<video controls autoplay playsinline src="/d/${dt}"></video>`:''}${a?`<audio controls src="/d/${dt}"></audio>`:''}${i?`<img src="/d/${dt}" alt="${f.name}">`:''}${p?`<iframe src="/d/${dt}" style="width:100%;height:80vh;border:none"></iframe>`:''}<div class="info">${f.name} • ${(f.size/1e6).toFixed(1)} MB • <a href="/d/${dt}" download>Télécharger</a></div></div>`));
485
  });
486
 
487
+ // Public Download
488
  app.get('/d/:token',async(req,res)=>{
489
  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());
490
  if(!l)return res.status(404).json({error:'Invalide'});
 
494
  await streamFile(res,f,mk,req.headers.range);
495
  });
496
 
497
+ // Delete
498
  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});});
499
 
500
+ // Thumb
501
  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>`);});
502
 
503
+ // SSE
504
  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));});
505
 
506
+ // Admin
507
  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());});
508
+
509
+ // Health
510
  app.get('/health',(_,res)=>res.json({ok:true,cache:fileCache.size,mem:process.memoryUsage()}));
511
 
512
+ // SPA
513
  app.use(express.static(__dirname));
514
  app.get('*',(_,res)=>res.sendFile(join(__dirname,'public.html')));
515
 
516
+ // ==========================================
517
+ // START
518
+ // ==========================================
519
  const server=app.listen(PORT,'0.0.0.0',()=>console.log(`🚀 PixelDrive v3.2 on http://0.0.0.0:${PORT} | Cache: 12GB LRU`));
520
  process.on('SIGTERM',()=>{console.log('SIGTERM');server.close(()=>process.exit(0));});
521