diff --git a/.env.example b/.env.example index cc435d0..7b222f4 100644 --- a/.env.example +++ b/.env.example @@ -36,5 +36,12 @@ RATE_LIMIT_VIEW=120/minute # rate limits will apply globally rather than per-client. TRUST_PROXY_HEADERS=false +# Public base URL — scheme + host, no trailing slash (e.g. https://paste.example.com). +# Builds the absolute URLs in social-preview meta tags (og:image, og:url) so +# link-unfurl bots (iMessage, Slack, …) can fetch the preview image. Leave empty +# to derive it from the request — set it when a TLS-terminating proxy would +# otherwise make the app advertise http:// URLs. A bad value fails fast at startup. +# BASE_URL= + # Server port (default: 8000) PORT=8000 diff --git a/Dockerfile b/Dockerfile index dceb7a5..d1e0c30 100644 --- a/Dockerfile +++ b/Dockerfile @@ -58,8 +58,11 @@ ENV PORT=8000 EXPOSE ${PORT} -HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \ - CMD ["sh", "-c", "wget -qO- http://localhost:${PORT}/healthz || exit 1"] +# No HEALTHCHECK instruction on purpose: `podman build` produces OCI-format +# images, which silently drop it ("HEALTHCHECK is not supported for OCI image +# format"). The liveness probe lives with the orchestrator instead — the +# `healthcheck:` block in docker-compose.yml and the Health* keys in the Podman +# Quadlet (see README) — so it behaves the same regardless of build engine. # JSON-array CMD (silences Dockerfile JSONArgsRecommended) + `exec` so the # shell is replaced by uvicorn. Without exec, uvicorn would be a child of diff --git a/README.md b/README.md index 0186ed6..21783e1 100644 --- a/README.md +++ b/README.md @@ -139,6 +139,12 @@ Environment=SQLITE_PATH=/data/ghostbit.db Environment=MAX_PASTE_SIZE=524288 Environment=PORT=8000 +HealthCmd=wget -qO- http://127.0.0.1:8000/healthz || exit 1 +HealthInterval=30s +HealthTimeout=5s +HealthStartPeriod=15s +HealthRetries=3 + [Service] Restart=always @@ -170,6 +176,7 @@ For Redis, add a `ghostbit-redis.container` alongside and use `After=ghostbit-re | `RATE_LIMIT_CREATE` | `30/minute` | Rate limit for paste creation | | `RATE_LIMIT_VIEW` | `120/minute` | Rate limit for paste viewing | | `TRUST_PROXY_HEADERS` | `false` | Use rightmost `X-Forwarded-For` for rate limiting (enable only behind a trusted proxy) | +| `BASE_URL` | — | Public base URL (e.g. `https://paste.example.com`) for the absolute links in social-preview meta tags. Derived from the request when unset. | | `WEBHOOK_SECRET` | — | HMAC-SHA256 secret for signing webhook payloads | --- diff --git a/app/config.py b/app/config.py index e3d6f73..7a0a0d1 100644 --- a/app/config.py +++ b/app/config.py @@ -1,3 +1,4 @@ +from pydantic import field_validator from pydantic_settings import BaseSettings @@ -26,9 +27,26 @@ class Settings(BaseSettings): # can spoof it and bypass rate limits. trust_proxy_headers: bool = False + # Public-facing base URL (scheme + host [+ :port]), e.g. "https://paste.example.com". + # Builds the absolute URLs in social-preview meta tags (og:image, og:url). + # Empty → derived from the incoming request, which is correct for direct + # exposure and for proxies that forward scheme + Host. Set it explicitly when + # a TLS-terminating proxy would otherwise leave the app advertising http://. + base_url: str = "" + # Ignore extra env vars (e.g. a stale ENCRYPTION_KEY from pre-E2E setups) # instead of failing at startup. model_config = {"env_file": ".env", "extra": "ignore"} + @field_validator("base_url") + @classmethod + def _normalize_base_url(cls, v: str) -> str: + # Fail fast on a malformed value rather than silently emitting broken + # URLs. Trailing slash stripped so callers can join cleanly. + v = v.strip().rstrip("/") + if v and not v.startswith(("http://", "https://")): + raise ValueError("BASE_URL must start with http:// or https://") + return v + settings = Settings() diff --git a/app/main.py b/app/main.py index 68f36d3..06d7963 100644 --- a/app/main.py +++ b/app/main.py @@ -244,14 +244,14 @@ async def security_txt(): # Browser icon probes. Without these routes, every browser hits # GET /favicon.ico / /apple-touch-icon.png / /apple-touch-icon-precomposed.png # on page load and those paths fall through to the `/{paste_id}` catch-all, -# which fails the ID regex and returns 422. The redirect to our existing -# logo.png is small, cacheable (browsers remember 301s aggressively), and -# stops the access log from filling up with 422 noise. +# which fails the ID regex and returns 422. The redirect to the square site +# icon is small, cacheable (browsers remember 301s aggressively), and stops +# the access log from filling up with 422 noise. @app.get("/favicon.ico", include_in_schema=False) @app.get("/apple-touch-icon.png", include_in_schema=False) @app.get("/apple-touch-icon-precomposed.png", include_in_schema=False) async def _browser_icon_redirect(): - return RedirectResponse("/static/logo.png", status_code=301) + return RedirectResponse("/static/icon.png", status_code=301) _ROBOTS_TXT = "User-agent: *\nDisallow: /api/\nDisallow: /docs\nDisallow: /redoc\n" @@ -288,6 +288,22 @@ def _asset_hash() -> str: templates.env.globals["v"] = _asset_hash() + +def _abs_url(request: Request, path: str) -> str: + """Absolute URL for a site path, used by social-preview meta tags. + + Prefers settings.base_url when configured — the escape hatch for + TLS-terminating proxies, where request.base_url would otherwise carry an + internal http:// scheme/host that link-preview bots cannot reach. Falls + back to the request's own base URL for direct exposure and scheme-aware + proxies. + """ + base = settings.base_url or str(request.base_url).rstrip("/") + return f"{base}/{path.lstrip('/')}" + + +templates.env.globals["abs_url"] = _abs_url + _ERROR_TITLES = { 404: "Not found", 403: "Forbidden", diff --git a/docker-compose.yml b/docker-compose.yml index 81078e3..d4c2f88 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -12,6 +12,14 @@ services: - PORT=${PORT:-8000} volumes: - ghostbit_data:/data + # 127.0.0.1, not localhost: busybox wget resolves localhost to [::1], but + # uvicorn binds IPv4 only (--host 0.0.0.0), so an IPv6 probe is refused. + healthcheck: + test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:${PORT:-8000}/healthz || exit 1"] + interval: 30s + timeout: 5s + start_period: 15s + retries: 3 restart: unless-stopped redis: diff --git a/docs/configuration.md b/docs/configuration.md index bf92865..c911d8e 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -17,6 +17,7 @@ All configuration is done via environment variables (or a `.env` file at the pro | `RATE_LIMIT_CREATE` | `30/minute` | Rate limit for paste creation per IP (`POST /api/v1/pastes`) | | `RATE_LIMIT_VIEW` | `120/minute` | Rate limit for paste reads per IP (`GET /api/v1/pastes/{id}`) | | `TRUST_PROXY_HEADERS` | `false` | Read client IP from `X-Forwarded-For` for rate-limiting. See the [Reverse proxy](#reverse-proxy) note below before enabling. | +| `BASE_URL` | — | Public base URL (e.g. `https://paste.example.com`). Builds the absolute URLs in social-preview meta tags (`og:image`, `og:url`). Derived from the request when unset — see [Reverse proxy](#reverse-proxy). | !!! info "No server-side encryption key" All encryption is performed client-side (AES-256-GCM in the browser or CLI). The server never sees plaintext — no `ENCRYPTION_KEY` is needed. @@ -97,6 +98,21 @@ would show only the reverse proxy's internal IP, which is not useful for incident triage. If you run the server outside of Docker, pass those flags yourself (`uvicorn app.main:app --proxy-headers --forwarded-allow-ips="*"`). +### Absolute URLs for link previews + +Ghostbit puts absolute URLs in its social-preview `` tags (`og:image`, +`og:url`) so link-unfurl bots — iMessage, Slack, Discord… — can fetch the +preview banner. They are derived from the incoming request by default, which +is correct for direct exposure and for proxies that forward the scheme and +`Host` header. If a TLS-terminating proxy would otherwise make the app emit +`http://` URLs, pin the public origin explicitly: + +```env +BASE_URL=https://paste.example.com +``` + +A malformed value (missing `http://` / `https://` scheme) fails fast at startup. + !!! warning "Multi-hop setups (CDN → LB → app)" If more than one trusted proxy sits between the client and Ghostbit, the rightmost entry will be the nearest proxy (not the client), and rate diff --git a/static/icon.png b/static/icon.png new file mode 100644 index 0000000..726ba0c Binary files /dev/null and b/static/icon.png differ diff --git a/static/og-banner.png b/static/og-banner.png new file mode 100644 index 0000000..612c6fb Binary files /dev/null and b/static/og-banner.png differ diff --git a/templates/base.html b/templates/base.html index a009780..6d07d1f 100644 --- a/templates/base.html +++ b/templates/base.html @@ -8,12 +8,19 @@ - - - + + + + + + + + + {% endblock %} - + +