Pixel-Drive / server.js
NathMen12's picture
Update server.js
3193270 verified
Raw
History Blame Contribute Delete
76.5 kB
// ==========================================
// PIXELDRIVE v6.1 - SERVER.JS (ESM)
// - NSFW via Hugging Face (@gradio/client) avec logs
// - FIX SSE : envoi {type:'done'} pour fermer la barre
// - Routes propres (/, /panel, /tos, /status)
// ==========================================
import express from 'express';
import session from 'express-session';
import Database from 'better-sqlite3';
import multer from 'multer';
import os from 'os';
import { createHash, randomBytes, scrypt, createCipheriv, createDecipheriv, timingSafeEqual } from 'crypto';
import { promisify } from 'util';
import { createReadStream, unlinkSync, existsSync, readdirSync, statSync, rmSync, copyFileSync, mkdirSync, readFileSync, writeFileSync, appendFileSync } from 'fs';
import { join } from 'path';
import { v4 as uuidv4 } from 'uuid';
import LRUCache from 'quick-lru';
import sharp from 'sharp';
// ✅ SUPPRIMÉ : TensorFlow.js et nsfwjs (bug tf.node.decodeImage)
import axios from 'axios';
import FormData from 'form-data';
import { fileURLToPath } from 'url';
// ✅ AJOUTÉ : Client Gradio officiel Hugging Face
import { Client } from "@gradio/client";
const __dirname = fileURLToPath(new URL('.', import.meta.url));
const PUBLIC_DIR = join(__dirname, 'public');
const PORT = 7860;
const DATA_DIR = '/data';
const TEMP_DIR = join(DATA_DIR, 'temp');
const THUMB_DIR = join(DATA_DIR, 'thumbs');
const BACKUP_DIR = join(DATA_DIR, 'backup');
const DB_PATH = join(DATA_DIR, 'pixeldrive.db');
const CHUNK_SIZE = 30 * 1024 * 1024;
const UP_CHUNK = 5 * 1024 * 1024;
const MAX_CONCURRENT_UPLOADS = 2;
const CACHE_MAX_BYTES = 12 * 1024 * 1024 * 1024;
const KEY_LEN = 32; const IV_LEN = 12; const TAG_LEN = 16; const OVERHEAD = IV_LEN + TAG_LEN;
const IS_PROD = process.env.NODE_ENV === 'production';
const USER_QUOTA = parseInt(process.env.USER_QUOTA_GB || '50', 10) * 1024 * 1024 * 1024;
const DAILY_TRANSFER = parseInt(process.env.DAILY_TRANSFER_GB || '100', 10) * 1024 * 1024 * 1024;
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
const TTL_ALLOWED = [1, 24, 168, 720];
const EDITABLE_RE = /\.(txt|md|markdown|json|js|mjs|css|html|htm|xml|yml|yaml|py|sh|c|cpp|h|java|ts|tsx|jsx|sql|log|ini|conf|csv)$/i;
function log(level, msg) {
const n = new Date();
const ts = [n.getHours(), n.getMinutes(), n.getSeconds()].map(x => String(x).padStart(2, '0')).join(':');
const line = `[${ts}] [${level}] ${msg}`;
if (level === 'ERROR' || level === 'FATAL ERROR') console.error(line);
else if (level === 'WARN' || level === 'ALERT') console.warn(line);
else console.log(line);
}
const IMGBB_API_KEY = process.env.IMGBB_API_KEY;
const SESSION_SECRET = process.env.SESSION_SECRET;
const ADMIN_USER = process.env.ADMIN_USER || 'admin';
if (!IMGBB_API_KEY || !SESSION_SECRET) { log('FATAL ERROR', 'Variables manquantes: IMGBB_API_KEY, SESSION_SECRET'); process.exit(1); }
const MASTER_SECRET = process.env.MASTER_SECRET || SESSION_SECRET;
if (process.env.MASTER_SECRET) log('INFO', 'MASTER_SECRET personnalisé actif');
else log('WARN', 'MASTER_SECRET non défini → clé maître dérivée de SESSION_SECRET');
const db = new Database(DB_PATH);
db.pragma('journal_mode = WAL');
db.pragma('busy_timeout = 5000');
const scryptAsync = promisify(scrypt);
async function deriveKey(password, salt) { return scryptAsync(password, salt, KEY_LEN); }
function encryptChunk(dataBuffer, key) { const iv = randomBytes(IV_LEN); const cipher = createCipheriv('aes-256-gcm', key, iv); const encrypted = Buffer.concat([cipher.update(dataBuffer), cipher.final()]); const tag = cipher.getAuthTag(); return Buffer.concat([iv, encrypted, tag]); }
function decryptChunk(encBuffer, key) { if (encBuffer.length < OVERHEAD) throw new Error('Buffer trop court'); const iv = encBuffer.subarray(0, IV_LEN); const tag = encBuffer.subarray(encBuffer.length - TAG_LEN); const data = encBuffer.subarray(IV_LEN, encBuffer.length - TAG_LEN); const decipher = createDecipheriv('aes-256-gcm', key, iv); decipher.setAuthTag(tag); return Buffer.concat([decipher.update(data), decipher.final()]); }
function esc(s) { return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;').replace(/'/g,'&#39;'); }
function bufferToPng(buffer) {
const byteLen = buffer.length;
const pixelsNeeded = Math.ceil(byteLen / 3);
const width = Math.ceil(Math.sqrt(pixelsNeeded));
const height = Math.ceil(pixelsNeeded / width);
const rgbBuffer = Buffer.alloc(width * height * 3);
buffer.copy(rgbBuffer);
return sharp(rgbBuffer, { raw: { width, height, channels: 3 } }).png({ compressionLevel: 9, palette: false }).withMetadata(false).toBuffer();
}
async function pngToBuffer(pngBuffer, expectedLen) {
const { data, info } = await sharp(pngBuffer).raw().toBuffer({ resolveWithObject: true });
const channels = info.channels;
const rgb = Buffer.alloc(info.width * info.height * 3);
if (channels === 3) data.copy(rgb, 0, 0, Math.min(data.length, rgb.length));
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]; } }
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]; } }
return rgb.subarray(0, expectedLen);
}
db.exec(`
CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT UNIQUE NOT NULL, password_hash BLOB NOT NULL, salt BLOB NOT NULL, is_admin INTEGER DEFAULT 0, tos_accepted INTEGER DEFAULT 0, avatar BLOB, created_at INTEGER DEFAULT (strftime('%s','now')));
CREATE TABLE IF NOT EXISTS folders (id INTEGER PRIMARY KEY AUTOINCREMENT, parent_id INTEGER REFERENCES folders(id) ON DELETE CASCADE, owner_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, name TEXT NOT NULL, created_at INTEGER DEFAULT (strftime('%s','now')), UNIQUE(parent_id, owner_id, name));
CREATE TABLE IF NOT EXISTS files (id TEXT PRIMARY KEY, folder_id INTEGER REFERENCES folders(id) ON DELETE SET NULL, owner_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, name TEXT NOT NULL, mime TEXT NOT NULL, size INTEGER NOT NULL, chunk_count INTEGER DEFAULT 1, status TEXT DEFAULT 'pending', progress REAL DEFAULT 0, error_msg TEXT, encryption_key_hash TEXT NOT NULL, master_key_enc BLOB, created_at INTEGER DEFAULT (strftime('%s','now')), updated_at INTEGER DEFAULT (strftime('%s','now')));
CREATE TABLE IF NOT EXISTS file_chunks (id INTEGER PRIMARY KEY AUTOINCREMENT, file_id TEXT NOT NULL REFERENCES files(id) ON DELETE CASCADE, chunk_index INTEGER NOT NULL, imgbb_url TEXT, imgbb_delete_url TEXT, imgbb_id TEXT, size INTEGER NOT NULL, encrypted_len INTEGER NOT NULL, UNIQUE(file_id, chunk_index));
CREATE TABLE IF NOT EXISTS share_links (token TEXT PRIMARY KEY, file_id TEXT NOT NULL REFERENCES files(id) ON DELETE CASCADE, owner_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, type TEXT NOT NULL, created_at INTEGER DEFAULT (strftime('%s','now')), expires_at INTEGER, views INTEGER DEFAULT 0, downloads INTEGER DEFAULT 0);
CREATE TABLE IF NOT EXISTS user_shares (id INTEGER PRIMARY KEY AUTOINCREMENT, file_id TEXT REFERENCES files(id) ON DELETE CASCADE, folder_id INTEGER REFERENCES folders(id) ON DELETE CASCADE, owner_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, shared_with INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, permission TEXT DEFAULT 'read', created_at INTEGER DEFAULT (strftime('%s','now')));
CREATE TABLE IF NOT EXISTS transfer_daily (id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, day TEXT NOT NULL, up_bytes INTEGER DEFAULT 0, down_bytes INTEGER DEFAULT 0, UNIQUE(user_id, day));
CREATE TABLE IF NOT EXISTS activity (id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER, action TEXT NOT NULL, detail TEXT, created_at INTEGER DEFAULT (strftime('%s','now')));
CREATE TABLE IF NOT EXISTS notifications (id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, type TEXT NOT NULL, detail TEXT, read INTEGER DEFAULT 0, created_at INTEGER DEFAULT (strftime('%s','now')));
CREATE INDEX IF NOT EXISTS idx_files_owner ON files(owner_id);
CREATE INDEX IF NOT EXISTS idx_chunks_file ON file_chunks(file_id);
CREATE INDEX IF NOT EXISTS idx_share_owner ON share_links(owner_id);
CREATE INDEX IF NOT EXISTS idx_ushares_with ON user_shares(shared_with);
CREATE INDEX IF NOT EXISTS idx_transfer_day ON transfer_daily(user_id, day);
CREATE INDEX IF NOT EXISTS idx_activity_user ON activity(user_id, created_at);
CREATE INDEX IF NOT EXISTS idx_notif_user ON notifications(user_id, read, created_at);
`);
function runMigrations() {
log('INFO', 'Vérification migrations BDD...');
const usersCols = db.prepare("PRAGMA table_info(users)").all().map(c => c.name);
if (!usersCols.includes('tos_accepted')) db.exec(`ALTER TABLE users ADD COLUMN tos_accepted INTEGER DEFAULT 0;`);
if (!usersCols.includes('avatar')) db.exec(`ALTER TABLE users ADD COLUMN avatar BLOB;`);
const usCols = db.prepare("PRAGMA table_info(user_shares)").all().map(c => c.name);
if (!usCols.includes('permission')) db.exec(`ALTER TABLE user_shares ADD COLUMN permission TEXT DEFAULT 'read';`);
const filesCols = db.prepare("PRAGMA table_info(files)").all().map(c => c.name);
const filesDefs = [
{ name: 'folder_id', def: 'INTEGER REFERENCES folders(id) ON DELETE SET NULL' },
{ name: 'master_key_enc', def: 'BLOB' },
{ name: 'encryption_key_hash', def: 'TEXT' },
{ name: 'status', def: "TEXT DEFAULT 'pending'" },
{ name: 'progress', def: 'REAL DEFAULT 0' },
{ name: 'error_msg', def: 'TEXT' },
{ name: 'chunk_count', def: 'INTEGER DEFAULT 1' },
{ name: 'updated_at', def: 'INTEGER DEFAULT 0' },
];
let updatedAdded = false;
for (const col of filesDefs) {
if (!filesCols.includes(col.name)) { log('INFO', `Migration: ALTER TABLE files ADD COLUMN ${col.name}`); db.exec(`ALTER TABLE files ADD COLUMN ${col.name} ${col.def};`); if (col.name === 'updated_at') updatedAdded = true; }
}
if (updatedAdded) db.exec(`UPDATE files SET updated_at = strftime('%s','now') WHERE updated_at = 0;`);
const slCols = db.prepare("PRAGMA table_info(share_links)").all().map(c => c.name);
if (!slCols.includes('views')) db.exec(`ALTER TABLE share_links ADD COLUMN views INTEGER DEFAULT 0;`);
if (!slCols.includes('downloads')) db.exec(`ALTER TABLE share_links ADD COLUMN downloads INTEGER DEFAULT 0;`);
const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table'").all().map(t => t.name);
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));`);
if (!tables.includes('share_links')) db.exec(`CREATE TABLE share_links (token TEXT PRIMARY KEY, file_id TEXT NOT NULL REFERENCES files(id) ON DELETE CASCADE, owner_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, type TEXT NOT NULL, created_at INTEGER DEFAULT (strftime('%s','now')), expires_at INTEGER, views INTEGER DEFAULT 0, downloads INTEGER DEFAULT 0);`);
if (!tables.includes('user_shares')) db.exec(`CREATE TABLE user_shares (id INTEGER PRIMARY KEY AUTOINCREMENT, file_id TEXT REFERENCES files(id) ON DELETE CASCADE, folder_id INTEGER REFERENCES folders(id) ON DELETE CASCADE, owner_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, shared_with INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, permission TEXT DEFAULT 'read', created_at INTEGER DEFAULT (strftime('%s','now'))); CREATE INDEX idx_ushares_with ON user_shares(shared_with);`);
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);`);
if (!tables.includes('activity')) db.exec(`CREATE TABLE activity (id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER, action TEXT NOT NULL, detail TEXT, created_at INTEGER DEFAULT (strftime('%s','now'))); CREATE INDEX idx_activity_user ON activity(user_id, created_at);`);
if (!tables.includes('notifications')) db.exec(`CREATE TABLE notifications (id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, type TEXT NOT NULL, detail TEXT, read INTEGER DEFAULT 0, created_at INTEGER DEFAULT (strftime('%s','now'))); CREATE INDEX idx_notif_user ON notifications(user_id, read, created_at);`);
const sessionsCols = tables.includes('sessions') ? db.prepare("PRAGMA table_info(sessions)").all().map(c => c.name) : [];
if (tables.includes('sessions') && (!sessionsCols.includes('data') || !sessionsCols.includes('expires_at'))) { log('WARN', 'Table sessions invalide → reconstruction'); 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);`); }
else if (!tables.includes('sessions')) db.exec(`CREATE TABLE sessions (sid TEXT PRIMARY KEY, data TEXT NOT NULL, expires_at INTEGER NOT NULL); CREATE INDEX idx_sessions_expires ON sessions(expires_at);`);
log('INFO', 'Migrations terminées.');
}
runMigrations();
db.exec(`CREATE INDEX IF NOT EXISTS idx_files_folder ON files(folder_id); DROP TRIGGER IF EXISTS update_file_ts; CREATE TRIGGER update_file_ts AFTER UPDATE ON files BEGIN UPDATE files SET updated_at = strftime('%s','now') WHERE id = NEW.id; END;`);
function todayKey() { return new Date().toISOString().slice(0, 10); }
function getTransfer(userId) { const r = db.prepare('SELECT up_bytes, down_bytes FROM transfer_daily WHERE user_id=? AND day=?').get(userId, todayKey()); return { up: r?.up_bytes || 0, down: r?.down_bytes || 0, total: (r?.up_bytes || 0) + (r?.down_bytes || 0) }; }
function addTransfer(userId, bytes, dir) { if (!userId || !bytes || bytes <= 0) return; const col = dir === 'up' ? 'up_bytes' : 'down_bytes'; try { db.prepare(`INSERT INTO transfer_daily(user_id, day, up_bytes, down_bytes) VALUES(?,?,?,?) ON CONFLICT(user_id, day) DO UPDATE SET ${col} = ${col} + excluded.${col}`).run(userId, todayKey(), dir === 'up' ? bytes : 0, dir === 'down' ? bytes : 0); } catch (e) { log('WARN', `addTransfer: ${e.message}`); } }
function transferBlocked(userId) { return getTransfer(userId).total >= DAILY_TRANSFER; }
function logActivity(userId, action, detail) { try { db.prepare('INSERT INTO activity(user_id, action, detail) VALUES(?,?,?)').run(userId, action, String(detail || '').slice(0, 200)); } catch (e) {} }
function notify(userId, type, detail) { try { db.prepare('INSERT INTO notifications(user_id, type, detail) VALUES(?,?,?)').run(userId, type, String(detail || '').slice(0, 200)); } catch (e) {} }
function cleanTemp() {
try {
const now = Date.now();
for (const f of readdirSync(TEMP_DIR)) {
const p = join(TEMP_DIR, f);
const st = statSync(p);
if (now - st.mtimeMs > 24 * 3600 * 1000) {
rmSync(p, { recursive: true, force: true });
if (f.startsWith('up_') || f.startsWith('assemble_')) {
const fid = f.startsWith('up_') ? f.slice(3) : f.slice(9);
db.prepare("UPDATE files SET status='error', error_msg='Expiré' WHERE id=? AND status IN ('uploading','processing')").run(fid);
}
log('INFO', `Temp nettoyé: ${f}`);
}
}
} catch (e) { log('WARN', `Temp cleanup: ${e.message}`); }
}
function backupDb() {
try {
mkdirSync(BACKUP_DIR, { recursive: true });
db.pragma('wal_checkpoint(TRUNCATE)');
const name = `pixeldrive-${Date.now()}.db`;
copyFileSync(DB_PATH, join(BACKUP_DIR, name));
const olds = readdirSync(BACKUP_DIR).filter(f => f.startsWith('pixeldrive-') && f.endsWith('.db')).sort();
while (olds.length > 3) rmSync(join(BACKUP_DIR, olds.shift()), { force: true });
log('INFO', `Backup BDD: ${name}`);
} catch (e) { log('ERROR', `Backup échoué: ${e.message}`); }
}
function cleanHourly() {
try {
const now = Date.now();
db.prepare('DELETE FROM sessions WHERE expires_at < ?').run(now);
db.prepare('DELETE FROM share_links WHERE expires_at IS NOT NULL AND expires_at < ?').run(now);
db.prepare('DELETE FROM activity WHERE created_at < ?').run(Math.floor(now / 1000) - 30 * 86400);
db.prepare('DELETE FROM activity WHERE id NOT IN (SELECT id FROM activity ORDER BY id DESC LIMIT 2000)').run();
db.prepare('DELETE FROM notifications WHERE created_at < ?').run(Math.floor(now / 1000) - 30 * 86400);
const soon = now + 24 * 3600000;
const expiring = db.prepare('SELECT token, file_id, owner_id FROM share_links WHERE type=? AND expires_at > ? AND expires_at < ?').all('embed', now, soon);
for (const lk of expiring) {
const dup = db.prepare('SELECT 1 FROM notifications WHERE user_id=? AND type=? AND detail=?').get(lk.owner_id, 'expiring', lk.token);
if (!dup) { const f = db.prepare('SELECT name FROM files WHERE id=?').get(lk.file_id); notify(lk.owner_id, 'expiring', (f?.name || '?') + ' (' + lk.token.slice(0, 8) + '…)'); }
}
const cutoff = Math.floor(now / 1000) - 7 * 86400;
const olds = db.prepare("SELECT id FROM files WHERE status='error' AND updated_at < ?").all(cutoff);
for (const f of olds) { db.prepare('DELETE FROM files WHERE id=?').run(f.id); try { unlinkSync(join(THUMB_DIR, `${f.id}.jpg`)); } catch (_) {} }
cleanTemp();
log('INFO', `Cleanup horaire: ${olds.length} fichier(s) erreur purgé(s)`);
} catch (e) { log('WARN', `Cleanup: ${e.message}`); }
}
mkdirSync(TEMP_DIR, { recursive: true });
mkdirSync(THUMB_DIR, { recursive: true });
cleanTemp();
setInterval(cleanHourly, 3600000);
setInterval(backupDb, 6 * 3600000);
setTimeout(backupDb, 60000);
function rateLimit({ windowMs = 60000, max = 100, message = 'Trop de requêtes' } = {}) {
const hits = new Map();
setInterval(() => { const now = Date.now(); for (const [k, v] of hits) if (now - v.start > windowMs) hits.delete(k); }, Math.max(windowMs, 60000));
return (req, res, next) => {
const key = (req.session && req.session.userId ? 'u' + req.session.userId : 'ip' + req.ip) + '|' + req.path;
const now = Date.now();
let h = hits.get(key);
if (!h || now - h.start > windowMs) { h = { start: now, count: 0 }; hits.set(key, h); }
h.count++;
if (h.count > max) { log('ALERT', `Rate limit dépassé: ${key} (${h.count}/${max})`); return res.status(429).json({ error: message }); }
next();
};
}
const globalLimit = rateLimit({ windowMs: 60000, max: 600 });
const loginLimit = rateLimit({ windowMs: 1560000, max: 10, message: 'Trop de tentatives' });
const registerLimit = rateLimit({ windowMs: 6060000, max: 5, message: 'Trop de comptes' });
const uploadLimit = rateLimit({ windowMs: 6060000, max: 60, message: 'Quota upload' });
const shareLimit = rateLimit({ windowMs: 6060000, max: 50, message: 'Trop de partages' });
const avatarLimit = rateLimit({ windowMs: 60*60000, max: 10, message: "Trop d'avatars" });
const publicLinkLimit = rateLimit({ windowMs: 60000, max: 30, message: 'Trop de requêtes sur ce lien' });
function csrfGuard(req, res, next) {
if (['GET', 'HEAD', 'OPTIONS'].includes(req.method)) return next();
const origin = req.headers.origin || req.headers.referer;
if (origin) {
let oHost;
try { oHost = new URL(origin).host; } catch (e) { return res.status(403).json({ error: 'Origine invalide' }); }
if (oHost !== req.get('host')) {
log('WARN', `CSRF bloqué: origin=${origin}`);
return res.status(403).json({ error: 'CSRF' });
}
}
next();
}
class SQLiteStore extends session.Store {
get(sid, cb) { try { const r = db.prepare('SELECT data, expires_at FROM sessions WHERE sid=?').get(sid); if(!r) return cb(null, null); if(r.expires_at < Date.now()){ db.prepare('DELETE FROM sessions WHERE sid=?').run(sid); return cb(null, null); } cb(null, JSON.parse(r.data)); } catch(e){ cb(e); } }
set(sid, sess, cb) { try { const exp = sess.cookie?.expires ? new Date(sess.cookie.expires).getTime() : Date.now() + 30*24*60*60*1000; db.prepare('INSERT OR REPLACE INTO sessions(sid,data,expires_at) VALUES(?,?,?)').run(sid, JSON.stringify(sess), exp); cb(null); } catch(e){ cb(e); } }
destroy(sid, cb) { try { db.prepare('DELETE FROM sessions WHERE sid=?').run(sid); cb(null); } catch(e){ cb(e); } }
clear(cb) { try { db.prepare('DELETE FROM sessions').run(); cb(null); } catch(e){ cb(e); } }
length(cb) { try { cb(null, db.prepare('SELECT COUNT(*) as c FROM sessions').get().c); } catch(e){ cb(e); } }
touch(sid, sess, cb) { this.set(sid, sess, cb); }
}
const sessionStore = new SQLiteStore();
const adminRow = db.prepare('SELECT * FROM users WHERE username=?').get(ADMIN_USER);
if (!adminRow) {
if (IS_PROD && !process.env.ADMIN_PASS) {
log('FATAL ERROR', 'ADMIN_PASS non défini en production. Arrêt du serveur.');
process.exit(1);
}
if (!process.env.ADMIN_PASS) {
log('ALERT', 'ADMIN_PASS non défini ! Utilisation du mot de passe par défaut "ChangeMeNow!". Changez-le immédiatement.');
}
const salt = randomBytes(16);
const hash = await deriveKey(process.env.ADMIN_PASS || 'ChangeMeNow!', salt);
db.prepare('INSERT INTO users(username,password_hash,salt,is_admin,tos_accepted) VALUES(?,?,?,1,1)').run(ADMIN_USER, hash, salt);
log('INFO', `Compte admin créé: ${ADMIN_USER}`);
}
const app = express();
app.set('trust proxy', 1);
app.use((req, res, next) => {
res.set({ 'X-Content-Type-Options': 'nosniff', 'X-Frame-Options': 'SAMEORIGIN', 'Referrer-Policy': 'strict-origin-when-cross-origin', 'Permissions-Policy': 'camera=(), microphone=(), geolocation=()', 'Cross-Origin-Opener-Policy': 'same-origin', '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'" });
if (req.secure || req.get('x-forwarded-proto') === 'https') res.set('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
next();
});
app.use(express.json({ limit: '50mb' }));
app.use(express.urlencoded({ extended: true, limit: '50mb' }));
app.use(session({ secret: SESSION_SECRET, store: sessionStore, resave: false, saveUninitialized: false, cookie: { secure: IS_PROD, httpOnly: true, maxAge: 30*24*60*60*1000, sameSite: 'lax' } }));
app.use('/api', csrfGuard);
app.use('/api', globalLimit);
const requireAuth = (req, res, next) => { if(!req.session.userId) return res.status(401).json({error:'Non authentifié'}); next(); };
const requireTos = (req, res, next) => { if(!req.session.tosAccepted) return res.status(403).json({error:'CGU non acceptées', needTos:true}); next(); };
const requireAdmin = (req, res, next) => { if(!req.session.isAdmin) return res.status(403).json({error:'Admin only'}); next(); };
class ByteLRUCache extends LRUCache { constructor(){ super({maxSize:CACHE_MAX_BYTES, sizeCalculation:v=>v.size}); } }
const fileCache = new ByteLRUCache();
setInterval(()=>{ const m = process.memoryUsage(); if(m.rss > 1.3*1024*1024*1024){ log('ALERT', `RAM ${(m.rss/1e9).toFixed(1)}GB → vidage cache`); fileCache.clear(); if(global.gc) global.gc(); } }, 30000);
// ==========================================
// ✅ FIX BUG 1 : NSFW via Hugging Face (@gradio/client)
// Remplace TensorFlow.js qui crashait silencieusement (tf.node.decodeImage)
// ==========================================
async function checkNsfw(buf) {
try {
log('INFO', 'NSFW: Début du scan via HuggingFace...');
const blob = new Blob([buf], { type: 'image/jpeg' });
const client = await Client.connect("ccabrerafreepik/nsfw_image_detector");
const result = await client.predict("/predict", {
image: blob,
confidence_level: "medium",
});
log('INFO', `NSFW: Réponse API -> ${JSON.stringify(result.data)}`);
if (!result || !result.data || !Array.isArray(result.data)) {
log('WARN', 'NSFW: Format de réponse invalide');
return { safe: true };
}
const classificationText = result.data[1] || '';
const isNsfw = classificationText.includes('True');
if (isNsfw) {
log('ALERT', `NSFW BLOQUÉ ! Classification: ${classificationText.replace(/\n/g, ' | ')}`);
return { safe: false };
}
log('INFO', 'NSFW: Image validée (safe).');
return { safe: true };
} catch (e) {
log('ERROR', `NSFW: Erreur réseau/API: ${e.message}`);
return { safe: true };
}
}
class UploadQueue{ constructor(c){ this.c=c; this.r=0; this.q=[]; } add(t){ return new Promise((res,rej)=>{ this.q.push({t,res,rej}); this.proc(); }); } async proc(){ if(this.r>=this.c||!this.q.length) return; this.r++; const {t,res,rej}=this.q.shift(); try{ res(await t()); }catch(e){ rej(e); }finally{ this.r--; this.proc(); } } }
const uploadQueue = new UploadQueue(MAX_CONCURRENT_UPLOADS);
async function imgbbUpload(buf){ const f=new FormData(); f.append('image',buf.toString('base64')); const {data}=await axios.post(`https://api.imgbb.com/1/upload?key=${IMGBB_API_KEY}`,f,{headers:f.getHeaders(),timeout:120000,maxContentLength:Infinity,maxBodyLength:Infinity}); if(!data.success) throw new Error(data.error?.message||'ImgBB fail'); return {url:data.data.url,deleteUrl:data.data.delete_url,id:data.data.id}; }
async function imgbbDelete(u){ try{await axios.get(u,{timeout:10000});}catch(_){} }
const SERVER_MASTER_KEY = createHash('sha256').update(MASTER_SECRET + '|PixelDriveMaster').digest();
function encryptMasterKey(k){ const iv=randomBytes(12); const c=createCipheriv('aes-256-gcm',SERVER_MASTER_KEY,iv); return Buffer.concat([iv,c.update(k),c.final(),c.getAuthTag()]); }
function decryptMasterKey(b){ const iv=b.subarray(0,12), tag=b.subarray(-16), d=b.subarray(12,-16); const dc=createDecipheriv('aes-256-gcm',SERVER_MASTER_KEY,iv); dc.setAuthTag(tag); return Buffer.concat([dc.update(d),dc.final()]); }
async function getMasterKey(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); }
function canAccessFolder(userId, folderId) {
let cur = db.prepare('SELECT * FROM folders WHERE id=?').get(folderId);
let depth = 0;
while (cur && depth < 50) {
if (cur.owner_id === userId) return { access: true, perm: 'write' };
const share = db.prepare('SELECT permission FROM user_shares WHERE folder_id=? AND shared_with=?').get(cur.id, userId);
if (share) return { access: true, perm: share.permission };
cur = cur.parent_id ? db.prepare('SELECT * FROM folders WHERE id=?').get(cur.parent_id) : null;
depth++;
}
return { access: false, perm: null };
}
function canAccessFile(userId, fileId) {
const f = db.prepare('SELECT * FROM files WHERE id=?').get(fileId);
if (!f) return null;
if (f.owner_id === userId) return { ...f, perm: 'write' };
const share = db.prepare('SELECT permission FROM user_shares WHERE file_id=? AND shared_with=?').get(fileId, userId);
if (share) return { ...f, perm: share.permission };
if (f.folder_id) { const fa = canAccessFolder(userId, f.folder_id); if (fa.access) return { ...f, perm: fa.perm }; }
return null;
}
function canModifyFile(userId, fileId) {
const f = db.prepare('SELECT * FROM files WHERE id=?').get(fileId);
if (!f) return null;
if (f.owner_id === userId) return f;
if (f.folder_id) { const acc = canAccessFolder(userId, f.folder_id); if (acc.access && acc.perm === 'write') return f; }
return null;
}
function isEditable(f) { return (f.mime && (f.mime.startsWith('text/') || ['application/json','application/javascript','application/xml'].includes(f.mime))) || EDITABLE_RE.test(f.name || ''); }
async function decryptWholeFile(f) { const k = await getMasterKey(f.id); const chunks = db.prepare('SELECT chunk_index,imgbb_url,size FROM file_chunks WHERE file_id=? ORDER BY chunk_index').all(f.id); const bufs = []; for (const c of chunks) { const ck = `chunk:${f.id}:${c.chunk_index}`; let dec = fileCache.get(ck)?.data; if (!dec) { const {data} = await axios.get(c.imgbb_url,{responseType:'arraybuffer',timeout:60000}); const enc = await pngToBuffer(Buffer.from(data), c.size + OVERHEAD); dec = decryptChunk(enc, k); fileCache.set(ck,{data:dec,size:dec.length}); } bufs.push(dec); } return Buffer.concat(bufs, f.size); }
async function genThumb(fid,mime,key){ if(!mime.startsWith('image/')&&!mime.startsWith('video/')) return; try{ const c = db.prepare('SELECT imgbb_url, size FROM file_chunks WHERE file_id=? AND chunk_index=0').get(fid); if(!c) return; const {data} = await axios.get(c.imgbb_url,{responseType:'arraybuffer',timeout:30000}); const enc = await pngToBuffer(Buffer.from(data), c.size + OVERHEAD); const dec = decryptChunk(enc,key); let buf; if(mime.startsWith('image/')) buf = await sharp(dec).rotate().resize(256,256,{fit:'inside'}).jpeg({quality:80}).toBuffer(); else buf = await sharp({create:{width:256,height:144,channels:3,background:'#1e1e2f'}}).jpeg().toBuffer(); await sharp(buf).toFile(join(THUMB_DIR,`${fid}.jpg`)); log('INFO', `Thumb généré: ${fid}`); }catch(e){ log('WARN', `Thumb ${fid}: ${e.message}`); } }
async function streamFile(res,file,key,range,counterUserId){ const chunks = db.prepare('SELECT chunk_index,imgbb_url,size FROM file_chunks WHERE file_id=? ORDER BY chunk_index').all(file.id); const total = file.size; let s = 0, e = total - 1; if(range){ const p = range.replace('bytes=','').split('-'); s = parseInt(p[0]); e = p[1] ? parseInt(p[1]) : total - 1; } if(s >= total || e >= total) return res.status(416).send('Range Not Satisfiable'); const sentBytes = e - s + 1; res.set({'Content-Type':file.mime,'Content-Length':sentBytes,'Accept-Ranges':'bytes','Content-Disposition':`inline; filename="${encodeURIComponent(file.name)}"`}); if(range){ res.status(206); res.set('Content-Range',`bytes ${s}-${e}/${total}`); } log('INFO',`Stream ${file.name} (${(sentBytes/1048576).toFixed(2)} MB)`); let cur = 0; for(const c of chunks){ const cs = cur, ce = cur + c.size - 1; if(ce >= s && cs <= e){ const off = Math.max(0, s - cs), len = Math.min(c.size - off, e - Math.max(s, cs) + 1); try{ const ck = `chunk:${file.id}:${c.chunk_index}`; let dec = fileCache.get(ck)?.data; if(!dec){ const {data} = await axios.get(c.imgbb_url,{responseType:'arraybuffer',timeout:120000}); const enc = await pngToBuffer(Buffer.from(data), c.size + OVERHEAD); dec = decryptChunk(enc, key); fileCache.set(ck, {data: dec, size: dec.length}); } const sl = dec.subarray(off, off + len); if(!res.write(sl)) await new Promise(r => res.once('drain', r)); }catch(err){ log('ERROR',`Stream chunk ${c.chunk_index}: ${err.message}`); return res.destroy(err); } } cur += c.size; if(cs > e) break; } res.end(); if (counterUserId) addTransfer(counterUserId, sentBytes, 'down'); }
const sseClients = new Map();
function broadcast(fid,d){ const cs = sseClients.get(fid); if(cs){ const p = `data: ${JSON.stringify(d)}\n\n`; for(const r of cs) r.write(p); } }
let EMBED_TPL = null;
function embedTpl() { if (EMBED_TPL === null) { try { EMBED_TPL = readFileSync(join(PUBLIC_DIR, 'embed.html'), 'utf8'); } catch (e) { EMBED_TPL = false; } } return EMBED_TPL || null; }
const partsDir = (fid) => join(TEMP_DIR, 'up_' + fid);
const assembledPath = (fid) => join(TEMP_DIR, 'assemble_' + fid);
const pad6 = (i) => String(i).padStart(6, '0');
async function imgbbUploadRetry(buf, tries = 4) {
for (let i = 0; i < tries; i++) {
try { return await imgbbUpload(buf); }
catch (e) { const status = e.response?.status; if (i === tries - 1) throw e; const wait = status === 429 ? 4000 * (i + 1) : 1200 * (i + 1); log('WARN', `ImgBB retry ${i+1} (HTTP ${status||'net'}) dans ${wait}ms`); await new Promise(r => setTimeout(r, wait)); }
}
}
async function processUpload(fid, path, fsize, username, uid) {
const t0 = Date.now();
const mk = await getMasterKey(fid);
const frow = db.prepare('SELECT mime FROM files WHERE id=?').get(fid);
const mime = frow?.mime || 'application/octet-stream';
const cc = Math.ceil(fsize / CHUNK_SIZE);
log('INFO',`Traitement démarré: ${fid} par ${username} (${(fsize/1048576).toFixed(2)} MB, ${cc} chunk(s))`);
try {
db.prepare("UPDATE files SET status='uploading' WHERE id=?").run(fid);
if (mime.startsWith('image/')) {
const c0 = db.prepare('SELECT size FROM file_chunks WHERE file_id=? AND chunk_index=0').get(fid);
const raw0 = await readChunk(path, 0, c0.size);
const scan = await checkNsfw(raw0);
if (!scan.safe) { log('ALERT',`NSFW bloqué: ${fid} par ${username}`); throw new Error('Contenu bloqué (NSFW)'); }
}
let next = 0, done = 0, err = null;
async function worker() {
while (true) {
if (err) return;
const i = next++;
if (i >= cc) return;
const ci = db.prepare('SELECT size, encrypted_len, imgbb_url FROM file_chunks WHERE file_id=? AND chunk_index=?').get(fid, i);
if (ci.imgbb_url) { done++; const prog = (done / cc) * 50; db.prepare('UPDATE files SET progress=? WHERE id=?').run(prog, fid); broadcast(fid, {type:'progress', progress:prog, status:'uploading'}); continue; }
try {
const raw = await readChunk(path, i * CHUNK_SIZE, ci.size);
const enc = encryptChunk(raw, mk);
const png = await bufferToPng(enc);
const up = await imgbbUploadRetry(png);
db.prepare('UPDATE file_chunks SET imgbb_url=?,imgbb_delete_url=?,imgbb_id=? WHERE file_id=? AND chunk_index=?').run(up.url, up.deleteUrl, up.id, fid, i);
done++;
const prog = (done / cc) * 50;
db.prepare('UPDATE files SET progress=?,status=? WHERE id=?').run(prog, 'uploading', fid);
broadcast(fid, {type:'progress', progress:prog, status:'uploading'});
log('INFO',`Chunk ${i+1}/${cc} envoyé (ImgBB)`);
} catch (e) { err = e; return; }
}
}
await Promise.all(Array.from({ length: Math.min(2, cc) }, () => worker()));
if (err) throw err;
try { unlinkSync(path); } catch (_) {}
db.prepare("UPDATE files SET status='processing',progress=50 WHERE id=?").run(fid);
broadcast(fid, {type:'progress', progress:50, status:'processing'});
await genThumb(fid, mime, mk);
db.prepare("UPDATE files SET status='ready',progress=100 WHERE id=?").run(fid);
broadcast(fid, {type:'progress', progress:100, status:'ready'});
// ✅ FIX BUG 2 : Envoyer explicitement {type:'done'} pour que le client ferme la barre
broadcast(fid, {type:'done'});
log('INFO', `Upload terminé + signal 'done' envoyé: ${fid}`);
addTransfer(uid, fsize, 'up');
logActivity(uid, 'upload', path.split('/').pop());
const secs = (Date.now() - t0) / 1000;
log('INFO',`Traitement terminé: ${fid} en ${secs.toFixed(1)}s (${(fsize/1048576/secs).toFixed(2)} MB/s)`);
} catch (e) {
log('ERROR',`Traitement échoué ${fid}: ${e.message}`);
db.prepare("UPDATE files SET status='error',error_msg=? WHERE id=?").run(e.message, fid);
broadcast(fid, {type:'error', error:e.message});
// ✅ FIX BUG 2 : Envoyer aussi 'done' en cas d'erreur pour débloquer le client
broadcast(fid, {type:'done'});
const cs = db.prepare('SELECT imgbb_delete_url FROM file_chunks WHERE file_id=?').all(fid);
for (const c of cs) if (c.imgbb_delete_url) await imgbbDelete(c.imgbb_delete_url);
}
}
function readChunk(p,o,l){return new Promise((res,rej)=>{const s=createReadStream(p,{start:o,end:o+l-1}),c=[];s.on('data',d=>c.push(d)).on('end',()=>res(Buffer.concat(c,l))).on('error',rej);});}
let lastCpuTimes = os.cpus().map(c => c.times);
function cpuUsagePct() {
const now = os.cpus().map(c => c.times);
let total = 0, idle = 0;
for (let i = 0; i < now.length; i++) {
const p = lastCpuTimes[i] || now[i];
const tN = now[i].user + now[i].nice + now[i].sys + now[i].idle + now[i].irq;
const tP = p.user + p.nice + p.sys + p.idle + p.irq;
total += tN - tP; idle += now[i].idle - p.idle;
}
lastCpuTimes = now;
return total > 0 ? Math.round(100 * (1 - idle / total)) : 0;
}
app.get('/api/admin/server-status', requireAuth, requireAdmin, (req,res) => {
const mu = process.memoryUsage();
res.json({ cpu: cpuUsagePct(), loadavg: os.loadavg(), cores: os.cpus().length, ramTotal: os.totalmem(), ramFree: os.freemem(), rss: mu.rss, heapUsed: mu.heapUsed, uptime: process.uptime(), osUptime: os.uptime(), platform: os.platform() + ' ' + os.release(), node: process.version, cache: fileCache.size });
});
// ==========================================
// ROUTES API
// ==========================================
app.post('/api/register', registerLimit, async(req,res) => {
const {u,p,t} = req.body;
if(!u||!p||!t) return res.status(400).json({error:'Champs manquants'});
if(typeof u !== 'string' || !/^[a-zA-Z0-9_.-]{3,20}$/.test(u)) return res.status(400).json({error:'Nom invalide'});
if(typeof p !== 'string' || p.length < 8) return res.status(400).json({error:'Mot de passe trop court (min 8)'});
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});
});
app.post('/api/login', loginLimit, async(req,res) => {
const {u,p} = req.body;
const usr = db.prepare('SELECT * FROM users WHERE username=?').get(u);
const salt = usr ? usr.salt : randomBytes(16);
const h = await deriveKey(p, salt);
if(!usr || !timingSafeEqual(h, usr.password_hash)) {
return res.status(401).json({error:'Invalide'});
}
try { await new Promise((ok, bad) => req.session.regenerate(e => e ? bad(e) : ok())); } catch(e) { return res.status(500).json({error:'Erreur session'}); }
req.session.userId = usr.id;
req.session.isAdmin = !!usr.is_admin;
req.session.tosAccepted = !!usr.tos_accepted;
req.session.username = usr.username;
log('INFO', `Connexion: ${u}`);
logActivity(usr.id, 'login', 'Connexion');
res.json({ok:true,user:{id:usr.id,username:usr.username,isAdmin:!!usr.is_admin,tosAccepted:!!usr.tos_accepted}});
});
app.post('/api/logout',(req,res) => { log('INFO',`Déconnexion: ${req.session.username||'?'}`); req.session.destroy(()=>res.json({ok:true})); });
app.get('/api/me',(req,res) => { if(!req.session.userId) return res.status(401).json({error:'Non connecté'}); const used = db.prepare("SELECT COALESCE(SUM(size),0) as t FROM files WHERE owner_id=? AND status!='error'").get(req.session.userId).t; const tr = getTransfer(req.session.userId); res.json({ id:req.session.userId, username:req.session.username, isAdmin:req.session.isAdmin, tosAccepted: req.session.tosAccepted, quotaUsed:used, quotaMax:USER_QUOTA, transferUp:tr.up, transferDown:tr.down, transferUsed:tr.total, transferMax:DAILY_TRANSFER }); });
app.get('/api/tos', ( _,res) => res.send(`PIXELDRIVE CGU\n1. RESPONSABILITÉ TOTALE\n2. AUCUNE GARANTIE\n3. INTERDITS: Illégal, NSFW, Abus\n4. CHIFFRÉ AES-256-GCM\n5. ACCEPTATION = Responsabilité légale`));
app.post('/api/tos/accept',requireAuth,(req,res) => {db.prepare('UPDATE users SET tos_accepted=1 WHERE id=?').run(req.session.userId);req.session.tosAccepted=true; log('INFO',`CGU acceptées: ${req.session.username}`); res.json({ok:true});});
app.get('/api/notifications', requireAuth, (req,res) => { res.json(db.prepare('SELECT id, type, detail, read, created_at FROM notifications WHERE user_id=? ORDER BY created_at DESC LIMIT 50').all(req.session.userId)); });
app.post('/api/notifications/read', requireAuth, (req,res) => { db.prepare('UPDATE notifications SET read=1 WHERE user_id=?').run(req.session.userId); res.json({ok:true}); });
app.post('/api/password', requireAuth, async(req,res) => {
const { current, next } = req.body;
if (typeof current !== 'string' || typeof next !== 'string') return res.status(400).json({error:'Champs manquants'});
if (next.length < 8) return res.status(400).json({error:'Trop court (min 8)'});
const usr = db.prepare('SELECT * FROM users WHERE id=?').get(req.session.userId);
const hCur = await deriveKey(current, usr.salt);
if (!timingSafeEqual(hCur, usr.password_hash)) return res.status(403).json({error:'Mot de passe actuel invalide'});
const salt = randomBytes(16);
const hNew = await deriveKey(next, salt);
db.prepare('UPDATE users SET password_hash=?, salt=? WHERE id=?').run(hNew, salt, usr.id);
log('INFO',`Mot de passe changé: ${usr.username}`);
logActivity(usr.id, 'password', 'Modifié');
res.json({ok:true});
});
app.delete('/api/account', requireAuth, async(req,res) => {
const { password } = req.body;
const usr = db.prepare('SELECT * FROM users WHERE id=?').get(req.session.userId);
const h = await deriveKey(String(password || ''), usr.salt);
if (!timingSafeEqual(h, usr.password_hash)) return res.status(403).json({error:'Mot de passe invalide'});
const urls = db.prepare('SELECT fc.imgbb_delete_url FROM file_chunks fc JOIN files f ON f.id=fc.file_id WHERE f.owner_id=? AND fc.imgbb_delete_url IS NOT NULL').all(usr.id).map(r => r.imgbb_delete_url);
db.prepare('DELETE FROM users WHERE id=?').run(usr.id);
log('ALERT',`Compte supprimé: ${usr.username}`);
(async () => { for (const u of urls) await imgbbDelete(u); })();
req.session.destroy(() => res.json({ok:true}));
});
app.get('/api/stats', requireAuth, (req,res) => { res.json(db.prepare(`SELECT CASE WHEN mime LIKE 'image/%' THEN 'image' WHEN mime LIKE 'video/%' THEN 'video' WHEN mime LIKE 'audio/%' THEN 'audio' ELSE 'autre' END as cat, COUNT(*) c, COALESCE(SUM(size),0) s FROM files WHERE owner_id=? AND status!='error' GROUP BY cat`).all(req.session.userId)); });
app.get('/api/stats/advanced', requireAuth, (req,res) => { const days = []; for (let i = 6; i >= 0; i--) { const d = new Date(Date.now() - i * 86400000).toISOString().slice(0,10); const r = db.prepare('SELECT COALESCE(SUM(up_bytes),0) u, COALESCE(SUM(down_bytes),0) d FROM transfer_daily WHERE user_id=? AND day=?').get(req.session.userId, d); const acts = db.prepare("SELECT COUNT(*) c FROM activity WHERE user_id=? AND strftime('%Y-%m-%d', created_at, 'unixepoch')=?").get(req.session.userId, d).c; days.push({ day: d.slice(5), up: r.u, down: r.d, acts }); } const top = db.prepare(`SELECT f.name, (SELECT COALESCE(SUM(downloads),0) FROM share_links sl WHERE sl.file_id=f.id) dl FROM files f WHERE f.owner_id=? AND f.status='ready' ORDER BY dl DESC LIMIT 5`).all(req.session.userId); res.json({ days, top }); });
app.get('/api/activity', requireAuth, (req,res) => { res.json(db.prepare('SELECT action, detail, created_at FROM activity WHERE user_id=? ORDER BY created_at DESC LIMIT 100').all(req.session.userId)); });
app.get('/api/admin/stats', requireAuth, requireAdmin, (req,res) => { const users = db.prepare('SELECT COUNT(*) c FROM users').get().c; const files = db.prepare('SELECT COUNT(*) c, COALESCE(SUM(size),0) s FROM files').get(); const shares = db.prepare('SELECT COUNT(*) c FROM user_shares').get().c; const links = db.prepare('SELECT COUNT(*) c FROM share_links').get().c; const transferToday = db.prepare('SELECT COALESCE(SUM(up_bytes + down_bytes),0) t FROM transfer_daily WHERE day=?').get(todayKey()).t; res.json({ users, filesCount: files.c, storage: files.s, shares, links, transferToday }); });
app.get('/api/admin/users', requireAuth, requireAdmin, (req,res) => { const rows = db.prepare('SELECT id, username, is_admin, tos_accepted, created_at FROM users ORDER BY id').all(); res.json(rows.map(u => ({ ...u, storageUsed: db.prepare("SELECT COALESCE(SUM(size),0) t FROM files WHERE owner_id=? AND status!='error'").get(u.id).t, filesCount: db.prepare('SELECT COUNT(*) c FROM files WHERE owner_id=?').get(u.id).c, transferToday: getTransfer(u.id).total }))); });
app.get('/api/admin/activity', requireAuth, requireAdmin, (req,res) => { res.json(db.prepare(`SELECT a.action, a.detail, a.created_at, COALESCE(u.username,'public') as username FROM activity a LEFT JOIN users u ON u.id=a.user_id ORDER BY a.created_at DESC LIMIT 100`).all()); });
app.delete('/api/admin/users/:id', requireAuth, requireAdmin, (req,res) => { const id = parseInt(req.params.id, 10); if (!id) return res.status(400).json({error:'ID invalide'}); if (id === req.session.userId) return res.status(400).json({error:'Impossible'}); const urls = db.prepare('SELECT fc.imgbb_delete_url FROM file_chunks fc JOIN files f ON f.id=fc.file_id WHERE f.owner_id=? AND fc.imgbb_delete_url IS NOT NULL').all(id).map(r => r.imgbb_delete_url); db.prepare('DELETE FROM users WHERE id=?').run(id); log('ALERT',`Admin ${req.session.username} a supprimé le compte #${id}`); (async () => { for (const u of urls) await imgbbDelete(u); })(); res.json({ok:true}); });
app.post('/api/admin/users/:id/toggle-admin', requireAuth, requireAdmin, (req,res) => { const id = parseInt(req.params.id, 10); if (!id || id === req.session.userId) return res.status(400).json({error:'Non'}); db.prepare('UPDATE users SET is_admin = 1 - is_admin WHERE id=?').run(id); log('ALERT',`Admin ${req.session.username} a inversé le rôle du compte #${id}`); res.json({ok:true}); });
app.post('/api/avatar', requireAuth, avatarLimit, async(req,res) => { const { data } = req.body; if (!data || !data.startsWith('data:image/')) return res.status(400).json({error:'Image invalide'}); try { const buf = Buffer.from(data.split(',')[1], 'base64'); if (buf.length > 5 * 1024*1024) return res.status(400).json({error:'Trop lourde'}); const jpeg = await sharp(buf).resize(128,128,{fit:'cover'}).jpeg({quality:82}).toBuffer(); db.prepare('UPDATE users SET avatar=? WHERE id=?').run(jpeg, req.session.userId); log('INFO',`Avatar mis à jour: ${req.session.username}`); res.json({ok:true}); } catch(e){ res.status(400).json({error:'Image invalide'}); } });
app.delete('/api/avatar', requireAuth, avatarLimit, (req,res) => { db.prepare('UPDATE users SET avatar=NULL WHERE id=?').run(req.session.userId); log('INFO',`Avatar retiré: ${req.session.username}`); res.json({ok:true}); });
app.get('/api/users/:id/avatar', requireAuth, (req,res) => { const u = db.prepare('SELECT avatar FROM users WHERE id=?').get(req.params.id); if (!u || !u.avatar) return res.status(404).json({error:"Pas d'avatar"}); res.set({'Content-Type':'image/jpeg','Cache-Control':'public, max-age=3600'}); res.send(u.avatar); });
app.get('/api/folders',requireAuth,(req,res) => {
const p = req.query.parent_id ? parseInt(req.query.parent_id) : null;
if (p !== null) {
const access = canAccessFolder(req.session.userId, p);
if (!access.access) return res.status(403).json({error:'Accès refusé'});
const rows = db.prepare(`SELECT folders.*, users.username AS owner_name FROM folders JOIN users ON users.id = folders.owner_id WHERE folders.parent_id=? ORDER BY folders.name COLLATE NOCASE`).all(p);
return res.json(rows.map(r => ({ ...r, perm: r.owner_id === req.session.userId ? 'write' : access.perm })));
}
const mine = db.prepare(`SELECT *, 'write' AS perm FROM folders WHERE owner_id=? AND parent_id IS NULL ORDER BY name COLLATE NOCASE`).all(req.session.userId);
const shared = db.prepare(`SELECT f.*, us.permission AS perm, u.username AS owner_name FROM folders f JOIN user_shares us ON us.folder_id=f.id JOIN users u ON u.id=f.owner_id WHERE us.shared_with=? AND f.parent_id IS NULL ORDER BY f.name COLLATE NOCASE`).all(req.session.userId);
res.json([...mine, ...shared]);
});
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); log('INFO',`Dossier créé: ${n}`); res.json({id:i.lastInsertRowid,name:n,parent_id:p});}catch(e){res.status(409).json({error:'Existe'});}});
app.delete('/api/folders/:id',requireAuth,(req,res) => {db.prepare('DELETE FROM folders WHERE id=? AND owner_id=?').run(req.params.id,req.session.userId); log('INFO',`Dossier supprimé: ${req.params.id}`); res.json({ok:true});});
app.post('/api/folders/:id/rename',requireAuth,(req,res) => { const { name } = req.body; if(!name || typeof name !== 'string' || !name.trim() || name.length > 100) return res.status(400).json({error:'Nom invalide'}); const fo = db.prepare('SELECT id FROM folders WHERE id=? AND owner_id=?').get(req.params.id, req.session.userId); if(!fo) return res.status(404).json({error:'Introuvable'}); try { db.prepare('UPDATE folders SET name=? WHERE id=?').run(name.trim(), req.params.id); log('INFO',`Dossier renommé: ${name.trim()}`); res.json({ok:true}); } catch(e){ res.status(409).json({error:'Nom déjà utilisé'}); } });
app.post('/api/folders/:id/move',requireAuth,(req,res) => { const { parent_id } = req.body; const fo = db.prepare('SELECT * FROM folders WHERE id=? AND owner_id=?').get(req.params.id, req.session.userId); if(!fo) return res.status(404).json({error:'Introuvable'}); if (parent_id) { const target = db.prepare('SELECT * FROM folders WHERE id=?').get(parent_id); if (!target || target.owner_id !== req.session.userId) return res.status(403).json({error:'Cible invalide'}); let cur = target, depth = 0; while (cur && depth < 50) { if (cur.id === fo.id) return res.status(400).json({error:'Boucle'}); cur = cur.parent_id ? db.prepare('SELECT * FROM folders WHERE id=?').get(cur.parent_id) : null; depth++; } } db.prepare('UPDATE folders SET parent_id=? WHERE id=?').run(parent_id || null, req.params.id); log('INFO',`Dossier déplacé: ${fo.name}`); res.json({ok:true}); });
app.get('/api/files',requireAuth,(req,res) => {
const f = req.query.folder_id ? parseInt(req.query.folder_id) : null;
if (f !== null) {
const access = canAccessFolder(req.session.userId, f);
if (!access.access) return res.status(403).json({error:'Accès refusé'});
const rows = db.prepare(`SELECT files.*, users.username AS owner_name FROM files JOIN users ON users.id = files.owner_id WHERE files.folder_id=? ORDER BY files.name COLLATE NOCASE`).all(f);
return res.json(rows.map(r => ({ ...r, perm: r.owner_id === req.session.userId ? 'write' : access.perm })));
}
const mine = db.prepare(`SELECT *, 'write' AS perm FROM files WHERE owner_id=? AND folder_id IS NULL ORDER BY name COLLATE NOCASE`).all(req.session.userId);
const shared = db.prepare(`SELECT f.*, us.permission AS perm, u.username AS owner_name FROM files f JOIN user_shares us ON us.file_id=f.id JOIN users u ON u.id=f.owner_id WHERE us.shared_with=? AND f.folder_id IS NULL ORDER BY f.name COLLATE NOCASE`).all(req.session.userId);
res.json([...mine, ...shared]);
});
app.post('/api/files/:id/rename',requireAuth,(req,res) => { if(!UUID_RE.test(req.params.id)) return res.status(400).json({error:'ID invalide'}); const { name } = req.body; if(!name || typeof name !== 'string' || !name.trim() || name.length > 200) return res.status(400).json({error:'Nom invalide'}); const f = canModifyFile(req.session.userId, req.params.id); if(!f) return res.status(404).json({error:'Introuvable'}); db.prepare('UPDATE files SET name=? WHERE id=?').run(name.trim(), f.id); log('INFO',`Fichier renommé: ${name.trim()}`); res.json({ok:true}); });
app.post('/api/files/:id/move',requireAuth,(req,res) => { if(!UUID_RE.test(req.params.id)) return res.status(400).json({error:'ID invalide'}); const { folder_id } = req.body; const f = canModifyFile(req.session.userId, req.params.id); if(!f) return res.status(404).json({error:'Introuvable'}); if (folder_id) { const acc = canAccessFolder(req.session.userId, folder_id); if (!acc.access || acc.perm !== 'write') return res.status(403).json({error:'Cible invalide'}); } db.prepare('UPDATE files SET folder_id=? WHERE id=?').run(folder_id || null, f.id); log('INFO',`Fichier déplacé: ${f.name}`); res.json({ok:true}); });
app.delete('/api/files/:id',requireAuth,async(req,res) => { if(!UUID_RE.test(req.params.id)) return res.status(400).json({error:'ID invalide'}); const f = canModifyFile(req.session.userId, req.params.id); if(!f) return res.status(404).json({error:'Introuvable'}); const cs=db.prepare('SELECT imgbb_delete_url FROM file_chunks WHERE file_id=?').all(f.id); for(const c of cs)if(c.imgbb_delete_url)await imgbbDelete(c.imgbb_delete_url); db.prepare('DELETE FROM files WHERE id=?').run(f.id); fileCache.delete(`chunk:${f.id}:0`); try{unlinkSync(join(THUMB_DIR,`${f.id}.jpg`));}catch(_){} log('INFO',`Fichier supprimé: ${f.name}`); logActivity(req.session.userId,'delete',f.name); res.json({ok:true}); });
app.post('/api/files/:id/edit', requireAuth, requireTos, async(req,res) => {
if(!UUID_RE.test(req.params.id)) return res.status(400).json({error:'ID invalide'});
const { content } = req.body;
if (typeof content !== 'string') return res.status(400).json({ error:'Contenu manquant'});
const f = canModifyFile(req.session.userId, req.params.id);
if(!f) return res.status(404).json({error:'Introuvable'});
if (f.status !== 'ready') return res.status(409).json({error:'Pas prêt'});
if (!isEditable(f)) return res.status(400).json({error:'Non éditable'});
const buf = Buffer.from(content, 'utf8');
if (buf.length > CHUNK_SIZE) return res.status(413).json({error:'Trop lourd'});
try {
const mk = decryptMasterKey(f.master_key_enc);
const enc = encryptChunk(buf, mk);
const png = await bufferToPng(enc);
const {url, deleteUrl, id: imgId} = await imgbbUpload(png);
const olds = db.prepare('SELECT imgbb_delete_url FROM file_chunks WHERE file_id=?').all(f.id);
db.prepare('DELETE FROM file_chunks WHERE file_id=?').run(f.id);
db.prepare('INSERT INTO file_chunks(file_id,chunk_index,size,encrypted_len) VALUES(?,?,?,?)').run(f.id,0,buf.length,enc.length);
db.prepare('UPDATE files SET size=?, chunk_count=1, status=? WHERE id=?').run(buf.length,'ready',f.id);
fileCache.delete(`chunk:${f.id}:0`);
for (const o of olds) if (o.imgbb_delete_url) await imgbbDelete(o.imgbb_delete_url);
log('INFO', `Fichier édité: ${f.name}`);
res.json({ok:true, size: buf.length});
} catch (e) { res.status(500).json({error:"Erreur"}); }
});
app.post('/api/files/:id/share',requireAuth,shareLimit,async(req,res) => { const f = canModifyFile(req.session.userId, req.params.id); if(!f||f.status!=='ready') return res.status(404).json({error:'Non disponible'}); const ttlReq = parseInt(req.body.ttl, 10); const ttl = TTL_ALLOWED.includes(ttlReq) ? ttlReq : 168; const pt=uuidv4(), dt2=uuidv4(), expires=Date.now()+ttl*3600000; db.prepare('INSERT INTO share_links(token,file_id,owner_id,type,expires_at) VALUES(?,?,?,?,?)').run(pt,f.id,req.session.userId,'embed',expires); db.prepare('INSERT INTO share_links(token,file_id,owner_id,type,expires_at) VALUES(?,?,?,?,?)').run(dt2,f.id,req.session.userId,'download',expires); log('INFO', `Lien public créé: ${f.name} (${ttl}h)`); res.json({preview:`/s/${pt}`,download:`/d/${dt2}`,expires,ttl}); });
app.get('/api/files/:id/download',requireAuth,async(req,res) => { if(!UUID_RE.test(req.params.id)) return res.status(400).json({error:'ID invalide'}); if (transferBlocked(req.session.userId)) return res.status(429).json({error:'Quota atteint'}); const f = canAccessFile(req.session.userId, req.params.id); if(!f)return res.status(404).json({error:'Introuvable'}); if(f.status!=='ready')return res.status(409).json({error:'Pas prêt'}); logActivity(req.session.userId,'download',f.name); const k = await getMasterKey(f.id); await streamFile(res,f,k,req.headers.range,req.session.userId); });
app.get('/api/files/:id/raw', requireAuth, async(req,res) => {
if(!UUID_RE.test(req.params.id)) return res.status(400).json({error:'ID invalide'});
const f = canAccessFile(req.session.userId, req.params.id);
if(!f) return res.status(404).json({error:'Introuvable'});
if(f.status!=='ready') return res.status(409).json({error:'Pas prêt'});
if (f.size > 2 * 1024 * 1024) return res.status(413).json({error:'Trop lourd'});
try {
const all = await decryptWholeFile(f);
res.set({'Content-Type': 'text/plain; charset=utf-8', 'Content-Disposition': 'inline'});
res.send(all);
} catch (e) { res.status(500).json({error:'Erreur'}); }
});
app.get('/api/files/:id/properties', requireAuth, async(req,res) => { if(!UUID_RE.test(req.params.id)) return res.status(400).json({error:'ID invalide'}); const f = canAccessFile(req.session.userId, req.params.id); if(!f) return res.status(404).json({error:'Introuvable'}); const chunks = db.prepare('SELECT imgbb_url, size FROM file_chunks WHERE file_id=? ORDER BY chunk_index').all(f.id); const hash = createHash('sha256'); for (const c of chunks) { try { const { data } = await axios.get(c.imgbb_url, { responseType:'arraybuffer', timeout:30000 }); hash.update(Buffer.from(data)); } catch (e) { return res.status(500).json({error:'Erreur'}); } } res.json({ id: f.id, name: f.name, size: f.size, mime: f.mime, status: f.status, created_at: f.created_at, updated_at: f.updated_at, chunk_count: f.chunk_count, encryption_key_hash: f.encryption_key_hash, sha256: hash.digest('hex'), owner_id: f.owner_id }); });
app.get('/api/shared-with-me',requireAuth,(req,res) => { res.json(db.prepare(`SELECT us.id as share_id, us.created_at as shared_at, us.permission, u.username as owner_name, us.owner_id, us.file_id, us.folder_id, f.name as file_name, f.mime, f.size, f.status, fo.name as folder_name FROM user_shares us JOIN users u ON u.id = us.owner_id LEFT JOIN files f ON f.id = us.file_id LEFT JOIN folders fo ON fo.id = us.folder_id WHERE us.shared_with = ? ORDER BY us.created_at DESC`).all(req.session.userId)); });
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); log('INFO',`Partage retiré par ${req.session.username}`); res.json({ok:true}); });
app.get('/api/my-shares',requireAuth,(req,res) => { res.json(db.prepare(`SELECT us.id, us.created_at, us.permission, u.username as shared_with_name, us.file_id, us.folder_id, f.name as file_name, f.mime, f.size, fo.name as folder_name FROM user_shares us JOIN users u ON u.id = us.shared_with LEFT JOIN files f ON f.id = us.file_id LEFT JOIN folders fo ON fo.id = us.folder_id WHERE us.owner_id = ? ORDER BY us.created_at DESC`).all(req.session.userId)); });
app.delete('/api/my-shares/:id',requireAuth,(req,res) => { const r = db.prepare('DELETE FROM user_shares WHERE id=? AND owner_id=?').run(req.params.id, req.session.userId); if (!r.changes) return res.status(404).json({error:'Introuvable'}); log('INFO',`Partage révoqué par ${req.session.username}`); res.json({ok:true}); });
app.post('/api/share-user',requireAuth,requireTos,shareLimit,(req,res) => {
const { type, id, username, permission } = req.body;
if (!type || !id || !username) return res.status(400).json({error:'Champs manquants'});
if (type !== 'file' && type !== 'folder') return res.status(400).json({error:'Type invalide'});
const perm = (permission === 'write') ? 'write' : 'read';
const target = db.prepare('SELECT id FROM users WHERE username=?').get(String(username).trim());
if (!target || target.id === req.session.userId) {
return res.status(400).json({error:'Utilisateur invalide ou impossible de partager avec vous-même'});
}
const col = type === 'file' ? 'file_id' : 'folder_id';
const own = db.prepare(`SELECT id, name FROM ${type === 'file' ? 'files' : 'folders'} WHERE id=? AND owner_id=?`).get(id, req.session.userId);
if (!own) return res.status(404).json({error:'Introuvable'});
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é'});
db.prepare(`INSERT INTO user_shares(${col},owner_id,shared_with,permission) VALUES(?,?,?,?)`).run(id, req.session.userId, target.id, perm);
notify(target.id, 'share',`${req.session.username}${own.name} (${perm === 'write' ? 'écriture' : 'lecture'})`);
log('INFO',`Partage ${type}${username} (${perm})`);
res.json({ok:true});
});
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)); });
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'}); log('INFO', `Lien public supprimé`); res.json({ok:true}); });
app.post('/api/upload/init', requireAuth, requireTos, uploadLimit, (req,res) => {
const { name, size, mime, folder_id } = req.body;
if (!name || !size) return res.status(400).json({error:'Champs manquants'});
const uid = req.session.userId;
if (folder_id) {
const acc = canAccessFolder(uid, folder_id);
if (!acc.access || acc.perm !== 'write') return res.status(403).json({error:'Accès refusé au dossier'});
}
const tr = getTransfer(uid);
const remaining = DAILY_TRANSFER - tr.total;
if (remaining <= 0) return res.status(429).json({error:'Quota atteint'});
if (size > remaining) return res.status(413).json({error:'Quota insuffisant'});
const used = db.prepare("SELECT COALESCE(SUM(size),0) as t FROM files WHERE owner_id=? AND status!='error'").get(uid).t;
if (used + size > USER_QUOTA) return res.status(413).json({error:'Quota stockage dépassé'});
const fid = uuidv4();
const mk = randomBytes(KEY_LEN);
const kh = createHash('sha256').update(mk).digest('hex');
const mke = encryptMasterKey(mk);
const cc = Math.ceil(size / CHUNK_SIZE);
db.prepare(`INSERT INTO files(id,folder_id,owner_id,name,mime,size,chunk_count,status,encryption_key_hash,master_key_enc) VALUES(?,?,?,?,?,?,?,'uploading',?,?)`).run(fid, folder_id||null, uid, name, mime||'application/octet-stream', size, cc, kh, mke);
const stmt = db.prepare('INSERT INTO file_chunks(file_id,chunk_index,size,encrypted_len) VALUES(?,?,?,?)');
const tx = db.transaction((arr) => {for(const c of arr)stmt.run(...c);});
const cd = [];
for (let i=0;i<cc;i++){ const sz = Math.min(CHUNK_SIZE, size - i * CHUNK_SIZE); cd.push([fid,i,sz,sz+OVERHEAD]); }
tx(cd);
mkdirSync(partsDir(fid), { recursive: true });
log('INFO', `Upload init: ${name} (${(size/1048576).toFixed(2)} MB, ${Math.ceil(size/UP_CHUNK)} chunks)`);
res.json({ uploadId: fid, chunkSize: UP_CHUNK });
});
const chunkUpload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 6 * 1024 * 1024 } });
app.post('/api/upload/chunk', requireAuth, chunkUpload.single('chunk'), (req,res) => {
const { uploadId, index } = req.body;
const f = db.prepare('SELECT * FROM files WHERE id=? AND owner_id=?').get(uploadId, req.session.userId);
if (!f) return res.status(404).json({error:'Introuvable'});
if (f.status !== 'uploading') return res.status(409).json({error:'Non modifiable'});
const i = parseInt(index, 10);
const total = Math.ceil(f.size / UP_CHUNK);
if (isNaN(i) || i < 0 || i >= total) return res.status(400).json({error:'Index invalide'});
const expect = (i === total-1) ? (f.size - i * UP_CHUNK) : UP_CHUNK;
if (!req.file || req.file.size !== expect) return res.status(400).json({error:'Taille invalide'});
mkdirSync(partsDir(f.id), { recursive: true });
writeFileSync(join(partsDir(f.id), pad6(i)), req.file.buffer);
res.json({ ok: true, index: i });
});
app.get('/api/upload/status', requireAuth, (req,res) => {
const fid = req.query.id;
const f = db.prepare('SELECT id,size,status,progress FROM files WHERE id=? AND owner_id=?').get(fid, req.session.userId);
if (!f) return res.status(404).json({error:'Introuvable'});
const total = Math.ceil(f.size / UP_CHUNK);
let received = [];
try { received = readdirSync(partsDir(fid)).map(x=>parseInt(x,10)).filter(n=>!isNaN(n)); } catch(e){}
res.json({ status: f.status, progress: f.progress, total, received });
});
function assembleParts(fid) {
const f = db.prepare('SELECT size FROM files WHERE id=?').get(fid);
const total = Math.ceil(f.size / UP_CHUNK);
const dir = partsDir(fid);
const out = assembledPath(fid);
for (let i=0;i<total;i++) appendFileSync(out, readFileSync(join(dir, pad6(i))));
rmSync(dir, { recursive: true, force: true });
return out;
}
function queueProcess(fid) {
const f = db.prepare('SELECT size, owner_id FROM files WHERE id=?').get(fid);
if (!f) return;
const uname = (db.prepare('SELECT username FROM users WHERE id=?').get(f.owner_id)||{}).username || '?';
uploadQueue.add(() => processUpload(fid, assembledPath(fid), f.size, uname, f.owner_id));
}
app.post('/api/upload/complete', requireAuth, (req,res) => {
const { uploadId } = req.body;
const f = db.prepare('SELECT * FROM files WHERE id=? AND owner_id=?').get(uploadId, req.session.userId);
if (!f) return res.status(404).json({error:'Introuvable'});
if (f.status !== 'uploading') return res.status(409).json({error:'Déjà complété'});
const total = Math.ceil(f.size / UP_CHUNK);
let received = [];
try { received = readdirSync(partsDir(f.id)).map(x=>parseInt(x,10)).filter(n=>!isNaN(n)); } catch(e){}
const missing = []; for (let i=0;i<total;i++) if (!received.includes(i)) missing.push(i);
if (missing.length) return res.status(400).json({error:'Chunks manquants', missing});
try { assembleParts(f.id); } catch(e){ return res.status(500).json({error:'Erreur assemblage'}); }
db.prepare("UPDATE files SET status='processing', progress=50 WHERE id=?").run(f.id);
log('INFO', `Upload complete: ${f.name} → traitement arrière-plan`);
queueProcess(f.id);
res.json({ ok: true });
});
const upload = multer({dest:TEMP_DIR,limits:{fileSize:5 * 1024 * 1024 * 1024}});
app.post('/api/files/upload', requireAuth, requireTos, uploadLimit, upload.single('file'), async(req,res) => {
if(!req.file) return res.status(400).json({error:'Fichier manquant'});
const uid = req.session.userId; const fsize = req.file.size;
const fidParam = req.body.folder_id ? parseInt(req.body.folder_id) : null;
if (fidParam) {
const acc = canAccessFolder(uid, fidParam);
if (!acc.access || acc.perm !== 'write') {
try{unlinkSync(req.file.path);}catch(_){}
return res.status(403).json({error:'Accès refusé au dossier'});
}
}
const tr = getTransfer(uid); const remaining = DAILY_TRANSFER - tr.total;
if (remaining <= 0) { try{unlinkSync(req.file.path);}catch(_){} return res.status(429).json({error:'Quota atteint'}); }
if (fsize > remaining) { try{unlinkSync(req.file.path);}catch(_){} return res.status(413).json({error:'Quota insuffisant'}); }
const used = db.prepare("SELECT COALESCE(SUM(size),0) as t FROM files WHERE owner_id=? AND status!='error'").get(uid).t;
if (used + fsize > USER_QUOTA) { try{unlinkSync(req.file.path);}catch(_){} return res.status(413).json({error:'Quota stockage'}); }
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);
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);
const stmt=db.prepare('INSERT INTO file_chunks(file_id,chunk_index,size,encrypted_len) VALUES(?,?,?,?)');
const tx=db.transaction((arr) => {for(const c of arr)stmt.run(...c);});
const cd=[]; for(let i=0;i<cc;i++){const sz=Math.min(CHUNK_SIZE,fsize-i*CHUNK_SIZE);cd.push([fid,i,sz,sz+OVERHEAD]);} tx(cd);
log('INFO', `Upload legacy: ${req.file.originalname} (${(fsize/1048576).toFixed(2)} MB)`);
res.json({fileId:fid,status:'pending',chunkCount:cc});
uploadQueue.add(() => processUpload(fid,fpath,fsize,req.session.username,uid));
});
app.get('/s/:token', publicLinkLimit, async(req,res) => {
const tpl = embedTpl();
const serve = (status, data, og) => {
if (!tpl) return res.status(status).send('Lien invalide');
const safeOg = og || ''; const safeData = JSON.stringify(data).replace(/</g, '\u003c'); const out = tpl.replace(/__OG_META__/g, () => safeOg).replace(/__EMBED_DATA__/g, () => safeData); res.status(status).send(out);
};
const l=db.prepare('SELECT * FROM share_links WHERE token=? AND type=? AND (expires_at IS NULL OR expires_at >?)').get(req.params.token,'embed',Date.now()); if (!l) return serve(404, { error: 'Lien invalide' }); const f=db.prepare('SELECT * FROM files WHERE id=?').get(l.file_id); if (!f) return serve(404, { error: 'Fichier supprimé' }); db.prepare('UPDATE share_links SET views = views + 1 WHERE token=?').run(req.params.token); logActivity(f.owner_id, 'public_view', f.name); log('INFO',`Accès public /s/: ${f.name}`); let dt; const existing = db.prepare('SELECT token FROM share_links WHERE file_id=? AND type=? AND owner_id=? AND (expires_at IS NULL OR expires_at >?)').get(f.id,'download',l.owner_id,Date.now()); if (existing) dt = existing.token; else { dt = uuidv4(); db.prepare('INSERT INTO share_links(token,file_id,owner_id,type,expires_at) VALUES(?,?,?,?,?)').run(dt,f.id,l.owner_id,'download',Date.now()+3600000); } const og = `<meta property="og:type" content="website"><meta property="og:site_name" content="PixelDrive"><meta property="og:title" content="${esc(f.name)}"><meta property="og:description" content="${(f.size/1e6).toFixed(1)} MB"><meta property="og:image" content="${req.protocol}://${req.get('host')}/banner.png"><meta name="twitter:card" content="summary_large_image"><meta name="twitter:title" content="${esc(f.name)}"><meta name="twitter:image" content="${req.protocol}://${req.get('host')}/banner.png">`;
serve(200, { name: f.name, size: f.size, mime: f.mime, dl: '/d/' + dt }, og);
});
app.get('/d/:token', publicLinkLimit, async(req,res) => {
const l=db.prepare('SELECT * FROM share_links WHERE token=? AND type=? AND (expires_at IS NULL OR expires_at >?)').get(req.params.token,'download',Date.now());
if(!l) return res.status(404).json({error:'Invalide'});
const f=db.prepare('SELECT * FROM files WHERE id=?').get(l.file_id);
if(!f || f.status!=='ready') return res.status(404).json({error:'Indisponible'});
if (transferBlocked(f.owner_id)) return res.status(429).json({error:'Quota atteint'});
db.prepare('UPDATE share_links SET downloads = downloads + 1 WHERE token=?').run(req.params.token);
logActivity(f.owner_id, 'public_download', f.name);
log('INFO', `Téléchargement public /d/: ${f.name}`);
const mk = decryptMasterKey(db.prepare('SELECT master_key_enc FROM files WHERE id=?').get(f.id).master_key_enc);
await streamFile(res,f,mk,req.headers.range,f.owner_id);
});
app.get('/api/files/:id/thumb',requireAuth,(req,res) => { if(!UUID_RE.test(req.params.id)) return res.status(400).json({error:'ID invalide'}); const f = canAccessFile(req.session.userId, req.params.id); if(!f) return res.status(404).json({error:'Introuvable'}); const p=join(THUMB_DIR,`${req.params.id}.jpg`); if(existsSync(p))return res.sendFile(p); res.set('Content-Type','image/svg+xml'); res.send(`<svg xmlns="http://www.w3.org/2000/svg" width="256" height="144"><rect fill="#2a2a3e" width="100%" height="100%"/><text x="50%" y="50%" dominant-baseline="middle" text-anchor="middle" fill="#666" font-family="sans-serif" font-size="16">FILE</text></svg>`); });
app.get('/api/files/:id/progress',requireAuth,(req,res) => { if(!UUID_RE.test(req.params.id)) return res.status(400).json({error:'ID invalide'}); const f = canAccessFile(req.session.userId, req.params.id); if(!f) return res.status(404).json({error:'Introuvable'}); const set = sseClients.get(req.params.id); if (set && set.size >= 5) return res.status(429).json({error:'Trop de connexions'}); res.set({'Content-Type':'text/event-stream','Cache-Control':'no-cache','Connection':'keep-alive'}); res.flushHeaders(); const id=req.params.id; if(!sseClients.has(id))sseClients.set(id,new Set()); sseClients.get(id).add(res); req.on('close',()=>sseClients.get(id)?.delete(res)); });
app.get('/health',(_,res) => res.json({ok:true,cache:fileCache.size,mem:process.memoryUsage()}));
// ==========================================
// ROUTES STATIQUES & URL CLEAN (v6.1)
// ==========================================
app.use((req, res, next) => {
if (req.path.startsWith('/api') || req.path.startsWith('/s/') || req.path.startsWith('/d/')) return next();
const cleanRoutes = {
'/': 'welcome.html',
'/welcome': 'welcome.html',
'/panel': 'index.html',
'/app': 'index.html',
'/login': 'index.html',
'/tos': 'tos.html',
'/status': 'status.html',
'/embed': 'embed.html'
};
if (cleanRoutes[req.path]) {
return res.sendFile(join(PUBLIC_DIR, cleanRoutes[req.path]));
}
next();
});
app.use(express.static(PUBLIC_DIR, { dotfiles: 'deny', index: false }));
app.get('/manifest.json', (req,res) => res.sendFile(join(__dirname, 'manifest.json')));
app.get('*', (req, res) => {
if (/\.[a-zA-Z0-9]+$/.test(req.path)) return res.status(404).send('Not Found');
res.sendFile(join(PUBLIC_DIR, 'welcome.html'));
});
app.use((err, req, res, next) => { if (err.type === 'entity.parse.failed' || err instanceof SyntaxError) return res.status(400).json({ error: 'Bad Request' }); log('ERROR',`Server: ${err.message}`); res.status(500).json({ error: 'Internal Server Error' }); });
function recoverStuckUploads() {
const stuck = db.prepare("SELECT id, size, status FROM files WHERE status IN ('uploading','processing')").all();
for (const f of stuck) {
try {
const assembled = assembledPath(f.id);
if (existsSync(assembled)) { queueProcess(f.id); log('INFO', `Recovery: ${f.id} re-file`); continue; }
const total = Math.ceil(f.size / UP_CHUNK);
let n = 0; try { n = readdirSync(partsDir(f.id)).length; } catch(e){}
if (total > 0 && n >= total) { assembleParts(f.id); queueProcess(f.id); log('INFO', `Recovery: ${f.id} assemblé`); }
} catch(e) {}
}
}
recoverStuckUploads();
const server = app.listen(PORT, '0.0.0.0', () => log('INFO', `PixelDrive v6.1 démarré sur ${PORT}`));
process.on('SIGTERM', () => { log('WARN', 'SIGTERM reçu, arrêt...'); server.close(() => process.exit(0)); });