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
4 changes: 2 additions & 2 deletions .env.example
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
# Copy to .env and fill in. `docker compose up` aborts if a required value is missing.

# --- Public hostnames -------------------------------------------------------
# Each needs an A record (and please an AAAA record) pointing at this host.
# Two names, each with an A record (and please an AAAA record) pointing at this
# host. The API has none of its own; it is served under /api on both.
# FRONTEND_HOST is the domain your readers visit. It must additionally be
# registered as a Website/Origin on the ticker itself, in the admin interface.
API_HOST=api.ticker.example.org
ADMIN_HOST=admin.ticker.example.org
FRONTEND_HOST=ticker.example.org

Expand Down
23 changes: 17 additions & 6 deletions compose.dev.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,12 @@
#
# Admin: http://admin.ticker.localhost
# Frontend: http://ticker.localhost
# API: http://api.ticker.localhost
# Traefik: http://localhost:8081
#
# The API has no hostname of its own; it is reached through /api on both.
#
# Browsers resolve *.localhost to 127.0.0.1 themselves. If your setup does not,
# add the three names to /etc/hosts.
# add the two names to /etc/hosts.

name: ticker-dev

Expand Down Expand Up @@ -57,7 +58,6 @@ services:
TICKER_DATABASE_TYPE: "postgres"
TICKER_DATABASE_DSN: "host=postgres port=5432 user=ticker password=ticker dbname=ticker sslmode=disable TimeZone=UTC"
TICKER_UPLOAD_PATH: "/data/uploads"
TICKER_UPLOAD_URL: "http://api.ticker.localhost"
volumes:
- ticker-data:/data
healthcheck:
Expand All @@ -71,9 +71,10 @@ services:
- traefik.enable=true
- traefik.http.services.ticker.loadbalancer.server.port=8080

- traefik.http.routers.ticker-api.rule=Host(`api.ticker.localhost`)
- traefik.http.routers.ticker-api.entrypoints=web
- traefik.http.routers.ticker-api.service=ticker
- traefik.http.routers.ticker-health.rule=Host(`ticker.localhost`) && Path(`/healthz`)
- traefik.http.routers.ticker-health.priority=100
- traefik.http.routers.ticker-health.entrypoints=web
- traefik.http.routers.ticker-health.service=ticker

- traefik.http.middlewares.api-strip.stripprefix.prefixes=/api
- traefik.http.middlewares.api-v1.addprefix.prefix=/v1
Expand All @@ -94,6 +95,11 @@ services:

admin:
image: systemli/ticker-admin:latest
depends_on:
ticker:
condition: service_healthy
environment:
TICKER_API_URL: "http://ticker:8080/v1"
networks:
- default
labels:
Expand All @@ -104,6 +110,11 @@ services:

frontend:
image: systemli/ticker-frontend:latest
depends_on:
ticker:
condition: service_healthy
environment:
TICKER_API_URL: "http://ticker:8080/v1"
networks:
- default
labels:
Expand Down
32 changes: 23 additions & 9 deletions compose.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@
# /api/** to /v1/** on their own hostnames. It also injects the Origin header,
# which the API needs in order to know which ticker a request belongs to --
# browsers omit Origin on same-origin GET requests.
#
# The API has no hostname of its own. Attachments are served below /v1, so they
# arrive through the same /api path as every other request.

name: ticker

Expand Down Expand Up @@ -90,10 +93,6 @@ services:
TICKER_DATABASE_TYPE: "postgres"
TICKER_DATABASE_DSN: "host=postgres port=5432 user=ticker password=${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env} dbname=ticker sslmode=disable TimeZone=UTC"
TICKER_UPLOAD_PATH: "/data/uploads"
# Attachment URLs are absolute and built from this value. It must be the
# public API base -- no /v1, no trailing slash -- because /media is served
# by the API at its root.
TICKER_UPLOAD_URL: "https://${API_HOST:?set API_HOST in .env}"
volumes:
- ticker-data:/data
networks:
Expand All @@ -112,11 +111,15 @@ services:
- traefik.http.services.ticker.loadbalancer.healthcheck.path=/healthz
- traefik.http.services.ticker.loadbalancer.healthcheck.interval=30s

# --- Public API host: /v1/**, /media/**, /healthz, feeds ---
- traefik.http.routers.ticker-api.rule=Host(`${API_HOST}`)
- traefik.http.routers.ticker-api.entrypoints=websecure
- traefik.http.routers.ticker-api.tls.certresolver=le
- traefik.http.routers.ticker-api.service=ticker
# --- /healthz, for uptime monitoring ---
# It lives outside /v1, so the /api rewrite below cannot reach it. Without
# its own router the frontend's SPA fallback would answer with index.html
# and a monitor would report healthy no matter what.
- traefik.http.routers.ticker-health.rule=Host(`${FRONTEND_HOST}`) && Path(`/healthz`)
- traefik.http.routers.ticker-health.priority=100
- traefik.http.routers.ticker-health.entrypoints=websecure
- traefik.http.routers.ticker-health.tls.certresolver=le
- traefik.http.routers.ticker-health.service=ticker

