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)'); // Read receipts: `fetched` (already present) is "delivered" — set when the // recipient polls /api/chat. `seen` is "seen" — set when the recipient // opens the thread with the sender via POST /api/chat/seen. The sender // learns both states by polling GET /api/chat/sent. $chatCols = $db->query('PRAGMA table_info(chat_messages)')->fetchAll(); $chatColNames = array_column($chatCols, 'name'); if (!in_array('seen', $chatColNames)) { $db->exec('ALTER TABLE chat_messages ADD COLUMN seen INTEGER NOT NULL DEFAULT 0'); } // Group chat rooms: room_uid (nullable — set for group messages, NULL for // 1:1). recipient_id stays non-null for 1:1; group rooms resolve recipients // via chat_room_members. `persistent` controls retention: 1 = keep in the // long-term store so multi-device / late-joiners can pull history, 0 = old // "disposable" behaviour (prune after fetch window). 1:1 defaults to 1; // group messages are always 1. if (!in_array('room_uid', $chatColNames)) { $db->exec('ALTER TABLE chat_messages ADD COLUMN room_uid TEXT'); $db->exec('CREATE INDEX IF NOT EXISTS idx_chat_room ON chat_messages(room_uid, id)'); } if (!in_array('persistent', $chatColNames)) { $db->exec('ALTER TABLE chat_messages ADD COLUMN persistent INTEGER NOT NULL DEFAULT 1'); } // Sync-notification cleanup: the bot=1 chat messages turned out to clutter // the chat thread. The feature moved to its own `relay_sync_events` queue // (see below). Drop any leftover bot rows so they stop showing up in // users' threads. The `bot` column stays on disk (SQLite ALTER DROP needs // a full table rebuild — not worth it for an ignored nullable column). if (in_array('bot', $chatColNames)) { $db->exec('DELETE FROM chat_messages WHERE bot = 1'); } $db->exec('CREATE INDEX IF NOT EXISTS idx_chat_sender_state ON chat_messages(sender_id, id)'); // Chat rooms: named group threads. Owner + membership mirrors collab // buckets. Group messages live in chat_messages with room_uid set. $db->exec('CREATE TABLE IF NOT EXISTS chat_rooms ( id INTEGER PRIMARY KEY AUTOINCREMENT, uid TEXT UNIQUE NOT NULL, owner_id INTEGER NOT NULL, owner_username TEXT NOT NULL, name TEXT NOT NULL, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (owner_id) REFERENCES tenants(id) ON DELETE CASCADE )'); $db->exec('CREATE TABLE IF NOT EXISTS chat_room_members ( room_uid TEXT NOT NULL, tenant_id INTEGER NOT NULL, username TEXT NOT NULL, added_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (room_uid, tenant_id), FOREIGN KEY (tenant_id) REFERENCES tenants(id) ON DELETE CASCADE )'); $db->exec('CREATE INDEX IF NOT EXISTS idx_chat_room_member_tenant ON chat_room_members(tenant_id, room_uid)'); // Shopping lists: multiple active cards per tenant, each mirrored from // the client. Phone toggles checkboxes in-place via the session UI. // hyprcrd remains the only surface that edits item text. $db->exec('CREATE TABLE IF NOT EXISTS shopping_state ( tenant_id INTEGER NOT NULL, card_uid TEXT NOT NULL, title TEXT NOT NULL DEFAULT "", body TEXT NOT NULL DEFAULT "", position INTEGER NOT NULL DEFAULT 0, updated_at_client TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at_server TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (tenant_id, card_uid), FOREIGN KEY (tenant_id) REFERENCES tenants(id) ON DELETE CASCADE )'); // Migrate from old single-PK schema (tenant_id only) to composite PK // + ensure position column exists (may be missing on tables created // before the multi-list update). $shoppingInfo = $db->query("PRAGMA table_info(shopping_state)")->fetchAll(); $shoppingPk = array_filter($shoppingInfo, fn($c) => $c['pk'] > 0); $shoppingColNames = array_column($shoppingInfo, 'name'); if (count($shoppingPk) === 1) { $db->exec('ALTER TABLE shopping_state RENAME TO _shopping_old'); $db->exec('CREATE TABLE shopping_state ( tenant_id INTEGER NOT NULL, card_uid TEXT NOT NULL, title TEXT NOT NULL DEFAULT "", body TEXT NOT NULL DEFAULT "", position INTEGER NOT NULL DEFAULT 0, updated_at_client TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at_server TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (tenant_id, card_uid), FOREIGN KEY (tenant_id) REFERENCES tenants(id) ON DELETE CASCADE )'); $db->exec('INSERT OR IGNORE INTO shopping_state (tenant_id, card_uid, title, body, updated_at_client, updated_at_server) SELECT tenant_id, card_uid, title, body, updated_at_client, updated_at_server FROM _shopping_old'); $db->exec('DROP TABLE _shopping_old'); } elseif (!in_array('position', $shoppingColNames)) { $db->exec('ALTER TABLE shopping_state ADD COLUMN position INTEGER NOT NULL DEFAULT 0'); } // Clean up __inactive__ sentinel rows from the old single-list era. $db->exec("DELETE FROM shopping_state WHERE card_uid = '__inactive__'"); // Sync-event notifications: replaces the old bot-chat mechanism. When a // client syncs a card they received, they POST here; the sender polls // GET /api/sync-events and surfaces each as a transient toast. Rows are // short-lived — we drop fetched ones after an hour, unfetched after a day. $db->exec('CREATE TABLE IF NOT EXISTS relay_sync_events ( id INTEGER PRIMARY KEY AUTOINCREMENT, recipient_id INTEGER NOT NULL, sender_username TEXT NOT NULL, 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 )'); $db->exec('CREATE INDEX IF NOT EXISTS idx_sync_events_recipient ON relay_sync_events(recipient_id, id)'); // Collaborative buckets: shared among multiple tenants. Each bucket has one // owner and a membership set. Buckets persist (not ephemeral) since they are // the container for ongoing collaboration — cleanup only when deleted. $db->exec('CREATE TABLE IF NOT EXISTS collab_buckets ( id INTEGER PRIMARY KEY AUTOINCREMENT, uid TEXT UNIQUE NOT NULL, owner_id INTEGER NOT NULL, owner_username TEXT NOT NULL, name TEXT NOT NULL, color TEXT NOT NULL DEFAULT "#38bdf8", created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (owner_id) REFERENCES tenants(id) ON DELETE CASCADE )'); $db->exec('CREATE TABLE IF NOT EXISTS collab_bucket_members ( bucket_uid TEXT NOT NULL, tenant_id INTEGER NOT NULL, username TEXT NOT NULL, added_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (bucket_uid, tenant_id), FOREIGN KEY (tenant_id) REFERENCES tenants(id) ON DELETE CASCADE )'); $db->exec('CREATE INDEX IF NOT EXISTS idx_collab_member_tenant ON collab_bucket_members(tenant_id, bucket_uid)'); $db->exec('CREATE INDEX IF NOT EXISTS idx_collab_bucket_updated ON collab_buckets(updated_at)'); // Collab cards: the actual synced content, stored at-rest under the // inter-tenant key. Payload is an encrypted JSON blob containing title, // body, color, tags, due_date, position. Tombstoned rows (deleted=1) // survive for 7 days so late pollers see the deletion. $db->exec('CREATE TABLE IF NOT EXISTS collab_cards ( id INTEGER PRIMARY KEY AUTOINCREMENT, bucket_uid TEXT NOT NULL, card_uid TEXT NOT NULL, author_username TEXT NOT NULL, payload TEXT NOT NULL, updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, deleted INTEGER NOT NULL DEFAULT 0, UNIQUE (bucket_uid, card_uid) )'); $db->exec('CREATE INDEX IF NOT EXISTS idx_collab_cards_bucket_updated ON collab_cards(bucket_uid, updated_at)'); // Collab attachments: binary assets referenced by collab cards (images, // PDFs, audio). Stored encrypted under the inter-tenant key. Scoped to // a bucket — all members of the bucket can fetch. Filename is the // hex-hash name that appears in card bodies as uploads/. $db->exec('CREATE TABLE IF NOT EXISTS collab_attachments ( id INTEGER PRIMARY KEY AUTOINCREMENT, bucket_uid TEXT NOT NULL, filename TEXT NOT NULL, data TEXT NOT NULL, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE (bucket_uid, filename) )'); $db->exec('CREATE INDEX IF NOT EXISTS idx_collab_att_bucket ON collab_attachments(bucket_uid, created_at)'); // Phase 4: advisory edit locks on collab cards. A client that opens the // editor for a collab card heartbeats into this table every 30s with a // 60s TTL. Other clients poll the list and show a lock indicator on the // card. The lock is purely advisory — force-editing is still allowed and // LWW remains the authoritative conflict resolver. // Exclusive lock: PRIMARY KEY is (bucket_uid, card_uid). The first writer // to acquire holds it; everyone else gets a 409 Locked. The previous schema // had holder_id in the PK, which let multiple users "lock" the same card // simultaneously — locks were advisory only, and concurrent writers could // silently overwrite each other through LWW. // // Migration: if the existing table still has the 3-column PK, drop it. // Locks are ephemeral (60s TTL) so dropping discards nothing important. $lockTableInfo = $db->query("SELECT sql FROM sqlite_master WHERE type='table' AND name='collab_card_locks'")->fetchColumn(); if ($lockTableInfo && stripos($lockTableInfo, 'PRIMARY KEY (bucket_uid, card_uid, holder_id)') !== false) { $db->exec('DROP TABLE collab_card_locks'); } $db->exec('CREATE TABLE IF NOT EXISTS collab_card_locks ( bucket_uid TEXT NOT NULL, card_uid TEXT NOT NULL, holder_id INTEGER NOT NULL, holder_username TEXT NOT NULL, acquired_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, expires_at TEXT NOT NULL, PRIMARY KEY (bucket_uid, card_uid), FOREIGN KEY (holder_id) REFERENCES tenants(id) ON DELETE CASCADE )'); $db->exec('CREATE INDEX IF NOT EXISTS idx_collab_card_locks_expires ON collab_card_locks(expires_at)'); $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 retention: // - persistent = 1 (default, 1:1 non-disposable + all group messages) // → never auto-deleted. Relay is the source of truth so late joiners // and second devices can backfill full history. // - persistent = 0 (disposable 1:1 threads) → drop after a 7-day grace // window so the sender's /api/chat/sent receipt poll still sees the // final state. $db->exec("DELETE FROM chat_messages WHERE persistent = 0 AND created_at < datetime('now', '-7 days')"); // Sync events are transient toasts — drop fetched ones after an hour, // everything after 24h regardless (sender was offline too long to care). $db->exec("DELETE FROM relay_sync_events WHERE fetched = 1 AND created_at < datetime('now', '-1 hour')"); $db->exec("DELETE FROM relay_sync_events WHERE created_at < datetime('now', '-24 hours')"); // Collab-card tombstones: 7 days so late pollers still see the delete. $db->exec("DELETE FROM collab_cards WHERE deleted = 1 AND updated_at < datetime('now', '-7 days')"); // Collab-card locks: drop everything past its TTL on every request. $db->exec("DELETE FROM collab_card_locks WHERE expires_at < datetime('now')"); $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'); }