NathMen12 commited on
Commit
08a5ed6
·
verified ·
1 Parent(s): ef0c2d7

Update server.js

Browse files
Files changed (1) hide show
  1. server.js +139 -17
server.js CHANGED
@@ -1,7 +1,7 @@
1
  // ==========================================
2
- // PIXELDRIVE v4.8 - SERVER.JS (ESM)
3
- // + password change, account delete, activity log, stats,
4
- // share TTL, view/download counters
5
  // ==========================================
6
  import express from 'express';
7
  import session from 'express-session';
@@ -39,7 +39,8 @@ const IS_PROD = process.env.NODE_ENV === 'production';
39
  const USER_QUOTA = parseInt(process.env.USER_QUOTA_GB || '50', 10) * 1024 * 1024 * 1024;
40
  const DAILY_TRANSFER = parseInt(process.env.DAILY_TRANSFER_GB || '100', 10) * 1024 * 1024 * 1024;
41
  const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
42
- const TTL_ALLOWED = [1, 24, 168, 720]; // ✅ 1h / 24h / 7j / 30j
 
43
 
44
  function log(level, msg) {
45
  const n = new Date();
@@ -90,7 +91,7 @@ async function pngToBuffer(pngBuffer, expectedLen) {
90
  }
91
 
92
  // ==========================================
93
- // SCHÉMA (+ activity, + compteurs views/downloads)
94
  // ==========================================
95
  db.exec(`
96
  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')));
@@ -101,12 +102,14 @@ CREATE TABLE IF NOT EXISTS share_links (token TEXT PRIMARY KEY, file_id TEXT NOT
101
  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, created_at INTEGER DEFAULT (strftime('%s','now')));
102
  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));
103
  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')));
 
104
  CREATE INDEX IF NOT EXISTS idx_files_owner ON files(owner_id);
105
  CREATE INDEX IF NOT EXISTS idx_chunks_file ON file_chunks(file_id);
106
  CREATE INDEX IF NOT EXISTS idx_share_owner ON share_links(owner_id);
107
  CREATE INDEX IF NOT EXISTS idx_ushares_with ON user_shares(shared_with);
108
  CREATE INDEX IF NOT EXISTS idx_transfer_day ON transfer_daily(user_id, day);
109
  CREATE INDEX IF NOT EXISTS idx_activity_user ON activity(user_id, created_at);
 
110
  `);
111
 
112
  function runMigrations() {
@@ -134,7 +137,6 @@ function runMigrations() {
134
  }
135
  }
136
  if (updatedAdded) db.exec(`UPDATE files SET updated_at = strftime('%s','now') WHERE updated_at = 0;`);
137
- // ✅ Compteurs de vues/téléchargements sur share_links
138
  const slCols = db.prepare("PRAGMA table_info(share_links)").all().map(c => c.name);
139
  if (!slCols.includes('views')) db.exec(`ALTER TABLE share_links ADD COLUMN views INTEGER DEFAULT 0;`);
140
  if (!slCols.includes('downloads')) db.exec(`ALTER TABLE share_links ADD COLUMN downloads INTEGER DEFAULT 0;`);
@@ -144,6 +146,7 @@ function runMigrations() {
144
  if (!tables.includes('user_shares')) db.exec(`CREATE TABLE user_shares (id INTEGER PRIMARY KEY AUTOINCREMENT, file_id TEXT REFERENCES files(id) ON DELETE CASCADE, folder_id INTEGER REFERENCES folders(id) ON DELETE CASCADE, owner_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, shared_with INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, created_at INTEGER DEFAULT (strftime('%s','now'))); CREATE INDEX idx_ushares_with ON user_shares(shared_with);`);
145
  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);`);
146
  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);`);
 
147
  const sessionsCols = tables.includes('sessions') ? db.prepare("PRAGMA table_info(sessions)").all().map(c => c.name) : [];
