naco-api/internal/activitypub/client.go
Nacorid c96447e3e0
fix(activitypub): harden client, add outbox fallback and throttled GTS warming
Extract ActivityPub outbox walk into internal/activitypub/outbox.go with
webfinger/actor resolution, Create/Note filtering and IRI dereferencing.
Fix searchStatus returning wrong post, nil-account panic in SearchActors,
@local handle resolution, empty Author via noteToInternalPost, protocol-
relative URL handling, pagination loops, limit clamping (200) and cacheErr
masking. Add per-host token-bucket throttle (1/s, cross-host concurrency)
in throttle.go and background GTS warming via search?resolve for outbox-
fetched posts without following remotes. Align AGENTS.md.

Co-Authored-By: internal-model
2026-09-10 09:59:44 +00:00

458 lines
11 KiB
Go

package activitypub
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"sync"
"time"
"github.com/nacorid/naco-api/internal/actor"
)
const maxMastodonPageSize = 40
const maxTimeframePages = 100
const maxResponseBody = 1 << 20
const maxLimit = 200
type APClient struct {
client *http.Client
host string
bearerToken string
outbox *outboxClient
limiter *hostLimiter
}
func NewAPClient(host, apikey string) (*APClient, error) {
if host == "" || apikey == "" {
return nil, fmt.Errorf("host and apikey must not be empty")
}
return &APClient{
client: &http.Client{Timeout: 30 * time.Second},
host: strings.TrimRight(host, "/"),
bearerToken: "Bearer " + apikey,
outbox: newOutboxClient(),
limiter: newHostLimiter(),
}, nil
}
func (ap *APClient) get(ctx context.Context, endpoint string, target any) error {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Authorization", ap.bearerToken)
resp, err := ap.client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
errBody, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
return fmt.Errorf("api error (status %d): %s", resp.StatusCode, string(errBody))
}
if err := json.NewDecoder(io.LimitReader(resp.Body, maxResponseBody)).Decode(target); err != nil {
return fmt.Errorf("failed to decode response: %w", err)
}
return nil
}
func (ap *APClient) accountSearch(ctx context.Context, query string) (*Account, error) {
endpoint := fmt.Sprintf("%s/api/v1/accounts/search?q=%s&resolve=true&limit=1", ap.host, url.QueryEscape(query))
var accounts []*Account
if err := ap.get(ctx, endpoint, &accounts); err != nil {
return nil, fmt.Errorf("failed to search accounts: %w", err)
}
for _, acc := range accounts {
if acc != nil {
return acc, nil
}
}
return nil, nil
}
func (ap *APClient) resolveAccount(ctx context.Context, actorHandle string) (*Account, error) {
handle := strings.TrimPrefix(actorHandle, "@")
acc, err := ap.accountSearch(ctx, handle)
if err != nil {
return nil, err
}
if acc != nil {
return acc, nil
}
if !strings.Contains(handle, "@") {
var byID Account
endpoint := fmt.Sprintf("%s/api/v1/accounts/%s", ap.host, url.PathEscape(handle))
if err := ap.get(ctx, endpoint, &byID); err != nil {
return nil, fmt.Errorf("account %q not found by search or id: %w", actorHandle, err)
}
if byID.ID == "" {
return nil, fmt.Errorf("account %q not found", actorHandle)
}
return &byID, nil
}
return nil, fmt.Errorf("account %q not found", actorHandle)
}
func (ap *APClient) GetPost(ctx context.Context, postID string) (*Post, error) {
status, err := ap.resolveStatus(ctx, postID)
if err != nil {
return nil, err
}
return status.toPost(), nil
}
func (ap *APClient) resolveStatus(ctx context.Context, postID string) (*internalPost, error) {
if strings.HasPrefix(postID, "http://") || strings.HasPrefix(postID, "https://") || strings.HasPrefix(postID, "//") {
if status, err := ap.searchStatus(ctx, postID); err == nil && status != nil {
return status, nil
}
note, derr := ap.outbox.resolveNote(ctx, postID)
if derr != nil {
return nil, fmt.Errorf("status %q not resolvable: %w", postID, derr)
}
author := extractHandleFromURL(postID)
return noteToInternalPost(note, author), nil
}
endpoint := fmt.Sprintf("%s/api/v1/statuses/%s", ap.host, url.PathEscape(postID))
var status internalPost
if err := ap.get(ctx, endpoint, &status); err != nil {
return nil, fmt.Errorf("failed to get post: %w", err)
}
return &status, nil
}
func (ap *APClient) searchStatus(ctx context.Context, postID string) (*internalPost, error) {
endpoint := fmt.Sprintf("%s/api/v2/search?q=%s&resolve=true&type=statuses&limit=1", ap.host, url.QueryEscape(postID))
var res searchResult
if err := ap.get(ctx, endpoint, &res); err != nil {
return nil, fmt.Errorf("failed to resolve status %q: %w", postID, err)
}
for _, s := range res.Statuses {
if s != nil && (s.URL == postID || s.URI == postID) {
return s, nil
}
}
return nil, nil
}
func (ap *APClient) GetLatestNPosts(ctx context.Context, actorID string, limit int) ([]*Post, error) {
if limit <= 0 {
return nil, nil
}
if limit > maxLimit {
limit = maxLimit
}
acc, err := ap.resolveAccount(ctx, actorID)
if err != nil {
return nil, err
}
cached, cacheErr := ap.cacheLatestPosts(ctx, acc, limit)
if cacheErr != nil {
return nil, cacheErr
}
if len(cached) >= limit {
return cachePostsToPosts(cached), nil
}
if !isRemoteAccount(acc) || acc.URL == "" {
return cachePostsToPosts(cached), nil
}
outboxPosts, apErr := ap.outbox.getLatest(ctx, acc.URL, acc.Acct, limit-len(cached))
if apErr != nil {
return nil, fmt.Errorf("outbox fallback failed: %w", apErr)
}
merged := mergePostsBySource(cached, outboxPosts)
if len(outboxPosts) > 0 {
urls := make([]string, 0, len(outboxPosts))
for _, p := range outboxPosts {
if p != nil && p.ID != "" {
urls = append(urls, p.ID)
}
}
go ap.warmGTSAsync(context.WithoutCancel(ctx), urls)
}
return merged, nil
}
func (ap *APClient) cacheLatestPosts(ctx context.Context, acc *Account, limit int) ([]*internalPost, error) {
var (
internal []*internalPost
maxID string
)
for len(internal) < limit {
if ctx.Err() != nil {
return nil, ctx.Err()
}
pageSize := limit - len(internal)
if pageSize > maxMastodonPageSize {
pageSize = maxMastodonPageSize
}
endpoint := fmt.Sprintf("%s/api/v1/accounts/%s/statuses?limit=%d", ap.host, url.PathEscape(acc.ID), pageSize)
if maxID != "" {
endpoint += "&max_id=" + url.QueryEscape(maxID)
}
var page []*internalPost
if err := ap.get(ctx, endpoint, &page); err != nil {
return nil, err
}
if len(page) == 0 {
break
}
newMaxID := page[len(page)-1].ID
if newMaxID == "" {
break
}
if maxID != "" && newMaxID == maxID {
break
}
internal = append(internal, page...)
maxID = newMaxID
}
if len(internal) > limit {
internal = internal[:limit]
}
return internal, nil
}
func (ap *APClient) GetPostsInTimeframe(ctx context.Context, actorID string, start, end time.Time, limit int) ([]*Post, error) {
if limit <= 0 {
return nil, nil
}
if limit > maxLimit {
limit = maxLimit
}
acc, err := ap.resolveAccount(ctx, actorID)
if err != nil {
return nil, err
}
cached, cacheErr := ap.cachePostsInTimeframe(ctx, acc, start, end, limit)
if cacheErr != nil {
return nil, cacheErr
}
if len(cached) >= limit {
return cachePostsToPosts(cached), nil
}
if !isRemoteAccount(acc) || acc.URL == "" {
return cachePostsToPosts(cached), nil
}
outboxPosts, apErr := ap.outbox.getInTimeframe(ctx, acc.URL, acc.Acct, start, end, limit-len(cached))
if apErr != nil {
return nil, fmt.Errorf("outbox fallback failed: %w", apErr)
}
merged := mergePostsBySource(cached, outboxPosts)
if len(outboxPosts) > 0 {
urls := make([]string, 0, len(outboxPosts))
for _, p := range outboxPosts {
if p != nil && p.ID != "" {
urls = append(urls, p.ID)
}
}
go ap.warmGTSAsync(context.WithoutCancel(ctx), urls)
}
return merged, nil
}
func (ap *APClient) cachePostsInTimeframe(ctx context.Context, acc *Account, start, end time.Time, limit int) ([]*internalPost, error) {
var (
internal []*internalPost
maxID string
)
for pages := 0; pages < maxTimeframePages; pages++ {
if ctx.Err() != nil {
return nil, ctx.Err()
}
if len(internal) >= limit {
break
}
endpoint := fmt.Sprintf("%s/api/v1/accounts/%s/statuses?limit=%d", ap.host, url.PathEscape(acc.ID), maxMastodonPageSize)
if maxID != "" {
endpoint += "&max_id=" + url.QueryEscape(maxID)
}
var page []*internalPost
if err := ap.get(ctx, endpoint, &page); err != nil {
return nil, err
}
if len(page) == 0 {
break
}
newMaxID := page[len(page)-1].ID
if newMaxID == "" {
break
}
if maxID != "" && newMaxID == maxID {
break
}
for _, i := range page {
if i.CreatedAt.After(end) {
continue
}
if i.CreatedAt.Before(start) {
return internal, nil
}
internal = append(internal, i)
if len(internal) >= limit {
return internal, nil
}
}
maxID = newMaxID
}
return internal, nil
}
func isRemoteAccount(acc *Account) bool {
return acc != nil && strings.Contains(acc.Acct, "@")
}
func cachePostsToPosts(cached []*internalPost) []*Post {
if len(cached) == 0 {
return nil
}
posts := make([]*Post, 0, len(cached))
for _, c := range cached {
if c == nil {
continue
}
posts = append(posts, c.toPost())
}
return posts
}
func (ap *APClient) GetActor(ctx context.Context, actorHandle string) (*actor.Profile, error) {
acc, err := ap.resolveAccount(ctx, actorHandle)
if err != nil {
return nil, err
}
return ap.toProfile(acc), nil
}
func (ap *APClient) SearchActors(ctx context.Context, query string, limit int) ([]*actor.Profile, error) {
endpoint := fmt.Sprintf("%s/api/v1/accounts/search?q=%s&resolve=true&limit=%d", ap.host, url.QueryEscape(query), limit)
var accounts []*Account
if err := ap.get(ctx, endpoint, &accounts); err != nil {
return nil, fmt.Errorf("failed to search actors: %w", err)
}
results := make([]*actor.Profile, 0, len(accounts))
for _, acc := range accounts {
if acc == nil {
continue
}
results = append(results, ap.toProfile(acc))
}
return results, nil
}
func (ap *APClient) toProfile(acc *Account) *actor.Profile {
return &actor.Profile{
Platform: "ActivityPub",
Handle: acc.Acct,
DisplayName: acc.DisplayName,
AvatarURL: acc.Avatar,
Bio: acc.Note,
FollowersCount: acc.FollowersCount,
FollowingCount: acc.FollowingCount,
PostCount: acc.StatusesCount,
URL: acc.URL,
}
}
func extractHandleFromURL(postURL string) string {
u, err := url.Parse(postURL)
if err != nil {
return ""
}
parts := strings.Split(strings.TrimPrefix(u.Path, "/"), "/")
for _, p := range parts {
if strings.HasPrefix(p, "@") {
return strings.TrimPrefix(p, "@")
}
}
return ""
}
func (ap *APClient) warmGTSAsync(ctx context.Context, urls []string) {
if len(urls) == 0 {
return
}
if ap.limiter == nil {
ap.limiter = newHostLimiter()
}
ctx, cancel := context.WithTimeout(ctx, 5*time.Minute)
defer cancel()
seen := make(map[string]struct{}, len(urls))
uniq := make([]string, 0, len(urls))
for _, u := range urls {
if _, ok := seen[u]; ok {
continue
}
seen[u] = struct{}{}
uniq = append(uniq, u)
}
if len(uniq) > 50 {
uniq = uniq[:50]
}
byHost := make(map[string][]string)
for _, u := range uniq {
parsed, err := url.Parse(u)
if err != nil || parsed.Host == "" {
continue
}
byHost[parsed.Host] = append(byHost[parsed.Host], u)
}
if len(byHost) == 0 {
return
}
var wg sync.WaitGroup
for host, us := range byHost {
wg.Add(1)
go func(host string, us []string) {
defer wg.Done()
for _, u := range us {
if err := ap.limiter.Wait(ctx, host); err != nil {
return
}
if ctx.Err() != nil {
return
}
var res searchResult
_ = ap.get(ctx, fmt.Sprintf("%s/api/v2/search?q=%s&resolve=true&type=statuses&limit=1", ap.host, url.QueryEscape(u)), &res)
}
}(host, us)
}
wg.Wait()
}