From 32f91e8c933eef389767232af9a892d4bcdbd568 Mon Sep 17 00:00:00 2001 From: Eli Mallon Date: Wed, 16 Sep 2026 04:31:01 -0700 Subject: [PATCH] web: a missing file is a 404, not the app shell The frontend fallback served index.html (200) for any path not in the bundle, including paths that are plainly files: a VOD blob or segment URL that reached the node's app handler, a hashed asset from an old deploy. Behind a CDN that is poison: the app page gets cached at the file's URL for as long as the CDN keeps a 200 (a month here), and the player then reads HTML as an init segment. Paths ending in a media, script, style, image, font or data extension that are not in the bundle now 404; app routes, including handles that end in a TLD, still get the shell (cherry picked from commit 2a36b8af864c14c02d7cf1b34d9b259bfced2180) --- pkg/api/api.go | 26 ++++++++++++++++++++++++++ pkg/api/asset_fallback_test.go | 18 ++++++++++++++++++ 2 files changed, 44 insertions(+) create mode 100644 pkg/api/asset_fallback_test.go diff --git a/pkg/api/api.go b/pkg/api/api.go index efe067d73..d95cb94f4 100644 --- a/pkg/api/api.go +++ b/pkg/api/api.go @@ -13,6 +13,7 @@ import ( "net/http/httputil" "net/url" "os" + "path/filepath" "regexp" "strconv" "strings" @@ -369,6 +370,22 @@ func (a *StreamplaceAPI) loadFrontends(ctx context.Context) (*frontendSet, error return &frontendSet{app: appHandler, web: webHandler}, nil } +// assetExtensions are the file types a browser or player asks for by name. +// A path ending in one of them that is not in the frontend bundle is a +// missing file, not an app route to serve the shell for. Handles are routes +// too and can end in a TLD (/live.example.eu), so this is a list, not "has an +// extension". +var assetExtensions = map[string]bool{ + ".mp4": true, ".m4s": true, ".m3u8": true, ".ts": true, ".webm": true, ".mp3": true, ".aac": true, ".ogg": true, + ".json": true, ".js": true, ".mjs": true, ".css": true, ".map": true, ".wasm": true, + ".png": true, ".jpg": true, ".jpeg": true, ".gif": true, ".webp": true, ".svg": true, ".ico": true, ".avif": true, + ".woff": true, ".woff2": true, ".ttf": true, ".otf": true, ".txt": true, ".xml": true, ".pdf": true, ".zip": true, +} + +func looksLikeAsset(path string) bool { + return assetExtensions[strings.ToLower(filepath.Ext(path))] +} + // buildLinkingHandler builds the static-file + link-card handler for a // single frontend. func (a *StreamplaceAPI) buildLinkingHandler(ctx context.Context, load func() (fs.FS, error)) (http.HandlerFunc, error) { @@ -423,6 +440,15 @@ func (a *StreamplaceAPI) notFoundLinkingHandler(ctx context.Context, linker *lin return } if errors.Is(err, ErrorIndex) || f == "" { + if looksLikeAsset(f) { + // A missing file, not an app route: a 404, so that a + // CDN in front of this node (a blob or segment URL that + // reached the app shell by mistake, a stale hashed + // asset) caches a short miss rather than the app page + // as a month-old "video". + apierrors.WriteHTTPNotFound(w, "file not found", nil) + return + } bs, err := linker.GenerateDefaultCard(ctx, req.URL, a.CLI.SentryDSN) if err != nil { log.Error(ctx, "error generating default card", "error", err) diff --git a/pkg/api/asset_fallback_test.go b/pkg/api/asset_fallback_test.go new file mode 100644 index 000000000..922ca90d4 --- /dev/null +++ b/pkg/api/asset_fallback_test.go @@ -0,0 +1,18 @@ +package api + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +// Missing files are 404s; app routes (including handles that end in a TLD) +// still get the app shell. +func TestLooksLikeAsset(t *testing.T) { + for _, p := range []string{"blobs/bafkr4ihdrzk.mp4", "blobs/bafkr4ihdrzk.json", "live/did:plc:x/1/42.m4s", "_expo/static/js/web/old-hash.js", "x.M3U8"} { + require.True(t, looksLikeAsset(p), p) + } + for _, p := range []string{"", "eli.bsky.social", "live.example.eu", "live.example.eu/video/3mvmy2nvqbmqv", "settings", "video", "docs/getting-started/"} { + require.False(t, looksLikeAsset(p), p) + } +} -- knotmirror 0.0.1