feat: manga bookmark sync backend + Bromite userscript

Backend (Go, stdlib net/http + modernc.org/sqlite, CGO-free static binary):
- GET/PUT/DELETE /bookmarks{,/key} + /healthz
- bearer auth (constant-time), CORS origin reflection + 204 preflight
- SQLite store keyed <site>:<series_id>, last-write-wins, server-set updated_at
- httptest + temp-sqlite tests (auth, CORS, round-trip); go vet clean
- multi-stage Dockerfile (distroless static nonroot) + compose (base + prod proxy override)

Userscript (single Bromite-compatible IIFE, no GM_* APIs):
- Asura + Demonic adapters, URL-regex ids + og: title/cover
- Shadow-DOM floating UI, localStorage cache, optimistic sync
- auto-progress (no regress) + manual override; framework-agnostic nav watcher

Live-verified adapters (Playwright, 2026-07-24): asurascans.com /comics/<slug-hash>,
demonicscans.org /manga/<slug> + /title/<slug>/chapter/<n> — corrects the plan's
assumed /series/ paths and asuracomic.net domain.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
claude
2026-07-24 16:48:28 +07:00
parent d47a07af46
commit f58d113934
15 changed files with 1494 additions and 0 deletions
+12
View File
@@ -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
+6
View File
@@ -0,0 +1,6 @@
.env
*.db
*.db-shm
*.db-wal
backend/server
.playwright-mcp/
+167
View File
@@ -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 `<site>:<series_id>` — 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.<domain>` → 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.<domain>/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.<domain>"; // no trailing slash
const API_TOKEN = "<same token as backend>";
```
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/<slug-hash>` | `/comics/<slug-hash>/chapter/<n>` | `<slug-hash>` |
| **Demonic** (`demonicscans.org`) | `/manga/<slug>` | `/title/<slug>/chapter/<n>/<page>` (`chaptered.php?manga=<id>&chapter=<n>` 301s here) | `<slug>` |
Notes:
- Asura `og:title` carries a `Chapter N - Read Online \| Asura Scans` suffix that
the adapter strips; Demonic chapter `og:title` is `<Title> 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.
+5
View File
@@ -0,0 +1,5 @@
# Only go source + module files are needed in the build context.
*
!go.mod
!go.sum
!*.go
+31
View File
@@ -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"]
+21
View File
@@ -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
)
+49
View File
@@ -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=
+92
View File
@@ -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"))
}
+99
View File
@@ -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)
}
}
+53
View File
@@ -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)
})
}
+119
View File
@@ -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
}
+189
View File
@@ -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)
}
}
+21
View File
@@ -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}
+29
View File
@@ -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:
+601
View File
@@ -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; }
`;
})();