148
  if (tables.includes('sessions') && (!sessionsCols.includes('data') || !sessionsCols.includes('expires_at'))) {
149
  log('WARN', 'Table sessions invalide → reconstruction');
@@ -178,12 +181,16 @@ function addTransfer(userId, bytes, dir) {
178
  }
179
  function transferBlocked(userId) { return getTransfer(userId).total >= DAILY_TRANSFER; }
180
 
181
- // ✅ JOURNAL D'ACTIVITÉ
182
  function logActivity(userId, action, detail) {
183
  try { db.prepare('INSERT INTO activity(user_id, action, detail) VALUES(?,?,?)').run(userId, action, String(detail || '').slice(0, 200)); } catch (e) {}
184
  }
185
 
186
- // --- NETTOYAGE + BACKUPS ---
 
 
 
 
 
187
  function cleanTemp() {
188
  try {
189
  const now = Date.now();
@@ -211,6 +218,17 @@ function cleanHourly() {
211
  const l = db.prepare('DELETE FROM share_links WHERE expires_at IS NOT NULL AND expires_at < ?').run(now);
212
  const a = db.prepare('DELETE FROM activity WHERE created_at < ?').run(Math.floor(now / 1000) - 30 * 86400);
213
  db.prepare('DELETE FROM activity WHERE id NOT IN (SELECT id FROM activity ORDER BY id DESC LIMIT 2000)').run();
 
 
 
 
 
 
 
 
 
 
 
214
  const cutoff = Math.floor(now / 1000) - 7 * 86400;
215
  const olds = db.prepare("SELECT id FROM files WHERE status='error' AND updated_at < ?").all(cutoff);
216
  for (const f of olds) { db.prepare('DELETE FROM files WHERE id=?').run(f.id); try { unlinkSync(join(THUMB_DIR, `${f.id}.jpg`)); } catch (_) {} }
@@ -338,6 +356,29 @@ function canAccessFile(userId, fileId){
338
  if (f.folder_id && canAccessFolder(userId, f.folder_id)) return f;
339
  return null;
340
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
341
 
342
  async function genThumb(fid,mime,key){
343
  if(!mime.startsWith('image/')&&!mime.startsWith('video/')) return;
@@ -442,7 +483,15 @@ app.get('/api/me',(req,res)=>{
442
  });
443
  app.post('/api/tos/accept',requireAuth,(req,res)=>{db.prepare('UPDATE users SET tos_accepted=1 WHERE id=?').run(req.session.userId);req.session.tosAccepted=true;res.json({ok:true});});
444
 
445
- // ✅ CHANGER SON MOT DE PASSE
 
 
 
 
 
 
 
 
446
  app.post('/api/password', requireAuth, async(req,res)=>{
447
  const { current, next } = req.body;
448
  if (typeof current !== 'string' || typeof next !== 'string') return res.status(400).json({error:'Champs manquants'});
@@ -458,7 +507,6 @@ app.post('/api/password', requireAuth, async(req,res)=>{
458
  res.json({ok:true});
459
  });
460
 
461
- // ✅ SUPPRIMER SON COMPTE (self-service)
462
  app.delete('/api/account', requireAuth, async(req,res)=>{
463
  const { password } = req.body;
464
  const usr = db.prepare('SELECT * FROM users WHERE id=?').get(req.session.userId);
@@ -472,13 +520,25 @@ app.delete('/api/account', requireAuth, async(req,res)=>{
472
  req.session.destroy(() => res.json({ok:true}));
473
  });
474
 
475
- // ✅ STATS PERSO (donut par type)
476
  app.get('/api/stats', requireAuth, (req,res) => {
477
  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
478
  FROM files WHERE owner_id=? AND status!='error' GROUP BY cat`).all(req.session.userId));
479
  });
480
 
481
- // ✅ ACTIVITÉ PERSO
 
 
 
 
 
 
 
 
 
 
 
 
 
482
  app.get('/api/activity', requireAuth, (req,res) => {
483
  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));
484
  });
@@ -620,10 +680,11 @@ app.post('/api/share-user',requireAuth,requireTos,shareLimit,(req,res)=>{
620
  if (!target) return res.status(404).json({error:'Utilisateur introuvable'});
621
  if (target.id === req.session.userId) return res.status(400).json({error:'Impossible de partager avec vous-même'});
622
  const col = type === 'file' ? 'file_id' : 'folder_id';
623
- const own = db.prepare(`SELECT id FROM ${type === 'file' ? 'files' : 'folders'} WHERE id=? AND owner_id=?`).get(id, req.session.userId);
624
  if (!own) return res.status(404).json({error:'Introuvable'});
625
  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é'});
626
  db.prepare(`INSERT INTO user_shares(${col},owner_id,shared_with) VALUES(?,?,?)`).run(id, req.session.userId, target.id);
 
627
  log('INFO', `Partage ${type} ${id} → ${username.trim()}`);
628
  logActivity(req.session.userId,'share_user',`${type} → ${username.trim()}`);
629
  res.json({ok:true});
@@ -738,7 +799,70 @@ app.get('/api/files/:id/download',requireAuth,async(req,res)=>{
738
  await streamFile(res,f,k,req.headers.range,req.session.userId);
739
  });
740
 
741
- // ✅ PARTAGE AVEC TTL AU CHOIX (1h / 24h / 7j / 30j)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
742
  app.post('/api/files/:id/share',requireAuth,shareLimit,async(req,res)=>{
743
  const f=db.prepare('SELECT * FROM files WHERE id=? AND owner_id=?').get(req.params.id,req.session.userId);
744
  if(!f||f.status!=='ready') return res.status(404).json({error:'Fichier non disponible'});
@@ -751,7 +875,6 @@ app.post('/api/files/:id/share',requireAuth,shareLimit,async(req,res)=>{
751
  res.json({preview:`/s/${pt}`,download:`/d/${dt2}`,expires,ttl});
752
  });
753
 
754
- // ✅ /s/ : embed + compteur de vues + activité
755
  app.get('/s/:token',async(req,res)=>{
756
  const tpl = embedTpl();
757
  const serve = (status, data, og) => {
@@ -774,7 +897,6 @@ app.get('/s/:token',async(req,res)=>{
774
  serve(200, { name: f.name, size: f.size, mime: f.mime, dl: '/d/' + dt }, og);
775
  });
776
 
777
- // ✅ /d/ : compteur de téléchargements + activité
778
  app.get('/d/:token',async(req,res)=>{
779
  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());
780
  if(!l)return res.status(404).json({error:'Invalide'});
@@ -838,5 +960,5 @@ app.use((err, req, res, next) => {
838
  res.status(500).json({ error: 'Internal Server Error' });
839
  });
840
 
841
- const server = app.listen(PORT, '0.0.0.0', () => log('INFO', `PixelDrive v4.8 démarré | stockage ${(USER_QUOTA/1073741824).toFixed(0)} GB | transit ${(DAILY_TRANSFER/1073741824).toFixed(0)} GB/j`));
842
  process.on('SIGTERM', () => { log('WARN', 'SIGTERM reçu, arrêt...'); server.close(() => process.exit(0)); });
 
1
  // ==========================================
2
+ // PIXELDRIVE v4.9 - SERVER.JS (ESM)
3
+ // + notifications, aperçu docs, éditeur texte, stats avancées,
4
+ // propriétés (SHA-256), animations (front)
5
  // ==========================================
6
  import express from 'express';
7
  import session from 'express-session';
 
39
  const USER_QUOTA = parseInt(process.env.USER_QUOTA_GB || '50', 10) * 1024 * 1024 * 1024;
40
  const DAILY_TRANSFER = parseInt(process.env.DAILY_TRANSFER_GB || '100', 10) * 1024 * 1024 * 1024;
41
  const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
42
+ const TTL_ALLOWED = [1, 24, 168, 720];
43
+ 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;
44
 
45
  function log(level, msg) {
46
  const n = new Date();
 
91
  }
92
 
93
  // ==========================================
94
+ // SCHÉMA (+ notifications)
95
  // ==========================================
96
  db.exec(`
97
  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')));
 
102
  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, created_at INTEGER DEFAULT (strftime('%s','now')));
103
  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));
104
  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')));
105
+ 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')));
106
  CREATE INDEX IF NOT EXISTS idx_files_owner ON files(owner_id);
107
  CREATE INDEX IF NOT EXISTS idx_chunks_file ON file_chunks(file_id);
108
  CREATE INDEX IF NOT EXISTS idx_share_owner ON share_links(owner_id);
109
  CREATE INDEX IF NOT EXISTS idx_ushares_with ON user_shares(shared_with);
110
  CREATE INDEX IF NOT EXISTS idx_transfer_day ON transfer_daily(user_id, day);
111
  CREATE INDEX IF NOT EXISTS idx_activity_user ON activity(user_id, created_at);
112
+ CREATE INDEX IF NOT EXISTS idx_notif_user ON notifications(user_id, read, created_at);
113
  `);
114
 
115
  function runMigrations() {
 
137
  }
138
  }
139
  if (updatedAdded) db.exec(`UPDATE files SET updated_at = strftime('%s','now') WHERE updated_at = 0;`);
 
140
  const slCols = db.prepare("PRAGMA table_info(share_links)").all().map(c => c.name);
141
  if (!slCols.includes('views')) db.exec(`ALTER TABLE share_links ADD COLUMN views INTEGER DEFAULT 0;`);
142
  if (!slCols.includes('downloads')) db.exec(`ALTER TABLE share_links ADD COLUMN downloads INTEGER DEFAULT 0;`);
 
146
  if (!tables.includes('user_shares')) db.exec(`CREATE TABLE user_shares (id INTEGER PRIMARY KEY AUTOINCREMENT, file_id TEXT REFERENCES files(id) ON DELETE CASCADE, folder_id INTEGER REFERENCES folders(id) ON DELETE CASCADE, owner_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, shared_with INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, created_at INTEGER DEFAULT (strftime('%s','now'))); CREATE INDEX idx_ushares_with ON user_shares(shared_with);`);
147
  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);`);
148
  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);`);
149
+ 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);`);
150
  const sessionsCols = tables.includes('sessions') ? db.prepare("PRAGMA table_info(sessions)").all().map(c => c.name) : [];
151
  if (tables.includes('sessions') && (!sessionsCols.includes('data') || !sessionsCols.includes('expires_at'))) {
152
  log('WARN', 'Table sessions invalide → reconstruction');
 
181
  }
182
  function transferBlocked(userId) { return getTransfer(userId).total >= DAILY_TRANSFER; }
183
 
 
184
  function logActivity(userId, action, detail) {
185
  try { db.prepare('INSERT INTO activity(user_id, action, detail) VALUES(?,?,?)').run(userId, action, String(detail || '').slice(0, 200)); } catch (e) {}
186
  }
187
 
188
+ // NOTIFICATIONS
189
+ function notify(userId, type, detail) {
190
+ try { db.prepare('INSERT INTO notifications(user_id, type, detail) VALUES(?,?,?)').run(userId, type, String(detail || '').slice(0, 200)); } catch (e) {}
191
+ }
192
+
193
+ // --- NETTOYAGE + BACKUPS + NOTIF EXPIRATION ---
194
  function cleanTemp() {
195
  try {
196
  const now = Date.now();
 
218
  const l = db.prepare('DELETE FROM share_links WHERE expires_at IS NOT NULL AND expires_at < ?').run(now);
219
  const a = db.prepare('DELETE FROM activity WHERE created_at < ?').run(Math.floor(now / 1000) - 30 * 86400);
220
  db.prepare('DELETE FROM activity WHERE id NOT IN (SELECT id FROM activity ORDER BY id DESC LIMIT 2000)').run();
221
+ db.prepare('DELETE FROM notifications WHERE created_at < ?').run(Math.floor(now / 1000) - 30 * 86400);
222
+ // ✅ Notifie les liens qui expirent sous 24h
223
+ const soon = now + 24 * 3600000;
224
+ 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);
225
+ for (const lk of expiring) {
226
+ const dup = db.prepare('SELECT 1 FROM notifications WHERE user_id=? AND type=? AND detail=?').get(lk.owner_id, 'expiring', lk.token);
227
+ if (!dup) {
228
+ const f = db.prepare('SELECT name FROM files WHERE id=?').get(lk.file_id);
229
+ notify(lk.owner_id, 'expiring', (f?.name || '?') + ' (' + lk.token.slice(0, 8) + '…)');
230
+ }
231
+ }
232
  const cutoff = Math.floor(now / 1000) - 7 * 86400;
233
  const olds = db.prepare("SELECT id FROM files WHERE status='error' AND updated_at < ?").all(cutoff);
234
  for (const f of olds) { db.prepare('DELETE FROM files WHERE id=?').run(f.id); try { unlinkSync(join(THUMB_DIR, `${f.id}.jpg`)); } catch (_) {} }
 
356
  if (f.folder_id && canAccessFolder(userId, f.folder_id)) return f;
357
  return null;
358
  }
359
+ function isEditable(f) {
360
+ return (f.mime && (f.mime.startsWith('text/') || ['application/json','application/javascript','application/xml'].includes(f.mime))) || EDITABLE_RE.test(f.name || '');
361
+ }
362
+ function isMarkdown(f) { return /\.(md|markdown)$/i.test(f.name || ''); }
363
+
364
+ // ✅ Déchiffre tout le fichier en mémoire (aperçu texte / édition)
365
+ async function decryptWholeFile(f) {
366
+ const k = await getMasterKey(f.id);
367
+ const chunks = db.prepare('SELECT chunk_index,imgbb_url,size FROM file_chunks WHERE file_id=? ORDER BY chunk_index').all(f.id);
368
+ const bufs = [];
369
+ for (const c of chunks) {
370
+ const ck = `chunk:${f.id}:${c.chunk_index}`;
371
+ let dec = fileCache.get(ck)?.data;
372
+ if (!dec) {
373
+ const {data} = await axios.get(c.imgbb_url,{responseType:'arraybuffer',timeout:60000});
374
+ const enc = await pngToBuffer(Buffer.from(data), c.size + OVERHEAD);
375
+ dec = decryptChunk(enc, k);
376
+ fileCache.set(ck,{data:dec,size:dec.length});
377
+ }
378
+ bufs.push(dec);
379
+ }
380
+ return Buffer.concat(bufs, f.size);
381
+ }
382
 
383
  async function genThumb(fid,mime,key){
384
  if(!mime.startsWith('image/')&&!mime.startsWith('video/')) return;
 
483
  });
484
  app.post('/api/tos/accept',requireAuth,(req,res)=>{db.prepare('UPDATE users SET tos_accepted=1 WHERE id=?').run(req.session.userId);req.session.tosAccepted=true;res.json({ok:true});});
485
 
486
+ // ✅ NOTIFICATIONS
487
+ app.get('/api/notifications', requireAuth, (req,res) => {
488
+ 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));
489
+ });
490
+ app.post('/api/notifications/read', requireAuth, (req,res) => {
491
+ db.prepare('UPDATE notifications SET read=1 WHERE user_id=?').run(req.session.userId);
492
+ res.json({ok:true});
493
+ });
494
+
495
  app.post('/api/password', requireAuth, async(req,res)=>{
496
  const { current, next } = req.body;
497
  if (typeof current !== 'string' || typeof next !== 'string') return res.status(400).json({error:'Champs manquants'});
 
507
  res.json({ok:true});
508
  });
509
 
 
510
  app.delete('/api/account', requireAuth, async(req,res)=>{
511
  const { password } = req.body;
512
  const usr = db.prepare('SELECT * FROM users WHERE id=?').get(req.session.userId);
 
520
  req.session.destroy(() => res.json({ok:true}));
521
  });
522
 
 
523
  app.get('/api/stats', requireAuth, (req,res) => {
524
  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
525
  FROM files WHERE owner_id=? AND status!='error' GROUP BY cat`).all(req.session.userId));
526
  });
527
 
528
+ // ✅ STATS AVANCÉES (7 jours + top fichiers)
529
+ app.get('/api/stats/advanced', requireAuth, (req,res) => {
530
+ const days = [];
531
+ for (let i = 6; i >= 0; i--) {
532
+ const d = new Date(Date.now() - i*86400000).toISOString().slice(0,10);
533
+ 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);
534
+ 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;
535
+ days.push({ day: d.slice(5), up: r.u, down: r.d, acts });
536
+ }
537
+ const top = db.prepare(`SELECT f.name, (SELECT COALESCE(SUM(downloads),0) FROM share_links sl WHERE sl.file_id=f.id) dl
538
+ FROM files f WHERE f.owner_id=? AND f.status='ready' ORDER BY dl DESC LIMIT 5`).all(req.session.userId);
539
+ res.json({ days, top });
540
+ });
541
+
542
  app.get('/api/activity', requireAuth, (req,res) => {
543
  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));
544
  });
 
680
  if (!target) return res.status(404).json({error:'Utilisateur introuvable'});
681
  if (target.id === req.session.userId) return res.status(400).json({error:'Impossible de partager avec vous-même'});
682
  const col = type === 'file' ? 'file_id' : 'folder_id';
683
+ const own = db.prepare(`SELECT id, name FROM ${type === 'file' ? 'files' : 'folders'} WHERE id=? AND owner_id=?`).get(id, req.session.userId);
684
  if (!own) return res.status(404).json({error:'Introuvable'});
685
  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é'});
686
  db.prepare(`INSERT INTO user_shares(${col},owner_id,shared_with) VALUES(?,?,?)`).run(id, req.session.userId, target.id);
687
+ notify(target.id, 'share', `${req.session.username} → ${own.name}`);
688
  log('INFO', `Partage ${type} ${id} → ${username.trim()}`);
689
  logActivity(req.session.userId,'share_user',`${type} → ${username.trim()}`);
690
  res.json({ok:true});
 
799
  await streamFile(res,f,k,req.headers.range,req.session.userId);
800
  });
801
 
802
+ // ✅ APERÇU TEXTE / MARKDOWN / CODE (contenu déchiffré, 2 MB max)
803
+ app.get('/api/files/:id/raw', requireAuth, async(req,res)=>{
804
+ if(!UUID_RE.test(req.params.id)) return res.status(400).json({error:'ID invalide'});
805
+ const f = canAccessFile(req.session.userId, req.params.id);
806
+ if(!f) return res.status(404).json({error:'Introuvable'});
807
+ if(f.status!=='ready') return res.status(409).json({error:'Pas prêt'});
808
+ if (f.size > 2*1024*1024) return res.status(413).json({error:'Fichier trop lourd pour l\'aperçu (2 MB max)'});
809
+ try {
810
+ const all = await decryptWholeFile(f);
811
+ res.set({'Content-Type': (f.mime && f.mime.startsWith('text/')) ? f.mime : 'text/plain; charset=utf-8'});
812
+ res.send(all);
813
+ } catch (e) { log('ERROR', `Raw ${f.id}: ${e.message}`); res.status(500).json({error:'Erreur de déchiffrement'}); }
814
+ });
815
+
816
+ // ✅ ÉDITEUR TEXTE (remplace le contenu, re-chiffre, re-upload)
817
+ app.post('/api/files/:id/edit', requireAuth, requireTos, async(req,res)=>{
818
+ if(!UUID_RE.test(req.params.id)) return res.status(400).json({error:'ID invalide'});
819
+ const { content } = req.body;
820
+ if (typeof content !== 'string') return res.status(400).json({error:'Contenu manquant'});
821
+ const f = db.prepare('SELECT * FROM files WHERE id=? AND owner_id=?').get(req.params.id, req.session.userId);
822
+ if(!f) return res.status(404).json({error:'Introuvable'});
823
+ if (!isEditable(f)) return res.status(400).json({error:'Type de fichier non éditable'});
824
+ const buf = Buffer.from(content, 'utf8');
825
+ if (buf.length > CHUNK_SIZE) return res.status(413).json({error:'Contenu trop lourd (30 MB max)'});
826
+ try {
827
+ const mk = decryptMasterKey(f.master_key_enc);
828
+ const enc = encryptChunk(buf, mk);
829
+ const png = await bufferToPng(enc);
830
+ const {url, deleteUrl, id: imgId} = await imgbbUpload(png);
831
+ const olds = db.prepare('SELECT imgbb_delete_url FROM file_chunks WHERE file_id=?').all(f.id);
832
+ db.prepare('DELETE FROM file_chunks WHERE file_id=?').run(f.id);
833
+ db.prepare('INSERT INTO file_chunks(file_id,chunk_index,size,encrypted_len) VALUES(?,?,?,?)').run(f.id,0,buf.length,enc.length);
834
+ db.prepare('UPDATE files SET size=?, chunk_count=1, status=? WHERE id=?').run(buf.length,'ready',f.id);
835
+ fileCache.delete(`chunk:${f.id}:0`);
836
+ for (const o of olds) if (o.imgbb_delete_url) await imgbbDelete(o.imgbb_delete_url);
837
+ logActivity(req.session.userId,'edit',f.name);
838
+ log('INFO', `Fichier édité: ${f.name} (${buf.length} o)`);
839
+ res.json({ok:true, size: buf.length});
840
+ } catch (e) { log('ERROR', `Edit ${f.id}: ${e.message}`); res.status(500).json({error:'Erreur d\'enregistrement'}); }
841
+ });
842
+
843
+ // ✅ PROPRIÉTÉS (SHA-256 du chiffré + hash de clé)
844
+ app.get('/api/files/:id/properties', requireAuth, async(req,res)=>{
845
+ if(!UUID_RE.test(req.params.id)) return res.status(400).json({error:'ID invalide'});
846
+ const f = canAccessFile(req.session.userId, req.params.id);
847
+ if(!f) return res.status(404).json({error:'Introuvable'});
848
+ const chunks = db.prepare('SELECT imgbb_url, size FROM file_chunks WHERE file_id=? ORDER BY chunk_index').all(f.id);
849
+ const hash = createHash('sha256');
850
+ for (const c of chunks) {
851
+ try {
852
+ const { data } = await axios.get(c.imgbb_url, { responseType:'arraybuffer', timeout:30000 });
853
+ hash.update(Buffer.from(data));
854
+ } catch (e) {
855
+ log('ERROR', `Properties hash failed for ${f.id}: ${e.message}`);
856
+ return res.status(500).json({error:'Erreur de calcul'});
857
+ }
858
+ }
859
+ res.json({
860
+ id: f.id, name: f.name, size: f.size, mime: f.mime, status: f.status,
861
+ created_at: f.created_at, updated_at: f.updated_at, chunk_count: f.chunk_count,
862
+ encryption_key_hash: f.encryption_key_hash, sha256: hash.digest('hex'), owner_id: f.owner_id
863
+ });
864
+ });
865
+
866
  app.post('/api/files/:id/share',requireAuth,shareLimit,async(req,res)=>{
867
  const f=db.prepare('SELECT * FROM files WHERE id=? AND owner_id=?').get(req.params.id,req.session.userId);
868
  if(!f||f.status!=='ready') return res.status(404).json({error:'Fichier non disponible'});
 
875
  res.json({preview:`/s/${pt}`,download:`/d/${dt2}`,expires,ttl});
876
  });
877
 
 
878
  app.get('/s/:token',async(req,res)=>{
879
  const tpl = embedTpl();
880
  const serve = (status, data, og) => {
 
897
  serve(200, { name: f.name, size: f.size, mime: f.mime, dl: '/d/' + dt }, og);
898
  });
899
 
 
900
  app.get('/d/:token',async(req,res)=>{
901
  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());
902
  if(!l)return res.status(404).json({error:'Invalide'});
 
960
  res.status(500).json({ error: 'Internal Server Error' });
961
  });
962
 
963
+ const server = app.listen(PORT, '0.0.0.0', () => log('INFO', `PixelDrive v4.9 démarré | stockage ${(USER_QUOTA/1073741824).toFixed(0)} GB | transit ${(DAILY_TRANSFER/1073741824).toFixed(0)} GB/j`));
964
  process.on('SIGTERM', () => { log('WARN', 'SIGTERM reçu, arrêt...'); server.close(() => process.exit(0)); });