Spaces:
Paused
Paused
Update server.js
Browse files
server.js
CHANGED
|
@@ -1,11 +1,12 @@
|
|
| 1 |
// ==========================================
|
| 2 |
-
// PIXELDRIVE v5.
|
|
|
|
| 3 |
// ==========================================
|
| 4 |
import express from 'express';
|
| 5 |
import session from 'express-session';
|
| 6 |
import Database from 'better-sqlite3';
|
| 7 |
import multer from 'multer';
|
| 8 |
-
import { createHash, randomBytes, scrypt, createCipheriv, createDecipheriv } from 'crypto';
|
| 9 |
import { promisify } from 'util';
|
| 10 |
import { createReadStream, unlinkSync, existsSync, readdirSync, statSync, rmSync, copyFileSync, mkdirSync, readFileSync, writeFileSync, appendFileSync } from 'fs';
|
| 11 |
import { join } from 'path';
|
|
@@ -91,7 +92,7 @@ CREATE TABLE IF NOT EXISTS folders (id INTEGER PRIMARY KEY AUTOINCREMENT, parent
|
|
| 91 |
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')));
|
| 92 |
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));
|
| 93 |
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, views INTEGER DEFAULT 0, downloads INTEGER DEFAULT 0);
|
| 94 |
-
CREATE TABLE IF NOT EXISTS user_shares (id INTEGER PRIMARY KEY AUTOINCREMENT, file_id TEXT REFERENCES files(id) ON DELETE CASCADE, folder_id INTEGER REFERENCES folders(id) ON DELETE CASCADE, owner_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, shared_with INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, created_at INTEGER DEFAULT (strftime('%s','now')));
|
| 95 |
CREATE TABLE IF NOT EXISTS transfer_daily (id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, day TEXT NOT NULL, up_bytes INTEGER DEFAULT 0, down_bytes INTEGER DEFAULT 0, UNIQUE(user_id, day));
|
| 96 |
CREATE TABLE IF NOT EXISTS activity (id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER, action TEXT NOT NULL, detail TEXT, created_at INTEGER DEFAULT (strftime('%s','now')));
|
| 97 |
CREATE TABLE IF NOT EXISTS notifications (id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, type TEXT NOT NULL, detail TEXT, read INTEGER DEFAULT 0, created_at INTEGER DEFAULT (strftime('%s','now')));
|
|
@@ -109,6 +110,11 @@ function runMigrations() {
|
|
| 109 |
const usersCols = db.prepare("PRAGMA table_info(users)").all().map(c => c.name);
|
| 110 |
if (!usersCols.includes('tos_accepted')) db.exec(`ALTER TABLE users ADD COLUMN tos_accepted INTEGER DEFAULT 0;`);
|
| 111 |
if (!usersCols.includes('avatar')) db.exec(`ALTER TABLE users ADD COLUMN avatar BLOB;`);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 112 |
const filesCols = db.prepare("PRAGMA table_info(files)").all().map(c => c.name);
|
| 113 |
const filesDefs = [
|
| 114 |
{ name: 'folder_id', def: 'INTEGER REFERENCES folders(id) ON DELETE SET NULL' },
|
|
@@ -131,7 +137,7 @@ function runMigrations() {
|
|
| 131 |
const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table'").all().map(t => t.name);
|
| 132 |
if (!tables.includes('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));`);
|
| 133 |
if (!tables.includes('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, views INTEGER DEFAULT 0, downloads INTEGER DEFAULT 0);`);
|
| 134 |
-
if (!tables.includes('user_shares')) db.exec(`CREATE TABLE user_shares (id INTEGER PRIMARY KEY AUTOINCREMENT, file_id TEXT REFERENCES files(id) ON DELETE CASCADE, folder_id INTEGER REFERENCES folders(id) ON DELETE CASCADE, owner_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, shared_with INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, created_at INTEGER DEFAULT (strftime('%s','now'))); CREATE INDEX idx_ushares_with ON user_shares(shared_with);`);
|
| 135 |
if (!tables.includes('transfer_daily')) db.exec(`CREATE TABLE transfer_daily (id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, day TEXT NOT NULL, up_bytes INTEGER DEFAULT 0, down_bytes INTEGER DEFAULT 0, UNIQUE(user_id, day)); CREATE INDEX idx_transfer_day ON transfer_daily(user_id, day);`);
|
| 136 |
if (!tables.includes('activity')) db.exec(`CREATE TABLE activity (id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER, action TEXT NOT NULL, detail TEXT, created_at INTEGER DEFAULT (strftime('%s','now'))); CREATE INDEX idx_activity_user ON activity(user_id, created_at);`);
|
| 137 |
if (!tables.includes('notifications')) db.exec(`CREATE TABLE notifications (id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, type TEXT NOT NULL, detail TEXT, read INTEGER DEFAULT 0, created_at INTEGER DEFAULT (strftime('%s','now'))); CREATE INDEX idx_notif_user ON notifications(user_id, read, created_at);`);
|
|
@@ -288,8 +294,33 @@ function encryptMasterKey(k){ const iv=randomBytes(12); const c=createCipheriv('
|
|
| 288 |
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()]); }
|
| 289 |
async function getMasterKey(fid){ const r=db.prepare('SELECT master_key_enc FROM files WHERE id=?').get(fid); if(!r?.master_key_enc) throw new Error('Clé manquante'); return decryptMasterKey(r.master_key_enc); }
|
| 290 |
|
| 291 |
-
|
| 292 |
-
function
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 293 |
function isEditable(f) { return (f.mime && (f.mime.startsWith('text/') || ['application/json','application/javascript','application/xml'].includes(f.mime))) || EDITABLE_RE.test(f.name || ''); }
|
| 294 |
|
| 295 |
async function decryptWholeFile(f) { const k = await getMasterKey(f.id); const chunks = db.prepare('SELECT chunk_index,imgbb_url,size FROM file_chunks WHERE file_id=? ORDER BY chunk_index').all(f.id); const bufs = []; for (const c of chunks) { const ck = `chunk:${f.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.size + OVERHEAD); dec = decryptChunk(enc, k); fileCache.set(ck,{data:dec,size:dec.length}); } bufs.push(dec); } return Buffer.concat(bufs, f.size); }
|
|
@@ -311,164 +342,74 @@ const partsDir = (fid) => join(TEMP_DIR, 'up_' + fid);
|
|
| 311 |
const assembledPath = (fid) => join(TEMP_DIR, 'assemble_' + fid);
|
| 312 |
const pad6 = (i) => String(i).padStart(6, '0');
|
| 313 |
|
| 314 |
-
|
| 315 |
-
|
| 316 |
-
|
| 317 |
-
|
| 318 |
-
|
| 319 |
-
|
| 320 |
-
|
| 321 |
-
|
| 322 |
-
|
| 323 |
-
|
| 324 |
-
const fid = uuidv4();
|
| 325 |
-
const mk = randomBytes(KEY_LEN);
|
| 326 |
-
const kh = createHash('sha256').update(mk).digest('hex');
|
| 327 |
-
const mke = encryptMasterKey(mk);
|
| 328 |
-
const cc = Math.ceil(size / CHUNK_SIZE);
|
| 329 |
-
db.prepare(`INSERT INTO files(id,folder_id,owner_id,name,mime,size,chunk_count,status,encryption_key_hash,master_key_enc) VALUES(?,?,?,?,?,?,?,'uploading',?,?)`).run(fid, folder_id||null, uid, name, mime||'application/octet-stream', size, cc, kh, mke);
|
| 330 |
-
const stmt = db.prepare('INSERT INTO file_chunks(file_id,chunk_index,size,encrypted_len) VALUES(?,?,?,?)');
|
| 331 |
-
const tx = db.transaction((arr)=>{for(const c of arr)stmt.run(...c);});
|
| 332 |
-
const cd = [];
|
| 333 |
-
for (let i=0;i<cc;i++){ const sz = Math.min(CHUNK_SIZE, size - i*CHUNK_SIZE); cd.push([fid,i,sz,sz+OVERHEAD]); }
|
| 334 |
-
tx(cd);
|
| 335 |
-
mkdirSync(partsDir(fid), { recursive: true });
|
| 336 |
-
log('INFO', `Upload init ${fid} (${name}, ${(size/1048576).toFixed(2)} MB, ${Math.ceil(size/UP_CHUNK)} chunks transport)`);
|
| 337 |
-
res.json({ uploadId: fid, chunkSize: UP_CHUNK });
|
| 338 |
-
});
|
| 339 |
-
|
| 340 |
-
const chunkUpload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 6*1024*1024 } });
|
| 341 |
-
app.post('/api/upload/chunk', requireAuth, chunkUpload.single('chunk'), (req,res) => {
|
| 342 |
-
const { uploadId, index } = req.body;
|
| 343 |
-
const f = db.prepare('SELECT * FROM files WHERE id=? AND owner_id=?').get(uploadId, req.session.userId);
|
| 344 |
-
if (!f) return res.status(404).json({error:'Introuvable'});
|
| 345 |
-
if (f.status !== 'uploading') return res.status(409).json({error:'Upload non modifiable'});
|
| 346 |
-
const i = parseInt(index, 10);
|
| 347 |
-
const total = Math.ceil(f.size / UP_CHUNK);
|
| 348 |
-
if (isNaN(i) || i < 0 || i >= total) return res.status(400).json({error:'Index invalide'});
|
| 349 |
-
const expect = (i === total-1) ? (f.size - i*UP_CHUNK) : UP_CHUNK;
|
| 350 |
-
if (!req.file || req.file.size !== expect) return res.status(400).json({error:'Taille de chunk invalide'});
|
| 351 |
-
mkdirSync(partsDir(f.id), { recursive: true });
|
| 352 |
-
writeFileSync(join(partsDir(f.id), pad6(i)), req.file.buffer);
|
| 353 |
-
res.json({ ok: true, index: i });
|
| 354 |
-
});
|
| 355 |
-
|
| 356 |
-
app.get('/api/upload/status', requireAuth, (req,res) => {
|
| 357 |
-
const fid = req.query.id;
|
| 358 |
-
const f = db.prepare('SELECT id,size,status,progress FROM files WHERE id=? AND owner_id=?').get(fid, req.session.userId);
|
| 359 |
-
if (!f) return res.status(404).json({error:'Introuvable'});
|
| 360 |
-
const total = Math.ceil(f.size / UP_CHUNK);
|
| 361 |
-
let received = [];
|
| 362 |
-
try { received = readdirSync(partsDir(fid)).map(x=>parseInt(x,10)).filter(n=>!isNaN(n)); } catch(e){}
|
| 363 |
-
res.json({ status: f.status, progress: f.progress, total, received });
|
| 364 |
-
});
|
| 365 |
-
|
| 366 |
-
function assembleParts(fid) {
|
| 367 |
-
const f = db.prepare('SELECT size FROM files WHERE id=?').get(fid);
|
| 368 |
-
const total = Math.ceil(f.size / UP_CHUNK);
|
| 369 |
-
const dir = partsDir(fid);
|
| 370 |
-
const out = assembledPath(fid);
|
| 371 |
-
for (let i=0;i<total;i++) appendFileSync(out, readFileSync(join(dir, pad6(i))));
|
| 372 |
-
rmSync(dir, { recursive: true, force: true });
|
| 373 |
-
return out;
|
| 374 |
-
}
|
| 375 |
-
function queueProcess(fid) {
|
| 376 |
-
const f = db.prepare('SELECT size, owner_id FROM files WHERE id=?').get(fid);
|
| 377 |
-
if (!f) return;
|
| 378 |
-
const uname = (db.prepare('SELECT username FROM users WHERE id=?').get(f.owner_id)||{}).username || '?';
|
| 379 |
-
uploadQueue.add(() => processUpload(fid, assembledPath(fid), f.size, uname, f.owner_id));
|
| 380 |
-
}
|
| 381 |
-
|
| 382 |
-
app.post('/api/upload/complete', requireAuth, (req,res) => {
|
| 383 |
-
const { uploadId } = req.body;
|
| 384 |
-
const f = db.prepare('SELECT * FROM files WHERE id=? AND owner_id=?').get(uploadId, req.session.userId);
|
| 385 |
-
if (!f) return res.status(404).json({error:'Introuvable'});
|
| 386 |
-
if (f.status !== 'uploading') return res.status(409).json({error:'Déjà complété'});
|
| 387 |
-
const total = Math.ceil(f.size / UP_CHUNK);
|
| 388 |
-
let received = [];
|
| 389 |
-
try { received = readdirSync(partsDir(f.id)).map(x=>parseInt(x,10)).filter(n=>!isNaN(n)); } catch(e){}
|
| 390 |
-
const missing = []; for (let i=0;i<total;i++) if (!received.includes(i)) missing.push(i);
|
| 391 |
-
if (missing.length) return res.status(400).json({error:'Chunks manquants', missing});
|
| 392 |
-
try { assembleParts(f.id); } catch(e){ log('ERROR', `Assemblage ${f.id}: ${e.message}`); return res.status(500).json({error:'Erreur assemblage'}); }
|
| 393 |
-
db.prepare("UPDATE files SET status='processing', progress=50 WHERE id=?").run(f.id);
|
| 394 |
-
queueProcess(f.id);
|
| 395 |
-
log('INFO', `Upload complete ${f.id} → traitement arrière-plan`);
|
| 396 |
-
res.json({ ok: true });
|
| 397 |
-
});
|
| 398 |
-
|
| 399 |
-
function recoverStuckUploads() {
|
| 400 |
-
const stuck = db.prepare("SELECT id, size, status FROM files WHERE status IN ('uploading','processing')").all();
|
| 401 |
-
for (const f of stuck) {
|
| 402 |
-
try {
|
| 403 |
-
const assembled = assembledPath(f.id);
|
| 404 |
-
if (existsSync(assembled)) { queueProcess(f.id); log('INFO', `Recovery: ${f.id} re-file (processing)`); continue; }
|
| 405 |
-
const total = Math.ceil(f.size / UP_CHUNK);
|
| 406 |
-
let n = 0; try { n = readdirSync(partsDir(f.id)).length; } catch(e){}
|
| 407 |
-
if (total > 0 && n >= total) { assembleParts(f.id); queueProcess(f.id); log('INFO', `Recovery: ${f.id} assemblé + re-file`); }
|
| 408 |
-
} catch(e) { log('WARN', `Recovery ${f.id}: ${e.message}`); }
|
| 409 |
}
|
| 410 |
}
|
| 411 |
|
| 412 |
-
|
| 413 |
-
const upload = multer({dest:TEMP_DIR,limits:{fileSize:5*1024*1024*1024}});
|
| 414 |
-
app.post('/api/files/upload', requireAuth, requireTos, uploadLimit, upload.single('file'), async(req,res)=>{
|
| 415 |
-
if(!req.file)return res.status(400).json({error:'Fichier manquant'});
|
| 416 |
-
const uid = req.session.userId; const fsize = req.file.size;
|
| 417 |
-
const tr = getTransfer(uid); const remaining = DAILY_TRANSFER - tr.total;
|
| 418 |
-
if (remaining <= 0) { try{unlinkSync(req.file.path);}catch(_){} return res.status(429).json({error:'Quota de transfert journalier atteint'}); }
|
| 419 |
-
if (fsize > remaining) { try{unlinkSync(req.file.path);}catch(_){} return res.status(413).json({error:'Quota journalier insuffisant'}); }
|
| 420 |
-
const used = db.prepare("SELECT COALESCE(SUM(size),0) as t FROM files WHERE owner_id=? AND status!='error'").get(uid).t;
|
| 421 |
-
if (used + fsize > USER_QUOTA) { try{unlinkSync(req.file.path);}catch(_){} return res.status(413).json({error:'Quota de stockage dépassé'}); }
|
| 422 |
-
const fid=uuidv4(),fpath=req.file.path,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;
|
| 423 |
-
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,uid,req.file.originalname,req.file.mimetype,fsize,cc,kh,mke);
|
| 424 |
-
const stmt=db.prepare('INSERT INTO file_chunks(file_id,chunk_index,size,encrypted_len) VALUES(?,?,?,?)');
|
| 425 |
-
const tx=db.transaction((arr)=>{for(const c of arr)stmt.run(...c);});
|
| 426 |
-
const cd=[]; for(let i=0;i<cc;i++){const sz=Math.min(CHUNK_SIZE,fsize-i*CHUNK_SIZE);cd.push([fid,i,sz,sz+OVERHEAD]);} tx(cd);
|
| 427 |
-
res.json({fileId:fid,status:'pending',chunkCount:cc});
|
| 428 |
-
uploadQueue.add(()=>processUpload(fid,fpath,fsize,req.session.username,uid));
|
| 429 |
-
});
|
| 430 |
-
|
| 431 |
-
async function processUpload(fid, path, fsize, username, uid){
|
| 432 |
const t0 = Date.now();
|
| 433 |
const mk = await getMasterKey(fid);
|
| 434 |
const frow = db.prepare('SELECT mime FROM files WHERE id=?').get(fid);
|
| 435 |
const mime = frow?.mime || 'application/octet-stream';
|
| 436 |
const cc = Math.ceil(fsize / CHUNK_SIZE);
|
| 437 |
-
log('INFO', `Traitement démarré: ${fid} par ${username} (${(fsize/1048576).toFixed(2)} MB, ${cc} chunk(s))`);
|
| 438 |
-
try{
|
| 439 |
db.prepare("UPDATE files SET status='uploading' WHERE id=?").run(fid);
|
| 440 |
-
|
| 441 |
-
const
|
| 442 |
-
|
| 443 |
-
const
|
| 444 |
-
|
| 445 |
-
if (i === 0 && mime.startsWith('image/')) { const scan = await checkNsfw(raw); if (!scan.safe) { log('ALERT', `NSFW bloqué: ${fid} par ${username}`); throw new Error('Contenu bloqué par le filtre de modération'); } }
|
| 446 |
-
const enc=encryptChunk(raw,mk);
|
| 447 |
-
if(enc.length>ci.encrypted_len)throw new Error(`Overflow chunk ${i}`);
|
| 448 |
-
const png=await bufferToPng(enc);
|
| 449 |
-
const{url,deleteUrl,id}=await imgbbUpload(png);
|
| 450 |
-
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);
|
| 451 |
-
const prog=((i+1)/cc)*50;
|
| 452 |
-
db.prepare('UPDATE files SET progress=?,status=? WHERE id=?').run(prog,'uploading',fid);
|
| 453 |
-
broadcast(fid,{type:'progress',progress:prog,status:'uploading'});
|
| 454 |
-
if(i<cc-1)await new Promise(r=>setTimeout(r,3200));
|
| 455 |
}
|
| 456 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 457 |
db.prepare("UPDATE files SET status='processing',progress=50 WHERE id=?").run(fid);
|
| 458 |
-
broadcast(fid,{type:'progress',progress:50,status:'processing'});
|
| 459 |
-
await genThumb(fid,mime,mk);
|
| 460 |
db.prepare("UPDATE files SET status='ready',progress=100 WHERE id=?").run(fid);
|
| 461 |
-
broadcast(fid,{type:'progress',progress:100,status:'ready',type:'done'});
|
| 462 |
addTransfer(uid, fsize, 'up');
|
| 463 |
logActivity(uid, 'upload', path.split('/').pop());
|
| 464 |
-
const secs = (Date.now()-t0)/1000;
|
| 465 |
log('INFO', `Traitement terminé: ${fid} en ${secs.toFixed(1)}s (${(fsize/1048576/secs).toFixed(2)} MB/s)`);
|
| 466 |
-
}catch(e){
|
| 467 |
log('ERROR', `Traitement échoué ${fid}: ${e.message}`);
|
| 468 |
-
db.prepare("UPDATE files SET status='error',error_msg=? WHERE id=?").run(e.message,fid);
|
| 469 |
-
broadcast(fid,{type:'error',error:e.message});
|
| 470 |
-
const cs=db.prepare('SELECT imgbb_delete_url FROM file_chunks WHERE file_id=?').all(fid);
|
| 471 |
-
for(const c of cs)if(c.imgbb_delete_url)await imgbbDelete(c.imgbb_delete_url);
|
| 472 |
}
|
| 473 |
}
|
| 474 |
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);});}
|
|
@@ -477,15 +418,15 @@ function readChunk(p,o,l){return new Promise((res,rej)=>{const s=createReadStrea
|
|
| 477 |
// ROUTES
|
| 478 |
// ==========================================
|
| 479 |
app.post('/api/register', registerLimit, async(req,res)=>{ const {u,p,t} = req.body; if(!u||!p||!t) return res.status(400).json({error:'Champs manquants'}); if(typeof u !== 'string' || !/^[a-zA-Z0-9_.\-]{3,20}$/.test(u)) return res.status(400).json({error:'Nom invalide'}); if(typeof p !== 'string' || p.length < 6) return res.status(400).json({error:'Mot de passe trop court'}); 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); log('INFO', `Nouvel utilisateur: ${u}`); res.json({ok:true}); });
|
| 480 |
-
app.post('/api/login', loginLimit, 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(!
|
| 481 |
app.post('/api/logout',(req,res)=>req.session.destroy(()=>res.json({ok:true})));
|
| 482 |
app.get('/api/me',(req,res)=>{ if(!req.session.userId) return res.status(401).json({error:'Non connecté'}); const used = db.prepare("SELECT COALESCE(SUM(size),0) as t FROM files WHERE owner_id=? AND status!='error'").get(req.session.userId).t; const tr = getTransfer(req.session.userId); res.json({ id:req.session.userId, username:req.session.username, isAdmin:req.session.isAdmin, tosAccepted:req.session.tosAccepted, quotaUsed:used, quotaMax:USER_QUOTA, transferUp:tr.up, transferDown:tr.down, transferUsed:tr.total, transferMax:DAILY_TRANSFER }); });
|
| 483 |
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`));
|
| 484 |
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});});
|
| 485 |
app.get('/api/notifications', requireAuth, (req,res) => { res.json(db.prepare('SELECT id, type, detail, read, created_at FROM notifications WHERE user_id=? ORDER BY created_at DESC LIMIT 50').all(req.session.userId)); });
|
| 486 |
app.post('/api/notifications/read', requireAuth, (req,res) => { db.prepare('UPDATE notifications SET read=1 WHERE user_id=?').run(req.session.userId); res.json({ok:true}); });
|
| 487 |
-
app.post('/api/password', requireAuth, async(req,res)=>{ const { current, next } = req.body; if (typeof current !== 'string' || typeof next !== 'string') return res.status(400).json({error:'Champs manquants'}); if (next.length < 6) return res.status(400).json({error:'Trop court'}); const usr = db.prepare('SELECT * FROM users WHERE id=?').get(req.session.userId); const hCur = await deriveKey(current, usr.salt); if (!
|
| 488 |
-
app.delete('/api/account', requireAuth, async(req,res)=>{ const { password } = req.body; const usr = db.prepare('SELECT * FROM users WHERE id=?').get(req.session.userId); const h = await deriveKey(String(password || ''), usr.salt); if (!
|
| 489 |
app.get('/api/stats', requireAuth, (req,res) => { res.json(db.prepare(`SELECT CASE WHEN mime LIKE 'image/%' THEN 'image' WHEN mime LIKE 'video/%' THEN 'video' WHEN mime LIKE 'audio/%' THEN 'audio' ELSE 'autre' END as cat, COUNT(*) c, COALESCE(SUM(size),0) s FROM files WHERE owner_id=? AND status!='error' GROUP BY cat`).all(req.session.userId)); });
|
| 490 |
app.get('/api/stats/advanced', requireAuth, (req,res) => { const days = []; for (let i = 6; i >= 0; i--) { const d = new Date(Date.now() - i*86400000).toISOString().slice(0,10); const r = db.prepare('SELECT COALESCE(SUM(up_bytes),0) u, COALESCE(SUM(down_bytes),0) d FROM transfer_daily WHERE user_id=? AND day=?').get(req.session.userId, d); const acts = db.prepare("SELECT COUNT(*) c FROM activity WHERE user_id=? AND strftime('%Y-%m-%d', created_at, 'unixepoch')=?").get(req.session.userId, d).c; days.push({ day: d.slice(5), up: r.u, down: r.d, acts }); } const top = db.prepare(`SELECT f.name, (SELECT COALESCE(SUM(downloads),0) FROM share_links sl WHERE sl.file_id=f.id) dl FROM files f WHERE f.owner_id=? AND f.status='ready' ORDER BY dl DESC LIMIT 5`).all(req.session.userId); res.json({ days, top }); });
|
| 491 |
app.get('/api/activity', requireAuth, (req,res) => { res.json(db.prepare('SELECT action, detail, created_at FROM activity WHERE user_id=? ORDER BY created_at DESC LIMIT 100').all(req.session.userId)); });
|
|
@@ -496,32 +437,79 @@ app.delete('/api/admin/users/:id', requireAuth, requireAdmin, (req,res) => { con
|
|
| 496 |
app.post('/api/admin/users/:id/toggle-admin', requireAuth, requireAdmin, (req,res) => { const id = parseInt(req.params.id, 10); if (!id) return res.status(400).json({error:'ID invalide'}); if (id === req.session.userId) return res.status(400).json({error:'Non'}); db.prepare('UPDATE users SET is_admin = 1 - is_admin WHERE id=?').run(id); res.json({ok:true}); });
|
| 497 |
app.post('/api/avatar', requireAuth, avatarLimit, async(req,res)=>{ const { data } = req.body; if (!data || !data.startsWith('data:image/')) return res.status(400).json({error:'Image invalide'}); try { const buf = Buffer.from(data.split(',')[1], 'base64'); if (buf.length > 5*1024*1024) return res.status(400).json({error:'Trop lourde'}); const jpeg = await sharp(buf).resize(128,128,{fit:'cover'}).jpeg({quality:82}).toBuffer(); db.prepare('UPDATE users SET avatar=? WHERE id=?').run(jpeg, req.session.userId); res.json({ok:true}); } catch(e){ res.status(400).json({error:'Image invalide'}); } });
|
| 498 |
app.delete('/api/avatar', requireAuth, avatarLimit, (req,res)=>{ db.prepare('UPDATE users SET avatar=NULL WHERE id=?').run(req.session.userId); res.json({ok:true}); });
|
| 499 |
-
app.get('/api/users/:id/avatar',(req,res)=>{ const u = db.prepare('SELECT avatar FROM users WHERE id=?').get(req.params.id); if (!u || !u.avatar) return res.status(404).json({error:"Pas d'avatar"}); res.set({'Content-Type':'image/jpeg','Cache-Control':'public, max-age=3600'}); res.send(u.avatar); });
|
| 500 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 501 |
app.post('/api/folders',requireAuth,requireTos,(req,res)=>{const{n,p}=req.body;if(!n||typeof n!=='string'||n.length>100)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'});}});
|
| 502 |
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});});
|
| 503 |
app.post('/api/folders/:id/rename',requireAuth,(req,res)=>{ const { name } = req.body; if(!name || typeof name !== 'string' || !name.trim() || name.length > 100) return res.status(400).json({error:'Nom invalide'}); const fo = db.prepare('SELECT id FROM folders WHERE id=? AND owner_id=?').get(req.params.id, req.session.userId); if(!fo) return res.status(404).json({error:'Introuvable'}); try { db.prepare('UPDATE folders SET name=? WHERE id=?').run(name.trim(), req.params.id); logActivity(req.session.userId,'rename',name.trim()); res.json({ok:true}); } catch(e){ res.status(409).json({error:'Nom déjà utilisé'}); } });
|
| 504 |
app.post('/api/folders/:id/move',requireAuth,(req,res)=>{ const { parent_id } = req.body; const fo = db.prepare('SELECT * FROM folders WHERE id=? AND owner_id=?').get(req.params.id, req.session.userId); if(!fo) return res.status(404).json({error:'Introuvable'}); if (parent_id) { const target = db.prepare('SELECT * FROM folders WHERE id=?').get(parent_id); if (!target || target.owner_id !== req.session.userId) return res.status(403).json({error:'Cible invalide'}); let cur = target, depth = 0; while (cur && depth < 50) { if (cur.id === fo.id) return res.status(400).json({error:'Boucle détectée'}); cur = cur.parent_id ? db.prepare('SELECT * FROM folders WHERE id=?').get(cur.parent_id) : null; depth++; } } db.prepare('UPDATE folders SET parent_id=? WHERE id=?').run(parent_id || null, req.params.id); logActivity(req.session.userId,'move',fo.name); res.json({ok:true}); });
|
| 505 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 506 |
app.post('/api/files/:id/rename',requireAuth,(req,res)=>{ if(!UUID_RE.test(req.params.id)) return res.status(400).json({error:'ID invalide'}); const { name } = req.body; if(!name || typeof name !== 'string' || !name.trim() || name.length > 200) return res.status(400).json({error:'Nom invalide'}); const f = db.prepare('SELECT id FROM files WHERE id=? AND owner_id=?').get(req.params.id, req.session.userId); if(!f) return res.status(404).json({error:'Introuvable'}); db.prepare('UPDATE files SET name=? WHERE id=?').run(name.trim(), req.params.id); logActivity(req.session.userId,'rename',name.trim()); res.json({ok:true}); });
|
| 507 |
app.post('/api/files/:id/move',requireAuth,(req,res)=>{ if(!UUID_RE.test(req.params.id)) return res.status(400).json({error:'ID invalide'}); const { folder_id } = req.body; const f = db.prepare('SELECT id FROM files WHERE id=? AND owner_id=?').get(req.params.id, req.session.userId); if(!f) return res.status(404).json({error:'Introuvable'}); if (folder_id) { const target = db.prepare('SELECT owner_id FROM folders WHERE id=?').get(folder_id); if (!target || target.owner_id !== req.session.userId) return res.status(403).json({error:'Cible invalide'}); } db.prepare('UPDATE files SET folder_id=? WHERE id=?').run(folder_id || null, req.params.id); logActivity(req.session.userId,'move',f.name); res.json({ok:true}); });
|
| 508 |
-
|
| 509 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 510 |
app.delete('/api/shared-with-me/:id',requireAuth,(req,res)=>{ db.prepare('DELETE FROM user_shares WHERE id=? AND shared_with=?').run(req.params.id, req.session.userId); res.json({ok:true}); });
|
| 511 |
-
app.get('/api/my-shares',requireAuth,(req,res)=>{ res.json(db.prepare(`SELECT us.id, us.created_at, u.username as shared_with_name, us.file_id, us.folder_id, f.name as file_name, f.mime, f.size, fo.name as folder_name FROM user_shares us JOIN users u ON u.id = us.shared_with LEFT JOIN files f ON f.id = us.file_id LEFT JOIN folders fo ON fo.id = us.folder_id WHERE us.owner_id = ? ORDER BY us.created_at DESC`).all(req.session.userId)); });
|
| 512 |
app.delete('/api/my-shares/:id',requireAuth,(req,res)=>{ const r = db.prepare('DELETE FROM user_shares WHERE id=? AND owner_id=?').run(req.params.id, req.session.userId); if (!r.changes) return res.status(404).json({error:'Introuvable'}); res.json({ok:true}); });
|
| 513 |
app.get('/api/share-links',requireAuth,(req,res)=>{ res.json(db.prepare(`SELECT sl.*, f.name as file_name, f.mime, f.size FROM share_links sl LEFT JOIN files f ON sl.file_id = f.id WHERE sl.owner_id = ? ORDER BY sl.created_at DESC`).all(req.session.userId)); });
|
| 514 |
app.delete('/api/share-links/:token',requireAuth,(req,res)=>{ const r = db.prepare('DELETE FROM share_links WHERE token=? AND owner_id=?').run(req.params.token, req.session.userId); if (!r.changes) return res.status(404).json({error:'Introuvable'}); res.json({ok:true}); });
|
|
|
|
| 515 |
app.get('/api/files/:id/download',requireAuth,async(req,res)=>{ if(!UUID_RE.test(req.params.id)) return res.status(400).json({error:'ID invalide'}); if (transferBlocked(req.session.userId)) return res.status(429).json({error:'Quota journalier atteint'}); const f = canAccessFile(req.session.userId, req.params.id); 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}); logActivity(req.session.userId,'download',f.name); const k = await getMasterKey(f.id); await streamFile(res,f,k,req.headers.range,req.session.userId); });
|
| 516 |
app.get('/api/files/:id/raw', requireAuth, async(req,res)=>{ if(!UUID_RE.test(req.params.id)) return res.status(400).json({error:'ID invalide'}); const f = canAccessFile(req.session.userId, req.params.id); if(!f) return res.status(404).json({error:'Introuvable'}); if(f.status!=='ready') return res.status(409).json({error:'Pas prêt'}); if (f.size > 2*1024*1024) return res.status(413).json({error:'Trop lourd pour aperçu (2 MB max)'}); try { const all = await decryptWholeFile(f); res.set({'Content-Type': (f.mime && f.mime.startsWith('text/')) ? f.mime : 'text/plain; charset=utf-8'}); res.send(all); } catch (e) { res.status(500).json({error:'Erreur déchiffrement'}); } });
|
| 517 |
-
app.post('/api/files/:id/edit', requireAuth, requireTos, async(req,res)=>{ if(!UUID_RE.test(req.params.id)) return res.status(400).json({error:'ID invalide'}); const { content } = req.body; if (typeof content !== 'string') return res.status(400).json({error:'Contenu manquant'}); const f =
|
| 518 |
app.get('/api/files/:id/properties', requireAuth, async(req,res)=>{ if(!UUID_RE.test(req.params.id)) return res.status(400).json({error:'ID invalide'}); const f = canAccessFile(req.session.userId, req.params.id); if(!f) return res.status(404).json({error:'Introuvable'}); const chunks = db.prepare('SELECT imgbb_url, size FROM file_chunks WHERE file_id=? ORDER BY chunk_index').all(f.id); const hash = createHash('sha256'); for (const c of chunks) { try { const { data } = await axios.get(c.imgbb_url, { responseType:'arraybuffer', timeout:30000 }); hash.update(Buffer.from(data)); } catch (e) { return res.status(500).json({error:'Erreur de calcul'}); } } res.json({ id: f.id, name: f.name, size: f.size, mime: f.mime, status: f.status, created_at: f.created_at, updated_at: f.updated_at, chunk_count: f.chunk_count, encryption_key_hash: f.encryption_key_hash, sha256: hash.digest('hex'), owner_id: f.owner_id }); });
|
| 519 |
app.post('/api/files/:id/share',requireAuth,shareLimit,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:'Non disponible'}); const ttlReq = parseInt(req.body.ttl, 10); const ttl = TTL_ALLOWED.includes(ttlReq) ? ttlReq : 168; const pt=uuidv4(), dt2=uuidv4(), expires=Date.now()+ttl*3600000; db.prepare('INSERT INTO share_links(token,file_id,owner_id,type,expires_at) VALUES(?,?,?,?,?)').run(pt,f.id,req.session.userId,'embed',expires); db.prepare('INSERT INTO share_links(token,file_id,owner_id,type,expires_at) VALUES(?,?,?,?,?)').run(dt2,f.id,req.session.userId,'download',expires); logActivity(req.session.userId,'share_link',`${f.name} (${ttl}h)`); res.json({preview:`/s/${pt}`,download:`/d/${dt2}`,expires,ttl}); });
|
| 520 |
app.get('/s/:token',async(req,res)=>{ const tpl = embedTpl(); const serve = (status, data, og) => { if (!tpl) return res.status(status).send('Lien invalide'); const out = tpl.replace('__OG_META__', og || '').replace('__EMBED_DATA__', JSON.stringify(data).replace(/</g, '\\u003c')); res.status(status).send(out); }; const l=db.prepare('SELECT * FROM share_links WHERE token=? AND type=? AND (expires_at IS NULL OR expires_at>?)').get(req.params.token,'embed',Date.now()); if (!l) return serve(404, { error: 'Lien invalide ou expiré' }); const f=db.prepare('SELECT * FROM files WHERE id=?').get(l.file_id); if (!f) return serve(404, { error: 'Fichier supprimé' }); db.prepare('UPDATE share_links SET views = views + 1 WHERE token=?').run(req.params.token); logActivity(f.owner_id, 'public_view', f.name); let dt; const existing = db.prepare('SELECT token FROM share_links WHERE file_id=? AND type=? AND owner_id=? AND (expires_at IS NULL OR expires_at>?)').get(f.id,'download',l.owner_id,Date.now()); if (existing) dt = existing.token; else { dt = uuidv4(); db.prepare('INSERT INTO share_links(token,file_id,owner_id,type,expires_at) VALUES(?,?,?,?,?)').run(dt,f.id,l.owner_id,'download',Date.now()+3600000); } const og = `<meta property="og:type" content="website"><meta property="og:site_name" content="PixelDrive"><meta property="og:title" content="${esc(f.name)}"><meta property="og:description" content="${(f.size/1e6).toFixed(1)} MB · Partage sécurisé PixelDrive"><meta property="og:image" content="${req.protocol}://${req.get('host')}/banner.png"><meta name="twitter:card" content="summary_large_image"><meta name="twitter:title" content="${esc(f.name)}"><meta name="twitter:image" content="${req.protocol}://${req.get('host')}/banner.png">`; serve(200, { name: f.name, size: f.size, mime: f.mime, dl: '/d/' + dt }, og); });
|
| 521 |
app.get('/d/:token',async(req,res)=>{ const l=db.prepare('SELECT * FROM share_links WHERE token=? AND type=? AND (expires_at IS NULL OR expires_at>?)').get(req.params.token,'download',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'}); if (transferBlocked(f.owner_id)) return res.status(429).json({error:'Quota propriétaire atteint'}); db.prepare('UPDATE share_links SET downloads = downloads + 1 WHERE token=?').run(req.params.token); logActivity(f.owner_id, 'public_download', f.name); 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,f.owner_id); });
|
| 522 |
app.delete('/api/files/:id',requireAuth,async(req,res)=>{ if(!UUID_RE.test(req.params.id)) return res.status(400).json({error:'ID invalide'}); 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(_){} logActivity(req.session.userId,'delete',f.name); res.json({ok:true}); });
|
| 523 |
-
app.get('/api/files/:id/thumb',requireAuth,(req,res)=>{ if(!UUID_RE.test(req.params.id)) return res.status(400).json({error:'ID invalide'});
|
| 524 |
-
app.get('/api/files/:id/progress',requireAuth,(req,res)=>{ if(!UUID_RE.test(req.params.id)) return res.status(400).json({error:'ID invalide'});
|
| 525 |
app.get('/health',(_,res)=>res.json({ok:true,cache:fileCache.size,mem:process.memoryUsage()}));
|
| 526 |
|
| 527 |
app.use(express.static(PUBLIC_DIR, { dotfiles: 'deny', index: false }));
|
|
@@ -531,5 +519,33 @@ app.get('*', (req, res) => { if (/\.[a-zA-Z0-9]+$/.test(req.path)) return res.st
|
|
| 531 |
app.use((err, req, res, next) => { if (err.type === 'entity.parse.failed' || err instanceof SyntaxError) return res.status(400).json({ error: 'Bad Request' }); log('ERROR', `Server: ${err.message}`); res.status(500).json({ error: 'Internal Server Error' }); });
|
| 532 |
|
| 533 |
recoverStuckUploads();
|
| 534 |
-
const server = app.listen(PORT, '0.0.0.0', () => log('INFO', `PixelDrive v5.
|
| 535 |
-
process.on('SIGTERM', () => { log('WARN', 'SIGTERM reçu, arrêt...'); server.close(() => process.exit(0)); });
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
// ==========================================
|
| 2 |
+
// PIXELDRIVE v5.3 - SERVER.JS (ESM)
|
| 3 |
+
// Permissions (read/write) + Fixes
|
| 4 |
// ==========================================
|
| 5 |
import express from 'express';
|
| 6 |
import session from 'express-session';
|
| 7 |
import Database from 'better-sqlite3';
|
| 8 |
import multer from 'multer';
|
| 9 |
+
import { createHash, randomBytes, scrypt, createCipheriv, createDecipheriv, timingSafeEqual } from 'crypto';
|
| 10 |
import { promisify } from 'util';
|
| 11 |
import { createReadStream, unlinkSync, existsSync, readdirSync, statSync, rmSync, copyFileSync, mkdirSync, readFileSync, writeFileSync, appendFileSync } from 'fs';
|
| 12 |
import { join } from 'path';
|
|
|
|
| 92 |
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')));
|
| 93 |
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));
|
| 94 |
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, views INTEGER DEFAULT 0, downloads INTEGER DEFAULT 0);
|
| 95 |
+
CREATE TABLE IF NOT EXISTS user_shares (id INTEGER PRIMARY KEY AUTOINCREMENT, file_id TEXT REFERENCES files(id) ON DELETE CASCADE, folder_id INTEGER REFERENCES folders(id) ON DELETE CASCADE, owner_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, shared_with INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, permission TEXT DEFAULT 'read', created_at INTEGER DEFAULT (strftime('%s','now')));
|
| 96 |
CREATE TABLE IF NOT EXISTS transfer_daily (id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, day TEXT NOT NULL, up_bytes INTEGER DEFAULT 0, down_bytes INTEGER DEFAULT 0, UNIQUE(user_id, day));
|
| 97 |
CREATE TABLE IF NOT EXISTS activity (id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER, action TEXT NOT NULL, detail TEXT, created_at INTEGER DEFAULT (strftime('%s','now')));
|
| 98 |
CREATE TABLE IF NOT EXISTS notifications (id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, type TEXT NOT NULL, detail TEXT, read INTEGER DEFAULT 0, created_at INTEGER DEFAULT (strftime('%s','now')));
|
|
|
|
| 110 |
const usersCols = db.prepare("PRAGMA table_info(users)").all().map(c => c.name);
|
| 111 |
if (!usersCols.includes('tos_accepted')) db.exec(`ALTER TABLE users ADD COLUMN tos_accepted INTEGER DEFAULT 0;`);
|
| 112 |
if (!usersCols.includes('avatar')) db.exec(`ALTER TABLE users ADD COLUMN avatar BLOB;`);
|
| 113 |
+
|
| 114 |
+
// ✅ Migration permission
|
| 115 |
+
const usCols = db.prepare("PRAGMA table_info(user_shares)").all().map(c => c.name);
|
| 116 |
+
if (!usCols.includes('permission')) db.exec(`ALTER TABLE user_shares ADD COLUMN permission TEXT DEFAULT 'read';`);
|
| 117 |
+
|
| 118 |
const filesCols = db.prepare("PRAGMA table_info(files)").all().map(c => c.name);
|
| 119 |
const filesDefs = [
|
| 120 |
{ name: 'folder_id', def: 'INTEGER REFERENCES folders(id) ON DELETE SET NULL' },
|
|
|
|
| 137 |
const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table'").all().map(t => t.name);
|
| 138 |
if (!tables.includes('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));`);
|
| 139 |
if (!tables.includes('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, views INTEGER DEFAULT 0, downloads INTEGER DEFAULT 0);`);
|
| 140 |
+
if (!tables.includes('user_shares')) db.exec(`CREATE TABLE user_shares (id INTEGER PRIMARY KEY AUTOINCREMENT, file_id TEXT REFERENCES files(id) ON DELETE CASCADE, folder_id INTEGER REFERENCES folders(id) ON DELETE CASCADE, owner_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, shared_with INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, permission TEXT DEFAULT 'read', created_at INTEGER DEFAULT (strftime('%s','now'))); CREATE INDEX idx_ushares_with ON user_shares(shared_with);`);
|
| 141 |
if (!tables.includes('transfer_daily')) db.exec(`CREATE TABLE transfer_daily (id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, day TEXT NOT NULL, up_bytes INTEGER DEFAULT 0, down_bytes INTEGER DEFAULT 0, UNIQUE(user_id, day)); CREATE INDEX idx_transfer_day ON transfer_daily(user_id, day);`);
|
| 142 |
if (!tables.includes('activity')) db.exec(`CREATE TABLE activity (id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER, action TEXT NOT NULL, detail TEXT, created_at INTEGER DEFAULT (strftime('%s','now'))); CREATE INDEX idx_activity_user ON activity(user_id, created_at);`);
|
| 143 |
if (!tables.includes('notifications')) db.exec(`CREATE TABLE notifications (id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, type TEXT NOT NULL, detail TEXT, read INTEGER DEFAULT 0, created_at INTEGER DEFAULT (strftime('%s','now'))); CREATE INDEX idx_notif_user ON notifications(user_id, read, created_at);`);
|
|
|
|
| 294 |
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()]); }
|
| 295 |
async function getMasterKey(fid){ const r=db.prepare('SELECT master_key_enc FROM files WHERE id=?').get(fid); if(!r?.master_key_enc) throw new Error('Clé manquante'); return decryptMasterKey(r.master_key_enc); }
|
| 296 |
|
| 297 |
+
// ✅ LOGIQUE PERMISSIONS
|
| 298 |
+
function canAccessFolder(userId, folderId) {
|
| 299 |
+
let cur = db.prepare('SELECT * FROM folders WHERE id=?').get(folderId);
|
| 300 |
+
let depth = 0;
|
| 301 |
+
while (cur && depth < 50) {
|
| 302 |
+
if (cur.owner_id === userId) return { access: true, perm: 'write' };
|
| 303 |
+
const share = db.prepare('SELECT permission FROM user_shares WHERE folder_id=? AND shared_with=?').get(cur.id, userId);
|
| 304 |
+
if (share) return { access: true, perm: share.permission };
|
| 305 |
+
cur = cur.parent_id ? db.prepare('SELECT * FROM folders WHERE id=?').get(cur.parent_id) : null;
|
| 306 |
+
depth++;
|
| 307 |
+
}
|
| 308 |
+
return { access: false, perm: null };
|
| 309 |
+
}
|
| 310 |
+
|
| 311 |
+
function canAccessFile(userId, fileId) {
|
| 312 |
+
const f = db.prepare('SELECT * FROM files WHERE id=?').get(fileId);
|
| 313 |
+
if (!f) return null;
|
| 314 |
+
if (f.owner_id === userId) return { ...f, perm: 'write' };
|
| 315 |
+
const share = db.prepare('SELECT permission FROM user_shares WHERE file_id=? AND shared_with=?').get(fileId, userId);
|
| 316 |
+
if (share) return { ...f, perm: share.permission };
|
| 317 |
+
if (f.folder_id) {
|
| 318 |
+
const folderAccess = canAccessFolder(userId, f.folder_id);
|
| 319 |
+
if (folderAccess.access) return { ...f, perm: folderAccess.perm };
|
| 320 |
+
}
|
| 321 |
+
return null;
|
| 322 |
+
}
|
| 323 |
+
|
| 324 |
function isEditable(f) { return (f.mime && (f.mime.startsWith('text/') || ['application/json','application/javascript','application/xml'].includes(f.mime))) || EDITABLE_RE.test(f.name || ''); }
|
| 325 |
|
| 326 |
async function decryptWholeFile(f) { const k = await getMasterKey(f.id); const chunks = db.prepare('SELECT chunk_index,imgbb_url,size FROM file_chunks WHERE file_id=? ORDER BY chunk_index').all(f.id); const bufs = []; for (const c of chunks) { const ck = `chunk:${f.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.size + OVERHEAD); dec = decryptChunk(enc, k); fileCache.set(ck,{data:dec,size:dec.length}); } bufs.push(dec); } return Buffer.concat(bufs, f.size); }
|
|
|
|
| 342 |
const assembledPath = (fid) => join(TEMP_DIR, 'assemble_' + fid);
|
| 343 |
const pad6 = (i) => String(i).padStart(6, '0');
|
| 344 |
|
| 345 |
+
async function imgbbUploadRetry(buf, tries = 4) {
|
| 346 |
+
for (let i = 0; i < tries; i++) {
|
| 347 |
+
try { return await imgbbUpload(buf); }
|
| 348 |
+
catch (e) {
|
| 349 |
+
const status = e.response?.status;
|
| 350 |
+
if (i === tries - 1) throw e;
|
| 351 |
+
const wait = status === 429 ? 4000 * (i + 1) : 1200 * (i + 1);
|
| 352 |
+
log('WARN', `ImgBB retry ${i+1} (HTTP ${status || 'net'}) dans ${wait}ms`);
|
| 353 |
+
await new Promise(r => setTimeout(r, wait));
|
| 354 |
+
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 355 |
}
|
| 356 |
}
|
| 357 |
|
| 358 |
+
async function processUpload(fid, path, fsize, username, uid) {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 359 |
const t0 = Date.now();
|
| 360 |
const mk = await getMasterKey(fid);
|
| 361 |
const frow = db.prepare('SELECT mime FROM files WHERE id=?').get(fid);
|
| 362 |
const mime = frow?.mime || 'application/octet-stream';
|
| 363 |
const cc = Math.ceil(fsize / CHUNK_SIZE);
|
| 364 |
+
log('INFO', `Traitement démarré: ${fid} par ${username} (${(fsize/1048576).toFixed(2)} MB, ${cc} chunk(s)) — parallélisme x2`);
|
| 365 |
+
try {
|
| 366 |
db.prepare("UPDATE files SET status='uploading' WHERE id=?").run(fid);
|
| 367 |
+
if (mime.startsWith('image/')) {
|
| 368 |
+
const c0 = db.prepare('SELECT size FROM file_chunks WHERE file_id=? AND chunk_index=0').get(fid);
|
| 369 |
+
const raw0 = await readChunk(path, 0, c0.size);
|
| 370 |
+
const scan = await checkNsfw(raw0);
|
| 371 |
+
if (!scan.safe) { log('ALERT', `NSFW bloqué: ${fid}`); throw new Error('Contenu bloqué par le filtre de modération'); }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 372 |
}
|
| 373 |
+
let next = 0, done = 0, err = null;
|
| 374 |
+
const CONC = 2;
|
| 375 |
+
async function worker() {
|
| 376 |
+
while (true) {
|
| 377 |
+
if (err) return;
|
| 378 |
+
const i = next++;
|
| 379 |
+
if (i >= cc) return;
|
| 380 |
+
const ci = db.prepare('SELECT size, encrypted_len, imgbb_url FROM file_chunks WHERE file_id=? AND chunk_index=?').get(fid, i);
|
| 381 |
+
if (ci.imgbb_url) { done++; const prog = (done / cc) * 50; db.prepare('UPDATE files SET progress=? WHERE id=?').run(prog, fid); broadcast(fid, {type:'progress', progress:prog, status:'uploading'}); continue; }
|
| 382 |
+
try {
|
| 383 |
+
const raw = await readChunk(path, i * CHUNK_SIZE, ci.size);
|
| 384 |
+
const enc = encryptChunk(raw, mk);
|
| 385 |
+
const png = await bufferToPng(enc);
|
| 386 |
+
const up = await imgbbUploadRetry(png);
|
| 387 |
+
db.prepare('UPDATE file_chunks SET imgbb_url=?,imgbb_delete_url=?,imgbb_id=? WHERE file_id=? AND chunk_index=?').run(up.url, up.deleteUrl, up.id, fid, i);
|
| 388 |
+
done++;
|
| 389 |
+
const prog = (done / cc) * 50;
|
| 390 |
+
db.prepare('UPDATE files SET progress=?,status=? WHERE id=?').run(prog, 'uploading', fid);
|
| 391 |
+
broadcast(fid, {type:'progress', progress:prog, status:'uploading'});
|
| 392 |
+
} catch (e) { err = e; return; }
|
| 393 |
+
}
|
| 394 |
+
}
|
| 395 |
+
await Promise.all(Array.from({ length: Math.min(CONC, cc) }, () => worker()));
|
| 396 |
+
if (err) throw err;
|
| 397 |
+
try { unlinkSync(path); } catch (_) {}
|
| 398 |
db.prepare("UPDATE files SET status='processing',progress=50 WHERE id=?").run(fid);
|
| 399 |
+
broadcast(fid, {type:'progress', progress:50, status:'processing'});
|
| 400 |
+
await genThumb(fid, mime, mk);
|
| 401 |
db.prepare("UPDATE files SET status='ready',progress=100 WHERE id=?").run(fid);
|
| 402 |
+
broadcast(fid, {type:'progress', progress:100, status:'ready', type:'done'});
|
| 403 |
addTransfer(uid, fsize, 'up');
|
| 404 |
logActivity(uid, 'upload', path.split('/').pop());
|
| 405 |
+
const secs = (Date.now() - t0) / 1000;
|
| 406 |
log('INFO', `Traitement terminé: ${fid} en ${secs.toFixed(1)}s (${(fsize/1048576/secs).toFixed(2)} MB/s)`);
|
| 407 |
+
} catch (e) {
|
| 408 |
log('ERROR', `Traitement échoué ${fid}: ${e.message}`);
|
| 409 |
+
db.prepare("UPDATE files SET status='error',error_msg=? WHERE id=?").run(e.message, fid);
|
| 410 |
+
broadcast(fid, {type:'error', error:e.message});
|
| 411 |
+
const cs = db.prepare('SELECT imgbb_delete_url FROM file_chunks WHERE file_id=?').all(fid);
|
| 412 |
+
for (const c of cs) if (c.imgbb_delete_url) await imgbbDelete(c.imgbb_delete_url);
|
| 413 |
}
|
| 414 |
}
|
| 415 |
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);});}
|
|
|
|
| 418 |
// ROUTES
|
| 419 |
// ==========================================
|
| 420 |
app.post('/api/register', registerLimit, async(req,res)=>{ const {u,p,t} = req.body; if(!u||!p||!t) return res.status(400).json({error:'Champs manquants'}); if(typeof u !== 'string' || !/^[a-zA-Z0-9_.\-]{3,20}$/.test(u)) return res.status(400).json({error:'Nom invalide'}); if(typeof p !== 'string' || p.length < 6) return res.status(400).json({error:'Mot de passe trop court'}); 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); log('INFO', `Nouvel utilisateur: ${u}`); res.json({ok:true}); });
|
| 421 |
+
app.post('/api/login', loginLimit, 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(!timingSafeEqual(h, usr.password_hash)) return res.status(401).json({error:'Invalide'}); try { await new Promise((ok, bad) => req.session.regenerate(e => e ? bad(e) : ok())); } catch(e) { return res.status(500).json({error:'Erreur session'}); } req.session.userId = usr.id; req.session.isAdmin = !!usr.is_admin; req.session.tosAccepted = !!usr.tos_accepted; req.session.username = usr.username; log('INFO', `Connexion: ${u}`); logActivity(usr.id, 'login', 'Connexion'); res.json({ok:true,user:{id:usr.id,username:usr.username,isAdmin:!!usr.is_admin,tosAccepted:!!usr.tos_accepted}}); });
|
| 422 |
app.post('/api/logout',(req,res)=>req.session.destroy(()=>res.json({ok:true})));
|
| 423 |
app.get('/api/me',(req,res)=>{ if(!req.session.userId) return res.status(401).json({error:'Non connecté'}); const used = db.prepare("SELECT COALESCE(SUM(size),0) as t FROM files WHERE owner_id=? AND status!='error'").get(req.session.userId).t; const tr = getTransfer(req.session.userId); res.json({ id:req.session.userId, username:req.session.username, isAdmin:req.session.isAdmin, tosAccepted:req.session.tosAccepted, quotaUsed:used, quotaMax:USER_QUOTA, transferUp:tr.up, transferDown:tr.down, transferUsed:tr.total, transferMax:DAILY_TRANSFER }); });
|
| 424 |
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`));
|
| 425 |
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});});
|
| 426 |
app.get('/api/notifications', requireAuth, (req,res) => { res.json(db.prepare('SELECT id, type, detail, read, created_at FROM notifications WHERE user_id=? ORDER BY created_at DESC LIMIT 50').all(req.session.userId)); });
|
| 427 |
app.post('/api/notifications/read', requireAuth, (req,res) => { db.prepare('UPDATE notifications SET read=1 WHERE user_id=?').run(req.session.userId); res.json({ok:true}); });
|
| 428 |
+
app.post('/api/password', requireAuth, async(req,res)=>{ const { current, next } = req.body; if (typeof current !== 'string' || typeof next !== 'string') return res.status(400).json({error:'Champs manquants'}); if (next.length < 6) return res.status(400).json({error:'Trop court'}); const usr = db.prepare('SELECT * FROM users WHERE id=?').get(req.session.userId); const hCur = await deriveKey(current, usr.salt); if (!timingSafeEqual(hCur, usr.password_hash)) return res.status(403).json({error:'Mot de passe actuel invalide'}); const salt = randomBytes(16); const hNew = await deriveKey(next, salt); db.prepare('UPDATE users SET password_hash=?, salt=? WHERE id=?').run(hNew, salt, usr.id); logActivity(usr.id, 'password', 'Mot de passe modifié'); res.json({ok:true}); });
|
| 429 |
+
app.delete('/api/account', requireAuth, async(req,res)=>{ const { password } = req.body; const usr = db.prepare('SELECT * FROM users WHERE id=?').get(req.session.userId); const h = await deriveKey(String(password || ''), usr.salt); if (!timingSafeEqual(h, usr.password_hash)) return res.status(403).json({error:'Mot de passe invalide'}); const urls = db.prepare('SELECT fc.imgbb_delete_url FROM file_chunks fc JOIN files f ON f.id=fc.file_id WHERE f.owner_id=? AND fc.imgbb_delete_url IS NOT NULL').all(usr.id).map(r => r.imgbb_delete_url); db.prepare('DELETE FROM users WHERE id=?').run(usr.id); (async () => { for (const u of urls) await imgbbDelete(u); })(); req.session.destroy(() => res.json({ok:true})); });
|
| 430 |
app.get('/api/stats', requireAuth, (req,res) => { res.json(db.prepare(`SELECT CASE WHEN mime LIKE 'image/%' THEN 'image' WHEN mime LIKE 'video/%' THEN 'video' WHEN mime LIKE 'audio/%' THEN 'audio' ELSE 'autre' END as cat, COUNT(*) c, COALESCE(SUM(size),0) s FROM files WHERE owner_id=? AND status!='error' GROUP BY cat`).all(req.session.userId)); });
|
| 431 |
app.get('/api/stats/advanced', requireAuth, (req,res) => { const days = []; for (let i = 6; i >= 0; i--) { const d = new Date(Date.now() - i*86400000).toISOString().slice(0,10); const r = db.prepare('SELECT COALESCE(SUM(up_bytes),0) u, COALESCE(SUM(down_bytes),0) d FROM transfer_daily WHERE user_id=? AND day=?').get(req.session.userId, d); const acts = db.prepare("SELECT COUNT(*) c FROM activity WHERE user_id=? AND strftime('%Y-%m-%d', created_at, 'unixepoch')=?").get(req.session.userId, d).c; days.push({ day: d.slice(5), up: r.u, down: r.d, acts }); } const top = db.prepare(`SELECT f.name, (SELECT COALESCE(SUM(downloads),0) FROM share_links sl WHERE sl.file_id=f.id) dl FROM files f WHERE f.owner_id=? AND f.status='ready' ORDER BY dl DESC LIMIT 5`).all(req.session.userId); res.json({ days, top }); });
|
| 432 |
app.get('/api/activity', requireAuth, (req,res) => { res.json(db.prepare('SELECT action, detail, created_at FROM activity WHERE user_id=? ORDER BY created_at DESC LIMIT 100').all(req.session.userId)); });
|
|
|
|
| 437 |
app.post('/api/admin/users/:id/toggle-admin', requireAuth, requireAdmin, (req,res) => { const id = parseInt(req.params.id, 10); if (!id) return res.status(400).json({error:'ID invalide'}); if (id === req.session.userId) return res.status(400).json({error:'Non'}); db.prepare('UPDATE users SET is_admin = 1 - is_admin WHERE id=?').run(id); res.json({ok:true}); });
|
| 438 |
app.post('/api/avatar', requireAuth, avatarLimit, async(req,res)=>{ const { data } = req.body; if (!data || !data.startsWith('data:image/')) return res.status(400).json({error:'Image invalide'}); try { const buf = Buffer.from(data.split(',')[1], 'base64'); if (buf.length > 5*1024*1024) return res.status(400).json({error:'Trop lourde'}); const jpeg = await sharp(buf).resize(128,128,{fit:'cover'}).jpeg({quality:82}).toBuffer(); db.prepare('UPDATE users SET avatar=? WHERE id=?').run(jpeg, req.session.userId); res.json({ok:true}); } catch(e){ res.status(400).json({error:'Image invalide'}); } });
|
| 439 |
app.delete('/api/avatar', requireAuth, avatarLimit, (req,res)=>{ db.prepare('UPDATE users SET avatar=NULL WHERE id=?').run(req.session.userId); res.json({ok:true}); });
|
| 440 |
+
app.get('/api/users/:id/avatar', requireAuth, (req,res)=>{ const u = db.prepare('SELECT avatar FROM users WHERE id=?').get(req.params.id); if (!u || !u.avatar) return res.status(404).json({error:"Pas d'avatar"}); res.set({'Content-Type':'image/jpeg','Cache-Control':'public, max-age=3600'}); res.send(u.avatar); });
|
| 441 |
+
|
| 442 |
+
// ✅ DOSSIERS (avec permissions)
|
| 443 |
+
app.get('/api/folders',requireAuth,(req,res)=>{
|
| 444 |
+
const p = req.query.parent_id ? parseInt(req.query.parent_id) : null;
|
| 445 |
+
if (p !== null) {
|
| 446 |
+
const access = canAccessFolder(req.session.userId, p);
|
| 447 |
+
if (!access.access) return res.status(403).json({error:'Accès refusé'});
|
| 448 |
+
return res.json(db.prepare('SELECT * FROM folders WHERE parent_id=? ORDER BY name COLLATE NOCASE').all(p));
|
| 449 |
+
}
|
| 450 |
+
// Racine : mes dossiers + dossiers partagés à la racine
|
| 451 |
+
const mine = db.prepare('SELECT *, \'write\' as perm FROM folders WHERE owner_id=? AND parent_id IS NULL ORDER BY name COLLATE NOCASE').all(req.session.userId);
|
| 452 |
+
const shared = db.prepare('SELECT f.*, us.permission as perm, u.username as owner_name FROM folders f JOIN user_shares us ON us.folder_id=f.id JOIN users u ON u.id=f.owner_id WHERE us.shared_with=? AND f.parent_id IS NULL ORDER BY f.name COLLATE NOCASE').all(req.session.userId);
|
| 453 |
+
res.json([...mine, ...shared]);
|
| 454 |
+
});
|
| 455 |
+
|
| 456 |
app.post('/api/folders',requireAuth,requireTos,(req,res)=>{const{n,p}=req.body;if(!n||typeof n!=='string'||n.length>100)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'});}});
|
| 457 |
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});});
|
| 458 |
app.post('/api/folders/:id/rename',requireAuth,(req,res)=>{ const { name } = req.body; if(!name || typeof name !== 'string' || !name.trim() || name.length > 100) return res.status(400).json({error:'Nom invalide'}); const fo = db.prepare('SELECT id FROM folders WHERE id=? AND owner_id=?').get(req.params.id, req.session.userId); if(!fo) return res.status(404).json({error:'Introuvable'}); try { db.prepare('UPDATE folders SET name=? WHERE id=?').run(name.trim(), req.params.id); logActivity(req.session.userId,'rename',name.trim()); res.json({ok:true}); } catch(e){ res.status(409).json({error:'Nom déjà utilisé'}); } });
|
| 459 |
app.post('/api/folders/:id/move',requireAuth,(req,res)=>{ const { parent_id } = req.body; const fo = db.prepare('SELECT * FROM folders WHERE id=? AND owner_id=?').get(req.params.id, req.session.userId); if(!fo) return res.status(404).json({error:'Introuvable'}); if (parent_id) { const target = db.prepare('SELECT * FROM folders WHERE id=?').get(parent_id); if (!target || target.owner_id !== req.session.userId) return res.status(403).json({error:'Cible invalide'}); let cur = target, depth = 0; while (cur && depth < 50) { if (cur.id === fo.id) return res.status(400).json({error:'Boucle détectée'}); cur = cur.parent_id ? db.prepare('SELECT * FROM folders WHERE id=?').get(cur.parent_id) : null; depth++; } } db.prepare('UPDATE folders SET parent_id=? WHERE id=?').run(parent_id || null, req.params.id); logActivity(req.session.userId,'move',fo.name); res.json({ok:true}); });
|
| 460 |
+
|
| 461 |
+
// ✅ FICHIERS (avec permissions)
|
| 462 |
+
app.get('/api/files',requireAuth,(req,res)=>{
|
| 463 |
+
const f = req.query.folder_id ? parseInt(req.query.folder_id) : null;
|
| 464 |
+
if (f !== null) {
|
| 465 |
+
const access = canAccessFolder(req.session.userId, f);
|
| 466 |
+
if (!access.access) return res.status(403).json({error:'Accès refusé'});
|
| 467 |
+
return res.json(db.prepare('SELECT * FROM files WHERE folder_id=? ORDER BY name COLLATE NOCASE').all(f));
|
| 468 |
+
}
|
| 469 |
+
// Racine : mes fichiers + fichiers partagés à la racine
|
| 470 |
+
const mine = db.prepare('SELECT *, \'write\' as perm FROM files WHERE owner_id=? AND folder_id IS NULL ORDER BY name COLLATE NOCASE').all(req.session.userId);
|
| 471 |
+
const shared = db.prepare('SELECT f.*, us.permission as perm, u.username as owner_name FROM files f JOIN user_shares us ON us.file_id=f.id JOIN users u ON u.id=f.owner_id WHERE us.shared_with=? AND f.folder_id IS NULL ORDER BY f.name COLLATE NOCASE').all(req.session.userId);
|
| 472 |
+
res.json([...mine, ...shared]);
|
| 473 |
+
});
|
| 474 |
+
|
| 475 |
app.post('/api/files/:id/rename',requireAuth,(req,res)=>{ if(!UUID_RE.test(req.params.id)) return res.status(400).json({error:'ID invalide'}); const { name } = req.body; if(!name || typeof name !== 'string' || !name.trim() || name.length > 200) return res.status(400).json({error:'Nom invalide'}); const f = db.prepare('SELECT id FROM files WHERE id=? AND owner_id=?').get(req.params.id, req.session.userId); if(!f) return res.status(404).json({error:'Introuvable'}); db.prepare('UPDATE files SET name=? WHERE id=?').run(name.trim(), req.params.id); logActivity(req.session.userId,'rename',name.trim()); res.json({ok:true}); });
|
| 476 |
app.post('/api/files/:id/move',requireAuth,(req,res)=>{ if(!UUID_RE.test(req.params.id)) return res.status(400).json({error:'ID invalide'}); const { folder_id } = req.body; const f = db.prepare('SELECT id FROM files WHERE id=? AND owner_id=?').get(req.params.id, req.session.userId); if(!f) return res.status(404).json({error:'Introuvable'}); if (folder_id) { const target = db.prepare('SELECT owner_id FROM folders WHERE id=?').get(folder_id); if (!target || target.owner_id !== req.session.userId) return res.status(403).json({error:'Cible invalide'}); } db.prepare('UPDATE files SET folder_id=? WHERE id=?').run(folder_id || null, req.params.id); logActivity(req.session.userId,'move',f.name); res.json({ok:true}); });
|
| 477 |
+
|
| 478 |
+
// ✅ PARTAGE UTILISATEUR (avec permission)
|
| 479 |
+
app.post('/api/share-user',requireAuth,requireTos,shareLimit,(req,res)=>{
|
| 480 |
+
const { type, id, username, permission } = req.body;
|
| 481 |
+
if (!type || !id || !username) return res.status(400).json({error:'Champs manquants'});
|
| 482 |
+
const perm = (permission === 'write') ? 'write' : 'read';
|
| 483 |
+
const target = db.prepare('SELECT id FROM users WHERE username=?').get(String(username).trim());
|
| 484 |
+
if (!target) return res.status(404).json({error:'Utilisateur introuvable'});
|
| 485 |
+
if (target.id === req.session.userId) return res.status(400).json({error:'Impossible avec vous-même'});
|
| 486 |
+
const col = type === 'file' ? 'file_id' : 'folder_id';
|
| 487 |
+
const own = db.prepare(`SELECT id, name FROM ${type === 'file' ? 'files' : 'folders'} WHERE id=? AND owner_id=?`).get(id, req.session.userId);
|
| 488 |
+
if (!own) return res.status(404).json({error:'Introuvable'});
|
| 489 |
+
if (db.prepare(`SELECT 1 FROM user_shares WHERE ${col}=? AND shared_with=?`).get(id, target.id)) return res.status(409).json({error:'Déjà partagé'});
|
| 490 |
+
db.prepare(`INSERT INTO user_shares(${col},owner_id,shared_with,permission) VALUES(?,?,?,?)`).run(id, req.session.userId, target.id, perm);
|
| 491 |
+
notify(target.id, 'share', `${req.session.username} → ${own.name} (${perm})`);
|
| 492 |
+
logActivity(req.session.userId,'share_user',`${type} → ${username.trim()} (${perm})`);
|
| 493 |
+
res.json({ok:true});
|
| 494 |
+
});
|
| 495 |
+
|
| 496 |
+
app.get('/api/shared-with-me',requireAuth,(req,res)=>{ res.json(db.prepare(`SELECT us.id as share_id, us.created_at as shared_at, us.permission, u.username as owner_name, us.owner_id, us.file_id, us.folder_id, f.name as file_name, f.mime, f.size, f.status, fo.name as folder_name FROM user_shares us JOIN users u ON u.id = us.owner_id LEFT JOIN files f ON f.id = us.file_id LEFT JOIN folders fo ON fo.id = us.folder_id WHERE us.shared_with = ? ORDER BY us.created_at DESC`).all(req.session.userId)); });
|
| 497 |
app.delete('/api/shared-with-me/:id',requireAuth,(req,res)=>{ db.prepare('DELETE FROM user_shares WHERE id=? AND shared_with=?').run(req.params.id, req.session.userId); res.json({ok:true}); });
|
| 498 |
+
app.get('/api/my-shares',requireAuth,(req,res)=>{ res.json(db.prepare(`SELECT us.id, us.created_at, us.permission, u.username as shared_with_name, us.file_id, us.folder_id, f.name as file_name, f.mime, f.size, fo.name as folder_name FROM user_shares us JOIN users u ON u.id = us.shared_with LEFT JOIN files f ON f.id = us.file_id LEFT JOIN folders fo ON fo.id = us.folder_id WHERE us.owner_id = ? ORDER BY us.created_at DESC`).all(req.session.userId)); });
|
| 499 |
app.delete('/api/my-shares/:id',requireAuth,(req,res)=>{ const r = db.prepare('DELETE FROM user_shares WHERE id=? AND owner_id=?').run(req.params.id, req.session.userId); if (!r.changes) return res.status(404).json({error:'Introuvable'}); res.json({ok:true}); });
|
| 500 |
app.get('/api/share-links',requireAuth,(req,res)=>{ res.json(db.prepare(`SELECT sl.*, f.name as file_name, f.mime, f.size FROM share_links sl LEFT JOIN files f ON sl.file_id = f.id WHERE sl.owner_id = ? ORDER BY sl.created_at DESC`).all(req.session.userId)); });
|
| 501 |
app.delete('/api/share-links/:token',requireAuth,(req,res)=>{ const r = db.prepare('DELETE FROM share_links WHERE token=? AND owner_id=?').run(req.params.token, req.session.userId); if (!r.changes) return res.status(404).json({error:'Introuvable'}); res.json({ok:true}); });
|
| 502 |
+
|
| 503 |
app.get('/api/files/:id/download',requireAuth,async(req,res)=>{ if(!UUID_RE.test(req.params.id)) return res.status(400).json({error:'ID invalide'}); if (transferBlocked(req.session.userId)) return res.status(429).json({error:'Quota journalier atteint'}); const f = canAccessFile(req.session.userId, req.params.id); 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}); logActivity(req.session.userId,'download',f.name); const k = await getMasterKey(f.id); await streamFile(res,f,k,req.headers.range,req.session.userId); });
|
| 504 |
app.get('/api/files/:id/raw', requireAuth, async(req,res)=>{ if(!UUID_RE.test(req.params.id)) return res.status(400).json({error:'ID invalide'}); const f = canAccessFile(req.session.userId, req.params.id); if(!f) return res.status(404).json({error:'Introuvable'}); if(f.status!=='ready') return res.status(409).json({error:'Pas prêt'}); if (f.size > 2*1024*1024) return res.status(413).json({error:'Trop lourd pour aperçu (2 MB max)'}); try { const all = await decryptWholeFile(f); res.set({'Content-Type': (f.mime && f.mime.startsWith('text/')) ? f.mime : 'text/plain; charset=utf-8'}); res.send(all); } catch (e) { res.status(500).json({error:'Erreur déchiffrement'}); } });
|
| 505 |
+
app.post('/api/files/:id/edit', requireAuth, requireTos, async(req,res)=>{ if(!UUID_RE.test(req.params.id)) return res.status(400).json({error:'ID invalide'}); const { content } = req.body; if (typeof content !== 'string') return res.status(400).json({error:'Contenu manquant'}); const f = canAccessFile(req.session.userId, req.params.id); if(!f) return res.status(404).json({error:'Introuvable'}); if (f.perm !== 'write') return res.status(403).json({error:'Lecture seule'}); if (!isEditable(f)) return res.status(400).json({error:'Non éditable'}); const buf = Buffer.from(content, 'utf8'); if (buf.length > CHUNK_SIZE) return res.status(413).json({error:'Trop lourd (30 MB max)'}); try { const mk = decryptMasterKey(f.master_key_enc); const enc = encryptChunk(buf, mk); const png = await bufferToPng(enc); const {url, deleteUrl, id: imgId} = await imgbbUpload(png); const olds = db.prepare('SELECT imgbb_delete_url FROM file_chunks WHERE file_id=?').all(f.id); db.prepare('DELETE FROM file_chunks WHERE file_id=?').run(f.id); db.prepare('INSERT INTO file_chunks(file_id,chunk_index,size,encrypted_len) VALUES(?,?,?,?)').run(f.id,0,buf.length,enc.length); db.prepare('UPDATE files SET size=?, chunk_count=1, status=? WHERE id=?').run(buf.length,'ready',f.id); fileCache.delete(`chunk:${f.id}:0`); for (const o of olds) if (o.imgbb_delete_url) await imgbbDelete(o.imgbb_delete_url); logActivity(req.session.userId,'edit',f.name); res.json({ok:true, size: buf.length}); } catch (e) { res.status(500).json({error:"Erreur d'enregistrement"}); } });
|
| 506 |
app.get('/api/files/:id/properties', requireAuth, async(req,res)=>{ if(!UUID_RE.test(req.params.id)) return res.status(400).json({error:'ID invalide'}); const f = canAccessFile(req.session.userId, req.params.id); if(!f) return res.status(404).json({error:'Introuvable'}); const chunks = db.prepare('SELECT imgbb_url, size FROM file_chunks WHERE file_id=? ORDER BY chunk_index').all(f.id); const hash = createHash('sha256'); for (const c of chunks) { try { const { data } = await axios.get(c.imgbb_url, { responseType:'arraybuffer', timeout:30000 }); hash.update(Buffer.from(data)); } catch (e) { return res.status(500).json({error:'Erreur de calcul'}); } } res.json({ id: f.id, name: f.name, size: f.size, mime: f.mime, status: f.status, created_at: f.created_at, updated_at: f.updated_at, chunk_count: f.chunk_count, encryption_key_hash: f.encryption_key_hash, sha256: hash.digest('hex'), owner_id: f.owner_id }); });
|
| 507 |
app.post('/api/files/:id/share',requireAuth,shareLimit,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:'Non disponible'}); const ttlReq = parseInt(req.body.ttl, 10); const ttl = TTL_ALLOWED.includes(ttlReq) ? ttlReq : 168; const pt=uuidv4(), dt2=uuidv4(), expires=Date.now()+ttl*3600000; db.prepare('INSERT INTO share_links(token,file_id,owner_id,type,expires_at) VALUES(?,?,?,?,?)').run(pt,f.id,req.session.userId,'embed',expires); db.prepare('INSERT INTO share_links(token,file_id,owner_id,type,expires_at) VALUES(?,?,?,?,?)').run(dt2,f.id,req.session.userId,'download',expires); logActivity(req.session.userId,'share_link',`${f.name} (${ttl}h)`); res.json({preview:`/s/${pt}`,download:`/d/${dt2}`,expires,ttl}); });
|
| 508 |
app.get('/s/:token',async(req,res)=>{ const tpl = embedTpl(); const serve = (status, data, og) => { if (!tpl) return res.status(status).send('Lien invalide'); const out = tpl.replace('__OG_META__', og || '').replace('__EMBED_DATA__', JSON.stringify(data).replace(/</g, '\\u003c')); res.status(status).send(out); }; const l=db.prepare('SELECT * FROM share_links WHERE token=? AND type=? AND (expires_at IS NULL OR expires_at>?)').get(req.params.token,'embed',Date.now()); if (!l) return serve(404, { error: 'Lien invalide ou expiré' }); const f=db.prepare('SELECT * FROM files WHERE id=?').get(l.file_id); if (!f) return serve(404, { error: 'Fichier supprimé' }); db.prepare('UPDATE share_links SET views = views + 1 WHERE token=?').run(req.params.token); logActivity(f.owner_id, 'public_view', f.name); let dt; const existing = db.prepare('SELECT token FROM share_links WHERE file_id=? AND type=? AND owner_id=? AND (expires_at IS NULL OR expires_at>?)').get(f.id,'download',l.owner_id,Date.now()); if (existing) dt = existing.token; else { dt = uuidv4(); db.prepare('INSERT INTO share_links(token,file_id,owner_id,type,expires_at) VALUES(?,?,?,?,?)').run(dt,f.id,l.owner_id,'download',Date.now()+3600000); } const og = `<meta property="og:type" content="website"><meta property="og:site_name" content="PixelDrive"><meta property="og:title" content="${esc(f.name)}"><meta property="og:description" content="${(f.size/1e6).toFixed(1)} MB · Partage sécurisé PixelDrive"><meta property="og:image" content="${req.protocol}://${req.get('host')}/banner.png"><meta name="twitter:card" content="summary_large_image"><meta name="twitter:title" content="${esc(f.name)}"><meta name="twitter:image" content="${req.protocol}://${req.get('host')}/banner.png">`; serve(200, { name: f.name, size: f.size, mime: f.mime, dl: '/d/' + dt }, og); });
|
| 509 |
app.get('/d/:token',async(req,res)=>{ const l=db.prepare('SELECT * FROM share_links WHERE token=? AND type=? AND (expires_at IS NULL OR expires_at>?)').get(req.params.token,'download',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'}); if (transferBlocked(f.owner_id)) return res.status(429).json({error:'Quota propriétaire atteint'}); db.prepare('UPDATE share_links SET downloads = downloads + 1 WHERE token=?').run(req.params.token); logActivity(f.owner_id, 'public_download', f.name); 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,f.owner_id); });
|
| 510 |
app.delete('/api/files/:id',requireAuth,async(req,res)=>{ if(!UUID_RE.test(req.params.id)) return res.status(400).json({error:'ID invalide'}); 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(_){} logActivity(req.session.userId,'delete',f.name); res.json({ok:true}); });
|
| 511 |
+
app.get('/api/files/:id/thumb',requireAuth,(req,res)=>{ if(!UUID_RE.test(req.params.id)) return res.status(400).json({error:'ID invalide'}); const f = canAccessFile(req.session.userId, req.params.id); if(!f) return res.status(404).json({error:'Introuvable'}); 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>`); });
|
| 512 |
+
app.get('/api/files/:id/progress',requireAuth,(req,res)=>{ if(!UUID_RE.test(req.params.id)) return res.status(400).json({error:'ID invalide'}); const f = canAccessFile(req.session.userId, req.params.id); if(!f) return res.status(404).json({error:'Introuvable'}); const set = sseClients.get(req.params.id); if (set && set.size >= 5) return res.status(429).json({error:'Trop de connexions'}); 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)); });
|
| 513 |
app.get('/health',(_,res)=>res.json({ok:true,cache:fileCache.size,mem:process.memoryUsage()}));
|
| 514 |
|
| 515 |
app.use(express.static(PUBLIC_DIR, { dotfiles: 'deny', index: false }));
|
|
|
|
| 519 |
app.use((err, req, res, next) => { if (err.type === 'entity.parse.failed' || err instanceof SyntaxError) return res.status(400).json({ error: 'Bad Request' }); log('ERROR', `Server: ${err.message}`); res.status(500).json({ error: 'Internal Server Error' }); });
|
| 520 |
|
| 521 |
recoverStuckUploads();
|
| 522 |
+
const server = app.listen(PORT, '0.0.0.0', () => log('INFO', `PixelDrive v5.3 démarré | upload chunké ${UP_CHUNK/1048576} MB | stockage ${(USER_QUOTA/1073741824).toFixed(0)} GB | transit ${(DAILY_TRANSFER/1073741824).toFixed(0)} GB/j`));
|
| 523 |
+
process.on('SIGTERM', () => { log('WARN', 'SIGTERM reçu, arrêt...'); server.close(() => process.exit(0)); });
|
| 524 |
+
|
| 525 |
+
function recoverStuckUploads() {
|
| 526 |
+
const stuck = db.prepare("SELECT id, size, status FROM files WHERE status IN ('uploading','processing')").all();
|
| 527 |
+
for (const f of stuck) {
|
| 528 |
+
try {
|
| 529 |
+
const assembled = assembledPath(f.id);
|
| 530 |
+
if (existsSync(assembled)) { queueProcess(f.id); log('INFO', `Recovery: ${f.id} re-file (processing)`); continue; }
|
| 531 |
+
const total = Math.ceil(f.size / UP_CHUNK);
|
| 532 |
+
let n = 0; try { n = readdirSync(partsDir(f.id)).length; } catch(e){}
|
| 533 |
+
if (total > 0 && n >= total) { assembleParts(f.id); queueProcess(f.id); log('INFO', `Recovery: ${f.id} assemblé + re-file`); }
|
| 534 |
+
} catch(e) { log('WARN', `Recovery ${f.id}: ${e.message}`); }
|
| 535 |
+
}
|
| 536 |
+
}
|
| 537 |
+
function assembleParts(fid) {
|
| 538 |
+
const f = db.prepare('SELECT size FROM files WHERE id=?').get(fid);
|
| 539 |
+
const total = Math.ceil(f.size / UP_CHUNK);
|
| 540 |
+
const dir = partsDir(fid);
|
| 541 |
+
const out = assembledPath(fid);
|
| 542 |
+
for (let i=0;i<total;i++) appendFileSync(out, readFileSync(join(dir, pad6(i))));
|
| 543 |
+
rmSync(dir, { recursive: true, force: true });
|
| 544 |
+
return out;
|
| 545 |
+
}
|
| 546 |
+
function queueProcess(fid) {
|
| 547 |
+
const f = db.prepare('SELECT size, owner_id FROM files WHERE id=?').get(fid);
|
| 548 |
+
if (!f) return;
|
| 549 |
+
const uname = (db.prepare('SELECT username FROM users WHERE id=?').get(f.owner_id)||{}).username || '?';
|
| 550 |
+
uploadQueue.add(() => processUpload(fid, assembledPath(fid), f.size, uname, f.owner_id));
|
| 551 |
+
}
|