1
0
mirror of git://f0xx.org/android_cast synced 2026-07-29 05:58:14 +03:00
This commit is contained in:
Anton Afanasyeu
2026-06-11 22:45:47 +02:00
parent f878700a0c
commit fef93d7cdc
23 changed files with 831 additions and 97 deletions

View File

@@ -2,65 +2,63 @@
**Topology:** same as [INFRA.md](../../docs/INFRA.md) — TLS on Gentoo FE, app on Alpine BE `:80`.
## Prerequisites (confirmed)
## Prerequisites
| Item | Status |
|------|--------|
| DNS `s.f0xx.org` → FE | PO |
| FE TLS cert | `/etc/letsencrypt/live/s.f0xx.org/` |
| Git remote | `git://f0xx.org/androicast_project/url-shortener` |
| MariaDB schema `url_shortener` | `sql/schema.mariadb.sql` |
| DB app user | **`androidcast`** (shared with crash reporter; same password as `androidcast_crashes` in crashes `config.php`) |
| DNS `s.f0xx.org` → FE | Done |
| FE TLS + BE nginx | Done |
| MariaDB `url_shortener` | Done |
| DB user | **`androidcast`** (same password as crashes `config.php`) |
## MariaDB (BE)
**Database:** `url_shortener`
**App user:** `androidcast` @ `localhost` + `127.0.0.1`**same password** as crash reporter (`examples/crash_reporter/backend/config/config.php``db.mysql.password`).
## Sync to BE
```bash
# 1) Schema (root)
mysql -u root -p < sql/schema.mariadb.sql
# From dev machine (example — adjust paths)
rsync -av backend/url-shortener/ alpine-be:/var/www/localhost/htdocs/apps/s/
# 2) Grants for existing androidcast user (root) — skip if already granted
./scripts/init-mariadb-grants.sh
# On BE
cp /var/www/localhost/htdocs/apps/s/config/config.example.php \
/var/www/localhost/htdocs/apps/s/config/config.php
# set db.mysql.password = same as crash reporter
# 3) Config on BE
cp config/config.example.php config/config.php
# edit password to match crashes config (do not commit config.php)
# 4) Smoke
mysql -u androidcast -p -h 127.0.0.1 url_shortener -e 'SHOW TABLES;'
apk add libqrencode-tools # QR PNG (/api/v1/qr/…)
rc-service php-fpm81 restart
```
Template: [config/config.example.php](config/config.example.php)
## MariaDB (one-time)
```bash
mysql -u root -p < sql/schema.mariadb.sql
./scripts/init-mariadb-grants.sh
php scripts/seed_bearer.php --label=prod # save printed bearer token
```
Optional cron (purge expired links + audit >90d):
```cron
0 4 * * * php81 /var/www/localhost/htdocs/apps/s/scripts/purge_url_shortener.php
```
## FE (Gentoo)
Add `deploy/nginx.fe-s.f0xx.org.snippet` inside a new `server { listen 443 ssl; server_name s.f0xx.org; }` block
(mirror `apps.f0xx.org` TLS paths; cert paths above).
See [deploy/nginx.fe-s.f0xx.org.snippet](deploy/nginx.fe-s.f0xx.org.snippet) — full URI → BE `:80`.
```bash
nginx -t && rc-service nginx reload
```
## BE nginx
## BE (Alpine)
See [deploy/nginx.be-url-shortener.fragment](deploy/nginx.be-url-shortener.fragment).
1. Sync this tree to docroot (suggested): `/var/www/localhost/htdocs/apps/s/`
2. Include `deploy/nginx.be-url-shortener.fragment` in `apps.conf` **or** dedicated `server_name` on BE
3. Apply SQL migration
4. `rc-service php-fpm81 restart` if needed
```bash
nginx -t && rc-service nginx reload
```
**Alpine/nginx note:** regex locations with `{n,m}` quantifiers must be **double-quoted** or nginx parses `{` as a block delimiter (`nginx: [emerg] improper regexp`).
**Quoted regex required:** `location ~ "^/[a-f0-9]{6,16}$"`
## Verify
```bash
curl -sSI https://s.f0xx.org/api/v1/health
./scripts/smoke_shorten.sh
curl -fsS https://s.f0xx.org/api/v1/health
export BEARER='…' ./scripts/smoke_shorten.sh
./scripts/demo_curl.sh
examples/crash_reporter/backend/scripts/validate_be_services.sh
```
Not added to `validate_be_services.sh` until first production deploy (SPEC).
## 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

