inital commit

This commit is contained in:
2026-05-25 22:27:18 +02:00
commit 965f9d6ad4
91 changed files with 28963 additions and 0 deletions

216
actions.go Normal file
View File

@@ -0,0 +1,216 @@
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
}