Skip to content

✨ Serve attachments below /v1 so the API needs no hostname of its own - #472

Merged
0x46616c6b merged 4 commits into
mainfrom
feature/media-under-api
Aug 19, 2026
Merged

✨ Serve attachments below /v1 so the API needs no hostname of its own#472
0x46616c6b merged 4 commits into
mainfrom
feature/media-under-api

Conversation

@0x46616c6b

@0x46616c6b 0x46616c6b commented Aug 18, 2026

Copy link
Copy Markdown
Member

Goal

Remove the last reason the API needs a public hostname of its own.

Attachments were served at /media/<file>, outside the /v1 prefix, and their URLs were absolute, built from upload.url / TICKER_UPLOAD_URL. The /api/** -> /v1/** rewrite that the admin and frontend already perform therefore could not reach them, which is why every installation needed a third hostname, a third certificate and a variable whose only failure mode is "all images broken, everything else fine".

Nothing else required it. The bridges read attachments from disk, the feeds carry no attachment URLs, the manifest emits only relative values, and no URL is persisted anywhere — it is rebuilt on every response.

What changes

  • The route moves to GET /v1/media/:fileName. The root route is gone.
  • Attachment URLs are host-relative, /api/media/<file>. One response body works for both interfaces, because each resolves it against its own origin.
  • upload.url and TICKER_UPLOAD_URL are removed. A warning is logged when the variable is still set.
  • API_HOST disappears from both compose stacks and .env.example; two hostnames remain.
  • /healthz gets its own Traefik router on the frontend hostname. It lives outside /v1, and without a router the SPA fallback would answer it with index.html — an uptime monitor would then report healthy no matter what.
  • The admin and frontend services get TICKER_API_URL and a depends_on, which the new interface images need in order to render their own nginx config at start.

Security fix included

Because attachments now share an origin with the admin interface, which keeps its session token in localStorage, one existing weakness had to be closed in the same change:

The stored file extension came from the client-supplied filename and was never validated, while GIFs are written to disk unmodified. A valid GIF uploaded as evil.html was stored as <uuid>.html and served as text/html. On a throwaway API hostname that was contained; on the admin origin it is editor-to-super-admin session theft.

The extension is now derived from the detected content type — the allowlist and the extension map are the same thing — and media responses carry Content-Type from the database, X-Content-Type-Options: nosniff, a restrictive CSP and an inline Content-Disposition, which also neutralises rows created earlier. This incidentally fixes a panic on filenames without a dot, and a Cache-Control header that interpolated a Unix timestamp into max-age.

Breaking changes

  • TICKER_UPLOAD_URL is ignored. Remove it.
  • GET /media/<file> at the root is gone; the path is /v1/media/<file>, or /api/media/<file> through either interface.
  • The API hostname is no longer routed. Feeds move from https://api.example.org/v1/feed to https://ticker.example.org/api/feed, and ?origin= still lets a reader pin a ticker because the query parameter beats the proxy-injected header. Keep the old name resolving for a day after the upgrade: the frontend persists its query cache for up to 24 hours, so returning visitors may briefly request the old absolute image URLs.

Interface images built with the default relative /api keep working against this change without being updated. Only a build with an absolute TICKER_API_URL needs the companion PRs in ticker-admin and ticker-frontend.

Related

0x46616c6b and others added 4 commits August 18, 2026 21:55
Attachments were served at /media, outside the /v1 prefix, and their URLs were
absolute and built from upload.url. That is the only reason the API needed a
public hostname of its own: the /api/** -> /v1/** rewrite the admin and frontend
already perform could not reach /media.

Move the route to /v1/media/:fileName and return host-relative URLs of the form
/api/media/<file>. One response body now works for both interfaces, because each
resolves the URL against its own origin. Nothing persisted a URL, so no migration
is needed.

upload.url and TICKER_UPLOAD_URL are gone. A warning is logged when the variable
is still set. With the base URL removed, the config parameter of the response
constructors became unused and was dropped, and the two diverging MediaURL
helpers collapse into one in storage.

Attachments now share an origin with the interfaces, so the stored file extension
is derived from the detected content type instead of the client-supplied
filename. It decided how a browser interprets the response, was never validated,
and GIFs are stored unmodified — a GIF named "evil.html" was served as text/html.
The content type allowlist and the extension map are now the same thing, which
also fixes a panic on filenames without a dot.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Media is now served from the same origin as the admin interface, which keeps its
session token in localStorage. Set Content-Type from the database rather than
letting http.ServeFile infer it from the file extension, and add nosniff, a
restrictive CSP and an inline Content-Disposition. Rows created before the
extension was derived from the content type may still carry an arbitrary one, and
these headers neutralise them.

Also replace the Cache-Control value, which interpolated a Unix timestamp into
max-age, with a real 30 days. File names contain a UUID, so responses are
immutable.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two hostnames instead of three, and API_HOST is gone. /healthz lives outside /v1,
so it gets its own small router on the frontend hostname — without one the SPA
fallback would answer it with index.html and an uptime monitor would report
healthy no matter what. Traefik's own service health check is unaffected; it polls
the container directly.

Set TICKER_API_URL on the admin and frontend services. Their images render their
nginx config at start and refuse to boot without a value, and depends_on is
required because nginx resolves the upstream at config load.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Rework the architecture diagram, the installation guide and the configuration
reference for two hostnames, and rewrite the "images are broken" troubleshooting
entry: its old advice, that /api/media is expected to 404, is now exactly
backwards.

Also correct the integrations page, which claimed attachments are posted using
absolute TICKER_UPLOAD_URL links. Every bridge reads the bytes from disk, and
always did.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@sonarqubecloud

Copy link
Copy Markdown

@0x46616c6b
0x46616c6b marked this pull request as ready for review August 18, 2026 20:04
@0x46616c6b
0x46616c6b merged commit cf3213d into main Aug 19, 2026
8 checks passed
@0x46616c6b
0x46616c6b deleted the feature/media-under-api branch August 19, 2026 17:26
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant