✨ Serve attachments below /v1 so the API needs no hostname of its own - #472
Merged
Conversation
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>
This was referenced Aug 18, 2026
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.



Goal
Remove the last reason the API needs a public hostname of its own.
Attachments were served at
/media/<file>, outside the/v1prefix, and their URLs were absolute, built fromupload.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
GET /v1/media/:fileName. The root route is gone./api/media/<file>. One response body works for both interfaces, because each resolves it against its own origin.upload.urlandTICKER_UPLOAD_URLare removed. A warning is logged when the variable is still set.API_HOSTdisappears from both compose stacks and.env.example; two hostnames remain./healthzgets its own Traefik router on the frontend hostname. It lives outside/v1, and without a router the SPA fallback would answer it withindex.html— an uptime monitor would then report healthy no matter what.TICKER_API_URLand adepends_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.htmlwas stored as<uuid>.htmland served astext/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-Typefrom the database,X-Content-Type-Options: nosniff, a restrictive CSP and an inlineContent-Disposition, which also neutralises rows created earlier. This incidentally fixes a panic on filenames without a dot, and aCache-Controlheader that interpolated a Unix timestamp intomax-age.Breaking changes
TICKER_UPLOAD_URLis ignored. Remove it.GET /media/<file>at the root is gone; the path is/v1/media/<file>, or/api/media/<file>through either interface.https://api.example.org/v1/feedtohttps://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
/apikeep working against this change without being updated. Only a build with an absoluteTICKER_API_URLneeds the companion PRs inticker-adminandticker-frontend.Related