multi-tenant relay und card-push to user
This commit is contained in:
1 parent
7219bae655
commit
5b18c46ac2
22 files changed
+7453
-519
No files matched your search
@@ -0,0 +1,203 @@
|
||||
<?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
|
||||
)');
|
||||
|
||||
$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
|
||||
)');
|
||||
|
||||
$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')");
|
||||
$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');
|
||||
}
|
||||
Reference in new issue
Block a user