From fef93d7cdce9e120a375f0fa94b462041ea853d0 Mon Sep 17 00:00:00 2001 From: Anton Afanasyeu Date: Thu, 11 Jun 2026 22:45:47 +0200 Subject: [PATCH] BE sync --- backend/url-shortener/DEPLOY.md | 78 ++++++------ backend/url-shortener/README.md | 36 +++--- backend/url-shortener/config/.gitignore | 1 + backend/url-shortener/public/index.php | 85 +++++++++---- .../scripts/purge_url_shortener.php | 15 +++ .../url-shortener/scripts/run-php-tests.sh | 5 + backend/url-shortener/scripts/seed_bearer.php | 26 ++++ .../url-shortener/scripts/smoke_shorten.sh | 21 ++-- backend/url-shortener/src/AuditLog.php | 18 +++ .../url-shortener/src/BearerRepository.php | 68 +++++++++++ backend/url-shortener/src/Database.php | 32 +++++ backend/url-shortener/src/JsonResponse.php | 23 ++++ backend/url-shortener/src/LinkRepository.php | 109 +++++++++++++++++ backend/url-shortener/src/QrHandler.php | 50 ++++++++ backend/url-shortener/src/RedirectHandler.php | 48 ++++++++ backend/url-shortener/src/ShortenService.php | 113 ++++++++++++++++++ .../url-shortener/src/SignerRepository.php | 33 +++++ backend/url-shortener/src/SlugGenerator.php | 19 +++ backend/url-shortener/src/UrlNormalizer.php | 60 ++++++++++ backend/url-shortener/src/bootstrap.php | 34 ++++++ backend/url-shortener/tests/run_tests.php | 35 ++++++ docs/20260610_SERVICES.md | 17 ++- docs/ROADMAP.md | 2 +- 23 files changed, 831 insertions(+), 97 deletions(-) create mode 100644 backend/url-shortener/config/.gitignore create mode 100755 backend/url-shortener/scripts/purge_url_shortener.php create mode 100755 backend/url-shortener/scripts/run-php-tests.sh create mode 100755 backend/url-shortener/scripts/seed_bearer.php create mode 100644 backend/url-shortener/src/AuditLog.php create mode 100644 backend/url-shortener/src/BearerRepository.php create mode 100644 backend/url-shortener/src/Database.php create mode 100644 backend/url-shortener/src/JsonResponse.php create mode 100644 backend/url-shortener/src/LinkRepository.php create mode 100644 backend/url-shortener/src/QrHandler.php create mode 100644 backend/url-shortener/src/RedirectHandler.php create mode 100644 backend/url-shortener/src/ShortenService.php create mode 100644 backend/url-shortener/src/SignerRepository.php create mode 100644 backend/url-shortener/src/SlugGenerator.php create mode 100644 backend/url-shortener/src/UrlNormalizer.php create mode 100644 backend/url-shortener/src/bootstrap.php create mode 100644 backend/url-shortener/tests/run_tests.php diff --git a/backend/url-shortener/DEPLOY.md b/backend/url-shortener/DEPLOY.md index 496c9ec..f52935d 100644 --- a/backend/url-shortener/DEPLOY.md +++ b/backend/url-shortener/DEPLOY.md @@ -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). diff --git a/backend/url-shortener/README.md b/backend/url-shortener/README.md index ec2f6d5..6ac6716 100644 --- a/backend/url-shortener/README.md +++ b/backend/url-shortener/README.md @@ -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` -**Public URL:** `https://s.f0xx.org` -**FE TLS (PO confirmed):** `/etc/letsencrypt/live/s.f0xx.org/{fullchain.pem,privkey.pem}` +**Git remote:** `git://f0xx.org/androicast_project/url-shortener` +**Public URL:** `https://s.f0xx.org` -## 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 ``` diff --git a/backend/url-shortener/config/.gitignore b/backend/url-shortener/config/.gitignore new file mode 100644 index 0000000..4f4773f --- /dev/null +++ b/backend/url-shortener/config/.gitignore @@ -0,0 +1 @@ +config.php diff --git a/backend/url-shortener/public/index.php b/backend/url-shortener/public/index.php index 8050cc8..863e42c 100644 --- a/backend/url-shortener/public/index.php +++ b/backend/url-shortener/public/index.php @@ -1,27 +1,72 @@ '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); diff --git a/backend/url-shortener/scripts/purge_url_shortener.php b/backend/url-shortener/scripts/purge_url_shortener.php new file mode 100755 index 0000000..129bdea --- /dev/null +++ b/backend/url-shortener/scripts/purge_url_shortener.php @@ -0,0 +1,15 @@ +#!/usr/bin/env php +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"; diff --git a/backend/url-shortener/scripts/run-php-tests.sh b/backend/url-shortener/scripts/run-php-tests.sh new file mode 100755 index 0000000..dfe4ab1 --- /dev/null +++ b/backend/url-shortener/scripts/run-php-tests.sh @@ -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" diff --git a/backend/url-shortener/scripts/seed_bearer.php b/backend/url-shortener/scripts/seed_bearer.php new file mode 100755 index 0000000..0ddb129 --- /dev/null +++ b/backend/url-shortener/scripts/seed_bearer.php @@ -0,0 +1,26 @@ +#!/usr/bin/env php + 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" diff --git a/backend/url-shortener/src/AuditLog.php b/backend/url-shortener/src/AuditLog.php new file mode 100644 index 0000000..f6357ce --- /dev/null +++ b/backend/url-shortener/src/AuditLog.php @@ -0,0 +1,18 @@ +prepare( + 'INSERT INTO audit_log (event, bearer_id, slug, ip, detail) VALUES (?, ?, ?, ?, ?)' + ); + $stmt->execute([$event, $bearerId, $slug, $ip, $detail]); + } +} diff --git a/backend/url-shortener/src/BearerRepository.php b/backend/url-shortener/src/BearerRepository.php new file mode 100644 index 0000000..d269906 --- /dev/null +++ b/backend/url-shortener/src/BearerRepository.php @@ -0,0 +1,68 @@ +|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()]; + } +} diff --git a/backend/url-shortener/src/Database.php b/backend/url-shortener/src/Database.php new file mode 100644 index 0000000..b431047 --- /dev/null +++ b/backend/url-shortener/src/Database.php @@ -0,0 +1,32 @@ + PDO::ERRMODE_EXCEPTION, + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, + ]); + return self::$pdo; + } +} diff --git a/backend/url-shortener/src/JsonResponse.php b/backend/url-shortener/src/JsonResponse.php new file mode 100644 index 0000000..40f68f8 --- /dev/null +++ b/backend/url-shortener/src/JsonResponse.php @@ -0,0 +1,23 @@ + $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 $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; + } +} diff --git a/backend/url-shortener/src/LinkRepository.php b/backend/url-shortener/src/LinkRepository.php new file mode 100644 index 0000000..9507507 --- /dev/null +++ b/backend/url-shortener/src/LinkRepository.php @@ -0,0 +1,109 @@ +|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|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 $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 $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|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]); + } +} diff --git a/backend/url-shortener/src/QrHandler.php b/backend/url-shortener/src/QrHandler.php new file mode 100644 index 0000000..cf59f46 --- /dev/null +++ b/backend/url-shortener/src/QrHandler.php @@ -0,0 +1,50 @@ + '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; + } +} diff --git a/backend/url-shortener/src/RedirectHandler.php b/backend/url-shortener/src/RedirectHandler.php new file mode 100644 index 0000000..0ce9163 --- /dev/null +++ b/backend/url-shortener/src/RedirectHandler.php @@ -0,0 +1,48 @@ + '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'); + } +} diff --git a/backend/url-shortener/src/ShortenService.php b/backend/url-shortener/src/ShortenService.php new file mode 100644 index 0000000..a50a49e --- /dev/null +++ b/backend/url-shortener/src/ShortenService.php @@ -0,0 +1,113 @@ + */ + 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 $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 */ + 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, + ]; + } +} diff --git a/backend/url-shortener/src/SignerRepository.php b/backend/url-shortener/src/SignerRepository.php new file mode 100644 index 0000000..b00fea9 --- /dev/null +++ b/backend/url-shortener/src/SignerRepository.php @@ -0,0 +1,33 @@ +|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()]; + } +} diff --git a/backend/url-shortener/src/SlugGenerator.php b/backend/url-shortener/src/SlugGenerator.php new file mode 100644 index 0000000..772c640 --- /dev/null +++ b/backend/url-shortener/src/SlugGenerator.php @@ -0,0 +1,19 @@ + 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; + } +} diff --git a/backend/url-shortener/src/UrlNormalizer.php b/backend/url-shortener/src/UrlNormalizer.php new file mode 100644 index 0000000..e08e644 --- /dev/null +++ b/backend/url-shortener/src/UrlNormalizer.php @@ -0,0 +1,60 @@ + 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'); + } + } +} diff --git a/backend/url-shortener/src/bootstrap.php b/backend/url-shortener/src/bootstrap.php new file mode 100644 index 0000000..17b7d4c --- /dev/null +++ b/backend/url-shortener/src/bootstrap.php @@ -0,0 +1,34 @@ + $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'; diff --git a/backend/url-shortener/tests/run_tests.php b/backend/url-shortener/tests/run_tests.php new file mode 100644 index 0000000..2733aaf --- /dev/null +++ b/backend/url-shortener/tests/run_tests.php @@ -0,0 +1,35 @@ += 2, 'collision candidates'); + +echo "OK url-shortener php tests\n"; diff --git a/docs/20260610_SERVICES.md b/docs/20260610_SERVICES.md index 6785612..72f3617 100644 --- a/docs/20260610_SERVICES.md +++ b/docs/20260610_SERVICES.md @@ -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 diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 77f147c..54c7de4 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -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