Spaces:
Paused
Paused
Update server.js
Browse files
server.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
| 1 |
// ==========================================
|
| 2 |
-
// PIXELDRIVE v3.
|
| 3 |
// ==========================================
|
| 4 |
import express from 'express';
|
| 5 |
import session from 'express-session';
|
|
@@ -32,7 +32,6 @@ const IV_LEN = 12;
|
|
| 32 |
const TAG_LEN = 16;
|
| 33 |
const OVERHEAD = IV_LEN + TAG_LEN;
|
| 34 |
|
| 35 |
-
// --- ENV ---
|
| 36 |
const IMGBB_API_KEY = process.env.IMGBB_API_KEY;
|
| 37 |
const SESSION_SECRET = process.env.SESSION_SECRET;
|
| 38 |
const ADMIN_USER = process.env.ADMIN_USER || 'admin';
|
|
@@ -42,130 +41,50 @@ if (!IMGBB_API_KEY || !SESSION_SECRET) {
|
|
| 42 |
process.exit(1);
|
| 43 |
}
|
| 44 |
|
| 45 |
-
// --- DATABASE INSTANCE ---
|
| 46 |
const db = new Database(DB_PATH);
|
| 47 |
db.pragma('journal_mode = WAL');
|
| 48 |
db.pragma('busy_timeout = 5000');
|
| 49 |
|
| 50 |
-
// --- CRYPTO
|
| 51 |
const scryptAsync = promisify(scrypt);
|
| 52 |
async function deriveKey(password, salt) { return scryptAsync(password, salt, KEY_LEN); }
|
| 53 |
-
function encryptChunk(dataBuffer, key) {
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 59 |
}
|
| 60 |
-
function
|
| 61 |
-
|
| 62 |
-
const iv = encBuffer.subarray(0, IV_LEN);
|
| 63 |
-
const tag = encBuffer.subarray(encBuffer.length - TAG_LEN);
|
| 64 |
-
const data = encBuffer.subarray(IV_LEN, encBuffer.length - TAG_LEN);
|
| 65 |
-
const decipher = createDecipheriv('aes-256-gcm', key, iv);
|
| 66 |
-
decipher.setAuthTag(tag);
|
| 67 |
-
return Buffer.concat([decipher.update(data), decipher.final()]);
|
| 68 |
-
}
|
| 69 |
-
|
| 70 |
-
// --- PNG STEGANOGRAPHY (OPTIMISÉ) ---
|
| 71 |
-
function bufferToPng(buffer) {
|
| 72 |
-
const byteLen = buffer.length;
|
| 73 |
-
const pixelsNeeded = Math.ceil(byteLen / 3);
|
| 74 |
-
const width = Math.ceil(Math.sqrt(pixelsNeeded));
|
| 75 |
-
const height = Math.ceil(pixelsNeeded / width);
|
| 76 |
-
const rgbBuffer = Buffer.alloc(width * height * 3);
|
| 77 |
-
buffer.copy(rgbBuffer);
|
| 78 |
-
return sharp(rgbBuffer, { raw: { width, height, channels: 3 } })
|
| 79 |
-
.png({ compressionLevel: 9, palette: false })
|
| 80 |
-
.withMetadata(false)
|
| 81 |
-
.toBuffer();
|
| 82 |
-
}
|
| 83 |
-
|
| 84 |
-
async function pngToBuffer(pngBuffer, expectedLen) {
|
| 85 |
-
const { data, info } = await sharp(pngBuffer)
|
| 86 |
-
.raw()
|
| 87 |
-
.toBuffer({ resolveWithObject: true });
|
| 88 |
-
|
| 89 |
const channels = info.channels;
|
| 90 |
-
const
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
if (channels === 3) {
|
| 94 |
-
|
| 95 |
-
} else if (channels === 4) {
|
| 96 |
-
for (let i = 0, j = 0; i < data.length && j < rgb.length; i += 4, j += 3) {
|
| 97 |
-
rgb[j] = data[i];
|
| 98 |
-
rgb[j+1] = data[i+1];
|
| 99 |
-
rgb[j+2] = data[i+2];
|
| 100 |
-
}
|
| 101 |
-
} else if (channels === 1) {
|
| 102 |
-
for (let i = 0, j = 0; i < data.length && j < rgb.length; i++, j += 3) {
|
| 103 |
-
rgb[j] = rgb[j+1] = rgb[j+2] = data[i];
|
| 104 |
-
}
|
| 105 |
-
}
|
| 106 |
-
|
| 107 |
-
return rgb.subarray(0, expectedLen);
|
| 108 |
}
|
| 109 |
|
| 110 |
// ==========================================
|
| 111 |
-
// SCHÉMA
|
| 112 |
// ==========================================
|
| 113 |
db.exec(`
|
| 114 |
-
CREATE TABLE IF NOT EXISTS users (
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
tos_accepted INTEGER DEFAULT 0,
|
| 121 |
-
created_at INTEGER DEFAULT (strftime('%s','now'))
|
| 122 |
-
);
|
| 123 |
-
CREATE TABLE IF NOT EXISTS folders (
|
| 124 |
-
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
| 125 |
-
parent_id INTEGER REFERENCES folders(id) ON DELETE CASCADE,
|
| 126 |
-
owner_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
| 127 |
-
name TEXT NOT NULL,
|
| 128 |
-
created_at INTEGER DEFAULT (strftime('%s','now')),
|
| 129 |
-
UNIQUE(parent_id, owner_id, name)
|
| 130 |
-
);
|
| 131 |
-
CREATE TABLE IF NOT EXISTS files (
|
| 132 |
-
id TEXT PRIMARY KEY,
|
| 133 |
-
folder_id INTEGER REFERENCES folders(id) ON DELETE SET NULL,
|
| 134 |
-
owner_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
| 135 |
-
name TEXT NOT NULL,
|
| 136 |
-
mime TEXT NOT NULL,
|
| 137 |
-
size INTEGER NOT NULL,
|
| 138 |
-
chunk_count INTEGER DEFAULT 1,
|
| 139 |
-
status TEXT DEFAULT 'pending',
|
| 140 |
-
progress REAL DEFAULT 0,
|
| 141 |
-
error_msg TEXT,
|
| 142 |
-
encryption_key_hash TEXT NOT NULL,
|
| 143 |
-
master_key_enc BLOB,
|
| 144 |
-
created_at INTEGER DEFAULT (strftime('%s','now')),
|
| 145 |
-
updated_at INTEGER DEFAULT (strftime('%s','now'))
|
| 146 |
-
);
|
| 147 |
-
CREATE TABLE IF NOT EXISTS file_chunks (
|
| 148 |
-
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
| 149 |
-
file_id TEXT NOT NULL REFERENCES files(id) ON DELETE CASCADE,
|
| 150 |
-
chunk_index INTEGER NOT NULL,
|
| 151 |
-
imgbb_url TEXT,
|
| 152 |
-
imgbb_delete_url TEXT,
|
| 153 |
-
imgbb_id TEXT,
|
| 154 |
-
size INTEGER NOT NULL,
|
| 155 |
-
encrypted_len INTEGER NOT NULL,
|
| 156 |
-
UNIQUE(file_id, chunk_index)
|
| 157 |
-
);
|
| 158 |
-
CREATE TABLE IF NOT EXISTS share_links (
|
| 159 |
-
token TEXT PRIMARY KEY,
|
| 160 |
-
file_id TEXT NOT NULL REFERENCES files(id) ON DELETE CASCADE,
|
| 161 |
-
owner_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
| 162 |
-
type TEXT NOT NULL,
|
| 163 |
-
created_at INTEGER DEFAULT (strftime('%s','now')),
|
| 164 |
-
expires_at INTEGER
|
| 165 |
-
);
|
| 166 |
CREATE INDEX IF NOT EXISTS idx_files_owner ON files(owner_id);
|
| 167 |
CREATE INDEX IF NOT EXISTS idx_chunks_file ON file_chunks(file_id);
|
| 168 |
CREATE INDEX IF NOT EXISTS idx_share_owner ON share_links(owner_id);
|
|
|
|
| 169 |
`);
|
| 170 |
|
| 171 |
// ==========================================
|
|
@@ -173,12 +92,8 @@ CREATE INDEX IF NOT EXISTS idx_share_owner ON share_links(owner_id);
|
|
| 173 |
// ==========================================
|
| 174 |
function runMigrations() {
|
| 175 |
console.log('🔍 Vérification migrations BDD...');
|
| 176 |
-
|
| 177 |
const usersCols = db.prepare("PRAGMA table_info(users)").all().map(c => c.name);
|
| 178 |
-
if (!usersCols.includes('tos_accepted'))
|
| 179 |
-
console.log('🔧 Migration: ALTER TABLE users ADD COLUMN tos_accepted');
|
| 180 |
-
db.exec(`ALTER TABLE users ADD COLUMN tos_accepted INTEGER DEFAULT 0;`);
|
| 181 |
-
}
|
| 182 |
|
| 183 |
const filesCols = db.prepare("PRAGMA table_info(files)").all().map(c => c.name);
|
| 184 |
const filesDefs = [
|
|
@@ -191,41 +106,26 @@ function runMigrations() {
|
|
| 191 |
{ name: 'chunk_count', def: 'INTEGER DEFAULT 1' },
|
| 192 |
{ name: 'updated_at', def: 'INTEGER DEFAULT 0' },
|
| 193 |
];
|
| 194 |
-
|
| 195 |
let updatedAdded = false;
|
| 196 |
for (const col of filesDefs) {
|
| 197 |
if (!filesCols.includes(col.name)) {
|
| 198 |
-
console.log(`🔧 Migration: ALTER TABLE files ADD COLUMN ${col.name} ${col.def}`);
|
| 199 |
db.exec(`ALTER TABLE files ADD COLUMN ${col.name} ${col.def};`);
|
| 200 |
if (col.name === 'updated_at') updatedAdded = true;
|
| 201 |
}
|
| 202 |
}
|
| 203 |
-
|
| 204 |
-
if (updatedAdded) {
|
| 205 |
-
console.log('🔧 Migration: Initialisation updated_at sur les lignes existantes');
|
| 206 |
-
db.exec(`UPDATE files SET updated_at = strftime('%s','now') WHERE updated_at = 0;`);
|
| 207 |
-
}
|
| 208 |
|
| 209 |
const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table'").all().map(t => t.name);
|
| 210 |
-
|
| 211 |
-
if (!tables.includes('
|
| 212 |
-
|
| 213 |
-
db.exec(`CREATE TABLE folders (id INTEGER PRIMARY KEY AUTOINCREMENT, parent_id INTEGER REFERENCES folders(id) ON DELETE CASCADE, owner_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, name TEXT NOT NULL, created_at INTEGER DEFAULT (strftime('%s','now')), UNIQUE(parent_id, owner_id, name));`);
|
| 214 |
-
}
|
| 215 |
-
if (!tables.includes('share_links')) {
|
| 216 |
-
console.log('🔧 Migration: CREATE TABLE share_links');
|
| 217 |
-
db.exec(`CREATE TABLE share_links (token TEXT PRIMARY KEY, file_id TEXT NOT NULL REFERENCES files(id) ON DELETE CASCADE, owner_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, type TEXT NOT NULL, created_at INTEGER DEFAULT (strftime('%s','now')), expires_at INTEGER);`);
|
| 218 |
-
}
|
| 219 |
|
| 220 |
const sessionsCols = tables.includes('sessions') ? db.prepare("PRAGMA table_info(sessions)").all().map(c => c.name) : [];
|
| 221 |
if (tables.includes('sessions') && (!sessionsCols.includes('data') || !sessionsCols.includes('expires_at'))) {
|
| 222 |
-
console.log('🔧 Migration: Reconstruction table sessions (colonnes manquantes)');
|
| 223 |
db.exec(`DROP TABLE sessions; CREATE TABLE sessions (sid TEXT PRIMARY KEY, data TEXT NOT NULL, expires_at INTEGER NOT NULL); CREATE INDEX idx_sessions_expires ON sessions(expires_at);`);
|
| 224 |
} else if (!tables.includes('sessions')) {
|
| 225 |
-
|
| 226 |
-
db.exec(`CREATE TABLE sessions (sid TEXT PRIMARY KEY, data TEXT NOT NULL, expires_at INTEGER NOT NULL); CREATE INDEX idx_sessions_expires ON sessions(expires_at);`);
|
| 227 |
}
|
| 228 |
-
|
| 229 |
console.log('✅ Migrations terminées.');
|
| 230 |
}
|
| 231 |
runMigrations();
|
|
@@ -233,32 +133,13 @@ runMigrations();
|
|
| 233 |
db.exec(`
|
| 234 |
CREATE INDEX IF NOT EXISTS idx_files_folder ON files(folder_id);
|
| 235 |
DROP TRIGGER IF EXISTS update_file_ts;
|
| 236 |
-
CREATE TRIGGER update_file_ts AFTER UPDATE ON files
|
| 237 |
-
BEGIN
|
| 238 |
-
UPDATE files SET updated_at = strftime('%s','now') WHERE id = NEW.id;
|
| 239 |
-
END;
|
| 240 |
`);
|
| 241 |
|
| 242 |
// --- SESSION STORE ---
|
| 243 |
class SQLiteStore extends session.Store {
|
| 244 |
-
get(sid, cb) {
|
| 245 |
-
|
| 246 |
-
const r = db.prepare('SELECT data, expires_at FROM sessions WHERE sid=?').get(sid);
|
| 247 |
-
if(!r) return cb(null, null);
|
| 248 |
-
if(r.expires_at < Date.now()){
|
| 249 |
-
db.prepare('DELETE FROM sessions WHERE sid=?').run(sid);
|
| 250 |
-
return cb(null, null);
|
| 251 |
-
}
|
| 252 |
-
cb(null, JSON.parse(r.data));
|
| 253 |
-
} catch(e){ cb(e); }
|
| 254 |
-
}
|
| 255 |
-
set(sid, sess, cb) {
|
| 256 |
-
try {
|
| 257 |
-
const exp = sess.cookie?.expires ? new Date(sess.cookie.expires).getTime() : Date.now() + 30*24*60*60*1000;
|
| 258 |
-
db.prepare('INSERT OR REPLACE INTO sessions(sid,data,expires_at) VALUES(?,?,?)').run(sid, JSON.stringify(sess), exp);
|
| 259 |
-
cb(null);
|
| 260 |
-
} catch(e){ cb(e); }
|
| 261 |
-
}
|
| 262 |
destroy(sid, cb) { try { db.prepare('DELETE FROM sessions WHERE sid=?').run(sid); cb(null); } catch(e){ cb(e); } }
|
| 263 |
clear(cb) { try { db.prepare('DELETE FROM sessions').run(); cb(null); } catch(e){ cb(e); } }
|
| 264 |
length(cb) { try { const r = db.prepare('SELECT COUNT(*) as c FROM sessions').get(); cb(null, r.c); } catch(e){ cb(e); } }
|
|
@@ -266,13 +147,13 @@ class SQLiteStore extends session.Store {
|
|
| 266 |
}
|
| 267 |
const sessionStore = new SQLiteStore();
|
| 268 |
|
| 269 |
-
// --- ADMIN
|
| 270 |
const adminRow = db.prepare('SELECT * FROM users WHERE username=?').get(ADMIN_USER);
|
| 271 |
-
if (!adminRow) {
|
| 272 |
-
const salt = randomBytes(16);
|
| 273 |
-
const hash = await deriveKey(process.env.ADMIN_PASS || 'ChangeMeNow!', salt);
|
| 274 |
-
db.prepare('INSERT INTO users(username,password_hash,salt,is_admin,tos_accepted) VALUES(?,?,?,1,1)').run(ADMIN_USER, hash, salt);
|
| 275 |
-
console.log(`👑 Admin: ${ADMIN_USER} / Pass: ${process.env.ADMIN_PASS || 'ChangeMeNow!'}`);
|
| 276 |
}
|
| 277 |
|
| 278 |
// --- EXPRESS ---
|
|
@@ -280,210 +161,118 @@ const app = express();
|
|
| 280 |
app.set('trust proxy', 1);
|
| 281 |
app.use(express.json({ limit: '50mb' }));
|
| 282 |
app.use(express.urlencoded({ extended: true, limit: '50mb' }));
|
| 283 |
-
app.use(session({
|
| 284 |
-
secret: SESSION_SECRET,
|
| 285 |
-
store: sessionStore,
|
| 286 |
-
resave: false,
|
| 287 |
-
saveUninitialized: false,
|
| 288 |
-
cookie: { secure: false, httpOnly: true, maxAge: 30*24*60*60*1000, sameSite: 'lax' }
|
| 289 |
-
}));
|
| 290 |
|
| 291 |
const requireAuth = (req, res, next) => { if(!req.session.userId) return res.status(401).json({error:'Non authentifié'}); next(); };
|
| 292 |
const requireTos = (req, res, next) => { if(!req.session.tosAccepted) return res.status(403).json({error:'CGU non acceptées', needTos:true}); next(); };
|
| 293 |
|
| 294 |
-
// --- CACHE
|
| 295 |
-
class ByteLRUCache extends LRUCache {
|
| 296 |
-
constructor(){ super({maxSize:CACHE_MAX_BYTES, sizeCalculation:v=>v.size}); }
|
| 297 |
-
async getOrLoad(key, loader){
|
| 298 |
-
if(this.has(key)) return this.get(key).data;
|
| 299 |
-
const data = await loader();
|
| 300 |
-
this.set(key, {data, size: data.length, time: Date.now()});
|
| 301 |
-
return data;
|
| 302 |
-
}
|
| 303 |
-
}
|
| 304 |
const fileCache = new ByteLRUCache();
|
| 305 |
-
setInterval(()=>{
|
| 306 |
-
|
| 307 |
-
|
| 308 |
-
console.warn(`⚠️ RAM ${(m.rss/1e9).toFixed(1)}GB, vidage cache`);
|
| 309 |
-
fileCache.clear();
|
| 310 |
-
if(global.gc) global.gc();
|
| 311 |
-
}
|
| 312 |
-
}, 30000);
|
| 313 |
-
|
| 314 |
-
// --- NSFW WASM ---
|
| 315 |
let nsfwModel = null;
|
| 316 |
-
async function loadNsfwModel(){
|
| 317 |
-
try{
|
| 318 |
-
await tf.setBackend('wasm');
|
| 319 |
-
tf.wasm.setWasmPaths('/app/tfjs_wasm/','tfjs-backend-wasm.wasm');
|
| 320 |
-
await tf.ready();
|
| 321 |
-
nsfwModel = await nsfwjs.load('/app/tfjs_wasm/',{type:'mobilenet_v2',size:224});
|
| 322 |
-
console.log('🛡️ NSFW WASM chargé');
|
| 323 |
-
}catch(e){ console.error('❌ NSFW load fail:',e.message); }
|
| 324 |
-
}
|
| 325 |
loadNsfwModel();
|
|
|
|
| 326 |
|
| 327 |
-
|
| 328 |
-
|
| 329 |
-
try{
|
| 330 |
-
const t = tf.node.decodeImage(buf,3).resizeNearestNeighbor([224,224]).expandDims(0);
|
| 331 |
-
const p = await nsfwModel.classify(t);
|
| 332 |
-
t.dispose();
|
| 333 |
-
const porn = p.find(x=>x.className==='Porn'||x.className==='Sexual Activity')?.probability||0;
|
| 334 |
-
const hentai = p.find(x=>x.className==='Hentai')?.probability||0;
|
| 335 |
-
if(porn>0.85||hentai>0.9) return {safe:false,reason:`NSFW (P:${porn.toFixed(2)} H:${hentai.toFixed(2)})`};
|
| 336 |
-
return {safe:true};
|
| 337 |
-
}catch(e){ return {safe:true}; }
|
| 338 |
-
}
|
| 339 |
-
|
| 340 |
-
// --- UPLOAD QUEUE ---
|
| 341 |
-
class UploadQueue{
|
| 342 |
-
constructor(c){ this.c=c; this.r=0; this.q=[]; }
|
| 343 |
-
add(t){ return new Promise((res,rej)=>{ this.q.push({t,res,rej}); this.proc(); }); }
|
| 344 |
-
async proc(){
|
| 345 |
-
if(this.r>=this.c||!this.q.length) return;
|
| 346 |
-
this.r++;
|
| 347 |
-
const {t,res,rej} = this.q.shift();
|
| 348 |
-
try{ res(await t()); }catch(e){ rej(e); }finally{ this.r--; this.proc(); }
|
| 349 |
-
}
|
| 350 |
-
}
|
| 351 |
const uploadQueue = new UploadQueue(MAX_CONCURRENT_UPLOADS);
|
| 352 |
|
| 353 |
// --- IMGBB ---
|
| 354 |
-
async function imgbbUpload(buf){
|
| 355 |
-
const f = new FormData();
|
| 356 |
-
f.append('image',buf.toString('base64'));
|
| 357 |
-
const {data} = await axios.post(
|
| 358 |
-
`https://api.imgbb.com/1/upload?key=${IMGBB_API_KEY}`,
|
| 359 |
-
f,
|
| 360 |
-
{
|
| 361 |
-
headers: f.getHeaders(),
|
| 362 |
-
timeout: 120000,
|
| 363 |
-
maxContentLength: Infinity,
|
| 364 |
-
maxBodyLength: Infinity
|
| 365 |
-
}
|
| 366 |
-
);
|
| 367 |
-
if(!data.success) throw new Error(data.error?.message||'ImgBB fail');
|
| 368 |
-
return {url:data.data.url,deleteUrl:data.data.delete_url,id:data.data.id};
|
| 369 |
-
}
|
| 370 |
async function imgbbDelete(u){ try{await axios.get(u,{timeout:10000});}catch(_){} }
|
| 371 |
|
| 372 |
-
// ---
|
| 373 |
const SERVER_MASTER_KEY = createHash('sha256').update(SESSION_SECRET+'|PixelDriveMaster').digest();
|
| 374 |
-
function encryptMasterKey(k){
|
| 375 |
-
|
| 376 |
-
|
| 377 |
-
|
| 378 |
-
|
| 379 |
-
|
| 380 |
-
|
| 381 |
-
|
| 382 |
-
|
| 383 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 384 |
}
|
| 385 |
-
|
| 386 |
-
const
|
| 387 |
-
if(!
|
| 388 |
-
|
|
|
|
|
|
|
|
|
|
| 389 |
}
|
| 390 |
|
| 391 |
// --- THUMB ---
|
| 392 |
-
async function genThumb(fid,mime,key){
|
| 393 |
-
if(!mime.startsWith('image/')&&!mime.startsWith('video/')) return;
|
| 394 |
-
try{
|
| 395 |
-
const c = db.prepare('SELECT imgbb_url, size FROM file_chunks WHERE file_id=? AND chunk_index=0').get(fid);
|
| 396 |
-
if(!c) return;
|
| 397 |
-
const {data} = await axios.get(c.imgbb_url,{responseType:'arraybuffer',timeout:30000});
|
| 398 |
-
const enc = await pngToBuffer(Buffer.from(data), c.size + OVERHEAD);
|
| 399 |
-
const dec = decryptChunk(enc,key);
|
| 400 |
-
let buf;
|
| 401 |
-
if(mime.startsWith('image/')) buf = await sharp(dec).rotate().resize(256,256,{fit:'inside'}).jpeg({quality:80}).toBuffer();
|
| 402 |
-
else buf = await sharp({create:{width:256,height:144,channels:3,background:'#1e1e2f'}}).jpeg().toBuffer();
|
| 403 |
-
await sharp(buf).toFile(join(THUMB_DIR,`${fid}.jpg`));
|
| 404 |
-
}catch(e){ console.warn(`Thumb ${fid}:`,e.message); }
|
| 405 |
}
|
| 406 |
|
| 407 |
-
// --- STREAM
|
| 408 |
-
async function streamFile(res,file,key,range){
|
| 409 |
-
const chunks = db.prepare('SELECT chunk_index,imgbb_url,size FROM file_chunks WHERE file_id=? ORDER BY chunk_index').all(file.id);
|
| 410 |
-
const total = file.size;
|
| 411 |
-
let s = 0, e = total - 1;
|
| 412 |
-
|
| 413 |
-
if(
|
| 414 |
-
|
| 415 |
-
|
| 416 |
-
e = p[1] ? parseInt(p[1]) : total - 1;
|
| 417 |
-
}
|
| 418 |
-
|
| 419 |
-
if(s >= total || e >= total) return res.status(416).send('Range Not Satisfiable');
|
| 420 |
-
|
| 421 |
-
res.set({
|
| 422 |
-
'Content-Type': file.mime,
|
| 423 |
-
'Content-Length': e - s + 1,
|
| 424 |
-
'Accept-Ranges': 'bytes',
|
| 425 |
-
'Content-Disposition': `inline; filename="${encodeURIComponent(file.name)}"`
|
| 426 |
-
});
|
| 427 |
-
|
| 428 |
-
if(range){
|
| 429 |
-
res.status(206);
|
| 430 |
-
res.set('Content-Range', `bytes ${s}-${e}/${total}`);
|
| 431 |
-
}
|
| 432 |
-
|
| 433 |
console.log(`📥 Stream ${file.name} (${((e-s+1)/1024/1024).toFixed(2)} MB, ${chunks.length} chunk(s))`);
|
| 434 |
-
|
| 435 |
-
|
| 436 |
-
|
| 437 |
-
|
| 438 |
-
|
| 439 |
-
|
| 440 |
-
|
| 441 |
-
|
| 442 |
-
|
| 443 |
-
let dec = fileCache.get(ck)?.data;
|
| 444 |
-
|
| 445 |
-
if(!dec){
|
| 446 |
const t0 = Date.now();
|
| 447 |
-
const {data} = await axios.get(c.imgbb_url,{
|
| 448 |
-
responseType:'arraybuffer',
|
| 449 |
-
timeout:120000
|
| 450 |
-
});
|
| 451 |
const t1 = Date.now();
|
| 452 |
console.log(` ⬇️ Chunk ${c.chunk_index}: ${(data.byteLength/1024/1024).toFixed(2)} MB en ${t1-t0}ms`);
|
| 453 |
-
|
| 454 |
-
const enc = await pngToBuffer(Buffer.from(data), c.size + OVERHEAD);
|
| 455 |
const t2 = Date.now();
|
| 456 |
console.log(` 🖼️ PNG→Buffer: ${t2-t1}ms`);
|
| 457 |
-
|
| 458 |
-
|
| 459 |
-
|
| 460 |
-
|
| 461 |
-
|
| 462 |
-
|
| 463 |
-
|
| 464 |
-
|
| 465 |
-
|
| 466 |
-
|
| 467 |
-
|
| 468 |
-
|
| 469 |
-
return res.destroy(err);
|
| 470 |
-
}
|
| 471 |
-
}
|
| 472 |
-
cur += c.size;
|
| 473 |
-
if(cs > e) break;
|
| 474 |
-
}
|
| 475 |
-
res.end();
|
| 476 |
}
|
| 477 |
|
| 478 |
// --- SSE ---
|
| 479 |
-
const sseClients = new Map();
|
| 480 |
-
function broadcast(fid,d){
|
| 481 |
-
const cs = sseClients.get(fid);
|
| 482 |
-
if(cs){
|
| 483 |
-
const p = `data: ${JSON.stringify(d)}\n\n`;
|
| 484 |
-
for(const r of cs) r.write(p);
|
| 485 |
-
}
|
| 486 |
-
}
|
| 487 |
|
| 488 |
// ==========================================
|
| 489 |
// ROUTES
|
|
@@ -495,31 +284,80 @@ app.get('/api/me',(req,res)=>req.session.userId?res.json({id:req.session.userId,
|
|
| 495 |
app.get('/api/tos',(_,res)=>res.send(`PIXELDRIVE CGU\n1. RESPONSABILITÉ TOTALE\n2. AUCUNE GARANTIE (Projet test)\n3. INTERDITS: Illégal, NSFW, Abus\n4. CHIFFRÉ AES-256-GCM + Filtre NSFW Local\n5. ACCEPTATION = Responsabilité légale`));
|
| 496 |
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});});
|
| 497 |
|
| 498 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 499 |
app.post('/api/folders',requireAuth,requireTos,(req,res)=>{const{n,p}=req.body;if(!n)return res.status(400).json({error:'Nom requis'});try{const i=db.prepare('INSERT INTO folders(name,parent_id,owner_id) VALUES(?,?,?)').run(n,p||null,req.session.userId);res.json({id:i.lastInsertRowid,name:n,parent_id:p});}catch(e){res.status(409).json({error:'Existe'});}});
|
| 500 |
app.delete('/api/folders/:id',requireAuth,(req,res)=>{db.prepare('DELETE FROM folders WHERE id=? AND owner_id=?').run(req.params.id,req.session.userId);res.json({ok:true});});
|
| 501 |
|
| 502 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 503 |
|
| 504 |
-
//
|
| 505 |
-
|
| 506 |
-
|
| 507 |
-
|
| 508 |
-
|
| 509 |
-
|
| 510 |
-
|
| 511 |
-
|
| 512 |
-
|
| 513 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 514 |
});
|
| 515 |
|
| 516 |
-
//
|
|
|
|
|
|
|
|
|
|
| 517 |
app.delete('/api/share-links/:token',requireAuth,(req,res)=>{
|
| 518 |
-
const
|
| 519 |
-
if (
|
| 520 |
res.json({ok:true});
|
| 521 |
});
|
| 522 |
|
|
|
|
| 523 |
const upload = multer({dest:TEMP_DIR,limits:{fileSize:5*1024*1024*1024}});
|
| 524 |
app.post('/api/files/upload',requireAuth,requireTos,upload.single('file'),async(req,res)=>{
|
| 525 |
if(!req.file)return res.status(400).json({error:'Fichier manquant'});
|
|
@@ -565,27 +403,29 @@ async function processUpload(fid,path,mk,cc,mime){
|
|
| 565 |
for(const c of cs)if(c.imgbb_delete_url)await imgbbDelete(c.imgbb_delete_url);
|
| 566 |
}
|
| 567 |
}
|
| 568 |
-
|
| 569 |
function readChunk(p,o,l){return new Promise((res,rej)=>{const s=createReadStream(p,{start:o,end:o+l-1}),c=[];s.on('data',d=>c.push(d)).on('end',()=>res(Buffer.concat(c,l))).on('error',rej);});}
|
| 570 |
|
| 571 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 572 |
|
| 573 |
-
// ✅ Génère TOUS les liens de partage d'un coup
|
| 574 |
app.post('/api/files/:id/share',requireAuth,async(req,res)=>{
|
| 575 |
const f=db.prepare('SELECT * FROM files WHERE id=? AND owner_id=?').get(req.params.id,req.session.userId);
|
| 576 |
if(!f||f.status!=='ready') return res.status(404).json({error:'Fichier non disponible'});
|
| 577 |
-
const
|
| 578 |
-
|
| 579 |
-
|
| 580 |
-
|
| 581 |
-
db.prepare('INSERT INTO share_links(token,file_id,owner_id,type,expires_at) VALUES(?,?,?,?,?)').run(downloadToken,f.id,req.session.userId,'download',expires);
|
| 582 |
-
res.json({preview:`/s/${previewToken}`,download:`/d/${downloadToken}`,expires:expires});
|
| 583 |
});
|
| 584 |
|
| 585 |
app.get('/api/files/:id/preview',requireAuth,async(req,res)=>{
|
| 586 |
const f=db.prepare('SELECT * FROM files WHERE id=? AND owner_id=?').get(req.params.id,req.session.userId);
|
| 587 |
if(!f||f.status!=='ready')return res.status(404).json({error:'Pas prêt'});
|
| 588 |
-
if(!f.mime.startsWith('image/')&&!f.mime.startsWith('video/')&&!f.mime.startsWith('audio/')&&f.mime!=='application/pdf')return res.status(400).json({error:'Type non prévisualisable'});
|
| 589 |
const t=uuidv4();
|
| 590 |
db.prepare('INSERT INTO share_links(token,file_id,owner_id,type,expires_at) VALUES(?,?,?,?,?)').run(t,f.id,req.session.userId,'embed',Date.now()+3600000);
|
| 591 |
res.json({url:`/s/${t}`});
|
|
@@ -613,32 +453,33 @@ app.get('/d/:token',async(req,res)=>{
|
|
| 613 |
|
| 614 |
app.delete('/api/files/:id',requireAuth,async(req,res)=>{const f=db.prepare('SELECT * FROM files WHERE id=? AND owner_id=?').get(req.params.id,req.session.userId);if(!f)return res.status(404).json({error:'Introuvable'});const cs=db.prepare('SELECT imgbb_delete_url FROM file_chunks WHERE file_id=?').all(f.id);for(const c of cs)if(c.imgbb_delete_url)await imgbbDelete(c.imgbb_delete_url);db.prepare('DELETE FROM files WHERE id=?').run(f.id);fileCache.delete(`chunk:${f.id}:0`);try{unlinkSync(join(THUMB_DIR,`${f.id}.jpg`));}catch(_){}res.json({ok:true});});
|
| 615 |
|
| 616 |
-
app.get('/api/files/:id/thumb',requireAuth,(req,res)=>{
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 617 |
|
| 618 |
app.get('/api/files/:id/progress',requireAuth,(req,res)=>{res.set({'Content-Type':'text/event-stream','Cache-Control':'no-cache','Connection':'keep-alive'});res.flushHeaders();const id=req.params.id;if(!sseClients.has(id))sseClients.set(id,new Set());sseClients.get(id).add(res);req.on('close',()=>sseClients.get(id)?.delete(res));});
|
| 619 |
|
| 620 |
app.get('/api/admin/users',requireAuth,(req,res)=>{if(!req.session.isAdmin)return res.status(403).json({error:'Admin only'});res.json(db.prepare('SELECT id,username,is_admin,tos_accepted,created_at FROM users').all());});
|
| 621 |
-
|
| 622 |
app.get('/health',(_,res)=>res.json({ok:true,cache:fileCache.size,mem:process.memoryUsage()}));
|
| 623 |
|
| 624 |
app.use(express.static(__dirname));
|
| 625 |
-
app.get('*',(
|
|
|
|
|
|
|
|
|
|
| 626 |
|
| 627 |
-
//
|
| 628 |
-
// ERROR HANDLER (Anti-pollution logs)
|
| 629 |
-
// ==========================================
|
| 630 |
app.use((err, req, res, next) => {
|
| 631 |
-
if (err.type === 'entity.parse.failed' || err instanceof SyntaxError) {
|
| 632 |
-
return res.status(400).json({ error: 'Bad Request' });
|
| 633 |
-
}
|
| 634 |
console.error('❌ Server Error:', err.message);
|
| 635 |
res.status(500).json({ error: 'Internal Server Error' });
|
| 636 |
});
|
| 637 |
|
| 638 |
-
|
| 639 |
-
// START
|
| 640 |
-
// ==========================================
|
| 641 |
-
const server = app.listen(PORT, '0.0.0.0', () => console.log(`🚀 PixelDrive v3.2 on http://0.0.0.0:${PORT} | Cache: 12GB LRU`));
|
| 642 |
process.on('SIGTERM', () => { console.log('SIGTERM'); server.close(() => process.exit(0)); });
|
| 643 |
|
| 644 |
function html(b){return `<!DOCTYPE html><html><head><meta charset="utf-8"/><meta name="viewport" content="width=device-width, initial-scale=1"/><title>PixelDrive</title><style>body{margin:0;font-family:system-ui,sans-serif;background:#0d0d12;color:#eee;display:flex;align-items:center;justify-content:center;height:100vh}</style></head><body>${b}</body></html>`;}
|
|
|
|
| 1 |
// ==========================================
|
| 2 |
+
// PIXELDRIVE v3.3 - SERVER.JS (ESM)
|
| 3 |
// ==========================================
|
| 4 |
import express from 'express';
|
| 5 |
import session from 'express-session';
|
|
|
|
| 32 |
const TAG_LEN = 16;
|
| 33 |
const OVERHEAD = IV_LEN + TAG_LEN;
|
| 34 |
|
|
|
|
| 35 |
const IMGBB_API_KEY = process.env.IMGBB_API_KEY;
|
| 36 |
const SESSION_SECRET = process.env.SESSION_SECRET;
|
| 37 |
const ADMIN_USER = process.env.ADMIN_USER || 'admin';
|
|
|
|
| 41 |
process.exit(1);
|
| 42 |
}
|
| 43 |
|
|
|
|
| 44 |
const db = new Database(DB_PATH);
|
| 45 |
db.pragma('journal_mode = WAL');
|
| 46 |
db.pragma('busy_timeout = 5000');
|
| 47 |
|
| 48 |
+
// --- CRYPTO ---
|
| 49 |
const scryptAsync = promisify(scrypt);
|
| 50 |
async function deriveKey(password, salt) { return scryptAsync(password, salt, KEY_LEN); }
|
| 51 |
+
function encryptChunk(dataBuffer, key) { const iv = randomBytes(IV_LEN); const cipher = createCipheriv('aes-256-gcm', key, iv); const encrypted = Buffer.concat([cipher.update(dataBuffer), cipher.final()]); const tag = cipher.getAuthTag(); return Buffer.concat([iv, encrypted, tag]); }
|
| 52 |
+
function decryptChunk(encBuffer, key) { if (encBuffer.length < OVERHEAD) throw new Error('Buffer trop court'); const iv = encBuffer.subarray(0, IV_LEN); const tag = encBuffer.subarray(encBuffer.length - TAG_LEN); const data = encBuffer.subarray(IV_LEN, encBuffer.length - TAG_LEN); const decipher = createDecipheriv('aes-256-gcm', key, iv); decipher.setAuthTag(tag); return Buffer.concat([decipher.update(data), decipher.final()]); }
|
| 53 |
+
|
| 54 |
+
// --- PNG STEGO (OPTIMISÉ) ---
|
| 55 |
+
function bufferToPng(buffer) {
|
| 56 |
+
const byteLen = buffer.length;
|
| 57 |
+
const pixelsNeeded = Math.ceil(byteLen / 3);
|
| 58 |
+
const width = Math.ceil(Math.sqrt(pixelsNeeded));
|
| 59 |
+
const height = Math.ceil(pixelsNeeded / width);
|
| 60 |
+
const rgbBuffer = Buffer.alloc(width * height * 3);
|
| 61 |
+
buffer.copy(rgbBuffer);
|
| 62 |
+
return sharp(rgbBuffer, { raw: { width, height, channels: 3 } }).png({ compressionLevel: 9, palette: false }).withMetadata(false).toBuffer();
|
| 63 |
}
|
| 64 |
+
async function pngToBuffer(pngBuffer, expectedLen) {
|
| 65 |
+
const { data, info } = await sharp(pngBuffer).raw().toBuffer({ resolveWithObject: true });
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 66 |
const channels = info.channels;
|
| 67 |
+
const rgb = Buffer.alloc(info.width * info.height * 3);
|
| 68 |
+
if (channels === 3) { data.copy(rgb, 0, 0, Math.min(data.length, rgb.length)); }
|
| 69 |
+
else if (channels === 4) { for (let i = 0, j = 0; i < data.length && j < rgb.length; i += 4, j += 3) { rgb[j] = data[i]; rgb[j+1] = data[i+1]; rgb[j+2] = data[i+2]; } }
|
| 70 |
+
else if (channels === 1) { for (let i = 0, j = 0; i < data.length && j < rgb.length; i++, j += 3) { rgb[j] = rgb[j+1] = rgb[j+2] = data[i]; } }
|
| 71 |
+
return rgb.subarray(0, expectedLen);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 72 |
}
|
| 73 |
|
| 74 |
// ==========================================
|
| 75 |
+
// SCHÉMA
|
| 76 |
// ==========================================
|
| 77 |
db.exec(`
|
| 78 |
+
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, created_at INTEGER DEFAULT (strftime('%s','now')));
|
| 79 |
+
CREATE TABLE IF NOT EXISTS folders (id INTEGER PRIMARY KEY AUTOINCREMENT, parent_id INTEGER REFERENCES folders(id) ON DELETE CASCADE, owner_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, name TEXT NOT NULL, created_at INTEGER DEFAULT (strftime('%s','now')), UNIQUE(parent_id, owner_id, name));
|
| 80 |
+
CREATE TABLE IF NOT EXISTS files (id TEXT PRIMARY KEY, folder_id INTEGER REFERENCES folders(id) ON DELETE SET NULL, owner_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, name TEXT NOT NULL, mime TEXT NOT NULL, size INTEGER NOT NULL, chunk_count INTEGER DEFAULT 1, status TEXT DEFAULT 'pending', progress REAL DEFAULT 0, error_msg TEXT, encryption_key_hash TEXT NOT NULL, master_key_enc BLOB, created_at INTEGER DEFAULT (strftime('%s','now')), updated_at INTEGER DEFAULT (strftime('%s','now')));
|
| 81 |
+
CREATE TABLE IF NOT EXISTS file_chunks (id INTEGER PRIMARY KEY AUTOINCREMENT, file_id TEXT NOT NULL REFERENCES files(id) ON DELETE CASCADE, chunk_index INTEGER NOT NULL, imgbb_url TEXT, imgbb_delete_url TEXT, imgbb_id TEXT, size INTEGER NOT NULL, encrypted_len INTEGER NOT NULL, UNIQUE(file_id, chunk_index));
|
| 82 |
+
CREATE TABLE IF NOT EXISTS share_links (token TEXT PRIMARY KEY, file_id TEXT NOT NULL REFERENCES files(id) ON DELETE CASCADE, owner_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, type TEXT NOT NULL, created_at INTEGER DEFAULT (strftime('%s','now')), expires_at INTEGER);
|
| 83 |
+
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')));
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 84 |
CREATE INDEX IF NOT EXISTS idx_files_owner ON files(owner_id);
|
| 85 |
CREATE INDEX IF NOT EXISTS idx_chunks_file ON file_chunks(file_id);
|
| 86 |
CREATE INDEX IF NOT EXISTS idx_share_owner ON share_links(owner_id);
|
| 87 |
+
CREATE INDEX IF NOT EXISTS idx_ushares_with ON user_shares(shared_with);
|
| 88 |
`);
|
| 89 |
|
| 90 |
// ==========================================
|
|
|
|
| 92 |
// ==========================================
|
| 93 |
function runMigrations() {
|
| 94 |
console.log('🔍 Vérification migrations BDD...');
|
|
|
|
| 95 |
const usersCols = db.prepare("PRAGMA table_info(users)").all().map(c => c.name);
|
| 96 |
+
if (!usersCols.includes('tos_accepted')) db.exec(`ALTER TABLE users ADD COLUMN tos_accepted INTEGER DEFAULT 0;`);
|
|
|
|
|
|
|
|
|
|
| 97 |
|
| 98 |
const filesCols = db.prepare("PRAGMA table_info(files)").all().map(c => c.name);
|
| 99 |
const filesDefs = [
|
|
|
|
| 106 |
{ name: 'chunk_count', def: 'INTEGER DEFAULT 1' },
|
| 107 |
{ name: 'updated_at', def: 'INTEGER DEFAULT 0' },
|
| 108 |
];
|
|
|
|
| 109 |
let updatedAdded = false;
|
| 110 |
for (const col of filesDefs) {
|
| 111 |
if (!filesCols.includes(col.name)) {
|
|
|
|
| 112 |
db.exec(`ALTER TABLE files ADD COLUMN ${col.name} ${col.def};`);
|
| 113 |
if (col.name === 'updated_at') updatedAdded = true;
|
| 114 |
}
|
| 115 |
}
|
| 116 |
+
if (updatedAdded) db.exec(`UPDATE files SET updated_at = strftime('%s','now') WHERE updated_at = 0;`);
|
|
|
|
|
|
|
|
|
|
|
|
|
| 117 |
|
| 118 |
const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table'").all().map(t => t.name);
|
| 119 |
+
if (!tables.includes('folders')) db.exec(`CREATE TABLE folders (id INTEGER PRIMARY KEY AUTOINCREMENT, parent_id INTEGER REFERENCES folders(id) ON DELETE CASCADE, owner_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, name TEXT NOT NULL, created_at INTEGER DEFAULT (strftime('%s','now')), UNIQUE(parent_id, owner_id, name));`);
|
| 120 |
+
if (!tables.includes('share_links')) db.exec(`CREATE TABLE share_links (token TEXT PRIMARY KEY, file_id TEXT NOT NULL REFERENCES files(id) ON DELETE CASCADE, owner_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, type TEXT NOT NULL, created_at INTEGER DEFAULT (strftime('%s','now')), expires_at INTEGER);`);
|
| 121 |
+
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);`);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 122 |
|
| 123 |
const sessionsCols = tables.includes('sessions') ? db.prepare("PRAGMA table_info(sessions)").all().map(c => c.name) : [];
|
| 124 |
if (tables.includes('sessions') && (!sessionsCols.includes('data') || !sessionsCols.includes('expires_at'))) {
|
|
|
|
| 125 |
db.exec(`DROP TABLE sessions; CREATE TABLE sessions (sid TEXT PRIMARY KEY, data TEXT NOT NULL, expires_at INTEGER NOT NULL); CREATE INDEX idx_sessions_expires ON sessions(expires_at);`);
|
| 126 |
} else if (!tables.includes('sessions')) {
|
| 127 |
+
db.exec(`CREATE TABLE sessions (sid TEXT PRIMARY KEY, data TEXT NOT NULL, expires_at INTEGER NOT NULL); CREATE INDEX idx_sessions_expires ON sessions(expires_at);`);
|
|
|
|
| 128 |
}
|
|
|
|
| 129 |
console.log('✅ Migrations terminées.');
|
| 130 |
}
|
| 131 |
runMigrations();
|
|
|
|
| 133 |
db.exec(`
|
| 134 |
CREATE INDEX IF NOT EXISTS idx_files_folder ON files(folder_id);
|
| 135 |
DROP TRIGGER IF EXISTS update_file_ts;
|
| 136 |
+
CREATE TRIGGER update_file_ts AFTER UPDATE ON files BEGIN UPDATE files SET updated_at = strftime('%s','now') WHERE id = NEW.id; END;
|
|
|
|
|
|
|
|
|
|
| 137 |
`);
|
| 138 |
|
| 139 |
// --- SESSION STORE ---
|
| 140 |
class SQLiteStore extends session.Store {
|
| 141 |
+
get(sid, cb) { try { const r = db.prepare('SELECT data, expires_at FROM sessions WHERE sid=?').get(sid); if(!r) return cb(null, null); if(r.expires_at < Date.now()){ db.prepare('DELETE FROM sessions WHERE sid=?').run(sid); return cb(null, null); } cb(null, JSON.parse(r.data)); } catch(e){ cb(e); } }
|
| 142 |
+
set(sid, sess, cb) { try { const exp = sess.cookie?.expires ? new Date(sess.cookie.expires).getTime() : Date.now() + 30*24*60*60*1000; db.prepare('INSERT OR REPLACE INTO sessions(sid,data,expires_at) VALUES(?,?,?)').run(sid, JSON.stringify(sess), exp); cb(null); } catch(e){ cb(e); } }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 143 |
destroy(sid, cb) { try { db.prepare('DELETE FROM sessions WHERE sid=?').run(sid); cb(null); } catch(e){ cb(e); } }
|
| 144 |
clear(cb) { try { db.prepare('DELETE FROM sessions').run(); cb(null); } catch(e){ cb(e); } }
|
| 145 |
length(cb) { try { const r = db.prepare('SELECT COUNT(*) as c FROM sessions').get(); cb(null, r.c); } catch(e){ cb(e); } }
|
|
|
|
| 147 |
}
|
| 148 |
const sessionStore = new SQLiteStore();
|
| 149 |
|
| 150 |
+
// --- ADMIN ---
|
| 151 |
const adminRow = db.prepare('SELECT * FROM users WHERE username=?').get(ADMIN_USER);
|
| 152 |
+
if (!adminRow) {
|
| 153 |
+
const salt = randomBytes(16);
|
| 154 |
+
const hash = await deriveKey(process.env.ADMIN_PASS || 'ChangeMeNow!', salt);
|
| 155 |
+
db.prepare('INSERT INTO users(username,password_hash,salt,is_admin,tos_accepted) VALUES(?,?,?,1,1)').run(ADMIN_USER, hash, salt);
|
| 156 |
+
console.log(`👑 Admin: ${ADMIN_USER} / Pass: ${process.env.ADMIN_PASS || 'ChangeMeNow!'}`);
|
| 157 |
}
|
| 158 |
|
| 159 |
// --- EXPRESS ---
|
|
|
|
| 161 |
app.set('trust proxy', 1);
|
| 162 |
app.use(express.json({ limit: '50mb' }));
|
| 163 |
app.use(express.urlencoded({ extended: true, limit: '50mb' }));
|
| 164 |
+
app.use(session({ secret: SESSION_SECRET, store: sessionStore, resave: false, saveUninitialized: false, cookie: { secure: false, httpOnly: true, maxAge: 30*24*60*60*1000, sameSite: 'lax' } }));
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 165 |
|
| 166 |
const requireAuth = (req, res, next) => { if(!req.session.userId) return res.status(401).json({error:'Non authentifié'}); next(); };
|
| 167 |
const requireTos = (req, res, next) => { if(!req.session.tosAccepted) return res.status(403).json({error:'CGU non acceptées', needTos:true}); next(); };
|
| 168 |
|
| 169 |
+
// --- CACHE ---
|
| 170 |
+
class ByteLRUCache extends LRUCache { constructor(){ super({maxSize:CACHE_MAX_BYTES, sizeCalculation:v=>v.size}); } }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 171 |
const fileCache = new ByteLRUCache();
|
| 172 |
+
setInterval(()=>{ const m = process.memoryUsage(); if(m.rss > 13*1024*1024*1024){ fileCache.clear(); if(global.gc) global.gc(); } }, 30000);
|
| 173 |
+
|
| 174 |
+
// --- NSFW ---
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 175 |
let nsfwModel = null;
|
| 176 |
+
async function loadNsfwModel(){ try{ await tf.setBackend('wasm'); tf.wasm.setWasmPaths('/app/tfjs_wasm/','tfjs-backend-wasm.wasm'); await tf.ready(); nsfwModel = await nsfwjs.load('/app/tfjs_wasm/',{type:'mobilenet_v2',size:224}); console.log('🛡️ NSFW WASM chargé'); }catch(e){ console.error('❌ NSFW load fail:',e.message); } }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 177 |
loadNsfwModel();
|
| 178 |
+
async function checkNsfw(buf){ if(!nsfwModel) return {safe:true}; try{ const t=tf.node.decodeImage(buf,3).resizeNearestNeighbor([224,224]).expandDims(0); const p=await nsfwModel.classify(t); t.dispose(); const porn=p.find(x=>x.className==='Porn'||x.className==='Sexual Activity')?.probability||0; const hentai=p.find(x=>x.className==='Hentai')?.probability||0; if(porn>0.85||hentai>0.9) return {safe:false}; return {safe:true}; }catch(e){ return {safe:true}; } }
|
| 179 |
|
| 180 |
+
// --- QUEUE ---
|
| 181 |
+
class UploadQueue{ constructor(c){ this.c=c; this.r=0; this.q=[]; } add(t){ return new Promise((res,rej)=>{ this.q.push({t,res,rej}); this.proc(); }); } async proc(){ if(this.r>=this.c||!this.q.length) return; this.r++; const {t,res,rej}=this.q.shift(); try{ res(await t()); }catch(e){ rej(e); }finally{ this.r--; this.proc(); } } }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 182 |
const uploadQueue = new UploadQueue(MAX_CONCURRENT_UPLOADS);
|
| 183 |
|
| 184 |
// --- IMGBB ---
|
| 185 |
+
async function imgbbUpload(buf){ const f=new FormData(); f.append('image',buf.toString('base64')); const {data}=await axios.post(`https://api.imgbb.com/1/upload?key=${IMGBB_API_KEY}`,f,{headers:f.getHeaders(),timeout:120000,maxContentLength:Infinity,maxBodyLength:Infinity}); if(!data.success) throw new Error(data.error?.message||'ImgBB fail'); return {url:data.data.url,deleteUrl:data.data.delete_url,id:data.data.id}; }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 186 |
async function imgbbDelete(u){ try{await axios.get(u,{timeout:10000});}catch(_){} }
|
| 187 |
|
| 188 |
+
// --- KEYS ---
|
| 189 |
const SERVER_MASTER_KEY = createHash('sha256').update(SESSION_SECRET+'|PixelDriveMaster').digest();
|
| 190 |
+
function encryptMasterKey(k){ const iv=randomBytes(12); const c=createCipheriv('aes-256-gcm',SERVER_MASTER_KEY,iv); return Buffer.concat([iv,c.update(k),c.final(),c.getAuthTag()]); }
|
| 191 |
+
function decryptMasterKey(b){ const iv=b.subarray(0,12), tag=b.subarray(-16), d=b.subarray(12,-16); const dc=createDecipheriv('aes-256-gcm',SERVER_MASTER_KEY,iv); dc.setAuthTag(tag); return Buffer.concat([dc.update(d),dc.final()]); }
|
| 192 |
+
async function getMasterKey(fid){ const r=db.prepare('SELECT master_key_enc FROM files WHERE id=?').get(fid); if(!r?.master_key_enc) throw new Error('Clé manquante'); return decryptMasterKey(r.master_key_enc); }
|
| 193 |
+
|
| 194 |
+
// ==========================================
|
| 195 |
+
// ✅ CONTRÔLE D'ACCÈS (partage entre utilisateurs)
|
| 196 |
+
// ==========================================
|
| 197 |
+
function canAccessFolder(userId, folderId){
|
| 198 |
+
let cur = db.prepare('SELECT * FROM folders WHERE id=?').get(folderId);
|
| 199 |
+
let depth = 0;
|
| 200 |
+
while (cur && depth < 50) {
|
| 201 |
+
if (cur.owner_id === userId) return true;
|
| 202 |
+
if (db.prepare('SELECT 1 FROM user_shares WHERE folder_id=? AND shared_with=?').get(cur.id, userId)) return true;
|
| 203 |
+
cur = cur.parent_id ? db.prepare('SELECT * FROM folders WHERE id=?').get(cur.parent_id) : null;
|
| 204 |
+
depth++;
|
| 205 |
+
}
|
| 206 |
+
return false;
|
| 207 |
}
|
| 208 |
+
function canAccessFile(userId, fileId){
|
| 209 |
+
const f = db.prepare('SELECT * FROM files WHERE id=?').get(fileId);
|
| 210 |
+
if (!f) return null;
|
| 211 |
+
if (f.owner_id === userId) return f;
|
| 212 |
+
if (db.prepare('SELECT 1 FROM user_shares WHERE file_id=? AND shared_with=?').get(fileId, userId)) return f;
|
| 213 |
+
if (f.folder_id && canAccessFolder(userId, f.folder_id)) return f;
|
| 214 |
+
return null;
|
| 215 |
}
|
| 216 |
|
| 217 |
// --- THUMB ---
|
| 218 |
+
async function genThumb(fid,mime,key){
|
| 219 |
+
if(!mime.startsWith('image/')&&!mime.startsWith('video/')) return;
|
| 220 |
+
try{
|
| 221 |
+
const c = db.prepare('SELECT imgbb_url, size FROM file_chunks WHERE file_id=? AND chunk_index=0').get(fid);
|
| 222 |
+
if(!c) return;
|
| 223 |
+
const {data} = await axios.get(c.imgbb_url,{responseType:'arraybuffer',timeout:30000});
|
| 224 |
+
const enc = await pngToBuffer(Buffer.from(data), c.size + OVERHEAD);
|
| 225 |
+
const dec = decryptChunk(enc,key);
|
| 226 |
+
let buf;
|
| 227 |
+
if(mime.startsWith('image/')) buf = await sharp(dec).rotate().resize(256,256,{fit:'inside'}).jpeg({quality:80}).toBuffer();
|
| 228 |
+
else buf = await sharp({create:{width:256,height:144,channels:3,background:'#1e1e2f'}}).jpeg().toBuffer();
|
| 229 |
+
await sharp(buf).toFile(join(THUMB_DIR,`${fid}.jpg`));
|
| 230 |
+
}catch(e){ console.warn(`Thumb ${fid}:`,e.message); }
|
| 231 |
}
|
| 232 |
|
| 233 |
+
// --- STREAM ---
|
| 234 |
+
async function streamFile(res,file,key,range){
|
| 235 |
+
const chunks = db.prepare('SELECT chunk_index,imgbb_url,size FROM file_chunks WHERE file_id=? ORDER BY chunk_index').all(file.id);
|
| 236 |
+
const total = file.size;
|
| 237 |
+
let s = 0, e = total - 1;
|
| 238 |
+
if(range){ const p = range.replace('bytes=','').split('-'); s = parseInt(p[0]); e = p[1] ? parseInt(p[1]) : total - 1; }
|
| 239 |
+
if(s >= total || e >= total) return res.status(416).send('Range Not Satisfiable');
|
| 240 |
+
res.set({'Content-Type':file.mime,'Content-Length':e-s+1,'Accept-Ranges':'bytes','Content-Disposition':`inline; filename="${encodeURIComponent(file.name)}"`});
|
| 241 |
+
if(range){ res.status(206); res.set('Content-Range', `bytes ${s}-${e}/${total}`); }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 242 |
console.log(`📥 Stream ${file.name} (${((e-s+1)/1024/1024).toFixed(2)} MB, ${chunks.length} chunk(s))`);
|
| 243 |
+
let cur = 0;
|
| 244 |
+
for(const c of chunks){
|
| 245 |
+
const cs = cur, ce = cur + c.size - 1;
|
| 246 |
+
if(ce >= s && cs <= e){
|
| 247 |
+
const off = Math.max(0, s - cs), len = Math.min(c.size - off, e - Math.max(s, cs) + 1);
|
| 248 |
+
try{
|
| 249 |
+
const ck = `chunk:${file.id}:${c.chunk_index}`;
|
| 250 |
+
let dec = fileCache.get(ck)?.data;
|
| 251 |
+
if(!dec){
|
|
|
|
|
|
|
|
|
|
| 252 |
const t0 = Date.now();
|
| 253 |
+
const {data} = await axios.get(c.imgbb_url,{responseType:'arraybuffer',timeout:120000});
|
|
|
|
|
|
|
|
|
|
| 254 |
const t1 = Date.now();
|
| 255 |
console.log(` ⬇️ Chunk ${c.chunk_index}: ${(data.byteLength/1024/1024).toFixed(2)} MB en ${t1-t0}ms`);
|
| 256 |
+
const enc = await pngToBuffer(Buffer.from(data), c.size + OVERHEAD);
|
|
|
|
| 257 |
const t2 = Date.now();
|
| 258 |
console.log(` 🖼️ PNG→Buffer: ${t2-t1}ms`);
|
| 259 |
+
dec = decryptChunk(enc, key);
|
| 260 |
+
console.log(` 🔓 Decrypt: ${Date.now()-t2}ms`);
|
| 261 |
+
fileCache.set(ck, {data: dec, size: dec.length});
|
| 262 |
+
}
|
| 263 |
+
const sl = dec.subarray(off, off + len);
|
| 264 |
+
if(!res.write(sl)) await new Promise(r => res.once('drain', r));
|
| 265 |
+
}catch(err){ console.error(`❌ Stream chunk ${c.chunk_index}:`, err.message); return res.destroy(err); }
|
| 266 |
+
}
|
| 267 |
+
cur += c.size;
|
| 268 |
+
if(cs > e) break;
|
| 269 |
+
}
|
| 270 |
+
res.end();
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 271 |
}
|
| 272 |
|
| 273 |
// --- SSE ---
|
| 274 |
+
const sseClients = new Map();
|
| 275 |
+
function broadcast(fid,d){ const cs = sseClients.get(fid); if(cs){ const p = `data: ${JSON.stringify(d)}\n\n`; for(const r of cs) r.write(p); } }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 276 |
|
| 277 |
// ==========================================
|
| 278 |
// ROUTES
|
|
|
|
| 284 |
app.get('/api/tos',(_,res)=>res.send(`PIXELDRIVE CGU\n1. RESPONSABILITÉ TOTALE\n2. AUCUNE GARANTIE (Projet test)\n3. INTERDITS: Illégal, NSFW, Abus\n4. CHIFFRÉ AES-256-GCM + Filtre NSFW Local\n5. ACCEPTATION = Responsabilité légale`));
|
| 285 |
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});});
|
| 286 |
|
| 287 |
+
// ✅ Folders (avec accès partagé)
|
| 288 |
+
app.get('/api/folders',requireAuth,(req,res)=>{
|
| 289 |
+
const p = req.query.parent_id ? parseInt(req.query.parent_id) : null;
|
| 290 |
+
if (p !== null) {
|
| 291 |
+
if (!canAccessFolder(req.session.userId, p)) return res.status(403).json({error:'Accès refusé'});
|
| 292 |
+
return res.json(db.prepare('SELECT * FROM folders WHERE parent_id=? ORDER BY name COLLATE NOCASE').all(p));
|
| 293 |
+
}
|
| 294 |
+
res.json(db.prepare('SELECT * FROM folders WHERE owner_id=? AND parent_id IS NULL ORDER BY name COLLATE NOCASE').all(req.session.userId));
|
| 295 |
+
});
|
| 296 |
app.post('/api/folders',requireAuth,requireTos,(req,res)=>{const{n,p}=req.body;if(!n)return res.status(400).json({error:'Nom requis'});try{const i=db.prepare('INSERT INTO folders(name,parent_id,owner_id) VALUES(?,?,?)').run(n,p||null,req.session.userId);res.json({id:i.lastInsertRowid,name:n,parent_id:p});}catch(e){res.status(409).json({error:'Existe'});}});
|
| 297 |
app.delete('/api/folders/:id',requireAuth,(req,res)=>{db.prepare('DELETE FROM folders WHERE id=? AND owner_id=?').run(req.params.id,req.session.userId);res.json({ok:true});});
|
| 298 |
|
| 299 |
+
// ✅ Files (avec accès partagé)
|
| 300 |
+
app.get('/api/files',requireAuth,(req,res)=>{
|
| 301 |
+
const f = req.query.folder_id ? parseInt(req.query.folder_id) : null;
|
| 302 |
+
if (f !== null) {
|
| 303 |
+
if (!canAccessFolder(req.session.userId, f)) return res.status(403).json({error:'Accès refusé'});
|
| 304 |
+
return res.json(db.prepare('SELECT * FROM files WHERE folder_id=? ORDER BY name COLLATE NOCASE').all(f));
|
| 305 |
+
}
|
| 306 |
+
res.json(db.prepare('SELECT * FROM files WHERE owner_id=? AND folder_id IS NULL ORDER BY name COLLATE NOCASE').all(req.session.userId));
|
| 307 |
+
});
|
| 308 |
+
|
| 309 |
+
// ✅ PARTAGE ENTRE UTILISATEURS
|
| 310 |
+
app.post('/api/share-user',requireAuth,requireTos,(req,res)=>{
|
| 311 |
+
const { type, id, username } = req.body;
|
| 312 |
+
if (!type || !id || !username) return res.status(400).json({error:'Champs manquants'});
|
| 313 |
+
const target = db.prepare('SELECT id FROM users WHERE username=?').get(username.trim());
|
| 314 |
+
if (!target) return res.status(404).json({error:'Utilisateur introuvable'});
|
| 315 |
+
if (target.id === req.session.userId) return res.status(400).json({error:'Impossible de partager avec vous-même'});
|
| 316 |
+
if (type === 'file') {
|
| 317 |
+
const f = db.prepare('SELECT id FROM files WHERE id=? AND owner_id=?').get(id, req.session.userId);
|
| 318 |
+
if (!f) return res.status(404).json({error:'Fichier introuvable'});
|
| 319 |
+
if (db.prepare('SELECT 1 FROM user_shares WHERE file_id=? AND shared_with=?').get(id, target.id)) return res.status(409).json({error:'Déjà partagé'});
|
| 320 |
+
db.prepare('INSERT INTO user_shares(file_id,owner_id,shared_with) VALUES(?,?,?)').run(id, req.session.userId, target.id);
|
| 321 |
+
} else {
|
| 322 |
+
const fo = db.prepare('SELECT id FROM folders WHERE id=? AND owner_id=?').get(id, req.session.userId);
|
| 323 |
+
if (!fo) return res.status(404).json({error:'Dossier introuvable'});
|
| 324 |
+
if (db.prepare('SELECT 1 FROM user_shares WHERE folder_id=? AND shared_with=?').get(id, target.id)) return res.status(409).json({error:'Déjà partagé'});
|
| 325 |
+
db.prepare('INSERT INTO user_shares(folder_id,owner_id,shared_with) VALUES(?,?,?)').run(id, req.session.userId, target.id);
|
| 326 |
+
}
|
| 327 |
+
res.json({ok:true});
|
| 328 |
+
});
|
| 329 |
|
| 330 |
+
app.get('/api/shared-with-me',requireAuth,(req,res)=>{
|
| 331 |
+
res.json(db.prepare(`
|
| 332 |
+
SELECT us.id as share_id, us.created_at as shared_at, u.username as owner_name,
|
| 333 |
+
us.file_id, us.folder_id,
|
| 334 |
+
f.name as file_name, f.mime, f.size, f.status,
|
| 335 |
+
fo.name as folder_name
|
| 336 |
+
FROM user_shares us
|
| 337 |
+
JOIN users u ON u.id = us.owner_id
|
| 338 |
+
LEFT JOIN files f ON f.id = us.file_id
|
| 339 |
+
LEFT JOIN folders fo ON fo.id = us.folder_id
|
| 340 |
+
WHERE us.shared_with = ?
|
| 341 |
+
ORDER BY us.created_at DESC
|
| 342 |
+
`).all(req.session.userId));
|
| 343 |
+
});
|
| 344 |
+
|
| 345 |
+
app.delete('/api/shared-with-me/:id',requireAuth,(req,res)=>{
|
| 346 |
+
db.prepare('DELETE FROM user_shares WHERE id=? AND shared_with=?').run(req.params.id, req.session.userId);
|
| 347 |
+
res.json({ok:true});
|
| 348 |
});
|
| 349 |
|
| 350 |
+
// Liens publics
|
| 351 |
+
app.get('/api/share-links',requireAuth,(req,res)=>{
|
| 352 |
+
res.json(db.prepare(`SELECT sl.*, f.name as file_name, f.mime, f.size FROM share_links sl LEFT JOIN files f ON sl.file_id = f.id WHERE sl.owner_id = ? ORDER BY sl.created_at DESC`).all(req.session.userId));
|
| 353 |
+
});
|
| 354 |
app.delete('/api/share-links/:token',requireAuth,(req,res)=>{
|
| 355 |
+
const r = db.prepare('DELETE FROM share_links WHERE token=? AND owner_id=?').run(req.params.token, req.session.userId);
|
| 356 |
+
if (r.changes === 0) return res.status(404).json({error:'Lien introuvable'});
|
| 357 |
res.json({ok:true});
|
| 358 |
});
|
| 359 |
|
| 360 |
+
// Upload
|
| 361 |
const upload = multer({dest:TEMP_DIR,limits:{fileSize:5*1024*1024*1024}});
|
| 362 |
app.post('/api/files/upload',requireAuth,requireTos,upload.single('file'),async(req,res)=>{
|
| 363 |
if(!req.file)return res.status(400).json({error:'Fichier manquant'});
|
|
|
|
| 403 |
for(const c of cs)if(c.imgbb_delete_url)await imgbbDelete(c.imgbb_delete_url);
|
| 404 |
}
|
| 405 |
}
|
|
|
|
| 406 |
function readChunk(p,o,l){return new Promise((res,rej)=>{const s=createReadStream(p,{start:o,end:o+l-1}),c=[];s.on('data',d=>c.push(d)).on('end',()=>res(Buffer.concat(c,l))).on('error',rej);});}
|
| 407 |
|
| 408 |
+
// ✅ Download (accès partagé autorisé)
|
| 409 |
+
app.get('/api/files/:id/download',requireAuth,async(req,res)=>{
|
| 410 |
+
const f = canAccessFile(req.session.userId, req.params.id);
|
| 411 |
+
if(!f)return res.status(404).json({error:'Introuvable'});
|
| 412 |
+
if(f.status!=='ready')return res.status(409).json({error:'Pas prêt',status:f.status});
|
| 413 |
+
const k = await getMasterKey(f.id);
|
| 414 |
+
await streamFile(res,f,k,req.headers.range);
|
| 415 |
+
});
|
| 416 |
|
|
|
|
| 417 |
app.post('/api/files/:id/share',requireAuth,async(req,res)=>{
|
| 418 |
const f=db.prepare('SELECT * FROM files WHERE id=? AND owner_id=?').get(req.params.id,req.session.userId);
|
| 419 |
if(!f||f.status!=='ready') return res.status(404).json({error:'Fichier non disponible'});
|
| 420 |
+
const pt=uuidv4(), dt2=uuidv4(), expires=Date.now()+7*24*3600000;
|
| 421 |
+
db.prepare('INSERT INTO share_links(token,file_id,owner_id,type,expires_at) VALUES(?,?,?,?,?)').run(pt,f.id,req.session.userId,'embed',expires);
|
| 422 |
+
db.prepare('INSERT INTO share_links(token,file_id,owner_id,type,expires_at) VALUES(?,?,?,?,?)').run(dt2,f.id,req.session.userId,'download',expires);
|
| 423 |
+
res.json({preview:`/s/${pt}`,download:`/d/${dt2}`,expires});
|
|
|
|
|
|
|
| 424 |
});
|
| 425 |
|
| 426 |
app.get('/api/files/:id/preview',requireAuth,async(req,res)=>{
|
| 427 |
const f=db.prepare('SELECT * FROM files WHERE id=? AND owner_id=?').get(req.params.id,req.session.userId);
|
| 428 |
if(!f||f.status!=='ready')return res.status(404).json({error:'Pas prêt'});
|
|
|
|
| 429 |
const t=uuidv4();
|
| 430 |
db.prepare('INSERT INTO share_links(token,file_id,owner_id,type,expires_at) VALUES(?,?,?,?,?)').run(t,f.id,req.session.userId,'embed',Date.now()+3600000);
|
| 431 |
res.json({url:`/s/${t}`});
|
|
|
|
| 453 |
|
| 454 |
app.delete('/api/files/:id',requireAuth,async(req,res)=>{const f=db.prepare('SELECT * FROM files WHERE id=? AND owner_id=?').get(req.params.id,req.session.userId);if(!f)return res.status(404).json({error:'Introuvable'});const cs=db.prepare('SELECT imgbb_delete_url FROM file_chunks WHERE file_id=?').all(f.id);for(const c of cs)if(c.imgbb_delete_url)await imgbbDelete(c.imgbb_delete_url);db.prepare('DELETE FROM files WHERE id=?').run(f.id);fileCache.delete(`chunk:${f.id}:0`);try{unlinkSync(join(THUMB_DIR,`${f.id}.jpg`));}catch(_){}res.json({ok:true});});
|
| 455 |
|
| 456 |
+
app.get('/api/files/:id/thumb',requireAuth,(req,res)=>{
|
| 457 |
+
if(!canAccessFile(req.session.userId, req.params.id)) return res.status(404).json({error:'Introuvable'});
|
| 458 |
+
const p=join(THUMB_DIR,`${req.params.id}.jpg`);
|
| 459 |
+
if(existsSync(p))return res.sendFile(p);
|
| 460 |
+
res.set('Content-Type','image/svg+xml');
|
| 461 |
+
res.send(`<svg xmlns="http://www.w3.org/2000/svg" width="256" height="144"><rect fill="#2a2a3e" width="100%" height="100%"/><text x="50%" y="50%" dominant-baseline="middle" text-anchor="middle" fill="#666" font-family="sans-serif" font-size="16">FILE</text></svg>`);
|
| 462 |
+
});
|
| 463 |
|
| 464 |
app.get('/api/files/:id/progress',requireAuth,(req,res)=>{res.set({'Content-Type':'text/event-stream','Cache-Control':'no-cache','Connection':'keep-alive'});res.flushHeaders();const id=req.params.id;if(!sseClients.has(id))sseClients.set(id,new Set());sseClients.get(id).add(res);req.on('close',()=>sseClients.get(id)?.delete(res));});
|
| 465 |
|
| 466 |
app.get('/api/admin/users',requireAuth,(req,res)=>{if(!req.session.isAdmin)return res.status(403).json({error:'Admin only'});res.json(db.prepare('SELECT id,username,is_admin,tos_accepted,created_at FROM users').all());});
|
|
|
|
| 467 |
app.get('/health',(_,res)=>res.json({ok:true,cache:fileCache.size,mem:process.memoryUsage()}));
|
| 468 |
|
| 469 |
app.use(express.static(__dirname));
|
| 470 |
+
app.get('*', (req, res) => {
|
| 471 |
+
if (/\.[a-zA-Z0-9]+$/.test(req.path)) return res.status(404).send('Not Found');
|
| 472 |
+
res.sendFile(join(__dirname, 'public.html'));
|
| 473 |
+
});
|
| 474 |
|
| 475 |
+
// ERROR HANDLER
|
|
|
|
|
|
|
| 476 |
app.use((err, req, res, next) => {
|
| 477 |
+
if (err.type === 'entity.parse.failed' || err instanceof SyntaxError) return res.status(400).json({ error: 'Bad Request' });
|
|
|
|
|
|
|
| 478 |
console.error('❌ Server Error:', err.message);
|
| 479 |
res.status(500).json({ error: 'Internal Server Error' });
|
| 480 |
});
|
| 481 |
|
| 482 |
+
const server = app.listen(PORT, '0.0.0.0', () => console.log(`🚀 PixelDrive v3.3 on http://0.0.0.0:${PORT} | Cache: 12GB LRU`));
|
|
|
|
|
|
|
|
|
|
| 483 |
process.on('SIGTERM', () => { console.log('SIGTERM'); server.close(() => process.exit(0)); });
|
| 484 |
|
| 485 |
function html(b){return `<!DOCTYPE html><html><head><meta charset="utf-8"/><meta name="viewport" content="width=device-width, initial-scale=1"/><title>PixelDrive</title><style>body{margin:0;font-family:system-ui,sans-serif;background:#0d0d12;color:#eee;display:flex;align-items:center;justify-content:center;height:100vh}</style></head><body>${b}</body></html>`;}
|