From bbd73272c06593a43c2c2da2e9645b0a0f677ed0 Mon Sep 17 00:00:00 2001 From: Dmitry Verkhoturov Date: Sat, 10 Oct 2026 23:45:59 +0200 Subject: [PATCH 1/3] Fill the configured site into the demo pages the binary serves The build emits the demo, counter and last-comments pages for site remark. The docker image replaces that with the first configured site at container start, but the binary served them as built, so on an instance serving another site the demo's API requests failed with "site not found" and its widget asked about a site the reader was not signed in to. The file server now fills in the first configured site the same way, in the html files the docker image rewrites, and the site is part of the cache validator so a changed SITE is not answered with 304. The release build fails if those pages lose the site_id:"remark" literal the server replaces and the docker image's pattern expects. --- backend/app/cmd/server.go | 13 +++++ backend/app/cmd/server_test.go | 18 +++++++ backend/app/rest/api/rest.go | 14 ++--- backend/app/rest/api/rest_test.go | 2 +- backend/app/rest/api/webfiles.go | 21 ++++++++ backend/app/rest/api/webfiles_test.go | 73 ++++++++++++++++++++++++++- scripts/prepare-release-assets.sh | 10 ++++ 7 files changed, 142 insertions(+), 9 deletions(-) diff --git a/backend/app/cmd/server.go b/backend/app/cmd/server.go index 8892449bcd..960e4660eb 100644 --- a/backend/app/cmd/server.go +++ b/backend/app/cmd/server.go @@ -741,6 +741,7 @@ func (s *ServerCommand) newServerApp(ctx context.Context) (*serverApp, error) { WebRoot: s.WebRoot, WebFS: webFS, RemarkURL: s.RemarkURL, + DemoSiteID: s.demoSiteID(), ImageProxy: imgProxy, CommentFormatter: commentFormatter, Migrator: migr, @@ -1558,6 +1559,18 @@ func (s *ServerCommand) servesAudience(auds []string) bool { return true } +// demoSiteID is the site the demo pages under /web show: the first configured one, as the docker +// image picks it from SITE. It is returned exactly as configured, since the stores register it +// that way and look it up by exact match +func (s *ServerCommand) demoSiteID() string { + for _, site := range s.Sites { + if strings.TrimSpace(site) != "" { + return site + } + } + return "" +} + func (s *ServerCommand) parseSameSite(ss string) http.SameSite { switch strings.ToLower(ss) { case "default": diff --git a/backend/app/cmd/server_test.go b/backend/app/cmd/server_test.go index 36d7a2ffe1..65ab6b7b28 100644 --- a/backend/app/cmd/server_test.go +++ b/backend/app/cmd/server_test.go @@ -1491,3 +1491,21 @@ func TestServerCommand_servesAudience(t *testing.T) { cmd.Store.Type = "rpc" assert.True(t, cmd.servesAudience([]string{"Remark"}), "a remote store decides which sites exist") } + +func TestServerCommand_demoSiteID(t *testing.T) { + tbl := []struct { + sites []string + want string + }{ + {[]string{"remark"}, "remark"}, + {[]string{"myblog", "other"}, "myblog"}, + // the stores register a site exactly as given, so the demo pages must name it the same way + {[]string{" myblog "}, " myblog "}, + {[]string{" ", "second"}, "second"}, + {[]string{"", "second"}, "second"}, + {nil, ""}, + } + for _, tt := range tbl { + assert.Equal(t, tt.want, (&ServerCommand{Sites: tt.sites}).demoSiteID(), "sites %q", tt.sites) + } +} diff --git a/backend/app/rest/api/rest.go b/backend/app/rest/api/rest.go index 15ad5a645c..4ef70a6d8d 100644 --- a/backend/app/rest/api/rest.go +++ b/backend/app/rest/api/rest.go @@ -47,6 +47,7 @@ type Rest struct { WebRoot string WebFS fs.FS RemarkURL string + DemoSiteID string // site the demo pages under /web show, the first configured one ReadOnlyAge int SharedSecret string TrustedProxies []*net.IPNet // reverse-proxy networks whose forwarding headers (X-Real-IP, X-Forwarded-For, ...) are trusted @@ -398,7 +399,7 @@ func (s *Rest) routes() http.Handler { log.Printf("[WARN] no embedded frontend, serving built-in assets only: %v", err) embeddedFrontend = emptyFS{} } - addFileServer(router, embeddedFrontend, s.WebRoot, s.Version, s.RemarkURL) + addFileServer(router, embeddedFrontend, s.WebRoot, s.Version, s.RemarkURL, s.DemoSiteID) return router } @@ -514,7 +515,7 @@ func (s *Rest) configCtrl(w http.ResponseWriter, r *http.Request) { // serves /web from the frontend build, falling back to the assets embedded in the binary for // names the build does not produce. the frontend build is read from webRoot on disk, or from the // copy embedded at app/cmd/web when that directory is absent. -func addFileServer(r *routegroup.Bundle, embeddedFrontend fs.FS, webRoot, version, remarkURL string) { +func addFileServer(r *routegroup.Bundle, embeddedFrontend fs.FS, webRoot, version, remarkURL, siteID string) { frontendFS := embeddedFrontend if _, err := os.Stat(webRoot); err == nil { @@ -529,17 +530,18 @@ func addFileServer(r *routegroup.Bundle, embeddedFrontend fs.FS, webRoot, versio sources := templatedFS{ fs: webFiles{frontend: frontendFS, embedded: webassets.FS}, remarkURL: remarkURL, + siteID: siteID, } webFS := http.StripPrefix("/web", http.FileServer(http.FS(sources))) r.HandleFunc("GET /web", http.RedirectHandler("/web/", http.StatusMovedPermanently).ServeHTTP) r.With(rateLimiter(20), R.Timeout(10*time.Second), - // the served body now depends on remarkURL, so it has to be part of the validator. Without - // it an operator who corrects a wrong REMARK_URL and restarts the same binary keeps getting - // 304 on revalidation, and the client keeps a bundle addressed to the old host for good, + // the served body now depends on remarkURL and the demo site, so both have to be part of the + // validator. Without them an operator who corrects REMARK_URL or SITE and restarts the same + // binary keeps getting 304 on revalidation, and the client keeps the old page for good, // since no-cache means it revalidates rather than aging out - cacheControl(time.Hour, version+":"+remarkURL), + cacheControl(time.Hour, version+":"+remarkURL+":"+siteID), ).HandleFunc("GET /web/", func(w http.ResponseWriter, r *http.Request) { // don't show dirs, just serve files if strings.HasSuffix(r.URL.Path, "/") && len(r.URL.Path) > 1 && r.URL.Path != ("/web/") { diff --git a/backend/app/rest/api/rest_test.go b/backend/app/rest/api/rest_test.go index d347b2b301..0d134e12ab 100644 --- a/backend/app/rest/api/rest_test.go +++ b/backend/app/rest/api/rest_test.go @@ -189,7 +189,7 @@ func TestRest_FileServerBackendAssets(t *testing.T) { func TestRest_FileServerEmbeddedFrontend(t *testing.T) { frontend := fstest.MapFS{"index.html": {Data: []byte("embedded frontend index")}} router := routegroup.New(http.NewServeMux()) - addFileServer(router, frontend, filepath.Join(t.TempDir(), "absent"), "test-version", "https://remark.example.com") + addFileServer(router, frontend, filepath.Join(t.TempDir(), "absent"), "test-version", "https://remark.example.com", "") ts := httptest.NewServer(router) defer ts.Close() diff --git a/backend/app/rest/api/webfiles.go b/backend/app/rest/api/webfiles.go index 69595e8eff..261ee6cdda 100644 --- a/backend/app/rest/api/webfiles.go +++ b/backend/app/rest/api/webfiles.go @@ -2,6 +2,7 @@ package api import ( "bytes" + "encoding/json" "errors" "io" "io/fs" @@ -77,12 +78,19 @@ func (emptyFS) Open(name string) (fs.File, error) { // serves the build embedded in itself and has nothing to rewrite, does it here. const remarkURLPlaceholder = "{% REMARK_URL %}" +// demoSiteLiteral is how the built demo, counter and last-comments pages name their site. The +// docker image replaces it with the first configured site at container start; the binary does it +// here, since a site the instance does not serve makes those pages fail and, signed in or not, ask +// the API about a site the reader's session does not belong to. +const demoSiteLiteral = `site_id:"remark"` + // templatedFS fills the instance URL into the files carrying the placeholder. Without it the // binary serves whatever the build baked in, which is a host no visitor can reach, and the widget // falls back to it whenever a page omits remark_config.host. type templatedFS struct { fs fs.FS remarkURL string + siteID string // first configured site, filled into the demo pages; empty leaves them as built } // Open substitutes in the file types the frontend templates, and hands everything else through @@ -107,9 +115,22 @@ func (t templatedFS) Open(name string) (fs.File, error) { } body = bytes.ReplaceAll(body, []byte(remarkURLPlaceholder), []byte(t.remarkURL)) + if t.siteID != "" && filepath.Ext(name) == ".html" { + body = bytes.ReplaceAll(body, []byte(demoSiteLiteral), demoSiteValue(t.siteID)) + } return &memFile{Reader: bytes.NewReader(body), info: sizedInfo{FileInfo: info, size: int64(len(body))}}, nil } +// demoSiteValue renders the site as the demo pages' literal does. json.Marshal quotes it as a +// script string and escapes <, > and &, so no site id can end the string or the script block +func demoSiteValue(siteID string) []byte { + quoted, err := json.Marshal(siteID) + if err != nil { // a string always marshals; keep the build's own literal if that ever changes + return []byte(demoSiteLiteral) + } + return append([]byte("site_id:"), quoted...) +} + // templatedName reports whether the frontend templates this file type. It mirrors the set the // docker image rewrites, so both distributions substitute in the same files func templatedName(name string) bool { diff --git a/backend/app/rest/api/webfiles_test.go b/backend/app/rest/api/webfiles_test.go index 402d22134b..0660835691 100644 --- a/backend/app/rest/api/webfiles_test.go +++ b/backend/app/rest/api/webfiles_test.go @@ -266,6 +266,52 @@ func TestTemplatedFS_SubstitutesTheInstanceURL(t *testing.T) { } } +// TestTemplatedFS_FillsTheDemoSite covers the demo, counter and last-comments pages, which the build +// emits for site "remark". The docker image rewrites that literal in html files at container start, +// and the binary has to do the same, or an instance serving another site answers those pages with +// "site not found" and their widget asks the API about a site the reader is not signed in to. +func TestTemplatedFS_FillsTheDemoSite(t *testing.T) { + const built = `var remark_config={site_id:"remark",host:"` + remarkURLPlaceholder + `"}` + source := fstest.MapFS{ + "index.html": {Data: []byte(built)}, + "counter.mjs": {Data: []byte(built)}, + "custom.html": {Data: []byte(`var remark_config={site_id:"mine"}`)}, + } + + tbl := []struct { + name, file, siteID, want string + }{ + {"html gets the site", "index.html", "myblog", + `var remark_config={site_id:"myblog",host:"https://remark.example.com"}`}, + {"no site leaves the build's", "index.html", "", + `var remark_config={site_id:"remark",host:"https://remark.example.com"}`}, + {"the site cannot end the string or the script", "index.html", `x"