NathMen12 commited on
Commit
e675feb
·
verified ·
1 Parent(s): 8c18929

Update server.js

Browse files
Files changed (1) hide show
  1. server.js +129 -87
server.js CHANGED
@@ -1,12 +1,12 @@
1
  // ============================================================
2
- // PIXELDRIVE v2.1 - Fix Range Decrypt, Robust Cache, Icons
3
  // ============================================================
4
 
5
  import express from 'express';
6
  import session from 'express-session';
7
  import multer from 'multer';
8
  import Database from 'better-sqlite3';
9
- import { PNG } from 'pngjs';
10
  import Bottleneck from 'bottleneck';
11
  import { randomBytes, createHash, scrypt, timingSafeEqual, createCipheriv, createDecipheriv } from 'crypto';
12
  import { promisify } from 'util';
@@ -14,8 +14,10 @@ import { fileURLToPath } from 'url';
14
  import { dirname, join } from 'path';
15
  import { v4 as uuidv4 } from 'uuid';
16
  import { unlink } from 'fs/promises';
 
 
17
 
18
- // --- CRYPTO & CONFIG ---
19
  const scryptAsync = promisify(scrypt);
20
  const getFileKey = (fid, secret) => createHash('sha256').update(`${secret}:file:${fid}`).digest();
21
 
@@ -24,28 +26,23 @@ const DATA_DIR = process.env.DATA_DIR || '/data';
24
  const DB_PATH = `${DATA_DIR}/pixeldrive.db`;
25
  const IMGBB_API_KEY = process.env.IMGBB_API_KEY;
26
  const PORT = parseInt(process.env.PORT) || 7860;
27
- const CHUNK_TARGET_SIZE = 31 * 1024 * 1024;
28
- const PNG_BPP = 4;
29
- const SESSION_SECRET = process.env.SESSION_SECRET || randomBytes(32).toString('hex');
 
30
  const ADMIN_USER = process.env.ADMIN_USER || 'admin';
31
  const ADMIN_PASS = process.env.ADMIN_PASS || 'changeme';
 
32
 
33
  // CACHE CONFIG
34
- const CACHE_MAX_BYTES = 12 * 1024 * 1024 * 1024; // 12 Go
35
- const CACHE_TTL_MS = 15 * 60 * 1000; // 15 min
36
-
37
- if (!IMGBB_API_KEY) { console.error("❌ IMGBB_API_KEY manquante !"); process.exit(1); }
38
 
39
  // ============================================================
40
- // LRU CACHE AVEC TAILLE RÉELLE (Buffer.length)
41
  // ============================================================
