//PIXELDRIVE - Single File Backend + Frontend (Node 22+) // ============================================================ import express from 'express'; import session from 'express-session'; import multer from 'multer'; import Database from 'better-sqlite3'; import { PNG } from 'pngjs'; import { ZstdCompressor, ZstdDecompressor } from '@bokuweb/zstd-wasm'; import Bottleneck from 'bottleneck'; import { randomBytes, createHash, scrypt, timingSafeEqual } from 'crypto'; import { WebSocketServer } from 'ws'; // Pour progression temps réel (optionnel) import { fileURLToPath } from 'url'; import { dirname, resolve } from 'path'; import { v4 as uuidv4 } from 'uuid'; // --- CONFIG --- const __dirname = dirname(fileURLToPath(import.meta.url)); const DATA_DIR = process.env.DATA_DIR || '/data'; const DB_PATH = `${DATA_DIR}/pixeldrive.db`; const IMGBB_API_KEY = process.env.IMGBB_API_KEY; const PORT = 7860; const CHUNK_TARGET_SIZE = 31 * 1024 * 1024; // 31 Mo (marge sous 32Mo IMGBB) const PNG_PIXEL_FORMAT = 'rgba'; // 4 octets/pixel (alignement 32bits + place ECC future) const BYTES_PER_PIXEL = 4; const UPLOAD_QUEUE_LIMIT = 20; // 20 req/min if (!IMGBB_API_KEY) { console.error("❌ IMGBB_API_KEY manquant !"); process.exit(1); } // --- INIT DB --- const db = new Database(DB_PATH); db.pragma('journal_mode = WAL'); db.exec(` CREATE TABLE IF NOT EXISTS users ( id INTEGER PRIMARY KEY, username TEXT UNIQUE, password_hash TEXT, salt TEXT, is_admin INTEGER DEFAULT 0 ); CREATE TABLE IF NOT EXISTS files ( id TEXT PRIMARY KEY, owner_id INTEGER, parent_id TEXT, name TEXT, mime TEXT, size INTEGER, chunk_count INTEGER, created_at INTEGER, FOREIGN KEY(owner_id) REFERENCES users(id), FOREIGN KEY(parent_id) REFERENCES files(id) ); CREATE TABLE IF NOT EXISTS chunks ( id INTEGER PRIMARY KEY, file_id TEXT, idx INTEGER, imgbb_url TEXT, imgbb_delete_url TEXT, sha256 TEXT, byte_size INTEGER, pixel_w INTEGER, pixel_h INTEGER, FOREIGN KEY(file_id) REFERENCES files(id) ON DELETE CASCADE ); CREATE INDEX IF NOT EXISTS idx_files_parent ON files(parent_id); CREATE INDEX IF NOT EXISTS idx_chunks_file ON chunks(file_id); `); // Créer admin par défaut const adminUser = process.env.ADMIN_USER || 'admin'; const adminPass = process.env.ADMIN_PASS || 'changeme'; const adminExists = db.prepare('SELECT 1 FROM users WHERE username = ?').get(adminUser); if (!adminExists) { const salt = randomBytes(16); const hash = await new Promise((res, rej) => scrypt(adminPass, salt, 32, (e, k) => e ? rej(e) : res(k))); db.prepare('INSERT INTO users (username, password_hash, salt, is_admin) VALUES (?, ?, ?, 1)') .run(adminUser, hash, salt); console.log(`✅ Admin créé: ${adminUser} / ${adminPass}`); } // --- CRYPTO HELPERS (Web Crypto API - AES-256-GCM) --- const deriveKey = (password, salt) => crypto.subtle.importKey('raw', await new Promise((r, j) => scrypt(password, salt, 32, (e, k) => e ? j(e) : r(k))), 'PBKDF2', false, ['deriveBits']) .then(k => crypto.subtle.deriveBits({ name: 'PBKDF2', salt, iterations: 210000, hash: 'SHA-256' }, k, 256)) .then(bits => crypto.subtle.importKey('raw', bits, { name: 'AES-GCM' }, false, ['encrypt', 'decrypt'])); // Simplifié pour code unique : Utilisation directe scrypt -> AES-GCM (Node crypto module est plus simple ici pour streaming) import { createCipheriv, createDecipheriv } from 'crypto'; const encryptStream = (key, iv) => createCipheriv('aes-256-gcm', key, iv); const decryptStream = (key, iv, authTag) => createDecipheriv('aes-256-gcm', key, iv).setAuthTag(authTag); // --- ZSTD INIT (WARMUP) --- const zstd = await ZstdCompressor.create(); const zstdDec = await ZstdDecompressor.create(); // --- IMGBB UPLOAD QUEUE (Rate Limit 20/min) --- const uploadLimiter = new Bottleneck({ minTime: 3000, maxConcurrent: 1 }); // 1 req / 3s = 20/min async function imgbbUpload(imageBuffer) { return uploadLimiter.schedule(async () => { const form = new FormData(); form.append('image', new Blob([imageBuffer], { type: 'image/png' })); form.append('expiration', '0'); // Ne pas auto-supprimer const res = await fetch(`https://api.imgbb.com/1/upload?key=${IMGBB_API_KEY}`, { method: 'POST', body: form }); const json = await res.json(); if (!json.success) throw new Error(`IMGBB: ${json.error?.message || 'Unknown'}`); return { url: json.data.url, delete_url: json.data.delete_url, width: json.data.width, height: json.data.height }; }); } async function imgbbDelete(deleteUrl) { return uploadLimiter.schedule(async () => { await fetch(deleteUrl, { method: 'GET' }); // IMGBB delete via GET sur delete_url }); } // --- PNG ENGINE --- function dataToPngBuffer(dataBuffer) { const pixelCount = Math.ceil(dataBuffer.length / BYTES_PER_PIXEL); const w = Math.ceil(Math.sqrt(pixelCount)); const h = Math.ceil(pixelCount / w); const png = new PNG({ width: w, height: h, colorType: 6, filterType: 4 }); // RGBA, Paeth filter png.data.set(dataBuffer); // Remplit R,G,B,A,R,G,B,A... // Padding auto à 0 par PNG.js return new Promise((res, rej) => { const chunks = []; png.on('data', c => chunks.push(c)); png.on('end', () => res(Buffer.concat(chunks))); png.on('error', rej); png.pack(); }); } function pngBufferToData(pngBuffer, expectedLength) { return new Promise((res, rej) => { const png = new PNG(); png.on('parsed', () => { const buf = png.data.subarray(0, expectedLength); res(buf); }); png.on('error', rej); png.parse(pngBuffer); }); } // --- EXPRESS APP --- const app = express(); app.use(express.json({ limit: '50mb' })); app.use(express.urlencoded({ extended: true, limit: '50mb' })); app.use(session({ secret: process.env.SESSION_SECRET || randomBytes(32).toString('hex'), resave: false, saveUninitialized: false, cookie: { httpOnly: true, secure: false, maxAge: 7 * 86400000, sameSite: 'lax' } })); const upload = multer({ dest: `${DATA_DIR}/tmp_uploads`, limits: { fileSize: 2 * 1024 * 1024 * 1024 } }); // 2Go max upload // Auth Middleware const requireAuth = (req, res, next) => req.session.userId ? next() : res.status(401).json({ error: 'Non connecté' }); const requireAdmin = (req, res, next) => req.session.isAdmin ? next() : res.status(403).json({ error: 'Admin requis' }); // --- API ROUTES --- // Auth app.post('/api/auth/register', async (req, res) => { const { username, password } = req.body; if (!username || !password) return res.status(400).json({ error: 'Champs manquants' }); const salt = randomBytes(16); const hash = await new Promise((r, j) => scrypt(password, salt, 32, (e, k) => e ? j(e) : r(k))); try { db.prepare('INSERT INTO users (username, password_hash, salt) VALUES (?, ?, ?)').run(username, hash, salt); res.json({ ok: true }); } catch (e) { res.status(409).json({ error: 'Utilisateur existe' }); } }); app.post('/api/auth/login', async (req, res) => { const { username, password } = req.body; const user = db.prepare('SELECT * FROM users WHERE username = ?').get(username); if (!user) return res.status(401).json({ error: 'Identifiants invalides' }); const hash = await new Promise((r, j) => scrypt(password, user.salt, 32, (e, k) => e ? j(e) : r(k))); if (!timingSafeEqual(hash, user.password_hash)) return res.status(401).json({ error: 'Identifiants invalides' }); req.session.userId = user.id; req.session.isAdmin = !!user.is_admin; req.session.username = user.username; res.json({ ok: true, user: { username: user.username, isAdmin: !!user.is_admin } }); }); app.post('/api/auth/logout', (req, res) => req.session.destroy(() => res.json({ ok: true }))); app.get('/api/auth/me', (req, res) => req.session.userId ? res.json({ user: { username: req.session.username, isAdmin: req.session.isAdmin }}) : res.status(401).json({ error: 'Non connecté' })); // Drive: List app.get('/api/fs/list', requireAuth, (req, res) => { const parentId = req.query.parent_id || null; const items = db.prepare('SELECT id, name, mime, size, chunk_count, created_at, parent_id FROM files WHERE owner_id = ? AND (parent_id IS ? OR parent_id = ?) ORDER BY name') .all(req.session.userId, parentId, parentId); res.json({ items }); }); // Drive: Mkdir app.post('/api/fs/mkdir', requireAuth, (req, res) => { const { name, parent_id } = req.body; if (!name) return res.status(400).json({ error: 'Nom requis' }); const id = uuidv4(); db.prepare('INSERT INTO files (id, owner_id, parent_id, name, mime, size, chunk_count) VALUES (?, ?, ?, ?, ?, 0, 0)') .run(id, req.session.userId, parent_id || null, name, 'application/vnd.pixeldrive.folder'); res.json({ ok: true, id }); }); // Drive: Upload (Streaming Pipeline) app.post('/api/fs/upload', requireAuth, upload.single('file'), async (req, res) => { if (!req.file) return res.status(400).json({ error: 'Fichier manquant' }); const parentId = req.body.parent_id || null; const originalName = req.file.originalname; const mime = req.file.mimetype || 'application/octet-stream'; const tmpPath = req.file.path; const fileId = uuidv4(); try { // 1. Créer entrée DB (statut pending) db.prepare('INSERT INTO files (id, owner_id, parent_id, name, mime, size, chunk_count) VALUES (?, ?, ?, ?, ?, ?, 0)') .run(fileId, req.session.userId, parentId, originalName, mime, req.file.size); // 2. Pipeline: Read -> Zstd -> Encrypt -> Chunk -> PNG -> IMGBB const fs = await import('fs'); const readStream = fs.createReadStream(tmpPath, { highWaterMark: 1024 * 1024 }); // 1Mo buffer // Key derivation per file (File Key) + User Master Key logic simplifié ici: 1 clé par user stockée en session ? Non, trop risqué. // STRATEGIE SIMPLE & ROBUSTE: Clé dérivée du mot de passe user + salt fichier. // Mais on a pas le MDP en session. ON VA FAIRE: Clé aléatoire par fichier, stockée chiffrée avec clé maître user ? // TROP COMPLEXE POUR 1 FICHIER. // COMPROMIS MOBILE: **Clé unique dérivée du UserID + Secret Serveur (SESSION_SECRET) + FileID**. // Permet de déchiffrer si on a accès au serveur + DB. Pas "Zero Knowledge" mais fonctionnel. const masterSecret = process.env.SESSION_SECRET; const fileKeyRaw = createHash('sha256').update(`${masterSecret}:${req.session.userId}:${fileId}`).digest(); // 32 bytes const iv = randomBytes(12); // GCM 96-bit const compressor = new zstd.simple.ZstdCompressor(3); // Level 3 rapide let chunkIdx = 0; let totalOriginalSize = 0; let pendingChunks = []; // Buffer d'accumulation pour chunking let buffer = Buffer.alloc(0); for await (const chunk of readStream) { buffer = Buffer.concat([buffer, chunk]); totalOriginalSize += chunk.length; while (buffer.length >= CHUNK_TARGET_SIZE) { const chunkData = buffer.subarray(0, CHUNK_TARGET_SIZE); buffer = buffer.subarray(CHUNK_TARGET_SIZE); await processAndUploadChunk(chunkData, chunkIdx++); } } // Dernier chunk if (buffer.length > 0) await processAndUploadChunk(buffer, chunkIdx); // Maj DB Final db.prepare('UPDATE files SET size = ?, chunk_count = ? WHERE id = ?').run(totalOriginalSize, chunkIdx, fileId); res.json({ ok: true, fileId, chunks: chunkIdx }); } catch (e) { console.error("Upload Error:", e); // Cleanup IMGBB si erreur partielle ? Trop complexe ici. Log only. res.status(500).json({ error: e.message }); } finally { fs.unlink(tmpPath).catch(()=>{}); } // --- Fonction interne Upload Chunk --- async function processAndUploadChunk(rawChunk, idx) { // 1. Compress const compressed = compressor.compress(new Uint8Array(rawChunk)); // 2. Encrypt const cipher = createCipheriv('aes-256-gcm', fileKeyRaw, iv); const encrypted = Buffer.concat([cipher.update(compressed), cipher.final()]); const authTag = cipher.getAuthTag(); const payload = Buffer.concat([iv, authTag, encrypted]); // IV(12) + TAG(16) + DATA // 3. PNG Encode const pngBuf = await dataToPngBuffer(payload); // 4. IMGBB Upload const { url, delete_url, width, height } = await imgbbUpload(pngBuf); // 5. Hash pour vérif const sha = createHash('sha256').update(payload).digest('hex'); // 6. Save Chunk Meta db.prepare('INSERT INTO chunks (file_id, idx, imgbb_url, imgbb_delete_url, sha256, byte_size, pixel_w, pixel_h) VALUES (?, ?, ?, ?, ?, ?, ?, ?)') .run(fileId, idx, url, delete_url, sha, payload.length, width, height); } }); // Drive: Download (Reconstruct) app.get('/api/fs/download/:fileId', requireAuth, async (req, res) => { const file = db.prepare('SELECT * FROM files WHERE id = ? AND owner_id = ?').get(req.params.fileId, req.session.userId); if (!file) return res.status(404).json({ error: 'Introuvable' }); if (file.mime === 'application/vnd.pixeldrive.folder') return res.status(400).json({ error: 'Est un dossier' }); const chunks = db.prepare('SELECT * FROM chunks WHERE file_id = ? ORDER BY idx').all(file.id); if (chunks.length === 0) return res.status(404).json({ error: 'Aucun chunk' }); // Key derivation (Même logique qu'upload) const masterSecret = process.env.SESSION_SECRET; const fileKeyRaw = createHash('sha256').update(`${masterSecret}:${req.session.userId}:${file.id}`).digest(); res.setHeader('Content-Disposition', `attachment; filename="${encodeURIComponent(file.name)}"`); res.setHeader('Content-Type', file.mime); res.setHeader('Content-Length', file.size); // Taille originale const decompressor = new zstd.simple.ZstdDecompressor(); for (const ch of chunks) { try { // 1. Fetch Image const imgRes = await fetch(ch.imgbb_url); if (!imgRes.ok) throw new Error(`DL IMGBB failed: ${imgRes.status}`); const pngBuf = Buffer.from(await imgRes.arrayBuffer()); // 2. Decode PNG const payload = await pngBufferToData(pngBuf, ch.byte_size); // 3. Verify Hash const sha = createHash('sha256').update(payload).digest('hex'); if (sha !== ch.sha256) throw new Error(`Hash mismatch chunk ${ch.idx}`); // 4. Decrypt const iv = payload.subarray(0, 12); const authTag = payload.subarray(12, 28); const encrypted = payload.subarray(28); const decipher = createDecipheriv('aes-256-gcm', fileKeyRaw, iv); decipher.setAuthTag(authTag); const compressed = Buffer.concat([decipher.update(encrypted), decipher.final()]); // 5. Decompress & Stream const original = decompressor.decompress(new Uint8Array(compressed)); res.write(Buffer.from(original)); } catch (e) { console.error(`Chunk ${ch.idx} error:`, e); return res.destroy(new Error(`Corruption chunk ${ch.idx}`)); } } res.end(); }); // Drive: Preview / Embed (Streaming direct pour Video/Image) app.get('/api/fs/preview/:fileId', requireAuth, async (req, res) => { const file = db.prepare('SELECT * FROM files WHERE id = ? AND owner_id = ?').get(req.params.fileId, req.session.userId); if (!file) return res.status(404).send('Not found'); // Seuls types embeddables const embeddable = ['image/', 'video/', 'audio/', 'application/pdf']; if (!embeddable.some(t => file.mime.startsWith(t))) return res.status(400).send('Non prévisualisable'); // Si image unique (1 chunk) -> Redirection directe IMGBB (Cache navigateur) if (file.chunk_count === 1 && file.mime.startsWith('image/')) { const ch = db.prepare('SELECT imgbb_url FROM chunks WHERE file_id = ?').get(file.id); if (ch) return res.redirect(ch.imgbb_url); } // Sinon Streaming reconstruit (Range requests pour vidéo) // NOTE: Implémentation complète Range Request complexe ici. // On fait simple: stream complet (OK pour images, lent pour grosses vidéos seek). // Pour VRAI streaming vidéo, il faut un worker qui reconstruit les chunks demandés. // ICI ON FAIT LE MINIMUM: Stream complet. req.url = `/api/fs/download/${file.id}`; // Hack interne app._router.handle(req, res); // Réutilise download logic }); // Drive: Delete (Cascade IMGBB + DB) app.delete('/api/fs/:fileId', requireAuth, async (req, res) => { const file = db.prepare('SELECT * FROM files WHERE id = ? AND owner_id = ?').get(req.params.fileId, req.session.userId); if (!file) return res.status(404).json({ error: 'Introuvable' }); const chunks = db.prepare('SELECT imgbb_delete_url FROM chunks WHERE file_id = ?').all(file.id); // Supprimer IMGBB (Background, ne pas bloquer) chunks.forEach(ch => { if (ch.imgbb_delete_url) imgbbDelete(ch.imgbb_delete_url).catch(console.error); }); // Supprimer DB (CASCADE supprime chunks) db.prepare('DELETE FROM files WHERE id = ?').run(file.id); res.json({ ok: true, deletedChunks: chunks.length }); }); // Admin: Stats app.get('/api/admin/stats', requireAuth, requireAdmin, (req, res) => { const users = db.prepare('SELECT COUNT(*) as c FROM users').get().c; const files = db.prepare('SELECT COUNT(*) as c, SUM(size) as s FROM files WHERE mime != ?').get('application/vnd.pixeldrive.folder'); const chunks = db.prepare('SELECT COUNT(*) as c FROM chunks').get().c; res.json({ users, files: files.c, totalSize: files.s || 0, chunks: chunks.c }); }); // --- FRONTEND SPA (Servi sur /) --- const HTML = ` PixelDrive

💾 PixelDrive

Connexion / Inscription

`; app.get('/', (req, res) => res.type('html').send(HTML)); app.use(express.static('public', { maxAge: '1h' })); // Au cas où tu ajoutes des assets // --- START --- const server = app.listen(PORT, '0.0.0.0', () => console.log(`🚀 PixelDrive running on http://0.0.0.0:${PORT}`)); // Graceful shutdown process.on('SIGTERM', () => { console.log('SIGTERM received'); server.close(() => { db.close(); process.exit(0); }); });