Spaces:
Paused
Paused
| // ============================================================ | |
| // PIXELDRIVE v2.1 - Fix Range Decrypt, Robust Cache, Icons | |
| // ============================================================ | |
| import express from 'express'; | |
| import session from 'express-session'; | |
| import multer from 'multer'; | |
| import Database from 'better-sqlite3'; | |
| import { PNG } from 'pngjs'; | |
| import Bottleneck from 'bottleneck'; | |
| import { randomBytes, createHash, scrypt, timingSafeEqual, createCipheriv, createDecipheriv } from 'crypto'; | |
| import { promisify } from 'util'; | |
| import { fileURLToPath } from 'url'; | |
| import { dirname, join } from 'path'; | |
| import { v4 as uuidv4 } from 'uuid'; | |
| import { unlink } from 'fs/promises'; | |
| // --- CRYPTO & CONFIG --- | |
| const scryptAsync = promisify(scrypt); | |
| const getFileKey = (fid, secret) => createHash('sha256').update(`${secret}:file:${fid}`).digest(); | |
| const __dirname = dirname(fileURLToPath(import.meta.url)); | |
| const DATA_DIR = process.env.DATA_DIR || '/data'; | |
| const DB_PATH = `${DATA_DIR}/pixeldrive.db`; | |
| const IMGBB_API_KEY = process.env.IMGBB_API_KEY; | |
| const PORT = parseInt(process.env.PORT) || 7860; | |
| const CHUNK_TARGET_SIZE = 31 * 1024 * 1024; | |
| const PNG_BPP = 4; | |
| const SESSION_SECRET = process.env.SESSION_SECRET || randomBytes(32).toString('hex'); | |
| const ADMIN_USER = process.env.ADMIN_USER || 'admin'; | |
| const ADMIN_PASS = process.env.ADMIN_PASS || 'changeme'; | |
| // CACHE CONFIG | |
| const CACHE_MAX_BYTES = 12 * 1024 * 1024 * 1024; // 12 Go | |
| const CACHE_TTL_MS = 15 * 60 * 1000; // 15 min | |
| if (!IMGBB_API_KEY) { console.error("❌ IMGBB_API_KEY manquante !"); process.exit(1); } | |
| // ============================================================ | |
| // LRU CACHE AVEC TAILLE RÉELLE (Buffer.length) | |
| // ============================================================ | |
| class ChunkCache { | |
| constructor(maxBytes, ttlMs) { | |
| this.maxBytes = maxBytes; this.ttlMs = ttlMs; | |
| this.map = new Map(); // key -> {buf, size, at} | |
| this.currentBytes = 0; | |
| setInterval(() => this._cleanup(), 60000).unref(); | |
| } | |
| _key(fid, idx) { return `${fid}:${idx}`; } | |
| _cleanup() { const now = Date.now(); for(const [k, v] of this.map) if(now - v.at > this.ttlMs) this._del(k); } | |
| _del(key) { const v = this.map.get(key); if(v) { this.currentBytes -= v.size; this.map.delete(key); } } | |
| _evict() { while(this.currentBytes > this.maxBytes && this.map.size > 0) { const oldest = this.map.keys().next().value; if(oldest) this._del(oldest); else break; } } | |
| get(fid, idx) { const v = this.map.get(this._key(fid, idx)); if(v) { v.at = Date.now(); return v.buf; } return null; } | |
| set(fid, idx, buf) { const key = this._key(fid, idx); const size = buf.length; if(size > this.maxBytes) return; this._del(key); this.map.set(key, {buf, size, at: Date.now()}); this.currentBytes += size; this._evict(); } | |
| } | |
| const chunkCache = new ChunkCache(CACHE_MAX_BYTES, CACHE_TTL_MS); | |
| // ============================================================ | |
| // SQLITE SESSION STORE | |
| // ============================================================ | |
| class SQLiteSessionStore extends session.Store { | |
| constructor(db) { super(); this.db = db; this.db.exec(`CREATE TABLE IF NOT EXISTS sessions (sid TEXT PRIMARY KEY, sess TEXT NOT NULL, expired_at INTEGER NOT NULL); CREATE INDEX IF NOT EXISTS idx_sessions_expired ON sessions(expired_at);`); setInterval(() => this._cleanup(), 3600000).unref(); } | |
| _cleanup() { this.db.prepare('DELETE FROM sessions WHERE expired_at < ?').run(Date.now()); } | |
| get(sid, cb) { try { const row = this.db.prepare('SELECT sess FROM sessions WHERE sid = ? AND expired_at > ?').get(sid, Date.now()); cb(null, row ? JSON.parse(row.sess) : null); } catch(e) { cb(e); } } | |
| set(sid, sess, cb) { try { const expiredAt = Date.now() + (sess.cookie?.maxAge || 86400000); this.db.prepare('INSERT OR REPLACE INTO sessions (sid, sess, expired_at) VALUES (?, ?, ?)').run(sid, JSON.stringify(sess), expiredAt); cb(null); } catch(e) { cb(e); } } | |
| destroy(sid, cb) { try { this.db.prepare('DELETE FROM sessions WHERE sid = ?').run(sid); cb(null); } catch(e) { cb(e); } } | |
| } | |
| // ============================================================ | |
| // DATABASE | |
| // ============================================================ | |
| const db = new Database(DB_PATH); | |
| db.pragma('journal_mode = WAL'); db.pragma('synchronous = NORMAL'); | |
| db.exec(` | |
| CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY, username TEXT UNIQUE, password_hash BLOB, salt BLOB, is_admin INTEGER DEFAULT 0, created_at INTEGER DEFAULT (strftime('%s','now'))); | |
| CREATE TABLE IF NOT EXISTS files (id TEXT PRIMARY KEY, owner_id INTEGER, parent_id TEXT, name TEXT, mime TEXT, size INTEGER, chunk_count INTEGER DEFAULT 0, created_at INTEGER DEFAULT (strftime('%s','now')), FOREIGN KEY(owner_id) REFERENCES users(id), FOREIGN KEY(parent_id) REFERENCES files(id)); | |
| CREATE TABLE IF NOT EXISTS chunks (id INTEGER PRIMARY KEY, file_id TEXT, idx INTEGER, imgbb_url TEXT, imgbb_delete_url TEXT, sha256 TEXT, byte_size INTEGER, pixel_w INTEGER, pixel_h INTEGER, FOREIGN KEY(file_id) REFERENCES files(id) ON DELETE CASCADE); | |
| CREATE TABLE IF NOT EXISTS shares (id INTEGER PRIMARY KEY, token TEXT UNIQUE, file_id TEXT, type TEXT CHECK(type IN ('download','embed')), created_at INTEGER DEFAULT (strftime('%s','now')), FOREIGN KEY(file_id) REFERENCES files(id) ON DELETE CASCADE); | |
| CREATE INDEX IF NOT EXISTS idx_files_parent ON files(parent_id); | |
| CREATE INDEX IF NOT EXISTS idx_chunks_file ON chunks(file_id); | |
| CREATE INDEX IF NOT EXISTS idx_shares_token ON shares(token); | |
| `); | |
| const adminExists = db.prepare('SELECT 1 FROM users WHERE username = ?').get(ADMIN_USER); | |
| 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}`); } | |
| // ============================================================ | |
| // PNG ENGINE | |
| // ============================================================ | |
| 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(); }); } | |
| 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); }); } | |
| // ============================================================ | |
| // IMGBB QUEUE | |
| // ============================================================ | |
| const imgbbLimiter = new Bottleneck({ minTime: 3000, maxConcurrent: 1 }); | |
| async function imgbbUpload(buf) { return imgbbLimiter.schedule(async () => { const f=new FormData(); f.append('image',new Blob([buf],{type:'image/png'})); f.append('expiration','0'); const r=await fetch(`https://api.imgbb.com/1/upload?key=${IMGBB_API_KEY}`,{method:'POST',body:f}); const j=await r.json(); if(!j.success) throw new Error(j.error?.message||'IMGBB fail'); return {url:j.data.url, delete_url:j.data.delete_url, width:j.data.width, height:j.data.height}; }); } | |
| async function imgbbDelete(u) { if(!u) return; return imgbbLimiter.schedule(async()=>{try{await fetch(u,{method:'GET'})}catch(e){}}); } | |
| // ============================================================ | |
| // UPLOAD STATUS TRACKER | |
| // ============================================================ | |
| const uploadStatus = new Map(); | |
| function setStatus(fid, data) { uploadStatus.set(fid, { ...uploadStatus.get(fid), ...data, updated: Date.now() }); } | |
| function getStatus(fid) { return uploadStatus.get(fid) || { stage: 'waiting' }; } | |
| function clearStatus(fid) { setTimeout(()=>uploadStatus.delete(fid), 60000); } // Keep for 1min after done | |
| // ============================================================ | |
| // HELPERS: RANGE & STREAMING (CORRIGÉ) | |
| // ============================================================ | |
| 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 }; } | |
| // Core: Get Decrypted Chunk Buffer (Cache or Fetch+Decrypt+Verify) | |
| async function getDecryptedChunk(fileId, chunkIdx, ch, fileKey) { | |
| // 1. Cache | |
| const cached = chunkCache.get(fileId, chunkIdx); | |
| if(cached) return cached; | |
| // 2. Fetch IMGBB | |
| const r = await fetch(ch.imgbb_url); | |
| if(!r.ok) throw new Error(`IMGBB DL Failed: ${r.status}`); | |
| const pngBuf = Buffer.from(await r.arrayBuffer()); | |
| // 3. Decode PNG | |
| const payload = await pngBufferToData(pngBuf, ch.byte_size); | |
| // 4. VERIFY HASH (CRITICAL FOR RANGE REQUESTS) | |
| const sha = createHash('sha256').update(payload).digest('hex'); | |
| if(sha !== ch.sha256) throw new Error(`Hash Mismatch Chunk ${chunkIdx} (Corrupted IMGBB or DB)`); | |
| // 5. Decrypt | |
| const iv = payload.subarray(0, 12); | |
| const tag = payload.subarray(12, 28); | |
| const enc = payload.subarray(28); | |
| const decipher = createDecipheriv('aes-256-gcm', fileKey, iv); | |
| decipher.setAuthTag(tag); | |
| const decrypted = Buffer.concat([decipher.update(enc), decipher.final()]); | |
| // 6. Cache & Return | |
| chunkCache.set(fileId, chunkIdx, decrypted); | |
| return decrypted; | |
| } | |
| // Stream Full (Download) | |
| async function streamFull(res, file, chunks, fileKey) { | |
| res.setHeader('Content-Length', file.size); | |
| res.setHeader('Accept-Ranges', 'bytes'); | |
| for(const ch of chunks) { res.write(await getDecryptedChunk(file.id, ch.idx, ch, fileKey)); } | |
| res.end(); | |
| } | |
| // Stream Range (Video/Audio Seek) - LOGIQUE CORRIGÉE | |
| async function streamRange(res, file, chunks, fileKey, range) { | |
| const { start, end, length } = range; | |
| 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); | |
| let currentByte = 0; | |
| for(const ch of chunks) { | |
| const decryptedBuf = await getDecryptedChunk(file.id, ch.idx, ch, fileKey); | |
| const chunkSize = decryptedBuf.length; // VRAIE TAILLE DÉCHIFFRÉE | |
| const chunkStart = currentByte; | |
| const chunkEnd = currentByte + chunkSize - 1; | |
| if(chunkEnd < start) { currentByte += chunkSize; continue; } // Avant range | |
| if(chunkStart > end) break; // Après range | |
| // Overlap | |
| const neededStart = Math.max(0, start - chunkStart); | |
| const neededEnd = Math.min(chunkSize - 1, end - chunkStart); | |
| res.write(decryptedBuf.subarray(neededStart, neededEnd + 1)); | |
| currentByte += chunkSize; | |
| } | |
| res.end(); | |
| } | |
| // ============================================================ | |
| // EXPRESS APP | |
| // ============================================================ | |
| const app = express(); | |
| app.use(express.json({limit:'50mb'})); app.use(express.urlencoded({extended:true,limit:'50mb'})); | |
| app.use(session({ store: new SQLiteSessionStore(db), secret: SESSION_SECRET, resave: false, saveUninitialized: false, cookie: { httpOnly: true, secure: false, maxAge: 7*86400000, sameSite: 'lax' } })); | |
| const upload = multer({ dest: `${DATA_DIR}/tmp_uploads`, limits: { fileSize: 4 * 1024 * 1024 * 1024 } }); | |
| const requireAuth = (req,res,next) => req.session.userId ? next() : res.status(401).json({error:'Non connecté'}); | |
| const requireAdmin = (req,res,next) => req.session.isAdmin ? next() : res.status(403).json({error:'Admin requis'}); | |
| // ============================================================ | |
| // API AUTH | |
| // ============================================================ | |
| 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'});} }); | |
| 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}}); }); | |
| app.post('/api/auth/logout', (req,res)=>req.session.destroy(()=>res.json({ok:true}))); | |
| 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é'})); | |
| // ============================================================ | |
| // API DRIVE | |
| // ============================================================ | |
| 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}); }); | |
| 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}); }); | |
| // UPLOAD | |
| app.post('/api/fs/upload', requireAuth, upload.single('file'), async (req,res)=>{ | |
| if(!req.file) return res.status(400).json({error:'Fichier manquant'}); | |
| const pid=req.body.parent_id||null; const name=req.file.originalname; const mime=req.file.mimetype||'application/octet-stream'; | |
| const tmpPath=req.file.path; const fid=uuidv4(); const fKey=getFileKey(fid, SESSION_SECRET); const iv=randomBytes(12); | |
| 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 }); | |
| try{ | |
| 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); | |
| 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(); | |
| setStatus(fid, { stage: 'chunking', chunksTotal: Math.ceil(req.file.size / CHUNK_TARGET_SIZE) }); | |
| 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);} } | |
| if(buf.length>0) await processChunk(buf,cIdx); | |
| setStatus(fid, { stage: 'finalizing', progress: req.file.size }); | |
| db.prepare('UPDATE files SET size=?,chunk_count=? WHERE id=?').run(tSize,cIdx,fid); | |
| setStatus(fid, { stage: 'done', progress: tSize, chunksDone: cIdx, chunksTotal: cIdx }); clearStatus(fid); | |
| res.json({ok:true,fileId:fid,chunks:cIdx,size:tSize}); | |
| }catch(e){console.error('[UPLOAD]',e);setStatus(fid,{stage:'error',error:e.message});clearStatus(fid);res.status(500).json({error:e.message});} | |
| finally{ try{await unlink(tmpPath);}catch(e){} } | |
| async function processChunk(raw,idx){ | |
| setStatus(fid, { stage: 'encrypting', currentChunk: idx+1 }); | |
| const cipher=createCipheriv('aes-256-gcm',fKey,iv); const enc=Buffer.concat([cipher.update(raw),cipher.final()]); const tag=cipher.getAuthTag(); | |
| const payload=Buffer.concat([iv,tag,enc]); | |
| setStatus(fid, { stage: 'png_encoding' }); const pngBuf=await dataToPngBuffer(payload); | |
| setStatus(fid, { stage: 'uploading_imgbb', currentChunk: idx+1 }); const {url,delete_url,width,height}=await imgbbUpload(pngBuf); | |
| const sha=createHash('sha256').update(payload).digest('hex'); | |
| 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); | |
| setStatus(fid, { chunksDone: idx+1 }); | |
| } | |
| }); | |
| 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); }); | |
| // DOWNLOAD (Range Support) | |
| app.get('/api/fs/download/:fid', requireAuth, async (req,res)=>{ | |
| 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'}); if(file.mime==='application/vnd.pixeldrive.folder') return res.status(400).json({error:'Dossier'}); | |
| const chunks=db.prepare('SELECT*FROM chunks WHERE file_id=? ORDER BY idx').all(file.id); if(!chunks.length) return res.status(404).json({error:'Corrompu'}); | |
| const range=parseRange(req.headers.range, file.size); | |
| res.setHeader('Content-Disposition', `attachment; filename="${encodeURIComponent(file.name)}"`); | |
| res.setHeader('Content-Type', file.mime); | |
| if(range) await streamRange(res, file, chunks, getFileKey(file.id, SESSION_SECRET), range); | |
| else await streamFull(res, file, chunks, getFileKey(file.id, SESSION_SECRET)); | |
| }); | |
| // PREVIEW (Range Support Critical) | |
| app.get('/api/fs/preview/:fid', requireAuth, async (req,res)=>{ | |
| 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).send('Not found'); | |
| const embed=['image/','video/','audio/','application/pdf']; if(!embed.some(t=>file.mime.startsWith(t))) return res.status(400).send('Non prévisualisable'); | |
| if(file.chunk_count===1 && file.mime.startsWith('image/')){ const ch=db.prepare('SELECT imgbb_url FROM chunks WHERE file_id=?').get(file.id); if(ch) return res.redirect(302,ch.imgbb_url); } | |
| const chunks=db.prepare('SELECT*FROM chunks WHERE file_id=? ORDER BY idx').all(file.id); | |
| const range=parseRange(req.headers.range, file.size); | |
| res.setHeader('Content-Disposition', `inline; filename="${encodeURIComponent(file.name)}"`); | |
| res.setHeader('Content-Type', file.mime); res.setHeader('Accept-Ranges', 'bytes'); | |
| if(range) await streamRange(res, file, chunks, getFileKey(file.id, SESSION_SECRET), range); | |
| else await streamFull(res, file, chunks, getFileKey(file.id, SESSION_SECRET)); | |
| }); | |
| // DELETE | |
| app.delete('/api/fs/:fid', requireAuth, async (req,res)=>{ | |
| 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'}); | |
| 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}`);}); | |
| db.prepare('DELETE FROM files WHERE id=?').run(file.id); res.json({ok:true,deletedChunks:chunks.length}); | |
| }); | |
| // ============================================================ | |
| // SHARES (PUBLIC) | |
| // ============================================================ | |
| 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}); }); | |
| 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}`}))}); }); | |
| 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}); }); | |
| // PUBLIC ROUTES | |
| async function resolveShare(req,res,next){ const {token}=req.params; const share=db.prepare('SELECT s.*,f.name,f.mime,f.size,f.chunk_count,f.id as file_id FROM shares s JOIN files f ON s.file_id=f.id WHERE s.token=?').get(token); if(!share) return res.status(404).send('Lien invalide ou expiré'); const chunks=db.prepare('SELECT*FROM chunks WHERE file_id=? ORDER BY idx').all(share.file_id); if(!chunks.length) return res.status(404).send('Fichier corrompu'); const fileKey=createHash('sha256').update(`${SESSION_SECRET}:file:${share.file_id}`).digest(); req.share={...share,chunks,fileKey}; next(); } | |
| app.get('/s/d/:token', resolveShare, async (req,res)=>{ const range=parseRange(req.headers.range, req.share.size); res.setHeader('Content-Disposition', `attachment; filename="${encodeURIComponent(req.share.name)}"`); res.setHeader('Content-Type', req.share.mime); res.setHeader('Accept-Ranges', 'bytes'); if(range) await streamRange(res, req.share, req.share.chunks, req.share.fileKey, range); else await streamFull(res, req.share, req.share.chunks, req.share.fileKey); }); | |
| app.get('/s/e/:token', resolveShare, async (req,res)=>{ if(req.share.chunk_count===1&&req.share.mime.startsWith('image/')){const ch=req.share.chunks[0];return res.redirect(302,ch.imgbb_url);} const range=parseRange(req.headers.range, req.share.size); res.setHeader('Content-Disposition', `inline; filename="${encodeURIComponent(req.share.name)}"`); res.setHeader('Content-Type', req.share.mime); res.setHeader('Accept-Ranges', 'bytes'); if(range) await streamRange(res, req.share, req.share.chunks, req.share.fileKey, range); else await streamFull(res, req.share, req.share.chunks, req.share.fileKey); }); | |
| // ADMIN | |
| app.get('/api/admin/stats', requireAuth, requireAdmin, (req,res)=>{ const u=db.prepare('SELECT COUNT(*) as c FROM users').get().c; const f=db.prepare('SELECT COUNT(*) as c, SUM(size) as s FROM files WHERE mime!=?').get('application/vnd.pixeldrive.folder'); const c=db.prepare('SELECT COUNT(*) as c FROM chunks').get().c; const s=db.prepare('SELECT COUNT(*) as c FROM shares').get().c; res.json({users:u,files:f.c,totalSize:f.s||0,chunks:c,shares:s, cacheMB: Math.round(chunkCache.currentBytes/1024/1024) }); }); | |
| // STATIC | |
| app.get('/', (req,res)=>res.sendFile(join(__dirname, 'public.html'))); | |
| 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`)); | |
| process.on('SIGTERM',()=>{server.close(()=>{db.close();process.exit(0);});}); | |
| process.on('SIGINT',()=>{server.close(()=>{db.close();process.exit(0);});}); |