package fa import ( "context" "fmt" "io" "net/http" "net/url" "strings" "github.com/PuerkitoBio/goquery" "git.anthrove.art/public/go-fa-api/internal/urls" ) // Fav adds a submission to the logged-in user's favorites. Idempotent: if // the submission is already favorited, the call is a no-op. // // Implementation note: FA gates this with a one-shot CSRF key that lives // on the "+Fav" anchor on the submission page. We fetch the submission to // scrape the key, then follow the link. The whole exchange happens // through the rate-limited transport. func (c *Client) Fav(ctx context.Context, id SubmissionID, opts ...Option) error { return c.toggleFavorite(ctx, id, true, opts) } // Unfav removes a submission from the logged-in user's favorites. // Idempotent: if not favorited, no-op. func (c *Client) Unfav(ctx context.Context, id SubmissionID, opts ...Option) error { return c.toggleFavorite(ctx, id, false, opts) } // toggleFavorite implements both Fav and Unfav: scrape the per-state link // off the submission page and follow it. wantFav=true means "fav if not // already faved"; wantFav=false means "unfav if currently faved". func (c *Client) toggleFavorite(ctx context.Context, id SubmissionID, wantFav bool, reqOpts []Option) error { if id <= 0 { return fmt.Errorf("fa: toggleFavorite: id must be > 0") } var actionURL string var alreadyInDesiredState bool err := c.fetch(ctx, urls.Submission(int64(id)), func(doc *goquery.Document) error { fav, unfav := findFavLinks(doc, int64(id)) switch { case wantFav && fav != "": actionURL = fav case wantFav && fav == "" && unfav != "": alreadyInDesiredState = true case !wantFav && unfav != "": actionURL = unfav case !wantFav && unfav == "" && fav != "": alreadyInDesiredState = true default: return fmt.Errorf("%w: submission %d: no fav/unfav link on page (not logged in?)", ErrUnauthorized, id) } return nil }, reqOpts...) if err != nil { return err } if alreadyInDesiredState { return nil } return c.followAction(ctx, actionURL, reqOpts...) } // Watch starts watching a user. Idempotent: if already watching, no-op. // The key + endpoint live on a button on the user's profile (or any page // that user is the "owner" of, like a journal). We scrape the profile. func (c *Client) Watch(ctx context.Context, name string, opts ...Option) error { return c.toggleWatch(ctx, name, true, opts) } // Unwatch stops watching a user. Idempotent. func (c *Client) Unwatch(ctx context.Context, name string, opts ...Option) error { return c.toggleWatch(ctx, name, false, opts) } // toggleWatch fetches the user page, picks watch or unwatch link by state. func (c *Client) toggleWatch(ctx context.Context, name string, wantWatch bool, reqOpts []Option) error { name = strings.TrimSpace(name) if name == "" { return fmt.Errorf("fa: toggleWatch: empty name") } var actionURL string var alreadyInDesiredState bool err := c.fetch(ctx, urls.User(name), func(doc *goquery.Document) error { watch, unwatch := findWatchLinks(doc, name) switch { case wantWatch && watch != "": actionURL = watch case wantWatch && watch == "" && unwatch != "": alreadyInDesiredState = true case !wantWatch && unwatch != "": actionURL = unwatch case !wantWatch && unwatch == "" && watch != "": alreadyInDesiredState = true default: return fmt.Errorf("%w: user %q: no watch/unwatch link on page", ErrUnauthorized, name) } return nil }, reqOpts...) if err != nil { return err } if alreadyInDesiredState { return nil } return c.followAction(ctx, actionURL, reqOpts...) } // findFavLinks scans a submission view page for the "+Fav" and "−Fav" // anchors that FA renders side by side (only one is real at a time — // whichever matches your current favorite state). Returns absolute URLs; // either may be "" if the page is anonymous-mode or doesn't show controls. func findFavLinks(doc *goquery.Document, subID int64) (favURL, unfavURL string) { subStr := fmt.Sprintf("/fav/%d/", subID) unfavStr := fmt.Sprintf("/unfav/%d/", subID) doc.Find("a[href*='/fav/'], a[href*='/unfav/']").Each(func(_ int, a *goquery.Selection) { href, _ := a.Attr("href") switch { case strings.HasPrefix(href, subStr): favURL = urls.AbsoluteCDN(href) case strings.HasPrefix(href, unfavStr): unfavURL = urls.AbsoluteCDN(href) } }) return favURL, unfavURL } // findWatchLinks scans a user page for the Watch / Unwatch button. // FA renders id="watch-button" on the active anchor with href // "/watch/{name}/?key=..." or "/unwatch/{name}/?key=...". func findWatchLinks(doc *goquery.Document, name string) (watchURL, unwatchURL string) { wPrefix := "/watch/" + strings.ToLower(name) + "/" uwPrefix := "/unwatch/" + strings.ToLower(name) + "/" doc.Find("a[href*='/watch/'], a[href*='/unwatch/']").Each(func(_ int, a *goquery.Selection) { href, _ := a.Attr("href") switch { case strings.HasPrefix(href, wPrefix): watchURL = urls.AbsoluteCDN(href) case strings.HasPrefix(href, uwPrefix): unwatchURL = urls.AbsoluteCDN(href) } }) return watchURL, unwatchURL } // followAction performs a one-shot GET to a fav/watch/etc. action URL and // classifies the response. FA typically redirects to the originating page // on success (handled transparently by the stdlib client), so a 2xx final // status means the action took effect. // // 4xx is surfaced as ErrUnauthorized / ErrNotFound / ErrSystemMessage via // the transport+classifier; 5xx becomes HTTPError. We don't parse the // response body FA's success states are too varied to verify reliably // from HTML alone. func (c *Client) followAction(ctx context.Context, actionURL string, opts ...Option) error { if actionURL == "" { return fmt.Errorf("fa: followAction: empty URL") } ctx = c.applyRequestOptions(ctx, opts) req, err := http.NewRequestWithContext(ctx, http.MethodGet, actionURL, nil) if err != nil { return err } // Rate limit + retries are handled by the transport on c.http. resp, err := c.http.Do(req) if err != nil { return err } defer resp.Body.Close() // Drain and classify. If the response is FA's System Error template we // surface that; otherwise a 2xx response is taken as success. body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) if resp.StatusCode == http.StatusOK || resp.StatusCode == http.StatusFound { // Parse out a possible system-message wrapper. if doc, derr := goquery.NewDocumentFromReader(strings.NewReader(string(body))); derr == nil { if smErr := classifySystemMessage(doc); smErr != nil { return smErr } } return nil } return &HTTPError{StatusCode: resp.StatusCode, URL: actionURL} } // postForm is the shared form-POST helper used by PostComment, SendNote, // and any other M3 write action. It honours the rate limiter, sets the // proper Content-Type, and surfaces system-message errors from the // response body. // // Returns the response body for callers that need to parse a confirmation // (e.g., to extract a newly-posted comment's ID). func (c *Client) postForm(ctx context.Context, rawURL string, form url.Values, opts ...Option) ([]byte, error) { ctx = c.applyRequestOptions(ctx, opts) req, err := http.NewRequestWithContext(ctx, http.MethodPost, rawURL, strings.NewReader(form.Encode())) if err != nil { return nil, err } req.Header.Set("Content-Type", "application/x-www-form-urlencoded") resp, err := c.http.Do(req) if err != nil { return nil, err } defer resp.Body.Close() body, _ := io.ReadAll(io.LimitReader(resp.Body, 4<<20)) if resp.StatusCode >= 400 { return body, &HTTPError{StatusCode: resp.StatusCode, URL: rawURL} } if doc, derr := goquery.NewDocumentFromReader(strings.NewReader(string(body))); derr == nil { if smErr := classifySystemMessage(doc); smErr != nil { return body, smErr } } return body, nil }