@@ -8,37 +8,41 @@ Standalone opt-in service — **not** part of `examples/crash_reporter/backend/`
| DR | [docs/DRs/20100611_3_url_shortener.md](../../docs/DRs/20100611_3_url_shortener.md) |
| Deploy | [DEPLOY.md](DEPLOY.md) |
**Status:** scaffold (OpenAPI + SQL + nginx fragments + smoke scripts). PHP handlers TODO.
**Status:** PHP API implemented (shorten, redirect, QR via `qrencode`).
**Git remote (PO confirmed):** `git://f0xx.org/androicast_project/url-shortener`
**Git remote:** `git://f0xx.org/androicast_project/url-shortener`
**Public URL:** `https://s.f0xx.org`
**FE TLS (PO confirmed):** `/etc/letsencrypt/live/s.f0xx.org/{fullchain.pem,privkey.pem}`
## Quick smoke (when deployed)
## Quick start (BE)
```bash
export BASE=https://s.f0xx.org
export BEARER=demo-bearer-replace-me
./scripts/demo_curl.sh
mysql -u root -p < sql/schema.mariadb.sql
./scripts/init-mariadb-grants.sh
cp config/config.example.php config/config.php # password = crashes androidcast user
php scripts/seed_bearer.php --label=prod
apk add libqrencode-tools # QR PNG
# sync tree → /var/www/.../apps/s/
rc-service php-fpm81 restart
export BEARER=… ./scripts/smoke_shorten.sh
```
## Local dev (future)
## Tests
```bash
# MariaDB: apply sql/schema.mariadb.sql
# php -S 127.0.0.1:8091 -t public
./scripts/run-php-tests.sh
```
## Layout
```text
backend/url-shortener/
openapi.yaml # API contract (source of truth with SPEC §6)
deploy/ # FE + BE nginx fragments
public/index.php # front controller
src/ # ShortenService, redirect, QR, repos
config/config.example.php
sql/schema.mariadb.sql
openapi.yaml
scripts/seed_bearer.php
scripts/purge_url_shortener.php
scripts/demo_curl.sh
scripts/smoke_shorten.sh
scripts/init-mariadb-grants.sh
config/config.example.php # DB url_shortener, user androidcast (same pwd as crashes)
public/index.php # stub router until impl
```

View File

@@ -0,0 +1 @@
config.php

View File

