// auth.js const fs = require('fs'); const path = require('path'); const crypto = require('crypto'); const Database = require('better-sqlite3'); const bcrypt = require('bcrypt'); const jwt = require('jsonwebtoken'); // --- LOGGER BACKEND (Structured JSON for HF Logs) --- const LOG_LEVEL = process.env.LOG_LEVEL || 'info'; const levels = { debug: 0, info: 1, warn: 2, error: 3 }; function log(level, msg, meta = {}) { if (levels[level] < levels[LOG_LEVEL]) return; console.log(JSON.stringify({ ts: new Date().toISOString(), level: level.toUpperCase(), msg, ...meta })); } const DATA_ROOT = '/data'; const META_DB_PATH = path.join(DATA_ROOT, 'meta.sqlite'); const JWT_SECRET = process.env.JWT_SECRET || 'change_me_in_hf_secrets_please'; const GLOBAL_QUOTA_BYTES = 5 * 1024 * 1024 * 1024; const TOS_VERSION = 1; const metaDb = new Database(META_DB_PATH); metaDb.pragma('journal_mode = WAL'); metaDb.exec(` CREATE TABLE IF NOT EXISTS users (id TEXT PRIMARY KEY, email TEXT UNIQUE, password_hash TEXT, created_at INTEGER, totp_secret TEXT, accepted_tos INTEGER DEFAULT 0); CREATE TABLE IF NOT EXISTS databases (id TEXT PRIMARY KEY, owner_id TEXT, name TEXT, created_at INTEGER, size_bytes INTEGER DEFAULT 0, FOREIGN KEY(owner_id) REFERENCES users(id)); CREATE INDEX IF NOT EXISTS idx_dbs_owner ON databases(owner_id); `); try { metaDb.prepare('ALTER TABLE users ADD COLUMN accepted_tos INTEGER DEFAULT 0').run(); } catch(e) {} // --- CAPTCHA --- function generateCaptcha() { const a = Math.floor(Math.random() * 10) + 1, b = Math.floor(Math.random() * 10) + 1; const op = Math.random() > 0.5 ? '+' : '-'; const q = `${a} ${op} ${b}`, ans = op === '+' ? a + b : a - b; const token = crypto.randomBytes(16).toString('hex'); if (!global.captchaStore) global.captchaStore = new Map(); global.captchaStore.set(token, { answer: ans, expires: Date.now() + 300000 }); log('debug', 'Captcha generated', { token: token.slice(0,6) }); return { token, question: q }; } function verifyCaptcha(token, userAnswer) { const store = global.captchaStore || new Map(); const data = store.get(token); if (!data || Date.now() > data.expires) { if(data) store.delete(token); log('warn', 'Captcha expired/invalid', { token: token?.slice(0,6) }); return false; } const ok = parseInt(userAnswer) === data.answer; if (ok) store.delete(token); log('debug', 'Captcha verified', { success: ok, token: token?.slice(0,6) }); return ok; } // --- UTILS --- function getDirSizeSync(dir) { if (!fs.existsSync(dir)) return 0; let s=0; for(const f of fs.readdirSync(dir,{withFileTypes:true})){const p=path.join(dir,f.name); s+=f.isDirectory()?getDirSizeSync(p):fs.statSync(p).size;} return s; } function getGlobalUsed() { return getDirSizeSync(DATA_ROOT); } // --- AUTH --- function registerUser(email, password, captchaToken, captchaAnswer, acceptTos) { log('info', 'Register attempt', { email }); if (!verifyCaptcha(captchaToken, captchaAnswer)) { log('warn', 'Register fail: captcha'); throw new Error('Captcha invalide/expiré'); } if (!acceptTos) { log('warn', 'Register fail: tos'); throw new Error('Vous devez accepter les CGU'); } if (metaDb.prepare('SELECT id FROM users WHERE email = ?').get(email)) { log('warn', 'Register fail: exists', { email }); throw new Error('Email déjà utilisé'); } const id = crypto.randomUUID(), hash = bcrypt.hashSync(password, 10); metaDb.prepare('INSERT INTO users (id, email, password_hash, created_at, accepted_tos) VALUES (?, ?, ?, ?, ?)').run(id, email, hash, Date.now(), TOS_VERSION); log('info', 'User registered', { uid: id, email }); return { id, email }; } function loginUser(email, password) { log('info', 'Login attempt', { email }); const user = metaDb.prepare('SELECT * FROM users WHERE email = ?').get(email); if (!user || !bcrypt.compareSync(password, user.password_hash)) { log('warn', 'Login fail: creds', { email }); throw new Error('Identifiants invalides'); } if (user.accepted_tos < TOS_VERSION) { log('info', 'Login: TOS required', { uid: user.id, current: user.accepted_tos, required: TOS_VERSION }); return { tosRequired: true, tempUser: { id: user.id, email: user.email } }; } const token = jwt.sign({ uid: user.id, email: user.email }, JWT_SECRET, { expiresIn: '30d' }); log('info', 'Login success', { uid: user.id }); return { token, user: { id: user.id, email: user.email } }; } function acceptTos(userId) { log('info', 'TOS accepted', { uid: userId }); metaDb.prepare('UPDATE users SET accepted_tos = ? WHERE id = ?').run(TOS_VERSION, userId); const user = metaDb.prepare('SELECT id, email FROM users WHERE id = ?').get(userId); return { token: jwt.sign({ uid: user.id, email: user.email }, JWT_SECRET, { expiresIn: '30d' }), user: { id: user.id, email: user.email } }; } function verifyToken(token) { try { return jwt.verify(token, JWT_SECRET); } catch (e) { log('debug', 'Token verify fail', { err: e.message }); return null; } } // --- DATABASES --- function createDatabase(ownerId, dbName, captchaToken, captchaAnswer) { log('info', 'Create DB attempt', { ownerId, name: dbName }); if (!verifyCaptcha(captchaToken, captchaAnswer)) { log('warn', 'Create DB fail: captcha'); throw new Error('Captcha invalide/expiré'); } if (getGlobalUsed() >= GLOBAL_QUOTA_BYTES) { log('error', 'Create DB fail: global quota'); throw new Error('Quota global 5Go atteint'); } const safeName = dbName.replace(/[^a-zA-Z0-9_-]/g, '').substring(0, 50); if (!safeName) throw new Error('Nom invalide'); const dbId = crypto.randomUUID(), dbPath = path.join(DATA_ROOT, ownerId, `${dbId}.sqlite`); fs.mkdirSync(path.dirname(dbPath), { recursive: true }); const newDb = new Database(dbPath); newDb.exec('PRAGMA journal_mode = WAL; CREATE TABLE IF NOT EXISTS _meta (k TEXT PRIMARY KEY, v TEXT);'); newDb.close(); metaDb.prepare('INSERT INTO databases (id, owner_id, name, created_at, size_bytes) VALUES (?, ?, ?, ?, 0)').run(dbId, ownerId, safeName, Date.now()); log('info', 'Database created', { dbId, ownerId, name: safeName }); return { id: dbId, name: safeName }; } function listDatabases(ownerId) { return metaDb.prepare('SELECT id, name, created_at, size_bytes FROM databases WHERE owner_id = ? ORDER BY created_at DESC').all(ownerId); } function getDatabaseFilePath(ownerId, dbId) { const row = metaDb.prepare('SELECT id FROM databases WHERE id = ? AND owner_id = ?').get(dbId, ownerId); return row ? path.join(DATA_ROOT, ownerId, `${dbId}.sqlite`) : null; } function deleteDatabase(ownerId, dbId) { log('warn', 'Delete DB', { ownerId, dbId }); const fp = getDatabaseFilePath(ownerId, dbId); if (fp && fs.existsSync(fp)) fs.unlinkSync(fp); metaDb.prepare('DELETE FROM databases WHERE id = ? AND owner_id = ?').run(dbId, ownerId); } function updateDbSize(dbId) { const row = metaDb.prepare('SELECT owner_id FROM databases WHERE id = ?').get(dbId); if(!row) return 0; const fp = path.join(DATA_ROOT, row.owner_id, `${dbId}.sqlite`); const size = fs.existsSync(fp) ? fs.statSync(fp).size : 0; metaDb.prepare('UPDATE databases SET size_bytes = ? WHERE id = ?').run(size, dbId); return size; } function getUserStats(ownerId) { const dbs = metaDb.prepare('SELECT SUM(size_bytes) as total, COUNT(*) as count FROM databases WHERE owner_id = ?').get(ownerId); return { totalBytes: dbs.total || 0, count: dbs.count || 0 }; } function executeSql(ownerId, dbId, sql, params = []) { const fp = getDatabaseFilePath(ownerId, dbId); if (!fp || !fs.existsSync(fp)) { log('error', 'SQL Exec: DB not found', { ownerId, dbId }); throw new Error('DB introuvable'); } const isSelect = sql.trim().toUpperCase().startsWith('SELECT') || sql.trim().toUpperCase().startsWith('PRAGMA'); log('debug', 'SQL Exec', { ownerId, dbId, type: isSelect ? 'READ' : 'WRITE', sql: sql.substring(0, 100) }); const db = new Database(fp, { readonly: false }); try { const stmt = db.prepare(sql); const result = isSelect ? stmt.all(...params) : stmt.run(...params); updateDbSize(dbId); return { success: true, data: result, changes: stmt.changes }; } catch (e) { log('error', 'SQL Error', { sql: sql.substring(0,100), err: e.message }); return { success: false, error: e.message }; } finally { db.close(); } } module.exports = { generateCaptcha, verifyCaptcha, TOS_VERSION, registerUser, loginUser, acceptTos, verifyToken, createDatabase, listDatabases, getDatabaseFilePath, deleteDatabase, executeSql, getUserStats, getGlobalUsed, GLOBAL_QUOTA_BYTES };