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:
@@ -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
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
.env
|
||||||
|
*.db
|
||||||
|
*.db-shm
|
||||||
|
*.db-wal
|
||||||
|
backend/server
|
||||||
|
.playwright-mcp/
|
||||||
@@ -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.
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
# Only go source + module files are needed in the build context.
|
||||||
|
*
|
||||||
|
!go.mod
|
||||||
|
!go.sum
|
||||||
|
!*.go
|
||||||
@@ -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"]
|
||||||
@@ -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
|
||||||
|
)
|
||||||
@@ -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=
|
||||||
@@ -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"))
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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}
|
||||||
@@ -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:
|
||||||
@@ -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; }
|
||||||
|
`;
|
||||||
|
})();
|
||||||
Reference in New Issue
Block a user