Files
remark42/backend/app/rest/api/webfiles.go
Dmitry VerkhoturovandGitHub 4793c1cd2c Fill the instance URL into the embedded frontend at serve time, and stop pinning compressor output in tests (#2198)
* Assert what the image endpoints promise rather than the compressor's output

Three tests pinned the exact bytes or the exact length of an encoded
image, so they fail on any toolchain whose deflate or png encoder emits
something different. CI pins go 1.25 and passes; go 1.27 fails all three,
while the images themselves are perfectly valid.

TestRest_QR now decodes both the golden file and the response and
compares the pixels, which is the same assertion about the qr code and
none about the encoder. The two resize cases assert the decoded image
fits the box resize was given and touches one of its sides, which is what
fitting to a box means and what the function actually promises.

Resolves #2200.

* Fill the instance URL into the embedded frontend at serve time

The widget falls back to a compiled-in URL whenever a page omits
`remark_config.host`. The bundler cannot know that URL, so it emits
`{% REMARK_URL %}` and each distribution substitutes it: the docker image
rewrites the files under its web root at container start, and the release
binary, which serves the build embedded in itself, had nothing doing it.
`prepare-release-assets.sh` filled the marker with `http://127.0.0.1:8080`
before the embed instead, so every copy of the binary shipped pointing at
the visitor's own loopback address, and on an https site the request is
blocked as mixed content besides.

It has been that way since v1.11.0, the first release to embed the
frontend, and the earlier binaries embedded none, so the tarball has never
served a correctly addressed widget.

The placeholder now survives into the embedded copy and the file server
fills it with the configured `REMARK_URL` as it serves, which is what the
docker image already does to its own copy. The image no longer bakes the
loopback address into its embedded copy either, so the fallback it keeps
for a missing web root is correct rather than misleading.

Substituted in html, js and mjs, the same set `docker-init.sh` rewrites,
and the served size is the substituted one so a response is neither
truncated nor left hanging.

Nothing exercised the marker the frontend build emits wherever the instance
url belongs. Every page in the suite sets `remark_config.host` from its own
origin, so the compiled-in fallback is never read, and a distribution that
stopped substituting would keep the suite green.

Two tests. The first reads the served bundles and pages back and asserts the
marker is gone from each and that what replaced it is this instance. The
second covers what the substitution is for: the widget document carries no
host of its own, since `iframe.html` builds its config from a query string the
parent never puts one in, so everything it requests is addressed with the
compiled-in url. It asserts the widget renders and that the config request
went to this instance.

The demo pages cannot show the second. Their loader builds the bundle's own
script url from `remark_config.host`, so a page without one never gets as far
as loading the widget.

Verified by disabling both substitution paths, the serve-time one and the
docker image's, and rebuilding: both tests fail. Editing the files on disk is
not enough, since the file server substitutes as it serves.

The served body now depends on remarkURL, but cacheControl builds its
etag from version and path only. An operator who notices the widget is
addressed to the wrong host, corrects REMARK_URL and restarts the same
binary gets 304 on revalidation, so the client keeps a bundle pointing
at the old host. Cache-Control is no-cache, so it revalidates every time
and never ages out of that state either.

That is the exact situation this substitution exists to fix, so the
validator has to carry the url.
2026-08-22 13:15:27 -05:00

141 lines
4.6 KiB
Go

package api
import (
"bytes"
"errors"
"io"
"io/fs"
"path/filepath"
"strings"
)
// webFiles serves /web from two sources: a name present in the frontend build is served from there,
// and any other name from the assets embedded in the binary.
type webFiles struct {
frontend fs.FS
embedded fs.FS
}
// Open resolves the name against both sources, and answers a missing .js with the .mjs sibling.
// The build stopped emitting .js while integrations still request it; the bundles carry no module
// syntax, so the same bytes serve both names.
func (w webFiles) Open(name string) (fs.File, error) {
// fs.ValidPath alone is not enough: it accepts names the operating system rejects, NUL among
// them, and os.DirFS turns those into fs.ErrInvalid, which renders as 500 rather than 404
if _, err := filepath.Localize(name); err != nil || !fs.ValidPath(name) {
return nil, &fs.PathError{Op: "open", Path: name, Err: fs.ErrNotExist}
}
f, err := w.open(name)
if err == nil {
return f, nil
}
if !errors.Is(err, fs.ErrNotExist) || !strings.HasSuffix(name, ".js") {
return nil, err
}
alias, aliasErr := w.open(strings.TrimSuffix(name, ".js") + ".mjs")
if aliasErr == nil {
return alias, nil
}
if !errors.Is(aliasErr, fs.ErrNotExist) {
return nil, aliasErr
}
return nil, err
}
// open looks the name up in the frontend build first. Only a missing file falls through to the
// embedded assets; every other error is returned so an unreadable file keeps reporting as one
// rather than being replaced by the embedded copy or reported as missing.
func (w webFiles) open(name string) (fs.File, error) {
f, err := w.frontend.Open(name)
if err == nil {
return f, nil
}
if !errors.Is(err, fs.ErrNotExist) {
return nil, err
}
if name == "." {
// the embedded set is a flat list of files; only the frontend build answers for the
// directory itself, so a missing web root reports as missing rather than listing them
return nil, err
}
return w.embedded.Open(name)
}
// emptyFS stands in for a frontend source that could not be opened, so a misconfigured one serves
// nothing instead of panicking or serving the build at paths it does not belong at
type emptyFS struct{}
func (emptyFS) Open(name string) (fs.File, error) {
return nil, &fs.PathError{Op: "open", Path: name, Err: fs.ErrNotExist}
}
// remarkURLPlaceholder is what the frontend build carries wherever the instance URL belongs. The
// bundler cannot know that URL, so it emits this marker and every distribution fills it in: the
// docker image rewrites the files under the web root at container start, and the binary, which
// serves the build embedded in itself and has nothing to rewrite, does it here.
const remarkURLPlaceholder = "{% REMARK_URL %}"
// 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
}
// Open substitutes in the file types the frontend templates, and hands everything else through
// untouched so images and stylesheets keep streaming from their original source
func (t templatedFS) Open(name string) (fs.File, error) {
f, err := t.fs.Open(name)
if err != nil || !templatedName(name) {
return f, err
}
info, err := f.Stat()
if err != nil || info.IsDir() {
return f, err
}
body, err := io.ReadAll(f)
if cerr := f.Close(); err == nil {
err = cerr
}
if err != nil {
return nil, err
}
body = bytes.ReplaceAll(body, []byte(remarkURLPlaceholder), []byte(t.remarkURL))
return &memFile{Reader: bytes.NewReader(body), info: sizedInfo{FileInfo: info, size: int64(len(body))}}, nil
}
// 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 {
switch filepath.Ext(name) {
case ".html", ".js", ".mjs":
return true
}
return false
}
// memFile is a substituted file held in memory. The file server needs a seeker to answer range
// requests and to sniff a content type, which a substituted body no longer has on disk
type memFile struct {
*bytes.Reader
info fs.FileInfo
}
func (f *memFile) Stat() (fs.FileInfo, error) { return f.info, nil }
func (f *memFile) Close() error { return nil }
// sizedInfo reports the length after substitution. The file server writes Content-Length from it,
// so reporting the length on disk would truncate the response or leave the client waiting
type sizedInfo struct {
fs.FileInfo
size int64
}
func (i sizedInfo) Size() int64 { return i.size }