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 <noreply@anthropic.com>
This commit is contained in:
2026-07-28 17:45:59 +07:00
parent 324b2efad6
commit 9280542b33
3 changed files with 222 additions and 6 deletions
+30 -6
View File
@@ -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() {