From 9280542b33175dd4517af1e454e142ca43ee4cbb Mon Sep 17 00:00:00 2001 From: Sulthan Zaki Date: Tue, 28 Jul 2026 17:45:59 +0700 Subject: [PATCH] feat(backend): serve userscript at token-protected path Reads the file per request from USERSCRIPT_PATH and rewrites its @version to an mtime-derived value, so Violentmonkey always sees a higher version after an edit regardless of what the file body claims. Also guards against ServeMux's own path-cleaning redirect turning an empty {token} segment into a 307 instead of the required 404. Co-Authored-By: Claude Opus 5 --- backend/main.go | 36 ++++++++-- backend/userscript.go | 60 +++++++++++++++++ backend/userscript_test.go | 132 +++++++++++++++++++++++++++++++++++++ 3 files changed, 222 insertions(+), 6 deletions(-) create mode 100644 backend/userscript.go create mode 100644 backend/userscript_test.go diff --git a/backend/main.go b/backend/main.go index 2706b78..323fa94 100644 --- a/backend/main.go +++ b/backend/main.go @@ -21,6 +21,9 @@ type Config struct { Port string // WebPassword gates the browser UI. Empty disables the web routes entirely. WebPassword string + // UserscriptPath is the file served at /u/{token}/manga-bookmark.user.js. + // Supplied by a bindmount so the script can be edited without a rebuild. + UserscriptPath string // LatestPoll configures the background latest-chapter fetcher. LatestPoll LatestPoll } @@ -128,11 +131,12 @@ func loadLatestPoll() LatestPoll { func loadConfig() Config { c := Config{ - Token: os.Getenv("API_TOKEN"), - DBPath: envOr("DB_PATH", "/data/bookmarks.db"), - Port: envOr("PORT", "8080"), - WebPassword: os.Getenv("WEB_PASSWORD"), - LatestPoll: loadLatestPoll(), + Token: os.Getenv("API_TOKEN"), + DBPath: envOr("DB_PATH", "/data/bookmarks.db"), + Port: envOr("PORT", "8080"), + WebPassword: os.Getenv("WEB_PASSWORD"), + UserscriptPath: envOr("USERSCRIPT_PATH", "/userscript/manga-bookmark.user.js"), + LatestPoll: loadLatestPoll(), } for _, o := range strings.Split(os.Getenv("ALLOWED_ORIGINS"), ",") { if o = strings.TrimSpace(o); o != "" { @@ -149,6 +153,11 @@ func newRouter(store *Store, cfg Config) http.Handler { mux := http.NewServeMux() mux.HandleFunc("GET /healthz", healthz) + // Outside withAuth (the updater sends no Authorization header) and outside + // the WEB_PASSWORD gate (the script must be installable either way). The + // path segment carries the token instead. + mux.HandleFunc("GET /u/{token}/manga-bookmark.user.js", userscriptHandler(cfg.Token, cfg.UserscriptPath)) + h := &bookmarkHandler{store: store} protected := http.NewServeMux() protected.HandleFunc("GET /bookmarks", h.list) @@ -170,7 +179,22 @@ func newRouter(store *Store, cfg Config) http.Handler { web.register(mux) } - return withCORS(cfg.AllowedOrigins, mux) + return withCORS(cfg.AllowedOrigins, guardEmptyUserscriptToken(mux)) +} + +// guardEmptyUserscriptToken heads off ServeMux's own path-cleaning redirect: +// an empty {token} segment makes the request path "/u//manga-bookmark.user.js", +// and ServeMux 307s that to "/u/manga-bookmark.user.js" before pattern +// matching ever runs. The endpoint's contract is 404 for any wrong token, +// including this one, so catch it ahead of the mux. +func guardEmptyUserscriptToken(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.HasPrefix(r.URL.Path, "/u//") { + http.NotFound(w, r) + return + } + next.ServeHTTP(w, r) + }) } func main() { diff --git a/backend/userscript.go b/backend/userscript.go new file mode 100644 index 0000000..bcb46cd --- /dev/null +++ b/backend/userscript.go @@ -0,0 +1,60 @@ +package main + +import ( + "crypto/subtle" + "log" + "net/http" + "os" + "regexp" + "time" +) + +// versionLine matches the userscript metadata block's @version directive. +var versionLine = regexp.MustCompile(`(?m)^// @version[ \t]+.*$`) + +// stampVersion replaces the served @version with one derived from the file's +// mtime, discarding whatever the file body says. +// +// Violentmonkey only updates when the served version sorts higher than the +// installed one. Deriving it from the body means one accidental downgrade or +// typo freezes updates forever; an mtime-derived version is monotonic by +// construction, so any later write always outranks any earlier one. +// +// A file with no @version line is returned untouched: such a script never +// auto-updates anyway, and inventing a metadata block is not this handler's job. +func stampVersion(src []byte, mod time.Time) []byte { + return versionLine.ReplaceAll(src, []byte("// @version "+mod.UTC().Format("2006.01.02.1504"))) +} + +// userscriptHandler serves the userscript to Violentmonkey's updater. +// +// The token lives in the path because the update poll sends no Authorization +// header, and the file embeds API_TOKEN in plain text, so an open path would +// hand that token to anyone who guessed the URL. A mismatch answers 404 rather +// than 401: a prober learns nothing about whether the route exists. +// +// The file is read per request — that is what lets a bindmounted copy be edited +// on the host without a restart. It is ~50 KB and polled about once a day. +func userscriptHandler(token, path string) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if subtle.ConstantTimeCompare([]byte(r.PathValue("token")), []byte(token)) != 1 { + http.NotFound(w, r) + return + } + info, err := os.Stat(path) + if err != nil { + log.Printf("userscript: stat %s: %v", path, err) + http.NotFound(w, r) + return + } + src, err := os.ReadFile(path) + if err != nil { + log.Printf("userscript: read %s: %v", path, err) + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "text/javascript; charset=utf-8") + w.Header().Set("Cache-Control", "no-cache") + w.Write(stampVersion(src, info.ModTime())) + } +} diff --git a/backend/userscript_test.go b/backend/userscript_test.go new file mode 100644 index 0000000..fec5b34 --- /dev/null +++ b/backend/userscript_test.go @@ -0,0 +1,132 @@ +package main + +import ( + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +// sampleScript is a stand-in for the real userscript: a metadata block with a +// @version line, plus a body that must survive the rewrite untouched. +const sampleScript = `// ==UserScript== +// @name Manga Bookmark Sync +// @version 1.5.0 +// @match https://asurascans.com/* +// ==/UserScript== +(function () { "use strict"; })(); +` + +// writeScript drops a userscript in a temp dir with a known mtime and returns +// its path plus the version string the handler is expected to stamp. +func writeScript(t *testing.T, body string) (path, wantVersion string) { + t.Helper() + path = filepath.Join(t.TempDir(), "manga-bookmark.user.js") + if err := os.WriteFile(path, []byte(body), 0o644); err != nil { + t.Fatalf("write script: %v", err) + } + mod := time.Date(2026, 7, 28, 16, 42, 0, 0, time.UTC) + if err := os.Chtimes(path, mod, mod); err != nil { + t.Fatalf("chtimes: %v", err) + } + return path, "2026.07.28.1642" +} + +func newUserscriptServer(t *testing.T, path string) http.Handler { + t.Helper() + store, err := OpenStore(filepath.Join(t.TempDir(), "test.db")) + if err != nil { + t.Fatalf("OpenStore: %v", err) + } + t.Cleanup(func() { store.Close() }) + cfg := testConfig() + cfg.UserscriptPath = path + return newRouter(store, cfg) +} + +func getScript(t *testing.T, srv http.Handler, token string) *httptest.ResponseRecorder { + t.Helper() + rr := httptest.NewRecorder() + srv.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/u/"+token+"/manga-bookmark.user.js", nil)) + return rr +} + +func TestUserscriptServedWithStampedVersion(t *testing.T) { + path, wantVersion := writeScript(t, sampleScript) + rr := getScript(t, newUserscriptServer(t, path), testToken) + + if rr.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", rr.Code) + } + if ct := rr.Header().Get("Content-Type"); !strings.HasPrefix(ct, "text/javascript") { + t.Errorf("Content-Type = %q, want text/javascript", ct) + } + if cc := rr.Header().Get("Cache-Control"); cc != "no-cache" { + t.Errorf("Cache-Control = %q, want no-cache", cc) + } + body := rr.Body.String() + if !strings.Contains(body, "// @version "+wantVersion) { + t.Errorf("body has no stamped version %q:\n%s", wantVersion, body) + } + if strings.Contains(body, "1.5.0") { + t.Errorf("body still carries the file's own version:\n%s", body) + } + // Everything outside the @version line is served verbatim. + if !strings.Contains(body, `(function () { "use strict"; })();`) { + t.Errorf("body was altered beyond the version line:\n%s", body) + } + if !strings.Contains(body, "// @name Manga Bookmark Sync") { + t.Errorf("metadata block was altered:\n%s", body) + } +} + +func TestUserscriptWrongTokenIs404(t *testing.T) { + path, _ := writeScript(t, sampleScript) + srv := newUserscriptServer(t, path) + for _, tok := range []string{"wrong", "", testToken + "x", testToken[:3]} { + if got := getScript(t, srv, tok).Code; got != http.StatusNotFound { + t.Errorf("token %q: status = %d, want 404", tok, got) + } + } +} + +func TestUserscriptMissingFileIs404(t *testing.T) { + srv := newUserscriptServer(t, filepath.Join(t.TempDir(), "absent.user.js")) + if got := getScript(t, srv, testToken).Code; got != http.StatusNotFound { + t.Fatalf("status = %d, want 404", got) + } +} + +func TestUserscriptWithoutVersionLineServedUnmodified(t *testing.T) { + const noVersion = "// ==UserScript==\n// @name x\n// ==/UserScript==\nconsole.log(1);\n" + path, _ := writeScript(t, noVersion) + rr := getScript(t, newUserscriptServer(t, path), testToken) + + if rr.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", rr.Code) + } + if rr.Body.String() != noVersion { + t.Fatalf("body = %q, want it unmodified", rr.Body.String()) + } +} + +// The endpoint must work on a deployment that never set WEB_PASSWORD, since +// the web routes are not registered at all in that case. +func TestUserscriptServedWithWebUIDisabled(t *testing.T) { + path, _ := writeScript(t, sampleScript) + store, err := OpenStore(filepath.Join(t.TempDir(), "nopass.db")) + if err != nil { + t.Fatalf("OpenStore: %v", err) + } + t.Cleanup(func() { store.Close() }) + cfg := testConfig() + cfg.WebPassword = "" + cfg.UserscriptPath = path + + if got := getScript(t, newRouter(store, cfg), testToken).Code; got != http.StatusOK { + t.Fatalf("status = %d, want 200", got) + } +}