217 lines
7.4 KiB
Go
217 lines
7.4 KiB
Go
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) error {
|
||
return c.toggleFavorite(ctx, id, true)
|
||
}
|
||
|
||
// 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) error {
|
||
return c.toggleFavorite(ctx, id, false)
|
||
}
|
||
|
||
// 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) 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
|
||
})
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if alreadyInDesiredState {
|
||
return nil
|
||
}
|
||
return c.followAction(ctx, actionURL)
|
||
}
|
||
|
||
// 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) error {
|
||
return c.toggleWatch(ctx, name, true)
|
||
}
|
||
|
||
// Unwatch stops watching a user. Idempotent.
|
||
func (c *Client) Unwatch(ctx context.Context, name string) error {
|
||
return c.toggleWatch(ctx, name, false)
|
||
}
|
||
|
||
// toggleWatch fetches the user page, picks watch or unwatch link by state.
|
||
func (c *Client) toggleWatch(ctx context.Context, name string, wantWatch bool) 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
|
||
})
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if alreadyInDesiredState {
|
||
return nil
|
||
}
|
||
return c.followAction(ctx, actionURL)
|
||
}
|
||
|
||
// 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) error {
|
||
if actionURL == "" {
|
||
return fmt.Errorf("fa: followAction: empty URL")
|
||
}
|
||
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) ([]byte, error) {
|
||
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
|
||
}
|