Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
7 changes: 5 additions & 2 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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 |

---
Expand Down
18 changes: 18 additions & 0 deletions app/config.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
from pydantic import field_validator
from pydantic_settings import BaseSettings


Expand Down Expand Up @@ -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
# <meta> 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()
24 changes: 20 additions & 4 deletions app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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",
Expand Down
8 changes: 8 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
16 changes: 16 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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 `<meta>` 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
Expand Down
Binary file added static/icon.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added static/og-banner.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
15 changes: 11 additions & 4 deletions templates/base.html
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,19 @@
<meta property="og:title" content="Ghostbit — Encrypted paste, zero knowledge">
<meta property="og:description" content="Self-hosted, end-to-end encrypted paste service. Your data never touches the server in plaintext.">
<meta property="og:type" content="website">
<meta property="og:image" content="/static/logo.png">
<meta name="twitter:card" content="summary">
<meta name="twitter:title" content="Ghostbit">
<meta property="og:url" content="{{ abs_url(request, request.url.path) }}">
<meta property="og:image" content="{{ abs_url(request, '/static/og-banner.png') }}">
<meta property="og:image:type" content="image/png">
<meta property="og:image:width" content="1200">
<meta property="og:image:height" content="630">
<meta property="og:image:alt" content="Ghostbit — end-to-end encrypted pastes">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="Ghostbit — Encrypted paste, zero knowledge">
<meta name="twitter:description" content="Self-hosted, end-to-end encrypted paste service.">
<meta name="twitter:image" content="{{ abs_url(request, '/static/og-banner.png') }}">
{% endblock %}
<link rel="icon" type="image/png" href="/static/logo.png?v={{ v }}">
<link rel="icon" type="image/png" href="/static/icon.png?v={{ v }}">
<link rel="apple-touch-icon" href="/static/icon.png?v={{ v }}">
<link rel="stylesheet" href="/static/style.css?v={{ v }}">
<script nonce="{{ request.state.csp_nonce }}">
// crypto.subtle requires a Secure Context. Browsers grant it to 'localhost'
Expand Down
5 changes: 4 additions & 1 deletion templates/paste.html
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,13 @@
{% block title %}{{ paste.id }} — Ghostbit{% endblock %}

{% block meta %}
{# Paste pages deliberately omit og:image. An encrypted paste has nothing to
preview, so link unfurls (iMessage, Slack, …) fall back to the compact card
with the square favicon instead of blowing up a logo into a hero image. #}
<meta property="og:title" content="{{ paste.id }} — Ghostbit">
<meta property="og:description" content="End-to-end encrypted paste. Decryption happens in your browser — the server never sees your data.">
<meta property="og:type" content="website">
<meta property="og:image" content="/static/logo.png">
<meta property="og:url" content="{{ abs_url(request, request.url.path) }}">
<meta name="twitter:card" content="summary">
<meta name="twitter:title" content="{{ paste.id }} — Ghostbit">
<meta name="twitter:description" content="End-to-end encrypted paste. Decryption happens in your browser.">
Expand Down
39 changes: 39 additions & 0 deletions tests/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,45 @@ async def test_csp_nonce_is_per_request(client):
assert f'nonce="{n1}"' in r1.text


@pytest.mark.anyio
async def test_homepage_og_image_is_absolute_banner(client):
"""Link unfurls need an absolute og:image. A relative URL (or the portrait
logo) is what made iMessage render a huge blown-up icon."""
html = (await client.get("/")).text
assert 'property="og:image" content="http://test/static/og-banner.png"' in html
assert 'name="twitter:card" content="summary_large_image"' in html


@pytest.mark.anyio
async def test_paste_page_omits_og_image(client):
"""Paste pages carry no og:image on purpose, so unfurls fall back to the
compact card instead of stretching an image into the hero slot."""
pid = (await client.post("/api/v1/pastes", json=_fake_paste())).json()["id"]
html = (await client.get(f"/{pid}")).text
assert "og:image" not in html
assert 'name="twitter:card" content="summary"' in html


def test_abs_url_prefers_configured_base_url(monkeypatch):
"""BASE_URL, when set, overrides the request-derived origin — the escape
hatch for TLS-terminating proxies that would otherwise emit http:// URLs."""
from app.config import settings
from app.main import _abs_url

class _Req:
base_url = "http://internal:8000/"

monkeypatch.setattr(settings, "base_url", "https://paste.example.com")
assert _abs_url(_Req(), "/static/og-banner.png") == (
"https://paste.example.com/static/og-banner.png"
)

monkeypatch.setattr(settings, "base_url", "")
assert _abs_url(_Req(), "/static/og-banner.png") == (
"http://internal:8000/static/og-banner.png"
)


@pytest.mark.anyio
async def test_create_and_get_paste(client):
r = await client.post("/api/v1/pastes", json=_fake_paste())
Expand Down