@@ -1,27 +1,72 @@
<?php
declare(strict_types=1);
/**
* URL shortener front controller (stub).
* Routes: /api/v1/health, /api/v1/shorten, /api/v1/qr/{slug}.png, /{slug}
* Full implementation tracked in docs/specs/20100611_3_url_shortener.md
*/
header('Content-Type: application/json; charset=utf-8');
require_once dirname(__DIR__) . '/src/bootstrap.php';
$method = strtoupper($_SERVER['REQUEST_METHOD'] ?? 'GET');
$uri = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH) ?: '/';
$uri = rtrim($uri, '/') ?: '/';
if ($uri === '/api/v1/health') {
echo json_encode([
'status' => 'stub',
'service' => 'url-shortener',
'message' => 'PHP handlers not deployed yet — see backend/url-shortener/README.md',
], JSON_UNESCAPED_SLASHES);
exit;
try {
if ($uri === '/api/v1/health') {
Database::pdo()->query('SELECT 1');
JsonResponse::ok([
'status' => 'ok',
'service' => 'url-shortener',
'db' => 'connected',
]);
}
if ($uri === '/api/v1/shorten') {
if ($method === 'GET') {
$result = ShortenService::handle(
(string) ($_GET['url'] ?? ''),
(string) ($_GET['bearer'] ?? ''),
(string) ($_GET['signer'] ?? ''),
isset($_GET['ttl']) ? (int) $_GET['ttl'] : null
);
} elseif ($method === 'POST') {
$raw = file_get_contents('php://input') ?: '';
$body = json_decode($raw, true);
if (!is_array($body)) {
JsonResponse::error(400, 'INVALID_URL', 'expected JSON body');
}
$result = ShortenService::handle(
(string) ($body['url'] ?? ''),
(string) ($body['bearer'] ?? ''),
(string) ($body['signer'] ?? ''),
isset($body['ttl']) ? (int) $body['ttl'] : null
);
} else {
JsonResponse::error(405, 'INTERNAL', 'method not allowed');
}
$http = (int) ($result['http'] ?? 200);
unset($result['http']);
if (($result['code'] ?? '') === '0') {
JsonResponse::ok($result, $http);
}
JsonResponse::error($http, (string) $result['code'], (string) $result['desc'], [
'url' => $result['url'] ?? '',
'bearer' => $result['bearer'] ?? '',
]);
}
if (preg_match('#^/api/v1/qr/([a-f0-9]{6,16})\.png$#', $uri, $m)) {
$size = isset($_GET['size']) ? (int) $_GET['size'] : 256;
QrHandler::handle($m[1], $size);
}
if (preg_match('#^/([a-f0-9]{6,16})$#', $uri, $m)) {
if ($method === 'GET' || $method === 'HEAD') {
RedirectHandler::handle($m[1], $method);
}
JsonResponse::error(405, 'INTERNAL', 'method not allowed');
}
http_response_code(404);
header('Content-Type: application/json; charset=utf-8');
echo json_encode(['code' => 'NOT_FOUND', 'desc' => 'not found'], JSON_UNESCAPED_SLASHES);
} catch (Throwable $e) {
error_log('url-shortener: ' . $e->getMessage());
JsonResponse::error(500, 'INTERNAL', 'server error');
}
http_response_code(501);
echo json_encode([
'code' => 'NOT_IMPLEMENTED',
'desc' => 'url-shortener API stub — deploy implementation from git://f0xx.org/androicast_project/url-shortener',
], JSON_UNESCAPED_SLASHES);

View File

@@ -0,0 +1,15 @@
#!/usr/bin/env php
<?php
declare(strict_types=1);
require_once dirname(__DIR__) . '/src/bootstrap.php';
$pdo = Database::pdo();
$deletedLinks = $pdo->exec(
'DELETE FROM links WHERE expires_at IS NOT NULL AND expires_at < NOW()'
);
$deletedAudit = $pdo->exec(
'DELETE FROM audit_log WHERE created_at < (NOW() - INTERVAL 90 DAY)'
);
echo "purge: links=$deletedLinks audit=$deletedAudit\n";

View File

@@ -0,0 +1,5 @@
#!/usr/bin/env bash
# PHP unit-style checks (no phpunit required).
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
php "$ROOT/tests/run_tests.php"

View File

@@ -0,0 +1,26 @@
#!/usr/bin/env php
<?php
declare(strict_types=1);
require_once dirname(__DIR__) . '/src/bootstrap.php';
$label = 'cli-seed';
$canPermanent = false;
foreach (array_slice($argv, 1) as $arg) {
if ($arg === '--permanent') {
$canPermanent = true;
} elseif (str_starts_with($arg, '--label=')) {
$label = substr($arg, 8);
}
}
$created = BearerRepository::createTrusted($label, true, $canPermanent);
echo "Bearer token (store securely — shown once):\n";
echo $created['token'] . "\n";
echo "bearer_id=" . $created['id'] . " label=" . $label . "\n";
if ($canPermanent) {
$signer = SignerRepository::create($label . '-signer');
echo "Signer key (for ttl=0):\n";
echo $signer['key'] . "\n";
}

View File

@@ -6,14 +6,8 @@ BASE="${BASE:-https://s.f0xx.org}"
BEARER="${BEARER:-}"
echo "==> health $BASE/api/v1/health"
code="$(curl -sS -o /tmp/url-shortener-health.json -w '%{http_code}' "$BASE/api/v1/health" || true)"
if [[ "$code" != "200" ]]; then
echo "FAIL health HTTP $code" >&2
cat /tmp/url-shortener-health.json 2>/dev/null || true
exit 1
fi
cat /tmp/url-shortener-health.json
echo
body="$(curl -fsS "$BASE/api/v1/health")"
echo "$body" | grep -q '"status":"ok"' || { echo "health not ok: $body"; exit 1; }
if [[ -z "$BEARER" ]]; then
echo "SKIP shorten (set BEARER to run full smoke)"
@@ -21,9 +15,14 @@ if [[ -z "$BEARER" ]]; then
fi
echo "==> shorten POST"
curl -fsS -X POST "$BASE/api/v1/shorten" \
resp="$(curl -fsS -X POST "$BASE/api/v1/shorten" \
-H 'Content-Type: application/json' \
-d '{"url":"https://example.com/smoke","bearer":"'"$BEARER"'","ttl":300}' \
| jq -e '.code == "0" and (.u | length) > 0' >/dev/null
-d '{"url":"https://example.com/smoke-'"$(date +%s)"'","bearer":"'"$BEARER"'","ttl":300}')"
echo "$resp"
short="$(echo "$resp" | sed -n 's/.*"u":"\([^"]*\)".*/\1/p')"
[[ -n "$short" ]] || { echo "no short url in response"; exit 1; }
echo "==> redirect HEAD"
curl -fsSI "$short" | grep -qi '^location:' || { echo "missing redirect"; exit 1; }
echo "OK"

View File

@@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
final class AuditLog {
public static function write(
string $event,
?int $bearerId,
?string $slug,
string $detail = ''
): void {
$pdo = Database::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,68 @@
<?php
declare(strict_types=1);
final class BearerRepository {
public static function hashToken(string $token): string {
return hash('sha256', $token);
}
/** @return array<string,mixed>|null */
public static function findByToken(string $token): ?array {
$pdo = Database::pdo();
$stmt = $pdo->prepare(
'SELECT * FROM bearer_tokens WHERE token_hash = ? AND revoked_at IS NULL LIMIT 1'
);
$stmt->execute([self::hashToken($token)]);
$row = $stmt->fetch();
return $row === false ? null : $row;
}
public static function isTrusted(array $bearer): bool {
return (int) ($bearer['trusted'] ?? 0) === 1;
}
public static function canTemporary(array $bearer): bool {
return (int) ($bearer['can_temporary'] ?? 0) === 1;
}
public static function canPermanent(array $bearer): bool {
return (int) ($bearer['can_permanent'] ?? 0) === 1;
}
public static function rateLimitPerHour(array $bearer): int {
return max(1, (int) ($bearer['rate_limit_per_hour'] ?? 1000));
}
public static function countCreatesLastHour(int $bearerId): int {
$pdo = Database::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);
}
/** @return array{token: string, id: int} */
public static function createTrusted(
string $label,
bool $canTemporary = true,
bool $canPermanent = false,
int $rateLimit = 1000
): array {
$token = bin2hex(random_bytes(24));
$pdo = Database::pdo();
$stmt = $pdo->prepare(
'INSERT INTO bearer_tokens (token_hash, label, trusted, can_temporary, can_permanent, rate_limit_per_hour)
VALUES (?, ?, 1, ?, ?, ?)'
);
$stmt->execute([
self::hashToken($token),
$label,
$canTemporary ? 1 : 0,
$canPermanent ? 1 : 0,
$rateLimit,
]);
return ['token' => $token, 'id' => (int) $pdo->lastInsertId()];
}
}

View File

@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
final class Database {
private static ?PDO $pdo = null;
public static function pdo(): PDO {
if (self::$pdo !== null) {
return self::$pdo;
}
$m = cfg('db.mysql', []);
$db = (string) ($m['database'] ?? 'url_shortener');
$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;
}
}

View File

@@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
final class JsonResponse {
/** @param array<string,mixed> $extra */
public static function error(int $http, string $code, string $desc, array $extra = []): void {
http_response_code($http);
header('Content-Type: application/json; charset=utf-8');
echo json_encode(array_merge([
'code' => $code,
'desc' => $desc,
], $extra), JSON_UNESCAPED_SLASHES);
exit;
}
/** @param array<string,mixed> $body */
public static function ok(array $body, int $http = 200): void {
http_response_code($http);
header('Content-Type: application/json; charset=utf-8');
echo json_encode($body, JSON_UNESCAPED_SLASHES);
exit;
}
}

View File

@@ -0,0 +1,109 @@
<?php
declare(strict_types=1);
final class LinkRepository {
public static function publicBase(): string {
return rtrim((string) cfg('public_base', 'https://s.f0xx.org'), '/');
}
public static function shortUrl(string $slug): string {
return self::publicBase() . '/' . $slug;
}
/** @return array<string,mixed>|null */
public static function findActiveByBearerOrigin(int $bearerId, string $originHash): ?array {
$pdo = Database::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) {
return null;
}
return self::isExpired($row) ? null : $row;
}
/** @return array<string,mixed>|null */
public static function findExpiredByBearerOrigin(int $bearerId, string $originHash): ?array {
$pdo = Database::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) {
return null;
}
return self::isExpired($row) ? $row : null;
}
/** @param array<string,mixed> $row */
public static function isExpired(array $row): bool {
$exp = $row['expires_at'] ?? null;
if ($exp === null || $exp === '') {
return false;
}
return strtotime((string) $exp) <= time();
}
/** @param array<string,mixed> $row */
public static function remainingTtlSeconds(array $row): string {
$exp = $row['expires_at'] ?? null;
if ($exp === null || $exp === '') {
return '0';
}
$rem = max(0, strtotime((string) $exp) - time());
return (string) $rem;
}
/** @return array<string,mixed>|null */
public static function findBySlug(string $slug): ?array {
$pdo = Database::pdo();
$stmt = $pdo->prepare('SELECT * FROM links WHERE slug = ? LIMIT 1');
$stmt->execute([$slug]);
$row = $stmt->fetch();
return $row === false ? null : $row;
}
public static function slugExists(string $slug): bool {
return self::findBySlug($slug) !== null;
}
public static function insert(
int $bearerId,
?int $signerId,
string $slug,
string $origin,
string $originHash,
int $ttlSeconds
): void {
$pdo = Database::pdo();
$expiresAt = $ttlSeconds <= 0
? null
: date('Y-m-d H:i:s', time() + $ttlSeconds);
$stmt = $pdo->prepare(
'INSERT INTO links (bearer_id, signer_id, slug, origin, origin_hash, ttl_seconds, expires_at)
VALUES (?, ?, ?, ?, ?, ?, ?)'
);
$stmt->execute([
$bearerId,
$signerId,
$slug,
$origin,
$originHash,
max(0, $ttlSeconds),
$expiresAt,
]);
}
public static function recordRedirect(string $slug): void {
$pdo = Database::pdo();
$stmt = $pdo->prepare(
'UPDATE links SET accessed_at = NOW(), access_count = access_count + 1 WHERE slug = ?'
);
$stmt->execute([$slug]);
}
}

View File

@@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
final class QrHandler {
public static function handle(string $slug, int $size): void {
$link = LinkRepository::findBySlug($slug);
if ($link === null) {
http_response_code(404);
header('Content-Type: application/json; charset=utf-8');
echo json_encode(['code' => 'NOT_FOUND', 'desc' => 'unknown slug'], JSON_UNESCAPED_SLASHES);
exit;
}
if (LinkRepository::isExpired($link)) {
http_response_code(410);
header('Content-Type: application/json; charset=utf-8');
echo json_encode(['code' => 'TTL_EXPIRED', 'desc' => 'link expired'], JSON_UNESCAPED_SLASHES);
exit;
}
$size = max(128, min(512, $size));
$target = LinkRepository::shortUrl($slug);
$png = self::renderPng($target, $size);
if ($png === null) {
JsonResponse::error(503, 'INTERNAL', 'QR generation unavailable (install qrencode on BE)');
}
header('Content-Type: image/png');
header('Cache-Control: public, max-age=3600');
echo $png;
exit;
}
private static function renderPng(string $text, int $size): ?string {
$qrencode = trim((string) shell_exec('command -v qrencode 2>/dev/null') ?? '');
if ($qrencode === '') {
return null;
}
$moduleSize = max(2, (int) round($size / 40));
$cmd = sprintf(
'%s -t PNG -s %d -m 1 -o - %s 2>/dev/null',
escapeshellcmd($qrencode),
$moduleSize,
escapeshellarg($text)
);
$out = shell_exec($cmd);
if (!is_string($out) || strlen($out) < 8) {
return null;
}
return $out;
}
}

View File

@@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
final class RedirectHandler {
public static function handle(string $slug, string $method): void {
$link = LinkRepository::findBySlug($slug);
if ($link === null) {
self::respondMissing($method);
}
if (LinkRepository::isExpired($link)) {
self::respondExpired($method);
}
LinkRepository::recordRedirect($slug);
AuditLog::write('redirect', (int) $link['bearer_id'], $slug, '');
http_response_code(302);
header('Location: ' . (string) $link['origin']);
exit;
}
private static function respondMissing(string $method): void {
http_response_code(404);
if (self::wantsJson()) {
header('Content-Type: application/json; charset=utf-8');
echo json_encode(['code' => 'NOT_FOUND', 'desc' => 'unknown slug'], JSON_UNESCAPED_SLASHES);
} else {
header('Content-Type: text/plain; charset=utf-8');
echo 'Not found';
}
exit;
}
private static function respondExpired(string $method): void {
http_response_code(410);
if (self::wantsJson()) {
header('Content-Type: application/json; charset=utf-8');
echo json_encode(['code' => 'TTL_EXPIRED', 'desc' => 'link expired'], JSON_UNESCAPED_SLASHES);
} else {
header('Content-Type: text/plain; charset=utf-8');
echo 'Gone';
}
exit;
}
private static function wantsJson(): bool {
$accept = $_SERVER['HTTP_ACCEPT'] ?? '';
return str_contains($accept, 'application/json');
}
}

View File

@@ -0,0 +1,113 @@
<?php
declare(strict_types=1);
final class ShortenService {
/** @return array<string,mixed> */
public static function handle(string $rawUrl, string $bearerToken, string $signerKey, ?int $ttl): array {
$bearerToken = trim($bearerToken);
if ($bearerToken === '') {
return self::err(401, 'MISSING_BEARER', 'missing bearer', $rawUrl, '');
}
$bearer = BearerRepository::findByToken($bearerToken);
if ($bearer === null) {
AuditLog::write('deny', null, null, 'unknown bearer');
return self::err(403, 'ACCESS_DENIED', 'access denied', $rawUrl, $bearerToken);
}
$bearerId = (int) $bearer['id'];
try {
$normalized = UrlNormalizer::normalize($rawUrl);
} catch (InvalidArgumentException $e) {
return self::err(400, 'INVALID_URL', $e->getMessage(), $rawUrl, $bearerToken);
}
$originHash = UrlNormalizer::originHash($normalized);
$cached = LinkRepository::findActiveByBearerOrigin($bearerId, $originHash);
if ($cached !== null) {
return self::success($normalized, $cached, $bearerToken);
}
$expired = LinkRepository::findExpiredByBearerOrigin($bearerId, $originHash);
if ($expired !== null) {
return self::err(409, 'TTL_EXPIRED', 'link expired', $normalized, $bearerToken);
}
if (!BearerRepository::isTrusted($bearer)) {
AuditLog::write('deny', $bearerId, null, 'untrusted bearer');
return self::err(403, 'ACCESS_DENIED', 'access denied', $normalized, $bearerToken);
}
$ttlSeconds = $ttl ?? 86400;
if ($ttlSeconds < 0) {
$ttlSeconds = 86400;
}
$signerId = null;
if ($ttlSeconds === 0) {
$signer = SignerRepository::findValid($signerKey);
if ($signer === null) {
return self::err(403, 'SIGNER_REQUIRED', 'signer required for permanent links', $normalized, $bearerToken);
}
if (!BearerRepository::canPermanent($bearer)) {
return self::err(403, 'ACCESS_DENIED', 'bearer cannot create permanent links', $normalized, $bearerToken);
}
$signerId = (int) $signer['id'];
} else {
if (!BearerRepository::canTemporary($bearer)) {
return self::err(403, 'ACCESS_DENIED', 'bearer cannot create temporary links', $normalized, $bearerToken);
}
}
$limit = BearerRepository::rateLimitPerHour($bearer);
if (BearerRepository::countCreatesLastHour($bearerId) >= $limit) {
return self::err(429, 'RATE_LIMITED', 'rate limit exceeded', $normalized, $bearerToken);
}
$slug = self::allocateSlug($bearerId, $normalized);
LinkRepository::insert($bearerId, $signerId, $slug, $normalized, $originHash, $ttlSeconds);
AuditLog::write('shorten', $bearerId, $slug, 'created');
$row = LinkRepository::findBySlug($slug);
if ($row === null) {
return self::err(500, 'INTERNAL', 'insert failed', $normalized, $bearerToken);
}
return self::success($normalized, $row, $bearerToken);
}
private static function allocateSlug(int $bearerId, string $normalized): string {
foreach (SlugGenerator::candidates($bearerId, $normalized) as $candidate) {
if (!LinkRepository::slugExists($candidate)) {
return $candidate;
}
AuditLog::write('warn', $bearerId, $candidate, 'slug collision');
}
throw new RuntimeException('slug exhaustion');
}
/** @param array<string,mixed> $row */
private static function success(string $normalized, array $row, string $bearerToken): array {
$slug = (string) $row['slug'];
$body = [
'http' => 200,
'code' => '0',
'url' => $normalized,
'u' => LinkRepository::shortUrl($slug),
'bearer' => $bearerToken,
'ttl' => LinkRepository::remainingTtlSeconds($row),
'qr' => LinkRepository::publicBase() . '/api/v1/qr/' . $slug . '.png',
];
return $body;
}
/** @return array<string,mixed> */
private static function err(int $http, string $code, string $desc, string $url, string $bearer): array {
return [
'http' => $http,
'code' => $code,
'url' => $url,
'bearer' => $bearer,
'desc' => $desc,
];
}
}

View File

@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
final class SignerRepository {
public static function hashKey(string $key): string {
return hash('sha256', $key);
}
/** @return array<string,mixed>|null */
public static function findValid(string $signerKey): ?array {
if ($signerKey === '') {
return null;
}
$pdo = Database::pdo();
$stmt = $pdo->prepare(
'SELECT * FROM signer_keys WHERE key_hash = ? AND revoked_at IS NULL AND can_permanent = 1 LIMIT 1'
);
$stmt->execute([self::hashKey($signerKey)]);
$row = $stmt->fetch();
return $row === false ? null : $row;
}
/** @return array{key: string, id: int} */
public static function create(string $label): array {
$key = bin2hex(random_bytes(24));
$pdo = Database::pdo();
$stmt = $pdo->prepare(
'INSERT INTO signer_keys (key_hash, label, can_permanent) VALUES (?, ?, 1)'
);
$stmt->execute([self::hashKey($key), $label]);
return ['key' => $key, 'id' => (int) $pdo->lastInsertId()];
}
}

View File

@@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
final class SlugGenerator {
public static function baseSlug(int $bearerId, string $normalizedUrl): string {
$material = $bearerId . "\x1f" . $normalizedUrl;
return substr(hash('sha256', $material), 0, 12);
}
/** @return list<string> suffix candidates on collision */
public static function candidates(int $bearerId, string $normalizedUrl): array {
$slugs = [self::baseSlug($bearerId, $normalizedUrl)];
$base = $bearerId . "\x1f" . $normalizedUrl;
for ($i = 1; $i <= 15; $i++) {
$slugs[] = substr(hash('sha256', $base . "\x1e" . $i), 0, 12);
}
return $slugs;
}
}

View File

@@ -0,0 +1,60 @@
<?php
declare(strict_types=1);
final class UrlNormalizer {
private const MAX_LEN = 2048;
public static function normalize(string $raw): string {
$url = trim($raw);
if ($url === '') {
throw new InvalidArgumentException('empty url');
}
if (strlen($url) > self::MAX_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;
}
public static function originHash(string $normalized): string {
return hash('sha256', $normalized);
}
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');
}
}
}

