From b68dd00527319a461a92eacc15f042209e549cf9 Mon Sep 17 00:00:00 2001 From: Anton Afanasyeu Date: Tue, 23 Jun 2026 22:37:55 +0200 Subject: [PATCH] cluster0: lab verify-email short links via prod s.f0xx.org API Lab MariaDB slugs are not on public s.f0xx.org; outbound mail calls prod shorten API (internal artc0 + Host header) with API bearer token in secrets. Co-authored-by: Cursor --- sim/cluster0/cluster.env | 3 + .../backend/src/ShortLinksRepository.php | 73 ++++++++++++++++++- sim/cluster0/scripts/compose-lab-backend.sh | 10 +++ .../scripts/ensure-auth-email-api-bearer.sh | 31 ++++++++ sim/cluster0/secrets.lab.env.example | 4 + 5 files changed, 120 insertions(+), 1 deletion(-) create mode 100755 sim/cluster0/scripts/ensure-auth-email-api-bearer.sh diff --git a/sim/cluster0/cluster.env b/sim/cluster0/cluster.env index 06f5e07..4a17875 100644 --- a/sim/cluster0/cluster.env +++ b/sim/cluster0/cluster.env @@ -58,6 +58,9 @@ GITEA_ORG=ac LAB_PUBLIC_ORIGIN=https://acl0.f0xx.org PUBLIC_ORIGIN=https://apps.f0xx.org SHORT_LINKS_PUBLIC_BASE=https://s.f0xx.org +# Lab cluster → prod url-shortener (cast01 has no outbound :443 to s.f0xx.org; use BE vhost + Host) +URL_SHORTENER_API_BASE=http://artc0.intra.raptor.org +URL_SHORTENER_API_HOST=s.f0xx.org FE_PROXY_TARGET=cast01.intra.raptor.org:80 # Lab DNS (2026-06): acl0.f0xx.org + c1–c3.acl0.f0xx.org → FE (cast01–03 cluster) diff --git a/sim/cluster0/lab-seeds/backend/src/ShortLinksRepository.php b/sim/cluster0/lab-seeds/backend/src/ShortLinksRepository.php index ef6f03a..30c438f 100644 --- a/sim/cluster0/lab-seeds/backend/src/ShortLinksRepository.php +++ b/sim/cluster0/lab-seeds/backend/src/ShortLinksRepository.php @@ -492,7 +492,18 @@ final class ShortLinksRepository { public static function shortenForOutboundMail(string $longUrl, int $ttlSeconds = 86400): array { $longUrl = trim($longUrl); $fallback = ['short_url' => $longUrl, 'qr_scan_url' => $longUrl, 'qr_image_url' => null]; - if ($longUrl === '' || !UrlShortenerDatabase::enabled() || !UrlShortenerDatabase::ping()) { + if ($longUrl === '') { + return $fallback; + } + $apiToken = trim((string) cfg('url_shortener.api_bearer_token', '')); + if ($apiToken !== '') { + $viaApi = self::shortenViaPublicApi($longUrl, $ttlSeconds, $apiToken); + if ($viaApi !== null) { + return $viaApi; + } + error_log('ShortLinksRepository: outbound API shorten failed, trying local DB'); + } + if (!UrlShortenerDatabase::enabled() || !UrlShortenerDatabase::ping()) { return $fallback; } $bearerId = self::resolveOutboundBearerId(); @@ -514,6 +525,66 @@ final class ShortLinksRepository { ]; } + /** + * Create short link on public s.f0xx.org (prod DB) — required when lab MariaDB is not served by s.f0xx.org. + * + * @return array{short_url:string,qr_scan_url:string,qr_image_url:?string}|null + */ + public static function shortenViaPublicApi(string $longUrl, int $ttlSeconds, string $bearerToken): ?array { + $apiBase = rtrim( + (string) cfg('url_shortener.api_base', cfg('url_shortener.public_base', 'https://s.f0xx.org')), + '/' + ); + $apiHost = trim((string) cfg('url_shortener.api_host', '')); + $payload = json_encode([ + 'url' => $longUrl, + 'bearer' => $bearerToken, + 'ttl' => max(0, $ttlSeconds), + ], JSON_UNESCAPED_SLASHES); + if (!is_string($payload)) { + return null; + } + $headers = "Content-Type: application/json\r\nAccept: application/json\r\n"; + if ($apiHost !== '') { + $headers .= 'Host: ' . $apiHost . "\r\n"; + } + $ctx = stream_context_create([ + 'http' => [ + 'method' => 'POST', + 'header' => $headers, + 'content' => $payload, + 'timeout' => 15, + 'ignore_errors' => true, + ], + 'ssl' => [ + 'verify_peer' => true, + 'verify_peer_name' => true, + ], + ]); + $raw = @file_get_contents($apiBase . '/api/v1/shorten', false, $ctx); + if (!is_string($raw) || $raw === '') { + return null; + } + $data = json_decode($raw, true); + if (!is_array($data) || ($data['code'] ?? '') !== '0') { + error_log('ShortLinksRepository: API shorten error: ' . substr($raw, 0, 240)); + return null; + } + $shortUrl = (string) ($data['u'] ?? ''); + if ($shortUrl === '') { + return null; + } + $qrImage = (string) ($data['qr'] ?? ''); + if ($qrImage === '' && preg_match('#/([a-f0-9]{6,16})$#', $shortUrl, $m)) { + $qrImage = $apiBase . '/api/v1/qr/' . $m[1] . '.png'; + } + return [ + 'short_url' => $shortUrl, + 'qr_scan_url' => self::qrScanUrl($shortUrl), + 'qr_image_url' => $qrImage !== '' ? $qrImage : null, + ]; + } + /** Short URL with src=qr for QR encoding and phone scan analytics. */ public static function qrScanUrl(string $shortUrl): string { return self::appendQueryParam($shortUrl, 'src', 'qr'); diff --git a/sim/cluster0/scripts/compose-lab-backend.sh b/sim/cluster0/scripts/compose-lab-backend.sh index 2df5afe..e0a5544 100755 --- a/sim/cluster0/scripts/compose-lab-backend.sh +++ b/sim/cluster0/scripts/compose-lab-backend.sh @@ -138,6 +138,13 @@ unset _MSMTP_PASS _mail_from_secret MAIL_FROM_PHP="$(printf '%s' "$MAIL_FROM" | sed "s/'/\\\\'/g")" AUTH_BEARER_ID="$(read_secret URL_SHORTENER_AUTH_BEARER_ID 2>/dev/null || true)" [ -n "$AUTH_BEARER_ID" ] || AUTH_BEARER_ID="${URL_SHORTENER_AUTH_BEARER_ID:-0}" +API_BEARER_TOKEN="$(read_secret URL_SHORTENER_API_BEARER_TOKEN 2>/dev/null || true)" +API_BEARER_PHP="" +if [ -n "$API_BEARER_TOKEN" ]; then + API_BEARER_PHP="$(printf '%s' "$API_BEARER_TOKEN" | sed "s/'/\\\\'/g")" +fi +URL_SHORTENER_API_BASE_CFG="${URL_SHORTENER_API_BASE:-${SHORT_LINKS_PUBLIC_BASE}}" +URL_SHORTENER_API_HOST_CFG="${URL_SHORTENER_API_HOST:-}" mkdir -p "${COMPOSED}/config" log "write composed config.php base_path=${ISSUES_BASE}" cat > "${COMPOSED}/config/config.php" < [ 'enabled' => true, 'public_base' => '${SHORT_LINKS_PUBLIC_BASE}', + 'api_base' => '${URL_SHORTENER_API_BASE_CFG}', + 'api_host' => '${URL_SHORTENER_API_HOST_CFG}', + 'api_bearer_token' => '${API_BEARER_PHP}', 'auth_bearer_id' => ${AUTH_BEARER_ID:-0}, 'db' => [ 'mysql' => [ diff --git a/sim/cluster0/scripts/ensure-auth-email-api-bearer.sh b/sim/cluster0/scripts/ensure-auth-email-api-bearer.sh new file mode 100755 index 0000000..9ad6d9b --- /dev/null +++ b/sim/cluster0/scripts/ensure-auth-email-api-bearer.sh @@ -0,0 +1,31 @@ +#!/bin/sh +# Store prod s.f0xx.org API bearer for lab outbound mail (links must live in prod DB). +set -eu +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +# shellcheck source=/dev/null +. "$ROOT/scripts/lib/common.sh" +load_cluster_env + +TOKEN="${1:-}" +if [ -z "$TOKEN" ]; then + die "usage: ensure-auth-email-api-bearer.sh +Mint on prod BE: php81 /var/www/localhost/htdocs/apps/s/scripts/seed_bearer.php --label=auth-email-system" +fi + +SECRETS="${CAST_CLUSTER_ROOT}/secrets.lab.env" +touch "$SECRETS" +chmod 600 "$SECRETS" +grep -v '^URL_SHORTENER_API_BEARER_TOKEN=' "$SECRETS" > /tmp/secrets.new || true +printf 'URL_SHORTENER_API_BEARER_TOKEN=%s\n' "$TOKEN" >> /tmp/secrets.new +mv /tmp/secrets.new "$SECRETS" +chmod 600 "$SECRETS" + +_short="$(curl -fsS -X POST "${URL_SHORTENER_API_BASE:-http://artc0.intra.raptor.org}/api/v1/shorten" \ + -H 'Content-Type: application/json' \ + -H "Host: ${URL_SHORTENER_API_HOST:-s.f0xx.org}" \ + -d "{\"url\":\"https://example.com/lab-smoke\",\"bearer\":\"${TOKEN}\",\"ttl\":60}" \ + | php83 -r '$j=json_decode(stream_get_contents(STDIN),true); echo (string)($j["u"]??"");' 2>/dev/null)" || _short="" +[ -n "$_short" ] || die "API smoke failed — token rejected by ${SHORT_LINKS_PUBLIC_BASE:-https://s.f0xx.org}" + +log "ensure-auth-email-api-bearer_ok smoke=${_short}" +log "next: compose-lab-backend.sh && verify-mail-lab.sh …" diff --git a/sim/cluster0/secrets.lab.env.example b/sim/cluster0/secrets.lab.env.example index 0fb866f..0260d59 100644 --- a/sim/cluster0/secrets.lab.env.example +++ b/sim/cluster0/secrets.lab.env.example @@ -26,3 +26,7 @@ MAIL_REPLY_TO=bestcastr@gmail.com MSMTP_FROM=bestcastr@gmail.com MSMTP_USER=bestcastr@gmail.com # MSMTP_APP_PASSWORD="xxxx xxxx xxxx xxxx" + +# Prod s.f0xx.org shorten API — required for verify-email short links from lab (lab MariaDB ≠ prod). +# Mint on alpine-be: php81 …/apps/s/scripts/seed_bearer.php --label=auth-email-system +# URL_SHORTENER_API_BEARER_TOKEN=