From ad9413b5f3a13833a2031cc7304dbc99bbb95e44 Mon Sep 17 00:00:00 2001 From: Anton Afanasyeu Date: Thu, 11 Jun 2026 23:23:20 +0200 Subject: [PATCH] BE sync --- examples/crash_reporter/backend/README.md | 7 +- .../backend/public/api/short_links.php | 50 ++++- .../backend/public/assets/css/app.css | 10 + .../backend/public/assets/i18n/en.json | 2 + .../backend/public/assets/i18n/ru.json | 2 + .../backend/public/assets/js/short_links.js | 193 +++++++++++++++--- .../backend/scripts/test_short_links_api.sh | 14 +- examples/crash_reporter/backend/src/Rbac.php | 9 + .../backend/src/ShortLinksRepository.php | 120 +++++++++-- .../crash_reporter/backend/views/layout.php | 46 ++++- .../views/partials/console_home_landing.php | 7 + 11 files changed, 405 insertions(+), 55 deletions(-) diff --git a/examples/crash_reporter/backend/README.md b/examples/crash_reporter/backend/README.md index e081993..88c884e 100644 --- a/examples/crash_reporter/backend/README.md +++ b/examples/crash_reporter/backend/README.md @@ -66,7 +66,7 @@ Multi-tenant foundation: - **Global roles** on `users.role`: `root`, `platform_admin` (legacy `admin` works), `viewer` - **Companies** + **company_memberships** (`owner` / `admin` / `member`) — **slug admin** = `owner` or `admin` on a company (graphs company scope) -- **Privilege sets** on `company_memberships.permissions_json`: `remote_access_user`, `remote_access_admin` (extra grants for `member` rows) +- **Privilege sets** on `company_memberships.permissions_json`: `remote_access_user`, `remote_access_admin`, `short_links_viewer`, `short_links_user` (extra grants for `member` rows) - **Devices** auto-registered on upload; reports get `company_id` + `device_id` - Default company slug `default` — existing rows migrate on first request (`Database::ensureRbacSchema()`) @@ -317,9 +317,10 @@ Android: Developer settings → **Remote access** → **RSSH** (alpha target; Wi Operator UI for `https://s.f0xx.org` — mint/revoke bearer tokens, list links, shorten from the console. SPEC §20. -- Dashboard: `…/crashes/?view=short_links` (RBAC: `short_links_view`; company owner/admin) -- Hub nav: **Short links** +- Dashboard: `…/crashes/?view=short_links` (RBAC: `short_links_view`; company owner/admin or privilege sets `short_links_viewer` / `short_links_user`) +- Hub nav + console home card: **Short links** - Admin API: `…/api/short_links.php` (session cookie — not on `s.f0xx.org`) +- Permanent links: mint signer key in UI, bearer with **Permanent links** cap, shorten with **Permanent** + signer - Public shorten API stays on `https://s.f0xx.org/api/v1/shorten` (bearer token only) **Production `config.php`:** set `url_shortener.enabled => true` and `url_shortener.db.mysql` to the same `androidcast` user + `url_shortener` database as [backend/url-shortener/config/config.php](../../../backend/url-shortener/config/config.example.php). See `config.example.php`. diff --git a/examples/crash_reporter/backend/public/api/short_links.php b/examples/crash_reporter/backend/public/api/short_links.php index c7464f6..11e6a50 100644 --- a/examples/crash_reporter/backend/public/api/short_links.php +++ b/examples/crash_reporter/backend/public/api/short_links.php @@ -28,6 +28,7 @@ if ($method === 'GET') { 'ok' => true, 'public_base' => UrlShortenerDatabase::publicBase(), 'bearers' => ShortLinksRepository::listBearers(), + 'signers' => ShortLinksRepository::listSigners(), 'audit' => ShortLinksRepository::listAudit(40), 'permissions' => [ 'can_operate' => Rbac::can('short_links_operate'), @@ -66,18 +67,53 @@ if ($action === 'mint_bearer') { (string) ($body['label'] ?? ''), !array_key_exists('can_temporary', $body) || !empty($body['can_temporary']), !empty($body['can_permanent']), - (int) ($body['rate_limit_per_hour'] ?? 1000) + (int) ($body['rate_limit_per_hour'] ?? 1000), + !empty($body['mint_signer']) ); if (empty($result['ok'])) { json_out(['ok' => false, 'error' => $result['error'] ?? 'failed'], 400); } + $resp = [ + 'ok' => true, + 'bearer_id' => $result['bearer_id'], + 'token' => $result['token'], + ]; + if (isset($result['signer_key'])) { + $resp['signer_key'] = $result['signer_key']; + $resp['signer_id'] = $result['signer_id']; + } + json_out($resp); +} + +if ($action === 'mint_signer') { + if (!Rbac::can('short_links_operate')) { + json_out(['ok' => false, 'error' => 'forbidden'], 403); + } + $result = ShortLinksRepository::mintSigner((string) ($body['label'] ?? '')); + if (empty($result['ok'])) { + json_out(['ok' => false, 'error' => $result['error'] ?? 'failed'], 400); + } json_out([ 'ok' => true, - 'bearer_id' => $result['bearer_id'], - 'token' => $result['token'], + 'signer_id' => $result['id'], + 'signer_key' => $result['key'], ]); } +if ($action === 'revoke_signer') { + if (!Rbac::can('short_links_operate')) { + json_out(['ok' => false, 'error' => 'forbidden'], 403); + } + $signerId = (int) ($body['signer_id'] ?? 0); + if ($signerId <= 0) { + json_out(['ok' => false, 'error' => 'missing_signer_id'], 400); + } + if (!ShortLinksRepository::revokeSigner($signerId)) { + json_out(['ok' => false, 'error' => 'not_found_or_revoked'], 404); + } + json_out(['ok' => true]); +} + if ($action === 'revoke_bearer') { if (!Rbac::can('short_links_operate')) { json_out(['ok' => false, 'error' => 'forbidden'], 403); @@ -102,11 +138,17 @@ if ($action === 'create_link') { if ($bearerId <= 0 || trim($url) === '') { json_out(['ok' => false, 'error' => 'missing_fields'], 400); } - $result = ShortLinksRepository::createLink($bearerId, $url, $ttl); + $result = ShortLinksRepository::createLink( + $bearerId, + $url, + $ttl, + (string) ($body['signer'] ?? '') + ); if (empty($result['ok'])) { $code = match ($result['error'] ?? '') { 'rate_limited' => 429, 'ttl_expired' => 409, + 'signer_required' => 403, default => 400, }; json_out(['ok' => false, 'error' => $result['error'] ?? 'failed', 'message' => $result['message'] ?? ''], $code); diff --git a/examples/crash_reporter/backend/public/assets/css/app.css b/examples/crash_reporter/backend/public/assets/css/app.css index 4413529..24f14df 100644 --- a/examples/crash_reporter/backend/public/assets/css/app.css +++ b/examples/crash_reporter/backend/public/assets/css/app.css @@ -1812,6 +1812,16 @@ button.report-tag--filter:hover { .tag-field--wide input { width: 100%; } +.tag-field--check span { + display: flex; + align-items: center; + gap: 0.35rem; + white-space: nowrap; +} +.tag-field--check input[type="checkbox"] { + width: auto; + margin: 0; +} .graph-quick-link { font-size: 0.9rem; } .graphs-scope { margin-top: 20px; } .graphs-scope-title { diff --git a/examples/crash_reporter/backend/public/assets/i18n/en.json b/examples/crash_reporter/backend/public/assets/i18n/en.json index 3f93fe4..72f590d 100644 --- a/examples/crash_reporter/backend/public/assets/i18n/en.json +++ b/examples/crash_reporter/backend/public/assets/i18n/en.json @@ -20,6 +20,8 @@ "home.card_tickets_desc": "Roadmap tasks, QA items, and workflow tags.", "home.card_graphs": "Graphs", "home.card_graphs_desc": "Sessions, crashes, and device activity over time.", + "home.card_short_links": "Short links", + "home.card_short_links_desc": "Mint bearer tokens and shorten URLs for s.f0xx.org.", "home.more": "All services", "reports.title": "Issues", "reports.by_time": "By time", diff --git a/examples/crash_reporter/backend/public/assets/i18n/ru.json b/examples/crash_reporter/backend/public/assets/i18n/ru.json index c805242..4c22080 100644 --- a/examples/crash_reporter/backend/public/assets/i18n/ru.json +++ b/examples/crash_reporter/backend/public/assets/i18n/ru.json @@ -30,6 +30,8 @@ "home.card_tickets_desc": "Задачи, QA и теги workflow.", "home.card_graphs": "Графики", "home.card_graphs_desc": "Сессии, сбои и активность устройств.", + "home.card_short_links": "Короткие ссылки", + "home.card_short_links_desc": "Токены и сокращение URL для s.f0xx.org.", "home.more": "Все сервисы", "reports.title": "Issues", "reports.by_time": "По времени", diff --git a/examples/crash_reporter/backend/public/assets/js/short_links.js b/examples/crash_reporter/backend/public/assets/js/short_links.js index 5930500..d025197 100644 --- a/examples/crash_reporter/backend/public/assets/js/short_links.js +++ b/examples/crash_reporter/backend/public/assets/js/short_links.js @@ -48,12 +48,19 @@ let selectedBearerId = 0; let selectedBearerLabel = ''; + function bearerCaps(b) { + const parts = []; + if (b.can_temporary) parts.push('temp'); + if (b.can_permanent) parts.push('perm'); + return parts.length ? parts.join(', ') : '—'; + } + function renderBearers(bearers) { const tbody = document.getElementById('sl-bearers-tbody'); if (!tbody) return; tbody.innerHTML = ''; if (!bearers || !bearers.length) { - tbody.innerHTML = 'No bearer tokens yet — mint one below.'; + tbody.innerHTML = 'No bearer tokens yet — mint one below.'; return; } (bearers || []).forEach((b) => { @@ -63,13 +70,13 @@ const revokeBtn = canOperate() && active ? ' ' : ''; - const viewBtn = canOperate() - ? '' - : ''; + const viewBtn = + ''; tr.innerHTML = '' + esc(b.id) + '' + '' + esc(b.label) + '' + '' + status + '' + + '' + esc(bearerCaps(b)) + '' + '' + esc(b.link_count) + (b.expired_link_count > 0 ? ' (' + esc(b.expired_link_count) + ' exp)' : '') + '' + '' + esc(b.rate_limit_per_hour) + '' + '' + esc(b.created_at || '—') + '' + @@ -78,6 +85,31 @@ }); } + function renderSigners(signers) { + const tbody = document.getElementById('sl-signers-tbody'); + if (!tbody) return; + tbody.innerHTML = ''; + if (!signers || !signers.length) { + tbody.innerHTML = 'No signer keys — mint one for permanent links.'; + return; + } + (signers || []).forEach((s) => { + const tr = document.createElement('tr'); + const active = s.active !== false && !s.revoked_at; + const status = active ? 'active' : 'revoked'; + const revokeBtn = canOperate() && active + ? ' ' + : ''; + tr.innerHTML = + '' + esc(s.id) + '' + + '' + esc(s.label) + '' + + '' + status + '' + + '' + esc(s.created_at || '—') + '' + + '' + revokeBtn + ''; + tbody.appendChild(tr); + }); + } + function renderLinks(links) { const tbody = document.getElementById('sl-links-tbody'); if (!tbody) return; @@ -88,7 +120,9 @@ } (links || []).forEach((l) => { const tr = document.createElement('tr'); - const exp = l.expired ? 'expired' : esc(l.expires_at || 'permanent'); + const exp = l.expired + ? 'expired' + : (l.ttl_seconds === 0 || !l.expires_at ? 'permanent' : esc(l.expires_at)); tr.innerHTML = '' + esc(l.short_url) + ' ' + '' + @@ -120,19 +154,36 @@ }); } - function showTokenModal(token) { - const modal = document.getElementById('sl-token-modal'); - const val = document.getElementById('sl-token-value'); + function showSecretModal(modalId, valueId, value) { + const modal = document.getElementById(modalId); + const val = document.getElementById(valueId); if (!modal || !val) return; - val.textContent = token; + val.textContent = value; modal.hidden = false; } - function hideTokenModal() { - const modal = document.getElementById('sl-token-modal'); + function hideModal(modalId) { + const modal = document.getElementById(modalId); if (modal) modal.hidden = true; } + function syncPermanentUi() { + const permCb = document.getElementById('sl-permanent-cb'); + const ttlWrap = document.getElementById('sl-ttl-wrap'); + const signerWrap = document.getElementById('sl-signer-wrap'); + const ttlInput = document.getElementById('sl-ttl-input'); + const permanent = permCb && permCb.checked; + if (ttlWrap) ttlWrap.hidden = !!permanent; + if (signerWrap) signerWrap.hidden = !permanent; + if (ttlInput && permanent) ttlInput.value = '0'; + } + + function syncMintPermanentUi() { + const permCb = document.getElementById('sl-can-permanent'); + const wrap = document.getElementById('sl-mint-signer-wrap'); + if (wrap) wrap.hidden = !(permCb && permCb.checked); + } + async function loadLinks(bearerId, label) { selectedBearerId = bearerId; selectedBearerLabel = label || String(bearerId); @@ -153,6 +204,7 @@ try { const data = await fetchJson(apiUrl('dashboard')); renderBearers(data.bearers); + renderSigners(data.signers); renderAudit(data.audit); if (selectedBearerId > 0) { await loadLinks(selectedBearerId, selectedBearerLabel); @@ -168,7 +220,11 @@ if (!(t instanceof HTMLElement)) return; if (t.id === 'sl-token-close' || t.closest('#sl-token-close')) { - hideTokenModal(); + hideModal('sl-token-modal'); + return; + } + if (t.id === 'sl-signer-close' || t.closest('#sl-signer-close')) { + hideModal('sl-signer-modal'); return; } if (t.id === 'sl-token-copy') { @@ -183,6 +239,18 @@ } return; } + if (t.id === 'sl-signer-copy') { + const val = document.getElementById('sl-signer-value'); + if (val) { + try { + await navigator.clipboard.writeText(val.textContent || ''); + setStatus('Signer copied'); + } catch (e) { + setStatus('Copy failed', true); + } + } + return; + } const copy = t.getAttribute('data-copy'); if (copy) { @@ -197,7 +265,6 @@ const viewId = t.getAttribute('data-view-links'); if (viewId) { - if (!canOperate()) return; try { await loadLinks(parseInt(viewId, 10), t.getAttribute('data-label') || ''); } catch (e) { @@ -209,7 +276,7 @@ const revokeId = t.getAttribute('data-revoke-bearer'); if (revokeId) { if (!canOperate()) return; - if (!window.confirm('Revoke bearer #' + revokeId + '? Existing links stop working for new shorten calls.')) return; + if (!window.confirm('Revoke bearer #' + revokeId + '?')) return; try { await fetchJson(apiUrl('revoke_bearer'), { method: 'POST', @@ -225,14 +292,40 @@ } catch (e) { setStatus('Revoke: ' + e.message, true); } + return; + } + + const revokeSignerId = t.getAttribute('data-revoke-signer'); + if (revokeSignerId) { + if (!canOperate()) return; + if (!window.confirm('Revoke signer #' + revokeSignerId + '?')) return; + try { + await fetchJson(apiUrl('revoke_signer'), { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ signer_id: parseInt(revokeSignerId, 10) }), + }); + await refresh(); + } catch (e) { + setStatus('Revoke signer: ' + e.message, true); + } } }); + const permCb = document.getElementById('sl-permanent-cb'); + if (permCb) { + permCb.addEventListener('change', syncPermanentUi); + syncPermanentUi(); + } + + const mintPermCb = document.getElementById('sl-can-permanent'); + if (mintPermCb) { + mintPermCb.addEventListener('change', syncMintPermanentUi); + syncMintPermanentUi(); + } + const mintForm = document.getElementById('sl-mint-form'); if (mintForm) { - if (!canOperate()) { - mintForm.hidden = true; - } mintForm.addEventListener('submit', async (ev) => { ev.preventDefault(); if (!canOperate()) return; @@ -245,10 +338,17 @@ body: JSON.stringify({ label: fd.get('label'), rate_limit_per_hour: parseInt(String(fd.get('rate_limit') || '1000'), 10), + can_temporary: fd.get('can_temporary') === '1', + can_permanent: fd.get('can_permanent') === '1', + mint_signer: fd.get('mint_signer') === '1', }), }); mintForm.reset(); - showTokenModal(data.token); + syncMintPermanentUi(); + showSecretModal('sl-token-modal', 'sl-token-value', data.token); + if (data.signer_key) { + showSecretModal('sl-signer-modal', 'sl-signer-value', data.signer_key); + } await refresh(); } catch (e) { setStatus('Mint: ' + e.message, true); @@ -256,26 +356,52 @@ }); } + const mintSignerForm = document.getElementById('sl-mint-signer-form'); + if (mintSignerForm) { + mintSignerForm.addEventListener('submit', async (ev) => { + ev.preventDefault(); + if (!canOperate()) return; + const fd = new FormData(mintSignerForm); + try { + const data = await fetchJson(apiUrl('mint_signer'), { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ label: fd.get('label') }), + }); + mintSignerForm.reset(); + showSecretModal('sl-signer-modal', 'sl-signer-value', data.signer_key); + await refresh(); + } catch (e) { + setStatus('Mint signer: ' + e.message, true); + } + }); + } + const shortenForm = document.getElementById('sl-shorten-form'); if (shortenForm) { - if (!canOperate()) shortenForm.hidden = true; shortenForm.addEventListener('submit', async (ev) => { ev.preventDefault(); if (!canOperate()) return; const fd = new FormData(shortenForm); + const permanent = fd.get('permanent') === '1'; try { + const payload = { + bearer_id: parseInt(String(fd.get('bearer_id') || '0'), 10), + url: fd.get('url'), + ttl: permanent ? 0 : parseInt(String(fd.get('ttl') || '86400'), 10), + }; + if (permanent) { + payload.signer = fd.get('signer'); + } const data = await fetchJson(apiUrl('create_link'), { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - bearer_id: parseInt(String(fd.get('bearer_id') || '0'), 10), - url: fd.get('url'), - ttl: parseInt(String(fd.get('ttl') || '86400'), 10), - }), + body: JSON.stringify(payload), }); if (data.link && data.link.short_url) { setStatus('Short URL: ' + data.link.short_url); } + shortenForm.querySelector('[name="url"]').value = ''; await refresh(); } catch (e) { setStatus('Shorten: ' + e.message, true); @@ -303,14 +429,19 @@ }); } - const modal = document.getElementById('sl-token-modal'); - if (modal) { - modal.addEventListener('click', (ev) => { - if (ev.target === modal) hideTokenModal(); - }); - } + ['sl-token-modal', 'sl-signer-modal'].forEach((id) => { + const modal = document.getElementById(id); + if (modal) { + modal.addEventListener('click', (ev) => { + if (ev.target === modal) hideModal(id); + }); + } + }); document.addEventListener('keydown', (ev) => { - if (ev.key === 'Escape') hideTokenModal(); + if (ev.key === 'Escape') { + hideModal('sl-token-modal'); + hideModal('sl-signer-modal'); + } }); refresh(); diff --git a/examples/crash_reporter/backend/scripts/test_short_links_api.sh b/examples/crash_reporter/backend/scripts/test_short_links_api.sh index 0d63614..377ac1a 100755 --- a/examples/crash_reporter/backend/scripts/test_short_links_api.sh +++ b/examples/crash_reporter/backend/scripts/test_short_links_api.sh @@ -31,8 +31,18 @@ if [[ "${FULL:-}" == "1" ]]; then echo "==> mint bearer" resp="$(curl_api "$BASE/api/short_links.php?action=mint_bearer" \ -X POST -H 'Content-Type: application/json' \ - -d '{"label":"api-smoke-'"$(date +%s)"'"}')" - echo "$resp" | grep -q '"token"' && echo OK + -d '{"label":"api-smoke-'"$(date +%s)"'","can_permanent":true,"mint_signer":true}')" + echo "$resp" | grep -q '"token"' && echo OK bearer + signer="$(echo "$resp" | sed -n 's/.*"signer_key":"\([^"]*\)".*/\1/p')" + bearer="$(echo "$resp" | sed -n 's/.*"token":"\([^"]*\)".*/\1/p')" + bid="$(echo "$resp" | sed -n 's/.*"bearer_id":\([0-9]*\).*/\1/p')" + if [[ -n "$signer" && -n "$bid" ]]; then + echo "==> permanent link" + curl_api "$BASE/api/short_links.php?action=create_link" \ + -X POST -H 'Content-Type: application/json' \ + -d '{"bearer_id":'"$bid"',"url":"https://example.com/perm-'"$(date +%s)"'","ttl":0,"signer":"'"$signer"'"}' \ + | grep -q '"short_url"' && echo OK permanent + fi fi echo "Done" diff --git a/examples/crash_reporter/backend/src/Rbac.php b/examples/crash_reporter/backend/src/Rbac.php index b7ca105..95e67b1 100644 --- a/examples/crash_reporter/backend/src/Rbac.php +++ b/examples/crash_reporter/backend/src/Rbac.php @@ -28,6 +28,8 @@ final class Rbac { public const PRIVILEGE_SET_NONE = ''; public const PRIVILEGE_SET_REMOTE_ACCESS_USER = 'remote_access_user'; public const PRIVILEGE_SET_REMOTE_ACCESS_ADMIN = 'remote_access_admin'; + public const PRIVILEGE_SET_SHORT_LINKS_VIEWER = 'short_links_viewer'; + public const PRIVILEGE_SET_SHORT_LINKS_USER = 'short_links_user'; /** @var array> */ public const PRIVILEGE_SETS = [ @@ -40,6 +42,13 @@ final class Rbac { 'remote_access_operate', 'remote_access_admin', ], + self::PRIVILEGE_SET_SHORT_LINKS_VIEWER => [ + 'short_links_view', + ], + self::PRIVILEGE_SET_SHORT_LINKS_USER => [ + 'short_links_view', + 'short_links_operate', + ], ]; private const COMPANY_ROLE_ACTIONS = [ diff --git a/examples/crash_reporter/backend/src/ShortLinksRepository.php b/examples/crash_reporter/backend/src/ShortLinksRepository.php index c99105c..6e8997d 100644 --- a/examples/crash_reporter/backend/src/ShortLinksRepository.php +++ b/examples/crash_reporter/backend/src/ShortLinksRepository.php @@ -87,13 +87,14 @@ final class ShortLinksRepository { } /** - * @return array{ok: true, bearer_id: int, token: string}|array{ok: false, error: string} + * @return array{ok: true, bearer_id: int, token: string, signer_key?: string, signer_id?: int}|array{ok: false, error: string} */ public static function mintBearer( string $label, bool $canTemporary = true, bool $canPermanent = false, - int $rateLimit = 1000 + int $rateLimit = 1000, + bool $mintSigner = false ): array { $label = trim($label); if ($label === '') { @@ -117,7 +118,67 @@ final class ShortLinksRepository { ]); $id = (int) $pdo->lastInsertId(); self::audit('admin_mint', $id, null, 'label=' . $label); - return ['ok' => true, 'bearer_id' => $id, 'token' => $token]; + $out = ['ok' => true, 'bearer_id' => $id, 'token' => $token]; + if ($canPermanent && $mintSigner) { + $signer = self::mintSigner($label . '-signer'); + $out['signer_id'] = $signer['id']; + $out['signer_key'] = $signer['key']; + } + return $out; + } + + /** @return list> */ + public static function listSigners(): array { + $pdo = UrlShortenerDatabase::pdo(); + $rows = $pdo->query( + 'SELECT id, label, can_permanent, revoked_at, created_at + FROM signer_keys ORDER BY id DESC' + )->fetchAll(); + return array_map(static function (array $r): array { + return [ + 'id' => (int) $r['id'], + 'label' => (string) $r['label'], + 'can_permanent' => (int) ($r['can_permanent'] ?? 0) === 1, + 'revoked_at' => $r['revoked_at'], + 'active' => empty($r['revoked_at']), + 'created_at' => $r['created_at'], + ]; + }, $rows); + } + + /** + * @return array{ok: true, id: int, key: string}|array{ok: false, error: string} + */ + public static function mintSigner(string $label): array { + $label = trim($label); + if ($label === '') { + return ['ok' => false, 'error' => 'missing_label']; + } + if (strlen($label) > 128) { + return ['ok' => false, 'error' => 'label_too_long']; + } + $key = bin2hex(random_bytes(24)); + $pdo = UrlShortenerDatabase::pdo(); + $stmt = $pdo->prepare( + 'INSERT INTO signer_keys (key_hash, label, can_permanent) VALUES (?, ?, 1)' + ); + $stmt->execute([hash('sha256', $key), $label]); + $id = (int) $pdo->lastInsertId(); + self::audit('admin_mint_signer', null, null, 'signer_id=' . $id . ' label=' . $label); + return ['ok' => true, 'id' => $id, 'key' => $key]; + } + + public static function revokeSigner(int $signerId): bool { + $pdo = UrlShortenerDatabase::pdo(); + $stmt = $pdo->prepare( + 'UPDATE signer_keys SET revoked_at = NOW() WHERE id = ? AND revoked_at IS NULL' + ); + $stmt->execute([$signerId]); + if ($stmt->rowCount() < 1) { + return false; + } + self::audit('admin_revoke_signer', null, null, 'signer_id=' . $signerId); + return true; } public static function revokeBearer(int $bearerId): bool { @@ -147,9 +208,14 @@ final class ShortLinksRepository { } /** - * @return array{ok: true, short_url: string, slug: string, qr_url: string, ttl: string}|array{ok: false, error: string} + * @return array{ok: true, short_url: string, slug: string, qr_url: string, ttl: string, url?: string}|array{ok: false, error: string, message?: string} */ - public static function createLink(int $bearerId, string $rawUrl, int $ttlSeconds = 86400): array { + public static function createLink( + int $bearerId, + string $rawUrl, + int $ttlSeconds = 86400, + string $signerKey = '' + ): array { $bearer = self::findBearerById($bearerId); if ($bearer === null) { return ['ok' => false, 'error' => 'unknown_bearer']; @@ -179,10 +245,18 @@ final class ShortLinksRepository { if ($ttlSeconds < 0) { $ttlSeconds = 86400; } + + $signerId = null; if ($ttlSeconds === 0) { - return ['ok' => false, 'error' => 'permanent_requires_signer']; - } - if ((int) ($bearer['can_temporary'] ?? 0) !== 1) { + $signer = self::findValidSigner($signerKey); + if ($signer === null) { + return ['ok' => false, 'error' => 'signer_required']; + } + if ((int) ($bearer['can_permanent'] ?? 0) !== 1) { + return ['ok' => false, 'error' => 'bearer_no_permanent']; + } + $signerId = (int) $signer['id']; + } elseif ((int) ($bearer['can_temporary'] ?? 0) !== 1) { return ['ok' => false, 'error' => 'bearer_no_temporary']; } @@ -192,14 +266,22 @@ final class ShortLinksRepository { } $slug = self::allocateSlug($bearerId, $normalized); - $expiresAt = date('Y-m-d H:i:s', time() + $ttlSeconds); + $expiresAt = $ttlSeconds <= 0 ? null : date('Y-m-d H:i:s', time() + $ttlSeconds); $pdo = UrlShortenerDatabase::pdo(); $stmt = $pdo->prepare( 'INSERT INTO links (bearer_id, signer_id, slug, origin, origin_hash, ttl_seconds, expires_at) - VALUES (?, NULL, ?, ?, ?, ?, ?)' + VALUES (?, ?, ?, ?, ?, ?, ?)' ); - $stmt->execute([$bearerId, $slug, $normalized, $originHash, $ttlSeconds, $expiresAt]); - self::audit('admin_shorten', $bearerId, $slug, 'created'); + $stmt->execute([ + $bearerId, + $signerId, + $slug, + $normalized, + $originHash, + max(0, $ttlSeconds), + $expiresAt, + ]); + self::audit('admin_shorten', $bearerId, $slug, $ttlSeconds === 0 ? 'permanent' : 'created'); $row = self::findLinkBySlug($slug); if ($row === null) { @@ -225,6 +307,20 @@ final class ShortLinksRepository { ]; } + /** @return array|null */ + private static function findValidSigner(string $signerKey): ?array { + if (trim($signerKey) === '') { + return null; + } + $pdo = UrlShortenerDatabase::pdo(); + $stmt = $pdo->prepare( + 'SELECT * FROM signer_keys WHERE key_hash = ? AND revoked_at IS NULL AND can_permanent = 1 LIMIT 1' + ); + $stmt->execute([hash('sha256', $signerKey)]); + $row = $stmt->fetch(); + return $row === false ? null : $row; + } + /** @return array|null */ private static function findBearerById(int $id): ?array { $pdo = UrlShortenerDatabase::pdo(); diff --git a/examples/crash_reporter/backend/views/layout.php b/examples/crash_reporter/backend/views/layout.php index 7d1b597..9041f0e 100644 --- a/examples/crash_reporter/backend/views/layout.php +++ b/examples/crash_reporter/backend/views/layout.php @@ -519,6 +519,7 @@ ID Label Status + Caps Links Rate/h Created @@ -528,9 +529,12 @@ -
+ > + + + @@ -538,12 +542,36 @@
+
+

Signer keys (permanent links)

+
+ + + + + + + + + + + +
IDLabelStatusCreatedActions
+
+
> + + +
+
+