fix(backend): strip rotating Asura build hash from bookmark keys
This commit is contained in:
@@ -4,6 +4,7 @@ import (
|
|||||||
"database/sql"
|
"database/sql"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
_ "modernc.org/sqlite"
|
_ "modernc.org/sqlite"
|
||||||
@@ -120,6 +121,10 @@ func OpenStore(path string) (*Store, error) {
|
|||||||
db.Close()
|
db.Close()
|
||||||
return nil, fmt.Errorf("migrate schema: %w", err)
|
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
|
return &Store{db: db}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -141,6 +146,72 @@ func migrateColumns(db *sql.DB) error {
|
|||||||
return nil
|
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) {
|
func existingColumns(db *sql.DB, table string) (map[string]struct{}, error) {
|
||||||
rows, err := db.Query(`SELECT name FROM pragma_table_info(?)`, table)
|
rows, err := db.Query(`SELECT name FROM pragma_table_info(?)`, table)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -905,3 +905,81 @@ func TestDueForLatestCheckSkipsFinishedKeepsArchived(t *testing.T) {
|
|||||||
t.Fatal("due included a finished series")
|
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