Relay:
- Inbox/shared/sent split into own view-inbox (nav button + poll).
Compose is now single-column, centered.
- Header logo links to compose. Inbox badge stays fresh on all
views via a quiet loadInbox on every view switch.
- Mobile menu: full-height sheet below header, larger buttons
(15px font, 14px padding), full width.
- Login form wrapped in <form> with name attributes + for/id on
labels so password managers can autofill.
- Admin user list shows "last seen <relative time>" per user.
last_seen_at column on tenants, updated on every authenticated
request (session + client API).
- Cache-busting on style.css + app.js via filemtime.
- View persistence in localStorage (survives reload).
- Shared-bucket cards show title only (preview line removed).
Favicon:
- favicon.svg added to project root + copied to public/, relay/,
website/. Linked via <link rel="icon" type="image/svg+xml"> in
all three <head> sections.
Settings:
- New toggle: "Show shopping list items in task collector"
(default off). collectTasks skips #einkauf/#shopping cards when
disabled.
Fixes:
- card-spinner gets display:inline-block so it rotates outside
flex containers (shopping save spinner).
- Tim tab test opens the plugin before checking active state.
- Compose view centered with margin:auto on desktop.
Bumps to 1.9.29.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
75 lines
2.9 KiB
PHP
75 lines
2.9 KiB
PHP
<?php
|
|
// Auth helpers: rate limiting, sessions (UI), client API auth (header).
|
|
|
|
if (!defined('RELAY_BOOTSTRAPPED')) { http_response_code(404); exit; }
|
|
|
|
function isRateLimited(PDO $db, string $endpoint, int $max = 10): bool {
|
|
$ip = $_SERVER['REMOTE_ADDR'] ?? '';
|
|
$count = $db->prepare("SELECT COUNT(*) FROM rate_limits WHERE ip = :ip AND endpoint = :ep AND attempted_at > datetime('now', '-1 minutes')");
|
|
$count->execute(['ip' => $ip, 'ep' => $endpoint]);
|
|
if ((int)$count->fetchColumn() >= $max) return true;
|
|
$db->prepare("INSERT INTO rate_limits (ip, endpoint) VALUES (:ip, :ep)")->execute(['ip' => $ip, 'ep' => $endpoint]);
|
|
return false;
|
|
}
|
|
|
|
function makeSession(PDO $db, int $tenantId, int $hours = 24): string {
|
|
$token = bin2hex(random_bytes(32));
|
|
$db->prepare("INSERT INTO sessions (id, tenant_id, expires_at) VALUES (:t, :u, datetime('now', '+{$hours} hours'))")
|
|
->execute(['t' => $token, 'u' => $tenantId]);
|
|
return $token;
|
|
}
|
|
|
|
function getSessionTenant(PDO $db): ?array {
|
|
$token = $_COOKIE['relay_session'] ?? '';
|
|
if (!$token) return null;
|
|
$stmt = $db->prepare("SELECT t.* FROM sessions s JOIN tenants t ON t.id = s.tenant_id WHERE s.id = :tok AND s.expires_at > datetime('now')");
|
|
$stmt->execute(['tok' => $token]);
|
|
$tenant = $stmt->fetch() ?: null;
|
|
if ($tenant) touchLastSeen($db, (int)$tenant['id']);
|
|
return $tenant;
|
|
}
|
|
|
|
function destroySession(PDO $db): void {
|
|
$token = $_COOKIE['relay_session'] ?? '';
|
|
if ($token) {
|
|
$db->prepare('DELETE FROM sessions WHERE id = :t')->execute(['t' => $token]);
|
|
}
|
|
setcookie('relay_session', '', [
|
|
'expires' => time() - 3600,
|
|
'path' => '/',
|
|
'httponly' => true,
|
|
'samesite' => 'Strict',
|
|
'secure' => !empty($_SERVER['HTTPS']) || ($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? '') === 'https',
|
|
]);
|
|
}
|
|
|
|
function setSessionCookie(string $token): void {
|
|
setcookie('relay_session', $token, [
|
|
'expires' => time() + 86400,
|
|
'path' => '/',
|
|
'httponly' => true,
|
|
'samesite' => 'Strict',
|
|
'secure' => !empty($_SERVER['HTTPS']) || ($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? '') === 'https',
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* Authenticate a tenant via the client API headers (X-Relay-User + X-Relay-Key).
|
|
* Returns the tenant row on success, null on failure.
|
|
*/
|
|
function authClient(PDO $db): ?array {
|
|
$username = $_SERVER['HTTP_X_RELAY_USER'] ?? '';
|
|
$key = $_SERVER['HTTP_X_RELAY_KEY'] ?? '';
|
|
if (!$username || !$key) return null;
|
|
$tenant = getTenantByUsername($db, $username);
|
|
if (!$tenant) return null;
|
|
if (!password_verify($key, $tenant['secret_hash'])) return null;
|
|
touchLastSeen($db, (int)$tenant['id']);
|
|
return $tenant;
|
|
}
|
|
|
|
function touchLastSeen(PDO $db, int $tenantId): void {
|
|
$db->prepare("UPDATE tenants SET last_seen_at = datetime('now') WHERE id = :id")
|
|
->execute(['id' => $tenantId]);
|
|
}
|