1
0
mirror of git://f0xx.org/android_cast synced 2026-07-29 02:18:40 +03:00
This commit is contained in:
Anton Afanasyeu
2026-06-11 23:11:42 +02:00
parent fef93d7cdc
commit 9ec9639010
18 changed files with 1142 additions and 2 deletions

View File

@@ -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).

View File

@@ -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
```
---

View File

@@ -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",

View File

@@ -15,6 +15,7 @@
"nav.crashes": "Крэши",
"nav.graphs": "Графики",
"nav.remote": "Удалённый доступ",
"nav.short_links": "Короткие ссылки",
"nav.builder": "Сборщик",
"nav.toggle": "Навигация",
"nav.security": "Безопасность",

View File

@@ -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">

View File

@@ -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 17 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.

View File

@@ -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',
],
],
],
];

View 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);

View File

@@ -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 {

View 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();
})();

View File

@@ -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',

View 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"

View File

@@ -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',

View 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]);
}
}

View 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;
}
}
}

View File

@@ -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';

View File

@@ -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">

View File

@@ -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'];
}