fix(backend): strip rotating Asura build hash from bookmark keys
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user