| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| import Database from 'better-sqlite3'; |
| import { randomUUID, randomInt } from 'crypto'; |
|
|
| const DB_PATH = process.env.DB_PATH || '/data/medos.db'; |
|
|
| let _db: Database.Database | null = null; |
|
|
| export function getDb(): Database.Database { |
| if (_db) return _db; |
| _db = new Database(DB_PATH); |
| _db.pragma('journal_mode = WAL'); |
| _db.pragma('busy_timeout = 5000'); |
| _db.pragma('foreign_keys = ON'); |
| runMigrations(_db); |
| seedAdmin(); |
| return _db; |
| } |
|
|
| |
| |
| |
|
|
| function runMigrations(db: Database.Database): void { |
| const version = db.pragma('user_version', { simple: true }) as number; |
|
|
| if (version < 1) { |
| db.exec(` |
| CREATE TABLE IF NOT EXISTS users ( |
| id TEXT PRIMARY KEY, |
| email TEXT UNIQUE NOT NULL COLLATE NOCASE, |
| password TEXT NOT NULL, |
| display_name TEXT, |
| email_verified INTEGER DEFAULT 0, |
| is_admin INTEGER DEFAULT 0, |
| verification_code TEXT, |
| verification_expires TEXT, |
| reset_token TEXT, |
| reset_expires TEXT, |
| created_at TEXT DEFAULT (datetime('now')), |
| updated_at TEXT DEFAULT (datetime('now')) |
| ); |
| |
| CREATE TABLE IF NOT EXISTS sessions ( |
| token TEXT PRIMARY KEY, |
| user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, |
| expires_at TEXT NOT NULL, |
| created_at TEXT DEFAULT (datetime('now')) |
| ); |
| |
| CREATE TABLE IF NOT EXISTS health_data ( |
| id TEXT PRIMARY KEY, |
| user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, |
| type TEXT NOT NULL, |
| data TEXT NOT NULL, |
| created_at TEXT DEFAULT (datetime('now')), |
| updated_at TEXT DEFAULT (datetime('now')) |
| ); |
| |
| CREATE TABLE IF NOT EXISTS chat_history ( |
| id TEXT PRIMARY KEY, |
| user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, |
| preview TEXT, |
| messages TEXT NOT NULL, |
| topic TEXT, |
| created_at TEXT DEFAULT (datetime('now')) |
| ); |
| |
| CREATE INDEX IF NOT EXISTS idx_health_user_type ON health_data(user_id, type); |
| CREATE INDEX IF NOT EXISTS idx_chat_user ON chat_history(user_id, created_at DESC); |
| CREATE INDEX IF NOT EXISTS idx_sessions_expires ON sessions(expires_at); |
| CREATE INDEX IF NOT EXISTS idx_users_email ON users(email); |
| |
| PRAGMA user_version = 1; |
| `); |
| } |
|
|
| if (version < 2) { |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| db.exec(` |
| CREATE TABLE IF NOT EXISTS user_settings ( |
| user_id TEXT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, |
| language TEXT, |
| country TEXT, |
| units TEXT, |
| default_model TEXT, |
| theme TEXT, |
| ehr TEXT NOT NULL DEFAULT '{}', |
| hf_token_encrypted TEXT, |
| updated_at TEXT DEFAULT (datetime('now')) |
| ); |
| |
| CREATE TABLE IF NOT EXISTS audit_log ( |
| id TEXT PRIMARY KEY, |
| user_id TEXT, |
| action TEXT NOT NULL, |
| ip TEXT, |
| meta TEXT NOT NULL DEFAULT '{}', |
| created_at TEXT DEFAULT (datetime('now')) |
| ); |
| CREATE INDEX IF NOT EXISTS idx_audit_user_time ON audit_log(user_id, created_at DESC); |
| CREATE INDEX IF NOT EXISTS idx_audit_action_time ON audit_log(action, created_at DESC); |
| |
| CREATE TABLE IF NOT EXISTS scan_log ( |
| id TEXT PRIMARY KEY, |
| user_id TEXT, |
| ip TEXT, |
| status INTEGER NOT NULL, |
| bytes INTEGER NOT NULL DEFAULT 0, |
| latency_ms INTEGER NOT NULL DEFAULT 0, |
| model TEXT, |
| created_at TEXT DEFAULT (datetime('now')) |
| ); |
| CREATE INDEX IF NOT EXISTS idx_scan_user_time ON scan_log(user_id, created_at DESC); |
| |
| PRAGMA user_version = 2; |
| `); |
| } |
|
|
| if (version < 3) { |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| const cols = db |
| .prepare(`PRAGMA table_info(users)`) |
| .all() as Array<{ name: string }>; |
| const has = (name: string) => cols.some((c) => c.name === name); |
|
|
| if (!has('is_active')) { |
| db.exec(`ALTER TABLE users ADD COLUMN is_active INTEGER NOT NULL DEFAULT 1;`); |
| } |
| if (!has('last_login_at')) { |
| db.exec(`ALTER TABLE users ADD COLUMN last_login_at TEXT;`); |
| } |
| if (!has('disabled_reason')) { |
| db.exec(`ALTER TABLE users ADD COLUMN disabled_reason TEXT;`); |
| } |
| db.exec(` |
| CREATE INDEX IF NOT EXISTS idx_users_is_active ON users(is_active); |
| PRAGMA user_version = 3; |
| `); |
| } |
| } |
|
|
| |
| |
| |
|
|
| export function genId(): string { |
| return randomUUID(); |
| } |
|
|
| export function genToken(): string { |
| return randomUUID() + '-' + randomUUID(); |
| } |
|
|
| |
| export function genVerificationCode(): string { |
| return String(randomInt(100000, 999999)); |
| } |
|
|
| |
| export function codeExpiry(): string { |
| return new Date(Date.now() + 15 * 60 * 1000).toISOString(); |
| } |
|
|
| |
| export function resetExpiry(): string { |
| return new Date(Date.now() + 60 * 60 * 1000).toISOString(); |
| } |
|
|
| |
| export function sessionExpiry(): string { |
| return new Date(Date.now() + 30 * 86400 * 1000).toISOString(); |
| } |
|
|
| export function pruneExpiredSessions(): void { |
| const db = getDb(); |
| db.prepare("DELETE FROM sessions WHERE expires_at < datetime('now')").run(); |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| export function seedAdmin(): void { |
| const db = getDb(); |
| const adminEmail = (process.env.ADMIN_EMAIL || 'admin@medos.health').toLowerCase(); |
| const adminPassword = process.env.ADMIN_PASSWORD || 'admin123456'; |
|
|
| const existing = db.prepare('SELECT id FROM users WHERE email = ?').get(adminEmail); |
| if (existing) return; |
|
|
| const bcrypt = require('bcryptjs'); |
| const id = genId(); |
| const hash = bcrypt.hashSync(adminPassword, 10); |
|
|
| db.prepare( |
| `INSERT INTO users (id, email, password, display_name, email_verified, is_admin) |
| VALUES (?, ?, ?, ?, 1, 1)`, |
| ).run(id, adminEmail, hash, 'Admin'); |
|
|
| console.log(`[Admin] Default admin seeded: ${adminEmail}`); |
| } |
|
|