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 83fb445a23..2cd7ff0c6f 100644 --- a/backend/app/cmd/server_test.go +++ b/backend/app/cmd/server_test.go @@ -1494,6 +1494,24 @@ func TestServerCommand_servesAudience(t *testing.T) { 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) + } +} + // TestServerApp_UnsubscribeTokenIsNotALogin covers the token in unsubscribe links: it names the user // and their email, is signed with the shared secret and is valid for a century, so the email login // must not accept it in place of a confirmation token it issued itself. 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"