Spaces:
Paused
Paused
Update server.js
Browse files
server.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
| 1 |
// ==========================================
|
| 2 |
-
// PIXELDRIVE v4.
|
| 3 |
-
// +
|
| 4 |
-
//
|
| 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];
|
|
|
|
| 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 (+
|
| 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 |
-
//
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
// ✅
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
// ✅
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
// ✅
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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.
|
| 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)); });
|