Spaces:
Runtime error
Runtime error
| // ========================================== | |
| // PIXELDRIVE v3.2 - SERVER.JS (ESM) | |
| // ========================================== | |
| import express from 'express'; | |
| import session from 'express-session'; | |
| import Database from 'better-sqlite3'; | |
| import multer from 'multer'; | |
| import { createHash, randomBytes, scrypt, createCipheriv, createDecipheriv } from 'crypto'; | |
| import { promisify } from 'util'; | |
| import { createReadStream, unlinkSync, existsSync } from 'fs'; | |
| import { join, extname } from 'path'; | |
| import { v4 as uuidv4 } from 'uuid'; | |
| import LRUCache from 'quick-lru'; | |
| import sharp from 'sharp'; | |
| import * as tf from '@tensorflow/tfjs'; | |
| import * as nsfwjs from 'nsfwjs'; | |
| import axios from 'axios'; | |
| import FormData from 'form-data'; | |
| import { fileURLToPath } from 'url'; | |
| const __dirname = fileURLToPath(new URL('.', import.meta.url)); | |
| const PORT = 7860; | |
| const DATA_DIR = '/data'; | |
| const TEMP_DIR = join(DATA_DIR, 'temp'); | |
| const THUMB_DIR = join(DATA_DIR, 'thumbs'); | |
| const DB_PATH = join(DATA_DIR, 'pixeldrive.db'); | |
| const CHUNK_SIZE = 30 * 1024 * 1024; | |
| const MAX_CONCURRENT_UPLOADS = 2; | |
| const CACHE_MAX_BYTES = 12 * 1024 * 1024 * 1024; | |
| const KEY_LEN = 32; | |
| const IV_LEN = 12; | |
| const TAG_LEN = 16; | |
| const OVERHEAD = IV_LEN + TAG_LEN; | |
| // --- ENV --- | |
| const IMGBB_API_KEY = process.env.IMGBB_API_KEY; | |
| const SESSION_SECRET = process.env.SESSION_SECRET; | |
| const ADMIN_USER = process.env.ADMIN_USER || 'admin'; | |
| if (!IMGBB_API_KEY || !SESSION_SECRET) { | |
| console.error('❌ ERREUR: Variables manquantes: IMGBB_API_KEY, SESSION_SECRET'); | |
| process.exit(1); | |
| } | |
| // --- DATABASE INSTANCE (DÉCLARÉE ICI, AVANT TOUT USAGE) --- | |
| const db = new Database(DB_PATH); | |
| db.pragma('journal_mode = WAL'); | |
| db.pragma('busy_timeout = 5000'); | |
| // --- CRYPTO UTILS --- | |
| const scryptAsync = promisify(scrypt); | |
| async function deriveKey(password, salt) { return scryptAsync(password, salt, KEY_LEN); } | |
| 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]); } | |
| 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()]); } | |
| // --- PNG STEGANOGRAPHY --- | |
| function bufferToPng(buffer) { const byteLen = buffer.length; const pixelsNeeded = Math.ceil(byteLen / 3); const width = Math.ceil(Math.sqrt(pixelsNeeded)); const height = Math.ceil(pixelsNeeded / width); const rgbBuffer = Buffer.alloc(width * height * 3); buffer.copy(rgbBuffer); return sharp(rgbBuffer, { raw: { width, height, channels: 3 } }).png({ compressionLevel: 9, palette: false }).toBuffer(); } | |
| async function pngToBuffer(pngBuffer, expectedLen) { const { data, info } = await sharp(pngBuffer).ensureAlpha().raw().toBuffer({ resolveWithObject: true }); const rgb = Buffer.alloc(info.width * info.height * 3); let j = 0; for (let i = 0; i < data.length; i += 4) { if (j + 2 < rgb.length) { rgb[j++] = data[i]; rgb[j++] = data[i+1]; rgb[j++] = data[i+2]; } } return rgb.subarray(0, expectedLen); } | |
| // ========================================== | |
| // SCHÉMA DE BASE (Tables seulement, PAS d'index sur folder_id) | |
| // ========================================== | |
| db.exec(` | |
| 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')) | |
| ); | |
| 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) | |
| ); | |
| 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')) | |
| ); | |
| 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) | |
| ); | |
| 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 | |
| ); | |
| CREATE INDEX IF NOT EXISTS idx_files_owner ON files(owner_id); | |
| CREATE INDEX IF NOT EXISTS idx_chunks_file ON file_chunks(file_id); | |
| `); | |
| // ========================================== | |
| // MIGRATIONS (Ajoute colonnes manquantes SI table existe déjà) | |
| // ========================================== | |
| function runMigrations() { | |
| console.log('🔍 Vérification migrations BDD...'); | |
| const filesCols = db.prepare("PRAGMA table_info(files)").all().map(c => c.name); | |
| const filesDefs = [ | |
| { name: 'folder_id', def: 'INTEGER REFERENCES folders(id) ON DELETE SET NULL' }, | |
| { name: 'master_key_enc', def: 'BLOB' }, | |
| { name: 'encryption_key_hash', def: 'TEXT' }, | |
| { name: 'status', def: "TEXT DEFAULT 'pending'" }, | |
| { name: 'progress', def: 'REAL DEFAULT 0' }, | |
| { name: 'error_msg', def: 'TEXT' }, | |
| { name: 'chunk_count', def: 'INTEGER DEFAULT 1' }, | |
| { name: 'updated_at', def: "INTEGER DEFAULT (strftime('%s','now'))" }, | |
| ]; | |
| for (const col of filesDefs) { | |
| if (!filesCols.includes(col.name)) { | |
| console.log(`🔧 Migration: ALTER TABLE files ADD COLUMN ${col.name} ${col.def}`); | |
| db.exec(`ALTER TABLE files ADD COLUMN ${col.name} ${col.def};`); | |
| } | |
| } | |
| const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table'").all().map(t => t.name); | |
| if (!tables.includes('folders')) { console.log('🔧 Migration: CREATE TABLE 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));`); } | |
| if (!tables.includes('share_links')) { console.log('🔧 Migration: CREATE TABLE 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);`); } | |
| if (!tables.includes('sessions')) { console.log('🔧 Migration: CREATE TABLE sessions'); 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);`); } | |
| console.log('✅ Migrations terminées.'); | |
| } | |
| runMigrations(); | |
| // ========================================== | |
| // INDEX & TRIGGERS DÉPENDANTS (Maintenant que folder_id existe) | |
| // ========================================== | |
| db.exec(` | |
| CREATE INDEX IF NOT EXISTS idx_files_folder ON files(folder_id); | |
| DROP TRIGGER IF EXISTS update_file_ts; | |
| CREATE TRIGGER update_file_ts AFTER UPDATE ON files BEGIN | |
| UPDATE files SET updated_at = strftime('%s','now') WHERE id = NEW.id; | |
| END; | |
| `); | |
| // --- SESSION STORE (Native) --- | |
| const sessionStore = { | |
| 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); } }, | |
| 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); } }, | |
| destroy(sid, cb) { try { db.prepare('DELETE FROM sessions WHERE sid=?').run(sid); cb(null); } catch(e){ cb(e); } }, | |
| clear(cb) { try { db.prepare('DELETE FROM sessions').run(); cb(null); } catch(e){ cb(e); } }, | |
| length(cb) { try { const r = db.prepare('SELECT COUNT(*) as c FROM sessions').get(); cb(null, r.c); } catch(e){ cb(e); } }, | |
| touch(sid, sess, cb) { this.set(sid, sess, cb); } | |
| }; | |
| // --- ADMIN INIT --- | |
| const adminRow = db.prepare('SELECT * FROM users WHERE username=?').get(ADMIN_USER); | |
| if (!adminRow) { const salt = randomBytes(16); const hash = await deriveKey(process.env.ADMIN_PASS || 'ChangeMeNow!', salt); db.prepare('INSERT INTO users(username,password_hash,salt,is_admin,tos_accepted) VALUES(?,?,?,1,1)').run(ADMIN_USER, hash, salt); console.log(`👑 Admin: ${ADMIN_USER} / Pass: ${process.env.ADMIN_PASS || 'ChangeMeNow!'}`); } | |
| // --- EXPRESS --- | |
| const app = express(); | |
| app.set('trust proxy', 1); | |
| app.use(express.json({ limit: '50mb' })); | |
| app.use(express.urlencoded({ extended: true, limit: '50mb' })); | |
| 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' } })); | |
| const requireAuth = (req, res, next) => { if(!req.session.userId) return res.status(401).json({error:'Non authentifié'}); next(); }; | |
| const requireTos = (req, res, next) => { if(!req.session.tosAccepted) return res.status(403).json({error:'CGU non acceptées', needTos:true}); next(); }; | |
| // --- CACHE LRU --- | |
| class ByteLRUCache extends LRUCache { constructor(){ super({maxSize:CACHE_MAX_BYTES, sizeCalculation:v=>v.size}); } 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; } } | |
| const fileCache = new ByteLRUCache(); | |
| 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); | |
| // --- NSFW WASM --- | |
| let nsfwModel=null; | |
| 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); } } | |
| loadNsfwModel(); | |
| async function checkNsfw(buf){ if(!nsfwModel) return {safe:true}; try{ const t=tf.node.decodeImage(buf,3).resizeNearestNeighbor([224,224]).expandDims(0); const p=await nsfwModel.classify(t); t.dispose(); const porn=p.find(x=>x.className==='Porn'||x.className==='Sexual Activity')?.probability||0; const hentai=p.find(x=>x.className==='Hentai')?.probability||0; if(porn>0.85||hentai>0.9) return {safe:false,reason:`NSFW (P:${porn.toFixed(2)} H:${hentai.toFixed(2)})`}; return {safe:true}; }catch(e){ return {safe:true}; } } | |
| // --- UPLOAD QUEUE --- | |
| 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(); } } } | |
| const uploadQueue=new UploadQueue(MAX_CONCURRENT_UPLOADS); | |
| // --- IMGBB --- | |
| 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}; } | |
| async function imgbbDelete(u){ try{await axios.get(u,{timeout:10000});}catch(_){} } | |
| // --- KEY MGMT --- | |
| const SERVER_MASTER_KEY=createHash('sha256').update(SESSION_SECRET+'|PixelDriveMaster').digest(); | |
| 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()]); } | |
| 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()]); } | |
| 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); } | |
| // --- THUMB --- | |
| async function genThumb(fid,mime,key){ if(!mime.startsWith('image/')&&!mime.startsWith('video/')) return; try{ const c=db.prepare('SELECT imgbb_url,encrypted_len FROM file_chunks WHERE file_id=? AND chunk_index=0').get(fid); if(!c) return; const {data}=await axios.get(c.imgbb_url,{responseType:'arraybuffer',timeout:30000}); const enc=await pngToBuffer(Buffer.from(data),c.encrypted_len); const dec=decryptChunk(enc,key); let buf; if(mime.startsWith('image/')) buf=await sharp(dec).rotate().resize(256,256,{fit:'inside'}).jpeg({quality:80}).toBuffer(); else buf=await sharp({create:{width:256,height:144,channels:3,background:'#1e1e2f'}}).jpeg().toBuffer(); await sharp(buf).toFile(join(THUMB_DIR,`${fid}.jpg`)); }catch(e){ console.warn(`Thumb ${fid}:`,e.message); } } | |
| // --- STREAM DOWNLOAD --- | |
| async function streamFile(res,file,key,range){ const chunks=db.prepare('SELECT chunk_index,imgbb_url,encrypted_len,size FROM file_chunks WHERE file_id=? ORDER BY chunk_index').all(file.id); const total=file.size; let s=0,e=total-1; if(range){ const p=range.replace('bytes=','').split('-'); s=parseInt(p[0]); e=p[1]?parseInt(p[1]):total-1; } if(s>=total||e>=total) return res.status(416).send('Range Not Satisfiable'); res.set({'Content-Type':file.mime,'Content-Length':e-s+1,'Accept-Ranges':'bytes','Content-Disposition':`inline; filename="${encodeURIComponent(file.name)}"`}); if(range){res.status(206);res.set('Content-Range',`bytes ${s}-${e}/${total}`);} let cur=0; for(const c of chunks){ const cs=cur, ce=cur+c.size-1; if(ce>=s&&cs<=e){ const off=Math.max(0,s-cs), len=Math.min(c.size-off,e-Math.max(s,cs)+1); try{ const ck=`chunk:${file.id}:${c.chunk_index}`; let dec=fileCache.get(ck)?.data; if(!dec){ const {data}=await axios.get(c.imgbb_url,{responseType:'arraybuffer',timeout:60000}); const enc=await pngToBuffer(Buffer.from(data),c.encrypted_len); dec=decryptChunk(enc,key); fileCache.set(ck,{data:dec,size:dec.length}); } const sl=dec.subarray(off,off+len); if(!res.write(sl)) await new Promise(r=>res.once('drain',r)); }catch(err){ console.error(`Stream chunk ${c.chunk_index}`,err); return res.destroy(err); } } cur+=c.size; if(cs>e) break; } res.end(); } | |
| // --- SSE --- | |
| const sseClients=new Map(); 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); } } | |
| // ========================================== | |
| // ROUTES | |
| // ========================================== | |
| // Auth | |
| 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});}); | |
| 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}});}); | |
| app.post('/api/logout',(req,res)=>req.session.destroy(()=>res.json({ok:true}))); | |
| 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é'})); | |
| 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`)); | |
| 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});}); | |
| // Folders | |
| 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));}); | |
| 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'});}}); | |
| 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});}); | |
| // Files | |
| 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));}); | |
| // Upload | |
| const upload=multer({dest:TEMP_DIR,limits:{fileSize:5*1024*1024*1024}}); | |
| 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),el=Math.ceil((sz+OVERHEAD)/3)*3;cd.push([fid,i,sz,el]);}tx(cd);res.json({fileId:fid,status:'pending',chunkCount:cc});uploadQueue.add(()=>processUpload(fid,fpath,mk,cc,req.file.mimetype));}); | |
| 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);}} | |
| 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);});} | |
| // Download | |
| 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);}); | |
| // Preview -> Share Embed | |
| app.get('/api/files/:id/preview',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||f.status!=='ready')return res.status(404).json({error:'Pas prêt'});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'});const t=uuidv4();db.prepare('INSERT INTO share_links(token,file_id,owner_id,type,expires_at) VALUES(?,"embed",?)').run(t,f.id,req.session.userId,Date.now()+3600000);res.json({url:`/s/${t}`});}); | |
| // Public Embed | |
| app.get('/s/:token',async(req,res)=>{const l=db.prepare('SELECT * FROM share_links WHERE token=? AND type="embed" AND (expires_at IS NULL OR expires_at>?)').get(req.params.token,Date.now());if(!l)return res.status(404).send(html('Lien invalide/expiré'));const f=db.prepare('SELECT * FROM files WHERE id=?').get(l.file_id);if(!f)return res.status(404).send(html('Fichier supprimé'));const dt=uuidv4();db.prepare('INSERT INTO share_links(token,file_id,owner_id,type,expires_at) VALUES(?,"download",?)').run(dt,f.id,l.owner_id,Date.now()+3600000);const v=f.mime.startsWith('video/'),a=f.mime.startsWith('audio/'),i=f.mime.startsWith('image/'),p=f.mime==='application/pdf';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>`));}); | |
| // Public Download | |
| app.get('/d/:token',async(req,res)=>{const l=db.prepare('SELECT * FROM share_links WHERE token=? AND type="download" AND (expires_at IS NULL OR expires_at>?)').get(req.params.token,Date.now());if(!l)return res.status(404).json({error:'Invalide'});const f=db.prepare('SELECT * FROM files WHERE id=?').get(l.file_id);if(!f||f.status!=='ready')return res.status(404).json({error:'Indisponible'});const mk=decryptMasterKey(db.prepare('SELECT master_key_enc FROM files WHERE id=?').get(f.id).master_key_enc);await streamFile(res,f,mk,req.headers.range);}); | |
| // Delete | |
| 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});}); | |
| // Thumb | |
| 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>`);}); | |
| // SSE | |
| 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));}); | |
| // Admin | |
| 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());}); | |
| // Health | |
| app.get('/health',(_,res)=>res.json({ok:true,cache:fileCache.size,mem:process.memoryUsage()})); | |
| // SPA | |
| app.use(express.static(__dirname)); | |
| app.get('*',(_,res)=>res.sendFile(join(__dirname,'public.html'))); | |
| // ========================================== | |
| // START | |
| // ========================================== | |
| const server=app.listen(PORT,'0.0.0.0',()=>console.log(`🚀 PixelDrive v3.2 on http://0.0.0.0:${PORT} | Cache: 12GB LRU`)); | |
| process.on('SIGTERM',()=>{console.log('SIGTERM');server.close(()=>process.exit(0));}); | |
| function html(b){return `<!DOCTYPE html><html><head><meta charset="utf-8"/><meta name="viewport" content="width=device-width, initial-scale=1"/><title>PixelDrive</title><style>body{margin:0;font-family:system-ui,sans-serif;background:#0d0d12;color:#eee;display:flex;align-items:center;justify-content:center;height:100vh}</style></head><body>${b}</body></html>`;} |