Spaces:
Paused
Paused
Update server.js
Browse files
server.js
CHANGED
|
@@ -1,12 +1,12 @@
|
|
| 1 |
// ============================================================
|
| 2 |
-
// PIXELDRIVE v2.
|
| 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
|
| 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 |
-
|
| 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
|
|
|
|
| 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;
|
| 35 |
-
const CACHE_TTL_MS = 15 * 60 * 1000;
|
| 36 |
-
|
| 37 |
-
if (!IMGBB_API_KEY) { console.error("❌ IMGBB_API_KEY manquante !"); process.exit(1); }
|
| 38 |
|
| 39 |
// ============================================================
|
| 40 |
-
// LRU CACHE
|
| 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 |
-
//
|
| 88 |
// ============================================================
|
| 89 |
-
|
| 90 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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); }
|
| 106 |
|
| 107 |
// ============================================================
|
| 108 |
-
// HELPERS
|
| 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 |
-
//
|
| 124 |
-
const
|
| 125 |
-
|
| 126 |
-
|
|
|
|
| 127 |
const sha = createHash('sha256').update(payload).digest('hex');
|
| 128 |
-
if(sha !== ch.sha256) throw new Error(`Hash Mismatch Chunk ${chunkIdx}
|
| 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;
|
| 164 |
-
const chunkStart = currentByte;
|
| 165 |
-
|
| 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 |
-
//
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
| 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 |
-
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 215 |
setStatus(fid, { stage: 'finalizing', progress: req.file.size });
|
| 216 |
-
db.prepare('UPDATE files SET size=?,chunk_count=? WHERE id=?').run(
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
|
|
|
|
|
|
|
| 220 |
finally{ try{await unlink(tmpPath);}catch(e){} }
|
| 221 |
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
|
| 228 |
-
|
| 229 |
-
|
| 230 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 (
|
| 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.
|
| 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);});});
|