Serve the userscript from the backend; card + loading fixes #8
+30
-6
@@ -21,6 +21,9 @@ type Config struct {
|
|||||||
Port string
|
Port string
|
||||||
// WebPassword gates the browser UI. Empty disables the web routes entirely.
|
// WebPassword gates the browser UI. Empty disables the web routes entirely.
|
||||||
WebPassword string
|
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 configures the background latest-chapter fetcher.
|
||||||
LatestPoll LatestPoll
|
LatestPoll LatestPoll
|
||||||
}
|
}
|
||||||
@@ -128,11 +131,12 @@ func loadLatestPoll() LatestPoll {
|
|||||||
|
|
||||||
func loadConfig() Config {
|
func loadConfig() Config {
|
||||||
c := Config{
|
c := Config{
|
||||||
Token: os.Getenv("API_TOKEN"),
|
Token: os.Getenv("API_TOKEN"),
|
||||||
DBPath: envOr("DB_PATH", "/data/bookmarks.db"),
|
DBPath: envOr("DB_PATH", "/data/bookmarks.db"),
|
||||||
Port: envOr("PORT", "8080"),
|
Port: envOr("PORT", "8080"),
|
||||||
WebPassword: os.Getenv("WEB_PASSWORD"),
|
WebPassword: os.Getenv("WEB_PASSWORD"),
|
||||||
LatestPoll: loadLatestPoll(),
|
UserscriptPath: envOr("USERSCRIPT_PATH", "/userscript/manga-bookmark.user.js"),
|
||||||
|
LatestPoll: loadLatestPoll(),
|
||||||
}
|
}
|
||||||
for _, o := range strings.Split(os.Getenv("ALLOWED_ORIGINS"), ",") {
|
for _, o := range strings.Split(os.Getenv("ALLOWED_ORIGINS"), ",") {
|
||||||
if o = strings.TrimSpace(o); o != "" {
|
if o = strings.TrimSpace(o); o != "" {
|
||||||
@@ -149,6 +153,11 @@ func newRouter(store *Store, cfg Config) http.Handler {
|
|||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
mux.HandleFunc("GET /healthz", healthz)
|
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}
|
h := &bookmarkHandler{store: store}
|
||||||
protected := http.NewServeMux()
|
protected := http.NewServeMux()
|
||||||
protected.HandleFunc("GET /bookmarks", h.list)
|
protected.HandleFunc("GET /bookmarks", h.list)
|
||||||
@@ -170,7 +179,22 @@ func newRouter(store *Store, cfg Config) http.Handler {
|
|||||||
web.register(mux)
|
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() {
|
func main() {
|
||||||
|
|||||||
@@ -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()))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user