Spaces:
Paused
Paused
Update server.js
Browse files
server.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
| 1 |
// ==========================================
|
| 2 |
-
// PIXELDRIVE
|
| 3 |
// ==========================================
|
| 4 |
import express from 'express';
|
| 5 |
import session from 'express-session';
|
|
@@ -8,7 +8,7 @@ import multer from 'multer';
|
|
| 8 |
import { createHash, randomBytes, scrypt, createCipheriv, createDecipheriv } from 'crypto';
|
| 9 |
import { promisify } from 'util';
|
| 10 |
import { createReadStream, unlinkSync, existsSync } from 'fs';
|
| 11 |
-
import { join
|
| 12 |
import { v4 as uuidv4 } from 'uuid';
|
| 13 |
import LRUCache from 'quick-lru';
|
| 14 |
import sharp from 'sharp';
|
|
@@ -19,6 +19,7 @@ import FormData from 'form-data';
|
|
| 19 |
import { fileURLToPath } from 'url';
|
| 20 |
|
| 21 |
const __dirname = fileURLToPath(new URL('.', import.meta.url));
|
|
|
|
| 22 |
const PORT = 7860;
|
| 23 |
const DATA_DIR = '/data';
|
| 24 |
const TEMP_DIR = join(DATA_DIR, 'temp');
|
|
@@ -32,12 +33,25 @@ const IV_LEN = 12;
|
|
| 32 |
const TAG_LEN = 16;
|
| 33 |
const OVERHEAD = IV_LEN + TAG_LEN;
|
| 34 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 35 |
const IMGBB_API_KEY = process.env.IMGBB_API_KEY;
|
| 36 |
const SESSION_SECRET = process.env.SESSION_SECRET;
|
| 37 |
const ADMIN_USER = process.env.ADMIN_USER || 'admin';
|
| 38 |
|
| 39 |
if (!IMGBB_API_KEY || !SESSION_SECRET) {
|
| 40 |
-
|
| 41 |
process.exit(1);
|
| 42 |
}
|
| 43 |
|
|
@@ -51,7 +65,7 @@ async function deriveKey(password, salt) { return scryptAsync(password, salt, KE
|
|
| 51 |
function encryptChunk(dataBuffer, key) { const iv = randomBytes(IV_LEN); const cipher = createCipheriv('aes-256-gcm', key, iv); const encrypted = Buffer.concat([cipher.update(dataBuffer), cipher.final()]); const tag = cipher.getAuthTag(); return Buffer.concat([iv, encrypted, tag]); }
|
| 52 |
function decryptChunk(encBuffer, key) { if (encBuffer.length < OVERHEAD) throw new Error('Buffer trop court'); const iv = encBuffer.subarray(0, IV_LEN); const tag = encBuffer.subarray(encBuffer.length - TAG_LEN); const data = encBuffer.subarray(IV_LEN, encBuffer.length - TAG_LEN); const decipher = createDecipheriv('aes-256-gcm', key, iv); decipher.setAuthTag(tag); return Buffer.concat([decipher.update(data), decipher.final()]); }
|
| 53 |
|
| 54 |
-
// --- PNG STEGO
|
| 55 |
function bufferToPng(buffer) {
|
| 56 |
const byteLen = buffer.length;
|
| 57 |
const pixelsNeeded = Math.ceil(byteLen / 3);
|
|
@@ -65,7 +79,7 @@ async function pngToBuffer(pngBuffer, expectedLen) {
|
|
| 65 |
const { data, info } = await sharp(pngBuffer).raw().toBuffer({ resolveWithObject: true });
|
| 66 |
const channels = info.channels;
|
| 67 |
const rgb = Buffer.alloc(info.width * info.height * 3);
|
| 68 |
-
if (channels === 3)
|
| 69 |
else if (channels === 4) { for (let i = 0, j = 0; i < data.length && j < rgb.length; i += 4, j += 3) { rgb[j] = data[i]; rgb[j+1] = data[i+1]; rgb[j+2] = data[i+2]; } }
|
| 70 |
else if (channels === 1) { for (let i = 0, j = 0; i < data.length && j < rgb.length; i++, j += 3) { rgb[j] = rgb[j+1] = rgb[j+2] = data[i]; } }
|
| 71 |
return rgb.subarray(0, expectedLen);
|
|
@@ -91,7 +105,7 @@ CREATE INDEX IF NOT EXISTS idx_ushares_with ON user_shares(shared_with);
|
|
| 91 |
// MIGRATIONS
|
| 92 |
// ==========================================
|
| 93 |
function runMigrations() {
|
| 94 |
-
|
| 95 |
const usersCols = db.prepare("PRAGMA table_info(users)").all().map(c => c.name);
|
| 96 |
if (!usersCols.includes('tos_accepted')) db.exec(`ALTER TABLE users ADD COLUMN tos_accepted INTEGER DEFAULT 0;`);
|
| 97 |
|
|
@@ -109,6 +123,7 @@ function runMigrations() {
|
|
| 109 |
let updatedAdded = false;
|
| 110 |
for (const col of filesDefs) {
|
| 111 |
if (!filesCols.includes(col.name)) {
|
|
|
|
| 112 |
db.exec(`ALTER TABLE files ADD COLUMN ${col.name} ${col.def};`);
|
| 113 |
if (col.name === 'updated_at') updatedAdded = true;
|
| 114 |
}
|
|
@@ -122,11 +137,12 @@ function runMigrations() {
|
|
| 122 |
|
| 123 |
const sessionsCols = tables.includes('sessions') ? db.prepare("PRAGMA table_info(sessions)").all().map(c => c.name) : [];
|
| 124 |
if (tables.includes('sessions') && (!sessionsCols.includes('data') || !sessionsCols.includes('expires_at'))) {
|
|
|
|
| 125 |
db.exec(`DROP TABLE sessions; CREATE TABLE sessions (sid TEXT PRIMARY KEY, data TEXT NOT NULL, expires_at INTEGER NOT NULL); CREATE INDEX idx_sessions_expires ON sessions(expires_at);`);
|
| 126 |
} else if (!tables.includes('sessions')) {
|
| 127 |
db.exec(`CREATE TABLE sessions (sid TEXT PRIMARY KEY, data TEXT NOT NULL, expires_at INTEGER NOT NULL); CREATE INDEX idx_sessions_expires ON sessions(expires_at);`);
|
| 128 |
}
|
| 129 |
-
|
| 130 |
}
|
| 131 |
runMigrations();
|
| 132 |
|
|
@@ -142,7 +158,7 @@ class SQLiteStore extends session.Store {
|
|
| 142 |
set(sid, sess, cb) { try { const exp = sess.cookie?.expires ? new Date(sess.cookie.expires).getTime() : Date.now() + 30*24*60*60*1000; db.prepare('INSERT OR REPLACE INTO sessions(sid,data,expires_at) VALUES(?,?,?)').run(sid, JSON.stringify(sess), exp); cb(null); } catch(e){ cb(e); } }
|
| 143 |
destroy(sid, cb) { try { db.prepare('DELETE FROM sessions WHERE sid=?').run(sid); cb(null); } catch(e){ cb(e); } }
|
| 144 |
clear(cb) { try { db.prepare('DELETE FROM sessions').run(); cb(null); } catch(e){ cb(e); } }
|
| 145 |
-
length(cb) { try {
|
| 146 |
touch(sid, sess, cb) { this.set(sid, sess, cb); }
|
| 147 |
}
|
| 148 |
const sessionStore = new SQLiteStore();
|
|
@@ -153,7 +169,7 @@ if (!adminRow) {
|
|
| 153 |
const salt = randomBytes(16);
|
| 154 |
const hash = await deriveKey(process.env.ADMIN_PASS || 'ChangeMeNow!', salt);
|
| 155 |
db.prepare('INSERT INTO users(username,password_hash,salt,is_admin,tos_accepted) VALUES(?,?,?,1,1)').run(ADMIN_USER, hash, salt);
|
| 156 |
-
|
| 157 |
}
|
| 158 |
|
| 159 |
// --- EXPRESS ---
|
|
@@ -169,11 +185,11 @@ const requireTos = (req, res, next) => { if(!req.session.tosAccepted) return res
|
|
| 169 |
// --- CACHE ---
|
| 170 |
class ByteLRUCache extends LRUCache { constructor(){ super({maxSize:CACHE_MAX_BYTES, sizeCalculation:v=>v.size}); } }
|
| 171 |
const fileCache = new ByteLRUCache();
|
| 172 |
-
setInterval(()=>{ const m = process.memoryUsage(); if(m.rss > 13*1024*1024*1024){ fileCache.clear(); if(global.gc) global.gc(); } }, 30000);
|
| 173 |
|
| 174 |
// --- NSFW ---
|
| 175 |
let nsfwModel = null;
|
| 176 |
-
async function loadNsfwModel(){ try{ await tf.setBackend('wasm'); tf.wasm.setWasmPaths('/app/tfjs_wasm/','tfjs-backend-wasm.wasm'); await tf.ready(); nsfwModel = await nsfwjs.load('/app/tfjs_wasm/',{type:'mobilenet_v2',size:224});
|
| 177 |
loadNsfwModel();
|
| 178 |
async function checkNsfw(buf){ if(!nsfwModel) return {safe:true}; try{ const t=tf.node.decodeImage(buf,3).resizeNearestNeighbor([224,224]).expandDims(0); const p=await nsfwModel.classify(t); t.dispose(); const porn=p.find(x=>x.className==='Porn'||x.className==='Sexual Activity')?.probability||0; const hentai=p.find(x=>x.className==='Hentai')?.probability||0; if(porn>0.85||hentai>0.9) return {safe:false}; return {safe:true}; }catch(e){ return {safe:true}; } }
|
| 179 |
|
|
@@ -191,9 +207,7 @@ function encryptMasterKey(k){ const iv=randomBytes(12); const c=createCipheriv('
|
|
| 191 |
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()]); }
|
| 192 |
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); }
|
| 193 |
|
| 194 |
-
//
|
| 195 |
-
// ✅ CONTRÔLE D'ACCÈS (partage entre utilisateurs)
|
| 196 |
-
// ==========================================
|
| 197 |
function canAccessFolder(userId, folderId){
|
| 198 |
let cur = db.prepare('SELECT * FROM folders WHERE id=?').get(folderId);
|
| 199 |
let depth = 0;
|
|
@@ -227,7 +241,7 @@ async function genThumb(fid,mime,key){
|
|
| 227 |
if(mime.startsWith('image/')) buf = await sharp(dec).rotate().resize(256,256,{fit:'inside'}).jpeg({quality:80}).toBuffer();
|
| 228 |
else buf = await sharp({create:{width:256,height:144,channels:3,background:'#1e1e2f'}}).jpeg().toBuffer();
|
| 229 |
await sharp(buf).toFile(join(THUMB_DIR,`${fid}.jpg`));
|
| 230 |
-
}catch(e){
|
| 231 |
}
|
| 232 |
|
| 233 |
// --- STREAM ---
|
|
@@ -239,7 +253,7 @@ async function streamFile(res,file,key,range){
|
|
| 239 |
if(s >= total || e >= total) return res.status(416).send('Range Not Satisfiable');
|
| 240 |
res.set({'Content-Type':file.mime,'Content-Length':e-s+1,'Accept-Ranges':'bytes','Content-Disposition':`inline; filename="${encodeURIComponent(file.name)}"`});
|
| 241 |
if(range){ res.status(206); res.set('Content-Range', `bytes ${s}-${e}/${total}`); }
|
| 242 |
-
|
| 243 |
let cur = 0;
|
| 244 |
for(const c of chunks){
|
| 245 |
const cs = cur, ce = cur + c.size - 1;
|
|
@@ -252,17 +266,15 @@ async function streamFile(res,file,key,range){
|
|
| 252 |
const t0 = Date.now();
|
| 253 |
const {data} = await axios.get(c.imgbb_url,{responseType:'arraybuffer',timeout:120000});
|
| 254 |
const t1 = Date.now();
|
| 255 |
-
|
| 256 |
const enc = await pngToBuffer(Buffer.from(data), c.size + OVERHEAD);
|
| 257 |
-
const t2 = Date.now();
|
| 258 |
-
console.log(` 🖼️ PNG→Buffer: ${t2-t1}ms`);
|
| 259 |
dec = decryptChunk(enc, key);
|
| 260 |
-
|
| 261 |
fileCache.set(ck, {data: dec, size: dec.length});
|
| 262 |
}
|
| 263 |
const sl = dec.subarray(off, off + len);
|
| 264 |
if(!res.write(sl)) await new Promise(r => res.once('drain', r));
|
| 265 |
-
}catch(err){
|
| 266 |
}
|
| 267 |
cur += c.size;
|
| 268 |
if(cs > e) break;
|
|
@@ -277,14 +289,13 @@ function broadcast(fid,d){ const cs = sseClients.get(fid); if(cs){ const p = `da
|
|
| 277 |
// ==========================================
|
| 278 |
// ROUTES
|
| 279 |
// ==========================================
|
| 280 |
-
app.post('/api/register',async(req,res)=>{const{u,p,t}=req.body;if(!u||!p||!t)return res.status(400).json({error:'Champs manquants'});if(db.prepare('SELECT 1 FROM users WHERE username=?').get(u))return res.status(409).json({error:'Existe'});const s=randomBytes(16);const h=await deriveKey(p,s);db.prepare('INSERT INTO users(username,password_hash,salt,tos_accepted) VALUES(?,?,?,1)').run(u,h,s);res.json({ok:true});});
|
| 281 |
-
app.post('/api/login',async(req,res)=>{const{u,p}=req.body;const usr=db.prepare('SELECT * FROM users WHERE username=?').get(u);if(!usr)return res.status(401).json({error:'Invalide'});const h=await deriveKey(p,usr.salt);if(!h.equals(usr.password_hash))return res.status(401).json({error:'Invalide'});req.session.userId=usr.id;req.session.isAdmin=!!usr.is_admin;req.session.tosAccepted=!!usr.tos_accepted;req.session.username=usr.username;res.json({ok:true,user:{id:usr.id,username:usr.username,isAdmin:!!usr.is_admin,tosAccepted:!!usr.tos_accepted}});});
|
| 282 |
app.post('/api/logout',(req,res)=>req.session.destroy(()=>res.json({ok:true})));
|
| 283 |
app.get('/api/me',(req,res)=>req.session.userId?res.json({id:req.session.userId,username:req.session.username,isAdmin:req.session.isAdmin,tosAccepted:req.session.tosAccepted}):res.status(401).json({error:'Non connecté'}));
|
| 284 |
-
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`));
|
| 285 |
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});});
|
| 286 |
|
| 287 |
-
//
|
| 288 |
app.get('/api/folders',requireAuth,(req,res)=>{
|
| 289 |
const p = req.query.parent_id ? parseInt(req.query.parent_id) : null;
|
| 290 |
if (p !== null) {
|
|
@@ -296,7 +307,7 @@ app.get('/api/folders',requireAuth,(req,res)=>{
|
|
| 296 |
app.post('/api/folders',requireAuth,requireTos,(req,res)=>{const{n,p}=req.body;if(!n)return res.status(400).json({error:'Nom requis'});try{const i=db.prepare('INSERT INTO folders(name,parent_id,owner_id) VALUES(?,?,?)').run(n,p||null,req.session.userId);res.json({id:i.lastInsertRowid,name:n,parent_id:p});}catch(e){res.status(409).json({error:'Existe'});}});
|
| 297 |
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});});
|
| 298 |
|
| 299 |
-
//
|
| 300 |
app.get('/api/files',requireAuth,(req,res)=>{
|
| 301 |
const f = req.query.folder_id ? parseInt(req.query.folder_id) : null;
|
| 302 |
if (f !== null) {
|
|
@@ -306,58 +317,47 @@ app.get('/api/files',requireAuth,(req,res)=>{
|
|
| 306 |
res.json(db.prepare('SELECT * FROM files WHERE owner_id=? AND folder_id IS NULL ORDER BY name COLLATE NOCASE').all(req.session.userId));
|
| 307 |
});
|
| 308 |
|
| 309 |
-
// ✅ PARTAGE
|
| 310 |
app.post('/api/share-user',requireAuth,requireTos,(req,res)=>{
|
| 311 |
const { type, id, username } = req.body;
|
| 312 |
if (!type || !id || !username) return res.status(400).json({error:'Champs manquants'});
|
| 313 |
const target = db.prepare('SELECT id FROM users WHERE username=?').get(username.trim());
|
| 314 |
if (!target) return res.status(404).json({error:'Utilisateur introuvable'});
|
| 315 |
if (target.id === req.session.userId) return res.status(400).json({error:'Impossible de partager avec vous-même'});
|
| 316 |
-
|
| 317 |
-
|
| 318 |
-
|
| 319 |
-
|
| 320 |
-
|
| 321 |
-
}
|
| 322 |
-
const fo = db.prepare('SELECT id FROM folders WHERE id=? AND owner_id=?').get(id, req.session.userId);
|
| 323 |
-
if (!fo) return res.status(404).json({error:'Dossier introuvable'});
|
| 324 |
-
if (db.prepare('SELECT 1 FROM user_shares WHERE folder_id=? AND shared_with=?').get(id, target.id)) return res.status(409).json({error:'Déjà partagé'});
|
| 325 |
-
db.prepare('INSERT INTO user_shares(folder_id,owner_id,shared_with) VALUES(?,?,?)').run(id, req.session.userId, target.id);
|
| 326 |
-
}
|
| 327 |
res.json({ok:true});
|
| 328 |
});
|
| 329 |
|
| 330 |
app.get('/api/shared-with-me',requireAuth,(req,res)=>{
|
| 331 |
-
res.json(db.prepare(`
|
| 332 |
-
|
| 333 |
-
|
| 334 |
-
f.name as file_name, f.mime, f.size, f.status,
|
| 335 |
-
fo.name as folder_name
|
| 336 |
-
FROM user_shares us
|
| 337 |
-
JOIN users u ON u.id = us.owner_id
|
| 338 |
-
LEFT JOIN files f ON f.id = us.file_id
|
| 339 |
-
LEFT JOIN folders fo ON fo.id = us.folder_id
|
| 340 |
-
WHERE us.shared_with = ?
|
| 341 |
-
ORDER BY us.created_at DESC
|
| 342 |
-
`).all(req.session.userId));
|
| 343 |
});
|
|
|
|
| 344 |
|
| 345 |
-
|
| 346 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 347 |
res.json({ok:true});
|
| 348 |
});
|
| 349 |
|
| 350 |
// Liens publics
|
| 351 |
-
app.get('/api/share-links',requireAuth,(req,res)=>{
|
| 352 |
-
|
| 353 |
-
});
|
| 354 |
-
app.delete('/api/share-links/:token',requireAuth,(req,res)=>{
|
| 355 |
-
const r = db.prepare('DELETE FROM share_links WHERE token=? AND owner_id=?').run(req.params.token, req.session.userId);
|
| 356 |
-
if (r.changes === 0) return res.status(404).json({error:'Lien introuvable'});
|
| 357 |
-
res.json({ok:true});
|
| 358 |
-
});
|
| 359 |
|
| 360 |
-
//
|
| 361 |
const upload = multer({dest:TEMP_DIR,limits:{fileSize:5*1024*1024*1024}});
|
| 362 |
app.post('/api/files/upload',requireAuth,requireTos,upload.single('file'),async(req,res)=>{
|
| 363 |
if(!req.file)return res.status(400).json({error:'Fichier manquant'});
|
|
@@ -369,10 +369,12 @@ app.post('/api/files/upload',requireAuth,requireTos,upload.single('file'),async(
|
|
| 369 |
for(let i=0;i<cc;i++){const sz=Math.min(CHUNK_SIZE,fsize-i*CHUNK_SIZE);cd.push([fid,i,sz,sz+OVERHEAD]);}
|
| 370 |
tx(cd);
|
| 371 |
res.json({fileId:fid,status:'pending',chunkCount:cc});
|
| 372 |
-
uploadQueue.add(()=>processUpload(fid,fpath,mk,cc,req.file.mimetype));
|
| 373 |
});
|
| 374 |
|
| 375 |
-
async function processUpload(fid,path,mk,cc,mime){
|
|
|
|
|
|
|
| 376 |
try{
|
| 377 |
db.prepare("UPDATE files SET status='uploading' WHERE id=?").run(fid);
|
| 378 |
for(let i=0;i<cc;i++){
|
|
@@ -395,8 +397,12 @@ async function processUpload(fid,path,mk,cc,mime){
|
|
| 395 |
await genThumb(fid,mime,mk);
|
| 396 |
db.prepare("UPDATE files SET status='ready',progress=100 WHERE id=?").run(fid);
|
| 397 |
broadcast(fid,{type:'progress',progress:100,status:'ready',type:'done'});
|
|
|
|
|
|
|
|
|
|
|
|
|
| 398 |
}catch(e){
|
| 399 |
-
|
| 400 |
db.prepare("UPDATE files SET status='error',error_msg=? WHERE id=?").run(e.message,fid);
|
| 401 |
broadcast(fid,{type:'error',error:e.message});
|
| 402 |
const cs=db.prepare('SELECT imgbb_delete_url FROM file_chunks WHERE file_id=?').all(fid);
|
|
@@ -405,7 +411,7 @@ async function processUpload(fid,path,mk,cc,mime){
|
|
| 405 |
}
|
| 406 |
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);});}
|
| 407 |
|
| 408 |
-
//
|
| 409 |
app.get('/api/files/:id/download',requireAuth,async(req,res)=>{
|
| 410 |
const f = canAccessFile(req.session.userId, req.params.id);
|
| 411 |
if(!f)return res.status(404).json({error:'Introuvable'});
|
|
@@ -423,14 +429,6 @@ app.post('/api/files/:id/share',requireAuth,async(req,res)=>{
|
|
| 423 |
res.json({preview:`/s/${pt}`,download:`/d/${dt2}`,expires});
|
| 424 |
});
|
| 425 |
|
| 426 |
-
app.get('/api/files/:id/preview',requireAuth,async(req,res)=>{
|
| 427 |
-
const f=db.prepare('SELECT * FROM files WHERE id=? AND owner_id=?').get(req.params.id,req.session.userId);
|
| 428 |
-
if(!f||f.status!=='ready')return res.status(404).json({error:'Pas prêt'});
|
| 429 |
-
const t=uuidv4();
|
| 430 |
-
db.prepare('INSERT INTO share_links(token,file_id,owner_id,type,expires_at) VALUES(?,?,?,?,?)').run(t,f.id,req.session.userId,'embed',Date.now()+3600000);
|
| 431 |
-
res.json({url:`/s/${t}`});
|
| 432 |
-
});
|
| 433 |
-
|
| 434 |
app.get('/s/:token',async(req,res)=>{
|
| 435 |
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());
|
| 436 |
if(!l)return res.status(404).send(html('Lien invalide/expiré'));
|
|
@@ -466,20 +464,22 @@ app.get('/api/files/:id/progress',requireAuth,(req,res)=>{res.set({'Content-Type
|
|
| 466 |
app.get('/api/admin/users',requireAuth,(req,res)=>{if(!req.session.isAdmin)return res.status(403).json({error:'Admin only'});res.json(db.prepare('SELECT id,username,is_admin,tos_accepted,created_at FROM users').all());});
|
| 467 |
app.get('/health',(_,res)=>res.json({ok:true,cache:fileCache.size,mem:process.memoryUsage()}));
|
| 468 |
|
| 469 |
-
|
|
|
|
|
|
|
| 470 |
app.get('*', (req, res) => {
|
| 471 |
if (/\.[a-zA-Z0-9]+$/.test(req.path)) return res.status(404).send('Not Found');
|
| 472 |
-
res.sendFile(join(
|
| 473 |
});
|
| 474 |
|
| 475 |
// ERROR HANDLER
|
| 476 |
app.use((err, req, res, next) => {
|
| 477 |
if (err.type === 'entity.parse.failed' || err instanceof SyntaxError) return res.status(400).json({ error: 'Bad Request' });
|
| 478 |
-
|
| 479 |
res.status(500).json({ error: 'Internal Server Error' });
|
| 480 |
});
|
| 481 |
|
| 482 |
-
const server = app.listen(PORT, '0.0.0.0', () =>
|
| 483 |
-
process.on('SIGTERM', () => {
|
| 484 |
|
| 485 |
function html(b){return `<!DOCTYPE html><html><head><meta charset="utf-8"/><meta name="viewport" content="width=device-width, initial-scale=1"/><title>PixelDrive</title><style>body{margin:0;font-family:system-ui,sans-serif;background:#0d0d12;color:#eee;display:flex;align-items:center;justify-content:center;height:100vh}</style></head><body>${b}</body></html>`;}
|
|
|
|
| 1 |
// ==========================================
|
| 2 |
+
// PIXELDRIVE v4.0 - SERVER.JS (ESM)
|
| 3 |
// ==========================================
|
| 4 |
import express from 'express';
|
| 5 |
import session from 'express-session';
|
|
|
|
| 8 |
import { createHash, randomBytes, scrypt, createCipheriv, createDecipheriv } from 'crypto';
|
| 9 |
import { promisify } from 'util';
|
| 10 |
import { createReadStream, unlinkSync, existsSync } from 'fs';
|
| 11 |
+
import { join } from 'path';
|
| 12 |
import { v4 as uuidv4 } from 'uuid';
|
| 13 |
import LRUCache from 'quick-lru';
|
| 14 |
import sharp from 'sharp';
|
|
|
|
| 19 |
import { fileURLToPath } from 'url';
|
| 20 |
|
| 21 |
const __dirname = fileURLToPath(new URL('.', import.meta.url));
|
| 22 |
+
const PUBLIC_DIR = join(__dirname, 'public');
|
| 23 |
const PORT = 7860;
|
| 24 |
const DATA_DIR = '/data';
|
| 25 |
const TEMP_DIR = join(DATA_DIR, 'temp');
|
|
|
|
| 33 |
const TAG_LEN = 16;
|
| 34 |
const OVERHEAD = IV_LEN + TAG_LEN;
|
| 35 |
|
| 36 |
+
// ==========================================
|
| 37 |
+
// ✅ SYSTÈME DE LOGS : [HH:MM:SS] [NIVEAU]
|
| 38 |
+
// ==========================================
|
| 39 |
+
function log(level, msg) {
|
| 40 |
+
const n = new Date();
|
| 41 |
+
const ts = [n.getHours(), n.getMinutes(), n.getSeconds()].map(x => String(x).padStart(2, '0')).join(':');
|
| 42 |
+
const line = `[${ts}] [${level}] ${msg}`;
|
| 43 |
+
if (level === 'ERROR' || level === 'FATAL ERROR') console.error(line);
|
| 44 |
+
else if (level === 'WARN' || level === 'ALERT') console.warn(line);
|
| 45 |
+
else console.log(line);
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
// --- ENV ---
|
| 49 |
const IMGBB_API_KEY = process.env.IMGBB_API_KEY;
|
| 50 |
const SESSION_SECRET = process.env.SESSION_SECRET;
|
| 51 |
const ADMIN_USER = process.env.ADMIN_USER || 'admin';
|
| 52 |
|
| 53 |
if (!IMGBB_API_KEY || !SESSION_SECRET) {
|
| 54 |
+
log('FATAL ERROR', 'Variables manquantes: IMGBB_API_KEY, SESSION_SECRET');
|
| 55 |
process.exit(1);
|
| 56 |
}
|
| 57 |
|
|
|
|
| 65 |
function encryptChunk(dataBuffer, key) { const iv = randomBytes(IV_LEN); const cipher = createCipheriv('aes-256-gcm', key, iv); const encrypted = Buffer.concat([cipher.update(dataBuffer), cipher.final()]); const tag = cipher.getAuthTag(); return Buffer.concat([iv, encrypted, tag]); }
|
| 66 |
function decryptChunk(encBuffer, key) { if (encBuffer.length < OVERHEAD) throw new Error('Buffer trop court'); const iv = encBuffer.subarray(0, IV_LEN); const tag = encBuffer.subarray(encBuffer.length - TAG_LEN); const data = encBuffer.subarray(IV_LEN, encBuffer.length - TAG_LEN); const decipher = createDecipheriv('aes-256-gcm', key, iv); decipher.setAuthTag(tag); return Buffer.concat([decipher.update(data), decipher.final()]); }
|
| 67 |
|
| 68 |
+
// --- PNG STEGO ---
|
| 69 |
function bufferToPng(buffer) {
|
| 70 |
const byteLen = buffer.length;
|
| 71 |
const pixelsNeeded = Math.ceil(byteLen / 3);
|
|
|
|
| 79 |
const { data, info } = await sharp(pngBuffer).raw().toBuffer({ resolveWithObject: true });
|
| 80 |
const channels = info.channels;
|
| 81 |
const rgb = Buffer.alloc(info.width * info.height * 3);
|
| 82 |
+
if (channels === 3) data.copy(rgb, 0, 0, Math.min(data.length, rgb.length));
|
| 83 |
else if (channels === 4) { for (let i = 0, j = 0; i < data.length && j < rgb.length; i += 4, j += 3) { rgb[j] = data[i]; rgb[j+1] = data[i+1]; rgb[j+2] = data[i+2]; } }
|
| 84 |
else if (channels === 1) { for (let i = 0, j = 0; i < data.length && j < rgb.length; i++, j += 3) { rgb[j] = rgb[j+1] = rgb[j+2] = data[i]; } }
|
| 85 |
return rgb.subarray(0, expectedLen);
|
|
|
|
| 105 |
// MIGRATIONS
|
| 106 |
// ==========================================
|
| 107 |
function runMigrations() {
|
| 108 |
+
log('INFO', 'Vérification migrations BDD...');
|
| 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 |
|
|
|
|
| 123 |
let updatedAdded = false;
|
| 124 |
for (const col of filesDefs) {
|
| 125 |
if (!filesCols.includes(col.name)) {
|
| 126 |
+
log('INFO', `Migration: ALTER TABLE files ADD COLUMN ${col.name}`);
|
| 127 |
db.exec(`ALTER TABLE files ADD COLUMN ${col.name} ${col.def};`);
|
| 128 |
if (col.name === 'updated_at') updatedAdded = true;
|
| 129 |
}
|
|
|
|
| 137 |
|
| 138 |
const sessionsCols = tables.includes('sessions') ? db.prepare("PRAGMA table_info(sessions)").all().map(c => c.name) : [];
|
| 139 |
if (tables.includes('sessions') && (!sessionsCols.includes('data') || !sessionsCols.includes('expires_at'))) {
|
| 140 |
+
log('WARN', 'Table sessions invalide → reconstruction');
|
| 141 |
db.exec(`DROP TABLE sessions; CREATE TABLE sessions (sid TEXT PRIMARY KEY, data TEXT NOT NULL, expires_at INTEGER NOT NULL); CREATE INDEX idx_sessions_expires ON sessions(expires_at);`);
|
| 142 |
} else if (!tables.includes('sessions')) {
|
| 143 |
db.exec(`CREATE TABLE sessions (sid TEXT PRIMARY KEY, data TEXT NOT NULL, expires_at INTEGER NOT NULL); CREATE INDEX idx_sessions_expires ON sessions(expires_at);`);
|
| 144 |
}
|
| 145 |
+
log('INFO', 'Migrations terminées.');
|
| 146 |
}
|
| 147 |
runMigrations();
|
| 148 |
|
|
|
|
| 158 |
set(sid, sess, cb) { try { const exp = sess.cookie?.expires ? new Date(sess.cookie.expires).getTime() : Date.now() + 30*24*60*60*1000; db.prepare('INSERT OR REPLACE INTO sessions(sid,data,expires_at) VALUES(?,?,?)').run(sid, JSON.stringify(sess), exp); cb(null); } catch(e){ cb(e); } }
|
| 159 |
destroy(sid, cb) { try { db.prepare('DELETE FROM sessions WHERE sid=?').run(sid); cb(null); } catch(e){ cb(e); } }
|
| 160 |
clear(cb) { try { db.prepare('DELETE FROM sessions').run(); cb(null); } catch(e){ cb(e); } }
|
| 161 |
+
length(cb) { try { cb(null, db.prepare('SELECT COUNT(*) as c FROM sessions').get().c); } catch(e){ cb(e); } }
|
| 162 |
touch(sid, sess, cb) { this.set(sid, sess, cb); }
|
| 163 |
}
|
| 164 |
const sessionStore = new SQLiteStore();
|
|
|
|
| 169 |
const salt = randomBytes(16);
|
| 170 |
const hash = await deriveKey(process.env.ADMIN_PASS || 'ChangeMeNow!', salt);
|
| 171 |
db.prepare('INSERT INTO users(username,password_hash,salt,is_admin,tos_accepted) VALUES(?,?,?,1,1)').run(ADMIN_USER, hash, salt);
|
| 172 |
+
log('INFO', `Compte admin créé: ${ADMIN_USER}`);
|
| 173 |
}
|
| 174 |
|
| 175 |
// --- EXPRESS ---
|
|
|
|
| 185 |
// --- CACHE ---
|
| 186 |
class ByteLRUCache extends LRUCache { constructor(){ super({maxSize:CACHE_MAX_BYTES, sizeCalculation:v=>v.size}); } }
|
| 187 |
const fileCache = new ByteLRUCache();
|
| 188 |
+
setInterval(()=>{ const m = process.memoryUsage(); if(m.rss > 13*1024*1024*1024){ log('ALERT', `RAM ${(m.rss/1e9).toFixed(1)}GB → vidage du cache`); fileCache.clear(); if(global.gc) global.gc(); } }, 30000);
|
| 189 |
|
| 190 |
// --- NSFW ---
|
| 191 |
let nsfwModel = null;
|
| 192 |
+
async function loadNsfwModel(){ try{ await tf.setBackend('wasm'); tf.wasm.setWasmPaths('/app/tfjs_wasm/','tfjs-backend-wasm.wasm'); await tf.ready(); nsfwModel = await nsfwjs.load('/app/tfjs_wasm/',{type:'mobilenet_v2',size:224}); log('INFO', 'NSFW WASM chargé'); }catch(e){ log('WARN', `NSFW load fail: ${e.message}`); } }
|
| 193 |
loadNsfwModel();
|
| 194 |
async function checkNsfw(buf){ if(!nsfwModel) return {safe:true}; try{ const t=tf.node.decodeImage(buf,3).resizeNearestNeighbor([224,224]).expandDims(0); const p=await nsfwModel.classify(t); t.dispose(); const porn=p.find(x=>x.className==='Porn'||x.className==='Sexual Activity')?.probability||0; const hentai=p.find(x=>x.className==='Hentai')?.probability||0; if(porn>0.85||hentai>0.9) return {safe:false}; return {safe:true}; }catch(e){ return {safe:true}; } }
|
| 195 |
|
|
|
|
| 207 |
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()]); }
|
| 208 |
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); }
|
| 209 |
|
| 210 |
+
// --- CONTRÔLE D'ACCÈS ---
|
|
|
|
|
|
|
| 211 |
function canAccessFolder(userId, folderId){
|
| 212 |
let cur = db.prepare('SELECT * FROM folders WHERE id=?').get(folderId);
|
| 213 |
let depth = 0;
|
|
|
|
| 241 |
if(mime.startsWith('image/')) buf = await sharp(dec).rotate().resize(256,256,{fit:'inside'}).jpeg({quality:80}).toBuffer();
|
| 242 |
else buf = await sharp({create:{width:256,height:144,channels:3,background:'#1e1e2f'}}).jpeg().toBuffer();
|
| 243 |
await sharp(buf).toFile(join(THUMB_DIR,`${fid}.jpg`));
|
| 244 |
+
}catch(e){ log('WARN', `Thumb ${fid}: ${e.message}`); }
|
| 245 |
}
|
| 246 |
|
| 247 |
// --- STREAM ---
|
|
|
|
| 253 |
if(s >= total || e >= total) return res.status(416).send('Range Not Satisfiable');
|
| 254 |
res.set({'Content-Type':file.mime,'Content-Length':e-s+1,'Accept-Ranges':'bytes','Content-Disposition':`inline; filename="${encodeURIComponent(file.name)}"`});
|
| 255 |
if(range){ res.status(206); res.set('Content-Range', `bytes ${s}-${e}/${total}`); }
|
| 256 |
+
log('INFO', `Stream ${file.name} (${((e-s+1)/1048576).toFixed(2)} MB, ${chunks.length} chunk(s))`);
|
| 257 |
let cur = 0;
|
| 258 |
for(const c of chunks){
|
| 259 |
const cs = cur, ce = cur + c.size - 1;
|
|
|
|
| 266 |
const t0 = Date.now();
|
| 267 |
const {data} = await axios.get(c.imgbb_url,{responseType:'arraybuffer',timeout:120000});
|
| 268 |
const t1 = Date.now();
|
| 269 |
+
log('INFO', ` Chunk ${c.chunk_index}: ${(data.byteLength/1048576).toFixed(2)} MB téléchargé en ${t1-t0}ms`);
|
| 270 |
const enc = await pngToBuffer(Buffer.from(data), c.size + OVERHEAD);
|
|
|
|
|
|
|
| 271 |
dec = decryptChunk(enc, key);
|
| 272 |
+
log('INFO', ` Chunk ${c.chunk_index}: décodé+déchiffré en ${Date.now()-t1}ms`);
|
| 273 |
fileCache.set(ck, {data: dec, size: dec.length});
|
| 274 |
}
|
| 275 |
const sl = dec.subarray(off, off + len);
|
| 276 |
if(!res.write(sl)) await new Promise(r => res.once('drain', r));
|
| 277 |
+
}catch(err){ log('ERROR', `Stream chunk ${c.chunk_index}: ${err.message}`); return res.destroy(err); }
|
| 278 |
}
|
| 279 |
cur += c.size;
|
| 280 |
if(cs > e) break;
|
|
|
|
| 289 |
// ==========================================
|
| 290 |
// ROUTES
|
| 291 |
// ==========================================
|
| 292 |
+
app.post('/api/register',async(req,res)=>{const{u,p,t}=req.body;if(!u||!p||!t)return res.status(400).json({error:'Champs manquants'});if(db.prepare('SELECT 1 FROM users WHERE username=?').get(u))return res.status(409).json({error:'Existe'});const s=randomBytes(16);const h=await deriveKey(p,s);db.prepare('INSERT INTO users(username,password_hash,salt,tos_accepted) VALUES(?,?,?,1)').run(u,h,s);log('INFO',`Nouvel utilisateur: ${u}`);res.json({ok:true});});
|
| 293 |
+
app.post('/api/login',async(req,res)=>{const{u,p}=req.body;const usr=db.prepare('SELECT * FROM users WHERE username=?').get(u);if(!usr)return res.status(401).json({error:'Invalide'});const h=await deriveKey(p,usr.salt);if(!h.equals(usr.password_hash))return res.status(401).json({error:'Invalide'});req.session.userId=usr.id;req.session.isAdmin=!!usr.is_admin;req.session.tosAccepted=!!usr.tos_accepted;req.session.username=usr.username;log('INFO',`Connexion: ${u}`);res.json({ok:true,user:{id:usr.id,username:usr.username,isAdmin:!!usr.is_admin,tosAccepted:!!usr.tos_accepted}});});
|
| 294 |
app.post('/api/logout',(req,res)=>req.session.destroy(()=>res.json({ok:true})));
|
| 295 |
app.get('/api/me',(req,res)=>req.session.userId?res.json({id:req.session.userId,username:req.session.username,isAdmin:req.session.isAdmin,tosAccepted:req.session.tosAccepted}):res.status(401).json({error:'Non connecté'}));
|
|
|
|
| 296 |
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});});
|
| 297 |
|
| 298 |
+
// Folders
|
| 299 |
app.get('/api/folders',requireAuth,(req,res)=>{
|
| 300 |
const p = req.query.parent_id ? parseInt(req.query.parent_id) : null;
|
| 301 |
if (p !== null) {
|
|
|
|
| 307 |
app.post('/api/folders',requireAuth,requireTos,(req,res)=>{const{n,p}=req.body;if(!n)return res.status(400).json({error:'Nom requis'});try{const i=db.prepare('INSERT INTO folders(name,parent_id,owner_id) VALUES(?,?,?)').run(n,p||null,req.session.userId);res.json({id:i.lastInsertRowid,name:n,parent_id:p});}catch(e){res.status(409).json({error:'Existe'});}});
|
| 308 |
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});});
|
| 309 |
|
| 310 |
+
// Files
|
| 311 |
app.get('/api/files',requireAuth,(req,res)=>{
|
| 312 |
const f = req.query.folder_id ? parseInt(req.query.folder_id) : null;
|
| 313 |
if (f !== null) {
|
|
|
|
| 317 |
res.json(db.prepare('SELECT * FROM files WHERE owner_id=? AND folder_id IS NULL ORDER BY name COLLATE NOCASE').all(req.session.userId));
|
| 318 |
});
|
| 319 |
|
| 320 |
+
// ✅ PARTAGE UTILISATEURS (vraie gestion)
|
| 321 |
app.post('/api/share-user',requireAuth,requireTos,(req,res)=>{
|
| 322 |
const { type, id, username } = req.body;
|
| 323 |
if (!type || !id || !username) return res.status(400).json({error:'Champs manquants'});
|
| 324 |
const target = db.prepare('SELECT id FROM users WHERE username=?').get(username.trim());
|
| 325 |
if (!target) return res.status(404).json({error:'Utilisateur introuvable'});
|
| 326 |
if (target.id === req.session.userId) return res.status(400).json({error:'Impossible de partager avec vous-même'});
|
| 327 |
+
const col = type === 'file' ? 'file_id' : 'folder_id';
|
| 328 |
+
const own = db.prepare(`SELECT id FROM ${type === 'file' ? 'files' : 'folders'} WHERE id=? AND owner_id=?`).get(id, req.session.userId);
|
| 329 |
+
if (!own) return res.status(404).json({error:'Introuvable'});
|
| 330 |
+
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é'});
|
| 331 |
+
db.prepare(`INSERT INTO user_shares(${col},owner_id,shared_with) VALUES(?,?,?)`).run(id, req.session.userId, target.id);
|
| 332 |
+
log('INFO', `Partage ${type} ${id} → ${username.trim()}`);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 333 |
res.json({ok:true});
|
| 334 |
});
|
| 335 |
|
| 336 |
app.get('/api/shared-with-me',requireAuth,(req,res)=>{
|
| 337 |
+
res.json(db.prepare(`SELECT us.id as share_id, us.created_at as shared_at, u.username as owner_name, us.file_id, us.folder_id, f.name as file_name, f.mime, f.size, f.status, fo.name as folder_name
|
| 338 |
+
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
|
| 339 |
+
WHERE us.shared_with = ? ORDER BY us.created_at DESC`).all(req.session.userId));
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 340 |
});
|
| 341 |
+
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}); });
|
| 342 |
|
| 343 |
+
// ✅ GESTION CÔTÉ PROPRIÉTAIRE : mes partages + révocation
|
| 344 |
+
app.get('/api/my-shares',requireAuth,(req,res)=>{
|
| 345 |
+
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
|
| 346 |
+
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
|
| 347 |
+
WHERE us.owner_id = ? ORDER BY us.created_at DESC`).all(req.session.userId));
|
| 348 |
+
});
|
| 349 |
+
app.delete('/api/my-shares/:id',requireAuth,(req,res)=>{
|
| 350 |
+
const r = db.prepare('DELETE FROM user_shares WHERE id=? AND owner_id=?').run(req.params.id, req.session.userId);
|
| 351 |
+
if (!r.changes) return res.status(404).json({error:'Introuvable'});
|
| 352 |
+
log('INFO', `Partage révoqué: #${req.params.id}`);
|
| 353 |
res.json({ok:true});
|
| 354 |
});
|
| 355 |
|
| 356 |
// Liens publics
|
| 357 |
+
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)); });
|
| 358 |
+
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}); });
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 359 |
|
| 360 |
+
// ✅ UPLOAD (timing + vitesse)
|
| 361 |
const upload = multer({dest:TEMP_DIR,limits:{fileSize:5*1024*1024*1024}});
|
| 362 |
app.post('/api/files/upload',requireAuth,requireTos,upload.single('file'),async(req,res)=>{
|
| 363 |
if(!req.file)return res.status(400).json({error:'Fichier manquant'});
|
|
|
|
| 369 |
for(let i=0;i<cc;i++){const sz=Math.min(CHUNK_SIZE,fsize-i*CHUNK_SIZE);cd.push([fid,i,sz,sz+OVERHEAD]);}
|
| 370 |
tx(cd);
|
| 371 |
res.json({fileId:fid,status:'pending',chunkCount:cc});
|
| 372 |
+
uploadQueue.add(()=>processUpload(fid,fpath,mk,cc,req.file.mimetype,fsize,req.session.username));
|
| 373 |
});
|
| 374 |
|
| 375 |
+
async function processUpload(fid,path,mk,cc,mime,fsize,username){
|
| 376 |
+
const t0 = Date.now();
|
| 377 |
+
log('INFO', `Upload démarré: "${path.split('/').pop()}" par ${username} (${(fsize/1048576).toFixed(2)} MB, ${cc} chunk(s))`);
|
| 378 |
try{
|
| 379 |
db.prepare("UPDATE files SET status='uploading' WHERE id=?").run(fid);
|
| 380 |
for(let i=0;i<cc;i++){
|
|
|
|
| 397 |
await genThumb(fid,mime,mk);
|
| 398 |
db.prepare("UPDATE files SET status='ready',progress=100 WHERE id=?").run(fid);
|
| 399 |
broadcast(fid,{type:'progress',progress:100,status:'ready',type:'done'});
|
| 400 |
+
// ✅ TIMING + VITESSE
|
| 401 |
+
const secs = (Date.now()-t0)/1000;
|
| 402 |
+
const speed = (fsize/1048576)/secs;
|
| 403 |
+
log('INFO', `Upload terminé: ${fid} en ${secs.toFixed(1)}s (${speed.toFixed(2)} MB/s)`);
|
| 404 |
}catch(e){
|
| 405 |
+
log('ERROR', `Upload échoué ${fid}: ${e.message}`);
|
| 406 |
db.prepare("UPDATE files SET status='error',error_msg=? WHERE id=?").run(e.message,fid);
|
| 407 |
broadcast(fid,{type:'error',error:e.message});
|
| 408 |
const cs=db.prepare('SELECT imgbb_delete_url FROM file_chunks WHERE file_id=?').all(fid);
|
|
|
|
| 411 |
}
|
| 412 |
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);});}
|
| 413 |
|
| 414 |
+
// Download / preview / public
|
| 415 |
app.get('/api/files/:id/download',requireAuth,async(req,res)=>{
|
| 416 |
const f = canAccessFile(req.session.userId, req.params.id);
|
| 417 |
if(!f)return res.status(404).json({error:'Introuvable'});
|
|
|
|
| 429 |
res.json({preview:`/s/${pt}`,download:`/d/${dt2}`,expires});
|
| 430 |
});
|
| 431 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 432 |
app.get('/s/:token',async(req,res)=>{
|
| 433 |
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());
|
| 434 |
if(!l)return res.status(404).send(html('Lien invalide/expiré'));
|
|
|
|
| 464 |
app.get('/api/admin/users',requireAuth,(req,res)=>{if(!req.session.isAdmin)return res.status(403).json({error:'Admin only'});res.json(db.prepare('SELECT id,username,is_admin,tos_accepted,created_at FROM users').all());});
|
| 465 |
app.get('/health',(_,res)=>res.json({ok:true,cache:fileCache.size,mem:process.memoryUsage()}));
|
| 466 |
|
| 467 |
+
// ✅ STATIQUE : dossier public/
|
| 468 |
+
app.use(express.static(PUBLIC_DIR));
|
| 469 |
+
app.get('/manifest.json', (req,res) => res.sendFile(join(__dirname, 'manifest.json')));
|
| 470 |
app.get('*', (req, res) => {
|
| 471 |
if (/\.[a-zA-Z0-9]+$/.test(req.path)) return res.status(404).send('Not Found');
|
| 472 |
+
res.sendFile(join(PUBLIC_DIR, 'index.html'));
|
| 473 |
});
|
| 474 |
|
| 475 |
// ERROR HANDLER
|
| 476 |
app.use((err, req, res, next) => {
|
| 477 |
if (err.type === 'entity.parse.failed' || err instanceof SyntaxError) return res.status(400).json({ error: 'Bad Request' });
|
| 478 |
+
log('ERROR', `Server: ${err.message}`);
|
| 479 |
res.status(500).json({ error: 'Internal Server Error' });
|
| 480 |
});
|
| 481 |
|
| 482 |
+
const server = app.listen(PORT, '0.0.0.0', () => log('INFO', `PixelDrive v4.0 démarré sur http://0.0.0.0:${PORT}`));
|
| 483 |
+
process.on('SIGTERM', () => { log('WARN', 'SIGTERM reçu, arrêt...'); server.close(() => process.exit(0)); });
|
| 484 |
|
| 485 |
function html(b){return `<!DOCTYPE html><html><head><meta charset="utf-8"/><meta name="viewport" content="width=device-width, initial-scale=1"/><title>PixelDrive</title><style>body{margin:0;font-family:system-ui,sans-serif;background:#0d0d12;color:#eee;display:flex;align-items:center;justify-content:center;height:100vh}</style></head><body>${b}</body></html>`;}
|