NathMen12 commited on
Commit
3e46d43
·
verified ·
1 Parent(s): c5d1a78

Update server.js

Browse files
Files changed (1) hide show
  1. server.js +207 -211
server.js CHANGED
@@ -1,14 +1,17 @@
1
- //PIXELDRIVE - Single File Backend + Frontend (Node 22+)
2
  // ============================================================
 
 
 
 
 
3
  import express from 'express';
4
  import session from 'express-session';
5
  import multer from 'multer';
6
  import Database from 'better-sqlite3';
7
  import { PNG } from 'pngjs';
8
- import { ZstdCompressor, ZstdDecompressor } from '@bokuweb/zstd-wasm';
9
  import Bottleneck from 'bottleneck';
10
- import { randomBytes, createHash, scrypt, timingSafeEqual } from 'crypto';
11
- import { WebSocketServer } from 'ws'; // Pour progression temps réel (optionnel)
12
  import { fileURLToPath } from 'url';
13
  import { dirname, resolve } from 'path';
14
  import { v4 as uuidv4 } from 'uuid';
@@ -18,24 +21,26 @@ const __dirname = dirname(fileURLToPath(import.meta.url));
18
  const DATA_DIR = process.env.DATA_DIR || '/data';
19
  const DB_PATH = `${DATA_DIR}/pixeldrive.db`;
20
  const IMGBB_API_KEY = process.env.IMGBB_API_KEY;
21
- const PORT = 7860;
22
- const CHUNK_TARGET_SIZE = 31 * 1024 * 1024; // 31 Mo (marge sous 32Mo IMGBB)
23
- const PNG_PIXEL_FORMAT = 'rgba'; // 4 octets/pixel (alignement 32bits + place ECC future)
24
- const BYTES_PER_PIXEL = 4;
25
- const UPLOAD_QUEUE_LIMIT = 20; // 20 req/min
 
26
 
27
- if (!IMGBB_API_KEY) { console.error("❌ IMGBB_API_KEY manquant !"); process.exit(1); }
28
 
29
- // --- INIT DB ---
30
  const db = new Database(DB_PATH);
31
  db.pragma('journal_mode = WAL');
 
32
  db.exec(`
33
  CREATE TABLE IF NOT EXISTS users (
34
- id INTEGER PRIMARY KEY, username TEXT UNIQUE, password_hash TEXT, salt TEXT, is_admin INTEGER DEFAULT 0
35
  );
36
  CREATE TABLE IF NOT EXISTS files (
37
  id TEXT PRIMARY KEY, owner_id INTEGER, parent_id TEXT, name TEXT, mime TEXT,
38
- size INTEGER, chunk_count INTEGER, created_at INTEGER,
39
  FOREIGN KEY(owner_id) REFERENCES users(id), FOREIGN KEY(parent_id) REFERENCES files(id)
40
  );
41
  CREATE TABLE IF NOT EXISTS chunks (
@@ -47,62 +52,31 @@ db.exec(`
47
  CREATE INDEX IF NOT EXISTS idx_chunks_file ON chunks(file_id);
48
  `);
49
 
50
- // Créer admin par défaut
51
- const adminUser = process.env.ADMIN_USER || 'admin';
52
- const adminPass = process.env.ADMIN_PASS || 'changeme';
53
- const adminExists = db.prepare('SELECT 1 FROM users WHERE username = ?').get(adminUser);
54
  if (!adminExists) {
55
  const salt = randomBytes(16);
56
- const hash = await new Promise((res, rej) => scrypt(adminPass, salt, 32, (e, k) => e ? rej(e) : res(k)));
57
- db.prepare('INSERT INTO users (username, password_hash, salt, is_admin) VALUES (?, ?, ?, 1)')
58
- .run(adminUser, hash, salt);
59
- console.log(`✅ Admin créé: ${adminUser} / ${adminPass}`);
60
  }
61
 
62
- // --- CRYPTO HELPERS (Web Crypto API - AES-256-GCM) ---
63
- const deriveKey = (password, salt) =>
64
- crypto.subtle.importKey('raw', await new Promise((r, j) => scrypt(password, salt, 32, (e, k) => e ? j(e) : r(k))), 'PBKDF2', false, ['deriveBits'])
65
- .then(k => crypto.subtle.deriveBits({ name: 'PBKDF2', salt, iterations: 210000, hash: 'SHA-256' }, k, 256))
66
- .then(bits => crypto.subtle.importKey('raw', bits, { name: 'AES-GCM' }, false, ['encrypt', 'decrypt']));
67
-
68
- // Simplifié pour code unique : Utilisation directe scrypt -> AES-GCM (Node crypto module est plus simple ici pour streaming)
69
- import { createCipheriv, createDecipheriv } from 'crypto';
70
- const encryptStream = (key, iv) => createCipheriv('aes-256-gcm', key, iv);
71
- const decryptStream = (key, iv, authTag) => createDecipheriv('aes-256-gcm', key, iv).setAuthTag(authTag);
72
-
73
- // --- ZSTD INIT (WARMUP) ---
74
- const zstd = await ZstdCompressor.create();
75
- const zstdDec = await ZstdDecompressor.create();
76
-
77
- // --- IMGBB UPLOAD QUEUE (Rate Limit 20/min) ---
78
- const uploadLimiter = new Bottleneck({ minTime: 3000, maxConcurrent: 1 }); // 1 req / 3s = 20/min
79
 
80
- async function imgbbUpload(imageBuffer) {
81
- return uploadLimiter.schedule(async () => {
82
- const form = new FormData();
83
- form.append('image', new Blob([imageBuffer], { type: 'image/png' }));
84
- form.append('expiration', '0'); // Ne pas auto-supprimer
85
- const res = await fetch(`https://api.imgbb.com/1/upload?key=${IMGBB_API_KEY}`, { method: 'POST', body: form });
86
- const json = await res.json();
87
- if (!json.success) throw new Error(`IMGBB: ${json.error?.message || 'Unknown'}`);
88
- return { url: json.data.url, delete_url: json.data.delete_url, width: json.data.width, height: json.data.height };
89
- });
90
  }
91
 
