Skip to content

Commit 52e28b6

Browse files
authored
fix(topics): stop resolved titles being overwritten on scheduler check (#94)
* docs(90): spec placeholder-only self-heal gate for display name Design for issue #90. The scheduler self-heal overwrites a topic's stored title with check.DisplayName on any difference, so a noisier Check title clobbers a good add-time title every poll. Kinozal also fetches its Check title from the raw mirror host while the rest of the plugin pins kinozal.tv, producing the divergent title. Spec: a display_name_is_placeholder provenance flag (migration 0010) gates self-heal to placeholder-only and locks on first resolve; plus canonicalize kinozal's Check title host. Lock all existing rows. * docs(#90): add implementation plan for self-heal gate * feat(#90): add topics.display_name_is_placeholder column * feat(#90): flag placeholder vs resolved title at topic creation * fix(#90): gate display-name self-heal to placeholder titles only * feat(#90): lock display-name flag on resolve and user rename * fix(#90): canonicalize kinozal Check title host to p.domain * docs(#90): document display-name placeholder flag and gated self-heal Also apply gofmt to domain/domain.go, topics/create.go, and kinozal/kinozal_test.go (whitespace-only alignment fixes left by earlier tasks in the series).
1 parent 6f4b670 commit 52e28b6

13 files changed

Lines changed: 1202 additions & 72 deletions

File tree

CLAUDE.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ techdebt/ Debt-tracking files (one per issue, see global rule)
4949
| `cfsolver` | in-process client to the standalone `cfsolver/` service |
5050
| `config` | env-driven config struct (caarlos0/env) — **add new env vars here** |
5151
| `crypto` | AES-256-GCM for tracker credentials and client config blobs |
52-
| `db` / `db/repo` | pgxpool wrapper + repository structs (`Topics`, `Clients`, `Notifiers`, `Users`, `TrackerCredentials`, `Deliveries`, `Audit`, `Settings`). `Settings` (repo added for the Sonarr integration) reads/writes the singleton `settings` row — `GetSonarr`/`UpsertSonarr` (API key encrypted at rest like `oidc_client_secret_enc`, migration `0009`) + `UpdateSonarrCursor` (history-poll cursor). `Topics` adds `GetByURL(user,url)` for the poller's dedup pre-check; `Users` adds `GetInitialAdmin`. `TrackerCredentials` carries encrypted `secret_enc` (password) **and** `session_enc`/`session_nonce` (cookie-session blob; migration `0002`), plus a nullable `session_expired_at` marker (migration `0003`) the scheduler uses to dedupe expiry notifications (atomic `MarkSessionExpired`, cleared by `SetSession` on re-auth). `Deliveries` (migration `0006`, `topic_deliveries`) records every torrent pushed to a client — `{topic_id, infohash, label, client_id, delivered_at}`, unique on `(topic_id, infohash)` so `Record` is idempotent (`ON CONFLICT DO NOTHING`). `Notifiers` gains `is_default bool` (migration `0008`, per-type unique partial index) + `Update(ctx, id, userID, name, displayName string, events []string, isDefault bool, configEnc, configNonce []byte) error` |
52+
| `db` / `db/repo` | pgxpool wrapper + repository structs (`Topics`, `Clients`, `Notifiers`, `Users`, `TrackerCredentials`, `Deliveries`, `Audit`, `Settings`). `Settings` (repo added for the Sonarr integration) reads/writes the singleton `settings` row — `GetSonarr`/`UpsertSonarr` (API key encrypted at rest like `oidc_client_secret_enc`, migration `0009`) + `UpdateSonarrCursor` (history-poll cursor). `Topics` adds `GetByURL(user,url)` for the poller's dedup pre-check; `Users` adds `GetInitialAdmin`. `Topics` also carries `display_name_is_placeholder` (migration `0010`): true while the title is a tracker-generated placeholder, set false once resolved (metadata, first self-heal, or a user rename). `UpdateDisplayName` clears it; `Update` clears it only when the submitted name changes. `TrackerCredentials` carries encrypted `secret_enc` (password) **and** `session_enc`/`session_nonce` (cookie-session blob; migration `0002`), plus a nullable `session_expired_at` marker (migration `0003`) the scheduler uses to dedupe expiry notifications (atomic `MarkSessionExpired`, cleared by `SetSession` on re-auth). `Deliveries` (migration `0006`, `topic_deliveries`) records every torrent pushed to a client — `{topic_id, infohash, label, client_id, delivered_at}`, unique on `(topic_id, infohash)` so `Record` is idempotent (`ON CONFLICT DO NOTHING`). `Notifiers` gains `is_default bool` (migration `0008`, per-type unique partial index) + `Update(ctx, id, userID, name, displayName string, events []string, isDefault bool, configEnc, configNonce []byte) error` |
5353
| **`notify`** | reusable notification dispatcher — `Send(userID, domain.Message)` fans out to all of a user's configured notifiers (best-effort, metered); `SendVia(userID, notifierID, …)` scopes the same fan-out to one notifier when a topic overrides it (nil ⇒ the user's **default** notifiers only (strict; none set ⇒ no send)). Consumers: scheduler new-release alerts + topic-error alerts (both per-topic via `SendVia`; error events deduped to fire once per error episode, on the first failure) + credential session-expiry alerts (global). The single event→notifier fan-out point |
5454
| `domain` | core types: `Topic` (incl. per-topic `ClientID`, **`NotifierID`** (per-topic notifier override, migration `0007`), `DownloadDir`, `Category`, `ImageURL`), `Check`, `Payload`, `TrackerCredential`, `TopicDelivery`, `AddOptions` (`DownloadDir` + `Category`) |
5555
| **`topics`** | shared `BuildAndCreate(store, CreateInput)` — the one tracker-match → `Parse` → fail-open `ResolveMetadata` → build → persist sequence, used by BOTH the `POST /topics` handler and the Sonarr poller (no duplication). Idempotent: a `(user_id,url)` unique-violation returns `Result{Created:false}` instead of erroring. Sentinels `ErrNoTracker`/`ErrParse`/`ErrQualityUnsupported` |
@@ -400,6 +400,8 @@ time so a new topic shows a real name + image immediately instead of a
400400
for the AddTopic form's preview card. The scheduler additionally self-heals the
401401
title on each check (`displayNamePersister``Topics.UpdateDisplayName`) when a
402402
plugin's `Check` reports a `DisplayName` that differs from what's stored. The
403+
self-heal is gated to placeholder titles only (`display_name_is_placeholder`)
404+
so a noisier `Check` title can never downgrade an already-resolved name (issue #90). The
403405
image is stored in `topics.image_url` (migration `0005`) and rendered as a
404406
graceful `<img>` (hidden on load error — no fake fallback).
405407

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
-- +goose Up
2+
-- +goose StatementBegin
3+
ALTER TABLE topics
4+
ADD COLUMN display_name_is_placeholder BOOLEAN NOT NULL DEFAULT false;
5+
-- +goose StatementEnd
6+
-- +goose Down
7+
-- +goose StatementBegin
8+
ALTER TABLE topics
9+
DROP COLUMN display_name_is_placeholder;
10+
-- +goose StatementEnd

backend/internal/db/repo/topics.go

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ const topicColumns = `id, user_id, tracker_name, url, display_name,
4040
COALESCE(download_dir,''), COALESCE(category,''), extra, COALESCE(last_hash,''),
4141
last_checked_at, last_updated_at, next_check_at,
4242
check_interval_sec, consecutive_errors, status,
43-
COALESCE(last_error,''), created_at, updated_at`
43+
COALESCE(last_error,''), created_at, updated_at, display_name_is_placeholder`
4444

4545
func scanTopic(row pgx.Row) (*domain.Topic, error) {
4646
var t domain.Topic
@@ -53,7 +53,7 @@ func scanTopic(row pgx.Row) (*domain.Topic, error) {
5353
&t.ImageURL, &clientID, &notifierID, &t.DownloadDir, &t.Category, &extraRaw, &t.LastHash,
5454
&lastChecked, &lastUpdated, &t.NextCheckAt,
5555
&t.CheckIntervalSec, &t.ConsecutiveErrors, &status,
56-
&t.LastError, &t.CreatedAt, &t.UpdatedAt,
56+
&t.LastError, &t.CreatedAt, &t.UpdatedAt, &t.DisplayNameIsPlaceholder,
5757
)
5858
if err != nil {
5959
return nil, err
@@ -83,12 +83,14 @@ func (r *Topics) Create(ctx context.Context, t *domain.Topic) (*domain.Topic, er
8383
extra, _ := json.Marshal(t.Extra)
8484
q := `
8585
INSERT INTO topics (user_id, tracker_name, url, display_name, image_url, client_id, notifier_id,
86-
download_dir, category, extra, check_interval_sec, next_check_at, status)
87-
VALUES ($1,$2,$3,$4,NULLIF($5,''),$6,$7,NULLIF($8,''),NULLIF($9,''),$10,$11,$12,$13)
86+
download_dir, category, extra, check_interval_sec, next_check_at, status,
87+
display_name_is_placeholder)
88+
VALUES ($1,$2,$3,$4,NULLIF($5,''),$6,$7,NULLIF($8,''),NULLIF($9,''),$10,$11,$12,$13,$14)
8889
RETURNING ` + topicColumns
8990
row := r.pool.QueryRow(ctx, q,
9091
t.UserID, t.TrackerName, t.URL, t.DisplayName, t.ImageURL, t.ClientID, t.NotifierID,
9192
t.DownloadDir, t.Category, extra, t.CheckIntervalSec, t.NextCheckAt, string(t.Status),
93+
t.DisplayNameIsPlaceholder,
9294
)
9395
return scanTopic(row)
9496
}
@@ -266,7 +268,9 @@ func (r *Topics) Update(ctx context.Context, id, userID uuid.UUID, displayName s
266268
}
267269
row := r.pool.QueryRow(ctx, `UPDATE topics SET
268270
display_name = $3, client_id = $4, notifier_id = $5, download_dir = $6, category = $7,
269-
extra = $8, updated_at = now()
271+
extra = $8,
272+
display_name_is_placeholder = CASE WHEN display_name <> $3 THEN false ELSE display_name_is_placeholder END,
273+
updated_at = now()
270274
WHERE id = $1 AND user_id = $2
271275
RETURNING `+topicColumns, id, userID, displayName, clientID, notifierID, downloadDir, category, raw)
272276
t, err := scanTopic(row)
@@ -287,7 +291,7 @@ func (r *Topics) UpdateDisplayName(ctx context.Context, id uuid.UUID, displayNam
287291
return nil
288292
}
289293
_, err := r.pool.Exec(ctx,
290-
`UPDATE topics SET display_name = $2, updated_at = now() WHERE id = $1`,
294+
`UPDATE topics SET display_name = $2, display_name_is_placeholder = false, updated_at = now() WHERE id = $1`,
291295
id, displayName,
292296
)
293297
return err

backend/internal/db/repo/topics_test.go

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -197,9 +197,9 @@ func TestTopics_MarkEpisodeDownloaded_DBError(t *testing.T) {
197197
// ---------- scanTopic malformed extra ----------
198198

199199
// topicRow returns a pgxmock row slice that matches topicColumns exactly
200-
// (21 columns as of migration 0007, which added notifier_id after
201-
// client_id). Callers override individual fields as needed. The helper
202-
// centralises column-order so tests don't drift.
200+
// (22 columns as of migration 0010, which added display_name_is_placeholder).
201+
// Callers override individual fields as needed. The helper centralises
202+
// column-order so tests don't drift.
203203
func topicRow(id, userID uuid.UUID, now time.Time) []any {
204204
return []any{
205205
id, userID, "faketracker", "https://example.invalid/t/1",
@@ -213,16 +213,17 @@ func topicRow(id, userID uuid.UUID, now time.Time) []any {
213213
(*time.Time)(nil), (*time.Time)(nil), now,
214214
3600, 0, "active",
215215
"", now, now,
216+
false, // display_name_is_placeholder
216217
}
217218
}
218219

219-
// topicColumnsAll mirrors the header slice for pgxmock.NewRows (21 cols).
220+
// topicColumnsAll mirrors the header slice for pgxmock.NewRows (22 cols).
220221
var topicColumnsAll = []string{
221222
"id", "user_id", "tracker_name", "url", "display_name", "image_url", "client_id", "notifier_id",
222223
"download_dir", "category", "extra", "last_hash",
223224
"last_checked_at", "last_updated_at", "next_check_at",
224225
"check_interval_sec", "consecutive_errors", "status",
225-
"last_error", "created_at", "updated_at",
226+
"last_error", "created_at", "updated_at", "display_name_is_placeholder",
226227
}
227228

228229
// TestTopics_ScanTopic_MalformedExtra drives GetByID through a mocked
@@ -237,7 +238,7 @@ func TestTopics_ScanTopic_MalformedExtra(t *testing.T) {
237238
userID := uuid.New()
238239
now := time.Now().UTC()
239240

240-
// Build a row that matches topicColumns exactly (21 columns).
241+
// Build a row that matches topicColumns exactly (22 columns).
241242
rows := pgxmock.NewRows(topicColumnsAll).AddRow(
242243
id, userID, "faketracker", "https://example.invalid/t/1",
243244
"My Topic", "", // display_name, image_url
@@ -248,6 +249,7 @@ func TestTopics_ScanTopic_MalformedExtra(t *testing.T) {
248249
(*time.Time)(nil), (*time.Time)(nil), now,
249250
3600, 0, "active",
250251
"", now, now,
252+
false, // display_name_is_placeholder
251253
)
252254

253255
mock.ExpectQuery(`SELECT .* FROM topics WHERE id = \$1`).
@@ -352,6 +354,7 @@ func TestTopics_Create_RoundTripsCategory(t *testing.T) {
352354
"movies", // category
353355
pgxmock.AnyArg(), // extra (JSON)
354356
3600, pgxmock.AnyArg(), "active",
357+
false, // display_name_is_placeholder
355358
).
356359
WillReturnRows(rows)
357360

@@ -490,6 +493,7 @@ func TestTopics_Create_RoundTripsNotifierID(t *testing.T) {
490493
"", "",
491494
pgxmock.AnyArg(),
492495
3600, pgxmock.AnyArg(), "active",
496+
false, // display_name_is_placeholder
493497
).
494498
WillReturnRows(rows)
495499

backend/internal/domain/domain.go

Lines changed: 26 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -65,27 +65,32 @@ const (
6565

6666
// Topic represents a URL that Marauder is monitoring.
6767
type Topic struct {
68-
ID uuid.UUID
69-
UserID uuid.UUID
70-
TrackerName string
71-
URL string
72-
DisplayName string
73-
ImageURL string
74-
ClientID *uuid.UUID
75-
NotifierID *uuid.UUID
76-
DownloadDir string
77-
Category string
78-
Extra map[string]any
79-
LastHash string
80-
LastCheckedAt *time.Time
81-
LastUpdatedAt *time.Time
82-
NextCheckAt time.Time
83-
CheckIntervalSec int
84-
ConsecutiveErrors int
85-
Status TopicStatus
86-
LastError string
87-
CreatedAt time.Time
88-
UpdatedAt time.Time
68+
ID uuid.UUID
69+
UserID uuid.UUID
70+
TrackerName string
71+
URL string
72+
DisplayName string
73+
ImageURL string
74+
// DisplayNameIsPlaceholder is true while DisplayName is a tracker-generated
75+
// placeholder (e.g. "Kinozal topic 123") eligible for scheduler self-heal.
76+
// Set false once a real title is resolved (metadata, first self-heal, or a
77+
// user rename) so self-heal can never downgrade a good title. See issue #90.
78+
DisplayNameIsPlaceholder bool
79+
ClientID *uuid.UUID
80+
NotifierID *uuid.UUID
81+
DownloadDir string
82+
Category string
83+
Extra map[string]any
84+
LastHash string
85+
LastCheckedAt *time.Time
86+
LastUpdatedAt *time.Time
87+
NextCheckAt time.Time
88+
CheckIntervalSec int
89+
ConsecutiveErrors int
90+
Status TopicStatus
91+
LastError string
92+
CreatedAt time.Time
93+
UpdatedAt time.Time
8994
}
9095

9196
// TrackerCredential holds a user's login details for a tracker plugin.

backend/internal/plugins/trackers/kinozal/kinozal.go

Lines changed: 23 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,22 @@ func extractImageURL(body []byte, host string) string {
178178
}
179179
}
180180

181+
// canonicalDetailsURL rebuilds the details URL from the trusted host (p.domain)
182+
// + the numeric id parsed from rawURL — never the raw user URL. Avoids request
183+
// forgery (CodeQL go/request-forgery) and pins the request to p.domain so
184+
// Check's title matches ResolveMetadata's (issue #90).
185+
func (p *plugin) canonicalDetailsURL(rawURL string) (string, error) {
186+
u, err := url.Parse(strings.TrimSpace(rawURL))
187+
if err != nil {
188+
return "", fmt.Errorf("kinozal: parse url: %w", err)
189+
}
190+
id, err := strconv.Atoi(u.Query().Get("id"))
191+
if err != nil {
192+
return "", fmt.Errorf("kinozal: topic id: %w", err)
193+
}
194+
return fmt.Sprintf("https://%s/details.php?id=%d", p.domain, id), nil
195+
}
196+
181197
// Check makes TWO requests per tick: the details page for the display title,
182198
// then get_srv_details.php for the infohash (Kinozal exposes the hash only
183199
// there, not on the details page). Both run under the scheduler's single
@@ -186,7 +202,11 @@ func extractImageURL(body []byte, host string) string {
186202
// already-fetched title is intentionally discarded — title self-heal simply
187203
// retries next tick rather than persisting a title with no matching hash.
188204
func (p *plugin) Check(ctx context.Context, topic *domain.Topic, creds *domain.TrackerCredential) (*domain.Check, error) {
189-
body, err := p.fetch(ctx, topic.URL, creds)
205+
canonical, err := p.canonicalDetailsURL(topic.URL)
206+
if err != nil {
207+
return nil, err
208+
}
209+
body, err := p.fetch(ctx, canonical, creds)
190210
if err != nil {
191211
return nil, err
192212
}
@@ -240,18 +260,10 @@ var _ registry.WithMetadata = (*plugin)(nil)
240260
// is publicly viewable, so a session isn't required. Image URL is "" when the
241261
// page exposes no poster; the caller treats errors as fail-open.
242262
func (p *plugin) ResolveMetadata(ctx context.Context, rawURL string, creds *domain.TrackerCredential) (*registry.Metadata, error) {
243-
m := urlPattern.FindStringSubmatch(strings.TrimSpace(rawURL))
244-
if m == nil {
245-
return nil, errors.New("resolve metadata: not a kinozal details URL")
246-
}
247-
id, err := strconv.Atoi(m[1])
263+
canonical, err := p.canonicalDetailsURL(rawURL)
248264
if err != nil {
249-
return nil, fmt.Errorf("resolve metadata: topic id: %w", err)
265+
return nil, fmt.Errorf("resolve metadata: %w", err)
250266
}
251-
// Rebuild the URL from the trusted host (p.domain) + the numeric id, never
252-
// the raw user-supplied URL, so a crafted URL cannot redirect the request
253-
// to an arbitrary host (CodeQL go/request-forgery). Mirrors Download.
254-
canonical := fmt.Sprintf("https://%s/details.php?id=%d", p.domain, id)
255267
body, err := p.fetch(ctx, canonical, creds)
256268
if err != nil {
257269
return nil, fmt.Errorf("resolve metadata: %w", err)

backend/internal/plugins/trackers/kinozal/kinozal_test.go

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -320,3 +320,52 @@ func TestDownload_ValidTopic_ReturnsTorrentBytes(t *testing.T) {
320320
t.Error("expected torrent bytes")
321321
}
322322
}
323+
324+
func TestCanonicalDetailsURL_RebuildsFromDomain(t *testing.T) {
325+
p := &plugin{domain: "kinozal.tv"}
326+
got, err := p.canonicalDetailsURL("https://kinozal.guru/details.php?id=2072973")
327+
if err != nil {
328+
t.Fatalf("canonicalDetailsURL: %v", err)
329+
}
330+
if want := "https://kinozal.tv/details.php?id=2072973"; got != want {
331+
t.Errorf("canonicalDetailsURL = %q, want %q", got, want)
332+
}
333+
}
334+
335+
// hostRecordingRewrite records the Host of every outgoing request, then
336+
// redirects it to the test server (target) over http so no real network is hit.
337+
type hostRecordingRewrite struct {
338+
target string // test server host:port (p.domain)
339+
hosts []string
340+
}
341+
342+
func (h *hostRecordingRewrite) RoundTrip(req *http.Request) (*http.Response, error) {
343+
h.hosts = append(h.hosts, req.URL.Host)
344+
req.URL.Scheme = "http"
345+
req.URL.Host = h.target
346+
return http.DefaultTransport.RoundTrip(req)
347+
}
348+
349+
func TestCheck_CanonicalizesMirrorHost(t *testing.T) {
350+
p := newTestPlugin(t) // p.domain == test server host
351+
rec := &hostRecordingRewrite{target: p.domain}
352+
p.transport = rec // applied per-session inside fetch()
353+
354+
// Topic URL points at a different mirror than p.domain.
355+
topic := &domain.Topic{URL: "https://kinozal.guru/details.php?id=99999"}
356+
check, err := p.Check(context.Background(), topic, nil)
357+
if err != nil {
358+
t.Fatalf("Check: %v", err)
359+
}
360+
if !strings.Contains(check.DisplayName, "The Movie") {
361+
t.Errorf("display name = %q, want it to contain The Movie", check.DisplayName)
362+
}
363+
if len(rec.hosts) == 0 {
364+
t.Fatal("no requests recorded")
365+
}
366+
// The FIRST request is the title/details fetch — it must target p.domain,
367+
// not the raw kinozal.guru mirror.
368+
if rec.hosts[0] != p.domain {
369+
t.Errorf("title fetch host = %q, want canonical %q", rec.hosts[0], p.domain)
370+
}
371+
}

backend/internal/scheduler/scheduler.go

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -398,10 +398,11 @@ func (s *Scheduler) runCheck(ctx context.Context, log zerolog.Logger, t *domain.
398398
}
399399
}
400400

401-
// Self-heal the display name: a tracker's Check often resolves the real
402-
// title (e.g. RuTracker's <title>) that wasn't available at add time.
403-
// Persist it when it changed so placeholder names upgrade themselves.
404-
if check.DisplayName != "" && check.DisplayName != t.DisplayName {
401+
// Self-heal the display name only while it's still a generated placeholder.
402+
// Once a real title is resolved (add-time metadata, a prior self-heal, or a
403+
// user rename) the topic is locked, so a noisier Check title can't downgrade
404+
// it (issue #90).
405+
if check.DisplayName != "" && check.DisplayName != t.DisplayName && t.DisplayNameIsPlaceholder {
405406
if p, ok := s.topics.(displayNamePersister); ok {
406407
if err := p.UpdateDisplayName(ctx, t.ID, check.DisplayName); err != nil {
407408
log.Warn().Err(err).Msg("UpdateDisplayName failed")

0 commit comments

Comments
 (0)