Files
go-fa-api/actions.go
2026-05-26 20:21:55 +02:00

219 lines
7.7 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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
}