92
- async function imgbbDelete(deleteUrl) {
93
- return uploadLimiter.schedule(async () => {
94
- await fetch(deleteUrl, { method: 'GET' }); // IMGBB delete via GET sur delete_url
95
- });
96
- }
97
-
98
- // --- PNG ENGINE ---
99
  function dataToPngBuffer(dataBuffer) {
100
- const pixelCount = Math.ceil(dataBuffer.length / BYTES_PER_PIXEL);
101
  const w = Math.ceil(Math.sqrt(pixelCount));
102
  const h = Math.ceil(pixelCount / w);
103
- const png = new PNG({ width: w, height: h, colorType: 6, filterType: 4 }); // RGBA, Paeth filter
104
- png.data.set(dataBuffer); // Remplit R,G,B,A,R,G,B,A...
105
- // Padding auto à 0 par PNG.js
106
  return new Promise((res, rej) => {
107
  const chunks = [];
108
  png.on('data', c => chunks.push(c));
@@ -115,49 +89,69 @@ function dataToPngBuffer(dataBuffer) {
115
  function pngBufferToData(pngBuffer, expectedLength) {
116
  return new Promise((res, rej) => {
117
  const png = new PNG();
118
- png.on('parsed', () => {
119
- const buf = png.data.subarray(0, expectedLength);
120
- res(buf);
121
- });
122
  png.on('error', rej);
123
  png.parse(pngBuffer);
124
  });
125
  }
126
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
127
  // --- EXPRESS APP ---
128
  const app = express();
129
  app.use(express.json({ limit: '50mb' }));
130
  app.use(express.urlencoded({ extended: true, limit: '50mb' }));
131
  app.use(session({
132
- secret: process.env.SESSION_SECRET || randomBytes(32).toString('hex'),
133
- resave: false, saveUninitialized: false,
134
  cookie: { httpOnly: true, secure: false, maxAge: 7 * 86400000, sameSite: 'lax' }
135
  }));
136
- const upload = multer({ dest: `${DATA_DIR}/tmp_uploads`, limits: { fileSize: 2 * 1024 * 1024 * 1024 } }); // 2Go max upload
137
 
138
- // Auth Middleware
 
 
 
139
  const requireAuth = (req, res, next) => req.session.userId ? next() : res.status(401).json({ error: 'Non connecté' });
140
  const requireAdmin = (req, res, next) => req.session.isAdmin ? next() : res.status(403).json({ error: 'Admin requis' });
141
 
142
  // --- API ROUTES ---
143
 
144
- // Auth
145
  app.post('/api/auth/register', async (req, res) => {
146
  const { username, password } = req.body;
147
  if (!username || !password) return res.status(400).json({ error: 'Champs manquants' });
148
- const salt = randomBytes(16);
149
- const hash = await new Promise((r, j) => scrypt(password, salt, 32, (e, k) => e ? j(e) : r(k)));
150
  try {
 
 
151
  db.prepare('INSERT INTO users (username, password_hash, salt) VALUES (?, ?, ?)').run(username, hash, salt);
152
  res.json({ ok: true });
153
- } catch (e) { res.status(409).json({ error: 'Utilisateur existe' }); }
154
  });
155
 
156
  app.post('/api/auth/login', async (req, res) => {
157
  const { username, password } = req.body;
158
  const user = db.prepare('SELECT * FROM users WHERE username = ?').get(username);
159
  if (!user) return res.status(401).json({ error: 'Identifiants invalides' });
160
- const hash = await new Promise((r, j) => scrypt(password, user.salt, 32, (e, k) => e ? j(e) : r(k)));
161
  if (!timingSafeEqual(hash, user.password_hash)) return res.status(401).json({ error: 'Identifiants invalides' });
162
  req.session.userId = user.id; req.session.isAdmin = !!user.is_admin; req.session.username = user.username;
163
  res.json({ ok: true, user: { username: user.username, isAdmin: !!user.is_admin } });
@@ -166,63 +160,51 @@ app.post('/api/auth/login', async (req, res) => {
166
  app.post('/api/auth/logout', (req, res) => req.session.destroy(() => res.json({ ok: true })));
167
  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é' }));
168
 
169
- // Drive: List
170
  app.get('/api/fs/list', requireAuth, (req, res) => {
171
  const parentId = req.query.parent_id || null;
172
- 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')
173
- .all(req.session.userId, parentId, parentId);
174
  res.json({ items });
175
  });
176
 
177
- // Drive: Mkdir
178
  app.post('/api/fs/mkdir', requireAuth, (req, res) => {
179
  const { name, parent_id } = req.body;
180
- if (!name) return res.status(400).json({ error: 'Nom requis' });
181
  const id = uuidv4();
182
- db.prepare('INSERT INTO files (id, owner_id, parent_id, name, mime, size, chunk_count) VALUES (?, ?, ?, ?, ?, 0, 0)')
183
  .run(id, req.session.userId, parent_id || null, name, 'application/vnd.pixeldrive.folder');
184
  res.json({ ok: true, id });
185
  });
186
 
187
- // Drive: Upload (Streaming Pipeline)
188
  app.post('/api/fs/upload', requireAuth, upload.single('file'), async (req, res) => {
189
  if (!req.file) return res.status(400).json({ error: 'Fichier manquant' });
 
190
  const parentId = req.body.parent_id || null;
191
  const originalName = req.file.originalname;
192
  const mime = req.file.mimetype || 'application/octet-stream';
193
  const tmpPath = req.file.path;
194
  const fileId = uuidv4();
 
 
195
 
196
  try {
197
- // 1. Créer entrée DB (statut pending)
198
- db.prepare('INSERT INTO files (id, owner_id, parent_id, name, mime, size, chunk_count) VALUES (?, ?, ?, ?, ?, ?, 0)')
199
- .run(fileId, req.session.userId, parentId, originalName, mime, req.file.size);
200
 
201
- // 2. Pipeline: Read -> Zstd -> Encrypt -> Chunk -> PNG -> IMGBB
202
  const fs = await import('fs');
203
- const readStream = fs.createReadStream(tmpPath, { highWaterMark: 1024 * 1024 }); // 1Mo buffer
204
-
205
- // Key derivation per file (File Key) + User Master Key logic simplifié ici: 1 clé par user stockée en session ? Non, trop risqué.
206
- // STRATEGIE SIMPLE & ROBUSTE: Clé dérivée du mot de passe user + salt fichier.
207
- // 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 ?
208
- // TROP COMPLEXE POUR 1 FICHIER.
209
- // COMPROMIS MOBILE: **Clé unique dérivée du UserID + Secret Serveur (SESSION_SECRET) + FileID**.
210
- // Permet de déchiffrer si on a accès au serveur + DB. Pas "Zero Knowledge" mais fonctionnel.
211
- const masterSecret = process.env.SESSION_SECRET;
212
- const fileKeyRaw = createHash('sha256').update(`${masterSecret}:${req.session.userId}:${fileId}`).digest(); // 32 bytes
213
- const iv = randomBytes(12); // GCM 96-bit
214
-
215
- const compressor = new zstd.simple.ZstdCompressor(3); // Level 3 rapide
216
- let chunkIdx = 0;
217
- let totalOriginalSize = 0;
218
- let pendingChunks = [];
219
-
220
- // Buffer d'accumulation pour chunking
221
  let buffer = Buffer.alloc(0);
 
 
222
 
223
  for await (const chunk of readStream) {
224
  buffer = Buffer.concat([buffer, chunk]);
225
- totalOriginalSize += chunk.length;
226
 
227
  while (buffer.length >= CHUNK_TARGET_SIZE) {
228
  const chunkData = buffer.subarray(0, CHUNK_TARGET_SIZE);
@@ -230,138 +212,157 @@ app.post('/api/fs/upload', requireAuth, upload.single('file'), async (req, res)
230
  await processAndUploadChunk(chunkData, chunkIdx++);
231
  }
232
  }
233
- // Dernier chunk
234
  if (buffer.length > 0) await processAndUploadChunk(buffer, chunkIdx);
235
 
236
- // Maj DB Final
237
- db.prepare('UPDATE files SET size = ?, chunk_count = ? WHERE id = ?').run(totalOriginalSize, chunkIdx, fileId);
238
- res.json({ ok: true, fileId, chunks: chunkIdx });
239
 
240
  } catch (e) {
241
- console.error("Upload Error:", e);
242
- // Cleanup IMGBB si erreur partielle ? Trop complexe ici. Log only.
243
  res.status(500).json({ error: e.message });
244
  } finally {
245
- fs.unlink(tmpPath).catch(()=>{});
 
246
  }
247
 
248
- // --- Fonction interne Upload Chunk ---
249
  async function processAndUploadChunk(rawChunk, idx) {
250
- // 1. Compress
251
- const compressed = compressor.compress(new Uint8Array(rawChunk));
252
- // 2. Encrypt
253
- const cipher = createCipheriv('aes-256-gcm', fileKeyRaw, iv);
254
- const encrypted = Buffer.concat([cipher.update(compressed), cipher.final()]);
255
  const authTag = cipher.getAuthTag();
256
- const payload = Buffer.concat([iv, authTag, encrypted]); // IV(12) + TAG(16) + DATA
 
 
257
 
258
- // 3. PNG Encode
259
  const pngBuf = await dataToPngBuffer(payload);
260
- // 4. IMGBB Upload
 
261
  const { url, delete_url, width, height } = await imgbbUpload(pngBuf);
262
- // 5. Hash pour vérif
 
263
  const sha = createHash('sha256').update(payload).digest('hex');
264
- // 6. Save Chunk Meta
 
265
  db.prepare('INSERT INTO chunks (file_id, idx, imgbb_url, imgbb_delete_url, sha256, byte_size, pixel_w, pixel_h) VALUES (?, ?, ?, ?, ?, ?, ?, ?)')
266
  .run(fileId, idx, url, delete_url, sha, payload.length, width, height);
267
  }
268
  });
269
 
270
- // Drive: Download (Reconstruct)
271
  app.get('/api/fs/download/:fileId', requireAuth, async (req, res) => {
272
  const file = db.prepare('SELECT * FROM files WHERE id = ? AND owner_id = ?').get(req.params.fileId, req.session.userId);
273
  if (!file) return res.status(404).json({ error: 'Introuvable' });
274
  if (file.mime === 'application/vnd.pixeldrive.folder') return res.status(400).json({ error: 'Est un dossier' });
275
 
276
  const chunks = db.prepare('SELECT * FROM chunks WHERE file_id = ? ORDER BY idx').all(file.id);
277
- if (chunks.length === 0) return res.status(404).json({ error: 'Aucun chunk' });
278
 
279
- // Key derivation (Même logique qu'upload)
280
- const masterSecret = process.env.SESSION_SECRET;
281
- const fileKeyRaw = createHash('sha256').update(`${masterSecret}:${req.session.userId}:${file.id}`).digest();
282
 
283
  res.setHeader('Content-Disposition', `attachment; filename="${encodeURIComponent(file.name)}"`);
284
  res.setHeader('Content-Type', file.mime);
285
- res.setHeader('Content-Length', file.size); // Taille originale
286
-
287
- const decompressor = new zstd.simple.ZstdDecompressor();
288
 
289
  for (const ch of chunks) {
290
  try {
291
- // 1. Fetch Image
292
  const imgRes = await fetch(ch.imgbb_url);
293
- if (!imgRes.ok) throw new Error(`DL IMGBB failed: ${imgRes.status}`);
294
  const pngBuf = Buffer.from(await imgRes.arrayBuffer());
295
 
296
  // 2. Decode PNG
297
  const payload = await pngBufferToData(pngBuf, ch.byte_size);
298
-
299
- // 3. Verify Hash
300
  const sha = createHash('sha256').update(payload).digest('hex');
301
- if (sha !== ch.sha256) throw new Error(`Hash mismatch chunk ${ch.idx}`);
302
 
303
  // 4. Decrypt
304
  const iv = payload.subarray(0, 12);
305
  const authTag = payload.subarray(12, 28);
306
  const encrypted = payload.subarray(28);
307
- const decipher = createDecipheriv('aes-256-gcm', fileKeyRaw, iv);
 
308
  decipher.setAuthTag(authTag);
309
- const compressed = Buffer.concat([decipher.update(encrypted), decipher.final()]);
310
 
311
- // 5. Decompress & Stream
312
- const original = decompressor.decompress(new Uint8Array(compressed));
313
- res.write(Buffer.from(original));
314
 
315
  } catch (e) {
316
- console.error(`Chunk ${ch.idx} error:`, e);
317
  return res.destroy(new Error(`Corruption chunk ${ch.idx}`));
318
  }
319
  }
320
  res.end();
321
  });
322
 
323
- // Drive: Preview / Embed (Streaming direct pour Video/Image)
324
  app.get('/api/fs/preview/:fileId', requireAuth, async (req, res) => {
325
  const file = db.prepare('SELECT * FROM files WHERE id = ? AND owner_id = ?').get(req.params.fileId, req.session.userId);
326
  if (!file) return res.status(404).send('Not found');
327
-
328
- // Seuls types embeddables
329
  const embeddable = ['image/', 'video/', 'audio/', 'application/pdf'];
330
  if (!embeddable.some(t => file.mime.startsWith(t))) return res.status(400).send('Non prévisualisable');
331
 
332
- // Si image unique (1 chunk) -> Redirection directe IMGBB (Cache navigateur)
333
  if (file.chunk_count === 1 && file.mime.startsWith('image/')) {
334
  const ch = db.prepare('SELECT imgbb_url FROM chunks WHERE file_id = ?').get(file.id);
335
- if (ch) return res.redirect(ch.imgbb_url);
336
  }
337
 
338
- // Sinon Streaming reconstruit (Range requests pour vidéo)
339
- // NOTE: Implémentation complète Range Request complexe ici.
340
- // On fait simple: stream complet (OK pour images, lent pour grosses vidéos seek).
341
- // Pour VRAI streaming vidéo, il faut un worker qui reconstruit les chunks demandés.
342
- // ICI ON FAIT LE MINIMUM: Stream complet.
343
- req.url = `/api/fs/download/${file.id}`; // Hack interne
344
- app._router.handle(req, res); // Réutilise download logic
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
345
  });
346
 
347
- // Drive: Delete (Cascade IMGBB + DB)
348
  app.delete('/api/fs/:fileId', requireAuth, async (req, res) => {
349
  const file = db.prepare('SELECT * FROM files WHERE id = ? AND owner_id = ?').get(req.params.fileId, req.session.userId);
350
  if (!file) return res.status(404).json({ error: 'Introuvable' });
351
 
352
  const chunks = db.prepare('SELECT imgbb_delete_url FROM chunks WHERE file_id = ?').all(file.id);
353
 
354
- // Supprimer IMGBB (Background, ne pas bloquer)
355
- chunks.forEach(ch => {
356
- if (ch.imgbb_delete_url) imgbbDelete(ch.imgbb_delete_url).catch(console.error);
357
- });
358
 
359
- // Supprimer DB (CASCADE supprime chunks)
360
  db.prepare('DELETE FROM files WHERE id = ?').run(file.id);
361
  res.json({ ok: true, deletedChunks: chunks.length });
362
  });
363
 
364
- // Admin: Stats
365
  app.get('/api/admin/stats', requireAuth, requireAdmin, (req, res) => {
366
  const users = db.prepare('SELECT COUNT(*) as c FROM users').get().c;
367
  const files = db.prepare('SELECT COUNT(*) as c, SUM(size) as s FROM files WHERE mime != ?').get('application/vnd.pixeldrive.folder');
@@ -369,7 +370,7 @@ app.get('/api/admin/stats', requireAuth, requireAdmin, (req, res) => {
369
  res.json({ users, files: files.c, totalSize: files.s || 0, chunks: chunks.c });
370
  });
371
 
372
- // --- FRONTEND SPA (Servi sur /) ---
373
  const HTML = `<!DOCTYPE html>
374
  <html lang="fr"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0">
375
  <title>PixelDrive</title>
@@ -390,7 +391,7 @@ main { flex:1; padding:1.5rem; max-width:900px; width:100%; margin:0 auto; }
390
  .grid { display:grid; grid-template-columns:repeat(auto-fill, minmax(180px, 1fr)); gap:1rem; }
391
  .item { background:var(--bg); border:1px solid var(--border); border-radius:6px; padding:1rem; display:flex; flex-direction:column; gap:0.5rem; position:relative; }
392
  .item.folder { border-color:var(--accent); }
393
- .item-name { font-weight:500; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
394
  .item-meta { font-size:0.75rem; color:#8b949e; }
395
  .item-actions { display:flex; gap:0.25rem; margin-top:auto; padding-top:0.5rem; border-top:1px solid var(--border); }
396
  .modal { position:fixed; inset:0; background:#0008; display:flex; align-items:center; justify-content:center; z-index:100; }
@@ -402,18 +403,18 @@ main { flex:1; padding:1.5rem; max-width:900px; width:100%; margin:0 auto; }
402
  @keyframes slide { from { transform:translateX(100%); opacity:0; } to { transform:translateX(0); opacity:1; } }
403
  .progress-bar { height:4px; background:var(--border); border-radius:2px; overflow:hidden; margin:0.5rem 0; }
404
  .progress-bar > div { height:100%; background:var(--accent); width:0%; transition:width 0.1s; }
 
405
  </style>
406
  </head><body>
407
  <header>
408
  <h1>💾 PixelDrive</h1>
409
  <div>
410
  <span id="userInfo" style="margin-right:1rem; font-size:0.9rem;"></span>
411
- <button id="btnLogout" class="danger" style="display:none;">Déconnexion</button>
412
  <button id="btnLogin" class="primary">Connexion</button>
413
  </div>
414
  </header>
415
  <main id="app">
416
- <!-- Vue Auth -->
417
  <div id="viewAuth" class="card" style="max-width:400px; margin:2rem auto;">
418
  <h2 style="margin-bottom:1rem;">Connexion / Inscription</h2>
419
  <input id="authUser" placeholder="Nom d'utilisateur" style="width:100%; margin-bottom:0.5rem;">
@@ -421,67 +422,65 @@ main { flex:1; padding:1.5rem; max-width:900px; width:100%; margin:0 auto; }
421
  <button id="btnDoLogin" class="primary" style="width:100%;">Se connecter</button>
422
  <button id="btnDoRegister" style="width:100%; margin-top:0.5rem;">Créer compte</button>
423
  </div>
424
-
425
- <!-- Vue Drive (Cachée au debut) -->
426
  <div id="viewDrive" class="hidden">
427
- <div class="card" style="display:flex; gap:0.5rem; flex-wrap:wrap; margin-bottom:1rem;">
428
- <input type="file" id="fileInput" multiple>
429
  <button id="btnUpload" class="primary">Uploader</button>
430
  <input type="text" id="mkdirName" placeholder="Nom dossier" style="flex:1; min-width:150px;">
431
  <button id="btnMkdir">Créer dossier</button>
432
- <span style="flex:1;"></span>
433
- <span id="pathDisplay" style="align-self:center; color:#8b949e; font-size:0.9rem;">🏠 Racine</span>
434
  </div>
435
  <div id="progressContainer" class="card hidden">
436
- <div>Upload: <span id="progressText">0%</span></div>
437
  <div class="progress-bar"><div id="progressBar"></div></div>
438
  </div>
439
  <div id="grid" class="grid"></div>
440
  </div>
441
  </main>
442
-
443
- <!-- Modals -->
444
- <div id="modalConfirm" class="modal hidden"><div class="modal-content"><h3>Confirmer</h3><p id="modalText"></p><div style="display:flex; justify-content:flex-end; gap:0.5rem;"><button id="modalCancel">Annuler</button><button id="modalOk" class="danger">Supprimer</button></div></div></div>
445
- <div id="modalPreview" class="modal hidden"><div class="modal-content" style="max-width:90vw; max-height:90vh; padding:0;"><button style="align-self:flex-end; background:none; border:none; color:var(--fg); font-size:1.5rem; padding:0.5rem;">✕</button><div id="previewContent" style="max-height:80vh; overflow:auto;"></div></div></div>
446
-
447
  <div id="toastContainer"></div>
448
 
449
  <script>
450
- const API = '';
451
- let currentParentId = null;
452
- const pathStack = [{id: null, name: 'Racine'}];
453
-
454
- // Utils
455
- const $ = (sel) => document.querySelector(sel);
456
- const $$ = (sel) => document.querySelectorAll(sel);
457
  const show = (el) => el.classList.remove('hidden');
458
  const hide = (el) => el.classList.add('hidden');
459
  const toast = (msg, type='') => { const t=document.createElement('div'); t.className='toast '+type; t.textContent=msg; $('#toastContainer').append(t); setTimeout(()=>t.remove(), 3000); };
460
- const req = async (url, opts={}) => { const r = await fetch(API+url, {credentials:'include', headers:{'Content-Type':'application/json', ...(opts.headers||{})}, ...opts}); if(!r.ok){ const e=await r.json().catch(()=>({error:r.statusText})); throw new Error(e.error||r.status); } return r.json(); };
461
- const reqForm = async (url, formData) => { const r = await fetch(API+url, {method:'POST', credentials:'include', body:formData}); if(!r.ok) throw new Error((await r.json()).error||r.status); return r.json(); };
 
 
 
 
 
462
 
463
  // Auth UI
464
  $('#btnLogin').onclick = () => { hide($('#viewDrive')); show($('#viewAuth')); };
465
- $('#btnLogout').onclick = async () => { await req('/api/auth/logout', {method:'POST'}); location.reload(); };
466
- $('#btnDoLogin').onclick = async () => { try{ await req('/api/auth/login', {method:'POST', body:JSON.stringify({username:$('#authUser').value, password:$('#authPass').value})}); toast('Connecté','success'); initDrive(); }catch(e){ toast(e.message,'error'); }};
467
- $('#btnDoRegister').onclick = async () => { try{ await req('/api/auth/register', {method:'POST', body:JSON.stringify({username:$('#authUser').value, password:$('#authPass').value})}); toast('Compte créé, connectez-vous','success'); }catch(e){ toast(e.message,'error'); }};
468
 
469
  // Drive Init
470
  async function initDrive() {
471
- const me = await req('/api/auth/me');
472
  $('#userInfo').textContent = me.user.username + (me.user.isAdmin ? ' 👑' : '');
473
  hide($('#viewAuth')); hide($('#btnLogin')); show($('#btnLogout')); show($('#viewDrive'));
474
  loadFolder(null);
475
  }
476
 
477
- // Folder Navigation
478
  function updatePath() { $('#pathDisplay').textContent = '🏠 ' + pathStack.map(p=>p.name).join(' / '); }
 
 
479
  async function loadFolder(parentId) {
480
  currentParentId = parentId;
481
  updatePath();
482
- const {items} = await req('/api/fs/list?parent_id=' + (parentId || ''));
483
  renderGrid(items);
484
  }
 
485
  function renderGrid(items) {
486
  const grid = $('#grid'); grid.innerHTML = '';
487
  items.forEach(item => {
@@ -489,14 +488,13 @@ function renderGrid(items) {
489
  div.className = 'item' + (item.mime === 'application/vnd.pixeldrive.folder' ? ' folder' : '');
490
  div.innerHTML = \`
491
  <div class="item-name" title="\${item.name}">\${item.mime === 'application/vnd.pixeldrive.folder' ? '📁' : '📄'} \${item.name}</div>
492
- <div class="item-meta">\${item.mime !== 'application/vnd.pixeldrive.folder' ? formatBytes(item.size) + ' • ' + item.chunk_count + ' chunks' : ''}\${new Date(item.created_at).toLocaleString()}</div>
493
  <div class="item-actions">
494
  \${item.mime !== 'application/vnd.pixeldrive.folder' ? \`<button class="btn-preview" title="Voir/Embed">👁</button>\` : ''}
495
  \${item.mime !== 'application/vnd.pixeldrive.folder' ? \`<button class="btn-download" title="Télécharger">⬇</button>\` : ''}
496
  <button class="btn-delete danger" title="Supprimer">🗑</button>
497
  </div>
498
  \`;
499
- // Events
500
  if(item.mime === 'application/vnd.pixeldrive.folder') {
501
  div.querySelector('.item-name').onclick = () => { pathStack.push(item); loadFolder(item.id); };
502
  } else {
@@ -506,65 +504,63 @@ function renderGrid(items) {
506
  div.querySelector('.btn-delete').onclick = () => confirmDelete(item.id, item.name, item.mime === 'application/vnd.pixeldrive.folder');
507
  grid.appendChild(div);
508
  });
509
- // Breadcrumb click
510
- $('#pathDisplay').onclick = (e) => { if(e.target.tagName==='SPAN'){} }; // Pas simple sans router, on met bouton "Retour"
511
  }
512
 
513
- // Upload
514
- $('#btnMkdir').onclick = async () => { const n=$('#mkdirName').value.trim(); if(!n) return; try{ await req('/api/fs/mkdir', {method:'POST', body:JSON.stringify({name:n, parent_id:currentParentId})}); toast('Dossier créé','success'); $('#mkdirName').value=''; loadFolder(currentParentId); }catch(e){ toast(e.message,'error'); }};
 
 
515
  $('#btnUpload').onclick = () => $('#fileInput').click();
516
  $('#fileInput').onchange = async (e) => {
517
- const files = e.target.files; if(!files.length) return;
518
  hide($('#btnUpload')); show($('#progressContainer'));
519
  for(const file of files) {
 
520
  const form = new FormData(); form.append('file', file); form.append('parent_id', currentParentId || '');
521
  try {
522
- // XHR pour progression
523
  await new Promise((resolve, reject) => {
524
  const xhr = new XMLHttpRequest();
525
  xhr.open('POST', '/api/fs/upload', true);
526
  xhr.withCredentials = true;
527
  xhr.upload.onprogress = (ev) => { if(ev.lengthComputable) { const p=Math.round(ev.loaded/ev.total*100); $('#progressBar').style.width=p+'%'; $('#progressText').textContent=p+'%'; }};
528
  xhr.onload = () => { if(xhr.status>=200&&xhr.status<300) resolve(JSON.parse(xhr.response)); else reject(new Error(xhr.responseText)); };
529
- xhr.onerror = () => reject(new Error('Network'));
530
  xhr.send(form);
531
  });
532
- toast(\`\${file.name} uploadé\`, 'success');
533
  } catch(err) { toast(\`Erreur \${file.name}: \${err.message}\`, 'error'); }
534
  }
535
  $('#fileInput').value=''; show($('#btnUpload')); hide($('#progressContainer')); loadFolder(currentParentId);
536
  };
537
 
538
  // Delete
539
- let deleteTarget = null;
540
- function confirmDelete(id, name, isFolder) { deleteTarget = {id, isFolder}; $('#modalText').textContent = \`Supprimer \${isFolder?'le dossier':'le fichier'} "\${name}" ? \${isFolder?'(Récursif - TODO)':''}\`; show($('#modalConfirm')); }
541
  $('#modalCancel').onclick = () => hide($('#modalConfirm'));
542
- $('#modalOk').onclick = async () => { if(!deleteTarget) return; try{ await req('/api/fs/'+deleteTarget.id, {method:'DELETE'}); toast('Supprimé','success'); loadFolder(currentParentId); }catch(e){ toast(e.message,'error'); } hide($('#modalConfirm')); deleteTarget=null; };
543
 
544
  // Preview Modal
545
  function openPreview(id, mime, name) {
546
  const c = $('#previewContent'); c.innerHTML = '';
547
- if(mime.startsWith('image/')) c.innerHTML = \`<img src="/api/fs/preview/\${id}" style="max-width:100%; max-height:80vh;" alt="\${name}">\`;
548
- else if(mime.startsWith('video/') || mime.startsWith('audio/')) c.innerHTML = \`<video controls style="max-width:100%; max-height:80vh;"><source src="/api/fs/preview/\${id}" type="\${mime}"></video>\`;
 
549
  else if(mime === 'application/pdf') c.innerHTML = \`<iframe src="/api/fs/preview/\${id}" style="width:100%; height:80vh; border:none;"></iframe>\`;
550
  else c.innerHTML = \`<p style="padding:2rem; text-align:center;">Aperçu non dispo pour \${mime}</p>\`;
551
  show($('#modalPreview'));
552
  }
553
  $('#modalPreview').onclick = (e) => { if(e.target === $('#modalPreview') || e.target.tagName==='BUTTON') hide($('#modalPreview')); };
554
 
555
- // Helpers
556
- function formatBytes(b) { if(b<1024) return b+'o'; const k=1024, sizes=['o','Ko','Mo','Go','To']; const i=Math.floor(Math.log(b)/Math.log(k)); return (b/Math.pow(k,i)).toFixed(1)+' '+sizes[i]; }
557
-
558
  // Start
559
  initDrive().catch(()=>{ show($('#viewAuth')); hide($('#viewDrive')); });
560
  </script>
561
  </body></html>`;
562
 
563
  app.get('/', (req, res) => res.type('html').send(HTML));
564
- app.use(express.static('public', { maxAge: '1h' })); // Au cas où tu ajoutes des assets
565
 
566
- // --- START ---
567
  const server = app.listen(PORT, '0.0.0.0', () => console.log(`🚀 PixelDrive running on http://0.0.0.0:${PORT}`));
568
 
569
- // Graceful shutdown
570
- process.on('SIGTERM', () => { console.log('SIGTERM received'); server.close(() => { db.close(); process.exit(0); }); });
 
 
 
1
  // ============================================================
2
+ // PIXELDRIVE - Single File Backend + Frontend (Node 22+)
3
+ // Architecture: Server-Side Only Processing
4
+ // Dependencies: better-sqlite3, express, express-session, multer, pngjs, bottleneck, uuid
5
+ // ============================================================
6
+
7
  import express from 'express';
8
  import session from 'express-session';
9
  import multer from 'multer';
10
  import Database from 'better-sqlite3';
11
  import { PNG } from 'pngjs';
 
12
  import Bottleneck from 'bottleneck';
13
+ import { randomBytes, createHash, scrypt, timingSafeEqual, createCipheriv, createDecipheriv } from 'crypto';
14
+ import { promisify } from 'util';
15
  import { fileURLToPath } from 'url';
16
  import { dirname, resolve } from 'path';
17
  import { v4 as uuidv4 } from 'uuid';
 
21
  const DATA_DIR = process.env.DATA_DIR || '/data';
22
  const DB_PATH = `${DATA_DIR}/pixeldrive.db`;
23
  const IMGBB_API_KEY = process.env.IMGBB_API_KEY;
24
+ const PORT = parseInt(process.env.PORT) || 7860;
25
+ const CHUNK_TARGET_SIZE = 31 * 1024 * 1024; // 31 Mo (Marge sous limite 32Mo IMGBB)
26
+ const PNG_BYTES_PER_PIXEL = 4; // RGBA (Alignement 32-bit optimal)
27
+ const SESSION_SECRET = process.env.SESSION_SECRET || randomBytes(32).toString('hex');
28
+ const ADMIN_USER = process.env.ADMIN_USER || 'admin';
29
+ const ADMIN_PASS = process.env.ADMIN_PASS || 'changeme';
30
 
31
+ if (!IMGBB_API_KEY) { console.error("❌ ERREUR FATALE: Variable d'environnement IMGBB_API_KEY manquante !"); process.exit(1); }
32
 
33
+ // --- INIT DATABASE (SQLite WAL Mode) ---
34
  const db = new Database(DB_PATH);
35
  db.pragma('journal_mode = WAL');
36
+ db.pragma('synchronous = NORMAL');
37
  db.exec(`
38
  CREATE TABLE IF NOT EXISTS users (
39
+ id INTEGER PRIMARY KEY, username TEXT UNIQUE, password_hash BLOB, salt BLOB, is_admin INTEGER DEFAULT 0, created_at INTEGER DEFAULT (strftime('%s','now'))
40
  );
41
  CREATE TABLE IF NOT EXISTS files (
42
  id TEXT PRIMARY KEY, owner_id INTEGER, parent_id TEXT, name TEXT, mime TEXT,
43
+ size INTEGER, chunk_count INTEGER DEFAULT 0, created_at INTEGER DEFAULT (strftime('%s','now')),
44
  FOREIGN KEY(owner_id) REFERENCES users(id), FOREIGN KEY(parent_id) REFERENCES files(id)
45
  );
46
  CREATE TABLE IF NOT EXISTS chunks (
 
52
  CREATE INDEX IF NOT EXISTS idx_chunks_file ON chunks(file_id);
53
  `);
54
 
55
+ // --- CREATE DEFAULT ADMIN ---
56
+ const adminExists = db.prepare('SELECT 1 FROM users WHERE username = ?').get(ADMIN_USER);
 
 
57
  if (!adminExists) {
58
  const salt = randomBytes(16);
59
+ const hash = await scryptAsync(ADMIN_PASS, salt, 32);
60
+ db.prepare('INSERT INTO users (username, password_hash, salt, is_admin) VALUES (?, ?, ?, 1)').run(ADMIN_USER, hash, salt);
61
+ console.log(`✅ Admin créé: ${ADMIN_USER} / ${ADMIN_PASS}`);
 
62
  }
63
 
64
+ // --- CRYPTO HELPERS ---
65
+ const scryptAsync = promisify(scrypt);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
66
 
67
+ // Dérivation clé fichier : SHA256(SESSION_SECRET + UserID + FileID) -> 32 bytes
68
+ // NOTE: Pas "Zero Knowledge" (serveur connaît la clé), mais simple, robuste, sans libsodium.
69
+ function getFileKey(userId, fileId) {
70
+ return createHash('sha256').update(`${SESSION_SECRET}:${userId}:${fileId}`).digest();
 
 
 
 
 
 
71
  }
72
 
73
+ // --- PNG ENGINE (Pure JS) ---
 
 
 
 
 
 
74
  function dataToPngBuffer(dataBuffer) {
75
+ const pixelCount = Math.ceil(dataBuffer.length / PNG_BYTES_PER_PIXEL);
76
  const w = Math.ceil(Math.sqrt(pixelCount));
77
  const h = Math.ceil(pixelCount / w);
78
+ const png = new PNG({ width: w, height: h, colorType: 6, filterType: 4 }); // RGBA, Paeth Filter
79
+ png.data.set(dataBuffer); // Remplissage direct (padding auto à 0)
 
80
  return new Promise((res, rej) => {
81
  const chunks = [];
82
  png.on('data', c => chunks.push(c));
 
89
  function pngBufferToData(pngBuffer, expectedLength) {
90
  return new Promise((res, rej) => {
91
  const png = new PNG();
92
+ png.on('parsed', () => res(png.data.subarray(0, expectedLength)));
 
 
 
93
  png.on('error', rej);
94
  png.parse(pngBuffer);
95
  });
96
  }
97
 
98
+ // --- IMGBB QUEUE (Rate Limit: 20 req/min = 1 req / 3s) ---
99
+ const imgbbLimiter = new Bottleneck({ minTime: 3000, maxConcurrent: 1 });
100
+
101
+ async function imgbbUpload(imageBuffer) {
102
+ return imgbbLimiter.schedule(async () => {
103
+ const form = new FormData();
104
+ form.append('image', new Blob([imageBuffer], { type: 'image/png' }));
105
+ form.append('expiration', '0'); // Pas d'auto-suppression
106
+ const res = await fetch(`https://api.imgbb.com/1/upload?key=${IMGBB_API_KEY}`, { method: 'POST', body: form });
107
+ const json = await res.json();
108
+ if (!json.success) throw new Error(`IMGBB Upload Failed: ${json.error?.message || res.statusText}`);
109
+ return { url: json.data.url, delete_url: json.data.delete_url, width: json.data.width, height: json.data.height };
110
+ });
111
+ }
112
+
113
+ async function imgbbDelete(deleteUrl) {
114
+ if (!deleteUrl) return;
115
+ return imgbbLimiter.schedule(async () => {
116
+ try { await fetch(deleteUrl, { method: 'GET' }); } catch (e) { console.warn('IMGBB Delete failed:', e.message); }
117
+ });
118
+ }
119
+
120
  // --- EXPRESS APP ---
121
  const app = express();
122
  app.use(express.json({ limit: '50mb' }));
123
  app.use(express.urlencoded({ extended: true, limit: '50mb' }));
124
  app.use(session({
125
+ secret: SESSION_SECRET, resave: false, saveUninitialized: false,
 
126
  cookie: { httpOnly: true, secure: false, maxAge: 7 * 86400000, sameSite: 'lax' }
127
  }));
 
128
 
129
+ // Multer: Stockage temporaire sur disque (évite RAM overflow gros fichiers)
130
+ const upload = multer({ dest: `${DATA_DIR}/tmp_uploads`, limits: { fileSize: 4 * 1024 * 1024 * 1024 } }); // 4Go max
131
+
132
+ // --- MIDDLEWARES ---
133
  const requireAuth = (req, res, next) => req.session.userId ? next() : res.status(401).json({ error: 'Non connecté' });
134
  const requireAdmin = (req, res, next) => req.session.isAdmin ? next() : res.status(403).json({ error: 'Admin requis' });
135
 
136
  // --- API ROUTES ---
137
 
138
+ // AUTH
139
  app.post('/api/auth/register', async (req, res) => {
140
  const { username, password } = req.body;
141
  if (!username || !password) return res.status(400).json({ error: 'Champs manquants' });
 
 
142
  try {
143
+ const salt = randomBytes(16);
144
+ const hash = await scryptAsync(password, salt, 32);
145
  db.prepare('INSERT INTO users (username, password_hash, salt) VALUES (?, ?, ?)').run(username, hash, salt);
146
  res.json({ ok: true });
147
+ } catch (e) { res.status(409).json({ error: 'Utilisateur existe déjà' }); }
148
  });
149
 
150
  app.post('/api/auth/login', async (req, res) => {
151
  const { username, password } = req.body;
152
  const user = db.prepare('SELECT * FROM users WHERE username = ?').get(username);
153
  if (!user) return res.status(401).json({ error: 'Identifiants invalides' });
154
+ const hash = await scryptAsync(password, user.salt, 32);
155
  if (!timingSafeEqual(hash, user.password_hash)) return res.status(401).json({ error: 'Identifiants invalides' });
156
  req.session.userId = user.id; req.session.isAdmin = !!user.is_admin; req.session.username = user.username;
157
  res.json({ ok: true, user: { username: user.username, isAdmin: !!user.is_admin } });
 
160
  app.post('/api/auth/logout', (req, res) => req.session.destroy(() => res.json({ ok: true })));
161
  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é' }));
162
 
163
+ // DRIVE: LIST
164
  app.get('/api/fs/list', requireAuth, (req, res) => {
165
  const parentId = req.query.parent_id || null;
166
+ const items = db.prepare('SELECT id, name, mime, size, chunk_count, created_at FROM files WHERE owner_id = ? AND (parent_id IS ? OR parent_id = ?) ORDER BY (mime = ?) DESC, name')
167
+ .all(req.session.userId, parentId, parentId, 'application/vnd.pixeldrive.folder');
168
  res.json({ items });
169
  });
170
 
171
+ // DRIVE: MKDIR
172
  app.post('/api/fs/mkdir', requireAuth, (req, res) => {
173
  const { name, parent_id } = req.body;
174
+ if (!name || name.includes('/')) return res.status(400).json({ error: 'Nom invalide' });
175
  const id = uuidv4();
176
+ db.prepare('INSERT INTO files (id, owner_id, parent_id, name, mime) VALUES (?, ?, ?, ?, ?)')
177
  .run(id, req.session.userId, parent_id || null, name, 'application/vnd.pixeldrive.folder');
178
  res.json({ ok: true, id });
179
  });
180
 
181
+ // DRIVE: UPLOAD (Streaming Pipeline: Read -> Chunk -> Encrypt -> PNG -> IMGBB)
182
  app.post('/api/fs/upload', requireAuth, upload.single('file'), async (req, res) => {
183
  if (!req.file) return res.status(400).json({ error: 'Fichier manquant' });
184
+
185
  const parentId = req.body.parent_id || null;
186
  const originalName = req.file.originalname;
187
  const mime = req.file.mimetype || 'application/octet-stream';
188
  const tmpPath = req.file.path;
189
  const fileId = uuidv4();
190
+ const fileKey = getFileKey(req.session.userId, fileId);
191
+ const iv = randomBytes(12); // IV unique par FICHIER (réutilisé pour tous chunks de ce fichier)
192
 
193
  try {
194
+ // 1. DB Entry
195
+ db.prepare('INSERT INTO files (id, owner_id, parent_id, name, mime, size, chunk_count) VALUES (?, ?, ?, ?, ?, 0, 0)')
196
+ .run(fileId, req.session.userId, parentId, originalName, mime);
197
 
198
+ // 2. Pipeline
199
  const fs = await import('fs');
200
+ const readStream = fs.createReadStream(tmpPath, { highWaterMark: 1024 * 1024 }); // 1Mo chunks lecture
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
201
  let buffer = Buffer.alloc(0);
202
+ let totalSize = 0;
203
+ let chunkIdx = 0;
204
 
205
  for await (const chunk of readStream) {
206
  buffer = Buffer.concat([buffer, chunk]);
207
+ totalSize += chunk.length;
208
 
209
  while (buffer.length >= CHUNK_TARGET_SIZE) {
210
  const chunkData = buffer.subarray(0, CHUNK_TARGET_SIZE);
 
212
  await processAndUploadChunk(chunkData, chunkIdx++);
213
  }
214
  }
 
215
  if (buffer.length > 0) await processAndUploadChunk(buffer, chunkIdx);
216
 
217
+ // 3. Finalize DB
218
+ db.prepare('UPDATE files SET size = ?, chunk_count = ? WHERE id = ?').run(totalSize, chunkIdx, fileId);
219
+ res.json({ ok: true, fileId, chunks: chunkIdx, size: totalSize });
220
 
221
  } catch (e) {
222
+ console.error(`[UPLOAD ERROR] ${fileId}:`, e);
 
223
  res.status(500).json({ error: e.message });
224
  } finally {
225
+ // Cleanup temp file
226
+ fs.unlink(tmpPath).catch(() => {});
227
  }
228
 
229
+ // --- Internal Chunk Processor ---
230
  async function processAndUploadChunk(rawChunk, idx) {
231
+ // 1. Encrypt (AES-256-GCM)
232
+ const cipher = createCipheriv('aes-256-gcm', fileKey, iv);
233
+ const encrypted = Buffer.concat([cipher.update(rawChunk), cipher.final()]);
 
 
234
  const authTag = cipher.getAuthTag();
235
+
236
+ // Payload Format: IV(12) | TAG(16) | ENCRYPTED_DATA
237
+ const payload = Buffer.concat([iv, authTag, encrypted]);
238
 
239
+ // 2. Encode PNG
240
  const pngBuf = await dataToPngBuffer(payload);
241
+
242
+ // 3. Upload IMGBB (Rate Limited)
243
  const { url, delete_url, width, height } = await imgbbUpload(pngBuf);
244
+
245
+ // 4. Integrity Hash
246
  const sha = createHash('sha256').update(payload).digest('hex');
247
+
248
+ // 5. Save Chunk Meta
249
  db.prepare('INSERT INTO chunks (file_id, idx, imgbb_url, imgbb_delete_url, sha256, byte_size, pixel_w, pixel_h) VALUES (?, ?, ?, ?, ?, ?, ?, ?)')
250
  .run(fileId, idx, url, delete_url, sha, payload.length, width, height);
251
  }
252
  });
253
 
254
+ // DRIVE: DOWNLOAD (Reconstruct Stream: IMGBB -> PNG -> Decrypt -> Client)
255
  app.get('/api/fs/download/:fileId', requireAuth, async (req, res) => {
256
  const file = db.prepare('SELECT * FROM files WHERE id = ? AND owner_id = ?').get(req.params.fileId, req.session.userId);
257
  if (!file) return res.status(404).json({ error: 'Introuvable' });
258
  if (file.mime === 'application/vnd.pixeldrive.folder') return res.status(400).json({ error: 'Est un dossier' });
259
 
260
  const chunks = db.prepare('SELECT * FROM chunks WHERE file_id = ? ORDER BY idx').all(file.id);
261
+ if (!chunks.length) return res.status(404).json({ error: 'Aucun chunk (corrompu?)' });
262
 
263
+ const fileKey = getFileKey(req.session.userId, file.id);
 
 
264
 
265
  res.setHeader('Content-Disposition', `attachment; filename="${encodeURIComponent(file.name)}"`);
266
  res.setHeader('Content-Type', file.mime);
267
+ res.setHeader('Content-Length', file.size);
 
 
268
 
269
  for (const ch of chunks) {
270
  try {
271
+ // 1. Fetch PNG from IMGBB
272
  const imgRes = await fetch(ch.imgbb_url);
273
+ if (!imgRes.ok) throw new Error(`IMGBB DL Failed: ${imgRes.status}`);
274
  const pngBuf = Buffer.from(await imgRes.arrayBuffer());
275
 
276
  // 2. Decode PNG
277
  const payload = await pngBufferToData(pngBuf, ch.byte_size);
278
+
279
+ // 3. Verify Integrity
280
  const sha = createHash('sha256').update(payload).digest('hex');
281
+ if (sha !== ch.sha256) throw new Error(`Hash Mismatch Chunk ${ch.idx}`);
282
 
283
  // 4. Decrypt
284
  const iv = payload.subarray(0, 12);
285
  const authTag = payload.subarray(12, 28);
286
  const encrypted = payload.subarray(28);
287
+
288
+ const decipher = createDecipheriv('aes-256-gcm', fileKey, iv);
289
  decipher.setAuthTag(authTag);
290
+ const decrypted = Buffer.concat([decipher.update(encrypted), decipher.final()]);
291
 
292
+ // 5. Stream to Client
293
+ res.write(decrypted);
 
294
 
295
  } catch (e) {
296
+ console.error(`[DOWNLOAD ERROR] File ${file.id} Chunk ${ch.idx}:`, e);
297
  return res.destroy(new Error(`Corruption chunk ${ch.idx}`));
298
  }
299
  }
300
  res.end();
301
  });
302
 
303
+ // DRIVE: PREVIEW / EMBED (Streaming pour Image/Video/Audio/PDF)
304
  app.get('/api/fs/preview/:fileId', requireAuth, async (req, res) => {
305
  const file = db.prepare('SELECT * FROM files WHERE id = ? AND owner_id = ?').get(req.params.fileId, req.session.userId);
306
  if (!file) return res.status(404).send('Not found');
307
+
 
308
  const embeddable = ['image/', 'video/', 'audio/', 'application/pdf'];
309
  if (!embeddable.some(t => file.mime.startsWith(t))) return res.status(400).send('Non prévisualisable');
310
 
311
+ // Optimisation: Si image unique (1 chunk) -> Redirection directe IMGBB (Cache navigateur + Pas de CPU serveur)
312
  if (file.chunk_count === 1 && file.mime.startsWith('image/')) {
313
  const ch = db.prepare('SELECT imgbb_url FROM chunks WHERE file_id = ?').get(file.id);
314
+ if (ch) return res.redirect(302, ch.imgbb_url);
315
  }
316
 
317
+ // Sinon: Stream reconstruit (Supporte Range Request basique via pipe manuel si besoin, ici simple stream)
318
+ // Note: Pour vrai streaming vidéo (seek), il faut un Range Parser complexe.
319
+ // Ici on stream complet. Suffisant pour images/PDF/petites vidéos.
320
+ req.params.fileId = file.id; // Réutilise la logique download mais sans attachment header
321
+ res.setHeader('Content-Disposition', `inline; filename="${encodeURIComponent(file.name)}"`);
322
+ res.setHeader('Content-Type', file.mime);
323
+ res.setHeader('Accept-Ranges', 'bytes'); // Indique support potentiel
324
+ res.setHeader('Content-Length', file.size);
325
+
326
+ // Appel interne de la logique download (on duplique le code pour enlever Content-Disposition attachment)
327
+ const chunks = db.prepare('SELECT * FROM chunks WHERE file_id = ? ORDER BY idx').all(file.id);
328
+ const fileKey = getFileKey(req.session.userId, file.id);
329
+
330
+ for (const ch of chunks) {
331
+ try {
332
+ const imgRes = await fetch(ch.imgbb_url);
333
+ if (!imgRes.ok) throw new Error(`IMGBB DL Failed: ${imgRes.status}`);
334
+ const pngBuf = Buffer.from(await imgRes.arrayBuffer());
335
+ const payload = await pngBufferToData(pngBuf, ch.byte_size);
336
+ const sha = createHash('sha256').update(payload).digest('hex');
337
+ if (sha !== ch.sha256) throw new Error(`Hash Mismatch Chunk ${ch.idx}`);
338
+ const iv = payload.subarray(0, 12);
339
+ const authTag = payload.subarray(12, 28);
340
+ const encrypted = payload.subarray(28);
341
+ const decipher = createDecipheriv('aes-256-gcm', fileKey, iv);
342
+ decipher.setAuthTag(authTag);
343
+ const decrypted = Buffer.concat([decipher.update(encrypted), decipher.final()]);
344
+ res.write(decrypted);
345
+ } catch (e) { console.error(e); return res.destroy(e); }
346
+ }
347
+ res.end();
348
  });
349
 
350
+ // DRIVE: DELETE (Cascade DB + IMGBB)
351
  app.delete('/api/fs/:fileId', requireAuth, async (req, res) => {
352
  const file = db.prepare('SELECT * FROM files WHERE id = ? AND owner_id = ?').get(req.params.fileId, req.session.userId);
353
  if (!file) return res.status(404).json({ error: 'Introuvable' });
354
 
355
  const chunks = db.prepare('SELECT imgbb_delete_url FROM chunks WHERE file_id = ?').all(file.id);
356
 
357
+ // Suppression IMGBB Async (Fire & Forget avec queue)
358
+ chunks.forEach(ch => { if (ch.imgbb_delete_url) imgbbDelete(ch.imgbb_delete_url); });
 
 
359
 
360
+ // Suppression DB (CASCADE supprime chunks table)
361
  db.prepare('DELETE FROM files WHERE id = ?').run(file.id);
362
  res.json({ ok: true, deletedChunks: chunks.length });
363
  });
364
 
365
+ // ADMIN: STATS
366
  app.get('/api/admin/stats', requireAuth, requireAdmin, (req, res) => {
367
  const users = db.prepare('SELECT COUNT(*) as c FROM users').get().c;
368
  const files = db.prepare('SELECT COUNT(*) as c, SUM(size) as s FROM files WHERE mime != ?').get('application/vnd.pixeldrive.folder');
 
370
  res.json({ users, files: files.c, totalSize: files.s || 0, chunks: chunks.c });
371
  });
372
 
373
+ // --- FRONTEND SPA (Served at /) ---
374
  const HTML = `<!DOCTYPE html>
375
  <html lang="fr"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0">
376
  <title>PixelDrive</title>
 
391
  .grid { display:grid; grid-template-columns:repeat(auto-fill, minmax(180px, 1fr)); gap:1rem; }
392
  .item { background:var(--bg); border:1px solid var(--border); border-radius:6px; padding:1rem; display:flex; flex-direction:column; gap:0.5rem; position:relative; }
393
  .item.folder { border-color:var(--accent); }
394
+ .item-name { font-weight:500; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; cursor:pointer; }
395
  .item-meta { font-size:0.75rem; color:#8b949e; }
396
  .item-actions { display:flex; gap:0.25rem; margin-top:auto; padding-top:0.5rem; border-top:1px solid var(--border); }
397
  .modal { position:fixed; inset:0; background:#0008; display:flex; align-items:center; justify-content:center; z-index:100; }
 
403
  @keyframes slide { from { transform:translateX(100%); opacity:0; } to { transform:translateX(0); opacity:1; } }
404
  .progress-bar { height:4px; background:var(--border); border-radius:2px; overflow:hidden; margin:0.5rem 0; }
405
  .progress-bar > div { height:100%; background:var(--accent); width:0%; transition:width 0.1s; }
406
+ .hidden { display:none !important; }
407
  </style>
408
  </head><body>
409
  <header>
410
  <h1>💾 PixelDrive</h1>
411
  <div>
412
  <span id="userInfo" style="margin-right:1rem; font-size:0.9rem;"></span>
413
+ <button id="btnLogout" class="danger hidden">Déconnexion</button>
414
  <button id="btnLogin" class="primary">Connexion</button>
415
  </div>
416
  </header>
417
  <main id="app">
 
418
  <div id="viewAuth" class="card" style="max-width:400px; margin:2rem auto;">
419
  <h2 style="margin-bottom:1rem;">Connexion / Inscription</h2>
420
  <input id="authUser" placeholder="Nom d'utilisateur" style="width:100%; margin-bottom:0.5rem;">
 
422
  <button id="btnDoLogin" class="primary" style="width:100%;">Se connecter</button>
423
  <button id="btnDoRegister" style="width:100%; margin-top:0.5rem;">Créer compte</button>
424
  </div>
 
 
425
  <div id="viewDrive" class="hidden">
426
+ <div class="card" style="display:flex; gap:0.5rem; flex-wrap:wrap; margin-bottom:1rem; align-items:center;">
427
+ <input type="file" id="fileInput" multiple style="flex:1; min-width:200px;">
428
  <button id="btnUpload" class="primary">Uploader</button>
429
  <input type="text" id="mkdirName" placeholder="Nom dossier" style="flex:1; min-width:150px;">
430
  <button id="btnMkdir">Créer dossier</button>
431
+ <button id="btnUpFolder" title="Dossier parent" style="padding:0.5rem 1rem;"></button>
432
+ <span id="pathDisplay" style="flex:1; text-align:right; color:#8b949e; font-size:0.9rem; white-space:nowrap; overflow:hidden; text-overflow:ellipsis;">🏠 Racine</span>
433
  </div>
434
  <div id="progressContainer" class="card hidden">
435
+ <div style="display:flex; justify-content:space-between; margin-bottom:0.25rem;"><span>Upload: <span id="progressText">0%</span></span><span id="currentFile" style="font-size:0.8rem; color:#8b949e;"></span></div>
436
  <div class="progress-bar"><div id="progressBar"></div></div>
437
  </div>
438
  <div id="grid" class="grid"></div>
439
  </div>
440
  </main>
441
+ <div id="modalConfirm" class="modal hidden"><div class="modal-content"><h3>Confirmer suppression</h3><p id="modalText"></p><div style="display:flex; justify-content:flex-end; gap:0.5rem;"><button id="modalCancel">Annuler</button><button id="modalOk" class="danger">Supprimer</button></div></div></div>
442
+ <div id="modalPreview" class="modal hidden"><div class="modal-content" style="max-width:90vw; max-height:90vh; padding:0; overflow:hidden;"><button style="align-self:flex-end; background:none; border:none; color:var(--fg); font-size:1.5rem; padding:0.5rem 1rem; cursor:pointer;">✕</button><div id="previewContent" style="max-height:80vh; overflow:auto; background:var(--bg);"></div></div></div>
 
 
 
443
  <div id="toastContainer"></div>
444
 
445
  <script>
446
+ const $ = (s) => document.querySelector(s);
447
+ const $$ = (s) => document.querySelectorAll(s);
 
 
 
 
 
448
  const show = (el) => el.classList.remove('hidden');
449
  const hide = (el) => el.classList.add('hidden');
450
  const toast = (msg, type='') => { const t=document.createElement('div'); t.className='toast '+type; t.textContent=msg; $('#toastContainer').append(t); setTimeout(()=>t.remove(), 3000); };
451
+ const fmtBytes = (b) => { if(!b) return '0o'; const k=1024, u=['o','Ko','Mo','Go','To']; const i=Math.floor(Math.log(b)/Math.log(k)); return (b/Math.pow(k,i)).toFixed(1)+' '+u[i]; };
452
+
453
+ let currentParentId = null;
454
+ const pathStack = [{id: null, name: 'Racine'}];
455
+
456
+ // API Helper
457
+ const api = (url, opts={}) => fetch(url, {credentials:'include', headers:{'Content-Type':'application/json', ...(opts.headers||{})}, ...opts}).then(r => { if(!r.ok) return r.json().then(e=>Promise.reject(e.error||r.statusText)); return r.json(); });
458
 
459
  // Auth UI
460
  $('#btnLogin').onclick = () => { hide($('#viewDrive')); show($('#viewAuth')); };
461
+ $('#btnLogout').onclick = async () => { await api('/api/auth/logout', {method:'POST'}); location.reload(); };
462
+ $('#btnDoLogin').onclick = async () => { try{ await api('/api/auth/login', {method:'POST', body:JSON.stringify({username:$('#authUser').value, password:$('#authPass').value})}); toast('Connecté','success'); initDrive(); }catch(e){ toast(e,'error'); }};
463
+ $('#btnDoRegister').onclick = async () => { try{ await api('/api/auth/register', {method:'POST', body:JSON.stringify({username:$('#authUser').value, password:$('#authPass').value})}); toast('Compte créé','success'); }catch(e){ toast(e,'error'); }};
464
 
465
  // Drive Init
466
  async function initDrive() {
467
+ const me = await api('/api/auth/me');
468
  $('#userInfo').textContent = me.user.username + (me.user.isAdmin ? ' 👑' : '');
469
  hide($('#viewAuth')); hide($('#btnLogin')); show($('#btnLogout')); show($('#viewDrive'));
470
  loadFolder(null);
471
  }
472
 
473
+ // Navigation
474
  function updatePath() { $('#pathDisplay').textContent = '🏠 ' + pathStack.map(p=>p.name).join(' / '); }
475
+ $('#btnUpFolder').onclick = () => { if(pathStack.length > 1) { pathStack.pop(); loadFolder(pathStack[pathStack.length-1].id); } };
476
+
477
  async function loadFolder(parentId) {
478
  currentParentId = parentId;
479
  updatePath();
480
+ const {items} = await api('/api/fs/list?parent_id=' + (parentId || ''));
481
  renderGrid(items);
482
  }
483
+
484
  function renderGrid(items) {
485
  const grid = $('#grid'); grid.innerHTML = '';
486
  items.forEach(item => {
 
488
  div.className = 'item' + (item.mime === 'application/vnd.pixeldrive.folder' ? ' folder' : '');
489
  div.innerHTML = \`
490
  <div class="item-name" title="\${item.name}">\${item.mime === 'application/vnd.pixeldrive.folder' ? '📁' : '📄'} \${item.name}</div>
491
+ <div class="item-meta">\${item.mime !== 'application/vnd.pixeldrive.folder' ? fmtBytes(item.size) + ' • ' + item.chunk_count + ' chunks' : ''}\${new Date(item.created_at*1000).toLocaleString()}</div>
492
  <div class="item-actions">
493
  \${item.mime !== 'application/vnd.pixeldrive.folder' ? \`<button class="btn-preview" title="Voir/Embed">👁</button>\` : ''}
494
  \${item.mime !== 'application/vnd.pixeldrive.folder' ? \`<button class="btn-download" title="Télécharger">⬇</button>\` : ''}
495
  <button class="btn-delete danger" title="Supprimer">🗑</button>
496
  </div>
497
  \`;
 
498
  if(item.mime === 'application/vnd.pixeldrive.folder') {
499
  div.querySelector('.item-name').onclick = () => { pathStack.push(item); loadFolder(item.id); };
500
  } else {
 
504
  div.querySelector('.btn-delete').onclick = () => confirmDelete(item.id, item.name, item.mime === 'application/vnd.pixeldrive.folder');
505
  grid.appendChild(div);
506
  });
 
 
507
  }
508
 
509
+ // Mkdir
510
+ $('#btnMkdir').onclick = async () => { const n=$('#mkdirName').value.trim(); if(!n) return; try{ await api('/api/fs/mkdir', {method:'POST', body:JSON.stringify({name:n, parent_id:currentParentId})}); toast('Dossier créé','success'); $('#mkdirName').value=''; loadFolder(currentParentId); }catch(e){ toast(e,'error'); }};
511
+
512
+ // Upload (XHR for progress)
513
  $('#btnUpload').onclick = () => $('#fileInput').click();
514
  $('#fileInput').onchange = async (e) => {
515
+ const files = Array.from(e.target.files); if(!files.length) return;
516
  hide($('#btnUpload')); show($('#progressContainer'));
517
  for(const file of files) {
518
+ $('#currentFile').textContent = file.name;
519
  const form = new FormData(); form.append('file', file); form.append('parent_id', currentParentId || '');
520
  try {
 
521
  await new Promise((resolve, reject) => {
522
  const xhr = new XMLHttpRequest();
523
  xhr.open('POST', '/api/fs/upload', true);
524
  xhr.withCredentials = true;
525
  xhr.upload.onprogress = (ev) => { if(ev.lengthComputable) { const p=Math.round(ev.loaded/ev.total*100); $('#progressBar').style.width=p+'%'; $('#progressText').textContent=p+'%'; }};
526
  xhr.onload = () => { if(xhr.status>=200&&xhr.status<300) resolve(JSON.parse(xhr.response)); else reject(new Error(xhr.responseText)); };
527
+ xhr.onerror = () => reject(new Error('Network Error'));
528
  xhr.send(form);
529
  });
530
+ toast(\`\${file.name} uploadé (\${files.length} total)\`, 'success');
531
  } catch(err) { toast(\`Erreur \${file.name}: \${err.message}\`, 'error'); }
532
  }
533
  $('#fileInput').value=''; show($('#btnUpload')); hide($('#progressContainer')); loadFolder(currentParentId);
534
  };
535
 
536
  // Delete
537
+ let delTarget = null;
538
+ function confirmDelete(id, name, isFolder) { delTarget = {id, isFolder}; $('#modalText').textContent = \`Supprimer \${isFolder?'le dossier':'le fichier'} "\${name}" ?\`; show($('#modalConfirm')); }
539
  $('#modalCancel').onclick = () => hide($('#modalConfirm'));
540
+ $('#modalOk').onclick = async () => { if(!delTarget) return; try{ await api('/api/fs/'+delTarget.id, {method:'DELETE'}); toast('Supprimé','success'); loadFolder(currentParentId); }catch(e){ toast(e,'error'); } hide($('#modalConfirm')); delTarget=null; };
541
 
542
  // Preview Modal
543
  function openPreview(id, mime, name) {
544
  const c = $('#previewContent'); c.innerHTML = '';
545
+ if(mime.startsWith('image/')) c.innerHTML = \`<img src="/api/fs/preview/\${id}" style="max-width:100%; max-height:80vh; display:block; margin:auto;" alt="\${name}">\`;
546
+ else if(mime.startsWith('video/')) c.innerHTML = \`<video controls style="max-width:100%; max-height:80vh; display:block; margin:auto;"><source src="/api/fs/preview/\${id}" type="\${mime}"></video>\`;
547
+ else if(mime.startsWith('audio/')) c.innerHTML = \`<audio controls style="width:100%;"><source src="/api/fs/preview/\${id}" type="\${mime}"></audio>\`;
548
  else if(mime === 'application/pdf') c.innerHTML = \`<iframe src="/api/fs/preview/\${id}" style="width:100%; height:80vh; border:none;"></iframe>\`;
549
  else c.innerHTML = \`<p style="padding:2rem; text-align:center;">Aperçu non dispo pour \${mime}</p>\`;
550
  show($('#modalPreview'));
551
  }
552
  $('#modalPreview').onclick = (e) => { if(e.target === $('#modalPreview') || e.target.tagName==='BUTTON') hide($('#modalPreview')); };
553
 
 
 
 
554
  // Start
555
  initDrive().catch(()=>{ show($('#viewAuth')); hide($('#viewDrive')); });
556
  </script>
557
  </body></html>`;
558
 
559
  app.get('/', (req, res) => res.type('html').send(HTML));
 
560
 
561
+ // --- START SERVER ---
562
  const server = app.listen(PORT, '0.0.0.0', () => console.log(`🚀 PixelDrive running on http://0.0.0.0:${PORT}`));
563
 
564
+ // Graceful Shutdown
565
+ process.on('SIGTERM', () => { console.log('SIGTERM received, closing...'); server.close(() => { db.close(); process.exit(0); }); });
566
+ process.on('SIGINT', () => { console.log('SIGINT received, closing...'); server.close(() => { db.close(); process.exit(0); }); });