View File

@@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
$configPath = dirname(__DIR__) . '/config/config.php';
if (!is_file($configPath)) {
$configPath = dirname(__DIR__) . '/config/config.example.php';
}
/** @var array<string,mixed> $config */
$config = require $configPath;
function cfg(string $key, $default = null) {
global $config;
$parts = explode('.', $key);
$v = $config;
foreach ($parts as $p) {
if (!is_array($v) || !array_key_exists($p, $v)) {
return $default;
}
$v = $v[$p];
}
return $v;
}
require_once __DIR__ . '/Database.php';
require_once __DIR__ . '/JsonResponse.php';
require_once __DIR__ . '/UrlNormalizer.php';
require_once __DIR__ . '/SlugGenerator.php';
require_once __DIR__ . '/AuditLog.php';
require_once __DIR__ . '/BearerRepository.php';
require_once __DIR__ . '/SignerRepository.php';
require_once __DIR__ . '/LinkRepository.php';
require_once __DIR__ . '/ShortenService.php';
require_once __DIR__ . '/RedirectHandler.php';
require_once __DIR__ . '/QrHandler.php';

View File

@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
require_once dirname(__DIR__) . '/src/UrlNormalizer.php';
require_once dirname(__DIR__) . '/src/SlugGenerator.php';
function assert_true(bool $cond, string $msg): void {
if (!$cond) {
fwrite(STDERR, "FAIL: $msg\n");
exit(1);
}
}
$n = UrlNormalizer::normalize('HTTPS://Example.COM/path?q=1#frag');
assert_true($n === 'https://example.com/path?q=1#frag', 'normalize host case');
$h = UrlNormalizer::originHash($n);
assert_true(strlen($h) === 64, 'origin hash length');
try {
UrlNormalizer::normalize('http://127.0.0.1/');
fwrite(STDERR, "FAIL: should block localhost\n");
exit(1);
} catch (InvalidArgumentException $e) {
// ok
}
$slug = SlugGenerator::baseSlug(42, 'https://example.com/a');
assert_true(strlen($slug) === 12, 'slug length 12');
assert_true(ctype_xdigit($slug), 'slug hex');
$candidates = SlugGenerator::candidates(1, 'https://a.test/');
assert_true(count($candidates) >= 2, 'collision candidates');
echo "OK url-shortener php tests\n";

