1
0
mirror of git://f0xx.org/android_cast synced 2026-07-29 04:57:40 +03:00
This commit is contained in:
Anton Afanasyeu
2026-06-11 23:23:20 +02:00
parent 96949f7d72
commit ad9413b5f3
11 changed files with 405 additions and 55 deletions

View File

@@ -66,7 +66,7 @@ Multi-tenant foundation:
- **Global roles** on `users.role`: `root`, `platform_admin` (legacy `admin` works), `viewer` - **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) - **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` - **Devices** auto-registered on upload; reports get `company_id` + `device_id`
- Default company slug `default` — existing rows migrate on first request (`Database::ensureRbacSchema()`) - 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. 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) - Dashboard: `…/crashes/?view=short_links` (RBAC: `short_links_view`; company owner/admin or privilege sets `short_links_viewer` / `short_links_user`)
- Hub nav: **Short links** - Hub nav + console home card: **Short links**
- Admin API: `…/api/short_links.php` (session cookie — not on `s.f0xx.org`) - 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) - 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`. **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`.

View File

@@ -28,6 +28,7 @@ if ($method === 'GET') {
'ok' => true, 'ok' => true,
'public_base' => UrlShortenerDatabase::publicBase(), 'public_base' => UrlShortenerDatabase::publicBase(),
'bearers' => ShortLinksRepository::listBearers(), 'bearers' => ShortLinksRepository::listBearers(),
'signers' => ShortLinksRepository::listSigners(),
'audit' => ShortLinksRepository::listAudit(40), 'audit' => ShortLinksRepository::listAudit(40),
'permissions' => [ 'permissions' => [
'can_operate' => Rbac::can('short_links_operate'), 'can_operate' => Rbac::can('short_links_operate'),
@@ -66,18 +67,53 @@ if ($action === 'mint_bearer') {
(string) ($body['label'] ?? ''), (string) ($body['label'] ?? ''),
!array_key_exists('can_temporary', $body) || !empty($body['can_temporary']), !array_key_exists('can_temporary', $body) || !empty($body['can_temporary']),
!empty($body['can_permanent']), !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'])) { if (empty($result['ok'])) {
json_out(['ok' => false, 'error' => $result['error'] ?? 'failed'], 400); 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([ json_out([
'ok' => true, 'ok' => true,
'bearer_id' => $result['bearer_id'], 'signer_id' => $result['id'],
'token' => $result['token'], '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 ($action === 'revoke_bearer') {
if (!Rbac::can('short_links_operate')) { if (!Rbac::can('short_links_operate')) {
json_out(['ok' => false, 'error' => 'forbidden'], 403); json_out(['ok' => false, 'error' => 'forbidden'], 403);
@@ -102,11 +138,17 @@ if ($action === 'create_link') {
if ($bearerId <= 0 || trim($url) === '') { if ($bearerId <= 0 || trim($url) === '') {
json_out(['ok' => false, 'error' => 'missing_fields'], 400); 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'])) { if (empty($result['ok'])) {
$code = match ($result['error'] ?? '') { $code = match ($result['error'] ?? '') {
'rate_limited' => 429, 'rate_limited' => 429,
'ttl_expired' => 409, 'ttl_expired' => 409,
'signer_required' => 403,
default => 400, default => 400,
}; };
json_out(['ok' => false, 'error' => $result['error'] ?? 'failed', 'message' => $result['message'] ?? ''], $code); json_out(['ok' => false, 'error' => $result['error'] ?? 'failed', 'message' => $result['message'] ?? ''], $code);

View File

@@ -1812,6 +1812,16 @@ button.report-tag--filter:hover {
.tag-field--wide input { .tag-field--wide input {
width: 100%; 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; } .graph-quick-link { font-size: 0.9rem; }
.graphs-scope { margin-top: 20px; } .graphs-scope { margin-top: 20px; }
.graphs-scope-title { .graphs-scope-title {

View File

@@ -20,6 +20,8 @@
"home.card_tickets_desc": "Roadmap tasks, QA items, and workflow tags.", "home.card_tickets_desc": "Roadmap tasks, QA items, and workflow tags.",
"home.card_graphs": "Graphs", "home.card_graphs": "Graphs",
"home.card_graphs_desc": "Sessions, crashes, and device activity over time.", "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", "home.more": "All services",
"reports.title": "Issues", "reports.title": "Issues",
"reports.by_time": "By time", "reports.by_time": "By time",

View File

@@ -30,6 +30,8 @@
"home.card_tickets_desc": "Задачи, QA и теги workflow.", "home.card_tickets_desc": "Задачи, QA и теги workflow.",
"home.card_graphs": "Графики", "home.card_graphs": "Графики",
"home.card_graphs_desc": "Сессии, сбои и активность устройств.", "home.card_graphs_desc": "Сессии, сбои и активность устройств.",
"home.card_short_links": "Короткие ссылки",
"home.card_short_links_desc": "Токены и сокращение URL для s.f0xx.org.",
"home.more": "Все сервисы", "home.more": "Все сервисы",
"reports.title": "Issues", "reports.title": "Issues",
"reports.by_time": "По времени", "reports.by_time": "По времени",

View File

@@ -48,12 +48,19 @@
let selectedBearerId = 0; let selectedBearerId = 0;
let selectedBearerLabel = ''; 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) { function renderBearers(bearers) {
const tbody = document.getElementById('sl-bearers-tbody'); const tbody = document.getElementById('sl-bearers-tbody');
if (!tbody) return; if (!tbody) return;
tbody.innerHTML = ''; tbody.innerHTML = '';
if (!bearers || !bearers.length) { if (!bearers || !bearers.length) {
tbody.innerHTML = '<tr><td colspan="7" class="muted">No bearer tokens yet — mint one below.</td></tr>'; tbody.innerHTML = '<tr><td colspan="8" class="muted">No bearer tokens yet — mint one below.</td></tr>';
return; return;
} }
(bearers || []).forEach((b) => { (bearers || []).forEach((b) => {
@@ -63,13 +70,13 @@
const revokeBtn = canOperate() && active const revokeBtn = canOperate() && active
? ' <button type="button" class="btn btn-sm" data-revoke-bearer="' + esc(String(b.id)) + '">Revoke</button>' ? ' <button type="button" class="btn btn-sm" data-revoke-bearer="' + esc(String(b.id)) + '">Revoke</button>'
: ''; : '';
const viewBtn = canOperate() const viewBtn =
? '<button type="button" class="btn btn-sm btn-primary" data-view-links="' + esc(String(b.id)) + '" data-label="' + esc(b.label || '') + '">Links</button>' '<button type="button" class="btn btn-sm btn-primary" data-view-links="' + esc(String(b.id)) + '" data-label="' + esc(b.label || '') + '">Links</button>';
: '';
tr.innerHTML = tr.innerHTML =
'<td>' + esc(b.id) + '</td>' + '<td>' + esc(b.id) + '</td>' +
'<td>' + esc(b.label) + '</td>' + '<td>' + esc(b.label) + '</td>' +
'<td>' + status + '</td>' + '<td>' + status + '</td>' +
'<td><code>' + esc(bearerCaps(b)) + '</code></td>' +
'<td>' + esc(b.link_count) + (b.expired_link_count > 0 ? ' <span class="muted">(' + esc(b.expired_link_count) + ' exp)</span>' : '') + '</td>' + '<td>' + esc(b.link_count) + (b.expired_link_count > 0 ? ' <span class="muted">(' + esc(b.expired_link_count) + ' exp)</span>' : '') + '</td>' +
'<td>' + esc(b.rate_limit_per_hour) + '</td>' + '<td>' + esc(b.rate_limit_per_hour) + '</td>' +
'<td>' + esc(b.created_at || '—') + '</td>' + '<td>' + esc(b.created_at || '—') + '</td>' +
@@ -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 = '<tr><td colspan="5" class="muted">No signer keys — mint one for permanent links.</td></tr>';
return;
}
(signers || []).forEach((s) => {
const tr = document.createElement('tr');
const active = s.active !== false && !s.revoked_at;
const status = active ? '<span class="tag-pill">active</span>' : 'revoked';
const revokeBtn = canOperate() && active
? ' <button type="button" class="btn btn-sm" data-revoke-signer="' + esc(String(s.id)) + '">Revoke</button>'
: '';
tr.innerHTML =
'<td>' + esc(s.id) + '</td>' +
'<td>' + esc(s.label) + '</td>' +
'<td>' + status + '</td>' +
'<td>' + esc(s.created_at || '—') + '</td>' +
'<td>' + revokeBtn + '</td>';
tbody.appendChild(tr);
});
}
function renderLinks(links) { function renderLinks(links) {
const tbody = document.getElementById('sl-links-tbody'); const tbody = document.getElementById('sl-links-tbody');
if (!tbody) return; if (!tbody) return;
@@ -88,7 +120,9 @@
} }
(links || []).forEach((l) => { (links || []).forEach((l) => {
const tr = document.createElement('tr'); const tr = document.createElement('tr');
const exp = l.expired ? '<span class="muted">expired</span>' : esc(l.expires_at || 'permanent'); const exp = l.expired
? '<span class="muted">expired</span>'
: (l.ttl_seconds === 0 || !l.expires_at ? '<span class="tag-pill">permanent</span>' : esc(l.expires_at));
tr.innerHTML = tr.innerHTML =
'<td><a href="' + esc(l.short_url) + '" target="_blank" rel="noopener">' + esc(l.short_url) + '</a> ' + '<td><a href="' + esc(l.short_url) + '" target="_blank" rel="noopener">' + esc(l.short_url) + '</a> ' +
'<button type="button" class="btn btn-sm" data-copy="' + esc(l.short_url) + '">Copy</button></td>' + '<button type="button" class="btn btn-sm" data-copy="' + esc(l.short_url) + '">Copy</button></td>' +
@@ -120,19 +154,36 @@
}); });
} }
function showTokenModal(token) { function showSecretModal(modalId, valueId, value) {
const modal = document.getElementById('sl-token-modal'); const modal = document.getElementById(modalId);
const val = document.getElementById('sl-token-value'); const val = document.getElementById(valueId);
if (!modal || !val) return; if (!modal || !val) return;
val.textContent = token; val.textContent = value;
modal.hidden = false; modal.hidden = false;
} }
function hideTokenModal() { function hideModal(modalId) {
const modal = document.getElementById('sl-token-modal'); const modal = document.getElementById(modalId);
if (modal) modal.hidden = true; 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) { async function loadLinks(bearerId, label) {
selectedBearerId = bearerId; selectedBearerId = bearerId;
selectedBearerLabel = label || String(bearerId); selectedBearerLabel = label || String(bearerId);
@@ -153,6 +204,7 @@
try { try {
const data = await fetchJson(apiUrl('dashboard')); const data = await fetchJson(apiUrl('dashboard'));
renderBearers(data.bearers); renderBearers(data.bearers);
renderSigners(data.signers);
renderAudit(data.audit); renderAudit(data.audit);
if (selectedBearerId > 0) { if (selectedBearerId > 0) {
await loadLinks(selectedBearerId, selectedBearerLabel); await loadLinks(selectedBearerId, selectedBearerLabel);
@@ -168,7 +220,11 @@
if (!(t instanceof HTMLElement)) return; if (!(t instanceof HTMLElement)) return;
if (t.id === 'sl-token-close' || t.closest('#sl-token-close')) { 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; return;
} }
if (t.id === 'sl-token-copy') { if (t.id === 'sl-token-copy') {
@@ -183,6 +239,18 @@
} }
return; 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'); const copy = t.getAttribute('data-copy');
if (copy) { if (copy) {
@@ -197,7 +265,6 @@
const viewId = t.getAttribute('data-view-links'); const viewId = t.getAttribute('data-view-links');
if (viewId) { if (viewId) {
if (!canOperate()) return;
try { try {
await loadLinks(parseInt(viewId, 10), t.getAttribute('data-label') || ''); await loadLinks(parseInt(viewId, 10), t.getAttribute('data-label') || '');
} catch (e) { } catch (e) {
@@ -209,7 +276,7 @@
const revokeId = t.getAttribute('data-revoke-bearer'); const revokeId = t.getAttribute('data-revoke-bearer');
if (revokeId) { if (revokeId) {
if (!canOperate()) return; 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 { try {
await fetchJson(apiUrl('revoke_bearer'), { await fetchJson(apiUrl('revoke_bearer'), {
method: 'POST', method: 'POST',
@@ -225,14 +292,40 @@
} catch (e) { } catch (e) {
setStatus('Revoke: ' + e.message, true); 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'); const mintForm = document.getElementById('sl-mint-form');
if (mintForm) { if (mintForm) {
if (!canOperate()) {
mintForm.hidden = true;
}
mintForm.addEventListener('submit', async (ev) => { mintForm.addEventListener('submit', async (ev) => {
ev.preventDefault(); ev.preventDefault();
if (!canOperate()) return; if (!canOperate()) return;
@@ -245,10 +338,17 @@
body: JSON.stringify({ body: JSON.stringify({
label: fd.get('label'), label: fd.get('label'),
rate_limit_per_hour: parseInt(String(fd.get('rate_limit') || '1000'), 10), 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(); 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(); await refresh();
} catch (e) { } catch (e) {
setStatus('Mint: ' + e.message, true); 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'); const shortenForm = document.getElementById('sl-shorten-form');
if (shortenForm) { if (shortenForm) {
if (!canOperate()) shortenForm.hidden = true;
shortenForm.addEventListener('submit', async (ev) => { shortenForm.addEventListener('submit', async (ev) => {
ev.preventDefault(); ev.preventDefault();
if (!canOperate()) return; if (!canOperate()) return;
const fd = new FormData(shortenForm); const fd = new FormData(shortenForm);
const permanent = fd.get('permanent') === '1';
try { 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'), { const data = await fetchJson(apiUrl('create_link'), {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify(payload),
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) { if (data.link && data.link.short_url) {
setStatus('Short URL: ' + data.link.short_url); setStatus('Short URL: ' + data.link.short_url);
} }
shortenForm.querySelector('[name="url"]').value = '';
await refresh(); await refresh();
} catch (e) { } catch (e) {
setStatus('Shorten: ' + e.message, true); setStatus('Shorten: ' + e.message, true);
@@ -303,14 +429,19 @@
}); });
} }
const modal = document.getElementById('sl-token-modal'); ['sl-token-modal', 'sl-signer-modal'].forEach((id) => {
const modal = document.getElementById(id);
if (modal) { if (modal) {
modal.addEventListener('click', (ev) => { modal.addEventListener('click', (ev) => {
if (ev.target === modal) hideTokenModal(); if (ev.target === modal) hideModal(id);
}); });
} }
});
document.addEventListener('keydown', (ev) => { document.addEventListener('keydown', (ev) => {
if (ev.key === 'Escape') hideTokenModal(); if (ev.key === 'Escape') {
hideModal('sl-token-modal');
hideModal('sl-signer-modal');
}
}); });
refresh(); refresh();

View File

@@ -31,8 +31,18 @@ if [[ "${FULL:-}" == "1" ]]; then
echo "==> mint bearer" echo "==> mint bearer"
resp="$(curl_api "$BASE/api/short_links.php?action=mint_bearer" \ resp="$(curl_api "$BASE/api/short_links.php?action=mint_bearer" \
-X POST -H 'Content-Type: application/json' \ -X POST -H 'Content-Type: application/json' \
-d '{"label":"api-smoke-'"$(date +%s)"'"}')" -d '{"label":"api-smoke-'"$(date +%s)"'","can_permanent":true,"mint_signer":true}')"
echo "$resp" | grep -q '"token"' && echo OK 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 fi
echo "Done" echo "Done"

View File

@@ -28,6 +28,8 @@ final class Rbac {
public const PRIVILEGE_SET_NONE = ''; public const PRIVILEGE_SET_NONE = '';
public const PRIVILEGE_SET_REMOTE_ACCESS_USER = 'remote_access_user'; public const PRIVILEGE_SET_REMOTE_ACCESS_USER = 'remote_access_user';
public const PRIVILEGE_SET_REMOTE_ACCESS_ADMIN = 'remote_access_admin'; 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<string, list<string>> */ /** @var array<string, list<string>> */
public const PRIVILEGE_SETS = [ public const PRIVILEGE_SETS = [
@@ -40,6 +42,13 @@ final class Rbac {
'remote_access_operate', 'remote_access_operate',
'remote_access_admin', '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 = [ private const COMPANY_ROLE_ACTIONS = [

View File

@@ -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( public static function mintBearer(
string $label, string $label,
bool $canTemporary = true, bool $canTemporary = true,
bool $canPermanent = false, bool $canPermanent = false,
int $rateLimit = 1000 int $rateLimit = 1000,
bool $mintSigner = false
): array { ): array {
$label = trim($label); $label = trim($label);
if ($label === '') { if ($label === '') {
@@ -117,7 +118,67 @@ final class ShortLinksRepository {
]); ]);
$id = (int) $pdo->lastInsertId(); $id = (int) $pdo->lastInsertId();
self::audit('admin_mint', $id, null, 'label=' . $label); 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<array<string,mixed>> */
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 { 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); $bearer = self::findBearerById($bearerId);
if ($bearer === null) { if ($bearer === null) {
return ['ok' => false, 'error' => 'unknown_bearer']; return ['ok' => false, 'error' => 'unknown_bearer'];
@@ -179,10 +245,18 @@ final class ShortLinksRepository {
if ($ttlSeconds < 0) { if ($ttlSeconds < 0) {
$ttlSeconds = 86400; $ttlSeconds = 86400;
} }
$signerId = null;
if ($ttlSeconds === 0) { if ($ttlSeconds === 0) {
return ['ok' => false, 'error' => 'permanent_requires_signer']; $signer = self::findValidSigner($signerKey);
if ($signer === null) {
return ['ok' => false, 'error' => 'signer_required'];
} }
if ((int) ($bearer['can_temporary'] ?? 0) !== 1) { 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']; return ['ok' => false, 'error' => 'bearer_no_temporary'];
} }
@@ -192,14 +266,22 @@ final class ShortLinksRepository {
} }
$slug = self::allocateSlug($bearerId, $normalized); $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(); $pdo = UrlShortenerDatabase::pdo();
$stmt = $pdo->prepare( $stmt = $pdo->prepare(
'INSERT INTO links (bearer_id, signer_id, slug, origin, origin_hash, ttl_seconds, expires_at) '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]); $stmt->execute([
self::audit('admin_shorten', $bearerId, $slug, 'created'); $bearerId,
$signerId,
$slug,
$normalized,
$originHash,
max(0, $ttlSeconds),
$expiresAt,
]);
self::audit('admin_shorten', $bearerId, $slug, $ttlSeconds === 0 ? 'permanent' : 'created');
$row = self::findLinkBySlug($slug); $row = self::findLinkBySlug($slug);
if ($row === null) { if ($row === null) {
@@ -225,6 +307,20 @@ final class ShortLinksRepository {
]; ];
} }
/** @return array<string,mixed>|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<string,mixed>|null */ /** @return array<string,mixed>|null */
private static function findBearerById(int $id): ?array { private static function findBearerById(int $id): ?array {
$pdo = UrlShortenerDatabase::pdo(); $pdo = UrlShortenerDatabase::pdo();

View File

@@ -519,6 +519,7 @@
<th scope="col">ID</th> <th scope="col">ID</th>
<th scope="col">Label</th> <th scope="col">Label</th>
<th scope="col">Status</th> <th scope="col">Status</th>
<th scope="col">Caps</th>
<th scope="col">Links</th> <th scope="col">Links</th>
<th scope="col">Rate/h</th> <th scope="col">Rate/h</th>
<th scope="col">Created</th> <th scope="col">Created</th>
@@ -528,9 +529,12 @@
<tbody id="sl-bearers-tbody"></tbody> <tbody id="sl-bearers-tbody"></tbody>
</table> </table>
</div> </div>
<form id="sl-mint-form" class="tag-editor-add" style="margin-top:1rem"> <form id="sl-mint-form" class="tag-editor-add" style="margin-top:1rem"<?= Rbac::can('short_links_operate') ? '' : ' hidden' ?>>
<label class="tag-field"><span>Label</span><input type="text" name="label" required maxlength="128" placeholder="prod-hub"></label> <label class="tag-field"><span>Label</span><input type="text" name="label" required maxlength="128" placeholder="prod-hub"></label>
<label class="tag-field"><span>Rate limit / hour</span><input type="number" name="rate_limit" value="1000" min="1" max="100000"></label> <label class="tag-field"><span>Rate limit / hour</span><input type="number" name="rate_limit" value="1000" min="1" max="100000"></label>
<label class="tag-field tag-field--check"><span><input type="checkbox" name="can_temporary" value="1" checked> Temporary links</span></label>
<label class="tag-field tag-field--check"><span><input type="checkbox" name="can_permanent" value="1" id="sl-can-permanent"> Permanent links</span></label>
<label class="tag-field tag-field--check" id="sl-mint-signer-wrap" hidden><span><input type="checkbox" name="mint_signer" value="1" checked> Mint signer key</span></label>
<button type="submit" class="btn btn-primary">Mint bearer</button> <button type="submit" class="btn btn-primary">Mint bearer</button>
<?php if (Rbac::isGlobalAdmin()): ?> <?php if (Rbac::isGlobalAdmin()): ?>
<button type="button" class="btn" id="sl-purge-btn">Purge expired</button> <button type="button" class="btn" id="sl-purge-btn">Purge expired</button>
@@ -538,12 +542,36 @@
</form> </form>
</section> </section>
<section class="graphs-scope">
<h2 class="graphs-scope-title">Signer keys (permanent links)</h2>
<div class="reports-table-wrap">
<table class="data-table reports-table--cols" id="sl-signers-table">
<thead>
<tr>
<th scope="col">ID</th>
<th scope="col">Label</th>
<th scope="col">Status</th>
<th scope="col">Created</th>
<th scope="col">Actions</th>
</tr>
</thead>
<tbody id="sl-signers-tbody"></tbody>
</table>
</div>
<form id="sl-mint-signer-form" class="tag-editor-add" style="margin-top:1rem"<?= Rbac::can('short_links_operate') ? '' : ' hidden' ?>>
<label class="tag-field"><span>Label</span><input type="text" name="label" required maxlength="128" placeholder="prod-perm"></label>
<button type="submit" class="btn">Mint signer</button>
</form>
</section>
<section class="graphs-scope" id="sl-links-section" hidden> <section class="graphs-scope" id="sl-links-section" hidden>
<h2 class="graphs-scope-title">Links for bearer <code id="sl-links-bearer-label">—</code></h2> <h2 class="graphs-scope-title">Links for bearer <code id="sl-links-bearer-label">—</code></h2>
<form id="sl-shorten-form" class="tag-editor-add" style="margin-bottom:1rem"> <form id="sl-shorten-form" class="tag-editor-add" style="margin-bottom:1rem"<?= Rbac::can('short_links_operate') ? '' : ' hidden' ?>>
<input type="hidden" name="bearer_id" id="sl-shorten-bearer-id" value=""> <input type="hidden" name="bearer_id" id="sl-shorten-bearer-id" value="">
<label class="tag-field tag-field--wide"><span>Long URL</span><input type="url" name="url" required maxlength="2048" placeholder="https://example.com/path"></label> <label class="tag-field tag-field--wide"><span>Long URL</span><input type="url" name="url" required maxlength="2048" placeholder="https://example.com/path"></label>
<label class="tag-field"><span>TTL (seconds)</span><input type="number" name="ttl" value="86400" min="60" max="31536000"></label> <label class="tag-field tag-field--check"><span><input type="checkbox" name="permanent" value="1" id="sl-permanent-cb"> Permanent (ttl=0)</span></label>
<label class="tag-field" id="sl-ttl-wrap"><span>TTL (seconds)</span><input type="number" name="ttl" id="sl-ttl-input" value="86400" min="60" max="31536000"></label>
<label class="tag-field tag-field--wide" id="sl-signer-wrap" hidden><span>Signer key</span><input type="password" name="signer" id="sl-signer-input" autocomplete="off" placeholder="required for permanent"></label>
<button type="submit" class="btn btn-primary">Shorten</button> <button type="submit" class="btn btn-primary">Shorten</button>
</form> </form>
<div class="reports-table-wrap"> <div class="reports-table-wrap">
@@ -591,6 +619,18 @@
<button type="button" class="btn btn-primary" id="sl-token-copy">Copy token</button> <button type="button" class="btn btn-primary" id="sl-token-copy">Copy token</button>
</div> </div>
</div> </div>
<div id="sl-signer-modal" class="graph-detail-overlay" hidden>
<div class="graph-detail-panel" role="dialog" aria-modal="true" aria-labelledby="sl-signer-title">
<header class="graph-detail-header">
<h2 id="sl-signer-title">Signer key — copy now</h2>
<button type="button" class="btn graph-detail-close" id="sl-signer-close">Close</button>
</header>
<p class="muted">Shown once. Required for <code>ttl=0</code> permanent links with a bearer that has permanent capability.</p>
<p><code id="sl-signer-value" style="word-break:break-all"></code></p>
<button type="button" class="btn btn-primary" id="sl-signer-copy">Copy signer</button>
</div>
</div>
</div> </div>
<?php elseif (($view ?? '') === 'rbac'): ?> <?php elseif (($view ?? '') === 'rbac'): ?>
<div id="rbac-app" class="reports-app"> <div id="rbac-app" class="reports-app">

View File

@@ -44,6 +44,13 @@ $proj = '/app/androidcast_project';
<h2 data-i18n="home.card_graphs">Graphs</h2> <h2 data-i18n="home.card_graphs">Graphs</h2>
<p class="muted" data-i18n="home.card_graphs_desc">Sessions, crashes, and device activity over time.</p> <p class="muted" data-i18n="home.card_graphs_desc">Sessions, crashes, and device activity over time.</p>
</a> </a>
<?php if (Rbac::can('short_links_view')): ?>
<a href="<?= h($bp) ?>/?view=short_links" class="card card--lift console-home-card">
<span class="nav-icon nav-icon--link" aria-hidden="true"></span>
<h2 data-i18n="home.card_short_links">Short links</h2>
<p class="muted" data-i18n="home.card_short_links_desc">Mint bearer tokens and shorten URLs for s.f0xx.org.</p>
</a>
<?php endif; ?>
</div> </div>
<h2 class="console-home-more" data-i18n="home.more">All services</h2> <h2 class="console-home-more" data-i18n="home.more">All services</h2>