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 @@
-
+
+ Signer keys (permanent links)
+
+
+
+
+ | ID |
+ Label |
+ Status |
+ Created |
+ Actions |
+
+
+
+
+
+
+
+
Links for bearer —
-
@@ -591,6 +619,18 @@
+
+
+
+
+
Shown once. Required for ttl=0 permanent links with a bearer that has permanent capability.
+
+
+
+
diff --git a/examples/crash_reporter/backend/views/partials/console_home_landing.php b/examples/crash_reporter/backend/views/partials/console_home_landing.php
index a8f26fd..ecb2d68 100644
--- a/examples/crash_reporter/backend/views/partials/console_home_landing.php
+++ b/examples/crash_reporter/backend/views/partials/console_home_landing.php
@@ -44,6 +44,13 @@ $proj = '/app/androidcast_project';
Graphs
Sessions, crashes, and device activity over time.
+
+
+
+ Short links
+ Mint bearer tokens and shorten URLs for s.f0xx.org.
+
+
All services