Breaking wire-format change: dual-read (V1+V2) / single-write (V2) so existing user secrets, stored messages, and inter-tenant key keep working. Old clients and relays lose cross-version chat until both sides are upgraded. Relay - Switch clientEncrypt/masterEncrypt to AES-256-GCM with HKDF-derived per-ciphertext salts; keep CBC-V1 fallback on decrypt only. - On-bootstrap migration re-envelopes tenant secret_encrypted and the inter-tenant key to GCM-V2 (idempotent). - CORS: stop reflecting arbitrary Origin; allow own origin + optional RELAY_CORS_ORIGINS env allowlist; drop Access-Control-Allow-Credentials. - Generic error messages replace "no such user: <name>" enumeration. - Exception messages no longer echoed back; logged server-side. - Collab-lock max hold capped at 10min so a member can't grief-refresh forever. - Attachment filenames validated against hex.<ext> pattern. - Passwords min length 10; /api/account/password rate-limited. App / Electron client - Shared hyprcrdRelayEncrypt/Decrypt helpers (GCM-V2 write, V1 read). - Chat + collab-buckets plugins delegate to the shared helpers. - Poll + push + attachments migrated to the dual-format path. - /api/relay/settings GET returns has_key boolean; secret never echoed. - Setup endpoint now CSRF-checked. - SSRF: CURLOPT_PROTOCOLS restricted to HTTP(S) everywhere; bookmarks favicon fetch also blocks private/loopback/link-local. - Login / password-change rate limiter moved to DB-backed IP keying. - Passwords min length 10 throughout. - Zip import enforces per-file (25MB) and total (500MB) caps, extension allowlist, no-overwrite. - Verbose relay-push debug gated behind APP_DEBUG env. Version bumped to 2.0.0 (protocol-breaking). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
465 lines
22 KiB
PHP
465 lines
22 KiB
PHP
<?php
|
|
// Bootstrap: DB connection, master key, schema + migration, common helpers.
|
|
// All include files require RELAY_BOOTSTRAPPED to be defined by index.php.
|
|
|
|
if (!defined('RELAY_BOOTSTRAPPED')) { http_response_code(404); exit; }
|
|
|
|
error_reporting(0);
|
|
ini_set('display_errors', '0');
|
|
|
|
// ── Paths ─────────────────────────────────────────────────────────
|
|
|
|
$RELAY_ROOT = dirname(__DIR__);
|
|
$dbPath = $RELAY_ROOT . '/data/relay.db';
|
|
$dbDir = dirname($dbPath);
|
|
if (!is_dir($dbDir)) mkdir($dbDir, 0755, true);
|
|
|
|
// ── Master key ────────────────────────────────────────────────────
|
|
//
|
|
// Generated on first install, stored in data/master.key (chmod 600,
|
|
// blocked from web access via .htaccess). Encrypts everything that
|
|
// needs to be recoverable: tenant secrets, the inter-tenant key.
|
|
|
|
$masterKeyPath = $RELAY_ROOT . '/data/master.key';
|
|
if (!file_exists($masterKeyPath)) {
|
|
file_put_contents($masterKeyPath, bin2hex(random_bytes(32)));
|
|
@chmod($masterKeyPath, 0600);
|
|
}
|
|
$MASTER_KEY = trim(file_get_contents($masterKeyPath));
|
|
|
|
// ── Database ──────────────────────────────────────────────────────
|
|
|
|
$db = new PDO('sqlite:' . $dbPath, null, null, [
|
|
PDO::ATTR_ERRMODE => 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
|
|
)');
|
|
|
|
// last_seen_at: updated on every authenticated request so the admin
|
|
// can see when a user was last active.
|
|
$tenantCols = $db->query("PRAGMA table_info(tenants)")->fetchAll();
|
|
$tenantColNames = array_column($tenantCols, 'name');
|
|
if (!in_array('last_seen_at', $tenantColNames)) {
|
|
$db->exec("ALTER TABLE tenants ADD COLUMN last_seen_at TEXT");
|
|
}
|
|
|
|
$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/<name>.
|
|
$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
|
|
)');
|
|
|
|
// ── v3.x → v1.10 crypto migration ────────────────────────────────
|
|
// Re-encrypt master-wrapped secrets (tenant client secrets + inter-tenant
|
|
// key) from CBC-V1 to GCM-V2. This only touches data we already hold the
|
|
// master key for, so no user action is needed and no secrets change —
|
|
// only the envelope around them. Idempotent: rows already in V2 skip.
|
|
require_once __DIR__ . '/crypto.php';
|
|
if (tableExists($db, 'tenants')) {
|
|
$legacyTenantRows = $db->query("SELECT id, secret_encrypted FROM tenants WHERE secret_encrypted IS NOT NULL AND secret_encrypted != '' AND secret_encrypted NOT LIKE '" . HYPRCRD_GCM_MAGIC_MASTER . "%'")->fetchAll();
|
|
if ($legacyTenantRows) {
|
|
$upd = $db->prepare('UPDATE tenants SET secret_encrypted = :s WHERE id = :i');
|
|
foreach ($legacyTenantRows as $r) {
|
|
$plain = masterDecrypt($r['secret_encrypted'], $MASTER_KEY);
|
|
if ($plain !== '') {
|
|
$upd->execute(['s' => masterEncrypt($plain, $MASTER_KEY), 'i' => $r['id']]);
|
|
}
|
|
}
|
|
@error_log('[hyprcrd-relay] migrated ' . count($legacyTenantRows) . ' tenant secret_encrypted rows to GCM-V2');
|
|
}
|
|
$legacyItk = $db->query("SELECT value FROM settings WHERE key = 'inter_tenant_key'")->fetchColumn();
|
|
if ($legacyItk && !str_starts_with($legacyItk, HYPRCRD_GCM_MAGIC_MASTER)) {
|
|
$plainItk = masterDecrypt($legacyItk, $MASTER_KEY);
|
|
if ($plainItk !== '') {
|
|
setInterTenantKey($db, $MASTER_KEY, $plainItk);
|
|
@error_log('[hyprcrd-relay] migrated inter-tenant key to GCM-V2');
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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');
|
|
}
|