Federation Refactoring & Architecture Improvements (#930)

* initial commit

* add lists

* update permissions

* fix waypoint create

* needs_full_sync for activitypub trails

* fixes build issues

* fix list get

* further CSRF protection

* update API docs

* adds rate limiter

* fix tiptap mentions

* a bit more cleanup of main.go

* Fix migration order

* fixes reviewed notes

* require context for activitpub server calls

* improve hashing for identifier

* fix copy paste error

* fix sync trail/list issues

* fix summit log/comment duplicates

* adaptions after trail merge

* fix dockerignore

---------

Co-authored-by: Christian Beutel <>
Co-authored-by: slothful-vassal <89943360+slothful-vassal@users.noreply.github.com>
This commit is contained in:
Flomp
2026-05-09 17:30:34 +02:00
committed by GitHub
parent 992ebfc0c4
commit d2ac49470a
73 changed files with 3773 additions and 2153 deletions

View File

@@ -0,0 +1,165 @@
package routes
import (
"context"
"errors"
"fmt"
"io"
"math"
"net/http"
"pocketbase/federation"
"pocketbase/util"
"strconv"
"sync"
"time"
pub "github.com/go-ap/activitypub"
"github.com/pocketbase/pocketbase/core"
)
func RemoteProfileFollowsList(e *core.RequestEvent) error {
handle := e.Request.PathValue("handle")
if handle == "" {
return e.BadRequestError("Missing required parameter 'handle'", nil)
}
followType := e.Request.URL.Query().Get("type")
if followType != "following" {
followType = "followers"
}
pageQuery := e.Request.URL.Query().Get("page")
if pageQuery == "" {
pageQuery = "1"
}
page, _ := strconv.Atoi(pageQuery)
var userActor *core.Record
if e.Auth != nil {
userActor, _ = e.App.FindFirstRecordByData("activitypub_actors", "user", e.Auth.Id)
}
ctx, err := util.GetSafeActorContext(e.Request, userActor)
if err != nil {
return err
}
// 1. Resolve Target Actor
actor, err := federation.GetActorByHandle(e.App, ctx, handle, false)
if err != nil {
return e.NotFoundError("Actor not found", err)
}
collectionIRI := actor.GetString(followType)
if collectionIRI == "" {
return e.BadRequestError(fmt.Sprintf("Actor has no %s collection", followType), nil)
}
// 2. Fetch Remote Content
client := util.SafeHTTPClient()
req, _ := http.NewRequestWithContext(ctx, "GET", fmt.Sprintf("%s?page=%d", collectionIRI, page), nil)
req.Header.Set("Accept", "application/activity+json")
resp, err := client.Do(req)
if err != nil || resp.StatusCode != http.StatusOK {
if errors.Is(err, util.ErrRateLimited) {
return e.TooManyRequestsError("Too many requests", err)
}
return e.InternalServerError("Failed to fetch remote collection", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return e.InternalServerError("Failed to read response body", err)
}
// 3. Proper Unmarshaling using go-ap
// This returns a pub.Item interface which could be an OrderedCollection,
// OrderedCollectionPage, or even a simple Object.
data, err := pub.UnmarshalJSON(body)
if err != nil {
return e.InternalServerError("Failed to unmarshal ActivityPub JSON", err)
}
var items pub.ItemCollection
var totalItems uint = 0
// 4. Type assertion using go-ap's type switch pattern
err = pub.OnOrderedCollectionPage(data, func(p *pub.OrderedCollectionPage) error {
items = p.OrderedItems
totalItems = p.TotalItems
return nil
})
// Fallback: some instances might return a plain OrderedCollection
// if the page isn't strictly formatted as a Page object
if err != nil || items == nil {
_ = pub.OnOrderedCollection(data, func(c *pub.OrderedCollection) error {
items = c.OrderedItems
totalItems = c.TotalItems
return nil
})
}
// 5. Resolve IRIs to Local Records
timeoutCtx, cancel := context.WithTimeout(ctx, 3*time.Second)
defer cancel()
var mu sync.Mutex
var wg sync.WaitGroup
resolvedItems := make([]*core.Record, 0, len(items))
for _, item := range items {
iri := item.GetLink().String()
if iri == "" {
continue
}
wg.Add(1)
go func(actorIRI string) {
defer wg.Done()
// We use a channel to wrap the GetActorByIRI call
// so we can respect the context timeout
done := make(chan *core.Record, 1)
go func() {
// Pass false to sync to prevent deep recursion/heavy syncing if possible
res, err := federation.GetActorByIRI(e.App, timeoutCtx, actorIRI, false)
if err == nil {
done <- res
} else {
done <- nil
}
}()
select {
case itemActor := <-done:
if itemActor != nil {
mu.Lock()
resolvedItems = append(resolvedItems, itemActor)
mu.Unlock()
}
case <-ctx.Done():
// Timeout reached for this specific resolution
return
}
}(iri)
}
wg.Wait()
// 6. Pagination Metadata
perPage := 10
if len(items) > 0 {
perPage = len(items)
}
return e.JSON(http.StatusOK, map[string]any{
"page": page,
"perPage": perPage,
"totalItems": totalItems,
"totalPages": math.Ceil(float64(totalItems) / float64(perPage)),
"items": resolvedItems,
})
}