PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, ]); $db->exec('PRAGMA journal_mode = WAL'); $db->exec('PRAGMA foreign_keys = ON'); // ── Schema migration from single-tenant relay ────────────────────── // // Old schema (pre-multitenant) had `messages` and `attachments` tables // without recipient_id/sender_id. CREATE TABLE IF NOT EXISTS would skip // recreating them, leaving the old shape in place — and inserts with // the new column list would silently fail. Detect and drop the old // tables here. Old data is 24h ephemeral so it's a safe drop. function tableHasColumn(PDO $db, string $table, string $col): bool { try { $cols = array_column($db->query("PRAGMA table_info($table)")->fetchAll(), 'name'); return in_array($col, $cols, true); } catch (\Throwable $e) { return false; } } function tableExists(PDO $db, string $table): bool { try { $r = $db->prepare("SELECT name FROM sqlite_master WHERE type='table' AND name = :n"); $r->execute(['n' => $table]); return (bool)$r->fetchColumn(); } catch (\Throwable $e) { return false; } } if (tableExists($db, 'messages') && !tableHasColumn($db, 'messages', 'recipient_id')) { $db->exec('DROP TABLE IF EXISTS messages'); @error_log('[hyprcrd-relay] migrated: dropped legacy single-tenant messages table'); } if (tableExists($db, 'attachments') && !tableHasColumn($db, 'attachments', 'recipient_id')) { $db->exec('DROP TABLE IF EXISTS attachments'); @error_log('[hyprcrd-relay] migrated: dropped legacy single-tenant attachments table'); } // Old single-tenant settings keys (relay_password, relay_key) are unused // but left in place to allow downgrade. // v2 → v3 migration: tenants.secret_hash and .secret_encrypted are now // nullable because users set their own client secret rather than having // the admin assign one. SQLite can't ALTER COLUMN constraints, so we // recreate the table while preserving rows. function tableColumnIsNotNull(PDO $db, string $table, string $col): bool { try { $cols = $db->query("PRAGMA table_info($table)")->fetchAll(); foreach ($cols as $c) { if ($c['name'] === $col) return (bool)$c['notnull']; } } catch (\Throwable $e) {} return false; } if (tableExists($db, 'tenants') && tableColumnIsNotNull($db, 'tenants', 'secret_hash')) { $db->exec('PRAGMA foreign_keys = OFF'); $db->exec('CREATE TABLE tenants_v3_new ( id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT UNIQUE NOT NULL, password_hash TEXT NOT NULL, secret_hash TEXT, secret_encrypted TEXT, is_admin INTEGER NOT NULL DEFAULT 0, password_changed INTEGER NOT NULL DEFAULT 1, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP )'); // Copy existing rows but blank the secret columns — old admin-assigned // secrets are deprecated; users must re-set their own via the account UI. // Non-admin tenants in v2 had admin-set passwords, so force them to change. $db->exec('INSERT INTO tenants_v3_new (id, username, password_hash, secret_hash, secret_encrypted, is_admin, password_changed, created_at) SELECT id, username, password_hash, NULL, NULL, is_admin, CASE WHEN is_admin = 1 THEN 1 ELSE 0 END, created_at FROM tenants'); $db->exec('DROP TABLE tenants'); $db->exec('ALTER TABLE tenants_v3_new RENAME TO tenants'); $db->exec('PRAGMA foreign_keys = ON'); @error_log('[hyprcrd-relay] migrated v2->v3: tenant secrets now self-set, non-admin passwords flagged for forced change'); } // v3.0 → v3.1 migration: add password_changed column if missing if (tableExists($db, 'tenants') && !tableHasColumn($db, 'tenants', 'password_changed')) { $db->exec('ALTER TABLE tenants ADD COLUMN password_changed INTEGER NOT NULL DEFAULT 1'); // Force non-admin users to change their password (they were admin-set) $db->exec('UPDATE tenants SET password_changed = 0 WHERE is_admin = 0'); @error_log('[hyprcrd-relay] migrated v3.0->v3.1: added password_changed flag, non-admins forced to change'); } // ── Schema ───────────────────────────────────────────────────────── $db->exec('CREATE TABLE IF NOT EXISTS tenants ( id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT UNIQUE NOT NULL, password_hash TEXT NOT NULL, secret_hash TEXT, secret_encrypted TEXT, is_admin INTEGER NOT NULL DEFAULT 0, password_changed INTEGER NOT NULL DEFAULT 1, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP )'); $db->exec('CREATE TABLE IF NOT EXISTS settings ( key TEXT PRIMARY KEY, value TEXT )'); $db->exec('CREATE TABLE IF NOT EXISTS messages ( id INTEGER PRIMARY KEY AUTOINCREMENT, recipient_id INTEGER NOT NULL, sender_id INTEGER, sender_username TEXT, title TEXT DEFAULT "", body TEXT NOT NULL, color TEXT DEFAULT "#38bdf8", tags TEXT DEFAULT "", relay_uid TEXT DEFAULT NULL, fetched INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (recipient_id) REFERENCES tenants(id) ON DELETE CASCADE, FOREIGN KEY (sender_id) REFERENCES tenants(id) ON DELETE SET NULL )'); $db->exec('CREATE TABLE IF NOT EXISTS attachments ( id INTEGER PRIMARY KEY AUTOINCREMENT, recipient_id INTEGER NOT NULL, relay_uid TEXT NOT NULL, filename TEXT NOT NULL, data TEXT NOT NULL, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (recipient_id) REFERENCES tenants(id) ON DELETE CASCADE )'); // Chat: separate from `messages` so chat traffic doesnt land in card inboxes. // Same decrypt-reencrypt hop as messages: arrives encrypted with senders // secret, stored at-rest under the inter-tenant key, returned re-encrypted // with the recipients secret. $db->exec('CREATE TABLE IF NOT EXISTS chat_messages ( id INTEGER PRIMARY KEY AUTOINCREMENT, recipient_id INTEGER NOT NULL, sender_id INTEGER, sender_username TEXT, body TEXT NOT NULL, fetched INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (recipient_id) REFERENCES tenants(id) ON DELETE CASCADE, FOREIGN KEY (sender_id) REFERENCES tenants(id) ON DELETE SET NULL )'); $db->exec('CREATE INDEX IF NOT EXISTS idx_chat_recipient ON chat_messages(recipient_id, fetched, id)'); $db->exec('CREATE TABLE IF NOT EXISTS sessions ( id TEXT PRIMARY KEY, tenant_id INTEGER NOT NULL, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, expires_at TEXT NOT NULL, FOREIGN KEY (tenant_id) REFERENCES tenants(id) ON DELETE CASCADE )'); $db->exec('CREATE TABLE IF NOT EXISTS rate_limits ( ip TEXT NOT NULL, endpoint TEXT NOT NULL, attempted_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP )'); // Cleanup: 24h ephemeral storage, expired sessions, old rate limits $db->exec("DELETE FROM messages WHERE created_at < datetime('now', '-24 hours')"); $db->exec("DELETE FROM attachments WHERE created_at < datetime('now', '-24 hours')"); // Chat: 7 days, longer than card sync but still ephemeral $db->exec("DELETE FROM chat_messages WHERE created_at < datetime('now', '-7 days')"); $db->exec("DELETE FROM sessions WHERE expires_at < datetime('now')"); $db->exec("DELETE FROM rate_limits WHERE attempted_at < datetime('now', '-5 minutes')"); // ── Common response helper ──────────────────────────────────────── function jsonResponse($data, int $code = 200): void { http_response_code($code); header('Content-Type: application/json'); echo json_encode($data); exit; } // ── Security headers ────────────────────────────────────────────── header('X-Frame-Options: DENY'); header('X-Content-Type-Options: nosniff'); header('Referrer-Policy: no-referrer'); if (!empty($_SERVER['HTTPS']) || ($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? '') === 'https') { header('Strict-Transport-Security: max-age=31536000; includeSubDomains'); }