diff --git a/backend/url-shortener/DEPLOY.md b/backend/url-shortener/DEPLOY.md
index f52935d..13df4bb 100644
--- a/backend/url-shortener/DEPLOY.md
+++ b/backend/url-shortener/DEPLOY.md
@@ -59,6 +59,14 @@ export BEARER='…' ./scripts/smoke_shorten.sh
examples/crash_reporter/backend/scripts/validate_be_services.sh
```
+## Hub admin (Short links card)
+
+Operator UI lives in the **crashes console** (shared `ac_crash_sess`), not on `s.f0xx.org`:
+
+- `https://apps.f0xx.org/app/androidcast_project/crashes/?view=short_links`
+- Enable in crashes `config.php`: `url_shortener.enabled => true` + MariaDB `url_shortener` block (see `examples/crash_reporter/backend/config/config.example.php`)
+- Smoke: `examples/crash_reporter/backend/scripts/test_short_links_api.sh`
+
## Config
[config/config.example.php](config/config.example.php) — prefer **`socket`** `/run/mysqld/mysqld.sock` on Alpine (same as crashes when TCP refused).
diff --git a/docs/20260610_SERVICES.md b/docs/20260610_SERVICES.md
index 72f3617..a8c40d8 100644
--- a/docs/20260610_SERVICES.md
+++ b/docs/20260610_SERVICES.md
@@ -332,7 +332,7 @@ Flow doc: [20260607-2FA-email-mobile-auth-flow.md](20260607-2FA-email-mobile-aut
## URL shortener
-**Web:** [https://s.f0xx.org](https://s.f0xx.org)
+**Web:** [https://s.f0xx.org](https://s.f0xx.org) · **Admin:** […/crashes/?view=short_links](https://apps.f0xx.org/app/androidcast_project/crashes/?view=short_links) (RBAC: company owner/admin)
| Kind | Path | Notes |
|------|------|--------|
@@ -340,13 +340,15 @@ Flow doc: [20260607-2FA-email-mobile-auth-flow.md](20260607-2FA-email-mobile-aut
| Shorten | `GET/POST /api/v1/shorten` | Bearer token required |
| QR | `GET /api/v1/qr/{slug}.png` | PNG (`libqrencode-tools` on BE) |
| Redirect | `GET /{slug}` | `302` → origin; `410` if expired |
+| Admin UI | `…/crashes/?view=short_links` | Mint/revoke bearers, list links (session RBAC) |
Spec: [specs/20100611_3_url_shortener.md](specs/20100611_3_url_shortener.md) · code: [backend/url-shortener/](../backend/url-shortener/)
```bash
curl -fsS https://s.f0xx.org/api/v1/health
-# Bearer: php backend/url-shortener/scripts/seed_bearer.php (on BE after deploy)
+# Bearer: hub Short links → Mint, or php backend/url-shortener/scripts/seed_bearer.php (CLI)
export BEARER=… BASE=https://s.f0xx.org backend/url-shortener/scripts/demo_curl.sh
+CRASHES_BASE=https://apps.f0xx.org/app/androidcast_project/crashes ./examples/crash_reporter/backend/scripts/test_short_links_api.sh
```
---
diff --git a/examples/app_hub/assets/i18n/hub-en.json b/examples/app_hub/assets/i18n/hub-en.json
index 8dbb114..0e961f1 100644
--- a/examples/app_hub/assets/i18n/hub-en.json
+++ b/examples/app_hub/assets/i18n/hub-en.json
@@ -15,6 +15,7 @@
"nav.crashes": "Crashes",
"nav.graphs": "Graphs",
"nav.remote": "Remote access",
+ "nav.short_links": "Short links",
"nav.builder": "Builder",
"nav.toggle": "Navigation",
"nav.security": "Security",
diff --git a/examples/app_hub/assets/i18n/hub-ru.json b/examples/app_hub/assets/i18n/hub-ru.json
index 21bc57c..b7a7429 100644
--- a/examples/app_hub/assets/i18n/hub-ru.json
+++ b/examples/app_hub/assets/i18n/hub-ru.json
@@ -15,6 +15,7 @@
"nav.crashes": "Крэши",
"nav.graphs": "Графики",
"nav.remote": "Удалённый доступ",
+ "nav.short_links": "Короткие ссылки",
"nav.builder": "Сборщик",
"nav.toggle": "Навигация",
"nav.security": "Безопасность",
diff --git a/examples/app_hub/index.php b/examples/app_hub/index.php
index 1c628f2..53e281f 100644
--- a/examples/app_hub/index.php
+++ b/examples/app_hub/index.php
@@ -143,6 +143,7 @@ function hub_h(string $s): string {
diff --git a/examples/crash_reporter/backend/README.md b/examples/crash_reporter/backend/README.md
index f93612e..e081993 100644
--- a/examples/crash_reporter/backend/README.md
+++ b/examples/crash_reporter/backend/README.md
@@ -313,6 +313,19 @@ BASE=https://apps.f0xx.org/app/androidcast_project/crashes ./scripts/ra_e2e_cli.
Android: Developer settings → **Remote access** → **RSSH** (alpha target; WireGuard lab-only). RSSH avoids VPN permission. Polls BE every 1–7 min while enabled. See [REMOTE_ACCESS_IMPL.md § VPN consent](../../../docs/REMOTE_ACCESS_IMPL.md#android-vpn-consent-wireguard) and [ROADMAP § RSSH](../../../docs/ROADMAP.md#remote-access-alpha--rssh).
+## Short links (URL shortener admin)
+
+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**
+- Admin API: `…/api/short_links.php` (session cookie — not on `s.f0xx.org`)
+- 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`.
+
+Smoke: `./scripts/test_short_links_api.sh` (needs admin/owner session cookie). Purge expired links: platform admin only (UI button or `backend/url-shortener/scripts/purge_url_shortener.php` cron).
+
## Third-party licenses (BE / landing)
- [THIRD_PARTY_LICENSES.md](THIRD_PARTY_LICENSES.md) — server stack, optional WireGuard tools, link to mobile app list.
diff --git a/examples/crash_reporter/backend/config/config.example.php b/examples/crash_reporter/backend/config/config.example.php
index 6385ae2..5903f97 100644
--- a/examples/crash_reporter/backend/config/config.example.php
+++ b/examples/crash_reporter/backend/config/config.example.php
@@ -99,4 +99,20 @@ return [
'password' => '', // set in prod config.php only — not in git
],
],
+ // URL shortener admin (hub Short links card) — MariaDB url_shortener on BE
+ 'url_shortener' => [
+ 'enabled' => false,
+ 'public_base' => 'https://s.f0xx.org',
+ 'db' => [
+ 'mysql' => [
+ 'host' => '127.0.0.1',
+ 'port' => 3306,
+ 'socket' => '/run/mysqld/mysqld.sock',
+ 'database' => 'url_shortener',
+ 'username' => 'androidcast',
+ 'password' => 'change-me',
+ 'charset' => 'utf8mb4',
+ ],
+ ],
+ ],
];
diff --git a/examples/crash_reporter/backend/public/api/short_links.php b/examples/crash_reporter/backend/public/api/short_links.php
new file mode 100644
index 0000000..c7464f6
--- /dev/null
+++ b/examples/crash_reporter/backend/public/api/short_links.php
@@ -0,0 +1,125 @@
+ false, 'error' => 'forbidden'], 403);
+}
+
+$method = $_SERVER['REQUEST_METHOD'] ?? 'GET';
+$action = trim((string) ($_GET['action'] ?? ''));
+
+$avail = ShortLinksRepository::availability();
+if (!$avail['ok']) {
+ json_out([
+ 'ok' => false,
+ 'error' => 'service_unavailable',
+ 'reason' => $avail['error'] ?? 'unknown',
+ ], 503);
+}
+
+if ($method === 'GET') {
+ if ($action === 'dashboard') {
+ json_out([
+ 'ok' => true,
+ 'public_base' => UrlShortenerDatabase::publicBase(),
+ 'bearers' => ShortLinksRepository::listBearers(),
+ 'audit' => ShortLinksRepository::listAudit(40),
+ 'permissions' => [
+ 'can_operate' => Rbac::can('short_links_operate'),
+ 'can_admin' => Rbac::isGlobalAdmin(),
+ ],
+ ]);
+ }
+ if ($action === 'links') {
+ $bearerId = (int) ($_GET['bearer_id'] ?? 0);
+ if ($bearerId <= 0) {
+ json_out(['ok' => false, 'error' => 'missing_bearer_id'], 400);
+ }
+ json_out([
+ 'ok' => true,
+ 'links' => ShortLinksRepository::listLinks($bearerId),
+ ]);
+ }
+ json_out(['ok' => false, 'error' => 'unknown_action'], 400);
+}
+
+if ($method !== 'POST') {
+ json_out(['ok' => false, 'error' => 'method_not_allowed'], 405);
+}
+
+$raw = file_get_contents('php://input') ?: '';
+$body = json_decode($raw, true);
+if (!is_array($body)) {
+ json_out(['ok' => false, 'error' => 'invalid_json'], 400);
+}
+
+if ($action === 'mint_bearer') {
+ if (!Rbac::can('short_links_operate')) {
+ json_out(['ok' => false, 'error' => 'forbidden'], 403);
+ }
+ $result = ShortLinksRepository::mintBearer(
+ (string) ($body['label'] ?? ''),
+ !array_key_exists('can_temporary', $body) || !empty($body['can_temporary']),
+ !empty($body['can_permanent']),
+ (int) ($body['rate_limit_per_hour'] ?? 1000)
+ );
+ 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'],
+ ]);
+}
+
+if ($action === 'revoke_bearer') {
+ if (!Rbac::can('short_links_operate')) {
+ json_out(['ok' => false, 'error' => 'forbidden'], 403);
+ }
+ $bearerId = (int) ($body['bearer_id'] ?? 0);
+ if ($bearerId <= 0) {
+ json_out(['ok' => false, 'error' => 'missing_bearer_id'], 400);
+ }
+ if (!ShortLinksRepository::revokeBearer($bearerId)) {
+ json_out(['ok' => false, 'error' => 'not_found_or_revoked'], 404);
+ }
+ json_out(['ok' => true]);
+}
+
+if ($action === 'create_link') {
+ if (!Rbac::can('short_links_operate')) {
+ json_out(['ok' => false, 'error' => 'forbidden'], 403);
+ }
+ $bearerId = (int) ($body['bearer_id'] ?? 0);
+ $url = (string) ($body['url'] ?? '');
+ $ttl = (int) ($body['ttl'] ?? 86400);
+ if ($bearerId <= 0 || trim($url) === '') {
+ json_out(['ok' => false, 'error' => 'missing_fields'], 400);
+ }
+ $result = ShortLinksRepository::createLink($bearerId, $url, $ttl);
+ if (empty($result['ok'])) {
+ $code = match ($result['error'] ?? '') {
+ 'rate_limited' => 429,
+ 'ttl_expired' => 409,
+ default => 400,
+ };
+ json_out(['ok' => false, 'error' => $result['error'] ?? 'failed', 'message' => $result['message'] ?? ''], $code);
+ }
+ json_out(['ok' => true, 'link' => $result]);
+}
+
+if ($action === 'purge_expired') {
+ if (!Rbac::isGlobalAdmin()) {
+ json_out(['ok' => false, 'error' => 'forbidden'], 403);
+ }
+ $counts = ShortLinksRepository::purgeExpired();
+ json_out(['ok' => true, 'purged' => $counts]);
+}
+
+json_out(['ok' => false, 'error' => 'unknown_action'], 400);
diff --git a/examples/crash_reporter/backend/public/assets/css/app.css b/examples/crash_reporter/backend/public/assets/css/app.css
index 3afbb14..4413529 100644
--- a/examples/crash_reporter/backend/public/assets/css/app.css
+++ b/examples/crash_reporter/backend/public/assets/css/app.css
@@ -1777,6 +1777,41 @@ button.report-tag--filter:hover {
border-radius: 2px;
box-sizing: border-box;
}
+.nav-icon--link {
+ width: 18px;
+ height: 18px;
+}
+.nav-icon--link::before {
+ content: "";
+ position: absolute;
+ left: 1px;
+ top: 9px;
+ width: 10px;
+ height: 10px;
+ border: 2px solid currentColor;
+ border-radius: 3px;
+ transform: rotate(-45deg);
+ box-sizing: border-box;
+}
+.nav-icon--link::after {
+ content: "";
+ position: absolute;
+ left: 8px;
+ top: 2px;
+ width: 10px;
+ height: 10px;
+ border: 2px solid currentColor;
+ border-radius: 3px;
+ transform: rotate(-45deg);
+ box-sizing: border-box;
+}
+.tag-field--wide {
+ flex: 1 1 280px;
+ min-width: 200px;
+}
+.tag-field--wide input {
+ width: 100%;
+}
.graph-quick-link { font-size: 0.9rem; }
.graphs-scope { margin-top: 20px; }
.graphs-scope-title {
diff --git a/examples/crash_reporter/backend/public/assets/js/short_links.js b/examples/crash_reporter/backend/public/assets/js/short_links.js
new file mode 100644
index 0000000..5930500
--- /dev/null
+++ b/examples/crash_reporter/backend/public/assets/js/short_links.js
@@ -0,0 +1,317 @@
+(function () {
+ function basePath() {
+ return document.body.getAttribute('data-base-path') || '';
+ }
+
+ function canOperate() {
+ return document.body.getAttribute('data-can-sl-operate') === '1';
+ }
+
+ function canAdmin() {
+ return document.body.getAttribute('data-can-sl-admin') === '1';
+ }
+
+ function apiUrl(action, params) {
+ const q = new URLSearchParams(params || {});
+ q.set('action', action);
+ return basePath() + '/api/short_links.php?' + q.toString();
+ }
+
+ function setStatus(msg, isError) {
+ const el = document.getElementById('sl-status');
+ if (!el) return;
+ el.textContent = msg;
+ el.classList.toggle('error', !!isError);
+ }
+
+ async function fetchJson(url, opts) {
+ const res = await fetch(url, Object.assign({ credentials: 'same-origin' }, opts || {}));
+ const data = await res.json().catch(() => ({}));
+ if (!res.ok || data.ok === false) {
+ const err = data.error || data.reason || ('HTTP ' + res.status);
+ throw new Error(err);
+ }
+ return data;
+ }
+
+ function esc(s) {
+ const d = document.createElement('div');
+ d.textContent = s == null ? '' : String(s);
+ return d.innerHTML;
+ }
+
+ function truncate(s, n) {
+ const t = String(s || '');
+ return t.length <= n ? t : t.slice(0, n - 1) + '…';
+ }
+
+ let selectedBearerId = 0;
+ let selectedBearerLabel = '';
+
+ 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. |
';
+ return;
+ }
+ (bearers || []).forEach((b) => {
+ const tr = document.createElement('tr');
+ const active = b.active !== false && !b.revoked_at;
+ const status = active ? '
active' : 'revoked';
+ const revokeBtn = canOperate() && active
+ ? '
'
+ : '';
+ const viewBtn = canOperate()
+ ? '
'
+ : '';
+ tr.innerHTML =
+ '
' + esc(b.id) + ' | ' +
+ '
' + esc(b.label) + ' | ' +
+ '
' + status + ' | ' +
+ '
' + 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 || '—') + ' | ' +
+ '
' + viewBtn + revokeBtn + ' | ';
+ tbody.appendChild(tr);
+ });
+ }
+
+ function renderLinks(links) {
+ const tbody = document.getElementById('sl-links-tbody');
+ if (!tbody) return;
+ tbody.innerHTML = '';
+ if (!links || !links.length) {
+ tbody.innerHTML = '
| No links for this bearer. |
';
+ return;
+ }
+ (links || []).forEach((l) => {
+ const tr = document.createElement('tr');
+ const exp = l.expired ? '
expired' : esc(l.expires_at || 'permanent');
+ tr.innerHTML =
+ '
' + esc(l.short_url) + ' ' +
+ ' | ' +
+ '
' + esc(truncate(l.origin, 64)) + ' | ' +
+ '
' + exp + ' | ' +
+ '
' + esc(l.access_count) + ' | ' +
+ '
PNG | ';
+ tbody.appendChild(tr);
+ });
+ }
+
+ function renderAudit(rows) {
+ const tbody = document.getElementById('sl-audit-tbody');
+ if (!tbody) return;
+ tbody.innerHTML = '';
+ if (!rows || !rows.length) {
+ tbody.innerHTML = '
| No audit events. |
';
+ return;
+ }
+ (rows || []).forEach((e) => {
+ const tr = document.createElement('tr');
+ tr.innerHTML =
+ '
' + esc(e.created_at) + ' | ' +
+ '
' + esc(e.event) + ' | ' +
+ '
' + esc(e.bearer_id != null ? e.bearer_id : '—') + ' | ' +
+ '
' + esc(e.slug || '—') + ' | ' +
+ '
' + esc(e.detail || '—') + ' | ';
+ tbody.appendChild(tr);
+ });
+ }
+
+ function showTokenModal(token) {
+ const modal = document.getElementById('sl-token-modal');
+ const val = document.getElementById('sl-token-value');
+ if (!modal || !val) return;
+ val.textContent = token;
+ modal.hidden = false;
+ }
+
+ function hideTokenModal() {
+ const modal = document.getElementById('sl-token-modal');
+ if (modal) modal.hidden = true;
+ }
+
+ async function loadLinks(bearerId, label) {
+ selectedBearerId = bearerId;
+ selectedBearerLabel = label || String(bearerId);
+ const section = document.getElementById('sl-links-section');
+ const labelEl = document.getElementById('sl-links-bearer-label');
+ const hidden = document.getElementById('sl-shorten-bearer-id');
+ if (section) section.hidden = false;
+ if (labelEl) labelEl.textContent = selectedBearerLabel + ' (#' + bearerId + ')';
+ if (hidden) hidden.value = String(bearerId);
+ setStatus('Loading links…');
+ const data = await fetchJson(apiUrl('links', { bearer_id: String(bearerId) }));
+ renderLinks(data.links);
+ setStatus('Links for bearer #' + bearerId);
+ }
+
+ async function refresh() {
+ setStatus('Loading…');
+ try {
+ const data = await fetchJson(apiUrl('dashboard'));
+ renderBearers(data.bearers);
+ renderAudit(data.audit);
+ if (selectedBearerId > 0) {
+ await loadLinks(selectedBearerId, selectedBearerLabel);
+ }
+ setStatus('Updated ' + new Date().toLocaleTimeString() + ' · ' + (data.public_base || ''));
+ } catch (e) {
+ setStatus('Failed: ' + e.message, true);
+ }
+ }
+
+ document.addEventListener('click', async (ev) => {
+ const t = ev.target;
+ if (!(t instanceof HTMLElement)) return;
+
+ if (t.id === 'sl-token-close' || t.closest('#sl-token-close')) {
+ hideTokenModal();
+ return;
+ }
+ if (t.id === 'sl-token-copy') {
+ const val = document.getElementById('sl-token-value');
+ if (val) {
+ try {
+ await navigator.clipboard.writeText(val.textContent || '');
+ setStatus('Token copied');
+ } catch (e) {
+ setStatus('Copy failed', true);
+ }
+ }
+ return;
+ }
+
+ const copy = t.getAttribute('data-copy');
+ if (copy) {
+ try {
+ await navigator.clipboard.writeText(copy);
+ setStatus('Copied short URL');
+ } catch (e) {
+ setStatus('Copy failed', true);
+ }
+ return;
+ }
+
+ const viewId = t.getAttribute('data-view-links');
+ if (viewId) {
+ if (!canOperate()) return;
+ try {
+ await loadLinks(parseInt(viewId, 10), t.getAttribute('data-label') || '');
+ } catch (e) {
+ setStatus('Links: ' + e.message, true);
+ }
+ return;
+ }
+
+ 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;
+ try {
+ await fetchJson(apiUrl('revoke_bearer'), {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ bearer_id: parseInt(revokeId, 10) }),
+ });
+ if (selectedBearerId === parseInt(revokeId, 10)) {
+ selectedBearerId = 0;
+ const section = document.getElementById('sl-links-section');
+ if (section) section.hidden = true;
+ }
+ await refresh();
+ } catch (e) {
+ setStatus('Revoke: ' + e.message, true);
+ }
+ }
+ });
+
+ const mintForm = document.getElementById('sl-mint-form');
+ if (mintForm) {
+ if (!canOperate()) {
+ mintForm.hidden = true;
+ }
+ mintForm.addEventListener('submit', async (ev) => {
+ ev.preventDefault();
+ if (!canOperate()) return;
+ const fd = new FormData(mintForm);
+ try {
+ setStatus('Minting bearer…');
+ const data = await fetchJson(apiUrl('mint_bearer'), {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ label: fd.get('label'),
+ rate_limit_per_hour: parseInt(String(fd.get('rate_limit') || '1000'), 10),
+ }),
+ });
+ mintForm.reset();
+ showTokenModal(data.token);
+ await refresh();
+ } catch (e) {
+ setStatus('Mint: ' + 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);
+ try {
+ 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),
+ }),
+ });
+ if (data.link && data.link.short_url) {
+ setStatus('Short URL: ' + data.link.short_url);
+ }
+ await refresh();
+ } catch (e) {
+ setStatus('Shorten: ' + e.message, true);
+ }
+ });
+ }
+
+ const purgeBtn = document.getElementById('sl-purge-btn');
+ if (purgeBtn) {
+ if (!canAdmin()) purgeBtn.hidden = true;
+ purgeBtn.addEventListener('click', async () => {
+ if (!canAdmin()) return;
+ if (!window.confirm('Delete all expired links and audit rows older than 90 days?')) return;
+ try {
+ const data = await fetchJson(apiUrl('purge_expired'), {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({}),
+ });
+ setStatus('Purged links=' + (data.purged && data.purged.links) + ' audit=' + (data.purged && data.purged.audit));
+ await refresh();
+ } catch (e) {
+ setStatus('Purge: ' + e.message, true);
+ }
+ });
+ }
+
+ const modal = document.getElementById('sl-token-modal');
+ if (modal) {
+ modal.addEventListener('click', (ev) => {
+ if (ev.target === modal) hideTokenModal();
+ });
+ }
+ document.addEventListener('keydown', (ev) => {
+ if (ev.key === 'Escape') hideTokenModal();
+ });
+
+ refresh();
+})();
diff --git a/examples/crash_reporter/backend/public/index.php b/examples/crash_reporter/backend/public/index.php
index d7bb107..57d8d13 100644
--- a/examples/crash_reporter/backend/public/index.php
+++ b/examples/crash_reporter/backend/public/index.php
@@ -105,6 +105,11 @@ if ($route === '/api/remote_access.php' || str_ends_with($route, '/api/remote_ac
exit;
}
+if ($route === '/api/short_links.php' || str_ends_with($route, '/api/short_links.php')) {
+ require __DIR__ . '/api/short_links.php';
+ exit;
+}
+
if ($route === '/api/rbac.php' || str_ends_with($route, '/api/rbac.php')) {
require __DIR__ . '/api/rbac.php';
exit;
@@ -311,6 +316,12 @@ Auth::check();
$grouped = isset($_GET['group']) && $_GET['group'] === '1';
$view = $_GET['view'] ?? 'home';
+if ($view === 'short_links' && !Rbac::can('short_links_view')) {
+ http_response_code(403);
+ echo 'Forbidden';
+ exit;
+}
+
if ($view === 'report' && isset($_GET['id'])) {
try {
$report = ReportRepository::getById((int) $_GET['id']);
@@ -366,6 +377,7 @@ $pageTitle = match ($view) {
'tickets' => 'Tickets',
'graphs' => 'Graphs',
'remote_access' => 'Remote access',
+ 'short_links' => 'Short links',
'rbac' => 'Access control',
'reports', 'report' => 'Issues',
default => 'Console',
diff --git a/examples/crash_reporter/backend/scripts/test_short_links_api.sh b/examples/crash_reporter/backend/scripts/test_short_links_api.sh
new file mode 100755
index 0000000..0d63614
--- /dev/null
+++ b/examples/crash_reporter/backend/scripts/test_short_links_api.sh
@@ -0,0 +1,38 @@
+#!/usr/bin/env bash
+# Short links admin API smoke — session cookie + RBAC.
+# Usage:
+# COOKIE='ac_crash_sess=…' CRASHES_BASE=https://apps.f0xx.org/app/androidcast_project/crashes ./scripts/test_short_links_api.sh
+set -euo pipefail
+
+ROOT="$(cd "$(dirname "$0")/.." && pwd)"
+BASE="${CRASHES_BASE:-http://127.0.0.1:8080}"
+COOKIE="${COOKIE:-}"
+JAR="${COOKIE_JAR:-/tmp/short_links_api_cookies.txt}"
+
+if [[ -z "$COOKIE" && ! -f "$JAR" ]]; then
+ echo "Set COOKIE=ac_crash_sess=… or login first (COOKIE_JAR=$JAR)" >&2
+ exit 1
+fi
+
+curl_api() {
+ local url="$1"
+ shift
+ if [[ -n "$COOKIE" ]]; then
+ curl -fsS -b "$COOKIE" "$@" "$url"
+ else
+ curl -fsS -b "$JAR" -c "$JAR" "$@" "$url"
+ fi
+}
+
+echo "==> dashboard"
+curl_api "$BASE/api/short_links.php?action=dashboard" | grep -q '"ok":true' && echo OK
+
+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
+fi
+
+echo "Done"
diff --git a/examples/crash_reporter/backend/src/Rbac.php b/examples/crash_reporter/backend/src/Rbac.php
index 4f30773..b7ca105 100644
--- a/examples/crash_reporter/backend/src/Rbac.php
+++ b/examples/crash_reporter/backend/src/Rbac.php
@@ -54,6 +54,8 @@ final class Rbac {
'remote_access_view',
'remote_access_operate',
'remote_access_admin',
+ 'short_links_view',
+ 'short_links_operate',
],
self::COMPANY_ADMIN => [
'view_reports',
@@ -65,6 +67,8 @@ final class Rbac {
'remote_access_view',
'remote_access_operate',
'remote_access_admin',
+ 'short_links_view',
+ 'short_links_operate',
],
self::COMPANY_MEMBER => [
'view_reports',
diff --git a/examples/crash_reporter/backend/src/ShortLinksRepository.php b/examples/crash_reporter/backend/src/ShortLinksRepository.php
new file mode 100644
index 0000000..c99105c
--- /dev/null
+++ b/examples/crash_reporter/backend/src/ShortLinksRepository.php
@@ -0,0 +1,385 @@
+ false, 'error' => 'disabled'];
+ }
+ if (!UrlShortenerDatabase::ping()) {
+ return ['ok' => false, 'error' => 'db_unreachable'];
+ }
+ return ['ok' => true];
+ }
+
+ /** @return list
> */
+ public static function listBearers(): array {
+ $pdo = UrlShortenerDatabase::pdo();
+ $rows = $pdo->query(
+ 'SELECT b.id, b.label, b.trusted, b.can_temporary, b.can_permanent,
+ b.rate_limit_per_hour, b.revoked_at, b.created_at,
+ COUNT(l.id) AS link_count,
+ SUM(CASE WHEN l.expires_at IS NOT NULL AND l.expires_at < NOW() THEN 1 ELSE 0 END) AS expired_link_count
+ FROM bearer_tokens b
+ LEFT JOIN links l ON l.bearer_id = b.id
+ GROUP BY b.id
+ ORDER BY b.id DESC'
+ )->fetchAll();
+ return array_map(static fn(array $r) => self::formatBearerRow($r), $rows);
+ }
+
+ /** @return list> */
+ public static function listLinks(int $bearerId, int $limit = 100): array {
+ $pdo = UrlShortenerDatabase::pdo();
+ $limit = max(1, min(500, $limit));
+ $stmt = $pdo->prepare(
+ 'SELECT id, slug, origin, ttl_seconds, expires_at, created_at, accessed_at, access_count
+ FROM links WHERE bearer_id = ? ORDER BY id DESC LIMIT ' . $limit
+ );
+ $stmt->execute([$bearerId]);
+ $base = UrlShortenerDatabase::publicBase();
+ $out = [];
+ foreach ($stmt->fetchAll() as $row) {
+ $slug = (string) $row['slug'];
+ $expired = self::rowExpired($row);
+ $out[] = [
+ 'id' => (int) $row['id'],
+ 'slug' => $slug,
+ 'short_url' => $base . '/' . $slug,
+ 'qr_url' => $base . '/api/v1/qr/' . $slug . '.png',
+ 'origin' => (string) $row['origin'],
+ 'ttl_seconds' => (int) $row['ttl_seconds'],
+ 'expires_at' => $row['expires_at'],
+ 'expired' => $expired,
+ 'created_at' => $row['created_at'],
+ 'accessed_at' => $row['accessed_at'],
+ 'access_count' => (int) $row['access_count'],
+ ];
+ }
+ return $out;
+ }
+
+ /** @return list> */
+ public static function listAudit(int $limit = 50): array {
+ $pdo = UrlShortenerDatabase::pdo();
+ $limit = max(1, min(200, $limit));
+ $rows = $pdo->query(
+ 'SELECT id, event, bearer_id, slug, ip, detail, created_at
+ FROM audit_log ORDER BY id DESC LIMIT ' . $limit
+ )->fetchAll();
+ return array_map(static function (array $r): array {
+ return [
+ 'id' => (int) $r['id'],
+ 'event' => (string) $r['event'],
+ 'bearer_id' => $r['bearer_id'] !== null ? (int) $r['bearer_id'] : null,
+ 'slug' => $r['slug'],
+ 'ip' => (string) $r['ip'],
+ 'detail' => (string) ($r['detail'] ?? ''),
+ 'created_at' => $r['created_at'],
+ ];
+ }, $rows);
+ }
+
+ /**
+ * @return array{ok: true, bearer_id: int, token: string}|array{ok: false, error: string}
+ */
+ public static function mintBearer(
+ string $label,
+ bool $canTemporary = true,
+ bool $canPermanent = false,
+ int $rateLimit = 1000
+ ): array {
+ $label = trim($label);
+ if ($label === '') {
+ return ['ok' => false, 'error' => 'missing_label'];
+ }
+ if (strlen($label) > 128) {
+ return ['ok' => false, 'error' => 'label_too_long'];
+ }
+ $token = bin2hex(random_bytes(24));
+ $pdo = UrlShortenerDatabase::pdo();
+ $stmt = $pdo->prepare(
+ 'INSERT INTO bearer_tokens (token_hash, label, trusted, can_temporary, can_permanent, rate_limit_per_hour)
+ VALUES (?, ?, 1, ?, ?, ?)'
+ );
+ $stmt->execute([
+ hash('sha256', $token),
+ $label,
+ $canTemporary ? 1 : 0,
+ $canPermanent ? 1 : 0,
+ max(1, min(100000, $rateLimit)),
+ ]);
+ $id = (int) $pdo->lastInsertId();
+ self::audit('admin_mint', $id, null, 'label=' . $label);
+ return ['ok' => true, 'bearer_id' => $id, 'token' => $token];
+ }
+
+ public static function revokeBearer(int $bearerId): bool {
+ $pdo = UrlShortenerDatabase::pdo();
+ $stmt = $pdo->prepare(
+ 'UPDATE bearer_tokens SET revoked_at = NOW() WHERE id = ? AND revoked_at IS NULL'
+ );
+ $stmt->execute([$bearerId]);
+ if ($stmt->rowCount() < 1) {
+ return false;
+ }
+ self::audit('admin_revoke', $bearerId, null, 'revoked');
+ return true;
+ }
+
+ /** @return array{links: int, audit: int} */
+ public static function purgeExpired(): array {
+ $pdo = UrlShortenerDatabase::pdo();
+ $links = (int) $pdo->exec(
+ 'DELETE FROM links WHERE expires_at IS NOT NULL AND expires_at < NOW()'
+ );
+ $audit = (int) $pdo->exec(
+ 'DELETE FROM audit_log WHERE created_at < (NOW() - INTERVAL 90 DAY)'
+ );
+ self::audit('admin_purge', null, null, "links=$links audit=$audit");
+ return ['links' => $links, 'audit' => $audit];
+ }
+
+ /**
+ * @return array{ok: true, short_url: string, slug: string, qr_url: string, ttl: string}|array{ok: false, error: string}
+ */
+ public static function createLink(int $bearerId, string $rawUrl, int $ttlSeconds = 86400): array {
+ $bearer = self::findBearerById($bearerId);
+ if ($bearer === null) {
+ return ['ok' => false, 'error' => 'unknown_bearer'];
+ }
+ if ($bearer['revoked_at'] !== null && $bearer['revoked_at'] !== '') {
+ return ['ok' => false, 'error' => 'bearer_revoked'];
+ }
+ if ((int) ($bearer['trusted'] ?? 0) !== 1) {
+ return ['ok' => false, 'error' => 'bearer_untrusted'];
+ }
+
+ try {
+ $normalized = self::normalizeUrl($rawUrl);
+ } catch (InvalidArgumentException $e) {
+ return ['ok' => false, 'error' => 'invalid_url', 'message' => $e->getMessage()];
+ }
+ $originHash = hash('sha256', $normalized);
+
+ $cached = self::findActiveLink($bearerId, $originHash);
+ if ($cached !== null) {
+ return self::linkSuccess($normalized, $cached);
+ }
+ if (self::findExpiredLink($bearerId, $originHash) !== null) {
+ return ['ok' => false, 'error' => 'ttl_expired'];
+ }
+
+ if ($ttlSeconds < 0) {
+ $ttlSeconds = 86400;
+ }
+ if ($ttlSeconds === 0) {
+ return ['ok' => false, 'error' => 'permanent_requires_signer'];
+ }
+ if ((int) ($bearer['can_temporary'] ?? 0) !== 1) {
+ return ['ok' => false, 'error' => 'bearer_no_temporary'];
+ }
+
+ $limit = max(1, (int) ($bearer['rate_limit_per_hour'] ?? 1000));
+ if (self::countCreatesLastHour($bearerId) >= $limit) {
+ return ['ok' => false, 'error' => 'rate_limited'];
+ }
+
+ $slug = self::allocateSlug($bearerId, $normalized);
+ $expiresAt = 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, ?, ?, ?, ?, ?)'
+ );
+ $stmt->execute([$bearerId, $slug, $normalized, $originHash, $ttlSeconds, $expiresAt]);
+ self::audit('admin_shorten', $bearerId, $slug, 'created');
+
+ $row = self::findLinkBySlug($slug);
+ if ($row === null) {
+ return ['ok' => false, 'error' => 'insert_failed'];
+ }
+ return self::linkSuccess($normalized, $row);
+ }
+
+ /** @param array $row */
+ private static function formatBearerRow(array $row): array {
+ return [
+ 'id' => (int) $row['id'],
+ 'label' => (string) $row['label'],
+ 'trusted' => (int) ($row['trusted'] ?? 0) === 1,
+ 'can_temporary' => (int) ($row['can_temporary'] ?? 0) === 1,
+ 'can_permanent' => (int) ($row['can_permanent'] ?? 0) === 1,
+ 'rate_limit_per_hour' => (int) ($row['rate_limit_per_hour'] ?? 1000),
+ 'revoked_at' => $row['revoked_at'],
+ 'active' => empty($row['revoked_at']),
+ 'created_at' => $row['created_at'],
+ 'link_count' => (int) ($row['link_count'] ?? 0),
+ 'expired_link_count' => (int) ($row['expired_link_count'] ?? 0),
+ ];
+ }
+
+ /** @return array|null */
+ private static function findBearerById(int $id): ?array {
+ $pdo = UrlShortenerDatabase::pdo();
+ $stmt = $pdo->prepare('SELECT * FROM bearer_tokens WHERE id = ? LIMIT 1');
+ $stmt->execute([$id]);
+ $row = $stmt->fetch();
+ return $row === false ? null : $row;
+ }
+
+ /** @return array|null */
+ private static function findActiveLink(int $bearerId, string $originHash): ?array {
+ $pdo = UrlShortenerDatabase::pdo();
+ $stmt = $pdo->prepare(
+ 'SELECT * FROM links WHERE bearer_id = ? AND origin_hash = ? ORDER BY id DESC LIMIT 1'
+ );
+ $stmt->execute([$bearerId, $originHash]);
+ $row = $stmt->fetch();
+ if ($row === false || self::rowExpired($row)) {
+ return null;
+ }
+ return $row;
+ }
+
+ /** @return array|null */
+ private static function findExpiredLink(int $bearerId, string $originHash): ?array {
+ $pdo = UrlShortenerDatabase::pdo();
+ $stmt = $pdo->prepare(
+ 'SELECT * FROM links WHERE bearer_id = ? AND origin_hash = ? ORDER BY id DESC LIMIT 1'
+ );
+ $stmt->execute([$bearerId, $originHash]);
+ $row = $stmt->fetch();
+ if ($row === false || !self::rowExpired($row)) {
+ return null;
+ }
+ return $row;
+ }
+
+ /** @return array|null */
+ private static function findLinkBySlug(string $slug): ?array {
+ $pdo = UrlShortenerDatabase::pdo();
+ $stmt = $pdo->prepare('SELECT * FROM links WHERE slug = ? LIMIT 1');
+ $stmt->execute([$slug]);
+ $row = $stmt->fetch();
+ return $row === false ? null : $row;
+ }
+
+ /** @param array $row */
+ private static function rowExpired(array $row): bool {
+ $exp = $row['expires_at'] ?? null;
+ if ($exp === null || $exp === '') {
+ return false;
+ }
+ return strtotime((string) $exp) <= time();
+ }
+
+ private static function countCreatesLastHour(int $bearerId): int {
+ $pdo = UrlShortenerDatabase::pdo();
+ $stmt = $pdo->prepare(
+ 'SELECT COUNT(*) AS c FROM links WHERE bearer_id = ? AND created_at >= (NOW() - INTERVAL 1 HOUR)'
+ );
+ $stmt->execute([$bearerId]);
+ $row = $stmt->fetch();
+ return (int) ($row['c'] ?? 0);
+ }
+
+ private static function allocateSlug(int $bearerId, string $normalized): string {
+ $material = $bearerId . "\x1f" . $normalized;
+ $candidates = [substr(hash('sha256', $material), 0, 12)];
+ for ($i = 1; $i <= 15; $i++) {
+ $candidates[] = substr(hash('sha256', $material . "\x1e" . $i), 0, 12);
+ }
+ $pdo = UrlShortenerDatabase::pdo();
+ foreach ($candidates as $slug) {
+ $stmt = $pdo->prepare('SELECT 1 FROM links WHERE slug = ? LIMIT 1');
+ $stmt->execute([$slug]);
+ if ($stmt->fetch() === false) {
+ return $slug;
+ }
+ }
+ throw new RuntimeException('slug exhaustion');
+ }
+
+ /** @param array $row */
+ private static function linkSuccess(string $normalized, array $row): array {
+ $slug = (string) $row['slug'];
+ $base = UrlShortenerDatabase::publicBase();
+ $exp = $row['expires_at'] ?? null;
+ $ttl = '0';
+ if ($exp !== null && $exp !== '') {
+ $ttl = (string) max(0, strtotime((string) $exp) - time());
+ }
+ return [
+ 'ok' => true,
+ 'url' => $normalized,
+ 'short_url' => $base . '/' . $slug,
+ 'slug' => $slug,
+ 'qr_url' => $base . '/api/v1/qr/' . $slug . '.png',
+ 'ttl' => $ttl,
+ ];
+ }
+
+ private static function normalizeUrl(string $raw): string {
+ $url = trim($raw);
+ if ($url === '') {
+ throw new InvalidArgumentException('empty url');
+ }
+ if (strlen($url) > self::MAX_URL_LEN) {
+ throw new InvalidArgumentException('url too long');
+ }
+ $parts = parse_url($url);
+ if ($parts === false || !isset($parts['scheme'], $parts['host'])) {
+ throw new InvalidArgumentException('malformed url');
+ }
+ $scheme = strtolower((string) $parts['scheme']);
+ if ($scheme !== 'http' && $scheme !== 'https') {
+ throw new InvalidArgumentException('unsupported scheme');
+ }
+ $host = strtolower((string) $parts['host']);
+ self::assertHostAllowed($host);
+ $port = isset($parts['port']) ? ':' . (int) $parts['port'] : '';
+ $path = $parts['path'] ?? '';
+ $query = isset($parts['query']) ? '?' . $parts['query'] : '';
+ $fragment = isset($parts['fragment']) ? '#' . $parts['fragment'] : '';
+ return $scheme . '://' . $host . $port . $path . $query . $fragment;
+ }
+
+ private static function assertHostAllowed(string $host): void {
+ if ($host === 'localhost' || str_ends_with($host, '.local') || str_ends_with($host, '.internal')) {
+ throw new InvalidArgumentException('blocked host');
+ }
+ if ($host === 'metadata.google.internal' || $host === 'metadata') {
+ throw new InvalidArgumentException('blocked host');
+ }
+ if (filter_var($host, FILTER_VALIDATE_IP)) {
+ self::assertIpAllowed($host);
+ return;
+ }
+ $resolved = @gethostbyname($host);
+ if ($resolved !== $host && filter_var($resolved, FILTER_VALIDATE_IP)) {
+ self::assertIpAllowed($resolved);
+ }
+ }
+
+ private static function assertIpAllowed(string $ip): void {
+ if (!filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE)) {
+ throw new InvalidArgumentException('blocked host');
+ }
+ }
+
+ private static function audit(string $event, ?int $bearerId, ?string $slug, string $detail): void {
+ $pdo = UrlShortenerDatabase::pdo();
+ $ip = $_SERVER['REMOTE_ADDR'] ?? '';
+ $stmt = $pdo->prepare(
+ 'INSERT INTO audit_log (event, bearer_id, slug, ip, detail) VALUES (?, ?, ?, ?, ?)'
+ );
+ $stmt->execute([$event, $bearerId, $slug, $ip, $detail]);
+ }
+}
diff --git a/examples/crash_reporter/backend/src/UrlShortenerDatabase.php b/examples/crash_reporter/backend/src/UrlShortenerDatabase.php
new file mode 100644
index 0000000..b42f176
--- /dev/null
+++ b/examples/crash_reporter/backend/src/UrlShortenerDatabase.php
@@ -0,0 +1,59 @@
+ PDO::ERRMODE_EXCEPTION,
+ PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
+ ]);
+ return self::$pdo;
+ }
+
+ public static function ping(): bool {
+ if (!self::enabled()) {
+ return false;
+ }
+ try {
+ self::pdo()->query('SELECT 1');
+ return true;
+ } catch (Throwable) {
+ return false;
+ }
+ }
+}
diff --git a/examples/crash_reporter/backend/src/bootstrap.php b/examples/crash_reporter/backend/src/bootstrap.php
index e78ff3a..76e9e3a 100644
--- a/examples/crash_reporter/backend/src/bootstrap.php
+++ b/examples/crash_reporter/backend/src/bootstrap.php
@@ -64,6 +64,8 @@ require_once __DIR__ . '/UserRepository.php';
require_once __DIR__ . '/TicketRepository.php';
require_once __DIR__ . '/GraphRepository.php';
require_once __DIR__ . '/RemoteAccessRepository.php';
+require_once __DIR__ . '/UrlShortenerDatabase.php';
+require_once __DIR__ . '/ShortLinksRepository.php';
require_once __DIR__ . '/WireGuardPeerProvisioner.php';
require_once __DIR__ . '/WireGuardAddressPool.php';
require_once __DIR__ . '/RsshSessionProvisioner.php';
diff --git a/examples/crash_reporter/backend/views/layout.php b/examples/crash_reporter/backend/views/layout.php
index 881729b..7d1b597 100644
--- a/examples/crash_reporter/backend/views/layout.php
+++ b/examples/crash_reporter/backend/views/layout.php
@@ -38,6 +38,9 @@
+
+
+
@@ -50,6 +53,8 @@
= (($view ?? '') === 'ticket' && !empty($ticket['id'])) ? ' data-ticket-id="' . (int) $ticket['id'] . '"' : '' ?>
= Rbac::can('remote_access_operate') ? ' data-can-ra-operate="1"' : '' ?>
= Rbac::can('remote_access_admin') ? ' data-can-ra-admin="1"' : '' ?>
+ = Rbac::can('short_links_operate') ? ' data-can-sl-operate="1"' : '' ?>
+ = Rbac::isGlobalAdmin() ? ' data-can-sl-admin="1"' : '' ?>
= Rbac::isRoot() ? ' data-can-rbac-root="1"' : '' ?>>
+
+
+
+
+
Loading…
+
+
+
+ Bearer tokens
+
+
+
+
+ | ID |
+ Label |
+ Status |
+ Links |
+ Rate/h |
+ Created |
+ Actions |
+
+
+
+
+
+
+
+
+
+ Links for bearer —
+
+
+
+
+
+ | Short URL |
+ Origin |
+ Expires |
+ Hits |
+ QR |
+
+
+
+
+
+
+
+
+ Audit log
+
+
+
+
+ | Time |
+ Event |
+ Bearer |
+ Slug |
+ Detail |
+
+
+
+
+
+
+
+
+
+
+
Shown once. Store in a secrets manager or BEARER=… for curl scripts.
+
+
+
+
+
diff --git a/examples/crash_reporter/backend/views/partials/console_quick_links.php b/examples/crash_reporter/backend/views/partials/console_quick_links.php
index a82220a..9106fd4 100644
--- a/examples/crash_reporter/backend/views/partials/console_quick_links.php
+++ b/examples/crash_reporter/backend/views/partials/console_quick_links.php
@@ -17,6 +17,9 @@ $items[] = ['Tickets', $bp . '/?view=tickets', 'nav-icon--tickets'];
if (Rbac::can('remote_access_view')) {
$items[] = ['Remote access', $bp . '/?view=remote_access', 'nav-icon--remote'];
}
+if (Rbac::can('short_links_view')) {
+ $items[] = ['Short links', $bp . '/?view=short_links', 'nav-icon--link'];
+}
if (Rbac::canManageRbac()) {
$items[] = ['Access control', $bp . '/?view=rbac', 'nav-icon--tickets'];
}