# --- Shared /api/** -> /v1/** rewrite, applied in this order ---
- traefik.http.middlewares.api-strip.stripprefix.prefixes=/api
Expand Down Expand Up @@ -145,6 +148,13 @@ services:
admin:
image: systemli/ticker-admin:${ADMIN_TAG:-latest}
restart: unless-stopped
depends_on:
- ticker
environment:
# The image renders its nginx config at start and proxies /api itself. In
# this stack Traefik gets there first, but nginx refuses to start without
# the value.
TICKER_API_URL: "http://ticker:8080/v1"
networks:
- proxy
labels:
Expand All @@ -158,6 +168,10 @@ services:
frontend:
image: systemli/ticker-frontend:${FRONTEND_TAG:-latest}
restart: unless-stopped
depends_on:
- ticker
environment:
TICKER_API_URL: "http://ticker:8080/v1"
networks:
- proxy
labels:
Expand Down
6 changes: 2 additions & 4 deletions config.yml.dist
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,6 @@ secret: ""
# listen address for the prometheus metrics exporter
metrics_listen: ":8181"
upload:
# path where uploaded files are stored
# path where uploaded files are stored. Attachment links are host-relative,
# so there is nothing else to configure here.
path: "uploads"
# public base URL of this API, used to build attachment links.
# No "/v1" and no trailing slash.
url: "http://localhost:8080"
12 changes: 6 additions & 6 deletions docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,12 @@
[route definitions](https://github.com/systemli/ticker/blob/main/internal/api/api.go) are the
authoritative reference for it.

All endpoints are served under the `/v1` prefix, with two exceptions that live at the root:
All endpoints are served under the `/v1` prefix, including `GET /v1/media/{file}` for uploaded
attachments. The only exception is `GET /healthz`, which lives at the root.

| Endpoint | Purpose |
| --- | --- |
| `GET /media/{file}` | uploaded attachments |
| `GET /healthz` | health check |
Attachment URLs in responses are **relative to the site that served them**, of the form
`/api/media/{file}` — that is the path the admin and frontend expose the API under. When you talk to
the API directly, replace `/api` with `/v1`.

## Identifying a ticker

Expand All @@ -24,7 +24,7 @@ Clients that do not send an `Origin` header — RSS readers, scripts — can pas
query parameter, which takes precedence:

```shell
curl 'https://api.ticker.example.org/v1/feed?origin=https://ticker.example.org'
curl 'https://ticker.example.org/api/feed?origin=https://ticker.example.org'
```

Requests that match no ticker return HTTP 200 with a `ticker not found` error body, or, for `/init`,
Expand Down
29 changes: 13 additions & 16 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,6 @@ For container deployments, environment variables alone are usually enough.
| `database.dsn` | `TICKER_DATABASE_DSN` | `ticker.db` | Connection string, see below. |
| `metrics_listen` | `TICKER_METRICS_LISTEN` | `:8181` | Address for the Prometheus exporter, on a separate listener. |
| `upload.path` | `TICKER_UPLOAD_PATH` | `uploads` | Directory for uploaded files. |
| `upload.url` | `TICKER_UPLOAD_URL` | `http://localhost:8080` | Public base URL used to build attachment links. |

That is the complete list. There is no environment variable for any setting not named above.

Expand Down Expand Up @@ -110,33 +109,31 @@ The schema is migrated automatically at startup; there is no separate migrate co

## Uploads

Two settings work together:

- `TICKER_UPLOAD_PATH` is where files are written. It must be a **persistent, writable** directory,
otherwise attachments are lost when the container is replaced while the database still references
them.
- `TICKER_UPLOAD_URL` is the **public base URL of the API**, and is used to build absolute
attachment links of the form `<TICKER_UPLOAD_URL>/media/<file>`.
There is one setting: `TICKER_UPLOAD_PATH`, the directory files are written to. It must be
**persistent and writable**, otherwise attachments are lost when the container is replaced while the
database still references them.

```shell
TICKER_UPLOAD_PATH=/data/uploads
TICKER_UPLOAD_URL=https://api.ticker.example.org
```

`TICKER_UPLOAD_URL` must be the API's own public hostname, with **no `/v1`** and **no trailing
slash**. Media is served by the API at its root, so:
Nothing else needs configuring. Attachments are served at `/v1/media/<file>` and the URLs in API
responses are relative — `/api/media/<file>`, resolved against whichever site served the response.
So the same response works for both interfaces, and the API needs no public address of its own.

- `https://api.ticker.example.org/v1` produces `…/v1/media/x`, which is a 404.
- Pointing it at the frontend produces links the frontend answers with its own HTML page rather
than an image.
!!! note "`TICKER_UPLOAD_URL` was removed"

These URLs are generated per response rather than stored, so correcting the value fixes existing
messages too, once the response cache expires.
Earlier versions built absolute attachment links from it. It is ignored now; the API logs a
warning when it is still set so you can drop it from your environment.

Uploads accept `image/jpeg`, `image/gif` and `image/png` only, and the API rejects request bodies
over 10 MB. If a reverse proxy in front of it imposes a smaller limit, uploads fail there first —
nginx defaults to 1 MB, for instance.

The stored file extension is derived from the detected content type, not from the uploaded filename,
and media responses carry `Content-Type` from the database plus `X-Content-Type-Options: nosniff`.
That matters because attachments share an origin with the admin interface.

## Metrics

Prometheus metrics are served on a **separate** listener, `metrics_listen` (`:8181` by default), at
Expand Down
20 changes: 12 additions & 8 deletions docs/development.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,10 @@ docker compose -f compose.dev.yaml up -d --build
| --- | --- |
| <http://ticker.localhost> | public frontend |
| <http://admin.ticker.localhost> | admin interface |
| <http://api.ticker.localhost> | API |
| <http://localhost:8081> | Traefik dashboard |

The API has no address of its own; it answers under `/api` on both hostnames.

Create a user, then set up a ticker:

```shell
Expand Down Expand Up @@ -123,17 +124,20 @@ npm run dev
| ticker-admin | <http://localhost:3000> |
| ticker-frontend | <http://localhost:4000> |

Point them at an API with a `.env` file. The variable must include the `/v1` suffix:
Both dev servers proxy `/api` to `http://localhost:8080/v1`, so run the API alongside them with
`go run . run` and nothing needs configuring.

```shell title=".env"
TICKER_API_URL=http://localhost:8080/v1
```
!!! warning "Delete a leftover `.env`"

`TICKER_API_URL` overrides the proxy with an absolute address. Requests then work but attachment
images do not, because their URLs are relative and resolve against the dev server instead. The
file is gitignored, so an old one may still be sitting in your checkout.

!!! warning "Register the dev server's own origin"

With an absolute `TICKER_API_URL`, the browser sends the **dev server's** address as `Origin`.
For the public frontend that means the ticker needs `http://localhost:4000` registered under its
websites, or you will only ever see the inactive page. The admin interface is unaffected.
The proxy sends the **dev server's** address as `Origin`. For the public frontend that means the
ticker needs `http://localhost:4000` registered under its websites, or you will only ever see
the inactive page. The admin interface is unaffected.

Other commands, in both repositories:

Expand Down
36 changes: 17 additions & 19 deletions docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ A complete installation is three services, published as three Docker images:

| Component | Image | Role |
| --- | --- | --- |
| [ticker](https://github.com/systemli/ticker) | `systemli/ticker` | The API. Stores everything, serves the public endpoints and media, dispatches to integrations. |
| [ticker](https://github.com/systemli/ticker) | `systemli/ticker` | The API. Stores everything, serves the public endpoints and media, dispatches to integrations. It needs no public hostname of its own. |
| [ticker-admin](https://github.com/systemli/ticker-admin) | `systemli/ticker-admin` | Admin interface. Editors log in here to manage tickers, messages and users. |
| [ticker-frontend](https://github.com/systemli/ticker-frontend) | `systemli/ticker-frontend` | The public page your readers visit. |

Expand All @@ -26,28 +26,26 @@ data of their own.
## How a request flows

```
┌─────────────────────────────┐
readers ────────────▶│ ticker.example.org │
│ ticker-frontend (SPA) │
│ /api/** ──────────────────┼──┐
└─────────────────────────────┘ │
┌─────────────────────────────┐ │ ┌──────────────┐
editors ────────────▶│ admin.ticker.example.org │ ├──▶│ ticker │
│ ticker-admin (SPA) │ │ │ (API) │
│ /api/** ──────────────────┼──┤ │ │
└─────────────────────────────┘ │ └──────┬───────┘
│ │
┌─────────────────────────────┐ │ ┌──────▼───────┐
feeds, media ───────▶│ api.ticker.example.org │──┘ │ PostgreSQL │
│ /v1/**, /media/** │ └──────────────┘
└─────────────────────────────┘
readers ┌─────────────────────────────┐
feeds, ────────▶│ ticker.example.org │
media │ ticker-frontend (SPA) │
│ /api/** ──────────────────┼──┐
└─────────────────────────────┘ │ ┌──────────────┐
├─────▶│ ticker │
┌─────────────────────────────┐ │ │ (API) │
editors ───────▶│ admin.ticker.example.org │ │ │ │
│ ticker-admin (SPA) │ │ └──────┬───────┘
│ /api/** ──────────────────┼──┘ │
└─────────────────────────────┘ ┌──────▼───────┐
│ PostgreSQL │
└──────────────┘
```

Two details of this shape matter, and explain most of the configuration:

- **The API needs its own public hostname.** Attachment URLs are absolute and served by the API
at `/media/...`, outside the `/v1` prefix. RSS readers also fetch feeds directly.
- **The API has no public hostname of its own.** Everything it serves — the public endpoints,
attachments, RSS feeds — lives below `/v1`, and both interfaces expose that as `/api` on their own
address. Attachment URLs in API responses are relative for the same reason.
- **The API works out which ticker a request is for from the browser's `Origin` header.** That is
why the public frontend's address must be registered on the ticker itself, and why the reverse
proxy has to pass a correct `Origin` along. See [Installation](installation.md).
Expand Down
Loading