mirror of
git://f0xx.org/android_cast
synced 2026-07-29 04:18:09 +03:00
BE sync
This commit is contained in:
@@ -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).
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
"nav.crashes": "Крэши",
|
||||
"nav.graphs": "Графики",
|
||||
"nav.remote": "Удалённый доступ",
|
||||
"nav.short_links": "Короткие ссылки",
|
||||
"nav.builder": "Сборщик",
|
||||
"nav.toggle": "Навигация",
|
||||
"nav.security": "Безопасность",
|
||||
|
||||
@@ -143,6 +143,7 @@ function hub_h(string $s): string {
|
||||
<li><a href="crashes/?view=tickets" class="nav-link"><span class="nav-icon nav-icon--tickets"></span><span class="nav-label" data-i18n="nav.tickets">Tickets</span></a></li>
|
||||
<li><a href="graphs/" class="nav-link"><span class="nav-icon nav-icon--graphs"></span><span class="nav-label" data-i18n="nav.graphs">Graphs</span></a></li>
|
||||
<li><a href="crashes/?view=remote_access" class="nav-link"><span class="nav-icon nav-icon--remote"></span><span class="nav-label" data-i18n="nav.remote">Remote access</span></a></li>
|
||||
<li><a href="crashes/?view=short_links" class="nav-link"><span class="nav-icon nav-icon--link"></span><span class="nav-label" data-i18n="nav.short_links">Short links</span></a></li>
|
||||
<li><a href="build/" class="nav-link"><span class="nav-icon nav-icon--builder"></span><span class="nav-label" data-i18n="nav.builder">Builder</span></a></li>
|
||||
</ul>
|
||||
<div class="nav-user">
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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',
|
||||
],
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
125
examples/crash_reporter/backend/public/api/short_links.php
Normal file
125
examples/crash_reporter/backend/public/api/short_links.php
Normal file
@@ -0,0 +1,125 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
require_once __DIR__ . '/../../src/bootstrap.php';
|
||||
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
|
||||
Auth::check();
|
||||
|
||||
if (!Rbac::can('short_links_view')) {
|
||||
json_out(['ok' => 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);
|
||||
@@ -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 {
|
||||
|
||||
317
examples/crash_reporter/backend/public/assets/js/short_links.js
Normal file
317
examples/crash_reporter/backend/public/assets/js/short_links.js
Normal file
@@ -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 = '<tr><td colspan="7" class="muted">No bearer tokens yet — mint one below.</td></tr>';
|
||||
return;
|
||||
}
|
||||
(bearers || []).forEach((b) => {
|
||||
const tr = document.createElement('tr');
|
||||
const active = b.active !== false && !b.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-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>'
|
||||
: '';
|
||||
tr.innerHTML =
|
||||
'<td>' + esc(b.id) + '</td>' +
|
||||
'<td>' + esc(b.label) + '</td>' +
|
||||
'<td>' + status + '</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>' +
|
||||
'<td>' + viewBtn + revokeBtn + '</td>';
|
||||
tbody.appendChild(tr);
|
||||
});
|
||||
}
|
||||
|
||||
function renderLinks(links) {
|
||||
const tbody = document.getElementById('sl-links-tbody');
|
||||
if (!tbody) return;
|
||||
tbody.innerHTML = '';
|
||||
if (!links || !links.length) {
|
||||
tbody.innerHTML = '<tr><td colspan="5" class="muted">No links for this bearer.</td></tr>';
|
||||
return;
|
||||
}
|
||||
(links || []).forEach((l) => {
|
||||
const tr = document.createElement('tr');
|
||||
const exp = l.expired ? '<span class="muted">expired</span>' : esc(l.expires_at || 'permanent');
|
||||
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>' +
|
||||
'<td title="' + esc(l.origin) + '">' + esc(truncate(l.origin, 64)) + '</td>' +
|
||||
'<td>' + exp + '</td>' +
|
||||
'<td>' + esc(l.access_count) + '</td>' +
|
||||
'<td><a class="btn btn-sm" href="' + esc(l.qr_url) + '" target="_blank" rel="noopener">PNG</a></td>';
|
||||
tbody.appendChild(tr);
|
||||
});
|
||||
}
|
||||
|
||||
function renderAudit(rows) {
|
||||
const tbody = document.getElementById('sl-audit-tbody');
|
||||
if (!tbody) return;
|
||||
tbody.innerHTML = '';
|
||||
if (!rows || !rows.length) {
|
||||
tbody.innerHTML = '<tr><td colspan="5" class="muted">No audit events.</td></tr>';
|
||||
return;
|
||||
}
|
||||
(rows || []).forEach((e) => {
|
||||
const tr = document.createElement('tr');
|
||||
tr.innerHTML =
|
||||
'<td>' + esc(e.created_at) + '</td>' +
|
||||
'<td>' + esc(e.event) + '</td>' +
|
||||
'<td>' + esc(e.bearer_id != null ? e.bearer_id : '—') + '</td>' +
|
||||
'<td>' + esc(e.slug || '—') + '</td>' +
|
||||
'<td>' + esc(e.detail || '—') + '</td>';
|
||||
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();
|
||||
})();
|
||||
@@ -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',
|
||||
|
||||
38
examples/crash_reporter/backend/scripts/test_short_links_api.sh
Executable file
38
examples/crash_reporter/backend/scripts/test_short_links_api.sh
Executable file
@@ -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"
|
||||
@@ -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',
|
||||
|
||||
385
examples/crash_reporter/backend/src/ShortLinksRepository.php
Normal file
385
examples/crash_reporter/backend/src/ShortLinksRepository.php
Normal file
@@ -0,0 +1,385 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Operator admin for url_shortener DB — SPEC §20 (session RBAC, not public API).
|
||||
*/
|
||||
final class ShortLinksRepository {
|
||||
private const MAX_URL_LEN = 2048;
|
||||
|
||||
/** @return array{ok: bool, error?: string} */
|
||||
public static function availability(): array {
|
||||
if (!UrlShortenerDatabase::enabled()) {
|
||||
return ['ok' => false, 'error' => 'disabled'];
|
||||
}
|
||||
if (!UrlShortenerDatabase::ping()) {
|
||||
return ['ok' => false, 'error' => 'db_unreachable'];
|
||||
}
|
||||
return ['ok' => true];
|
||||
}
|
||||
|
||||
/** @return list<array<string,mixed>> */
|
||||
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<array<string,mixed>> */
|
||||
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<array<string,mixed>> */
|
||||
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<string,mixed> $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<string,mixed>|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<string,mixed>|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<string,mixed>|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<string,mixed>|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<string,mixed> $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<string,mixed> $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]);
|
||||
}
|
||||
}
|
||||
59
examples/crash_reporter/backend/src/UrlShortenerDatabase.php
Normal file
59
examples/crash_reporter/backend/src/UrlShortenerDatabase.php
Normal file
@@ -0,0 +1,59 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/** PDO to MariaDB `url_shortener` (separate from crashes DB). */
|
||||
final class UrlShortenerDatabase {
|
||||
private static ?PDO $pdo = null;
|
||||
|
||||
public static function enabled(): bool {
|
||||
return (bool) cfg('url_shortener.enabled', false);
|
||||
}
|
||||
|
||||
public static function publicBase(): string {
|
||||
return rtrim((string) cfg('url_shortener.public_base', 'https://s.f0xx.org'), '/');
|
||||
}
|
||||
|
||||
public static function pdo(): PDO {
|
||||
if (!self::enabled()) {
|
||||
throw new RuntimeException('url_shortener.enabled is false');
|
||||
}
|
||||
if (self::$pdo !== null) {
|
||||
return self::$pdo;
|
||||
}
|
||||
$m = cfg('url_shortener.db.mysql', []);
|
||||
if (!is_array($m) || ($m['database'] ?? '') === '') {
|
||||
throw new RuntimeException('url_shortener.db.mysql not configured');
|
||||
}
|
||||
$db = (string) $m['database'];
|
||||
$charset = (string) ($m['charset'] ?? 'utf8mb4');
|
||||
$socket = trim((string) ($m['socket'] ?? ''));
|
||||
if ($socket !== '') {
|
||||
$dsn = sprintf('mysql:unix_socket=%s;dbname=%s;charset=%s', $socket, $db, $charset);
|
||||
} else {
|
||||
$dsn = sprintf(
|
||||
'mysql:host=%s;port=%d;dbname=%s;charset=%s',
|
||||
$m['host'] ?? '127.0.0.1',
|
||||
(int) ($m['port'] ?? 3306),
|
||||
$db,
|
||||
$charset
|
||||
);
|
||||
}
|
||||
self::$pdo = new PDO($dsn, (string) ($m['username'] ?? ''), (string) ($m['password'] ?? ''), [
|
||||
PDO::ATTR_ERRMODE => 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -38,6 +38,9 @@
|
||||
<?php if (($view ?? '') === 'remote_access'): ?>
|
||||
<script src="<?= h(Auth::basePath()) ?>/assets/js/remote_access.js" defer></script>
|
||||
<?php endif; ?>
|
||||
<?php if (($view ?? '') === 'short_links'): ?>
|
||||
<script src="<?= h(Auth::basePath()) ?>/assets/js/short_links.js" defer></script>
|
||||
<?php endif; ?>
|
||||
<?php if (($view ?? '') === 'rbac'): ?>
|
||||
<script src="<?= h(Auth::basePath()) ?>/assets/js/rbac_admin.js" defer></script>
|
||||
<?php endif; ?>
|
||||
@@ -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"' : '' ?>>
|
||||
<header class="top-menu" hidden aria-hidden="true"></header>
|
||||
<div class="shell<?= ($view ?? '') === 'graphs' ? ' shell--graphs-full' : '' ?>">
|
||||
@@ -108,6 +113,17 @@
|
||||
</a>
|
||||
</li>
|
||||
<?php endif; ?>
|
||||
<?php if (Rbac::can('short_links_view')): ?>
|
||||
<li>
|
||||
<a href="<?= h(Auth::basePath()) ?>/?view=short_links"
|
||||
class="nav-link <?= ($view ?? '') === 'short_links' ? 'active' : '' ?>"
|
||||
aria-label="Short links"
|
||||
title="Short links">
|
||||
<span class="nav-icon nav-icon--link" aria-hidden="true"></span>
|
||||
<span class="nav-label">Short links</span>
|
||||
</a>
|
||||
</li>
|
||||
<?php endif; ?>
|
||||
<?php if (Rbac::canManageRbac()): ?>
|
||||
<li>
|
||||
<a href="<?= h(Auth::basePath()) ?>/?view=rbac"
|
||||
@@ -474,6 +490,108 @@
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<?php elseif (($view ?? '') === 'short_links'): ?>
|
||||
<div id="short-links-app" class="reports-app">
|
||||
<div class="toolbar reports-toolbar">
|
||||
<h1>Short links</h1>
|
||||
<div class="toolbar-actions">
|
||||
<label class="toolbar-select">
|
||||
<span>Theme</span>
|
||||
<select id="theme-select" aria-label="UI theme">
|
||||
<option value="dark">Dark</option>
|
||||
<option value="light">Light</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<?php require __DIR__ . '/partials/console_quick_links.php'; ?>
|
||||
<p id="sl-status" class="reports-status muted" aria-live="polite">Loading…</p>
|
||||
<p class="muted graphs-footnote">
|
||||
Public API: <code>https://s.f0xx.org/api/v1/shorten</code> · machine clients use bearer tokens minted here.
|
||||
</p>
|
||||
|
||||
<section class="graphs-scope">
|
||||
<h2 class="graphs-scope-title">Bearer tokens</h2>
|
||||
<div class="reports-table-wrap">
|
||||
<table class="data-table reports-table--cols" id="sl-bearers-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">ID</th>
|
||||
<th scope="col">Label</th>
|
||||
<th scope="col">Status</th>
|
||||
<th scope="col">Links</th>
|
||||
<th scope="col">Rate/h</th>
|
||||
<th scope="col">Created</th>
|
||||
<th scope="col">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="sl-bearers-tbody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<form id="sl-mint-form" class="tag-editor-add" style="margin-top:1rem">
|
||||
<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>
|
||||
<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>
|
||||
<?php endif; ?>
|
||||
</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">
|
||||
<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>
|
||||
<button type="submit" class="btn btn-primary">Shorten</button>
|
||||
</form>
|
||||
<div class="reports-table-wrap">
|
||||
<table class="data-table reports-table--cols" id="sl-links-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Short URL</th>
|
||||
<th scope="col">Origin</th>
|
||||
<th scope="col">Expires</th>
|
||||
<th scope="col">Hits</th>
|
||||
<th scope="col">QR</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="sl-links-tbody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="graphs-scope">
|
||||
<h2 class="graphs-scope-title">Audit log</h2>
|
||||
<div class="reports-table-wrap">
|
||||
<table class="data-table reports-table--cols" id="sl-audit-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Time</th>
|
||||
<th scope="col">Event</th>
|
||||
<th scope="col">Bearer</th>
|
||||
<th scope="col">Slug</th>
|
||||
<th scope="col">Detail</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="sl-audit-tbody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div id="sl-token-modal" class="graph-detail-overlay" hidden>
|
||||
<div class="graph-detail-panel" role="dialog" aria-modal="true" aria-labelledby="sl-token-title">
|
||||
<header class="graph-detail-header">
|
||||
<h2 id="sl-token-title">Bearer token — copy now</h2>
|
||||
<button type="button" class="btn graph-detail-close" id="sl-token-close">Close</button>
|
||||
</header>
|
||||
<p class="muted">Shown once. Store in a secrets manager or <code>BEARER=…</code> for curl scripts.</p>
|
||||
<p><code id="sl-token-value" style="word-break:break-all"></code></p>
|
||||
<button type="button" class="btn btn-primary" id="sl-token-copy">Copy token</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<?php elseif (($view ?? '') === 'rbac'): ?>
|
||||
<div id="rbac-app" class="reports-app">
|
||||
<div class="toolbar reports-toolbar">
|
||||
|
||||
@@ -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'];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user