diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..515dd63 --- /dev/null +++ b/.env.example @@ -0,0 +1,12 @@ +# Copy to .env and fill in. Never commit the real .env. + +# Long random secret shared with the userscript's API_TOKEN. Generate one: +# openssl rand -hex 32 +API_TOKEN=changeme-generate-a-long-random-token + +# Comma-separated origins allowed to call the API (CORS). Both Asura domains +# plus Demonic. Add/remove as the sites' hostnames change. +ALLOWED_ORIGINS=https://asuracomic.net,https://asurascans.com,https://demonicscans.org + +# Only needed for the prod override, if your proxy network isn't named "proxy". +# PROXY_NETWORK=proxy diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4fc2e56 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +.env +*.db +*.db-shm +*.db-wal +backend/server +.playwright-mcp/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..33a9600 --- /dev/null +++ b/README.md @@ -0,0 +1,167 @@ +# Manga Bookmark + +Track manga read-progress on **asurascans.com** (a.k.a. asuracomic.net) and +**demonicscans.org** from a phone (Bromite / mobile Chromium), synced to a +self-hosted Go backend so bookmarks unify across both sites and all devices. + +Two parts: + +- **`backend/`** — tiny Go (`net/http` + pure-Go SQLite) sync service. 4 routes, + static binary, distroless container. +- **`userscript/manga-bookmark.user.js`** — single Bromite-compatible userscript + (no `GM_*` APIs) that injects an on-page bookmark UI and syncs via `fetch()`. + +``` +Bromite userscript (isolated world, Shadow DOM UI, localStorage cache) + -- fetch() HTTPS --> reverse proxy (TLS + CORS) --> Go net/http --> SQLite (volume) +``` + +--- + +## 1. Backend + +### Config (env) + +| Var | Default | Notes | +|-----|---------|-------| +| `API_TOKEN` | *(required)* | Bearer token shared with the userscript. | +| `ALLOWED_ORIGINS` | Asura + Demonic origins | Comma-separated CORS allowlist. | +| `DB_PATH` | `/data/bookmarks.db` | SQLite file location. | +| `PORT` | `8080` | Plain HTTP; TLS terminated by the proxy. | + +### Endpoints + +| Method | Path | Auth | Description | +|--------|------|------|-------------| +| `GET` | `/bookmarks` | Bearer | All bookmarks (single-user). | +| `PUT` | `/bookmarks/{key}` | Bearer | Upsert one series; server sets `updated_at`. | +| `DELETE` | `/bookmarks/{key}` | Bearer | Remove one. | +| `GET` | `/healthz` | none | `200 ok`. | + +`key` is `:` — e.g. `asura:trash-of-the-counts-family-f886a8af` +or `demonic:Infinite-Level-Up-in-Murim`. Sync is last-write-wins. + +### Develop / test + +```bash +cd backend +go test ./... # unit + handler tests +CGO_ENABLED=0 go build # static binary +``` + +### Run the stack + +```bash +cp .env.example .env +# edit .env: set API_TOKEN (openssl rand -hex 32) + +docker compose up -d --build # binds 127.0.0.1:8080 +``` + +Smoke test: + +```bash +TOKEN=$(grep '^API_TOKEN=' .env | cut -d= -f2) +curl -s localhost:8080/healthz # ok +curl -s localhost:8080/bookmarks # 401 +curl -s -H "Authorization: Bearer $TOKEN" localhost:8080/bookmarks # [] +curl -s -X PUT -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \ + -d '{"title":"Test","last_chapter":"Chapter 1","last_chapter_num":1}' \ + localhost:8080/bookmarks/asura:test-1 +curl -s -i -X OPTIONS -H 'Origin: https://asurascans.com' \ + -H 'Access-Control-Request-Method: PUT' \ + localhost:8080/bookmarks/asura:test-1 | grep -i access-control # 204 + CORS headers +``` + +### Deploy behind your reverse proxy + +Route `https://manga-api.` → the service on `:8080` (TLS at the proxy). + +- **Host proxy** (nginx/Caddy on the host): the base compose already binds + `127.0.0.1:8080`; point the proxy `proxy_pass http://127.0.0.1:8080;`. +- **Docker proxy** (Traefik/nginx in a container on its own network): use the + override, which drops the published port and joins the shared network: + + ```bash + docker network create proxy # once, if it doesn't exist + docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d --build + ``` + Set `PROXY_NETWORK` in `.env` if your network isn't named `proxy`. + +Verify: `https://manga-api./healthz` returns `ok` over valid TLS (no +mixed-content), and an `OPTIONS` preflight from a real site origin returns the +CORS headers. + +--- + +## 2. Userscript + +### Configure + +Edit the config block at the top of `userscript/manga-bookmark.user.js`: + +```js +const API_BASE = "https://manga-api."; // no trailing slash +const API_TOKEN = ""; +``` + +The token lives in the userscript's **isolated world** — the manga sites' own +JS cannot read it. + +### Install on Bromite (mobile) + +Bromite runs Chromium's native userscript engine (no Tampermonkey needed): + +1. Bromite → **Settings → User scripts** → enable user scripts (allow the + permission prompt). +2. Save the configured `manga-bookmark.user.js` to the device (or open its raw + URL). Bromite detects the `.user.js` and offers to install it. +3. Confirm the install; the `@match` list covers both sites. +4. Open a series on either site — a 📑 button appears bottom-right. + +> Exact menu wording varies by Bromite build; if "User scripts" is absent, +> update Bromite or use a build with userscript support. + +### Desktop iteration (optional) + +The script is `GM_*`-free, so it also runs in Tampermonkey/Violentmonkey on +desktop for faster testing — install the same file unchanged. + +### Use + +- **Bookmark**: on a series or chapter page, open the panel → **+ Bookmark this**. +- **Auto-progress**: opening a chapter of a bookmarked series records it when the + chapter number ≥ the stored one (re-reading older chapters never regresses + progress; unparseable numbers set the current chapter). +- **Manual override**: panel → **Edit** on any row forces a specific chapter. +- **Continue**: jumps to the last-read chapter (or the series page). +- Bookmarks made on Asura appear when the panel is opened on Demonic, and vice + versa — the backend is the shared store. + +Offline / backend down: changes are cached in `localStorage` and retried on the +next successful load (last-write-wins). + +--- + +## Adapter reference (verified live 2026-07-24) + +The site adapters key everything off URL regex, with `title`/`cover` from +`og:title` / `og:image`. Confirmed against live pages via Playwright: + +| Site | Series URL | Chapter URL | `series_id` | +|------|-----------|-------------|-------------| +| **Asura** (`asurascans.com`; `asuracomic.net` 301s here) | `/comics/` | `/comics//chapter/` | `` | +| **Demonic** (`demonicscans.org`) | `/manga/` | `/title//chapter//` (`chaptered.php?manga=&chapter=` 301s here) | `` | + +Notes: +- Asura `og:title` carries a `Chapter N - Read Online \| Asura Scans` suffix that + the adapter strips; Demonic chapter `og:title` is ` Chapter N`. +- Demonic's `<slug>` is identical on `/manga/…` and the canonical `/title/…` + reader, so a bookmark set from the series page and the auto-update from the + reader resolve to the **same key**. +- Asura showed no Next.js markers on the live site, so navigation uses a + framework-agnostic watcher (history patch + polling) rather than a Next-only + hook — works for client-routed and full-reload sites alike. + +If either site changes its URL shape, update the regex in the matching adapter +in `userscript/manga-bookmark.user.js` and re-verify. diff --git a/backend/.dockerignore b/backend/.dockerignore new file mode 100644 index 0000000..723850f --- /dev/null +++ b/backend/.dockerignore @@ -0,0 +1,5 @@ +# Only go source + module files are needed in the build context. +* +!go.mod +!go.sum +!*.go diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..6f8a527 --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,31 @@ +# syntax=docker/dockerfile:1 + +# --- build stage: compile a static, CGO-free binary --- +FROM golang:1.23-alpine AS build +WORKDIR /src + +# Dependencies first for layer caching (changes rarely). +COPY go.mod go.sum ./ +RUN go mod download + +# Then source (changes often). +COPY *.go ./ + +# Static binary: pure-Go sqlite means CGO_ENABLED=0 -> no libc dependency. +# -trimpath + -ldflags strip paths and debug info for a smaller image. +RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /out/server . + +# Data dir with the runtime user's ownership so the mounted volume inherits it. +RUN mkdir -p /out/data + +# --- runtime stage: distroless static, non-root --- +FROM gcr.io/distroless/static:nonroot +WORKDIR / +COPY --from=build /out/server /server +COPY --from=build --chown=65532:65532 /out/data /data + +VOLUME ["/data"] +EXPOSE 8080 +USER nonroot:nonroot +ENV DB_PATH=/data/bookmarks.db PORT=8080 +ENTRYPOINT ["/server"] diff --git a/backend/go.mod b/backend/go.mod new file mode 100644 index 0000000..3076eb2 --- /dev/null +++ b/backend/go.mod @@ -0,0 +1,21 @@ +module mangabm/backend + +go 1.23 + +require modernc.org/sqlite v1.34.4 + +require ( + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/ncruces/go-strftime v0.1.9 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + golang.org/x/sys v0.22.0 // indirect + modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 // indirect + modernc.org/libc v1.55.3 // indirect + modernc.org/mathutil v1.6.0 // indirect + modernc.org/memory v1.8.0 // indirect + modernc.org/strutil v1.2.0 // indirect + modernc.org/token v1.1.0 // indirect +) diff --git a/backend/go.sum b/backend/go.sum new file mode 100644 index 0000000..c337282 --- /dev/null +++ b/backend/go.sum @@ -0,0 +1,49 @@ +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo= +github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= +github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +golang.org/x/mod v0.16.0 h1:QX4fJ0Rr5cPQCF7O9lh9Se4pmwfwskqZfq5moyldzic= +golang.org/x/mod v0.16.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI= +golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/tools v0.19.0 h1:tfGCXNR1OsFG+sVdLAitlpjAvD/I6dHDKnYrpEZUHkw= +golang.org/x/tools v0.19.0/go.mod h1:qoJWxmGSIBmAeriMx19ogtrEPrGtDbPK634QFIcLAhc= +modernc.org/cc/v4 v4.21.4 h1:3Be/Rdo1fpr8GrQ7IVw9OHtplU4gWbb+wNgeoBMmGLQ= +modernc.org/cc/v4 v4.21.4/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ= +modernc.org/ccgo/v4 v4.19.2 h1:lwQZgvboKD0jBwdaeVCTouxhxAyN6iawF3STraAal8Y= +modernc.org/ccgo/v4 v4.19.2/go.mod h1:ysS3mxiMV38XGRTTcgo0DQTeTmAO4oCmJl1nX9VFI3s= +modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE= +modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ= +modernc.org/gc/v2 v2.4.1 h1:9cNzOqPyMJBvrUipmynX0ZohMhcxPtMccYgGOJdOiBw= +modernc.org/gc/v2 v2.4.1/go.mod h1:wzN5dK1AzVGoH6XOzc3YZ+ey/jPgYHLuVckd62P0GYU= +modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 h1:5D53IMaUuA5InSeMu9eJtlQXS2NxAhyWQvkKEgXZhHI= +modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6/go.mod h1:Qz0X07sNOR1jWYCrJMEnbW/X55x206Q7Vt4mz6/wHp4= +modernc.org/libc v1.55.3 h1:AzcW1mhlPNrRtjS5sS+eW2ISCgSOLLNyFzRh/V3Qj/U= +modernc.org/libc v1.55.3/go.mod h1:qFXepLhz+JjFThQ4kzwzOjA/y/artDeg+pcYnY+Q83w= +modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4= +modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo= +modernc.org/memory v1.8.0 h1:IqGTL6eFMaDZZhEWwcREgeMXYwmW83LYW8cROZYkg+E= +modernc.org/memory v1.8.0/go.mod h1:XPZ936zp5OMKGWPqbD3JShgd/ZoQ7899TUuQqxY+peU= +modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4= +modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0= +modernc.org/sortutil v1.2.0 h1:jQiD3PfS2REGJNzNCMMaLSp/wdMNieTbKX920Cqdgqc= +modernc.org/sortutil v1.2.0/go.mod h1:TKU2s7kJMf1AE84OoiGppNHJwvB753OYfNl2WRb++Ss= +modernc.org/sqlite v1.34.4 h1:sjdARozcL5KJBvYQvLlZEmctRgW9xqIZc2ncN7PU0P8= +modernc.org/sqlite v1.34.4/go.mod h1:3QQFCG2SEMtc2nv+Wq4cQCH7Hjcg+p/RMlS1XK+zwbk= +modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA= +modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= diff --git a/backend/handlers.go b/backend/handlers.go new file mode 100644 index 0000000..84f7e32 --- /dev/null +++ b/backend/handlers.go @@ -0,0 +1,92 @@ +package main + +import ( + "encoding/json" + "log" + "net/http" + "strings" + "time" +) + +type bookmarkHandler struct { + store *Store +} + +func writeJSON(w http.ResponseWriter, status int, v any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + if v != nil { + if err := json.NewEncoder(w).Encode(v); err != nil { + log.Printf("encode response: %v", err) + } + } +} + +// list returns all bookmarks. GET /bookmarks +func (h *bookmarkHandler) list(w http.ResponseWriter, r *http.Request) { + items, err := h.store.List() + if err != nil { + log.Printf("list: %v", err) + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + writeJSON(w, http.StatusOK, items) +} + +// put upserts one bookmark. PUT /bookmarks/{key} +func (h *bookmarkHandler) put(w http.ResponseWriter, r *http.Request) { + key := r.PathValue("key") + if key == "" { + http.Error(w, "missing key", http.StatusBadRequest) + return + } + + var b Bookmark + if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<16)).Decode(&b); err != nil { + http.Error(w, "invalid JSON body", http.StatusBadRequest) + return + } + + // Path key is authoritative; derive site/series_id from it when the body + // omits them so the stored row is always self-consistent. + b.Key = key + if b.Site == "" || b.SeriesID == "" { + if site, series, ok := strings.Cut(key, ":"); ok { + if b.Site == "" { + b.Site = site + } + if b.SeriesID == "" { + b.SeriesID = series + } + } + } + b.UpdatedAt = time.Now().UnixMilli() // server-assigned, ignore client value + + if err := h.store.Upsert(b); err != nil { + log.Printf("upsert: %v", err) + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + writeJSON(w, http.StatusOK, b) +} + +// delete removes one bookmark. DELETE /bookmarks/{key} +func (h *bookmarkHandler) delete(w http.ResponseWriter, r *http.Request) { + key := r.PathValue("key") + if key == "" { + http.Error(w, "missing key", http.StatusBadRequest) + return + } + if err := h.store.Delete(key); err != nil { + log.Printf("delete: %v", err) + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + w.WriteHeader(http.StatusNoContent) +} + +func healthz(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/plain") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("ok")) +} diff --git a/backend/main.go b/backend/main.go new file mode 100644 index 0000000..de900b8 --- /dev/null +++ b/backend/main.go @@ -0,0 +1,99 @@ +package main + +import ( + "context" + "errors" + "log" + "net/http" + "os" + "os/signal" + "strings" + "syscall" + "time" +) + +// Config holds all runtime settings, sourced from environment variables. +type Config struct { + Token string + AllowedOrigins []string + DBPath string + Port string +} + +func envOr(key, def string) string { + if v := os.Getenv(key); v != "" { + return v + } + return def +} + +func loadConfig() Config { + c := Config{ + Token: os.Getenv("API_TOKEN"), + DBPath: envOr("DB_PATH", "/data/bookmarks.db"), + Port: envOr("PORT", "8080"), + } + for _, o := range strings.Split(os.Getenv("ALLOWED_ORIGINS"), ",") { + if o = strings.TrimSpace(o); o != "" { + c.AllowedOrigins = append(c.AllowedOrigins, o) + } + } + return c +} + +// newRouter wires routes and middleware. CORS is the outermost layer so +// preflight OPTIONS short-circuits before auth; /bookmarks* is auth-protected, +// /healthz is public. +func newRouter(store *Store, cfg Config) http.Handler { + mux := http.NewServeMux() + mux.HandleFunc("GET /healthz", healthz) + + h := &bookmarkHandler{store: store} + protected := http.NewServeMux() + protected.HandleFunc("GET /bookmarks", h.list) + protected.HandleFunc("PUT /bookmarks/{key}", h.put) + protected.HandleFunc("DELETE /bookmarks/{key}", h.delete) + + auth := withAuth(cfg.Token, protected) + mux.Handle("/bookmarks", auth) + mux.Handle("/bookmarks/", auth) + + return withCORS(cfg.AllowedOrigins, mux) +} + +func main() { + cfg := loadConfig() + if cfg.Token == "" { + log.Fatal("API_TOKEN is required") + } + + store, err := OpenStore(cfg.DBPath) + if err != nil { + log.Fatalf("open store: %v", err) + } + defer store.Close() + + srv := &http.Server{ + Addr: ":" + cfg.Port, + Handler: newRouter(store, cfg), + ReadHeaderTimeout: 10 * time.Second, + } + + go func() { + log.Printf("listening on :%s (db=%s, origins=%v)", cfg.Port, cfg.DBPath, cfg.AllowedOrigins) + if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { + log.Fatalf("serve: %v", err) + } + }() + + stop := make(chan os.Signal, 1) + signal.Notify(stop, syscall.SIGINT, syscall.SIGTERM) + <-stop + + log.Println("shutting down") + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + if err := srv.Shutdown(ctx); err != nil { + log.Printf("shutdown: %v", err) + } +} diff --git a/backend/middleware.go b/backend/middleware.go new file mode 100644 index 0000000..dad76d9 --- /dev/null +++ b/backend/middleware.go @@ -0,0 +1,53 @@ +package main + +import ( + "crypto/subtle" + "net/http" + "strings" +) + +const bearerPrefix = "Bearer " + +// withAuth guards a handler with a constant-time bearer-token check. +func withAuth(token string, next http.Handler) http.Handler { + want := []byte(token) + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + h := r.Header.Get("Authorization") + if !strings.HasPrefix(h, bearerPrefix) { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + got := []byte(strings.TrimPrefix(h, bearerPrefix)) + if subtle.ConstantTimeCompare(got, want) != 1 { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + next.ServeHTTP(w, r) + }) +} + +// withCORS reflects the request Origin only when it is in allowed, answers +// preflight OPTIONS with 204, and passes everything else through. It wraps the +// auth middleware so preflight (which carries no Authorization header) is never +// rejected by auth. +func withCORS(allowed []string, next http.Handler) http.Handler { + set := make(map[string]struct{}, len(allowed)) + for _, o := range allowed { + set[o] = struct{}{} + } + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + origin := r.Header.Get("Origin") + if _, ok := set[origin]; ok && origin != "" { + w.Header().Set("Access-Control-Allow-Origin", origin) + w.Header().Add("Vary", "Origin") + w.Header().Set("Access-Control-Allow-Methods", "GET,PUT,DELETE,OPTIONS") + w.Header().Set("Access-Control-Allow-Headers", "Authorization,Content-Type") + w.Header().Set("Access-Control-Max-Age", "86400") + } + if r.Method == http.MethodOptions { + w.WriteHeader(http.StatusNoContent) + return + } + next.ServeHTTP(w, r) + }) +} diff --git a/backend/store.go b/backend/store.go new file mode 100644 index 0000000..161c4e3 --- /dev/null +++ b/backend/store.go @@ -0,0 +1,119 @@ +package main + +import ( + "database/sql" + "fmt" + "strings" + + _ "modernc.org/sqlite" +) + +// Bookmark is one tracked series, keyed "<site>:<series_id>" across both sites. +type Bookmark struct { + Key string `json:"key"` + Site string `json:"site"` + SeriesID string `json:"series_id"` + Title string `json:"title"` + SeriesURL string `json:"series_url"` + Cover string `json:"cover"` + LastChapter string `json:"last_chapter"` + LastChapterNum float64 `json:"last_chapter_num"` + LastChapterURL string `json:"last_chapter_url"` + UpdatedAt int64 `json:"updated_at"` // unix ms, server-assigned +} + +const schema = ` +CREATE TABLE IF NOT EXISTS bookmarks ( + key TEXT PRIMARY KEY, + site TEXT NOT NULL, + series_id TEXT NOT NULL, + title TEXT, + series_url TEXT, + cover TEXT, + last_chapter TEXT, + last_chapter_num REAL, + last_chapter_url TEXT, + updated_at INTEGER NOT NULL +);` + +// Store is the SQLite-backed bookmark store. +type Store struct { + db *sql.DB +} + +// OpenStore opens (or creates) the SQLite database at path and applies the schema. +func OpenStore(path string) (*Store, error) { + // busy_timeout guards against SQLITE_BUSY under the reverse proxy's + // concurrent requests; a single writer connection keeps writes serialized. + dsn := path + if !strings.Contains(dsn, "?") { + dsn += "?_pragma=busy_timeout(5000)&_pragma=journal_mode(WAL)" + } + db, err := sql.Open("sqlite", dsn) + if err != nil { + return nil, fmt.Errorf("open sqlite %q: %w", path, err) + } + db.SetMaxOpenConns(1) + if _, err := db.Exec(schema); err != nil { + db.Close() + return nil, fmt.Errorf("apply schema: %w", err) + } + return &Store{db: db}, nil +} + +// Close releases the underlying database handle. +func (s *Store) Close() error { return s.db.Close() } + +// List returns every bookmark, newest activity first. +func (s *Store) List() ([]Bookmark, error) { + rows, err := s.db.Query(` + SELECT key, site, series_id, title, series_url, cover, + last_chapter, last_chapter_num, last_chapter_url, updated_at + FROM bookmarks + ORDER BY updated_at DESC`) + if err != nil { + return nil, fmt.Errorf("query bookmarks: %w", err) + } + defer rows.Close() + + out := []Bookmark{} + for rows.Next() { + var b Bookmark + if err := rows.Scan( + &b.Key, &b.Site, &b.SeriesID, &b.Title, &b.SeriesURL, &b.Cover, + &b.LastChapter, &b.LastChapterNum, &b.LastChapterURL, &b.UpdatedAt, + ); err != nil { + return nil, fmt.Errorf("scan bookmark: %w", err) + } + out = append(out, b) + } + return out, rows.Err() +} + +// Upsert inserts or replaces a bookmark by key (last-write-wins). +func (s *Store) Upsert(b Bookmark) error { + _, err := s.db.Exec(` + INSERT INTO bookmarks + (key, site, series_id, title, series_url, cover, + last_chapter, last_chapter_num, last_chapter_url, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(key) DO UPDATE SET + site=excluded.site, series_id=excluded.series_id, title=excluded.title, + series_url=excluded.series_url, cover=excluded.cover, + last_chapter=excluded.last_chapter, last_chapter_num=excluded.last_chapter_num, + last_chapter_url=excluded.last_chapter_url, updated_at=excluded.updated_at`, + b.Key, b.Site, b.SeriesID, b.Title, b.SeriesURL, b.Cover, + b.LastChapter, b.LastChapterNum, b.LastChapterURL, b.UpdatedAt) + if err != nil { + return fmt.Errorf("upsert %q: %w", b.Key, err) + } + return nil +} + +// Delete removes a bookmark by key. Deleting a missing key is not an error. +func (s *Store) Delete(key string) error { + if _, err := s.db.Exec(`DELETE FROM bookmarks WHERE key = ?`, key); err != nil { + return fmt.Errorf("delete %q: %w", key, err) + } + return nil +} diff --git a/backend/store_test.go b/backend/store_test.go new file mode 100644 index 0000000..3c18906 --- /dev/null +++ b/backend/store_test.go @@ -0,0 +1,189 @@ +package main + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "path/filepath" + "testing" +) + +const testToken = "s3cret-token" + +func testConfig() Config { + return Config{ + Token: testToken, + AllowedOrigins: []string{"https://asuracomic.net", "https://demonicscans.org"}, + Port: "8080", + } +} + +func newTestServer(t *testing.T) http.Handler { + t.Helper() + dbPath := filepath.Join(t.TempDir(), "test.db") + store, err := OpenStore(dbPath) + if err != nil { + t.Fatalf("OpenStore: %v", err) + } + t.Cleanup(func() { store.Close() }) + return newRouter(store, testConfig()) +} + +func auth(req *http.Request) *http.Request { + req.Header.Set("Authorization", "Bearer "+testToken) + return req +} + +func TestHealthzNoAuth(t *testing.T) { + srv := newTestServer(t) + rr := httptest.NewRecorder() + srv.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/healthz", nil)) + if rr.Code != http.StatusOK { + t.Fatalf("healthz status = %d, want 200", rr.Code) + } + if rr.Body.String() != "ok" { + t.Fatalf("healthz body = %q, want ok", rr.Body.String()) + } +} + +func TestAuthRequired(t *testing.T) { + srv := newTestServer(t) + cases := []struct { + name string + header string + }{ + {"no header", ""}, + {"bad token", "Bearer wrong"}, + {"not bearer", "Basic " + testToken}, + {"empty bearer", "Bearer "}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/bookmarks", nil) + if tc.header != "" { + req.Header.Set("Authorization", tc.header) + } + rr := httptest.NewRecorder() + srv.ServeHTTP(rr, req) + if rr.Code != http.StatusUnauthorized { + t.Fatalf("status = %d, want 401", rr.Code) + } + }) + } +} + +func TestAuthAccepted(t *testing.T) { + srv := newTestServer(t) + rr := httptest.NewRecorder() + srv.ServeHTTP(rr, auth(httptest.NewRequest(http.MethodGet, "/bookmarks", nil))) + if rr.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", rr.Code) + } + if got := rr.Body.String(); got != "[]\n" { + t.Fatalf("empty list body = %q, want []", got) + } +} + +func TestCORSPreflight(t *testing.T) { + srv := newTestServer(t) + req := httptest.NewRequest(http.MethodOptions, "/bookmarks/asura:foo-1", nil) + req.Header.Set("Origin", "https://asuracomic.net") + req.Header.Set("Access-Control-Request-Method", "PUT") + rr := httptest.NewRecorder() + srv.ServeHTTP(rr, req) + + if rr.Code != http.StatusNoContent { + t.Fatalf("preflight status = %d, want 204", rr.Code) + } + if got := rr.Header().Get("Access-Control-Allow-Origin"); got != "https://asuracomic.net" { + t.Fatalf("Allow-Origin = %q, want reflected origin", got) + } + if got := rr.Header().Get("Access-Control-Allow-Methods"); got == "" { + t.Fatal("Allow-Methods missing") + } + if got := rr.Header().Get("Access-Control-Allow-Headers"); got == "" { + t.Fatal("Allow-Headers missing") + } +} + +func TestCORSDisallowedOrigin(t *testing.T) { + srv := newTestServer(t) + req := httptest.NewRequest(http.MethodOptions, "/bookmarks", nil) + req.Header.Set("Origin", "https://evil.example") + rr := httptest.NewRecorder() + srv.ServeHTTP(rr, req) + if got := rr.Header().Get("Access-Control-Allow-Origin"); got != "" { + t.Fatalf("Allow-Origin = %q, want empty for disallowed origin", got) + } +} + +func TestBookmarkRoundTrip(t *testing.T) { + srv := newTestServer(t) + key := "asura:solo-leveling-123" + in := Bookmark{ + Title: "Solo Leveling", + SeriesURL: "https://asuracomic.net/series/solo-leveling-123", + Cover: "https://asuracomic.net/cover.jpg", + LastChapter: "Chapter 10", + LastChapterNum: 10, + LastChapterURL: "https://asuracomic.net/series/solo-leveling-123/chapter/10", + } + body, _ := json.Marshal(in) + + // PUT + rr := httptest.NewRecorder() + srv.ServeHTTP(rr, auth(httptest.NewRequest(http.MethodPut, "/bookmarks/"+key, bytes.NewReader(body)))) + if rr.Code != http.StatusOK { + t.Fatalf("PUT status = %d, want 200", rr.Code) + } + var stored Bookmark + if err := json.Unmarshal(rr.Body.Bytes(), &stored); err != nil { + t.Fatalf("decode PUT response: %v", err) + } + if stored.Key != key || stored.Site != "asura" || stored.SeriesID != "solo-leveling-123" { + t.Fatalf("derived fields wrong: %+v", stored) + } + if stored.UpdatedAt == 0 { + t.Fatal("server did not set updated_at") + } + + // GET + rr = httptest.NewRecorder() + srv.ServeHTTP(rr, auth(httptest.NewRequest(http.MethodGet, "/bookmarks", nil))) + var list []Bookmark + if err := json.Unmarshal(rr.Body.Bytes(), &list); err != nil { + t.Fatalf("decode list: %v", err) + } + if len(list) != 1 || list[0].Key != key || list[0].LastChapterNum != 10 { + t.Fatalf("GET list wrong: %+v", list) + } + + // PUT again (upsert, progress advance) + in.LastChapter, in.LastChapterNum = "Chapter 11", 11 + body, _ = json.Marshal(in) + rr = httptest.NewRecorder() + srv.ServeHTTP(rr, auth(httptest.NewRequest(http.MethodPut, "/bookmarks/"+key, bytes.NewReader(body)))) + if rr.Code != http.StatusOK { + t.Fatalf("second PUT status = %d", rr.Code) + } + rr = httptest.NewRecorder() + srv.ServeHTTP(rr, auth(httptest.NewRequest(http.MethodGet, "/bookmarks", nil))) + json.Unmarshal(rr.Body.Bytes(), &list) + if len(list) != 1 || list[0].LastChapterNum != 11 { + t.Fatalf("upsert did not update in place: %+v", list) + } + + // DELETE + rr = httptest.NewRecorder() + srv.ServeHTTP(rr, auth(httptest.NewRequest(http.MethodDelete, "/bookmarks/"+key, nil))) + if rr.Code != http.StatusNoContent { + t.Fatalf("DELETE status = %d, want 204", rr.Code) + } + rr = httptest.NewRecorder() + srv.ServeHTTP(rr, auth(httptest.NewRequest(http.MethodGet, "/bookmarks", nil))) + json.Unmarshal(rr.Body.Bytes(), &list) + if len(list) != 0 { + t.Fatalf("after delete list = %+v, want empty", list) + } +} diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml new file mode 100644 index 0000000..2ea4ae5 --- /dev/null +++ b/docker-compose.prod.yml @@ -0,0 +1,21 @@ +# Production override: attach to an existing external reverse-proxy Docker +# network instead of publishing a host port. The proxy routes +# manga-api.<domain> -> manga-api:8080 over the shared network. +# +# docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d +# +# The network must already exist (created by your proxy stack): +# docker network create proxy # if it doesn't yet +# Override the name with PROXY_NETWORK in .env if yours differs. + +services: + manga-api: + # Drop the loopback port publish; traffic comes over the proxy network. + ports: !reset [] + networks: + - proxy + +networks: + proxy: + external: true + name: ${PROXY_NETWORK:-proxy} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..8f1487d --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,29 @@ +# Base stack — works standalone for local smoke testing (`docker compose up`). +# The service binds 127.0.0.1:8080; a host reverse proxy (nginx/Caddy/Traefik) +# terminates TLS for manga-api.<domain> and forwards to it. +# +# If your proxy runs in Docker on its own network, use the prod override which +# attaches to that network instead of publishing a port: +# docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d + +services: + manga-api: + build: ./backend + image: mangabm-backend:latest + container_name: manga-api + restart: unless-stopped + environment: + # API_TOKEN is required — compose refuses to start without it. + API_TOKEN: ${API_TOKEN:?set API_TOKEN in .env} + ALLOWED_ORIGINS: ${ALLOWED_ORIGINS:-https://asuracomic.net,https://asurascans.com,https://demonicscans.org} + DB_PATH: /data/bookmarks.db + PORT: "8080" + volumes: + - bookmarks-data:/data + # Bound to loopback only: the proxy (or curl during smoke test) reaches it, + # the public internet does not. + ports: + - "127.0.0.1:8080:8080" + +volumes: + bookmarks-data: diff --git a/userscript/manga-bookmark.user.js b/userscript/manga-bookmark.user.js new file mode 100644 index 0000000..8eb294f --- /dev/null +++ b/userscript/manga-bookmark.user.js @@ -0,0 +1,601 @@ +// ==UserScript== +// @name Manga Bookmark Sync +// @namespace mangabm +// @version 1.0.0 +// @description Track read progress on Asura & Demonic and sync to a self-hosted backend. Bromite-compatible (no GM_* APIs). +// @author you +// @match https://asuracomic.net/* +// @match https://asurascans.com/* +// @match https://demonicscans.org/* +// @run-at document-idle +// @noframes +// ==/UserScript== + +(function () { + "use strict"; + + // ============================================================ + // CONFIG — fill these in before installing. + // ============================================================ + const API_BASE = "https://manga-api.example.com"; // your backend origin, no trailing slash + const API_TOKEN = "PASTE_YOUR_API_TOKEN_HERE"; // must equal backend API_TOKEN + + // Safe in Bromite's isolated world: the page's own JS cannot read these. + const CACHE_KEY = "mangabm:cache"; + + // ============================================================ + // Site adapters + // + // Page type + IDs come from URL regex (most stable); title/cover come from + // og: meta tags. Verified live 2026-07-24 against asurascans.com and + // demonicscans.org — see README "Adapter reference". + // ============================================================ + + function meta(prop) { + const el = document.querySelector('meta[property="' + prop + '"]'); + return el ? el.getAttribute("content") : null; + } + + const asura = { + site: "asura", + // asuracomic.net currently 301s to asurascans.com; match both. + matches: (loc) => /(^|\.)asurascans\.com$|(^|\.)asuracomic\.net$/.test(loc.hostname), + detect(loc) { + const path = loc.pathname; + // /comics/<slug-hash>/chapter/<n> + let m = path.match(/^\/comics\/([^/]+)\/chapter\/([^/?#]+)/); + if (m) { + const num = parseFloat(m[2]); + return { + type: "chapter", + site: this.site, + seriesId: m[1], + title: cleanTitle(meta("og:title")), + cover: meta("og:image") || "", + seriesUrl: loc.origin + "/comics/" + m[1], + chapterLabel: "Chapter " + m[2], + chapterNum: isNaN(num) ? null : num, + chapterUrl: loc.href, + }; + } + // /comics/<slug-hash> + m = path.match(/^\/comics\/([^/?#]+)\/?$/); + if (m) { + return { + type: "series", + site: this.site, + seriesId: m[1], + title: cleanTitle(meta("og:title")), + cover: meta("og:image") || "", + seriesUrl: loc.origin + "/comics/" + m[1], + chapterLabel: null, + chapterNum: null, + chapterUrl: null, + }; + } + return { type: "other" }; + + // Strips "Chapter N - Read Online" and the " | Asura Scans" suffix. + function cleanTitle(t) { + if (!t) return ""; + return t + .replace(/\s*Chapter\s+[\d.]+\s*-\s*Read Online\s*/i, " ") + .replace(/\s*\|\s*Asura Scans\s*$/i, "") + .trim(); + } + }, + }; + + const demonic = { + site: "demonic", + matches: (loc) => /(^|\.)demonicscans\.org$/.test(loc.hostname), + detect(loc) { + const path = loc.pathname; + // Canonical reader: /title/<slug>/chapter/<n>/<page> + // (chaptered.php?manga=<id>&chapter=<n> 301s to this before we run.) + let m = path.match(/^\/title\/([^/]+)\/chapter\/([^/?#]+)/); + if (m) { + const num = parseFloat(m[2]); + return { + type: "chapter", + site: this.site, + seriesId: decodeURIComponent(m[1]), + title: cleanTitle(meta("og:title")), + cover: meta("og:image") || "", + seriesUrl: loc.origin + "/manga/" + m[1], + chapterLabel: "Chapter " + m[2], + chapterNum: isNaN(num) ? null : num, + chapterUrl: loc.href, + }; + } + // Series: /manga/<slug> + m = path.match(/^\/manga\/([^/?#]+)/); + if (m) { + return { + type: "series", + site: this.site, + seriesId: decodeURIComponent(m[1]), + title: cleanTitle(meta("og:title")), + cover: meta("og:image") || "", + seriesUrl: loc.origin + "/manga/" + m[1], + chapterLabel: null, + chapterNum: null, + chapterUrl: null, + }; + } + return { type: "other" }; + + // Demonic chapter og:title is "<Title> Chapter N"; series is clean. + function cleanTitle(t) { + if (!t) return ""; + return t.replace(/\s*Chapter\s+[\d.]+\s*$/i, "").trim(); + } + }, + }; + + const ADAPTERS = [asura, demonic]; + + function detect() { + const loc = window.location; + for (const a of ADAPTERS) { + if (a.matches(loc)) return a.detect(loc); + } + return { type: "other" }; + } + + function keyOf(page) { + return page.site + ":" + page.seriesId; + } + + // ============================================================ + // API client + local cache + // ============================================================ + + function loadCache() { + try { + const raw = localStorage.getItem(CACHE_KEY); + return raw ? JSON.parse(raw) : []; + } catch (e) { + return []; + } + } + + function saveCache(list) { + try { + localStorage.setItem(CACHE_KEY, JSON.stringify(list)); + } catch (e) { + /* quota / private mode — ignore */ + } + } + + function authHeaders(extra) { + return Object.assign({ Authorization: "Bearer " + API_TOKEN }, extra || {}); + } + + async function apiGet() { + const res = await fetch(API_BASE + "/bookmarks", { headers: authHeaders() }); + if (!res.ok) throw new Error("GET /bookmarks " + res.status); + return res.json(); + } + + async function apiPut(key, obj) { + const res = await fetch(API_BASE + "/bookmarks/" + encodeURIComponent(key), { + method: "PUT", + headers: authHeaders({ "Content-Type": "application/json" }), + body: JSON.stringify(obj), + }); + if (!res.ok) throw new Error("PUT /bookmarks " + res.status); + return res.json(); + } + + async function apiDelete(key) { + const res = await fetch(API_BASE + "/bookmarks/" + encodeURIComponent(key), { + method: "DELETE", + headers: authHeaders(), + }); + if (!res.ok) throw new Error("DELETE /bookmarks " + res.status); + } + + // ============================================================ + // State + // ============================================================ + + const state = { + list: loadCache(), // last-known bookmarks (array) + byKey: {}, + page: { type: "other" }, + }; + reindex(); + + function reindex() { + state.byKey = {}; + for (const b of state.list) state.byKey[b.key] = b; + state.list.sort((a, b) => (b.updated_at || 0) - (a.updated_at || 0)); + } + + function setList(list) { + state.list = Array.isArray(list) ? list : []; + reindex(); + saveCache(state.list); + } + + function upsertLocal(bm) { + const i = state.list.findIndex((b) => b.key === bm.key); + if (i >= 0) state.list[i] = bm; + else state.list.push(bm); + reindex(); + saveCache(state.list); + } + + function removeLocal(key) { + state.list = state.list.filter((b) => b.key !== key); + reindex(); + saveCache(state.list); + } + + // ============================================================ + // Mutations (optimistic: update UI/cache first, then sync) + // ============================================================ + + async function bookmarkCurrent() { + const p = state.page; + if (p.type !== "series" && p.type !== "chapter") return; + const key = keyOf(p); + const existing = state.byKey[key]; + const bm = { + key: key, + site: p.site, + series_id: p.seriesId, + title: p.title || (existing && existing.title) || p.seriesId, + series_url: p.seriesUrl || (existing && existing.series_url) || "", + cover: p.cover || (existing && existing.cover) || "", + last_chapter: p.chapterLabel || (existing && existing.last_chapter) || "", + last_chapter_num: + p.chapterNum != null ? p.chapterNum : existing ? existing.last_chapter_num : null, + last_chapter_url: p.chapterUrl || (existing && existing.last_chapter_url) || "", + updated_at: Date.now(), + }; + await syncUpsert(bm, "Bookmarked"); + } + + async function updateToCurrentChapter() { + const p = state.page; + if (p.type !== "chapter") return; + const key = keyOf(p); + const existing = state.byKey[key] || {}; + const bm = Object.assign({}, existing, { + key: key, + site: p.site, + series_id: p.seriesId, + title: existing.title || p.title || p.seriesId, + series_url: existing.series_url || p.seriesUrl || "", + cover: existing.cover || p.cover || "", + last_chapter: p.chapterLabel || "", + last_chapter_num: p.chapterNum, + last_chapter_url: p.chapterUrl || "", + updated_at: Date.now(), + }); + await syncUpsert(bm, "Progress updated"); + } + + // Manual override from the panel input — forces any value. + async function setChapterManual(key, label, num, url) { + const existing = state.byKey[key]; + if (!existing) return; + const bm = Object.assign({}, existing, { + last_chapter: label, + last_chapter_num: isNaN(num) ? existing.last_chapter_num : num, + last_chapter_url: url || existing.last_chapter_url, + updated_at: Date.now(), + }); + await syncUpsert(bm, "Progress set"); + } + + async function syncUpsert(bm, okMsg) { + upsertLocal(bm); // optimistic + render(); + try { + const saved = await apiPut(bm.key, bm); + upsertLocal(saved); // adopt server updated_at + render(); + toast(okMsg); + } catch (e) { + toast("Offline — saved locally, will retry", true); + } + } + + async function removeBookmark(key) { + removeLocal(key); // optimistic + render(); + try { + await apiDelete(key); + toast("Removed"); + } catch (e) { + toast("Offline — remove will retry", true); + } + } + + // Auto-record on opening a chapter of a bookmarked series. + async function maybeAutoUpdate() { + const p = state.page; + if (p.type !== "chapter") return; + const existing = state.byKey[keyOf(p)]; + if (!existing) return; // only track series the user bookmarked + + const cur = p.chapterNum; + const prev = existing.last_chapter_num; + // Advance when current >= stored, or when either is unparseable (set current). + const advance = cur == null || prev == null || cur >= prev; + if (!advance) return; + if (existing.last_chapter_url === p.chapterUrl && existing.last_chapter === p.chapterLabel) { + return; // already recorded + } + await updateToCurrentChapter(); + } + + // ============================================================ + // UI — Shadow DOM (isolated from site CSS) + // ============================================================ + + let root, panelOpen = false; + + function buildUI() { + const host = document.createElement("div"); + host.id = "mangabm-host"; + host.style.cssText = "all: initial;"; + (document.body || document.documentElement).appendChild(host); + root = host.attachShadow({ mode: "open" }); + + const style = document.createElement("style"); + style.textContent = CSS; + root.appendChild(style); + + const wrap = document.createElement("div"); + wrap.id = "wrap"; + wrap.innerHTML = TEMPLATE; + root.appendChild(wrap); + + root.getElementById("fab").addEventListener("click", togglePanel); + root.getElementById("backdrop").addEventListener("click", togglePanel); + root.getElementById("closeBtn").addEventListener("click", togglePanel); + } + + function togglePanel() { + panelOpen = !panelOpen; + render(); + if (panelOpen) refresh(); // pull latest from server on open + } + + function el(tag, props, children) { + const e = document.createElement(tag); + if (props) for (const k in props) { + if (k === "text") e.textContent = props[k]; + else if (k === "html") e.innerHTML = props[k]; + else if (k.slice(0, 2) === "on") e.addEventListener(k.slice(2).toLowerCase(), props[k]); + else e.setAttribute(k, props[k]); + } + if (children) for (const c of children) if (c) e.appendChild(c); + return e; + } + + function render() { + if (!root) return; + root.getElementById("panel").classList.toggle("open", panelOpen); + root.getElementById("backdrop").classList.toggle("open", panelOpen); + + // Context header for the current page. + const ctx = root.getElementById("context"); + ctx.innerHTML = ""; + const p = state.page; + if (p.type === "series" || p.type === "chapter") { + const key = keyOf(p); + const existing = state.byKey[key]; + ctx.appendChild(el("div", { class: "ctx-title", text: p.title || p.seriesId })); + if (!existing) { + ctx.appendChild(el("button", { class: "btn primary", text: "+ Bookmark this", onclick: () => bookmarkCurrent() })); + } else { + const cur = existing.last_chapter || "—"; + ctx.appendChild(el("div", { class: "ctx-sub", text: "Tracked · last read: " + cur })); + if (p.type === "chapter" && p.chapterLabel && p.chapterLabel !== existing.last_chapter) { + ctx.appendChild(el("button", { class: "btn primary", text: "Update to " + p.chapterLabel, onclick: () => updateToCurrentChapter() })); + } + } + } else { + ctx.appendChild(el("div", { class: "ctx-sub", text: "Open a series or chapter to bookmark it." })); + } + + // Bookmark list. + const listEl = root.getElementById("list"); + listEl.innerHTML = ""; + if (state.list.length === 0) { + listEl.appendChild(el("div", { class: "empty", text: "No bookmarks yet." })); + } + for (const b of state.list) { + listEl.appendChild(renderItem(b)); + } + } + + function renderItem(b) { + const cont = b.last_chapter_url || b.series_url || "#"; + const row = el("div", { class: "item" }, [ + b.cover + ? el("img", { class: "cover", src: b.cover, loading: "lazy", alt: "" }) + : el("div", { class: "cover ph" }), + el("div", { class: "meta" }, [ + el("div", { class: "t", text: b.title || b.series_id }), + el("div", { class: "c", text: (b.last_chapter || "—") + " · " + b.site }), + el("div", { class: "actions" }, [ + el("a", { class: "btn small", href: cont, text: "Continue" }), + el("button", { class: "btn small", text: "Edit", onclick: () => editChapter(b) }), + el("button", { class: "btn small danger", text: "Remove", onclick: () => removeBookmark(b.key) }), + ]), + ]), + ]); + return row; + } + + function editChapter(b) { + const raw = prompt("Set last-read chapter for \"" + (b.title || b.series_id) + "\":", b.last_chapter || ""); + if (raw == null) return; + const label = raw.trim(); + const num = parseFloat((label.match(/[\d.]+/) || [])[0]); + setChapterManual(b.key, label, num, b.last_chapter_url); + } + + // Toast inside the shadow root. + function toast(msg, isErr) { + if (!root) return; + const t = el("div", { class: "toast" + (isErr ? " err" : ""), text: msg }); + root.getElementById("toasts").appendChild(t); + setTimeout(() => t.classList.add("show"), 10); + setTimeout(() => { + t.classList.remove("show"); + setTimeout(() => t.remove(), 300); + }, 2600); + } + + // ============================================================ + // Refresh + navigation + // ============================================================ + + async function refresh() { + try { + const list = await apiGet(); + setList(list); + render(); + } catch (e) { + render(); // fall back to cache + } + } + + let lastUrl = location.href; + function onNavigate() { + state.page = detect(); + render(); + maybeAutoUpdate(); + } + + // Framework-agnostic URL-change watcher: patch history + poll as a fallback, + // so it works whether the site is client-routed (Asura) or reloads (Demonic). + function installNavWatcher() { + const fire = () => { + if (location.href !== lastUrl) { + lastUrl = location.href; + // Meta tags update slightly after the URL on client routes. + setTimeout(onNavigate, 300); + } + }; + const wrap = (name) => { + const orig = history[name]; + history[name] = function () { + const r = orig.apply(this, arguments); + fire(); + return r; + }; + }; + wrap("pushState"); + wrap("replaceState"); + window.addEventListener("popstate", fire); + setInterval(fire, 1500); // catch routes that bypass history + } + + // ============================================================ + // Boot + // ============================================================ + + function init() { + buildUI(); + state.page = detect(); + render(); + installNavWatcher(); + refresh().then(() => maybeAutoUpdate()); // sync, then auto-record this page + } + + if (document.body) init(); + else document.addEventListener("DOMContentLoaded", init); + + // ============================================================ + // Markup + styles (kept at bottom for readability) + // ============================================================ + + const TEMPLATE = ` + <button id="fab" aria-label="Manga bookmarks">📑</button> + <div id="backdrop"></div> + <aside id="panel" role="dialog" aria-label="Manga bookmarks"> + <header> + <span>Manga Bookmarks</span> + <button id="closeBtn" aria-label="Close">✕</button> + </header> + <section id="context"></section> + <div id="list"></div> + </aside> + <div id="toasts"></div> + `; + + const CSS = ` + :host { all: initial; } + * { box-sizing: border-box; font-family: system-ui, -apple-system, sans-serif; } + #fab { + position: fixed; right: 16px; + bottom: calc(16px + env(safe-area-inset-bottom, 0px)); + width: 52px; height: 52px; border-radius: 50%; border: none; + background: #6d28d9; color: #fff; font-size: 22px; line-height: 52px; + box-shadow: 0 4px 14px rgba(0,0,0,.4); z-index: 2147483646; cursor: pointer; + } + #fab:active { transform: scale(.94); } + #backdrop { + position: fixed; inset: 0; background: rgba(0,0,0,.5); + opacity: 0; pointer-events: none; transition: opacity .2s; z-index: 2147483646; + } + #backdrop.open { opacity: 1; pointer-events: auto; } + #panel { + position: fixed; top: 0; right: 0; height: 100%; + width: min(400px, 92vw); background: #1b1b22; color: #eee; + transform: translateX(105%); transition: transform .25s ease; + z-index: 2147483647; display: flex; flex-direction: column; + padding-bottom: env(safe-area-inset-bottom, 0px); + box-shadow: -4px 0 20px rgba(0,0,0,.5); + } + #panel.open { transform: translateX(0); } + header { + display: flex; align-items: center; justify-content: space-between; + padding: 14px 16px; border-bottom: 1px solid #33333d; font-weight: 600; font-size: 16px; + } + #closeBtn { background: none; border: none; color: #aaa; font-size: 18px; cursor: pointer; } + #context { + padding: 12px 16px; border-bottom: 1px solid #33333d; + display: flex; flex-direction: column; gap: 8px; + } + .ctx-title { font-weight: 600; font-size: 15px; } + .ctx-sub { color: #9a9aa5; font-size: 13px; } + #list { overflow-y: auto; flex: 1; padding: 8px 0; } + .empty { color: #9a9aa5; text-align: center; padding: 30px 16px; font-size: 14px; } + .item { display: flex; gap: 10px; padding: 10px 16px; border-bottom: 1px solid #2a2a33; } + .cover { width: 46px; height: 62px; object-fit: cover; border-radius: 4px; flex: none; background: #333; } + .cover.ph { display: block; } + .meta { min-width: 0; flex: 1; } + .t { font-weight: 600; font-size: 14px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } + .c { color: #9a9aa5; font-size: 12px; margin: 2px 0 8px; } + .actions { display: flex; gap: 6px; flex-wrap: wrap; } + .btn { + background: #33333d; color: #eee; border: none; border-radius: 6px; + padding: 8px 12px; font-size: 13px; cursor: pointer; text-decoration: none; display: inline-block; + } + .btn:active { opacity: .8; } + .btn.primary { background: #6d28d9; color: #fff; } + .btn.small { padding: 6px 10px; font-size: 12px; } + .btn.danger { background: #7f1d1d; } + #toasts { + position: fixed; left: 50%; transform: translateX(-50%); + bottom: calc(80px + env(safe-area-inset-bottom, 0px)); + z-index: 2147483647; display: flex; flex-direction: column; gap: 8px; align-items: center; + } + .toast { + background: #2f2f3a; color: #fff; padding: 10px 16px; border-radius: 8px; + font-size: 13px; opacity: 0; transform: translateY(10px); transition: all .3s; + box-shadow: 0 4px 14px rgba(0,0,0,.4); max-width: 80vw; + } + .toast.show { opacity: 1; transform: translateY(0); } + .toast.err { background: #7f1d1d; } + `; +})();