Pixel-Drive / server.js
NathMen12's picture
Update server.js
3eb0321 verified
Raw
History Blame
24 kB
// ============================================================
// 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);});});