mirror of
git://f0xx.org/android_cast
synced 2026-07-29 04:18:09 +03:00
BE sync
This commit is contained in:
@@ -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`.
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "По времени",
|
||||
|
||||
@@ -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 = '<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;
|
||||
}
|
||||
(bearers || []).forEach((b) => {
|
||||
@@ -63,13 +70,13 @@
|
||||
const revokeBtn = canOperate() && active
|
||||
? ' <button type="button" class="btn btn-sm" data-revoke-bearer="' + esc(String(b.id)) + '">Revoke</button>'
|
||||
: '';
|
||||
const viewBtn = canOperate()
|
||||
? '<button type="button" class="btn btn-sm btn-primary" data-view-links="' + esc(String(b.id)) + '" data-label="' + esc(b.label || '') + '">Links</button>'
|
||||
: '';
|
||||
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>';
|
||||
tr.innerHTML =
|
||||
'<td>' + esc(b.id) + '</td>' +
|
||||
'<td>' + esc(b.label) + '</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.rate_limit_per_hour) + '</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) {
|
||||
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 ? '<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 =
|
||||
'<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>' +
|
||||
@@ -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();
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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<string, list<string>> */
|
||||
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 = [
|
||||
|
||||
@@ -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<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 {
|
||||
@@ -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<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 */
|
||||
private static function findBearerById(int $id): ?array {
|
||||
$pdo = UrlShortenerDatabase::pdo();
|
||||
|
||||
@@ -519,6 +519,7 @@
|
||||
<th scope="col">ID</th>
|
||||
<th scope="col">Label</th>
|
||||
<th scope="col">Status</th>
|
||||
<th scope="col">Caps</th>
|
||||
<th scope="col">Links</th>
|
||||
<th scope="col">Rate/h</th>
|
||||
<th scope="col">Created</th>
|
||||
@@ -528,9 +529,12 @@
|
||||
<tbody id="sl-bearers-tbody"></tbody>
|
||||
</table>
|
||||
</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>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>
|
||||
<?php if (Rbac::isGlobalAdmin()): ?>
|
||||
<button type="button" class="btn" id="sl-purge-btn">Purge expired</button>
|
||||
@@ -538,12 +542,36 @@
|
||||
</form>
|
||||
</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>
|
||||
<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="">
|
||||
<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>
|
||||
</form>
|
||||
<div class="reports-table-wrap">
|
||||
@@ -591,6 +619,18 @@
|
||||
<button type="button" class="btn btn-primary" id="sl-token-copy">Copy token</button>
|
||||
</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>
|
||||
<?php elseif (($view ?? '') === 'rbac'): ?>
|
||||
<div id="rbac-app" class="reports-app">
|
||||
|
||||
@@ -44,6 +44,13 @@ $proj = '/app/androidcast_project';
|
||||
<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>
|
||||
</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>
|
||||
|
||||
<h2 class="console-home-more" data-i18n="home.more">All services</h2>
|
||||
|
||||
Reference in New Issue
Block a user