Spaces:
Paused
Paused
Update server.js
Browse files
server.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
| 1 |
// ==========================================
|
| 2 |
-
// PIXELDRIVE v4.
|
| 3 |
// ==========================================
|
| 4 |
import express from 'express';
|
| 5 |
import session from 'express-session';
|
|
@@ -34,13 +34,10 @@ const IV_LEN = 12;
|
|
| 34 |
const TAG_LEN = 16;
|
| 35 |
const OVERHEAD = IV_LEN + TAG_LEN;
|
| 36 |
const IS_PROD = process.env.NODE_ENV === 'production';
|
| 37 |
-
const USER_QUOTA = parseInt(process.env.USER_QUOTA_GB || '50', 10) * 1024 * 1024 * 1024;
|
| 38 |
-
const DAILY_TRANSFER = parseInt(process.env.DAILY_TRANSFER_GB || '100', 10) * 1024 * 1024 * 1024;
|
| 39 |
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
| 40 |
|
| 41 |
-
// ==========================================
|
| 42 |
-
// LOGS
|
| 43 |
-
// ==========================================
|
| 44 |
function log(level, msg) {
|
| 45 |
const n = new Date();
|
| 46 |
const ts = [n.getHours(), n.getMinutes(), n.getSeconds()].map(x => String(x).padStart(2, '0')).join(':');
|
|
@@ -50,15 +47,11 @@ function log(level, msg) {
|
|
| 50 |
else console.log(line);
|
| 51 |
}
|
| 52 |
|
| 53 |
-
// --- ENV ---
|
| 54 |
const IMGBB_API_KEY = process.env.IMGBB_API_KEY;
|
| 55 |
const SESSION_SECRET = process.env.SESSION_SECRET;
|
| 56 |
const ADMIN_USER = process.env.ADMIN_USER || 'admin';
|
| 57 |
|
| 58 |
-
if (!IMGBB_API_KEY || !SESSION_SECRET) {
|
| 59 |
-
log('FATAL ERROR', 'Variables manquantes: IMGBB_API_KEY, SESSION_SECRET');
|
| 60 |
-
process.exit(1);
|
| 61 |
-
}
|
| 62 |
|
| 63 |
const MASTER_SECRET = process.env.MASTER_SECRET || SESSION_SECRET;
|
| 64 |
if (process.env.MASTER_SECRET) log('INFO', 'MASTER_SECRET personnalisé actif');
|
|
@@ -66,17 +59,14 @@ else log('WARN', 'MASTER_SECRET non défini → clé maître dérivée de SESSIO
|
|
| 66 |
|
| 67 |
const db = new Database(DB_PATH);
|
| 68 |
db.pragma('journal_mode = WAL');
|
| 69 |
-
db.pragma('busy_timeout = 5000');
|
| 70 |
|
| 71 |
-
// --- CRYPTO ---
|
| 72 |
const scryptAsync = promisify(scrypt);
|
| 73 |
async function deriveKey(password, salt) { return scryptAsync(password, salt, KEY_LEN); }
|
| 74 |
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]); }
|
| 75 |
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()]); }
|
| 76 |
-
|
| 77 |
function esc(s) { return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"').replace(/'/g,'''); }
|
| 78 |
|
| 79 |
-
// --- PNG STEGO ---
|
| 80 |
function bufferToPng(buffer) {
|
| 81 |
const byteLen = buffer.length;
|
| 82 |
const pixelsNeeded = Math.ceil(byteLen / 3);
|
|
@@ -96,9 +86,6 @@ async function pngToBuffer(pngBuffer, expectedLen) {
|
|
| 96 |
return rgb.subarray(0, expectedLen);
|
| 97 |
}
|
| 98 |
|
| 99 |
-
// ==========================================
|
| 100 |
-
// SCHÉMA (+ table transfer_daily)
|
| 101 |
-
// ==========================================
|
| 102 |
db.exec(`
|
| 103 |
CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT UNIQUE NOT NULL, password_hash BLOB NOT NULL, salt BLOB NOT NULL, is_admin INTEGER DEFAULT 0, tos_accepted INTEGER DEFAULT 0, avatar BLOB, created_at INTEGER DEFAULT (strftime('%s','now')));
|
| 104 |
CREATE TABLE IF NOT EXISTS folders (id INTEGER PRIMARY KEY AUTOINCREMENT, parent_id INTEGER REFERENCES folders(id) ON DELETE CASCADE, owner_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, name TEXT NOT NULL, created_at INTEGER DEFAULT (strftime('%s','now')), UNIQUE(parent_id, owner_id, name));
|
|
@@ -114,15 +101,11 @@ CREATE INDEX IF NOT EXISTS idx_ushares_with ON user_shares(shared_with);
|
|
| 114 |
CREATE INDEX IF NOT EXISTS idx_transfer_day ON transfer_daily(user_id, day);
|
| 115 |
`);
|
| 116 |
|
| 117 |
-
// ==========================================
|
| 118 |
-
// MIGRATIONS
|
| 119 |
-
// ==========================================
|
| 120 |
function runMigrations() {
|
| 121 |
log('INFO', 'Vérification migrations BDD...');
|
| 122 |
const usersCols = db.prepare("PRAGMA table_info(users)").all().map(c => c.name);
|
| 123 |
if (!usersCols.includes('tos_accepted')) db.exec(`ALTER TABLE users ADD COLUMN tos_accepted INTEGER DEFAULT 0;`);
|
| 124 |
if (!usersCols.includes('avatar')) db.exec(`ALTER TABLE users ADD COLUMN avatar BLOB;`);
|
| 125 |
-
|
| 126 |
const filesCols = db.prepare("PRAGMA table_info(files)").all().map(c => c.name);
|
| 127 |
const filesDefs = [
|
| 128 |
{ name: 'folder_id', def: 'INTEGER REFERENCES folders(id) ON DELETE SET NULL' },
|
|
@@ -143,13 +126,11 @@ function runMigrations() {
|
|
| 143 |
}
|
| 144 |
}
|
| 145 |
if (updatedAdded) db.exec(`UPDATE files SET updated_at = strftime('%s','now') WHERE updated_at = 0;`);
|
| 146 |
-
|
| 147 |
const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table'").all().map(t => t.name);
|
| 148 |
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));`);
|
| 149 |
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);`);
|
| 150 |
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);`);
|
| 151 |
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);`);
|
| 152 |
-
|
| 153 |
const sessionsCols = tables.includes('sessions') ? db.prepare("PRAGMA table_info(sessions)").all().map(c => c.name) : [];
|
| 154 |
if (tables.includes('sessions') && (!sessionsCols.includes('data') || !sessionsCols.includes('expires_at'))) {
|
| 155 |
log('WARN', 'Table sessions invalide → reconstruction');
|
|
@@ -167,9 +148,7 @@ DROP TRIGGER IF EXISTS update_file_ts;
|
|
| 167 |
CREATE TRIGGER update_file_ts AFTER UPDATE ON files BEGIN UPDATE files SET updated_at = strftime('%s','now') WHERE id = NEW.id; END;
|
| 168 |
`);
|
| 169 |
|
| 170 |
-
//
|
| 171 |
-
// ✅ QUOTA DE TRANSIT JOURNALIER
|
| 172 |
-
// ==========================================
|
| 173 |
function todayKey() { return new Date().toISOString().slice(0, 10); }
|
| 174 |
function getTransfer(userId) {
|
| 175 |
const r = db.prepare('SELECT up_bytes, down_bytes FROM transfer_daily WHERE user_id=? AND day=?').get(userId, todayKey());
|
|
@@ -184,20 +163,15 @@ function addTransfer(userId, bytes, dir) {
|
|
| 184 |
.run(userId, todayKey(), dir === 'up' ? bytes : 0, dir === 'down' ? bytes : 0);
|
| 185 |
} catch (e) { log('WARN', `addTransfer: ${e.message}`); }
|
| 186 |
}
|
| 187 |
-
function transferBlocked(userId) {
|
| 188 |
-
return getTransfer(userId).total >= DAILY_TRANSFER;
|
| 189 |
-
}
|
| 190 |
|
| 191 |
-
//
|
| 192 |
-
// NETTOYAGE AUTO + BACKUPS
|
| 193 |
-
// ==========================================
|
| 194 |
function cleanTemp() {
|
| 195 |
try {
|
| 196 |
const now = Date.now();
|
| 197 |
for (const f of readdirSync(TEMP_DIR)) {
|
| 198 |
const p = join(TEMP_DIR, f);
|
| 199 |
-
|
| 200 |
-
if (now - st.mtimeMs > 24 * 3600 * 1000) { rmSync(p, { force: true }); log('INFO', `Temp nettoyé: ${f}`); }
|
| 201 |
}
|
| 202 |
} catch (e) { log('WARN', `Temp cleanup: ${e.message}`); }
|
| 203 |
}
|
|
@@ -237,9 +211,7 @@ setInterval(() => { cleanTemp(); cleanErrorFiles(); cleanOldTransfers(); }, 3600
|
|
| 237 |
setInterval(backupDb, 6 * 3600000);
|
| 238 |
setTimeout(backupDb, 60000);
|
| 239 |
|
| 240 |
-
//
|
| 241 |
-
// RATE LIMITER
|
| 242 |
-
// ==========================================
|
| 243 |
function rateLimit({ windowMs = 60000, max = 100, message = 'Trop de requêtes, réessayez plus tard' } = {}) {
|
| 244 |
const hits = new Map();
|
| 245 |
setInterval(() => { const now = Date.now(); for (const [k, v] of hits) if (now - v.start > windowMs) hits.delete(k); }, Math.max(windowMs, 60000));
|
|
@@ -260,24 +232,18 @@ const uploadLimit = rateLimit({ windowMs: 60*60000, max: 30, message: 'Quota u
|
|
| 260 |
const shareLimit = rateLimit({ windowMs: 60*60000, max: 50, message: 'Trop de partages' });
|
| 261 |
const avatarLimit = rateLimit({ windowMs: 60*60000, max: 10, message: 'Trop de changements d\'avatar' });
|
| 262 |
|
| 263 |
-
//
|
| 264 |
-
// CSRF
|
| 265 |
-
// ==========================================
|
| 266 |
function csrfGuard(req, res, next) {
|
| 267 |
if (['GET', 'HEAD', 'OPTIONS'].includes(req.method)) return next();
|
| 268 |
const origin = req.headers.origin;
|
| 269 |
if (origin) {
|
| 270 |
let oHost;
|
| 271 |
try { oHost = new URL(origin).host; } catch (e) { return res.status(403).json({ error: 'Origin invalide' }); }
|
| 272 |
-
if (oHost !== req.get('host')) {
|
| 273 |
-
log('WARN', `CSRF bloqué: origin=${origin} host=${req.get('host')}`);
|
| 274 |
-
return res.status(403).json({ error: 'Requête cross-origin refusée' });
|
| 275 |
-
}
|
| 276 |
}
|
| 277 |
next();
|
| 278 |
}
|
| 279 |
|
| 280 |
-
// --- SESSION STORE ---
|
| 281 |
class SQLiteStore extends session.Store {
|
| 282 |
get(sid, cb) { try { const r = db.prepare('SELECT data, expires_at FROM sessions WHERE sid=?').get(sid); if(!r) return cb(null, null); if(r.expires_at < Date.now()){ db.prepare('DELETE FROM sessions WHERE sid=?').run(sid); return cb(null, null); } cb(null, JSON.parse(r.data)); } catch(e){ cb(e); } }
|
| 283 |
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); } }
|
|
@@ -288,7 +254,6 @@ class SQLiteStore extends session.Store {
|
|
| 288 |
}
|
| 289 |
const sessionStore = new SQLiteStore();
|
| 290 |
|
| 291 |
-
// --- ADMIN ---
|
| 292 |
const adminRow = db.prepare('SELECT * FROM users WHERE username=?').get(ADMIN_USER);
|
| 293 |
if (!adminRow) {
|
| 294 |
const salt = randomBytes(16);
|
|
@@ -297,10 +262,8 @@ if (!adminRow) {
|
|
| 297 |
log('INFO', `Compte admin créé: ${ADMIN_USER}`);
|
| 298 |
}
|
| 299 |
|
| 300 |
-
// --- EXPRESS ---
|
| 301 |
const app = express();
|
| 302 |
app.set('trust proxy', 1);
|
| 303 |
-
|
| 304 |
app.use((req, res, next) => {
|
| 305 |
res.set({
|
| 306 |
'X-Content-Type-Options': 'nosniff',
|
|
@@ -310,28 +273,18 @@ app.use((req, res, next) => {
|
|
| 310 |
'Cross-Origin-Opener-Policy': 'same-origin',
|
| 311 |
'Content-Security-Policy': "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; media-src 'self'; connect-src 'self'; frame-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'",
|
| 312 |
});
|
| 313 |
-
if (req.secure || req.get('x-forwarded-proto') === 'https')
|
| 314 |
-
res.set('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
|
| 315 |
-
}
|
| 316 |
next();
|
| 317 |
});
|
| 318 |
-
|
| 319 |
app.use(express.json({ limit: '50mb' }));
|
| 320 |
app.use(express.urlencoded({ extended: true, limit: '50mb' }));
|
| 321 |
-
app.use(session({
|
| 322 |
-
secret: SESSION_SECRET,
|
| 323 |
-
store: sessionStore,
|
| 324 |
-
resave: false,
|
| 325 |
-
saveUninitialized: false,
|
| 326 |
-
cookie: { secure: IS_PROD, httpOnly: true, maxAge: 30*24*60*60*1000, sameSite: 'lax' }
|
| 327 |
-
}));
|
| 328 |
app.use('/api', csrfGuard);
|
| 329 |
app.use('/api', globalLimit);
|
| 330 |
|
| 331 |
const requireAuth = (req, res, next) => { if(!req.session.userId) return res.status(401).json({error:'Non authentifié'}); next(); };
|
| 332 |
const requireTos = (req, res, next) => { if(!req.session.tosAccepted) return res.status(403).json({error:'CGU non acceptées', needTos:true}); next(); };
|
| 333 |
|
| 334 |
-
// --- CACHE ---
|
| 335 |
class ByteLRUCache extends LRUCache { constructor(){ super({maxSize:CACHE_MAX_BYTES, sizeCalculation:v=>v.size}); } }
|
| 336 |
const fileCache = new ByteLRUCache();
|
| 337 |
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);
|
|
@@ -343,27 +296,22 @@ setInterval(() => {
|
|
| 343 |
if (s.changes || l.changes) log('INFO', `Cleanup: ${s.changes} session(s), ${l.changes} lien(s) expiré(s)`);
|
| 344 |
}, 3600000);
|
| 345 |
|
| 346 |
-
// --- NSFW ---
|
| 347 |
let nsfwModel = null;
|
| 348 |
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}`); } }
|
| 349 |
loadNsfwModel();
|
| 350 |
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}; } }
|
| 351 |
|
| 352 |
-
// --- QUEUE ---
|
| 353 |
class UploadQueue{ constructor(c){ this.c=c; this.r=0; this.q=[]; } add(t){ return new Promise((res,rej)=>{ this.q.push({t,res,rej}); this.proc(); }); } async proc(){ if(this.r>=this.c||!this.q.length) return; this.r++; const {t,res,rej}=this.q.shift(); try{ res(await t()); }catch(e){ rej(e); }finally{ this.r--; this.proc(); } } }
|
| 354 |
const uploadQueue = new UploadQueue(MAX_CONCURRENT_UPLOADS);
|
| 355 |
|
| 356 |
-
// --- IMGBB ---
|
| 357 |
async function imgbbUpload(buf){ const f=new FormData(); f.append('image',buf.toString('base64')); const {data}=await axios.post(`https://api.imgbb.com/1/upload?key=${IMGBB_API_KEY}`,f,{headers:f.getHeaders(),timeout:120000,maxContentLength:Infinity,maxBodyLength:Infinity}); if(!data.success) throw new Error(data.error?.message||'ImgBB fail'); return {url:data.data.url,deleteUrl:data.data.delete_url,id:data.data.id}; }
|
| 358 |
async function imgbbDelete(u){ try{await axios.get(u,{timeout:10000});}catch(_){} }
|
| 359 |
|
| 360 |
-
// --- KEYS ---
|
| 361 |
const SERVER_MASTER_KEY = createHash('sha256').update(MASTER_SECRET + '|PixelDriveMaster').digest();
|
| 362 |
function encryptMasterKey(k){ const iv=randomBytes(12); const c=createCipheriv('aes-256-gcm',SERVER_MASTER_KEY,iv); return Buffer.concat([iv,c.update(k),c.final(),c.getAuthTag()]); }
|
| 363 |
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()]); }
|
| 364 |
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); }
|
| 365 |
|
| 366 |
-
// --- CONTRÔLE D'ACCÈS ---
|
| 367 |
function canAccessFolder(userId, folderId){
|
| 368 |
let cur = db.prepare('SELECT * FROM folders WHERE id=?').get(folderId);
|
| 369 |
let depth = 0;
|
|
@@ -384,7 +332,6 @@ function canAccessFile(userId, fileId){
|
|
| 384 |
return null;
|
| 385 |
}
|
| 386 |
|
| 387 |
-
// --- THUMB ---
|
| 388 |
async function genThumb(fid,mime,key){
|
| 389 |
if(!mime.startsWith('image/')&&!mime.startsWith('video/')) return;
|
| 390 |
try{
|
|
@@ -400,7 +347,6 @@ async function genThumb(fid,mime,key){
|
|
| 400 |
}catch(e){ log('WARN', `Thumb ${fid}: ${e.message}`); }
|
| 401 |
}
|
| 402 |
|
| 403 |
-
// --- STREAM (✅ compte le transit download) ---
|
| 404 |
async function streamFile(res,file,key,range,counterUserId){
|
| 405 |
const chunks = db.prepare('SELECT chunk_index,imgbb_url,size FROM file_chunks WHERE file_id=? ORDER BY chunk_index').all(file.id);
|
| 406 |
const total = file.size;
|
|
@@ -410,7 +356,7 @@ async function streamFile(res,file,key,range,counterUserId){
|
|
| 410 |
const sentBytes = e - s + 1;
|
| 411 |
res.set({'Content-Type':file.mime,'Content-Length':sentBytes,'Accept-Ranges':'bytes','Content-Disposition':`inline; filename="${encodeURIComponent(file.name)}"`});
|
| 412 |
if(range){ res.status(206); res.set('Content-Range', `bytes ${s}-${e}/${total}`); }
|
| 413 |
-
log('INFO', `Stream ${file.name} (${(sentBytes/1048576).toFixed(2)} MB
|
| 414 |
let cur = 0;
|
| 415 |
for(const c of chunks){
|
| 416 |
const cs = cur, ce = cur + c.size - 1;
|
|
@@ -423,10 +369,9 @@ async function streamFile(res,file,key,range,counterUserId){
|
|
| 423 |
const t0 = Date.now();
|
| 424 |
const {data} = await axios.get(c.imgbb_url,{responseType:'arraybuffer',timeout:120000});
|
| 425 |
const t1 = Date.now();
|
| 426 |
-
log('INFO', ` Chunk ${c.chunk_index}: ${(data.byteLength/1048576).toFixed(2)} MB en ${t1-t0}ms`);
|
| 427 |
const enc = await pngToBuffer(Buffer.from(data), c.size + OVERHEAD);
|
| 428 |
dec = decryptChunk(enc, key);
|
| 429 |
-
log('INFO', ` Chunk ${c.chunk_index}:
|
| 430 |
fileCache.set(ck, {data: dec, size: dec.length});
|
| 431 |
}
|
| 432 |
const sl = dec.subarray(off, off + len);
|
|
@@ -437,10 +382,9 @@ async function streamFile(res,file,key,range,counterUserId){
|
|
| 437 |
if(cs > e) break;
|
| 438 |
}
|
| 439 |
res.end();
|
| 440 |
-
if (counterUserId) addTransfer(counterUserId, sentBytes, 'down');
|
| 441 |
}
|
| 442 |
|
| 443 |
-
// --- SSE ---
|
| 444 |
const sseClients = new Map();
|
| 445 |
function broadcast(fid,d){ const cs = sseClients.get(fid); if(cs){ const p = `data: ${JSON.stringify(d)}\n\n`; for(const r of cs) r.write(p); } }
|
| 446 |
|
|
@@ -453,8 +397,7 @@ app.post('/api/register', registerLimit, async(req,res)=>{
|
|
| 453 |
if(typeof u !== 'string' || !/^[a-zA-Z0-9_.\-]{3,20}$/.test(u)) return res.status(400).json({error:'Nom d\'utilisateur invalide (3-20 caractères alphanumériques)'});
|
| 454 |
if(typeof p !== 'string' || p.length < 6) return res.status(400).json({error:'Mot de passe trop court (6 caractères min)'});
|
| 455 |
if(db.prepare('SELECT 1 FROM users WHERE username=?').get(u)) return res.status(409).json({error:'Existe'});
|
| 456 |
-
const s = randomBytes(16);
|
| 457 |
-
const h = await deriveKey(p, s);
|
| 458 |
db.prepare('INSERT INTO users(username,password_hash,salt,tos_accepted) VALUES(?,?,?,1)').run(u,h,s);
|
| 459 |
log('INFO', `Nouvel utilisateur: ${u}`);
|
| 460 |
res.json({ok:true});
|
|
@@ -466,35 +409,23 @@ app.post('/api/login', loginLimit, async(req,res)=>{
|
|
| 466 |
if(!usr) return res.status(401).json({error:'Invalide'});
|
| 467 |
const h = await deriveKey(p, usr.salt);
|
| 468 |
if(!h.equals(usr.password_hash)) return res.status(401).json({error:'Invalide'});
|
| 469 |
-
try {
|
| 470 |
-
|
| 471 |
-
|
| 472 |
-
log('ERROR', `Session regenerate échouée: ${e.message}`);
|
| 473 |
-
return res.status(500).json({error:'Erreur session'});
|
| 474 |
-
}
|
| 475 |
-
req.session.userId = usr.id;
|
| 476 |
-
req.session.isAdmin = !!usr.is_admin;
|
| 477 |
-
req.session.tosAccepted = !!usr.tos_accepted;
|
| 478 |
-
req.session.username = usr.username;
|
| 479 |
log('INFO', `Connexion: ${u}`);
|
| 480 |
res.json({ok:true,user:{id:usr.id,username:usr.username,isAdmin:!!usr.is_admin,tosAccepted:!!usr.tos_accepted}});
|
| 481 |
});
|
| 482 |
app.post('/api/logout',(req,res)=>req.session.destroy(()=>res.json({ok:true})));
|
| 483 |
|
| 484 |
-
// ✅ /api/me AVEC QUOTAS (stockage + transit)
|
| 485 |
app.get('/api/me',(req,res)=>{
|
| 486 |
if(!req.session.userId) return res.status(401).json({error:'Non connecté'});
|
| 487 |
const used = db.prepare("SELECT COALESCE(SUM(size),0) as t FROM files WHERE owner_id=? AND status!='error'").get(req.session.userId).t;
|
| 488 |
const tr = getTransfer(req.session.userId);
|
| 489 |
-
res.json({
|
| 490 |
-
|
| 491 |
-
quotaUsed:used, quotaMax:USER_QUOTA,
|
| 492 |
-
transferUp:tr.up, transferDown:tr.down, transferUsed:tr.total, transferMax:DAILY_TRANSFER
|
| 493 |
-
});
|
| 494 |
});
|
| 495 |
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});});
|
| 496 |
|
| 497 |
-
// AVATARS
|
| 498 |
app.post('/api/avatar', requireAuth, avatarLimit, async(req,res)=>{
|
| 499 |
const { data } = req.body;
|
| 500 |
if (!data || !data.startsWith('data:image/')) return res.status(400).json({error:'Image invalide'});
|
|
@@ -515,7 +446,6 @@ app.get('/api/users/:id/avatar',(req,res)=>{
|
|
| 515 |
res.send(u.avatar);
|
| 516 |
});
|
| 517 |
|
| 518 |
-
// Folders
|
| 519 |
app.get('/api/folders',requireAuth,(req,res)=>{
|
| 520 |
const p = req.query.parent_id ? parseInt(req.query.parent_id) : null;
|
| 521 |
if (p !== null) {
|
|
@@ -527,7 +457,36 @@ app.get('/api/folders',requireAuth,(req,res)=>{
|
|
| 527 |
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'});}});
|
| 528 |
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});});
|
| 529 |
|
| 530 |
-
//
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 531 |
app.get('/api/files',requireAuth,(req,res)=>{
|
| 532 |
const f = req.query.folder_id ? parseInt(req.query.folder_id) : null;
|
| 533 |
if (f !== null) {
|
|
@@ -537,7 +496,32 @@ app.get('/api/files',requireAuth,(req,res)=>{
|
|
| 537 |
res.json(db.prepare('SELECT * FROM files WHERE owner_id=? AND folder_id IS NULL ORDER BY name COLLATE NOCASE').all(req.session.userId));
|
| 538 |
});
|
| 539 |
|
| 540 |
-
//
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 541 |
app.post('/api/share-user',requireAuth,requireTos,shareLimit,(req,res)=>{
|
| 542 |
const { type, id, username } = req.body;
|
| 543 |
if (!type || !id || !username) return res.status(400).json({error:'Champs manquants'});
|
|
@@ -572,18 +556,14 @@ app.delete('/api/my-shares/:id',requireAuth,(req,res)=>{
|
|
| 572 |
res.json({ok:true});
|
| 573 |
});
|
| 574 |
|
| 575 |
-
// Liens publics
|
| 576 |
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)); });
|
| 577 |
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}); });
|
| 578 |
|
| 579 |
-
// ✅ UPLOAD : quota stockage + quota transit + NSFW
|
| 580 |
const upload = multer({dest:TEMP_DIR,limits:{fileSize:5*1024*1024*1024}});
|
| 581 |
app.post('/api/files/upload',requireAuth,requireTos,uploadLimit,upload.single('file'),async(req,res)=>{
|
| 582 |
if(!req.file)return res.status(400).json({error:'Fichier manquant'});
|
| 583 |
const uid = req.session.userId;
|
| 584 |
const fsize = req.file.size;
|
| 585 |
-
|
| 586 |
-
// ✅ Quota transit journalier (upload)
|
| 587 |
const tr = getTransfer(uid);
|
| 588 |
const remaining = DAILY_TRANSFER - tr.total;
|
| 589 |
if (remaining <= 0) {
|
|
@@ -595,14 +575,11 @@ app.post('/api/files/upload',requireAuth,requireTos,uploadLimit,upload.single('f
|
|
| 595 |
try { unlinkSync(req.file.path); } catch (_) {}
|
| 596 |
return res.status(413).json({error:`Quota journalier insuffisant (reste ${(remaining/1073741824).toFixed(1)} GB)`});
|
| 597 |
}
|
| 598 |
-
|
| 599 |
-
// Quota stockage
|
| 600 |
const used = db.prepare("SELECT COALESCE(SUM(size),0) as t FROM files WHERE owner_id=? AND status!='error'").get(uid).t;
|
| 601 |
if (used + fsize > USER_QUOTA) {
|
| 602 |
try { unlinkSync(req.file.path); } catch (_) {}
|
| 603 |
return res.status(413).json({error:`Quota de stockage dépassé (${(USER_QUOTA/1073741824).toFixed(0)} GB max)`});
|
| 604 |
}
|
| 605 |
-
|
| 606 |
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;
|
| 607 |
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);
|
| 608 |
const stmt=db.prepare('INSERT INTO file_chunks(file_id,chunk_index,size,encrypted_len) VALUES(?,?,?,?)');
|
|
@@ -625,10 +602,7 @@ async function processUpload(fid,path,mk,cc,mime,fsize,username,uid){
|
|
| 625 |
const raw=await readChunk(path,off,ci.size);
|
| 626 |
if (i === 0 && mime.startsWith('image/')) {
|
| 627 |
const scan = await checkNsfw(raw);
|
| 628 |
-
if (!scan.safe) {
|
| 629 |
-
log('ALERT', `NSFW bloqué: ${fid} par ${username}`);
|
| 630 |
-
throw new Error('Contenu bloqué par le filtre de modération');
|
| 631 |
-
}
|
| 632 |
}
|
| 633 |
const enc=encryptChunk(raw,mk);
|
| 634 |
if(enc.length>ci.encrypted_len)throw new Error(`Overflow chunk ${i}`);
|
|
@@ -646,10 +620,9 @@ async function processUpload(fid,path,mk,cc,mime,fsize,username,uid){
|
|
| 646 |
await genThumb(fid,mime,mk);
|
| 647 |
db.prepare("UPDATE files SET status='ready',progress=100 WHERE id=?").run(fid);
|
| 648 |
broadcast(fid,{type:'progress',progress:100,status:'ready',type:'done'});
|
| 649 |
-
addTransfer(uid, fsize, 'up');
|
| 650 |
const secs = (Date.now()-t0)/1000;
|
| 651 |
-
|
| 652 |
-
log('INFO', `Upload terminé: ${fid} en ${secs.toFixed(1)}s (${speed.toFixed(2)} MB/s)`);
|
| 653 |
}catch(e){
|
| 654 |
log('ERROR', `Upload échoué ${fid}: ${e.message}`);
|
| 655 |
db.prepare("UPDATE files SET status='error',error_msg=? WHERE id=?").run(e.message,fid);
|
|
@@ -661,13 +634,9 @@ async function processUpload(fid,path,mk,cc,mime,fsize,username,uid){
|
|
| 661 |
}
|
| 662 |
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);});}
|
| 663 |
|
| 664 |
-
// ✅ DOWNLOAD : quota transit du demandeur
|
| 665 |
app.get('/api/files/:id/download',requireAuth,async(req,res)=>{
|
| 666 |
if(!UUID_RE.test(req.params.id)) return res.status(400).json({error:'ID invalide'});
|
| 667 |
-
if (transferBlocked(req.session.userId)) {
|
| 668 |
-
log('ALERT', `Quota transit atteint (download bloqué): ${req.session.username}`);
|
| 669 |
-
return res.status(429).json({error:'Quota de transfert journalier atteint, réessayez demain'});
|
| 670 |
-
}
|
| 671 |
const f = canAccessFile(req.session.userId, req.params.id);
|
| 672 |
if(!f)return res.status(404).json({error:'Introuvable'});
|
| 673 |
if(f.status!=='ready')return res.status(409).json({error:'Pas prêt',status:f.status});
|
|
@@ -684,7 +653,6 @@ app.post('/api/files/:id/share',requireAuth,shareLimit,async(req,res)=>{
|
|
| 684 |
res.json({preview:`/s/${pt}`,download:`/d/${dt2}`,expires});
|
| 685 |
});
|
| 686 |
|
| 687 |
-
// PAGE PUBLIQUE
|
| 688 |
app.get('/s/:token',async(req,res)=>{
|
| 689 |
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());
|
| 690 |
if(!l)return res.status(404).send(html('Lien invalide/expiré'));
|
|
@@ -703,19 +671,15 @@ app.get('/s/:token',async(req,res)=>{
|
|
| 703 |
{ title: name, desc: `${sizeMb} MB · PixelDrive — partage sécurisé`, image: ogImage }));
|
| 704 |
});
|
| 705 |
|
| 706 |
-
// ✅ DOWNLOAD PUBLIC : quota transit du PROPRIÉTAIRE
|
| 707 |
app.get('/d/:token',async(req,res)=>{
|
| 708 |
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());
|
| 709 |
if(!l)return res.status(404).json({error:'Invalide'});
|
| 710 |
const f=db.prepare('SELECT * FROM files WHERE id=?').get(l.file_id);
|
| 711 |
if(!f||f.status!=='ready')return res.status(404).json({error:'Indisponible'});
|
| 712 |
-
if (transferBlocked(f.owner_id)) {
|
| 713 |
-
log('ALERT', `Quota transit propriétaire atteint (lien public bloqué): fichier ${f.id}`);
|
| 714 |
-
return res.status(429).json({error:'Quota journalier du propriétaire atteint'});
|
| 715 |
-
}
|
| 716 |
log('INFO', `Téléchargement public /d/${req.params.token} (${f.name})`);
|
| 717 |
-
const mk=decryptMasterKey(db.prepare('SELECT master_key_enc FROM files WHERE id=?').get(f.id).master_key_enc);
|
| 718 |
-
await streamFile(res,f,
|
| 719 |
});
|
| 720 |
|
| 721 |
app.delete('/api/files/:id',requireAuth,async(req,res)=>{
|
|
@@ -755,7 +719,6 @@ app.get('/api/files/:id/progress',requireAuth,(req,res)=>{
|
|
| 755 |
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());});
|
| 756 |
app.get('/health',(_,res)=>res.json({ok:true,cache:fileCache.size,mem:process.memoryUsage()}));
|
| 757 |
|
| 758 |
-
// STATIQUE
|
| 759 |
app.use(express.static(PUBLIC_DIR, { dotfiles: 'deny', index: false }));
|
| 760 |
app.get('/manifest.json', (req,res) => res.sendFile(join(__dirname, 'manifest.json')));
|
| 761 |
app.get('*', (req, res) => {
|
|
@@ -763,14 +726,13 @@ app.get('*', (req, res) => {
|
|
| 763 |
res.sendFile(join(PUBLIC_DIR, 'index.html'));
|
| 764 |
});
|
| 765 |
|
| 766 |
-
// ERROR HANDLER
|
| 767 |
app.use((err, req, res, next) => {
|
| 768 |
if (err.type === 'entity.parse.failed' || err instanceof SyntaxError) return res.status(400).json({ error: 'Bad Request' });
|
| 769 |
log('ERROR', `Server: ${err.message}`);
|
| 770 |
res.status(500).json({ error: 'Internal Server Error' });
|
| 771 |
});
|
| 772 |
|
| 773 |
-
const server = app.listen(PORT, '0.0.0.0', () => log('INFO', `PixelDrive v4.
|
| 774 |
process.on('SIGTERM', () => { log('WARN', 'SIGTERM reçu, arrêt...'); server.close(() => process.exit(0)); });
|
| 775 |
|
| 776 |
function html(b, og) {
|
|
|
|
| 1 |
// ==========================================
|
| 2 |
+
// PIXELDRIVE v4.6 - SERVER.JS (ESM) - RENAME/MOVE + QUOTAS
|
| 3 |
// ==========================================
|
| 4 |
import express from 'express';
|
| 5 |
import session from 'express-session';
|
|
|
|
| 34 |
const TAG_LEN = 16;
|
| 35 |
const OVERHEAD = IV_LEN + TAG_LEN;
|
| 36 |
const IS_PROD = process.env.NODE_ENV === 'production';
|
| 37 |
+
const USER_QUOTA = parseInt(process.env.USER_QUOTA_GB || '50', 10) * 1024 * 1024 * 1024;
|
| 38 |
+
const DAILY_TRANSFER = parseInt(process.env.DAILY_TRANSFER_GB || '100', 10) * 1024 * 1024 * 1024;
|
| 39 |
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
| 40 |
|
|
|
|
|
|
|
|
|
|
| 41 |
function log(level, msg) {
|
| 42 |
const n = new Date();
|
| 43 |
const ts = [n.getHours(), n.getMinutes(), n.getSeconds()].map(x => String(x).padStart(2, '0')).join(':');
|
|
|
|
| 47 |
else console.log(line);
|
| 48 |
}
|
| 49 |
|
|
|
|
| 50 |
const IMGBB_API_KEY = process.env.IMGBB_API_KEY;
|
| 51 |
const SESSION_SECRET = process.env.SESSION_SECRET;
|
| 52 |
const ADMIN_USER = process.env.ADMIN_USER || 'admin';
|
| 53 |
|
| 54 |
+
if (!IMGBB_API_KEY || !SESSION_SECRET) { log('FATAL ERROR', 'Variables manquantes: IMGBB_API_KEY, SESSION_SECRET'); process.exit(1); }
|
|
|
|
|
|
|
|
|
|
| 55 |
|
| 56 |
const MASTER_SECRET = process.env.MASTER_SECRET || SESSION_SECRET;
|
| 57 |
if (process.env.MASTER_SECRET) log('INFO', 'MASTER_SECRET personnalisé actif');
|
|
|
|
| 59 |
|
| 60 |
const db = new Database(DB_PATH);
|
| 61 |
db.pragma('journal_mode = WAL');
|
| 62 |
+
db.pragma('busy_timeout = 5000);'.slice(0,0) || db.pragma('busy_timeout = 5000');
|
| 63 |
|
|
|
|
| 64 |
const scryptAsync = promisify(scrypt);
|
| 65 |
async function deriveKey(password, salt) { return scryptAsync(password, salt, KEY_LEN); }
|
| 66 |
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]); }
|
| 67 |
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()]); }
|
|
|
|
| 68 |
function esc(s) { return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"').replace(/'/g,'''); }
|
| 69 |
|
|
|
|
| 70 |
function bufferToPng(buffer) {
|
| 71 |
const byteLen = buffer.length;
|
| 72 |
const pixelsNeeded = Math.ceil(byteLen / 3);
|
|
|
|
| 86 |
return rgb.subarray(0, expectedLen);
|
| 87 |
}
|
| 88 |
|
|
|
|
|
|
|
|
|
|
| 89 |
db.exec(`
|
| 90 |
CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT UNIQUE NOT NULL, password_hash BLOB NOT NULL, salt BLOB NOT NULL, is_admin INTEGER DEFAULT 0, tos_accepted INTEGER DEFAULT 0, avatar BLOB, created_at INTEGER DEFAULT (strftime('%s','now')));
|
| 91 |
CREATE TABLE IF NOT EXISTS folders (id INTEGER PRIMARY KEY AUTOINCREMENT, parent_id INTEGER REFERENCES folders(id) ON DELETE CASCADE, owner_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, name TEXT NOT NULL, created_at INTEGER DEFAULT (strftime('%s','now')), UNIQUE(parent_id, owner_id, name));
|
|
|
|
| 101 |
CREATE INDEX IF NOT EXISTS idx_transfer_day ON transfer_daily(user_id, day);
|
| 102 |
`);
|
| 103 |
|
|
|
|
|
|
|
|
|
|
| 104 |
function runMigrations() {
|
| 105 |
log('INFO', 'Vérification migrations BDD...');
|
| 106 |
const usersCols = db.prepare("PRAGMA table_info(users)").all().map(c => c.name);
|
| 107 |
if (!usersCols.includes('tos_accepted')) db.exec(`ALTER TABLE users ADD COLUMN tos_accepted INTEGER DEFAULT 0;`);
|
| 108 |
if (!usersCols.includes('avatar')) db.exec(`ALTER TABLE users ADD COLUMN avatar BLOB;`);
|
|
|
|
| 109 |
const filesCols = db.prepare("PRAGMA table_info(files)").all().map(c => c.name);
|
| 110 |
const filesDefs = [
|
| 111 |
{ name: 'folder_id', def: 'INTEGER REFERENCES folders(id) ON DELETE SET NULL' },
|
|
|
|
| 126 |
}
|
| 127 |
}
|
| 128 |
if (updatedAdded) db.exec(`UPDATE files SET updated_at = strftime('%s','now') WHERE updated_at = 0;`);
|
|
|
|
| 129 |
const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table'").all().map(t => t.name);
|
| 130 |
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));`);
|
| 131 |
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);`);
|
| 132 |
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);`);
|
| 133 |
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);`);
|
|
|
|
| 134 |
const sessionsCols = tables.includes('sessions') ? db.prepare("PRAGMA table_info(sessions)").all().map(c => c.name) : [];
|
| 135 |
if (tables.includes('sessions') && (!sessionsCols.includes('data') || !sessionsCols.includes('expires_at'))) {
|
| 136 |
log('WARN', 'Table sessions invalide → reconstruction');
|
|
|
|
| 148 |
CREATE TRIGGER update_file_ts AFTER UPDATE ON files BEGIN UPDATE files SET updated_at = strftime('%s','now') WHERE id = NEW.id; END;
|
| 149 |
`);
|
| 150 |
|
| 151 |
+
// --- QUOTA TRANSIT ---
|
|
|
|
|
|
|
| 152 |
function todayKey() { return new Date().toISOString().slice(0, 10); }
|
| 153 |
function getTransfer(userId) {
|
| 154 |
const r = db.prepare('SELECT up_bytes, down_bytes FROM transfer_daily WHERE user_id=? AND day=?').get(userId, todayKey());
|
|
|
|
| 163 |
.run(userId, todayKey(), dir === 'up' ? bytes : 0, dir === 'down' ? bytes : 0);
|
| 164 |
} catch (e) { log('WARN', `addTransfer: ${e.message}`); }
|
| 165 |
}
|
| 166 |
+
function transferBlocked(userId) { return getTransfer(userId).total >= DAILY_TRANSFER; }
|
|
|
|
|
|
|
| 167 |
|
| 168 |
+
// --- NETTOYAGE + BACKUPS ---
|
|
|
|
|
|
|
| 169 |
function cleanTemp() {
|
| 170 |
try {
|
| 171 |
const now = Date.now();
|
| 172 |
for (const f of readdirSync(TEMP_DIR)) {
|
| 173 |
const p = join(TEMP_DIR, f);
|
| 174 |
+
if (now - statSync(p).mtimeMs > 24 * 3600 * 1000) { rmSync(p, { force: true }); log('INFO', `Temp nettoyé: ${f}`); }
|
|
|
|
| 175 |
}
|
| 176 |
} catch (e) { log('WARN', `Temp cleanup: ${e.message}`); }
|
| 177 |
}
|
|
|
|
| 211 |
setInterval(backupDb, 6 * 3600000);
|
| 212 |
setTimeout(backupDb, 60000);
|
| 213 |
|
| 214 |
+
// --- RATE LIMITER ---
|
|
|
|
|
|
|
| 215 |
function rateLimit({ windowMs = 60000, max = 100, message = 'Trop de requêtes, réessayez plus tard' } = {}) {
|
| 216 |
const hits = new Map();
|
| 217 |
setInterval(() => { const now = Date.now(); for (const [k, v] of hits) if (now - v.start > windowMs) hits.delete(k); }, Math.max(windowMs, 60000));
|
|
|
|
| 232 |
const shareLimit = rateLimit({ windowMs: 60*60000, max: 50, message: 'Trop de partages' });
|
| 233 |
const avatarLimit = rateLimit({ windowMs: 60*60000, max: 10, message: 'Trop de changements d\'avatar' });
|
| 234 |
|
| 235 |
+
// --- CSRF ---
|
|
|
|
|
|
|
| 236 |
function csrfGuard(req, res, next) {
|
| 237 |
if (['GET', 'HEAD', 'OPTIONS'].includes(req.method)) return next();
|
| 238 |
const origin = req.headers.origin;
|
| 239 |
if (origin) {
|
| 240 |
let oHost;
|
| 241 |
try { oHost = new URL(origin).host; } catch (e) { return res.status(403).json({ error: 'Origin invalide' }); }
|
| 242 |
+
if (oHost !== req.get('host')) { log('WARN', `CSRF bloqué: origin=${origin}`); return res.status(403).json({ error: 'Requête cross-origin refusée' }); }
|
|
|
|
|
|
|
|
|
|
| 243 |
}
|
| 244 |
next();
|
| 245 |
}
|
| 246 |
|
|
|
|
| 247 |
class SQLiteStore extends session.Store {
|
| 248 |
get(sid, cb) { try { const r = db.prepare('SELECT data, expires_at FROM sessions WHERE sid=?').get(sid); if(!r) return cb(null, null); if(r.expires_at < Date.now()){ db.prepare('DELETE FROM sessions WHERE sid=?').run(sid); return cb(null, null); } cb(null, JSON.parse(r.data)); } catch(e){ cb(e); } }
|
| 249 |
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); } }
|
|
|
|
| 254 |
}
|
| 255 |
const sessionStore = new SQLiteStore();
|
| 256 |
|
|
|
|
| 257 |
const adminRow = db.prepare('SELECT * FROM users WHERE username=?').get(ADMIN_USER);
|
| 258 |
if (!adminRow) {
|
| 259 |
const salt = randomBytes(16);
|
|
|
|
| 262 |
log('INFO', `Compte admin créé: ${ADMIN_USER}`);
|
| 263 |
}
|
| 264 |
|
|
|
|
| 265 |
const app = express();
|
| 266 |
app.set('trust proxy', 1);
|
|
|
|
| 267 |
app.use((req, res, next) => {
|
| 268 |
res.set({
|
| 269 |
'X-Content-Type-Options': 'nosniff',
|
|
|
|
| 273 |
'Cross-Origin-Opener-Policy': 'same-origin',
|
| 274 |
'Content-Security-Policy': "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; media-src 'self'; connect-src 'self'; frame-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'",
|
| 275 |
});
|
| 276 |
+
if (req.secure || req.get('x-forwarded-proto') === 'https') res.set('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
|
|
|
|
|
|
|
| 277 |
next();
|
| 278 |
});
|
|
|
|
| 279 |
app.use(express.json({ limit: '50mb' }));
|
| 280 |
app.use(express.urlencoded({ extended: true, limit: '50mb' }));
|
| 281 |
+
app.use(session({ secret: SESSION_SECRET, store: sessionStore, resave: false, saveUninitialized: false, cookie: { secure: IS_PROD, httpOnly: true, maxAge: 30*24*60*60*1000, sameSite: 'lax' } }));
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 282 |
app.use('/api', csrfGuard);
|
| 283 |
app.use('/api', globalLimit);
|
| 284 |
|
| 285 |
const requireAuth = (req, res, next) => { if(!req.session.userId) return res.status(401).json({error:'Non authentifié'}); next(); };
|
| 286 |
const requireTos = (req, res, next) => { if(!req.session.tosAccepted) return res.status(403).json({error:'CGU non acceptées', needTos:true}); next(); };
|
| 287 |
|
|
|
|
| 288 |
class ByteLRUCache extends LRUCache { constructor(){ super({maxSize:CACHE_MAX_BYTES, sizeCalculation:v=>v.size}); } }
|
| 289 |
const fileCache = new ByteLRUCache();
|
| 290 |
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);
|
|
|
|
| 296 |
if (s.changes || l.changes) log('INFO', `Cleanup: ${s.changes} session(s), ${l.changes} lien(s) expiré(s)`);
|
| 297 |
}, 3600000);
|
| 298 |
|
|
|
|
| 299 |
let nsfwModel = null;
|
| 300 |
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}`); } }
|
| 301 |
loadNsfwModel();
|
| 302 |
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}; } }
|
| 303 |
|
|
|
|
| 304 |
class UploadQueue{ constructor(c){ this.c=c; this.r=0; this.q=[]; } add(t){ return new Promise((res,rej)=>{ this.q.push({t,res,rej}); this.proc(); }); } async proc(){ if(this.r>=this.c||!this.q.length) return; this.r++; const {t,res,rej}=this.q.shift(); try{ res(await t()); }catch(e){ rej(e); }finally{ this.r--; this.proc(); } } }
|
| 305 |
const uploadQueue = new UploadQueue(MAX_CONCURRENT_UPLOADS);
|
| 306 |
|
|
|
|
| 307 |
async function imgbbUpload(buf){ const f=new FormData(); f.append('image',buf.toString('base64')); const {data}=await axios.post(`https://api.imgbb.com/1/upload?key=${IMGBB_API_KEY}`,f,{headers:f.getHeaders(),timeout:120000,maxContentLength:Infinity,maxBodyLength:Infinity}); if(!data.success) throw new Error(data.error?.message||'ImgBB fail'); return {url:data.data.url,deleteUrl:data.data.delete_url,id:data.data.id}; }
|
| 308 |
async function imgbbDelete(u){ try{await axios.get(u,{timeout:10000});}catch(_){} }
|
| 309 |
|
|
|
|
| 310 |
const SERVER_MASTER_KEY = createHash('sha256').update(MASTER_SECRET + '|PixelDriveMaster').digest();
|
| 311 |
function encryptMasterKey(k){ const iv=randomBytes(12); const c=createCipheriv('aes-256-gcm',SERVER_MASTER_KEY,iv); return Buffer.concat([iv,c.update(k),c.final(),c.getAuthTag()]); }
|
| 312 |
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()]); }
|
| 313 |
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); }
|
| 314 |
|
|
|
|
| 315 |
function canAccessFolder(userId, folderId){
|
| 316 |
let cur = db.prepare('SELECT * FROM folders WHERE id=?').get(folderId);
|
| 317 |
let depth = 0;
|
|
|
|
| 332 |
return null;
|
| 333 |
}
|
| 334 |
|
|
|
|
| 335 |
async function genThumb(fid,mime,key){
|
| 336 |
if(!mime.startsWith('image/')&&!mime.startsWith('video/')) return;
|
| 337 |
try{
|
|
|
|
| 347 |
}catch(e){ log('WARN', `Thumb ${fid}: ${e.message}`); }
|
| 348 |
}
|
| 349 |
|
|
|
|
| 350 |
async function streamFile(res,file,key,range,counterUserId){
|
| 351 |
const chunks = db.prepare('SELECT chunk_index,imgbb_url,size FROM file_chunks WHERE file_id=? ORDER BY chunk_index').all(file.id);
|
| 352 |
const total = file.size;
|
|
|
|
| 356 |
const sentBytes = e - s + 1;
|
| 357 |
res.set({'Content-Type':file.mime,'Content-Length':sentBytes,'Accept-Ranges':'bytes','Content-Disposition':`inline; filename="${encodeURIComponent(file.name)}"`});
|
| 358 |
if(range){ res.status(206); res.set('Content-Range', `bytes ${s}-${e}/${total}`); }
|
| 359 |
+
log('INFO', `Stream ${file.name} (${(sentBytes/1048576).toFixed(2)} MB)`);
|
| 360 |
let cur = 0;
|
| 361 |
for(const c of chunks){
|
| 362 |
const cs = cur, ce = cur + c.size - 1;
|
|
|
|
| 369 |
const t0 = Date.now();
|
| 370 |
const {data} = await axios.get(c.imgbb_url,{responseType:'arraybuffer',timeout:120000});
|
| 371 |
const t1 = Date.now();
|
|
|
|
| 372 |
const enc = await pngToBuffer(Buffer.from(data), c.size + OVERHEAD);
|
| 373 |
dec = decryptChunk(enc, key);
|
| 374 |
+
log('INFO', ` Chunk ${c.chunk_index}: ${(data.byteLength/1048576).toFixed(2)} MB en ${Date.now()-t0}ms`);
|
| 375 |
fileCache.set(ck, {data: dec, size: dec.length});
|
| 376 |
}
|
| 377 |
const sl = dec.subarray(off, off + len);
|
|
|
|
| 382 |
if(cs > e) break;
|
| 383 |
}
|
| 384 |
res.end();
|
| 385 |
+
if (counterUserId) addTransfer(counterUserId, sentBytes, 'down');
|
| 386 |
}
|
| 387 |
|
|
|
|
| 388 |
const sseClients = new Map();
|
| 389 |
function broadcast(fid,d){ const cs = sseClients.get(fid); if(cs){ const p = `data: ${JSON.stringify(d)}\n\n`; for(const r of cs) r.write(p); } }
|
| 390 |
|
|
|
|
| 397 |
if(typeof u !== 'string' || !/^[a-zA-Z0-9_.\-]{3,20}$/.test(u)) return res.status(400).json({error:'Nom d\'utilisateur invalide (3-20 caractères alphanumériques)'});
|
| 398 |
if(typeof p !== 'string' || p.length < 6) return res.status(400).json({error:'Mot de passe trop court (6 caractères min)'});
|
| 399 |
if(db.prepare('SELECT 1 FROM users WHERE username=?').get(u)) return res.status(409).json({error:'Existe'});
|
| 400 |
+
const s = randomBytes(16); const h = await deriveKey(p, s);
|
|
|
|
| 401 |
db.prepare('INSERT INTO users(username,password_hash,salt,tos_accepted) VALUES(?,?,?,1)').run(u,h,s);
|
| 402 |
log('INFO', `Nouvel utilisateur: ${u}`);
|
| 403 |
res.json({ok:true});
|
|
|
|
| 409 |
if(!usr) return res.status(401).json({error:'Invalide'});
|
| 410 |
const h = await deriveKey(p, usr.salt);
|
| 411 |
if(!h.equals(usr.password_hash)) return res.status(401).json({error:'Invalide'});
|
| 412 |
+
try { await new Promise((ok, bad) => req.session.regenerate(e => e ? bad(e) : ok())); }
|
| 413 |
+
catch(e) { log('ERROR', `Session regenerate échouée: ${e.message}`); return res.status(500).json({error:'Erreur session'}); }
|
| 414 |
+
req.session.userId = usr.id; req.session.isAdmin = !!usr.is_admin; req.session.tosAccepted = !!usr.tos_accepted; req.session.username = usr.username;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 415 |
log('INFO', `Connexion: ${u}`);
|
| 416 |
res.json({ok:true,user:{id:usr.id,username:usr.username,isAdmin:!!usr.is_admin,tosAccepted:!!usr.tos_accepted}});
|
| 417 |
});
|
| 418 |
app.post('/api/logout',(req,res)=>req.session.destroy(()=>res.json({ok:true})));
|
| 419 |
|
|
|
|
| 420 |
app.get('/api/me',(req,res)=>{
|
| 421 |
if(!req.session.userId) return res.status(401).json({error:'Non connecté'});
|
| 422 |
const used = db.prepare("SELECT COALESCE(SUM(size),0) as t FROM files WHERE owner_id=? AND status!='error'").get(req.session.userId).t;
|
| 423 |
const tr = getTransfer(req.session.userId);
|
| 424 |
+
res.json({ id:req.session.userId, username:req.session.username, isAdmin:req.session.isAdmin, tosAccepted:req.session.tosAccepted,
|
| 425 |
+
quotaUsed:used, quotaMax:USER_QUOTA, transferUp:tr.up, transferDown:tr.down, transferUsed:tr.total, transferMax:DAILY_TRANSFER });
|
|
|
|
|
|
|
|
|
|
| 426 |
});
|
| 427 |
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});});
|
| 428 |
|
|
|
|
| 429 |
app.post('/api/avatar', requireAuth, avatarLimit, async(req,res)=>{
|
| 430 |
const { data } = req.body;
|
| 431 |
if (!data || !data.startsWith('data:image/')) return res.status(400).json({error:'Image invalide'});
|
|
|
|
| 446 |
res.send(u.avatar);
|
| 447 |
});
|
| 448 |
|
|
|
|
| 449 |
app.get('/api/folders',requireAuth,(req,res)=>{
|
| 450 |
const p = req.query.parent_id ? parseInt(req.query.parent_id) : null;
|
| 451 |
if (p !== null) {
|
|
|
|
| 457 |
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'});}});
|
| 458 |
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});});
|
| 459 |
|
| 460 |
+
// ✅ RENAMER DOSSIER
|
| 461 |
+
app.post('/api/folders/:id/rename',requireAuth,(req,res)=>{
|
| 462 |
+
const { name } = req.body;
|
| 463 |
+
if(!name || typeof name !== 'string' || !name.trim() || name.length > 100) return res.status(400).json({error:'Nom invalide'});
|
| 464 |
+
const fo = db.prepare('SELECT id FROM folders WHERE id=? AND owner_id=?').get(req.params.id, req.session.userId);
|
| 465 |
+
if(!fo) return res.status(404).json({error:'Introuvable'});
|
| 466 |
+
try { db.prepare('UPDATE folders SET name=? WHERE id=?').run(name.trim(), req.params.id); res.json({ok:true}); }
|
| 467 |
+
catch(e){ res.status(409).json({error:'Un dossier porte déjà ce nom'}); }
|
| 468 |
+
});
|
| 469 |
+
|
| 470 |
+
// ✅ DÉPLACER DOSSIER (anti-cycle)
|
| 471 |
+
app.post('/api/folders/:id/move',requireAuth,(req,res)=>{
|
| 472 |
+
const { parent_id } = req.body;
|
| 473 |
+
const fo = db.prepare('SELECT * FROM folders WHERE id=? AND owner_id=?').get(req.params.id, req.session.userId);
|
| 474 |
+
if(!fo) return res.status(404).json({error:'Introuvable'});
|
| 475 |
+
if (parent_id) {
|
| 476 |
+
const target = db.prepare('SELECT * FROM folders WHERE id=?').get(parent_id);
|
| 477 |
+
if (!target || target.owner_id !== req.session.userId) return res.status(403).json({error:'Dossier cible invalide'});
|
| 478 |
+
let cur = target, depth = 0;
|
| 479 |
+
while (cur && depth < 50) {
|
| 480 |
+
if (cur.id === fo.id) return res.status(400).json({error:'Impossible de déplacer un dossier dans lui-même'});
|
| 481 |
+
cur = cur.parent_id ? db.prepare('SELECT * FROM folders WHERE id=?').get(cur.parent_id) : null;
|
| 482 |
+
depth++;
|
| 483 |
+
}
|
| 484 |
+
}
|
| 485 |
+
db.prepare('UPDATE folders SET parent_id=? WHERE id=?').run(parent_id || null, req.params.id);
|
| 486 |
+
log('INFO', `Dossier ${req.params.id} déplacé → ${parent_id || 'racine'}`);
|
| 487 |
+
res.json({ok:true});
|
| 488 |
+
});
|
| 489 |
+
|
| 490 |
app.get('/api/files',requireAuth,(req,res)=>{
|
| 491 |
const f = req.query.folder_id ? parseInt(req.query.folder_id) : null;
|
| 492 |
if (f !== null) {
|
|
|
|
| 496 |
res.json(db.prepare('SELECT * FROM files WHERE owner_id=? AND folder_id IS NULL ORDER BY name COLLATE NOCASE').all(req.session.userId));
|
| 497 |
});
|
| 498 |
|
| 499 |
+
// ✅ RENAMER FICHIER
|
| 500 |
+
app.post('/api/files/:id/rename',requireAuth,(req,res)=>{
|
| 501 |
+
if(!UUID_RE.test(req.params.id)) return res.status(400).json({error:'ID invalide'});
|
| 502 |
+
const { name } = req.body;
|
| 503 |
+
if(!name || typeof name !== 'string' || !name.trim() || name.length > 200) return res.status(400).json({error:'Nom invalide'});
|
| 504 |
+
const f = db.prepare('SELECT id FROM files WHERE id=? AND owner_id=?').get(req.params.id, req.session.userId);
|
| 505 |
+
if(!f) return res.status(404).json({error:'Introuvable'});
|
| 506 |
+
db.prepare('UPDATE files SET name=? WHERE id=?').run(name.trim(), req.params.id);
|
| 507 |
+
res.json({ok:true});
|
| 508 |
+
});
|
| 509 |
+
|
| 510 |
+
// ✅ DÉPLACER FICHIER
|
| 511 |
+
app.post('/api/files/:id/move',requireAuth,(req,res)=>{
|
| 512 |
+
if(!UUID_RE.test(req.params.id)) return res.status(400).json({error:'ID invalide'});
|
| 513 |
+
const { folder_id } = req.body;
|
| 514 |
+
const f = db.prepare('SELECT id FROM files WHERE id=? AND owner_id=?').get(req.params.id, req.session.userId);
|
| 515 |
+
if(!f) return res.status(404).json({error:'Introuvable'});
|
| 516 |
+
if (folder_id) {
|
| 517 |
+
const target = db.prepare('SELECT owner_id FROM folders WHERE id=?').get(folder_id);
|
| 518 |
+
if (!target || target.owner_id !== req.session.userId) return res.status(403).json({error:'Dossier cible invalide'});
|
| 519 |
+
}
|
| 520 |
+
db.prepare('UPDATE files SET folder_id=? WHERE id=?').run(folder_id || null, req.params.id);
|
| 521 |
+
log('INFO', `Fichier ${req.params.id} déplacé → ${folder_id || 'racine'}`);
|
| 522 |
+
res.json({ok:true});
|
| 523 |
+
});
|
| 524 |
+
|
| 525 |
app.post('/api/share-user',requireAuth,requireTos,shareLimit,(req,res)=>{
|
| 526 |
const { type, id, username } = req.body;
|
| 527 |
if (!type || !id || !username) return res.status(400).json({error:'Champs manquants'});
|
|
|
|
| 556 |
res.json({ok:true});
|
| 557 |
});
|
| 558 |
|
|
|
|
| 559 |
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)); });
|
| 560 |
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}); });
|
| 561 |
|
|
|
|
| 562 |
const upload = multer({dest:TEMP_DIR,limits:{fileSize:5*1024*1024*1024}});
|
| 563 |
app.post('/api/files/upload',requireAuth,requireTos,uploadLimit,upload.single('file'),async(req,res)=>{
|
| 564 |
if(!req.file)return res.status(400).json({error:'Fichier manquant'});
|
| 565 |
const uid = req.session.userId;
|
| 566 |
const fsize = req.file.size;
|
|
|
|
|
|
|
| 567 |
const tr = getTransfer(uid);
|
| 568 |
const remaining = DAILY_TRANSFER - tr.total;
|
| 569 |
if (remaining <= 0) {
|
|
|
|
| 575 |
try { unlinkSync(req.file.path); } catch (_) {}
|
| 576 |
return res.status(413).json({error:`Quota journalier insuffisant (reste ${(remaining/1073741824).toFixed(1)} GB)`});
|
| 577 |
}
|
|
|
|
|
|
|
| 578 |
const used = db.prepare("SELECT COALESCE(SUM(size),0) as t FROM files WHERE owner_id=? AND status!='error'").get(uid).t;
|
| 579 |
if (used + fsize > USER_QUOTA) {
|
| 580 |
try { unlinkSync(req.file.path); } catch (_) {}
|
| 581 |
return res.status(413).json({error:`Quota de stockage dépassé (${(USER_QUOTA/1073741824).toFixed(0)} GB max)`});
|
| 582 |
}
|
|
|
|
| 583 |
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;
|
| 584 |
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);
|
| 585 |
const stmt=db.prepare('INSERT INTO file_chunks(file_id,chunk_index,size,encrypted_len) VALUES(?,?,?,?)');
|
|
|
|
| 602 |
const raw=await readChunk(path,off,ci.size);
|
| 603 |
if (i === 0 && mime.startsWith('image/')) {
|
| 604 |
const scan = await checkNsfw(raw);
|
| 605 |
+
if (!scan.safe) { log('ALERT', `NSFW bloqué: ${fid} par ${username}`); throw new Error('Contenu bloqué par le filtre de modération'); }
|
|
|
|
|
|
|
|
|
|
| 606 |
}
|
| 607 |
const enc=encryptChunk(raw,mk);
|
| 608 |
if(enc.length>ci.encrypted_len)throw new Error(`Overflow chunk ${i}`);
|
|
|
|
| 620 |
await genThumb(fid,mime,mk);
|
| 621 |
db.prepare("UPDATE files SET status='ready',progress=100 WHERE id=?").run(fid);
|
| 622 |
broadcast(fid,{type:'progress',progress:100,status:'ready',type:'done'});
|
| 623 |
+
addTransfer(uid, fsize, 'up');
|
| 624 |
const secs = (Date.now()-t0)/1000;
|
| 625 |
+
log('INFO', `Upload terminé: ${fid} en ${secs.toFixed(1)}s (${(fsize/1048576/secs).toFixed(2)} MB/s)`);
|
|
|
|
| 626 |
}catch(e){
|
| 627 |
log('ERROR', `Upload échoué ${fid}: ${e.message}`);
|
| 628 |
db.prepare("UPDATE files SET status='error',error_msg=? WHERE id=?").run(e.message,fid);
|
|
|
|
| 634 |
}
|
| 635 |
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);});}
|
| 636 |
|
|
|
|
| 637 |
app.get('/api/files/:id/download',requireAuth,async(req,res)=>{
|
| 638 |
if(!UUID_RE.test(req.params.id)) return res.status(400).json({error:'ID invalide'});
|
| 639 |
+
if (transferBlocked(req.session.userId)) { log('ALERT', `Quota transit atteint (download bloqué): ${req.session.username}`); return res.status(429).json({error:'Quota de transfert journalier atteint, réessayez demain'}); }
|
|
|
|
|
|
|
|
|
|
| 640 |
const f = canAccessFile(req.session.userId, req.params.id);
|
| 641 |
if(!f)return res.status(404).json({error:'Introuvable'});
|
| 642 |
if(f.status!=='ready')return res.status(409).json({error:'Pas prêt',status:f.status});
|
|
|
|
| 653 |
res.json({preview:`/s/${pt}`,download:`/d/${dt2}`,expires});
|
| 654 |
});
|
| 655 |
|
|
|
|
| 656 |
app.get('/s/:token',async(req,res)=>{
|
| 657 |
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());
|
| 658 |
if(!l)return res.status(404).send(html('Lien invalide/expiré'));
|
|
|
|
| 671 |
{ title: name, desc: `${sizeMb} MB · PixelDrive — partage sécurisé`, image: ogImage }));
|
| 672 |
});
|
| 673 |
|
|
|
|
| 674 |
app.get('/d/:token',async(req,res)=>{
|
| 675 |
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());
|
| 676 |
if(!l)return res.status(404).json({error:'Invalide'});
|
| 677 |
const f=db.prepare('SELECT * FROM files WHERE id=?').get(l.file_id);
|
| 678 |
if(!f||f.status!=='ready')return res.status(404).json({error:'Indisponible'});
|
| 679 |
+
if (transferBlocked(f.owner_id)) { log('ALERT', `Quota transit propriétaire atteint: ${f.id}`); return res.status(429).json({error:'Quota journalier du propriétaire atteint'}); }
|
|
|
|
|
|
|
|
|
|
| 680 |
log('INFO', `Téléchargement public /d/${req.params.token} (${f.name})`);
|
| 681 |
+
const mk = decryptMasterKey(db.prepare('SELECT master_key_enc FROM files WHERE id=?').get(f.id).master_key_enc);
|
| 682 |
+
await streamFile(res,f,mk,req.headers.range,f.owner_id);
|
| 683 |
});
|
| 684 |
|
| 685 |
app.delete('/api/files/:id',requireAuth,async(req,res)=>{
|
|
|
|
| 719 |
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());});
|
| 720 |
app.get('/health',(_,res)=>res.json({ok:true,cache:fileCache.size,mem:process.memoryUsage()}));
|
| 721 |
|
|
|
|
| 722 |
app.use(express.static(PUBLIC_DIR, { dotfiles: 'deny', index: false }));
|
| 723 |
app.get('/manifest.json', (req,res) => res.sendFile(join(__dirname, 'manifest.json')));
|
| 724 |
app.get('*', (req, res) => {
|
|
|
|
| 726 |
res.sendFile(join(PUBLIC_DIR, 'index.html'));
|
| 727 |
});
|
| 728 |
|
|
|
|
| 729 |
app.use((err, req, res, next) => {
|
| 730 |
if (err.type === 'entity.parse.failed' || err instanceof SyntaxError) return res.status(400).json({ error: 'Bad Request' });
|
| 731 |
log('ERROR', `Server: ${err.message}`);
|
| 732 |
res.status(500).json({ error: 'Internal Server Error' });
|
| 733 |
});
|
| 734 |
|
| 735 |
+
const server = app.listen(PORT, '0.0.0.0', () => log('INFO', `PixelDrive v4.6 démarré | stockage ${(USER_QUOTA/1073741824).toFixed(0)} GB | transit ${(DAILY_TRANSFER/1073741824).toFixed(0)} GB/j`));
|
| 736 |
process.on('SIGTERM', () => { log('WARN', 'SIGTERM reçu, arrêt...'); server.close(() => process.exit(0)); });
|
| 737 |
|
| 738 |
function html(b, og) {
|