42
  class ChunkCache {
43
- constructor(maxBytes, ttlMs) {
44
- this.maxBytes = maxBytes; this.ttlMs = ttlMs;
45
- this.map = new Map(); // key -> {buf, size, at}
46
- this.currentBytes = 0;
47
- setInterval(() => this._cleanup(), 60000).unref();
48
- }
49
  _key(fid, idx) { return `${fid}:${idx}`; }
50
  _cleanup() { const now = Date.now(); for(const [k, v] of this.map) if(now - v.at > this.ttlMs) this._del(k); }
51
  _del(key) { const v = this.map.get(key); if(v) { this.currentBytes -= v.size; this.map.delete(key); } }
@@ -84,10 +81,19 @@ const adminExists = db.prepare('SELECT 1 FROM users WHERE username = ?').get(ADM
84
  if (!adminExists) { const salt = randomBytes(16); const hash = await scryptAsync(ADMIN_PASS, salt, 32); db.prepare('INSERT INTO users (username, password_hash, salt, is_admin) VALUES (?, ?, ?, 1)').run(ADMIN_USER, hash, salt); console.log(`✅ Admin: ${ADMIN_USER} / ${ADMIN_PASS}`); }
85
 
86
  // ============================================================
87
- // PNG ENGINE
88
  // ============================================================
89
- function dataToPngBuffer(dataBuffer) { const pc = Math.ceil(dataBuffer.length / PNG_BPP); const w = Math.ceil(Math.sqrt(pc)); const h = Math.ceil(pc / w); const png = new PNG({ width: w, height: h, colorType: 6, filterType: 4 }); png.data.set(dataBuffer); return new Promise((res, rej) => { const c=[]; png.on('data',d=>c.push(d)).on('end',()=>res(Buffer.concat(c))).on('error',rej); png.pack(); }); }
90
- function pngBufferToData(pngBuffer, expectedLength) { return new Promise((res, rej) => { const png=new PNG(); png.on('parsed',()=>res(png.data.subarray(0,expectedLength))).on('error',rej); png.parse(pngBuffer); }); }
 
 
 
 
 
 
 
 
 
91
 
92
  // ============================================================
93
  // IMGBB QUEUE
@@ -97,77 +103,60 @@ async function imgbbUpload(buf) { return imgbbLimiter.schedule(async () => { con
97
  async function imgbbDelete(u) { if(!u) return; return imgbbLimiter.schedule(async()=>{try{await fetch(u,{method:'GET'})}catch(e){}}); }
98
 
99
  // ============================================================
100
- // UPLOAD STATUS TRACKER
101
  // ============================================================
102
  const uploadStatus = new Map();
103
  function setStatus(fid, data) { uploadStatus.set(fid, { ...uploadStatus.get(fid), ...data, updated: Date.now() }); }
104
  function getStatus(fid) { return uploadStatus.get(fid) || { stage: 'waiting' }; }
105
- function clearStatus(fid) { setTimeout(()=>uploadStatus.delete(fid), 60000); } // Keep for 1min after done
106
 
107
  // ============================================================
108
- // HELPERS: RANGE & STREAMING (CORRIGÉ)
109
  // ============================================================
110
  function parseRange(header, total) { if(!header) return null; const parts = header.replace('bytes=', '').split('-'); const start = parseInt(parts[0], 10); const end = parts[1] ? parseInt(parts[1], 10) : total - 1; if(isNaN(start)||isNaN(end)||start>end||end>=total) return null; return { start, end, length: end - start + 1 }; }
111
 
112
- // Core: Get Decrypted Chunk Buffer (Cache or Fetch+Decrypt+Verify)
113
  async function getDecryptedChunk(fileId, chunkIdx, ch, fileKey) {
114
- // 1. Cache
115
  const cached = chunkCache.get(fileId, chunkIdx);
116
  if(cached) return cached;
117
-
118
- // 2. Fetch IMGBB
119
  const r = await fetch(ch.imgbb_url);
120
  if(!r.ok) throw new Error(`IMGBB DL Failed: ${r.status}`);
121
  const pngBuf = Buffer.from(await r.arrayBuffer());
122
-
123
- // 3. Decode PNG
124
- const payload = await pngBufferToData(pngBuf, ch.byte_size);
125
-
126
- // 4. VERIFY HASH (CRITICAL FOR RANGE REQUESTS)
 
127
  const sha = createHash('sha256').update(payload).digest('hex');
128
- if(sha !== ch.sha256) throw new Error(`Hash Mismatch Chunk ${chunkIdx} (Corrupted IMGBB or DB)`);
129
-
130
- // 5. Decrypt
131
  const iv = payload.subarray(0, 12);
132
  const tag = payload.subarray(12, 28);
133
  const enc = payload.subarray(28);
134
  const decipher = createDecipheriv('aes-256-gcm', fileKey, iv);
135
  decipher.setAuthTag(tag);
136
  const decrypted = Buffer.concat([decipher.update(enc), decipher.final()]);
137
-
138
- // 6. Cache & Return
139
  chunkCache.set(fileId, chunkIdx, decrypted);
140
  return decrypted;
141
  }
142
 
143
- // Stream Full (Download)
144
  async function streamFull(res, file, chunks, fileKey) {
145
- res.setHeader('Content-Length', file.size);
146
- res.setHeader('Accept-Ranges', 'bytes');
147
  for(const ch of chunks) { res.write(await getDecryptedChunk(file.id, ch.idx, ch, fileKey)); }
148
  res.end();
149
  }
150
 
151
- // Stream Range (Video/Audio Seek) - LOGIQUE CORRIGÉE
152
  async function streamRange(res, file, chunks, fileKey, range) {
153
  const { start, end, length } = range;
154
- res.status(206);
155
- res.setHeader('Content-Range', `bytes ${start}-${end}/${file.size}`);
156
- res.setHeader('Accept-Ranges', 'bytes');
157
- res.setHeader('Content-Length', length);
158
- res.setHeader('Content-Type', file.mime);
159
-
160
  let currentByte = 0;
161
  for(const ch of chunks) {
162
  const decryptedBuf = await getDecryptedChunk(file.id, ch.idx, ch, fileKey);
163
- const chunkSize = decryptedBuf.length; // VRAIE TAILLE DÉCHIFFRÉE
164
- const chunkStart = currentByte;
165
- const chunkEnd = currentByte + chunkSize - 1;
166
-
167
- if(chunkEnd < start) { currentByte += chunkSize; continue; } // Avant range
168
- if(chunkStart > end) break; // Après range
169
-
170
- // Overlap
171
  const neededStart = Math.max(0, start - chunkStart);
172
  const neededEnd = Math.min(chunkSize - 1, end - chunkStart);
173
  res.write(decryptedBuf.subarray(neededStart, neededEnd + 1));
@@ -186,54 +175,111 @@ const upload = multer({ dest: `${DATA_DIR}/tmp_uploads`, limits: { fileSize: 4 *
186
  const requireAuth = (req,res,next) => req.session.userId ? next() : res.status(401).json({error:'Non connecté'});
187
  const requireAdmin = (req,res,next) => req.session.isAdmin ? next() : res.status(403).json({error:'Admin requis'});
188
 
189
- // ============================================================
190
- // API AUTH
191
- // ============================================================
192
  app.post('/api/auth/register', async (req,res)=>{ const {username,password}=req.body; if(!username||!password) return res.status(400).json({error:'Champs manquants'}); try{const salt=randomBytes(16);const hash=await scryptAsync(password,salt,32);db.prepare('INSERT INTO users(username,password_hash,salt)VALUES(?,?,?)').run(username,hash,salt);res.json({ok:true});}catch(e){res.status(409).json({error:'Existe'});} });
193
  app.post('/api/auth/login', async (req,res)=>{ const {username,password}=req.body; const u=db.prepare('SELECT*FROM users WHERE username=?').get(username); if(!u) return res.status(401).json({error:'Invalide'}); const h=await scryptAsync(password,u.salt,32); if(!timingSafeEqual(h,u.password_hash)) return res.status(401).json({error:'Invalide'}); req.session.userId=u.id;req.session.isAdmin=!!u.is_admin;req.session.username=u.username; res.json({ok:true,user:{username:u.username,isAdmin:!!u.is_admin}}); });
194
  app.post('/api/auth/logout', (req,res)=>req.session.destroy(()=>res.json({ok:true})));
195
  app.get('/api/auth/me', (req,res)=>req.session.userId?res.json({user:{username:req.session.username,isAdmin:req.session.isAdmin}}):res.status(401).json({error:'Non connecté'}));
196
 
197
- // ============================================================
198
- // API DRIVE
199
- // ============================================================
200
  app.get('/api/fs/list', requireAuth, (req,res)=>{ const pid=req.query.parent_id||null; const items=db.prepare('SELECT id,name,mime,size,chunk_count,created_at FROM files WHERE owner_id=? AND (parent_id IS ? OR parent_id=?) ORDER BY (mime=?) DESC,name').all(req.session.userId,pid,pid,'application/vnd.pixeldrive.folder'); res.json({items}); });
201
  app.post('/api/fs/mkdir', requireAuth, (req,res)=>{ const {name,parent_id}=req.body; if(!name||name.includes('/')) return res.status(400).json({error:'Nom invalide'}); const id=uuidv4(); db.prepare('INSERT INTO files(id,owner_id,parent_id,name,mime)VALUES(?,?,?,?,?)').run(id,req.session.userId,parent_id||null,name,'application/vnd.pixeldrive.folder'); res.json({ok:true,id}); });
202
 
203
- // UPLOAD
 
 
204
  app.post('/api/fs/upload', requireAuth, upload.single('file'), async (req,res)=>{
205
  if(!req.file) return res.status(400).json({error:'Fichier manquant'});
206
  const pid=req.body.parent_id||null; const name=req.file.originalname; const mime=req.file.mimetype||'application/octet-stream';
207
  const tmpPath=req.file.path; const fid=uuidv4(); const fKey=getFileKey(fid, SESSION_SECRET); const iv=randomBytes(12);
208
- setStatus(fid, { owner: req.session.userId, stage: 'receiving', progress: 0, total: req.file.size, current: 0, fileName: name, startTime: Date.now(), speed: 0, eta: 0, chunksTotal: 0, chunksDone: 0 });
 
 
209
  try{
210
  db.prepare('INSERT INTO files(id,owner_id,parent_id,name,mime,size,chunk_count)VALUES(?,?,?,?,?,0,0)').run(fid,req.session.userId,pid,name,mime);
211
- const fs=await import('fs'); const rs=fs.createReadStream(tmpPath,{highWaterMark:1024*1024}); let buf=Buffer.alloc(0); let tSize=0; let cIdx=0; const startTime=Date.now();
212
- setStatus(fid, { stage: 'chunking', chunksTotal: Math.ceil(req.file.size / CHUNK_TARGET_SIZE) });
213
- for await(const ch of rs){ buf=Buffer.concat([buf,ch]); tSize+=ch.length; const elapsed=(Date.now()-startTime)/1000; const sp=elapsed>0?tSize/elapsed/1024/1024:0; const et=sp>0?(req.file.size-tSize)/sp/1024/1024:0; setStatus(fid,{progress:tSize,current:tSize,speed:sp,eta:et,stage:'chunking'}); while(buf.length>=CHUNK_TARGET_SIZE){await processChunk(buf.subarray(0,CHUNK_TARGET_SIZE),cIdx++);buf=buf.subarray(CHUNK_TARGET_SIZE);} }
214
- if(buf.length>0) await processChunk(buf,cIdx);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
215
  setStatus(fid, { stage: 'finalizing', progress: req.file.size });
216
- db.prepare('UPDATE files SET size=?,chunk_count=? WHERE id=?').run(tSize,cIdx,fid);
217
- setStatus(fid, { stage: 'done', progress: tSize, chunksDone: cIdx, chunksTotal: cIdx }); clearStatus(fid);
218
- res.json({ok:true,fileId:fid,chunks:cIdx,size:tSize});
219
- }catch(e){console.error('[UPLOAD]',e);setStatus(fid,{stage:'error',error:e.message});clearStatus(fid);res.status(500).json({error:e.message});}
 
 
220
  finally{ try{await unlink(tmpPath);}catch(e){} }
221
 
222
- async function processChunk(raw,idx){
223
- setStatus(fid, { stage: 'encrypting', currentChunk: idx+1 });
224
- const cipher=createCipheriv('aes-256-gcm',fKey,iv); const enc=Buffer.concat([cipher.update(raw),cipher.final()]); const tag=cipher.getAuthTag();
225
- const payload=Buffer.concat([iv,tag,enc]);
226
- setStatus(fid, { stage: 'png_encoding' }); const pngBuf=await dataToPngBuffer(payload);
227
- setStatus(fid, { stage: 'uploading_imgbb', currentChunk: idx+1 }); const {url,delete_url,width,height}=await imgbbUpload(pngBuf);
228
- const sha=createHash('sha256').update(payload).digest('hex');
229
- db.prepare('INSERT INTO chunks(file_id,idx,imgbb_url,imgbb_delete_url,sha256,byte_size,pixel_w,pixel_h)VALUES(?,?,?,?,?,?,?,?)').run(fid,idx,url,delete_url,sha,payload.length,width,height);
230
- setStatus(fid, { chunksDone: idx+1 });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
231
  }
232
  });
233
 
234
  app.get('/api/fs/upload/status/:fid', requireAuth, (req,res)=>{ const s=getStatus(req.params.fid); if(!s||s.owner!==req.session.userId) return res.status(404).json({error:'Not found'}); res.json(s); });
235
 
236
- // DOWNLOAD (Range Support)
237
  app.get('/api/fs/download/:fid', requireAuth, async (req,res)=>{
238
  const file=db.prepare('SELECT*FROM files WHERE id=? AND owner_id=?').get(req.params.fid,req.session.userId);
239
  if(!file) return res.status(404).json({error:'Introuvable'}); if(file.mime==='application/vnd.pixeldrive.folder') return res.status(400).json({error:'Dossier'});
@@ -245,7 +291,6 @@ app.get('/api/fs/download/:fid', requireAuth, async (req,res)=>{
245
  else await streamFull(res, file, chunks, getFileKey(file.id, SESSION_SECRET));
246
  });
247
 
248
- // PREVIEW (Range Support Critical)
249
  app.get('/api/fs/preview/:fid', requireAuth, async (req,res)=>{
250
  const file=db.prepare('SELECT*FROM files WHERE id=? AND owner_id=?').get(req.params.fid,req.session.userId);
251
  if(!file) return res.status(404).send('Not found');
@@ -259,16 +304,13 @@ app.get('/api/fs/preview/:fid', requireAuth, async (req,res)=>{
259
  else await streamFull(res, file, chunks, getFileKey(file.id, SESSION_SECRET));
260
  });
261
 
262
- // DELETE
263
  app.delete('/api/fs/:fid', requireAuth, async (req,res)=>{
264
  const file=db.prepare('SELECT*FROM files WHERE id=? AND owner_id=?').get(req.params.fid,req.session.userId); if(!file) return res.status(404).json({error:'Introuvable'});
265
  const chunks=db.prepare('SELECT imgbb_delete_url, idx FROM chunks WHERE file_id=?').all(file.id); chunks.forEach(c=>{imgbbDelete(c.imgbb_delete_url); chunkCache._del(`${file.id}:${c.idx}`);});
266
  db.prepare('DELETE FROM files WHERE id=?').run(file.id); res.json({ok:true,deletedChunks:chunks.length});
267
  });
268
 
269
- // ============================================================
270
- // SHARES (PUBLIC)
271
- // ============================================================
272
  app.post('/api/fs/share/:fid', requireAuth, (req,res)=>{ const file=db.prepare('SELECT id FROM files WHERE id=? AND owner_id=? AND mime!=?').get(req.params.fid, req.session.userId, 'application/vnd.pixeldrive.folder'); if(!file) return res.status(404).json({error:'Fichier introuvable ou dossier'}); const {type}=req.body; if(!['download','embed'].includes(type)) return res.status(400).json({error:'Type invalide'}); let share=db.prepare('SELECT token FROM shares WHERE file_id=? AND type=?').get(file.id,type); if(!share){const token=randomBytes(16).toString('hex');db.prepare('INSERT INTO shares(token,file_id,type)VALUES(?,?,?)').run(token,file.id,type);share={token};} const base=`${req.protocol}://${req.get('host')}`; const url=`${base}/s/${type[0]}/${share.token}`; res.json({ok:true,url,type,token:share.token}); });
273
  app.get('/api/fs/shares/:fid', requireAuth, (req,res)=>{ const file=db.prepare('SELECT id FROM files WHERE id=? AND owner_id=?').get(req.params.fid,req.session.userId); if(!file) return res.status(404).json({error:'Introuvable'}); const shares=db.prepare('SELECT token,type,created_at FROM shares WHERE file_id=?').all(file.id); const base=`${req.protocol}://${req.get('host')}`; res.json({shares:shares.map(s=>({...s,url:`${base}/s/${s.type[0]}/${s.token}`}))}); });
274
  app.delete('/api/fs/share/:fid/:type', requireAuth, (req,res)=>{ const file=db.prepare('SELECT id FROM files WHERE id=? AND owner_id=?').get(req.params.fid,req.session.userId); if(!file) return res.status(404).json({error:'Introuvable'}); db.prepare('DELETE FROM shares WHERE file_id=? AND type=?').run(file.id,req.params.type); res.json({ok:true}); });
@@ -285,6 +327,6 @@ app.get('/api/admin/stats', requireAuth, requireAdmin, (req,res)=>{ const u=db.p
285
  // STATIC
286
  app.get('/', (req,res)=>res.sendFile(join(__dirname, 'public.html')));
287
 
288
- const server=app.listen(PORT,'0.0.0.0',()=>console.log(`🚀 PixelDrive v2.1 running on http://0.0.0.0:${PORT} | Cache: 12GB LRU`));
289
  process.on('SIGTERM',()=>{server.close(()=>{db.close();process.exit(0);});});
290
  process.on('SIGINT',()=>{server.close(()=>{db.close();process.exit(0);});});
 
1
  // ============================================================
2
+ // PIXELDRIVE v2.2 - Sharp Streaming Pipeline (Fast & Stable)
3
  // ============================================================
4
 
5
  import express from 'express';
6
  import session from 'express-session';
7
  import multer from 'multer';
8
  import Database from 'better-sqlite3';
9
+ import sharp from 'sharp'; // <--- REMPLACE pngjs
10
  import Bottleneck from 'bottleneck';
11
  import { randomBytes, createHash, scrypt, timingSafeEqual, createCipheriv, createDecipheriv } from 'crypto';
12
  import { promisify } from 'util';
 
14
  import { dirname, join } from 'path';
15
  import { v4 as uuidv4 } from 'uuid';
16
  import { unlink } from 'fs/promises';
17
+ import { pipeline, Transform } from 'stream';
18
+ import { promisify as pify } from 'util';
19
 
20
+ const pipelineAsync = pify(pipeline);
21
  const scryptAsync = promisify(scrypt);
22
  const getFileKey = (fid, secret) => createHash('sha256').update(`${secret}:file:${fid}`).digest();
23
 
 
26
  const DB_PATH = `${DATA_DIR}/pixeldrive.db`;
27
  const IMGBB_API_KEY = process.env.IMGBB_API_KEY;
28
  const PORT = parseInt(process.env.PORT) || 7860;
29
+ const CHUNK_TARGET_SIZE = 31 * 1024 * 1024; // 31 Mo
30
+ const PNG_BPP = 4; // RGBA
31
+ const SESSION_SECRET = process.env.SESSION_SECRET;
32
+ if (!SESSION_SECRET || SESSION_SECRET.length < 32) { console.error("❌ SESSION_SECRET manquant ou <32 chars dans .env"); process.exit(1); }
33
  const ADMIN_USER = process.env.ADMIN_USER || 'admin';
34
  const ADMIN_PASS = process.env.ADMIN_PASS || 'changeme';
35
+ if (!IMGBB_API_KEY) { console.error("❌ IMGBB_API_KEY manquante !"); process.exit(1); }
36
 
37
  // CACHE CONFIG
38
+ const CACHE_MAX_BYTES = 12 * 1024 * 1024 * 1024;
39
+ const CACHE_TTL_MS = 15 * 60 * 1000;
 
 
40
 
41
  // ============================================================
42
+ // LRU CACHE
43
  // ============================================================
44
  class ChunkCache {
45
+ constructor(maxBytes, ttlMs) { this.maxBytes = maxBytes; this.ttlMs = ttlMs; this.map = new Map(); this.currentBytes = 0; setInterval(() => this._cleanup(), 60000).unref(); }
 
 
 
 
 
46
  _key(fid, idx) { return `${fid}:${idx}`; }
47
  _cleanup() { const now = Date.now(); for(const [k, v] of this.map) if(now - v.at > this.ttlMs) this._del(k); }
48
  _del(key) { const v = this.map.get(key); if(v) { this.currentBytes -= v.size; this.map.delete(key); } }
 
81
  if (!adminExists) { const salt = randomBytes(16); const hash = await scryptAsync(ADMIN_PASS, salt, 32); db.prepare('INSERT INTO users (username, password_hash, salt, is_admin) VALUES (?, ?, ?, 1)').run(ADMIN_USER, hash, salt); console.log(`✅ Admin: ${ADMIN_USER} / ${ADMIN_PASS}`); }
82
 
83
  // ============================================================
84
+ // CRYPTO STREAMS
85
  // ============================================================
86
+ class EncryptTransform extends Transform {
87
+ constructor(key, iv) { super(); this.cipher = createCipheriv('aes-256-gcm', key, iv); this.iv = iv; this.tag = null; this.started = false; }
88
+ _transform(chunk, enc, cb) { try { if(!this.started) { this.started = true; this.push(this.iv); } this.push(this.cipher.update(chunk)); cb(); } catch(e) { cb(e); } }
89
+ _flush(cb) { try { this.push(this.cipher.final()); this.tag = this.cipher.getAuthTag(); this.push(this.tag); cb(); } catch(e) { cb(e); } }
90
+ }
91
+
92
+ class DecryptTransform extends Transform {
93
+ constructor(key, iv, tag) { super(); this.decipher = createDecipheriv('aes-256-gcm', key, iv); this.decipher.setAuthTag(tag); }
94
+ _transform(chunk, enc, cb) { try { this.push(this.decipher.update(chunk)); cb(); } catch(e) { cb(e); } }
95
+ _flush(cb) { try { this.push(this.decipher.final()); cb(); } catch(e) { cb(e); } }
96
+ }
97
 
98
  // ============================================================
99
  // IMGBB QUEUE
 
103
  async function imgbbDelete(u) { if(!u) return; return imgbbLimiter.schedule(async()=>{try{await fetch(u,{method:'GET'})}catch(e){}}); }
104
 
105
  // ============================================================
106
+ // UPLOAD STATUS
107
  // ============================================================
108
  const uploadStatus = new Map();
109
  function setStatus(fid, data) { uploadStatus.set(fid, { ...uploadStatus.get(fid), ...data, updated: Date.now() }); }
110
  function getStatus(fid) { return uploadStatus.get(fid) || { stage: 'waiting' }; }
111
+ function clearStatus(fid) { setTimeout(()=>uploadStatus.delete(fid), 60000); }
112
 
113
  // ============================================================
114
+ // HELPERS RANGE / DECRYPT
115
  // ============================================================
116
  function parseRange(header, total) { if(!header) return null; const parts = header.replace('bytes=', '').split('-'); const start = parseInt(parts[0], 10); const end = parts[1] ? parseInt(parts[1], 10) : total - 1; if(isNaN(start)||isNaN(end)||start>end||end>=total) return null; return { start, end, length: end - start + 1 }; }
117
 
 
118
  async function getDecryptedChunk(fileId, chunkIdx, ch, fileKey) {
 
119
  const cached = chunkCache.get(fileId, chunkIdx);
120
  if(cached) return cached;
 
 
121
  const r = await fetch(ch.imgbb_url);
122
  if(!r.ok) throw new Error(`IMGBB DL Failed: ${r.status}`);
123
  const pngBuf = Buffer.from(await r.arrayBuffer());
124
+
125
+ // Sharp pour décoder PNG -> Raw RGB (Buffer)
126
+ const { data, info } = await sharp(pngBuf).ensureAlpha().raw().toBuffer({ resolveWithObject: true });
127
+ // data.length = width * height * 4. Mais payload réel = ch.byte_size.
128
+ const payload = data.subarray(0, ch.byte_size);
129
+
130
  const sha = createHash('sha256').update(payload).digest('hex');
131
+ if(sha !== ch.sha256) throw new Error(`Hash Mismatch Chunk ${chunkIdx}`);
132
+
 
133
  const iv = payload.subarray(0, 12);
134
  const tag = payload.subarray(12, 28);
135
  const enc = payload.subarray(28);
136
  const decipher = createDecipheriv('aes-256-gcm', fileKey, iv);
137
  decipher.setAuthTag(tag);
138
  const decrypted = Buffer.concat([decipher.update(enc), decipher.final()]);
139
+
 
140
  chunkCache.set(fileId, chunkIdx, decrypted);
141
  return decrypted;
142
  }
143
 
 
144
  async function streamFull(res, file, chunks, fileKey) {
145
+ res.setHeader('Content-Length', file.size); res.setHeader('Accept-Ranges', 'bytes');
 
146
  for(const ch of chunks) { res.write(await getDecryptedChunk(file.id, ch.idx, ch, fileKey)); }
147
  res.end();
148
  }
149
 
 
150
  async function streamRange(res, file, chunks, fileKey, range) {
151
  const { start, end, length } = range;
152
+ res.status(206); res.setHeader('Content-Range', `bytes ${start}-${end}/${file.size}`); res.setHeader('Accept-Ranges', 'bytes'); res.setHeader('Content-Length', length); res.setHeader('Content-Type', file.mime);
 
 
 
 
 
153
  let currentByte = 0;
154
  for(const ch of chunks) {
155
  const decryptedBuf = await getDecryptedChunk(file.id, ch.idx, ch, fileKey);
156
+ const chunkSize = decryptedBuf.length;
157
+ const chunkStart = currentByte; const chunkEnd = currentByte + chunkSize - 1;
158
+ if(chunkEnd < start) { currentByte += chunkSize; continue; }
159
+ if(chunkStart > end) break;
 
 
 
 
160
  const neededStart = Math.max(0, start - chunkStart);
161
  const neededEnd = Math.min(chunkSize - 1, end - chunkStart);
162
  res.write(decryptedBuf.subarray(neededStart, neededEnd + 1));
 
175
  const requireAuth = (req,res,next) => req.session.userId ? next() : res.status(401).json({error:'Non connecté'});
176
  const requireAdmin = (req,res,next) => req.session.isAdmin ? next() : res.status(403).json({error:'Admin requis'});
177
 
178
+ // AUTH
 
 
179
  app.post('/api/auth/register', async (req,res)=>{ const {username,password}=req.body; if(!username||!password) return res.status(400).json({error:'Champs manquants'}); try{const salt=randomBytes(16);const hash=await scryptAsync(password,salt,32);db.prepare('INSERT INTO users(username,password_hash,salt)VALUES(?,?,?)').run(username,hash,salt);res.json({ok:true});}catch(e){res.status(409).json({error:'Existe'});} });
180
  app.post('/api/auth/login', async (req,res)=>{ const {username,password}=req.body; const u=db.prepare('SELECT*FROM users WHERE username=?').get(username); if(!u) return res.status(401).json({error:'Invalide'}); const h=await scryptAsync(password,u.salt,32); if(!timingSafeEqual(h,u.password_hash)) return res.status(401).json({error:'Invalide'}); req.session.userId=u.id;req.session.isAdmin=!!u.is_admin;req.session.username=u.username; res.json({ok:true,user:{username:u.username,isAdmin:!!u.is_admin}}); });
181
  app.post('/api/auth/logout', (req,res)=>req.session.destroy(()=>res.json({ok:true})));
182
  app.get('/api/auth/me', (req,res)=>req.session.userId?res.json({user:{username:req.session.username,isAdmin:req.session.isAdmin}}):res.status(401).json({error:'Non connecté'}));
183
 
184
+ // FS LIST / MKDIR
 
 
185
  app.get('/api/fs/list', requireAuth, (req,res)=>{ const pid=req.query.parent_id||null; const items=db.prepare('SELECT id,name,mime,size,chunk_count,created_at FROM files WHERE owner_id=? AND (parent_id IS ? OR parent_id=?) ORDER BY (mime=?) DESC,name').all(req.session.userId,pid,pid,'application/vnd.pixeldrive.folder'); res.json({items}); });
186
  app.post('/api/fs/mkdir', requireAuth, (req,res)=>{ const {name,parent_id}=req.body; if(!name||name.includes('/')) return res.status(400).json({error:'Nom invalide'}); const id=uuidv4(); db.prepare('INSERT INTO files(id,owner_id,parent_id,name,mime)VALUES(?,?,?,?,?)').run(id,req.session.userId,parent_id||null,name,'application/vnd.pixeldrive.folder'); res.json({ok:true,id}); });
187
 
188
+ // ============================================================
189
+ // UPLOAD : PIPELINE STREAMING (SHARP + CRYPTO)
190
+ // ============================================================
191
  app.post('/api/fs/upload', requireAuth, upload.single('file'), async (req,res)=>{
192
  if(!req.file) return res.status(400).json({error:'Fichier manquant'});
193
  const pid=req.body.parent_id||null; const name=req.file.originalname; const mime=req.file.mimetype||'application/octet-stream';
194
  const tmpPath=req.file.path; const fid=uuidv4(); const fKey=getFileKey(fid, SESSION_SECRET); const iv=randomBytes(12);
195
+
196
+ setStatus(fid, { owner: req.session.userId, stage: 'starting', progress: 0, total: req.file.size, current: 0, fileName: name, startTime: Date.now(), speed: 0, eta: 0, chunksTotal: 0, chunksDone: 0 });
197
+
198
  try{
199
  db.prepare('INSERT INTO files(id,owner_id,parent_id,name,mime,size,chunk_count)VALUES(?,?,?,?,?,0,0)').run(fid,req.session.userId,pid,name,mime);
200
+
201
+ const fs = await import('fs');
202
+ const readStream = fs.createReadStream(tmpPath, { highWaterMark: 1024 * 1024 }); // 1MB read buffer
203
+
204
+ let totalBytes = 0;
205
+ let chunkIdx = 0;
206
+ let currentChunkBuffer = Buffer.alloc(0);
207
+ const startTime = Date.now();
208
+
209
+ setStatus(fid, { stage: 'uploading', chunksTotal: Math.ceil(req.file.size / CHUNK_TARGET_SIZE) });
210
+
211
+ // Boucle de lecture fichier source
212
+ for await (const chunk of readStream) {
213
+ currentChunkBuffer = Buffer.concat([currentChunkBuffer, chunk]);
214
+ totalBytes += chunk.length;
215
+
216
+ const elapsed = (Date.now() - startTime) / 1000;
217
+ const speed = elapsed > 0 ? totalBytes / elapsed / 1024 / 1024 : 0;
218
+ const eta = speed > 0 ? (req.file.size - totalBytes) / speed / 1024 / 1024 : 0;
219
+ setStatus(fid, { progress: totalBytes, current: totalBytes, speed, eta });
220
+
221
+ // Traiter les chunks complets
222
+ while (currentChunkBuffer.length >= CHUNK_TARGET_SIZE) {
223
+ const rawChunk = currentChunkBuffer.subarray(0, CHUNK_TARGET_SIZE);
224
+ currentChunkBuffer = currentChunkBuffer.subarray(CHUNK_TARGET_SIZE);
225
+ await processAndUploadChunk(rawChunk, chunkIdx++);
226
+ setStatus(fid, { chunksDone: chunkIdx, currentChunk: chunkIdx });
227
+ }
228
+ }
229
+
230
+ // Dernier chunk
231
+ if (currentChunkBuffer.length > 0) {
232
+ await processAndUploadChunk(currentChunkBuffer, chunkIdx++);
233
+ setStatus(fid, { chunksDone: chunkIdx, currentChunk: chunkIdx });
234
+ }
235
+
236
  setStatus(fid, { stage: 'finalizing', progress: req.file.size });
237
+ db.prepare('UPDATE files SET size=?,chunk_count=? WHERE id=?').run(totalBytes, chunkIdx, fid);
238
+
239
+ setStatus(fid, { stage: 'done', progress: totalBytes, chunksDone: chunkIdx, chunksTotal: chunkIdx }); clearStatus(fid);
240
+ res.json({ok:true,fileId:fid,chunks:chunkIdx,size:totalBytes});
241
+
242
+ }catch(e){ console.error('[UPLOAD]', e); setStatus(fid,{stage:'error',error:e.message}); clearStatus(fid); res.status(500).json({error:e.message}); }
243
  finally{ try{await unlink(tmpPath);}catch(e){} }
244
 
245
+ // --- Traitement 1 Chunk : Encrypt -> Sharp PNG -> IMGBB ---
246
+ async function processAndUploadChunk(rawChunk, idx) {
247
+ setStatus(fid, { stage: 'processing_chunk', currentChunk: idx + 1 });
248
+
249
+ // 1. Chiffrement Stream (Raw -> Encrypted Payload IV+TAG+DATA)
250
+ // On utilise un pipeline en mémoire pour récupérer le buffer chiffré complet
251
+ // Car Sharp a besoin du buffer complet pour connaître dimensions (ou on calcule nous-même)
252
+ // Ici on chiffre en mémoire (31Mo max, OK pour RAM)
253
+ const cipher = createCipheriv('aes-256-gcm', fKey, iv);
254
+ const encrypted = Buffer.concat([cipher.update(rawChunk), cipher.final()]);
255
+ const tag = cipher.getAuthTag();
256
+ const payload = Buffer.concat([iv, tag, encrypted]); // Payload complet
257
+
258
+ // 2. Sharp PNG Encoding (Streaming/Buffer -> PNG Buffer)
259
+ // Calcul dimensions optimales
260
+ const pixelCount = Math.ceil(payload.length / PNG_BPP);
261
+ const w = Math.ceil(Math.sqrt(pixelCount));
262
+ const h = Math.ceil(pixelCount / w);
263
+
264
+ // Sharp: Raw RGBA Buffer -> PNG Buffer (Très rapide, natif)
265
+ const pngBuf = await sharp(payload, { raw: { width: w, height: h, channels: 4 } })
266
+ .png({ compressionLevel: 3, palette: false }) // Level 3 = bon compromis vitesse/taille
267
+ .toBuffer();
268
+
269
+ // 3. Upload IMGBB
270
+ setStatus(fid, { stage: 'uploading_imgbb', currentChunk: idx + 1 });
271
+ const { url, delete_url, width, height } = await imgbbUpload(pngBuf);
272
+
273
+ // 4. Hash & DB
274
+ const sha = createHash('sha256').update(payload).digest('hex');
275
+ db.prepare('INSERT INTO chunks(file_id,idx,imgbb_url,imgbb_delete_url,sha256,byte_size,pixel_w,pixel_h)VALUES(?,?,?,?,?,?,?,?)')
276
+ .run(fid, idx, url, delete_url, sha, payload.length, width, height);
277
  }
278
  });
279
 
280
  app.get('/api/fs/upload/status/:fid', requireAuth, (req,res)=>{ const s=getStatus(req.params.fid); if(!s||s.owner!==req.session.userId) return res.status(404).json({error:'Not found'}); res.json(s); });
281
 
282
+ // DOWNLOAD / PREVIEW (Identique, utilise getDecryptedChunk optimisé)
283
  app.get('/api/fs/download/:fid', requireAuth, async (req,res)=>{
284
  const file=db.prepare('SELECT*FROM files WHERE id=? AND owner_id=?').get(req.params.fid,req.session.userId);
285
  if(!file) return res.status(404).json({error:'Introuvable'}); if(file.mime==='application/vnd.pixeldrive.folder') return res.status(400).json({error:'Dossier'});
 
291
  else await streamFull(res, file, chunks, getFileKey(file.id, SESSION_SECRET));
292
  });
293
 
 
294
  app.get('/api/fs/preview/:fid', requireAuth, async (req,res)=>{
295
  const file=db.prepare('SELECT*FROM files WHERE id=? AND owner_id=?').get(req.params.fid,req.session.userId);
296
  if(!file) return res.status(404).send('Not found');
 
304
  else await streamFull(res, file, chunks, getFileKey(file.id, SESSION_SECRET));
305
  });
306
 
 
307
  app.delete('/api/fs/:fid', requireAuth, async (req,res)=>{
308
  const file=db.prepare('SELECT*FROM files WHERE id=? AND owner_id=?').get(req.params.fid,req.session.userId); if(!file) return res.status(404).json({error:'Introuvable'});
309
  const chunks=db.prepare('SELECT imgbb_delete_url, idx FROM chunks WHERE file_id=?').all(file.id); chunks.forEach(c=>{imgbbDelete(c.imgbb_delete_url); chunkCache._del(`${file.id}:${c.idx}`);});
310
  db.prepare('DELETE FROM files WHERE id=?').run(file.id); res.json({ok:true,deletedChunks:chunks.length});
311
  });
312
 
313
+ // SHARES
 
 
314
  app.post('/api/fs/share/:fid', requireAuth, (req,res)=>{ const file=db.prepare('SELECT id FROM files WHERE id=? AND owner_id=? AND mime!=?').get(req.params.fid, req.session.userId, 'application/vnd.pixeldrive.folder'); if(!file) return res.status(404).json({error:'Fichier introuvable ou dossier'}); const {type}=req.body; if(!['download','embed'].includes(type)) return res.status(400).json({error:'Type invalide'}); let share=db.prepare('SELECT token FROM shares WHERE file_id=? AND type=?').get(file.id,type); if(!share){const token=randomBytes(16).toString('hex');db.prepare('INSERT INTO shares(token,file_id,type)VALUES(?,?,?)').run(token,file.id,type);share={token};} const base=`${req.protocol}://${req.get('host')}`; const url=`${base}/s/${type[0]}/${share.token}`; res.json({ok:true,url,type,token:share.token}); });
315
  app.get('/api/fs/shares/:fid', requireAuth, (req,res)=>{ const file=db.prepare('SELECT id FROM files WHERE id=? AND owner_id=?').get(req.params.fid,req.session.userId); if(!file) return res.status(404).json({error:'Introuvable'}); const shares=db.prepare('SELECT token,type,created_at FROM shares WHERE file_id=?').all(file.id); const base=`${req.protocol}://${req.get('host')}`; res.json({shares:shares.map(s=>({...s,url:`${base}/s/${s.type[0]}/${s.token}`}))}); });
316
  app.delete('/api/fs/share/:fid/:type', requireAuth, (req,res)=>{ const file=db.prepare('SELECT id FROM files WHERE id=? AND owner_id=?').get(req.params.fid,req.session.userId); if(!file) return res.status(404).json({error:'Introuvable'}); db.prepare('DELETE FROM shares WHERE file_id=? AND type=?').run(file.id,req.params.type); res.json({ok:true}); });
 
327
  // STATIC
328
  app.get('/', (req,res)=>res.sendFile(join(__dirname, 'public.html')));
329
 
330
+ const server=app.listen(PORT,'0.0.0.0',()=>console.log(`🚀 PixelDrive v2.2 (Sharp) running on http://0.0.0.0:${PORT} | Cache: 12GB LRU`));
331
  process.on('SIGTERM',()=>{server.close(()=>{db.close();process.exit(0);});});
332
  process.on('SIGINT',()=>{server.close(()=>{db.close();process.exit(0);});});