From a review against what got other clients banned: a 4003 on anything but a login is an old token (as Schedules Direct sometimes says), so it gets one new login rather than stopping the account; pictures are fetched from where they redirect without the token going along to S3; programs are asked for 2000 at a time rather than the 5000 allowed; and up to 2000 pictures an update, of a subscriber's 5000 a day. Saving the account again lifts a picture pause, as a subscription lifts a trial's cap.
- commit
- a2588d9a50700d8c33a35bbe8356f87f04d6af32
- parent
- 9633ca2e7b6a5c1e87223a037d485fc6a0029978
- author
- FelineStateMachine <226808055+FelineStateMachine@users.noreply.github.com>
- kind
- fix risk score 1.7 of 3, Jev's estimate
| M | internal/ | 18 | ++++++++-- |
| M | internal/ | 7 | ++-- |
| M | internal/ | 55 | ++++++++++++++++++++++++++++++ |
| M | internal/ | 4 | ++- |
@@ -24,8 +24,8 @@ import (
2424 // a week.2525 const (2626 maxArtChunk = 500 // keys per /metadata/programs27- maxArtLookups = 1000 // keys looked up per update28- maxArtFetches = 500 // downloads per update27+ maxArtLookups = 2000 // keys looked up per update28+ maxArtFetches = 2000 // downloads per update, of a subscriber's 5000 a day2929 artRecheck = 7 * 24 * time.Hour3030 )3131 @@ -239,7 +239,19 @@ func (c *client) fetchPicture(ctx context.Context, uri, path string) error {
239239 }240240 req.Header.Set("User-Agent", c.ua)241241 req.Header.Set("Token", c.st.Token)242- resp, err := c.http.Do(req)242+ // The picture is wherever it redirects to (S3): the token isn't for243+ // there.244+ hc := *c.http245+ hc.CheckRedirect = func(r *http.Request, via []*http.Request) error {246+ if len(via) >= 5 {247+ return errors.New("too many redirects")248+ }249+ if r.URL.Host != via[0].URL.Host {250+ r.Header.Del("Token")251+ }252+ return nil253+ }254+ resp, err := hc.Do(req)243255 if err != nil {244256 return err245257 }@@ -21,7 +21,7 @@ const API = "https://json.schedulesdirect.org/20141201/"
2121 // Request limits.2222 const (2323 maxStations = 5000 // per /schedules and /schedules/md524- maxPrograms = 5000 // per /programs24+ maxPrograms = 2000 // per /programs (5000 at most; smaller is kinder)2525 )2626 2727 // StopError is a refusal that ends updating until the account is@@ -128,8 +128,9 @@ func (c *client) do(ctx context.Context, method, path string, in, out any) error
128128 var env envelope129129 if err := json.Unmarshal(trimmed, &env); err == nil && env.Code != nil && *env.Code != 0 {130130 code := *env.Code131- if code == 4006 && path != "token" && !c.relogged {132- // The token ran out: one new one, one retry.131+ // The token ran out (Schedules Direct may say so with 4003 as132+ // well as 4006 outside a login): one new one, one retry.133+ if (code == 4006 || code == 4003) && path != "token" && !c.relogged {133134 c.relogged = true134135 c.st.Token = ""135136 if err := c.login(ctx, true); err != nil {@@ -25,6 +25,8 @@ type fakeSD struct {
2525 md5 string2626 images []string // pictures downloaded2727 capped bool // pictures answer with the daily cap28+ stale bool // the next request gets 4003 once, as for an old token29+ s3 string // where pictures redirect to, when set2830 }2931 3032 func (f *fakeSD) ServeHTTP(w http.ResponseWriter, r *http.Request) {@@ -61,6 +63,11 @@ func (f *fakeSD) ServeHTTP(w http.ResponseWriter, r *http.Request) {
6163 fail(403, 4006, "TOKEN_EXPIRED")6264 return6365 }66+ if f.stale {67+ f.stale = false68+ fail(400, 4003, "INVALID_USER")69+ return70+ }6471 today := time.Now().UTC().Format(time.DateOnly)6572 if uri, ok := strings.CutPrefix(path, "image/"); ok {6673 if f.capped {@@ -68,6 +75,10 @@ func (f *fakeSD) ServeHTTP(w http.ResponseWriter, r *http.Request) {
6875 return6976 }7077 f.images = append(f.images, uri)78+ if f.s3 != "" {79+ http.Redirect(w, r, f.s3+"/"+uri, http.StatusSeeOther)80+ return81+ }7182 w.Header().Set("Content-Type", "image/jpeg")7283 fmt.Fprint(w, "JPEG "+uri)7384 return@@ -457,3 +468,47 @@ func TestArtworkCap(t *testing.T) {
457468 t.Fatal("a picture without a download")458469 }459470 }471+472+// TestStaleToken: a 4003 outside a login means an old token: one new473+// login, and the update goes on.474+func TestStaleToken(t *testing.T) {475+ s, f := newSource(t)476+ _ = s.SetAccount("viewer", "hunter2")477+ a, _ := LoadAccount(s.accountPath())478+ s.update(context.Background(), a)479+ f.mu.Lock()480+ f.stale, f.md5 = true, "day5"481+ f.mu.Unlock()482+ s.update(context.Background(), a)483+ if st := s.Status(); !st.OK || st.Stopped != "" || f.count("POST token") != 2 {484+ t.Fatalf("status %+v, %d logins", st, f.count("POST token"))485+ }486+}487+488+// TestPictureRedirect: pictures are fetched from where Schedules Direct489+// redirects, without the token.490+func TestPictureRedirect(t *testing.T) {491+ var mu sync.Mutex492+ var tokens []string493+ s3 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {494+ mu.Lock()495+ tokens = append(tokens, r.Header.Get("Token"))496+ mu.Unlock()497+ w.Header().Set("Content-Type", "image/jpeg")498+ fmt.Fprint(w, "JPEG from S3")499+ }))500+ defer s3.Close()501+ s, f := newSource(t)502+ f.s3 = strings.Replace(s3.URL, "127.0.0.1", "localhost", 1) // another host503+ _ = s.SetAccount("viewer", "hunter2")504+ a, _ := LoadAccount(s.accountPath())505+ s.update(context.Background(), a)506+ mu.Lock()507+ defer mu.Unlock()508+ if len(tokens) != 2 || tokens[0] != "" || tokens[1] != "" {509+ t.Fatalf("S3 saw tokens %q", tokens)510+ }511+ if s.Guide().Programs["20370"][0].Image == "" {512+ t.Fatal("no picture")513+ }514+}@@ -165,8 +165,10 @@ func (s *Source) SetAccount(user, password string) error {
165165 return err166166 }167167 s.mu.Lock()168- // A new login: try again, with a token of its own.168+ // A new login: try again, with a token of its own, and pictures too169+ // (a subscription lifts a trial's cap on them).169170 s.st.Stopped, s.st.Token, s.st.TokenExpires = "", "", time.Time{}171+ s.st.ImagesPaused = time.Time{}170172 s.status = Status{User: a.User}171173 if a.User == "" {172174 s.st = &state{}