Serve the userscript from the backend; card + loading fixes (#8)
Serves the userscript from the backend so Violentmonkey auto-updates it, plus two panel fixes.
## Backend: `GET /u/{token}/manga-bookmark.user.js`
The script is read off disk per request from `USERSCRIPT_PATH` and streamed back with its `@version` line rewritten.
- **Token in the path, not a header.** Violentmonkey's update poll sends no `Authorization` header, and the script embeds `API_TOKEN` in plain text — an open URL would hand that token to anyone who guessed it. Compare is constant-time.
- **404, never 401**, for both a wrong token and a missing file: a prober learns nothing about whether the route exists.
- Registered outside `withAuth` and outside the `WEB_PASSWORD` gate, so the script is installable on a deployment that never enabled the web UI.
- Stdlib only (`crypto/subtle`, `os`, `regexp`) — no new Go dependencies.
**The served `@version` is derived from the file's mtime** (`YYYY.MM.DD.HHMM`, UTC), discarding whatever the file body says. Violentmonkey only updates when the served version sorts higher than the installed one, so a body-derived version means one typo or accidental downgrade freezes updates forever. An mtime-derived version is monotonic by construction. A file with no `@version` line is served byte-identical. `os.Stat` runs before `os.ReadFile`, so a concurrent edit can only serve new content under an old stamp — which self-heals on the next poll — never the reverse.
## Bindmount
`./userscript` is bindmounted read-only at `/userscript`. The script is deliberately **not** copied into the image: the build context stays `./backend`, and widening it would churn every `COPY` path for a file the mount always supplies. Editing the file on the VPS is live on the next poll — no rebuild, no restart. `git pull` restores the committed version, so a redeploy always ships the repo's script; checkout sets mtime to now, so even a rollback serves a *higher* version and is adopted. Without the mount the endpoint 404s and logs it; bookmark sync is unaffected.
`@downloadURL` / `@updateURL` are literal URLs in the metadata block — it is parsed before any JS runs, so `API_BASE`/`API_TOKEN` cannot be interpolated. The token was already committed in this file, so this adds no new exposure.
## Userscript UI
- **Card actions moved under the subtitle.** Only the cover and the title continue reading now; the subtitle and the action row are inert siblings in the text column. A thumb that misses ★ lands on nothing, and Remove is never inside a link.
- **Loading spinner** while the first fetch is in flight — the panel used to read as frozen on the first open after a cold start. It draws only when there is nothing cached to draw instead, so a populated list never flaps.
## Verification
- `go test -count=1 ./...` — ok, 7.070s
- `node --check` clean; `node --test userscript/test/logic.test.js` — 14/14
- Live `docker compose` smoke: `/healthz` 200, wrong token 404, script served with a stamped `@version 2026.07.28.1057` and both metadata URLs present; `touch`ing the file advanced the served version to `2026.07.28.1100` with no restart.
Layout and spinner are verified on-device — there is deliberately no DOM test harness.
Reviewed-on: #8
Co-authored-by: Sulthan Zaki <sultankiki05@gmail.com>
Co-committed-by: Sulthan Zaki <sultankiki05@gmail.com>
This commit was merged in pull request #8.
This commit is contained in:
@@ -0,0 +1,91 @@
|
|||||||
|
---
|
||||||
|
name: testing-the-userscript
|
||||||
|
description: Use when writing, running, or debugging tests for userscript/manga-bookmark.user.js — adding a case to logic.test.js, exporting a function for test, a test that fails with "is not a function"/undefined export, or deciding whether some userscript behaviour is testable at all.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Testing the userscript
|
||||||
|
|
||||||
|
`userscript/manga-bookmark.user.js` is a browser IIFE, not a module. It is tested
|
||||||
|
by `require()`-ing it into Node under a hand-written four-object browser stub in
|
||||||
|
`userscript/test/logic.test.js`. The harness covers **pure logic only** — adapters,
|
||||||
|
parsers, helpers. UI, network, and storage behaviour are verified on-device.
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node --check userscript/manga-bookmark.user.js # parse check, silent on success
|
||||||
|
node --test userscript/test/logic.test.js # 14 tests as of 2026-07-28
|
||||||
|
```
|
||||||
|
|
||||||
|
Run both before every commit that touches the userscript.
|
||||||
|
|
||||||
|
**Use the file path, not `node --test userscript/test/`.** The directory form
|
||||||
|
fails `MODULE_NOT_FOUND` on this machine's Node v22.22.2. Older docs and plans
|
||||||
|
still write the directory form — substitute the file path; do not try to fix it.
|
||||||
|
|
||||||
|
## How the harness works
|
||||||
|
|
||||||
|
The test file installs four globals **before** requiring the userscript:
|
||||||
|
|
||||||
|
| Global | What it is | Why |
|
||||||
|
|---|---|---|
|
||||||
|
| `localStorage` | `Map`-backed stub | `loadCache`, `loadQueue`, and the key-migration IIFE touch it at module scope |
|
||||||
|
| `location` | `{href, hostname, pathname, origin}` | read during boot |
|
||||||
|
| `document` | `querySelector` for `meta[property="…"]` only, plus a no-op `addEventListener` | adapters read `og:title`/`og:image` |
|
||||||
|
| `document.body` | **left `undefined`** | this is the whole trick |
|
||||||
|
|
||||||
|
`document.body === undefined` sends the userscript's boot block down its `else`
|
||||||
|
branch, where it waits for a `DOMContentLoaded` that never fires. `init()`,
|
||||||
|
`buildUI()`, and every `fetch` stay dormant, so nothing else needs stubbing.
|
||||||
|
|
||||||
|
The export hook near the end of the userscript is what makes `require()` work:
|
||||||
|
|
||||||
|
```js
|
||||||
|
if (typeof window === "undefined" && typeof module === "object" && module.exports) {
|
||||||
|
module.exports = { stripBuildHash, asura, demonic, anchorsFromHTML, statusOf };
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`typeof window === "undefined"` is load-bearing: under `@grant none` the script
|
||||||
|
shares page globals, so it must not clobber a page's own UMD shim.
|
||||||
|
|
||||||
|
## Adding a test
|
||||||
|
|
||||||
|
1. If the function isn't already exported, add it to that `module.exports` list
|
||||||
|
and to the destructuring `require` at the top of `logic.test.js`. A test
|
||||||
|
failing with `X is not a function` means you skipped this step.
|
||||||
|
2. Set `metaTags` (module-level `let` in the test file) for anything that reads
|
||||||
|
`og:` tags — it is reassigned per test, so set every tag your case needs.
|
||||||
|
3. Build locations with the `loc(href)` helper; `detect()` reads only
|
||||||
|
`pathname`, `origin`, `href`, `hostname`.
|
||||||
|
4. Keep the case pure: inputs in, value out, `assert` on the result.
|
||||||
|
|
||||||
|
```js
|
||||||
|
test("stripBuildHash removes a trailing 8-hex suffix", () => {
|
||||||
|
assert.equal(stripBuildHash("solo-leveling-059befe1"), "solo-leveling");
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## What is NOT testable here
|
||||||
|
|
||||||
|
- **DOM, layout, Shadow DOM, the panel, the spinner.** There is no DOM harness
|
||||||
|
and **you must not add one** — no jsdom, no happy-dom, no second test file for
|
||||||
|
UI. Verified on-device (Cromite + Violentmonkey) instead.
|
||||||
|
- **`fetch`, sync, the retry queue's network behaviour.** Verified against a
|
||||||
|
running backend.
|
||||||
|
- Anything reachable only through `init()`/`buildUI()`.
|
||||||
|
|
||||||
|
If a change's only meaningful verification is visual or on-device, say so in the
|
||||||
|
report rather than inventing coverage.
|
||||||
|
|
||||||
|
## Gotchas
|
||||||
|
|
||||||
|
- **New module-scope code that touches a browser API breaks every test**, not
|
||||||
|
just a new one — the `require()` runs it. Keep such work inside functions that
|
||||||
|
only `init()` calls. If you must add module-scope access, extend the stub.
|
||||||
|
- `stripBuildHash` must stay in sync with `asuraBuildHash` in `backend/store.go`;
|
||||||
|
changing one without the other silently splits series identity.
|
||||||
|
- `document.querySelector` only understands `meta[property="…"]`. Any other
|
||||||
|
selector returns `null` — extend the stub rather than working around it.
|
||||||
|
- The userscript stays GM-free (no `GM_*` APIs); a test that needs one is
|
||||||
|
testing something that can't ship.
|
||||||
@@ -72,6 +72,8 @@ Bromite userscript (isolated world, per-site adapters, localStorage cache)
|
|||||||
(gates browser UI; unset disables it),
|
(gates browser UI; unset disables it),
|
||||||
`LATEST_CHAPTER_POLL_ENABLED`/`_COOLDOWN`/`_INTERVAL`/`_BATCH`/`_STAGGER`
|
`LATEST_CHAPTER_POLL_ENABLED`/`_COOLDOWN`/`_INTERVAL`/`_BATCH`/`_STAGGER`
|
||||||
(background latest-chapter poller; defaults on, `1h`/`10m`/`14`/`20s`).
|
(background latest-chapter poller; defaults on, `1h`/`10m`/`14`/`20s`).
|
||||||
|
`USERSCRIPT_PATH` (file served at `/u/{token}/manga-bookmark.user.js`,
|
||||||
|
default `/userscript/manga-bookmark.user.js`, supplied by a bindmount).
|
||||||
|
|
||||||
### Userscript structure (single IIFE, `manga-bookmark.user.js`)
|
### Userscript structure (single IIFE, `manga-bookmark.user.js`)
|
||||||
|
|
||||||
|
|||||||
@@ -72,6 +72,8 @@ Bromite userscript (isolated world, per-site adapters, localStorage cache)
|
|||||||
(gates the browser UI; unset disables it),
|
(gates the browser UI; unset disables it),
|
||||||
`LATEST_CHAPTER_POLL_ENABLED`/`_COOLDOWN`/`_INTERVAL`/`_BATCH`/`_STAGGER`
|
`LATEST_CHAPTER_POLL_ENABLED`/`_COOLDOWN`/`_INTERVAL`/`_BATCH`/`_STAGGER`
|
||||||
(background latest-chapter poller; defaults on, `1h`/`10m`/`14`/`20s`).
|
(background latest-chapter poller; defaults on, `1h`/`10m`/`14`/`20s`).
|
||||||
|
`USERSCRIPT_PATH` (file served at `/u/{token}/manga-bookmark.user.js`,
|
||||||
|
default `/userscript/manga-bookmark.user.js`, supplied by a bindmount).
|
||||||
|
|
||||||
### Userscript structure (single IIFE, `manga-bookmark.user.js`)
|
### Userscript structure (single IIFE, `manga-bookmark.user.js`)
|
||||||
|
|
||||||
|
|||||||
@@ -166,6 +166,12 @@ const API_TOKEN = "<same token as .env>";
|
|||||||
The token sits in the userscript's isolated world — the manga sites' JS can't
|
The token sits in the userscript's isolated world — the manga sites' JS can't
|
||||||
read it.
|
read it.
|
||||||
|
|
||||||
|
Also edit the `@downloadURL`/`@updateURL` metadata lines near the top of the
|
||||||
|
file — they ship hardcoded to this deployment's domain and token, so a
|
||||||
|
deployer who skips them ends up auto-updating from someone else's backend.
|
||||||
|
See "Installing / updating the userscript" below for how those two lines are
|
||||||
|
used.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 5. Install on Bromite
|
## 5. Install on Bromite
|
||||||
@@ -218,3 +224,43 @@ SQLite data persists in the named volume `bookmarks-data` across rebuilds.
|
|||||||
| `compose ... config` errors about `API_TOKEN` | Run compose from the dir with `.env`, or export the vars. |
|
| `compose ... config` errors about `API_TOKEN` | Run compose from the dir with `.env`, or export the vars. |
|
||||||
|
|
||||||
Backend config reference and endpoint list: see `README.md`.
|
Backend config reference and endpoint list: see `README.md`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Installing / updating the userscript
|
||||||
|
|
||||||
|
The backend serves the script itself, so Violentmonkey can auto-update it.
|
||||||
|
Complements §4 above — that step points `API_BASE`/`API_TOKEN` at your
|
||||||
|
backend; this one points `@downloadURL`/`@updateURL` at the same place so
|
||||||
|
auto-updates come from it too.
|
||||||
|
|
||||||
|
Install once, on the phone (Cromite + Violentmonkey):
|
||||||
|
|
||||||
|
```
|
||||||
|
https://manga-api.<your-domain>/u/<API_TOKEN>/manga-bookmark.user.js
|
||||||
|
```
|
||||||
|
|
||||||
|
Open that URL in Cromite; Violentmonkey offers to install it. The token is in
|
||||||
|
the path because Violentmonkey's update poll sends no `Authorization` header,
|
||||||
|
and the script embeds `API_TOKEN` in plain text — an open URL would leak it. A
|
||||||
|
wrong token answers 404.
|
||||||
|
|
||||||
|
Updating, without a redeploy:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
vi userscript/manga-bookmark.user.js # on the VPS, in this checkout
|
||||||
|
```
|
||||||
|
|
||||||
|
`./userscript` is bindmounted read-only into the container and read fresh on
|
||||||
|
every request, so the edit is live immediately. The served `@version` is derived
|
||||||
|
from the file's mtime (`YYYY.MM.DD.HHMM`, UTC), not from the `@version` in the
|
||||||
|
file, so any edit outranks the installed copy and Violentmonkey pulls it on its
|
||||||
|
next check. The `@version` in the repo is a human marker only.
|
||||||
|
|
||||||
|
Updating via redeploy: `git pull` overwrites the file with the committed
|
||||||
|
version, which is the intended behaviour — a deploy always ships the repo's
|
||||||
|
script. Note that `git pull` sets mtime to checkout time, so even a rollback
|
||||||
|
serves a *higher* version and is adopted.
|
||||||
|
|
||||||
|
If the mount is missing, the endpoint answers 404 and logs it; bookmark sync is
|
||||||
|
unaffected.
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ Bromite userscript (isolated world, Shadow DOM UI, localStorage cache)
|
|||||||
| `PUT` | `/bookmarks/{key}` | Bearer | Upsert one series; returns the row as stored. |
|
| `PUT` | `/bookmarks/{key}` | Bearer | Upsert one series; returns the row as stored. |
|
||||||
| `DELETE` | `/bookmarks/{key}` | Bearer | Remove one. |
|
| `DELETE` | `/bookmarks/{key}` | Bearer | Remove one. |
|
||||||
| `GET` | `/healthz` | none | `200 ok`. |
|
| `GET` | `/healthz` | none | `200 ok`. |
|
||||||
|
| `GET` | `/u/{token}/manga-bookmark.user.js` | token in path | Serves the userscript with an mtime-derived `@version`. |
|
||||||
|
|
||||||
`key` is `<site>:<series_id>` — e.g. `asura:trash-of-the-counts-family-f886a8af`
|
`key` is `<site>:<series_id>` — e.g. `asura:trash-of-the-counts-family-f886a8af`
|
||||||
or `demonic:Infinite-Level-Up-in-Murim`. Sync is last-write-wins.
|
or `demonic:Infinite-Level-Up-in-Murim`. Sync is last-write-wins.
|
||||||
|
|||||||
+25
-1
@@ -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
|
||||||
}
|
}
|
||||||
@@ -132,6 +135,7 @@ func loadConfig() Config {
|
|||||||
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"),
|
||||||
|
UserscriptPath: envOr("USERSCRIPT_PATH", "/userscript/manga-bookmark.user.js"),
|
||||||
LatestPoll: loadLatestPoll(),
|
LatestPoll: loadLatestPoll(),
|
||||||
}
|
}
|
||||||
for _, o := range strings.Split(os.Getenv("ALLOWED_ORIGINS"), ",") {
|
for _, o := range strings.Split(os.Getenv("ALLOWED_ORIGINS"), ",") {
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,6 +20,8 @@ services:
|
|||||||
PORT: "8080"
|
PORT: "8080"
|
||||||
# Gates the browser UI. Unset means the web routes are not served at all.
|
# Gates the browser UI. Unset means the web routes are not served at all.
|
||||||
WEB_PASSWORD: ${WEB_PASSWORD:-}
|
WEB_PASSWORD: ${WEB_PASSWORD:-}
|
||||||
|
# Path inside the container; matches the bindmount above.
|
||||||
|
USERSCRIPT_PATH: ${USERSCRIPT_PATH:-/userscript/manga-bookmark.user.js}
|
||||||
# Latest-chapter poller. LATEST_CHAPTER_POLL_ENABLED=0 in .env is the kill
|
# Latest-chapter poller. LATEST_CHAPTER_POLL_ENABLED=0 in .env is the kill
|
||||||
# switch; it only takes effect because these are listed here.
|
# switch; it only takes effect because these are listed here.
|
||||||
LATEST_CHAPTER_POLL_ENABLED: ${LATEST_CHAPTER_POLL_ENABLED:-1}
|
LATEST_CHAPTER_POLL_ENABLED: ${LATEST_CHAPTER_POLL_ENABLED:-1}
|
||||||
@@ -29,6 +31,11 @@ services:
|
|||||||
LATEST_CHAPTER_POLL_STAGGER: ${LATEST_CHAPTER_POLL_STAGGER:-20s}
|
LATEST_CHAPTER_POLL_STAGGER: ${LATEST_CHAPTER_POLL_STAGGER:-20s}
|
||||||
volumes:
|
volumes:
|
||||||
- bookmarks-data:/data
|
- bookmarks-data:/data
|
||||||
|
# The userscript is served from here, read fresh on every request. Editing
|
||||||
|
# the file in this checkout takes effect on the next Violentmonkey poll —
|
||||||
|
# no rebuild, no restart. `git pull` restores the committed version, which
|
||||||
|
# is why a redeploy always ships the repo's script.
|
||||||
|
- ./userscript:/userscript:ro
|
||||||
# Bound to loopback only: the proxy (or curl during smoke test) reaches it,
|
# Bound to loopback only: the proxy (or curl during smoke test) reaches it,
|
||||||
# the public internet does not.
|
# the public internet does not.
|
||||||
ports:
|
ports:
|
||||||
|
|||||||
@@ -4,6 +4,8 @@
|
|||||||
// @version 1.5.0
|
// @version 1.5.0
|
||||||
// @description Track read progress on Asura & Demonic and sync to a self-hosted backend. Bromite-compatible (no GM_* APIs).
|
// @description Track read progress on Asura & Demonic and sync to a self-hosted backend. Bromite-compatible (no GM_* APIs).
|
||||||
// @author you
|
// @author you
|
||||||
|
// @downloadURL https://manga-api.violetcrown.my.id/u/40d79969b5442f90df4fe306a092c7c50e7b4a7a98099f98cc398f4fb374b1df/manga-bookmark.user.js
|
||||||
|
// @updateURL https://manga-api.violetcrown.my.id/u/40d79969b5442f90df4fe306a092c7c50e7b4a7a98099f98cc398f4fb374b1df/manga-bookmark.user.js
|
||||||
// @match https://asuracomic.net/*
|
// @match https://asuracomic.net/*
|
||||||
// @match https://asurascans.com/*
|
// @match https://asurascans.com/*
|
||||||
// @match https://demonicscans.org/*
|
// @match https://demonicscans.org/*
|
||||||
@@ -850,6 +852,9 @@
|
|||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
let root, panelOpen = false;
|
let root, panelOpen = false;
|
||||||
|
// True while refresh() is waiting on the backend. Only changes what the list
|
||||||
|
// draws when there is nothing cached to draw instead.
|
||||||
|
let loading = false;
|
||||||
let activeTab = "all"; // "all" | "favorites" | "archived"; resets each page, by design
|
let activeTab = "all"; // "all" | "favorites" | "archived"; resets each page, by design
|
||||||
|
|
||||||
function buildUI() {
|
function buildUI() {
|
||||||
@@ -1069,7 +1074,11 @@
|
|||||||
if (s !== "reading") return false;
|
if (s !== "reading") return false;
|
||||||
return activeTab !== "favorites" || b.favorite;
|
return activeTab !== "favorites" || b.favorite;
|
||||||
});
|
});
|
||||||
if (items.length === 0) {
|
if (items.length === 0 && loading) {
|
||||||
|
// Nothing cached and the fetch is still out — without this the panel
|
||||||
|
// looks frozen on the first open after a cold start.
|
||||||
|
listEl.appendChild(el("div", { class: "empty" }, [el("div", { class: "spinner" })]));
|
||||||
|
} else if (items.length === 0) {
|
||||||
const empty = {
|
const empty = {
|
||||||
favorites: "No favourites yet.",
|
favorites: "No favourites yet.",
|
||||||
archived: "Nothing archived.",
|
archived: "Nothing archived.",
|
||||||
@@ -1092,20 +1101,18 @@
|
|||||||
const sub = behind
|
const sub = behind
|
||||||
? "Read: " + (b.last_chapter || "—") + " · Latest: " + b.latest_chapter
|
? "Read: " + (b.last_chapter || "—") + " · Latest: " + b.latest_chapter
|
||||||
: (b.last_chapter || "—") + " · " + b.site;
|
: (b.last_chapter || "—") + " · " + b.site;
|
||||||
// Cover and text are one link: the card body *is* the continue button, so
|
// Cover and title are the only continue links. The subtitle and the action
|
||||||
// there is no separate one. The action row is a sibling of that link, never
|
// row live in the same text column but are not navigation targets — a thumb
|
||||||
// a child — a thumb that misses ★ must not land on a navigation target, and
|
// that misses ★ must not land on one, and Remove must never be one.
|
||||||
// Remove must never be one.
|
|
||||||
return el("div", { class: "item" }, [
|
return el("div", { class: "item" }, [
|
||||||
el("a", { class: "go", href: cont }, [
|
el("a", { class: "go", href: cont }, [
|
||||||
b.cover
|
b.cover
|
||||||
? el("img", { class: "cover", src: b.cover, loading: "lazy", alt: "" })
|
? el("img", { class: "cover", src: b.cover, loading: "lazy", alt: "" })
|
||||||
: el("div", { class: "cover ph" }),
|
: el("div", { class: "cover ph" }),
|
||||||
|
]),
|
||||||
el("div", { class: "meta" }, [
|
el("div", { class: "meta" }, [
|
||||||
el("div", { class: "t", text: b.title || b.series_id }),
|
el("a", { class: "go-t t", href: cont, text: b.title || b.series_id }),
|
||||||
el("div", { class: "c" + (behind ? " behind" : ""), text: sub }),
|
el("div", { class: "c" + (behind ? " behind" : ""), text: sub }),
|
||||||
]),
|
|
||||||
]),
|
|
||||||
el("div", { class: "actions" }, [
|
el("div", { class: "actions" }, [
|
||||||
el("button", {
|
el("button", {
|
||||||
class: "btn small star" + (b.favorite ? " on" : ""),
|
class: "btn small star" + (b.favorite ? " on" : ""),
|
||||||
@@ -1124,6 +1131,7 @@
|
|||||||
onclick: () => confirmRemove(b),
|
onclick: () => confirmRemove(b),
|
||||||
}),
|
}),
|
||||||
]),
|
]),
|
||||||
|
]),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1153,12 +1161,16 @@
|
|||||||
|
|
||||||
async function refresh() {
|
async function refresh() {
|
||||||
await drain(); // push what we owe before adopting the server's view of it
|
await drain(); // push what we owe before adopting the server's view of it
|
||||||
|
loading = true;
|
||||||
|
render();
|
||||||
try {
|
try {
|
||||||
const list = await apiGet();
|
const list = await apiGet();
|
||||||
setList(overlayPending(list));
|
setList(overlayPending(list));
|
||||||
render();
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
render(); // fall back to cache
|
// Offline or backend down: keep whatever the cache holds.
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
render();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1379,17 +1391,24 @@
|
|||||||
.tab.active { color: #eee; border-bottom-color: #6d28d9; }
|
.tab.active { color: #eee; border-bottom-color: #6d28d9; }
|
||||||
#list { overflow-y: auto; flex: 1; padding: 8px 0; }
|
#list { overflow-y: auto; flex: 1; padding: 8px 0; }
|
||||||
.empty { color: #9a9aa5; text-align: center; padding: 30px 16px; font-size: 14px; }
|
.empty { color: #9a9aa5; text-align: center; padding: 30px 16px; font-size: 14px; }
|
||||||
|
.spinner {
|
||||||
|
width: 26px; height: 26px; margin: 0 auto;
|
||||||
|
border: 3px solid #33333d; border-top-color: #6d28d9; border-radius: 50%;
|
||||||
|
animation: spin .8s linear infinite;
|
||||||
|
}
|
||||||
|
@keyframes spin { to { transform: rotate(360deg); } }
|
||||||
.item {
|
.item {
|
||||||
display: flex; flex-direction: column; gap: 8px;
|
display: flex; flex-direction: row; gap: 10px;
|
||||||
padding: 10px 16px; border-bottom: 1px solid #2a2a33;
|
padding: 10px 16px; border-bottom: 1px solid #2a2a33;
|
||||||
}
|
}
|
||||||
.go { display: flex; gap: 10px; text-decoration: none; color: inherit; }
|
.go { display: block; flex: none; }
|
||||||
.go:active { opacity: .7; }
|
.go:active, .go-t:active { opacity: .7; }
|
||||||
|
.go-t { display: block; text-decoration: none; color: inherit; }
|
||||||
.cover { width: 46px; height: 62px; object-fit: cover; border-radius: 4px; flex: none; background: #333; }
|
.cover { width: 46px; height: 62px; object-fit: cover; border-radius: 4px; flex: none; background: #333; }
|
||||||
.cover.ph { display: block; }
|
.cover.ph { display: block; }
|
||||||
.meta { min-width: 0; flex: 1; }
|
.meta { min-width: 0; flex: 1; display: flex; flex-direction: column; gap: 6px; }
|
||||||
.t { font-weight: 600; font-size: 14px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
.t { font-weight: 600; font-size: 14px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||||
.c { color: #9a9aa5; font-size: 12px; margin: 2px 0 0; }
|
.c { color: #9a9aa5; font-size: 12px; margin: 0; }
|
||||||
.c.behind { color: #c4b5fd; }
|
.c.behind { color: #c4b5fd; }
|
||||||
.actions { display: flex; gap: 6px; flex-wrap: wrap; }
|
.actions { display: flex; gap: 6px; flex-wrap: wrap; }
|
||||||
.btn {
|
.btn {
|
||||||
|
|||||||
Reference in New Issue
Block a user