From d602b67b8477f01e050ca85fb1b869743f151b41 Mon Sep 17 00:00:00 2001 From: Sulthan Zaki Date: Tue, 28 Jul 2026 06:45:53 +0700 Subject: [PATCH] fix(backend): strip rotating Asura build hash from bookmark keys --- backend/store.go | 71 +++++++++++++++++++++++++++++++++++++++ backend/store_test.go | 78 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 149 insertions(+) diff --git a/backend/store.go b/backend/store.go index e3df5d2..443d4e7 100644 --- a/backend/store.go +++ b/backend/store.go @@ -4,6 +4,7 @@ import ( "database/sql" "errors" "fmt" + "regexp" "strings" _ "modernc.org/sqlite" @@ -120,6 +121,10 @@ func OpenStore(path string) (*Store, error) { db.Close() return nil, fmt.Errorf("migrate schema: %w", err) } + if err := migrateAsuraKeys(db); err != nil { + db.Close() + return nil, fmt.Errorf("migrate asura keys: %w", err) + } return &Store{db: db}, nil } @@ -141,6 +146,72 @@ func migrateColumns(db *sql.DB) error { return nil } +// asuraBuildHash matches the trailing "-xxxxxxxx" site-wide build ID Asura +// appends to every series slug. It rotates on each site redeploy, so it +// must not be part of series_id. Must stay in sync with stripBuildHash in +// userscript/manga-bookmark.user.js. +var asuraBuildHash = regexp.MustCompile(`-[0-9a-f]{8}$`) + +// migrateAsuraKeys rewrites asura bookmarks whose series_id still carries +// the build hash to the stable, hashless ID. Rows keyed with a hash are +// orphaned on every Asura redeploy (old-hash URLs 302 to new-hash ones, so +// detection yields a key that never matches). When two hash-generations of +// one series collide, the row with the newest updated_at wins and the rest +// are deleted. Idempotent: hashless IDs never match the regex. +func migrateAsuraKeys(db *sql.DB) error { + rows, err := db.Query(`SELECT key, series_id, updated_at FROM bookmarks WHERE site = 'asura'`) + if err != nil { + return fmt.Errorf("list asura rows: %w", err) + } + type row struct { + key, id string + updated int64 + } + var all []row + for rows.Next() { + var r row + if err := rows.Scan(&r.key, &r.id, &r.updated); err != nil { + rows.Close() + return fmt.Errorf("scan asura row: %w", err) + } + all = append(all, r) + } + if err := rows.Close(); err != nil { + return err + } + + groups := map[string][]row{} + for _, r := range all { + stripped := asuraBuildHash.ReplaceAllString(r.id, "") + groups[stripped] = append(groups[stripped], r) + } + for stripped, g := range groups { + winner := 0 + for i := range g { + if g[i].updated > g[winner].updated { + winner = i + } + } + for i, r := range g { + if i == winner { + if r.id == stripped { + continue // already stable + } + if _, err := db.Exec( + `UPDATE bookmarks SET key = ?, series_id = ? WHERE key = ?`, + "asura:"+stripped, stripped, r.key); err != nil { + return fmt.Errorf("rewrite key %q: %w", r.key, err) + } + continue + } + if _, err := db.Exec(`DELETE FROM bookmarks WHERE key = ?`, r.key); err != nil { + return fmt.Errorf("drop duplicate %q: %w", r.key, err) + } + } + } + return nil +} + func existingColumns(db *sql.DB, table string) (map[string]struct{}, error) { rows, err := db.Query(`SELECT name FROM pragma_table_info(?)`, table) if err != nil { diff --git a/backend/store_test.go b/backend/store_test.go index 619a5ff..e842461 100644 --- a/backend/store_test.go +++ b/backend/store_test.go @@ -905,3 +905,81 @@ func TestDueForLatestCheckSkipsFinishedKeepsArchived(t *testing.T) { t.Fatal("due included a finished series") } } + +// Asura slugs used to include the site build hash; rows keyed with it must +// be rewritten to the stable ID on open, merging hash-generations of the +// same series into the newest row. +func TestOpenStoreMigratesAsuraBuildHashKeys(t *testing.T) { + dbPath := filepath.Join(t.TempDir(), "hash.db") + + store, err := OpenStore(dbPath) + if err != nil { + t.Fatalf("open: %v", err) + } + seed := []Bookmark{ + {Key: "asura:swordmasters-youngest-son-f886a8af", Site: "asura", + SeriesID: "swordmasters-youngest-son-f886a8af", Title: "Old gen", + LastChapterNum: 50, UpdatedAt: 100}, + {Key: "asura:swordmasters-youngest-son-059befe1", Site: "asura", + SeriesID: "swordmasters-youngest-son-059befe1", Title: "Re-bookmarked", + LastChapterNum: 60, UpdatedAt: 200}, + {Key: "asura:overgeared-059befe1", Site: "asura", + SeriesID: "overgeared-059befe1", Title: "Single gen", UpdatedAt: 150}, + // Hash-like suffix on another site must be left alone. + {Key: "demonic:x-deadbeef", Site: "demonic", + SeriesID: "x-deadbeef", Title: "Not asura", UpdatedAt: 300}, + } + for _, b := range seed { + if _, err := store.Upsert(b); err != nil { + t.Fatalf("seed %s: %v", b.Key, err) + } + } + if err := store.Close(); err != nil { + t.Fatalf("close: %v", err) + } + + reopened, err := OpenStore(dbPath) + if err != nil { + t.Fatalf("reopen: %v", err) + } + t.Cleanup(func() { reopened.Close() }) + + list, err := reopened.List() + if err != nil { + t.Fatalf("List: %v", err) + } + byKey := map[string]Bookmark{} + for _, b := range list { + byKey[b.Key] = b + } + if len(list) != 3 { + t.Fatalf("want 3 rows after merge, got %d: %+v", len(list), list) + } + merged, ok := byKey["asura:swordmasters-youngest-son"] + if !ok { + t.Fatalf("merged key missing: %+v", byKey) + } + // Newest row wins the merge. + if merged.Title != "Re-bookmarked" || merged.LastChapterNum != 60 || merged.UpdatedAt != 200 { + t.Fatalf("merge kept wrong row: %+v", merged) + } + if merged.SeriesID != "swordmasters-youngest-son" { + t.Fatalf("series_id not stripped: %q", merged.SeriesID) + } + if _, ok := byKey["asura:overgeared-059befe1"]; ok { + t.Fatal("single-generation hashed key not rewritten") + } + if _, ok := byKey["asura:overgeared"]; !ok { + t.Fatal("single-generation row missing under stripped key") + } + if _, ok := byKey["demonic:x-deadbeef"]; !ok { + t.Fatal("non-asura row touched") + } + + // Idempotent: a third open changes nothing. + third, err := OpenStore(dbPath) + if err != nil { + t.Fatalf("third open: %v", err) + } + third.Close() +}