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
+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)
}
}