View File

@@ -332,24 +332,23 @@ 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) (dedicated vhost; FE TLS + BE nginx **deployed 2026-06**)
**Web:** [https://s.f0xx.org](https://s.f0xx.org)
| Kind | Path | Notes |
|------|------|--------|
| Health | `GET /api/v1/health` | Returns JSON when `backend/url-shortener/public` synced to BE |
| Shorten | `POST /api/v1/shorten` | Bearer token; **501 stub** until PHP impl |
| QR | `GET /api/v1/qr/{slug}.png` | PNG |
| Redirect | `GET /{slug}` | `302` → original URL |
| Health | `GET /api/v1/health` | JSON `status: ok`, DB ping |
| 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 |
Spec: [specs/20100611_3_url_shortener.md](specs/20100611_3_url_shortener.md) · scaffold: [backend/url-shortener/](../backend/url-shortener/)
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
# Full cookbook (when shorten live): backend/url-shortener/scripts/demo_curl.sh
# Bearer: php backend/url-shortener/scripts/seed_bearer.php (on BE after deploy)
export BEARER=BASE=https://s.f0xx.org backend/url-shortener/scripts/demo_curl.sh
```
**Still TODO:** sync PHP tree to BE docroot, `schema.mariadb.sql`, bearer tokens, shorten/redirect handlers.
---
## Non-HTTP services

View File

@@ -224,7 +224,7 @@ See [ndk/README.md](../ndk/README.md).
## Deferred / post-alpha
- **URL shortener** (`https://s.f0xx.org`) — standalone PHP service; SPEC [specs/20100611_3_url_shortener.md](specs/20100611_3_url_shortener.md), scaffold [backend/url-shortener/](../backend/url-shortener/); git + FE TLS live; PHP handlers TODO (~8.5 d)
- **URL shortener** (`https://s.f0xx.org`) — PHP API in [backend/url-shortener/](../backend/url-shortener/); sync to BE + `seed_bearer.php` + `libqrencode-tools`
- Internet relay gateway
- TLS on cast stream
- Passthrough video codec