diff --git a/backend/url-shortener/DEPLOY.md b/backend/url-shortener/DEPLOY.md new file mode 100644 index 0000000..b1441ea --- /dev/null +++ b/backend/url-shortener/DEPLOY.md @@ -0,0 +1,41 @@ +# URL shortener — deploy (FE → BE) + +**Topology:** same as [INFRA.md](../../docs/INFRA.md) — TLS on Gentoo FE, app on Alpine BE `:80`. + +## Prerequisites (confirmed) + +| 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` | + +## 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). + +```bash +nginx -t && rc-service nginx reload +``` + +## BE (Alpine) + +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 +``` + +## Verify + +```bash +curl -sSI https://s.f0xx.org/api/v1/health +./scripts/smoke_shorten.sh +``` + +Not added to `validate_be_services.sh` until first production deploy (SPEC). diff --git a/backend/url-shortener/README.md b/backend/url-shortener/README.md new file mode 100644 index 0000000..e3d3b7b --- /dev/null +++ b/backend/url-shortener/README.md @@ -0,0 +1,42 @@ +# URL shortener (`s.f0xx.org`) + +Standalone opt-in service — **not** part of `examples/crash_reporter/backend/`. + +| Doc | Path | +|-----|------| +| SPEC | [docs/specs/20100611_3_url_shortener.md](../../docs/specs/20100611_3_url_shortener.md) | +| 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. + +**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}` + +## Quick smoke (when deployed) + +```bash +export BASE=https://s.f0xx.org +export BEARER=demo-bearer-replace-me +./scripts/demo_curl.sh +``` + +## Local dev (future) + +```bash +# MariaDB: apply sql/schema.mariadb.sql +# php -S 127.0.0.1:8091 -t public +``` + +## Layout + +```text +backend/url-shortener/ + openapi.yaml # API contract (source of truth with SPEC §6) + deploy/ # FE + BE nginx fragments + sql/schema.mariadb.sql + scripts/demo_curl.sh + scripts/smoke_shorten.sh + public/index.php # stub router (health only until impl) +``` diff --git a/backend/url-shortener/deploy/nginx.be-url-shortener.fragment b/backend/url-shortener/deploy/nginx.be-url-shortener.fragment new file mode 100644 index 0000000..b90a165 --- /dev/null +++ b/backend/url-shortener/deploy/nginx.be-url-shortener.fragment @@ -0,0 +1,29 @@ +# BE Alpine — url-shortener vhost fragment +# Include from /etc/nginx/conf.d/apps.conf or separate file. +# +# Docroot: /var/www/localhost/htdocs/apps/s/public +# PHP-FPM: same socket as crash reporter unless load warrants a pool. + +server { + listen 80; + server_name s.f0xx.org; + + root /var/www/localhost/htdocs/apps/s/public; + index index.php; + + # API + QR + location ^~ /api/ { + try_files $uri /index.php?$query_string; + } + + # Short slug redirect (12–16 hex chars) + location ~ ^/[a-f0-9]{6,16}$ { + try_files $uri /index.php?$query_string; + } + + location ~ \.php$ { + include fastcgi_params; + fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; + fastcgi_pass unix:/run/php-fpm81.sock; + } +} diff --git a/backend/url-shortener/deploy/nginx.fe-s.f0xx.org.snippet b/backend/url-shortener/deploy/nginx.fe-s.f0xx.org.snippet new file mode 100644 index 0000000..1b558f0 --- /dev/null +++ b/backend/url-shortener/deploy/nginx.fe-s.f0xx.org.snippet @@ -0,0 +1,30 @@ +# FE Gentoo — server block for s.f0xx.org (URL shortener) +# File: tmp/FE_gentoo/etc/nginx/nginx.conf OR conf.d/s.f0xx.org.conf +# +# TLS cert (confirmed on FE): +# ssl_certificate /etc/letsencrypt/live/s.f0xx.org/fullchain.pem; +# ssl_certificate_key /etc/letsencrypt/live/s.f0xx.org/privkey.pem; + +server { + listen 80; + server_name s.f0xx.org; + return 301 https://$host$request_uri; +} + +server { + listen 443 ssl http2; + server_name s.f0xx.org; + + ssl_certificate /etc/letsencrypt/live/s.f0xx.org/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/s.f0xx.org/privkey.pem; + + # All paths → BE :80 (PHP-FPM url-shortener vhost) + location / { + proxy_pass http://artc0.intra.raptor.org:80; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } +} diff --git a/backend/url-shortener/openapi.yaml b/backend/url-shortener/openapi.yaml new file mode 100644 index 0000000..d74e822 --- /dev/null +++ b/backend/url-shortener/openapi.yaml @@ -0,0 +1,253 @@ +openapi: 3.0.3 +info: + title: Android Cast URL Shortener + description: | + Standalone service at https://s.f0xx.org — SPEC docs/specs/20100611_3_url_shortener.md + version: 0.1.0 +servers: + - url: https://s.f0xx.org + description: Production (FE TLS → BE :80) + +paths: + /api/v1/health: + get: + summary: Service health + operationId: health + responses: + '200': + description: OK + content: + application/json: + schema: + type: object + properties: + status: + type: string + service: + type: string + + /api/v1/shorten: + get: + summary: Shorten URL (query params) + description: Prefer POST for long URLs or sensitive query strings. + operationId: shortenGet + parameters: + - $ref: '#/components/parameters/url' + - $ref: '#/components/parameters/bearer' + - $ref: '#/components/parameters/signer' + - $ref: '#/components/parameters/ttl' + responses: + '200': + $ref: '#/components/responses/ShortenSuccess' + '400': + $ref: '#/components/responses/ShortenError' + '401': + $ref: '#/components/responses/ShortenError' + '403': + $ref: '#/components/responses/ShortenError' + '409': + $ref: '#/components/responses/ShortenError' + '429': + $ref: '#/components/responses/ShortenError' + post: + summary: Shorten URL (JSON body) — preferred + operationId: shortenPost + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ShortenRequest' + responses: + '200': + $ref: '#/components/responses/ShortenSuccess' + '400': + $ref: '#/components/responses/ShortenError' + '401': + $ref: '#/components/responses/ShortenError' + '403': + $ref: '#/components/responses/ShortenError' + '409': + $ref: '#/components/responses/ShortenError' + '429': + $ref: '#/components/responses/ShortenError' + + /api/v1/qr/{slug}.png: + get: + summary: QR code PNG for short URL + operationId: qrPng + parameters: + - name: slug + in: path + required: true + schema: + type: string + pattern: '^[a-f0-9]{6,16}$' + - name: size + in: query + schema: + type: integer + default: 256 + minimum: 128 + maximum: 512 + responses: + '200': + description: PNG image + content: + image/png: + schema: + type: string + format: binary + '404': + description: Unknown slug + '410': + description: Expired slug + + /{slug}: + get: + summary: Redirect to original URL + operationId: redirect + parameters: + - name: slug + in: path + required: true + schema: + type: string + pattern: '^[a-f0-9]{6,16}$' + responses: + '302': + description: Found — Location is original URL + headers: + Location: + schema: + type: string + format: uri + '404': + description: Unknown slug + '410': + description: Expired slug + head: + summary: Redirect probe (no body) + operationId: redirectHead + parameters: + - name: slug + in: path + required: true + schema: + type: string + responses: + '302': + description: Found + '404': + description: Unknown + '410': + description: Expired + +components: + parameters: + url: + name: url + in: query + required: true + schema: + type: string + format: uri + maxLength: 2048 + bearer: + name: bearer + in: query + required: true + schema: + type: string + minLength: 8 + signer: + name: signer + in: query + required: false + schema: + type: string + ttl: + name: ttl + in: query + required: false + description: Seconds until expiry; 0 = permanent (signer required) + schema: + type: integer + minimum: 0 + default: 86400 + + schemas: + ShortenRequest: + type: object + required: [url, bearer] + properties: + url: + type: string + format: uri + maxLength: 2048 + bearer: + type: string + signer: + type: string + ttl: + type: integer + minimum: 0 + default: 86400 + ShortenSuccess: + type: object + required: [code, url, u, bearer, ttl] + properties: + code: + type: string + enum: ['0'] + url: + type: string + format: uri + u: + type: string + format: uri + description: Short URL + bearer: + type: string + ttl: + type: string + description: Remaining seconds as string; "0" if permanent + qr: + type: string + format: uri + description: Optional v1.1 — URL to QR PNG + ShortenError: + type: object + required: [code, desc] + properties: + code: + type: string + enum: + - ACCESS_DENIED + - MISSING_BEARER + - INVALID_URL + - TTL_EXPIRED + - SIGNER_REQUIRED + - RATE_LIMITED + - INTERNAL + - NOT_IMPLEMENTED + url: + type: string + bearer: + type: string + desc: + type: string + + responses: + ShortenSuccess: + description: Short URL created or cached + content: + application/json: + schema: + $ref: '#/components/schemas/ShortenSuccess' + ShortenError: + description: Error + content: + application/json: + schema: + $ref: '#/components/schemas/ShortenError' diff --git a/backend/url-shortener/public/index.php b/backend/url-shortener/public/index.php new file mode 100644 index 0000000..8050cc8 --- /dev/null +++ b/backend/url-shortener/public/index.php @@ -0,0 +1,27 @@ + 'stub', + 'service' => 'url-shortener', + 'message' => 'PHP handlers not deployed yet — see backend/url-shortener/README.md', + ], JSON_UNESCAPED_SLASHES); + exit; +} + +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/demo_curl.sh b/backend/url-shortener/scripts/demo_curl.sh new file mode 100755 index 0000000..d30d121 --- /dev/null +++ b/backend/url-shortener/scripts/demo_curl.sh @@ -0,0 +1,55 @@ +#!/usr/bin/env bash +# End-to-end curl cookbook — SPEC appendix A. +# Usage: +# export BASE=https://s.f0xx.org +# export BEARER=your-trusted-token +# export SIGNER=optional-for-ttl0 +# ./scripts/demo_curl.sh +set -euo pipefail + +BASE="${BASE:-https://s.f0xx.org}" +BEARER="${BEARER:?set BEARER to a trusted API token}" +SIGNER="${SIGNER:-}" +TARGET="${TARGET:-https://defender.net/very/long/link?demo=1}" +TTL="${TTL:-3600}" + +echo "==> 1) Health" +curl -fsS "$BASE/api/v1/health" | head -c 200 +echo + +echo "==> 2) Shorten (POST preferred)" +SHORT_JSON="$(curl -fsS -X POST "$BASE/api/v1/shorten" \ + -H 'Content-Type: application/json' \ + -d "$(jq -nc --arg url "$TARGET" --arg bearer "$BEARER" --argjson ttl "$TTL" \ + '{url:$url,bearer:$bearer,ttl:$ttl}')")" +echo "$SHORT_JSON" | jq . + +SHORT="$(echo "$SHORT_JSON" | jq -r '.u // empty')" +if [[ -z "$SHORT" ]]; then + echo "shorten failed (no .u in response)" >&2 + exit 1 +fi +SLUG="${SHORT##*/}" + +echo "==> 3) Shorten cache hit (same bearer + url)" +curl -fsS -X POST "$BASE/api/v1/shorten" \ + -H 'Content-Type: application/json' \ + -d "$(jq -nc --arg url "$TARGET" --arg bearer "$BEARER" --argjson ttl "$TTL" \ + '{url:$url,bearer:$bearer,ttl:$ttl}')" | jq . + +echo "==> 4) Redirect (HEAD — no body)" +curl -sSI "$SHORT" | grep -E '^(HTTP|Location:)' + +echo "==> 5) QR PNG" +curl -fsS -o "/tmp/url-shortener-qr-${SLUG}.png" "$BASE/api/v1/qr/${SLUG}.png?size=256" +file "/tmp/url-shortener-qr-${SLUG}.png" + +if [[ -n "$SIGNER" ]]; then + echo "==> 6) Permanent link (ttl=0, signer required)" + curl -fsS -X POST "$BASE/api/v1/shorten" \ + -H 'Content-Type: application/json' \ + -d "$(jq -nc --arg url "$TARGET&perm=1" --arg bearer "$BEARER" --arg signer "$SIGNER" \ + '{url:$url,bearer:$bearer,signer:$signer,ttl:0}')" | jq . +fi + +echo "==> Done" diff --git a/backend/url-shortener/scripts/smoke_shorten.sh b/backend/url-shortener/scripts/smoke_shorten.sh new file mode 100755 index 0000000..6ec5374 --- /dev/null +++ b/backend/url-shortener/scripts/smoke_shorten.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +# Minimal deploy smoke — exits non-zero on failure. +set -euo pipefail + +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 + +if [[ -z "$BEARER" ]]; then + echo "SKIP shorten (set BEARER to run full smoke)" + exit 0 +fi + +echo "==> shorten POST" +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 + +echo "OK" diff --git a/backend/url-shortener/sql/schema.mariadb.sql b/backend/url-shortener/sql/schema.mariadb.sql new file mode 100644 index 0000000..39fa560 --- /dev/null +++ b/backend/url-shortener/sql/schema.mariadb.sql @@ -0,0 +1,62 @@ +-- URL shortener — MariaDB schema (SPEC docs/specs/20100611_3_url_shortener.md §9) +CREATE DATABASE IF NOT EXISTS url_shortener + CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +USE url_shortener; + +CREATE TABLE IF NOT EXISTS bearer_tokens ( + id INT UNSIGNED NOT NULL AUTO_INCREMENT, + token_hash CHAR(64) NOT NULL, + label VARCHAR(128) NOT NULL DEFAULT '', + trusted TINYINT(1) NOT NULL DEFAULT 0, + can_temporary TINYINT(1) NOT NULL DEFAULT 1, + can_permanent TINYINT(1) NOT NULL DEFAULT 0, + rate_limit_per_hour INT UNSIGNED NOT NULL DEFAULT 1000, + revoked_at DATETIME NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (id), + UNIQUE KEY uq_bearer_token_hash (token_hash) +) ENGINE=InnoDB; + +CREATE TABLE IF NOT EXISTS signer_keys ( + id INT UNSIGNED NOT NULL AUTO_INCREMENT, + key_hash CHAR(64) NOT NULL, + label VARCHAR(128) NOT NULL DEFAULT '', + can_permanent TINYINT(1) NOT NULL DEFAULT 1, + revoked_at DATETIME NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (id), + UNIQUE KEY uq_signer_key_hash (key_hash) +) ENGINE=InnoDB; + +CREATE TABLE IF NOT EXISTS links ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + bearer_id INT UNSIGNED NOT NULL, + signer_id INT UNSIGNED NULL, + slug VARCHAR(16) NOT NULL, + origin TEXT NOT NULL, + origin_hash CHAR(64) NOT NULL, + ttl_seconds INT UNSIGNED NOT NULL DEFAULT 86400, + expires_at DATETIME NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + accessed_at DATETIME NULL, + access_count BIGINT UNSIGNED NOT NULL DEFAULT 0, + PRIMARY KEY (id), + UNIQUE KEY uq_slug (slug), + KEY idx_bearer_origin (bearer_id, origin_hash), + CONSTRAINT fk_links_bearer FOREIGN KEY (bearer_id) REFERENCES bearer_tokens (id), + CONSTRAINT fk_links_signer FOREIGN KEY (signer_id) REFERENCES signer_keys (id) +) ENGINE=InnoDB; + +CREATE TABLE IF NOT EXISTS audit_log ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + event VARCHAR(32) NOT NULL, + bearer_id INT UNSIGNED NULL, + slug VARCHAR(16) NULL, + ip VARCHAR(45) NOT NULL DEFAULT '', + detail TEXT, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (id), + KEY idx_audit_created (created_at) +) ENGINE=InnoDB; diff --git a/docs/20260521_next_gen_steps_egress_review.pdf b/docs/20260521_next_gen_steps_egress_review.pdf index 2abf601..2df20e6 100644 --- a/docs/20260521_next_gen_steps_egress_review.pdf +++ b/docs/20260521_next_gen_steps_egress_review.pdf @@ -92,7 +92,7 @@ endobj endobj 13 0 obj << -/Author (Android Cast project) /CreationDate (D:20260521190501+02'00') /Creator (\(unspecified\)) /Keywords () /ModDate (D:20260521190501+02'00') /Producer (ReportLab PDF Library - \(opensource\)) +/Author (Android Cast project) /CreationDate (D:20260611185343+02'00') /Creator (\(unspecified\)) /Keywords () /ModDate (D:20260611185343+02'00') /Producer (ReportLab PDF Library - \(opensource\)) /Subject (\(unspecified\)) /Title (Android Cast \204 Next-gen egress review) /Trapped /False >> endobj @@ -153,7 +153,7 @@ xref trailer << /ID -[<69103a44ba79ad224f4c4d9612cf06ea><69103a44ba79ad224f4c4d9612cf06ea>] +[<7980b3b39b2662662624213f08f5e4c7><7980b3b39b2662662624213f08f5e4c7>] % ReportLab generated PDF document -- digest (opensource) /Info 13 0 R