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

210
db/routes/activitypub.go Normal file
View File

@@ -0,0 +1,210 @@
package routes
import (
"database/sql"
"errors"
"fmt"
"io"
"net/http"
"os"
"pocketbase/federation"
"pocketbase/util"
"strconv"
"strings"
pub "github.com/go-ap/activitypub"
"github.com/pocketbase/pocketbase/core"
)
func ActivitypubActor(e *core.RequestEvent) error {
resource := e.Request.URL.Query().Get("resource")
resource = strings.TrimPrefix(resource, "acct:")
iri := e.Request.URL.Query().Get("iri")
follows := e.Request.URL.Query().Get("follows") == "true"
var userActor *core.Record
var err error
if e.Auth != nil {
userActor, err = e.App.FindFirstRecordByData("activitypub_actors", "user", e.Auth.Id)
if err != nil {
return err
}
}
ctx, err := util.GetSafeActorContext(e.Request, userActor)
if err != nil {
return err
}
var actor *core.Record
if resource != "" {
actor, err = federation.GetActorByHandle(e.App, ctx, resource, follows)
} else {
actor, err = federation.GetActorByIRI(e.App, ctx, iri, follows)
}
if err != nil && actor == nil {
if strings.HasPrefix(err.Error(), "webfinger") {
return e.NotFoundError("Not found", err)
}
return err
} else if err != nil && actor != nil {
if errors.Is(err, federation.ErrProfilePrivate) {
// this is our own profile
if e.Auth != nil && actor.GetString("user") == e.Auth.Id {
return e.JSON(http.StatusOK, map[string]any{"actor": actor, "error": nil})
} else {
return e.JSON(http.StatusNotFound, map[string]any{"error": "profile is private"})
}
}
// we could not fetch the remote actor so we return our local cached copy
return e.JSON(http.StatusOK, map[string]any{"actor": actor, "error": err.Error()})
}
return e.JSON(http.StatusOK, map[string]any{"actor": actor, "error": nil})
}
func ActivitypubActivityProcess(e *core.RequestEvent) error {
origin := os.Getenv("ORIGIN")
if origin == "" {
return fmt.Errorf("ORIGIN not set")
}
body, err := io.ReadAll(e.Request.Body)
if err != nil {
return err
}
var activity pub.Activity
err = activity.UnmarshalJSON(body)
if err != nil {
return err
}
inbox := fmt.Sprintf("%s%s", origin, e.Request.Header.Get("X-Forwarded-Path"))
recipient, err := e.App.FindFirstRecordByData("activitypub_actors", "inbox", inbox)
if err != nil {
return err
}
actor, err := e.App.FindFirstRecordByData("activitypub_actors", "iri", activity.Actor.GetID().String())
if err != nil {
if err == sql.ErrNoRows {
ctx, err := util.GetSafeActorContext(e.Request, recipient)
if err != nil {
return err
}
actor, err = federation.GetActorByIRI(e.App, ctx, activity.Actor.GetID().String(), false)
if err != nil {
return err
}
} else {
return err
}
}
verified, err := util.VerifySignature(e.App, e.Request, actor.GetString("public_key"))
if err != nil || !verified {
e.App.Logger().Error(err.Error())
return e.UnauthorizedError("Invalid http signature", err)
}
switch activity.Type {
case pub.FollowType:
err = federation.ProcessFollowActivity(e.App, actor, activity)
case pub.AcceptType:
err = federation.ProcessAcceptActivity(e.App, actor, activity)
case pub.UndoType:
err = federation.ProcessUndoActivity(e.App, actor, activity)
case pub.UpdateType:
fallthrough
case pub.CreateType:
err = federation.ProcessCreateOrUpdateActivity(e.App, actor, recipient, activity)
case pub.DeleteType:
err = federation.ProcessDeleteActivity(e.App, actor, activity)
case pub.AnnounceType:
err = federation.ProcessAnnounceActivity(e.App, actor, activity)
case pub.LikeType:
err = federation.ProcessLikeActivity(e.App, actor, activity)
}
return e.JSON(http.StatusOK, err)
}
func ActivitypubActorFollow(e *core.RequestEvent) error {
id := e.Request.PathValue("id")
followType := e.Request.PathValue("follow")
page := e.Request.URL.Query().Get("page")
intPage := 0
if page != "" {
var err error
intPage, err = strconv.Atoi(page)
if err != nil {
return err
}
}
actor, err := e.App.FindRecordById("activitypub_actors", id)
if err != nil {
return err
}
var userActor *core.Record
if e.Auth != nil {
userActor, err = e.App.FindFirstRecordByData("activitypub_actors", "user", e.Auth.Id)
if err != nil {
return err
}
}
ctx, err := util.GetSafeActorContext(e.Request, userActor)
if err != nil {
return err
}
url := actor.GetString(followType)
if url == "" {
return e.BadRequestError("unknown type: "+followType, nil)
}
collection, err := federation.FetchCollection(e.App, ctx, fmt.Sprintf("%s?page=%d", url, intPage))
if err != nil {
if errors.Is(err, federation.ErrProfilePrivate) {
return e.JSON(http.StatusNotFound, map[string]any{"error": "profile is private"})
} else if errors.Is(err, util.ErrRateLimited) {
return e.TooManyRequestsError("Too many requests", err)
}
return err
}
return e.JSON(http.StatusOK, collection)
}
func ActivitypubTrail(e *core.RequestEvent) error {
id := e.Request.PathValue("id")
trail, err := e.App.FindRecordById("trails", id)
if err != nil {
return err
}
trailObject, err := util.ObjectFromTrail(e.App, trail, nil)
if err != nil {
return err
}
return e.JSON(http.StatusOK, trailObject)
}
func ActivitypubComment(e *core.RequestEvent) error {
id := e.Request.PathValue("id")
comment, err := e.App.FindRecordById("comments", id)
if err != nil {
return err
}
commentObject, err := util.ObjectFromComment(e.App, comment, nil)
if err != nil {
return err
}
return e.JSON(http.StatusOK, commentObject)
}

50
db/routes/auth_token.go Normal file
View File

@@ -0,0 +1,50 @@
package routes
import (
"net/http"
"time"
"github.com/pocketbase/pocketbase/apis"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/tools/security"
)
func AuthToken(e *core.RequestEvent) error {
var data struct {
APIToken string `json:"api_token"`
}
if err := e.BindBody(&data); err != nil {
return apis.NewBadRequestError("Failed to read request data", err)
}
hashedAPIToken := security.SHA256(data.APIToken)
tokenRecord, err := e.App.FindFirstRecordByFilter(
"api_tokens",
"token = {:hash}",
map[string]any{"hash": hashedAPIToken},
)
if err != nil {
return apis.NewNotFoundError("Invalid or revoked API token", nil)
}
if !tokenRecord.GetDateTime("expiration").IsZero() &&
tokenRecord.GetDateTime("expiration").Time().Before(time.Now()) {
return apis.NewBadRequestError("Key has expired", nil)
}
tokenRecord.Set("last_used", time.Now())
if err := e.App.Save(tokenRecord); err != nil {
return err
}
userRecord, _ := e.App.FindRecordById("users", tokenRecord.GetString("user"))
token, err := userRecord.NewAuthToken()
if err != nil {
return err
}
return e.JSON(http.StatusOK, map[string]any{
"token": token,
"record": userRecord,
})
}

11
db/routes/health.go Normal file
View File

@@ -0,0 +1,11 @@
package routes
import (
"net/http"
"github.com/pocketbase/pocketbase/core"
)
func Health(e *core.RequestEvent) error {
return e.JSON(http.StatusOK, map[string]string{"status": "ok"})
}

View File

@@ -0,0 +1,81 @@
package routes
import (
"encoding/json"
"net/http"
"os"
"pocketbase/integrations/hammerhead"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/apis"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/tools/security"
)
func IntegrationHammerheadUpload(e *core.RequestEvent) error {
h, err := loginHammerhead(e)
if err != nil {
return err
}
if err := h.UploadActivities(e); err != nil {
return err
}
return e.JSON(http.StatusOK, nil)
}
func IntegrationHammerheadLogin(e *core.RequestEvent) error {
_, err := loginHammerhead(e)
if err != nil {
return err
}
return e.JSON(http.StatusOK, nil)
}
func loginHammerhead(e *core.RequestEvent) (*hammerhead.HammerheadApi, error) {
encryptionKey := os.Getenv("POCKETBASE_ENCRYPTION_KEY")
if len(encryptionKey) == 0 {
return nil, apis.NewBadRequestError("POCKETBASE_ENCRYPTION_KEY not set", nil)
}
userId := ""
if e.Auth != nil {
userId = e.Auth.Id
} else {
return nil, e.UnauthorizedError("authentication required", nil)
}
integrations, err := e.App.FindAllRecords("integrations", dbx.NewExp("user = {:id}", dbx.Params{"id": userId}))
if err != nil {
return nil, err
}
if len(integrations) == 0 {
return nil, apis.NewBadRequestError("user has no integration", nil)
}
integration := integrations[0]
hammerheadString := integration.GetString("hammerhead")
if len(hammerheadString) == 0 {
return nil, apis.NewBadRequestError("hammerhead integration missing", nil)
}
var hammerheadIntegration hammerhead.HammerheadIntegration
err = json.Unmarshal([]byte(hammerheadString), &hammerheadIntegration)
if err != nil {
return nil, err
}
decryptedPassword, err := security.Decrypt(hammerheadIntegration.Password, encryptionKey)
if err != nil {
return nil, err
}
k := &hammerhead.HammerheadApi{}
err = k.Login(hammerheadIntegration.Email, string(decryptedPassword))
if err != nil {
return nil, apis.NewUnauthorizedError("invalid credentials", nil)
}
return k, e.JSON(http.StatusOK, nil)
}

View File

@@ -0,0 +1,58 @@
package routes
import (
"encoding/json"
"net/http"
"os"
"pocketbase/integrations/komoot"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/apis"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/tools/security"
)
func IntegrationKommotLogin(e *core.RequestEvent) error {
encryptionKey := os.Getenv("POCKETBASE_ENCRYPTION_KEY")
if len(encryptionKey) == 0 {
return apis.NewBadRequestError("POCKETBASE_ENCRYPTION_KEY not set", nil)
}
userId := ""
if e.Auth != nil {
userId = e.Auth.Id
} else {
return e.UnauthorizedError("authentication required", nil)
}
integrations, err := e.App.FindAllRecords("integrations", dbx.NewExp("user = {:id}", dbx.Params{"id": userId}))
if err != nil {
return err
}
if len(integrations) == 0 {
return apis.NewBadRequestError("user has no integration", nil)
}
integration := integrations[0]
komootString := integration.GetString("komoot")
if len(komootString) == 0 {
return apis.NewBadRequestError("komoot integration missing", nil)
}
var komootIntegration komoot.KomootIntegration
err = json.Unmarshal([]byte(komootString), &komootIntegration)
if err != nil {
return err
}
decryptedPassword, err := security.Decrypt(komootIntegration.Password, encryptionKey)
if err != nil {
return err
}
k := &komoot.KomootApi{}
err = k.Login(komootIntegration.Email, string(decryptedPassword))
if err != nil {
return apis.NewUnauthorizedError("invalid credentials", nil)
}
return e.JSON(http.StatusOK, nil)
}

View File

@@ -0,0 +1,87 @@
package routes
import (
"encoding/json"
"net/http"
"os"
"pocketbase/integrations/strava"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/apis"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/tools/security"
)
func IntegrationStravaToken(e *core.RequestEvent) error {
encryptionKey := os.Getenv("POCKETBASE_ENCRYPTION_KEY")
if len(encryptionKey) == 0 {
return apis.NewBadRequestError("POCKETBASE_ENCRYPTION_KEY not set", nil)
}
var data strava.TokenRequest
if err := e.BindBody(&data); err != nil {
return apis.NewBadRequestError("Failed to read request data", err)
}
userId := ""
if e.Auth != nil {
userId = e.Auth.Id
} else {
return e.UnauthorizedError("authentication required", nil)
}
integrations, err := e.App.FindAllRecords("integrations", dbx.NewExp("user = {:id}", dbx.Params{"id": userId}))
if err != nil {
return err
}
if len(integrations) == 0 {
return apis.NewBadRequestError("user has no integration", nil)
}
integration := integrations[0]
stravaString := integration.GetString("strava")
if len(stravaString) == 0 {
return apis.NewBadRequestError("strava integration missing", nil)
}
var stravaIntegration strava.StravaIntegration
err = json.Unmarshal([]byte(stravaString), &stravaIntegration)
if err != nil {
return err
}
decryptedSecret, err := security.Decrypt(stravaIntegration.ClientSecret, encryptionKey)
if err != nil {
return err
}
request := strava.TokenRequest{
ClientID: stravaIntegration.ClientID,
ClientSecret: string(decryptedSecret),
Code: data.Code,
GrantType: "authorization_code",
}
r, err := strava.GetStravaToken(request)
if err != nil {
return err
}
if r.AccessToken != "" {
stravaIntegration.AccessToken = r.AccessToken
}
if r.RefreshToken != "" {
stravaIntegration.RefreshToken = r.RefreshToken
}
if r.AccessToken != "" {
stravaIntegration.ExpiresAt = r.ExpiresAt
}
stravaIntegration.Active = true
b, err := json.Marshal(stravaIntegration)
if err != nil {
return err
}
integration.Set("strava", string(b))
err = e.App.Save(integration)
if err != nil {
return err
}
return e.JSON(http.StatusOK, nil)
}

210
db/routes/remote_list.go Normal file
View File

@@ -0,0 +1,210 @@
package routes
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/url"
"pocketbase/federation"
"pocketbase/util"
"time"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/core"
)
// --- Main Handler ---
func RemoteListGet(e *core.RequestEvent) error {
handle := e.Request.URL.Query().Get("handle")
listID := e.Request.PathValue("id")
expandQuery := e.Request.URL.Query().Get("expand")
var record *core.Record
var err error
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
}
if handle != "" {
record, err = findLocalListByRemoteInfo(e, ctx, handle, listID)
if err != nil {
return e.InternalServerError("Failed to resolve trail", err)
}
if record.Id == "" || record.GetBool("needs_full_sync") {
record, err = performFullListSync(e.App, ctx, e.Request.URL, record)
if err != nil {
if errors.Is(err, util.ErrRateLimited) {
return e.TooManyRequestsError("Too many requests", err)
}
return e.InternalServerError("Sync failed", err)
}
} else {
updatedAt := record.GetDateTime("updated").Time()
if time.Now().UTC().Sub(updatedAt) > 60*time.Minute {
go performFullListSync(e.App, ctx, e.Request.URL, record)
}
}
} else {
record, err = e.App.FindRecordById("lists", listID)
if err != nil {
return e.NotFoundError("List not found", nil)
}
}
return expandAndReturn(e, record, expandQuery)
}
func findLocalListByRemoteInfo(e *core.RequestEvent, ctx context.Context, handle, trailID string) (*core.Record, error) {
// 1. Get Actor to build the IRI
actor, err := federation.GetActorByHandle(e.App, ctx, handle, false)
if err != nil {
return nil, err
}
actorURL, _ := url.Parse(actor.GetString("iri"))
iri := fmt.Sprintf("%s://%s/api/v1/list/%s", actorURL.Scheme, actorURL.Host, trailID)
// 2. Check if this IRI already exists in our DB
existing, _ := e.App.FindFirstRecordByFilter("lists", "iri={:iri}||id={:id}", dbx.Params{"id": trailID, "iri": iri})
if existing != nil {
return existing, nil
}
// 3. Not found? Return a new Shell
collection, _ := e.App.FindCollectionByNameOrId("lists")
shell := core.NewRecord(collection)
shell.Set("iri", iri)
shell.Set("author", actor.Id)
return shell, nil
}
func performFullListSync(app core.App, ctx context.Context, reqURL *url.URL, localList *core.Record) (*core.Record, error) {
client := util.SafeHTTPClient()
iri := localList.GetString("iri")
remoteUrl, _ := url.Parse(iri)
remoteUrl.RawQuery = reqURL.RawQuery
origin := fmt.Sprintf("%s://%s", remoteUrl.Scheme, remoteUrl.Host)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, remoteUrl.String(), nil)
if err != nil {
return nil, err
}
res, err := client.Do(req)
if err != nil || res.StatusCode != 200 {
return localList, err
}
defer res.Body.Close()
var remoteMap map[string]any
if err := json.NewDecoder(res.Body).Decode(&remoteMap); err != nil {
return localList, err
}
err = app.RunInTransaction(func(txApp core.App) error {
remoteID, _ := remoteMap["id"].(string)
// 1. Sync Files
syncListRecordFiles(ctx, localList, "lists", remoteID, origin, remoteMap)
// 2. Map Relations & Simple Fields
syncListMetadata(localList, remoteMap)
localList.Set("needs_full_sync", false)
// 3. Sync Trails
if expand, ok := remoteMap["expand"].(map[string]any); ok {
if trails, ok := expand["trails"].([]any); ok {
err = syncTrails(txApp, ctx, localList, origin, trails)
if err != nil {
return err
}
}
}
if err := txApp.Save(localList); err != nil {
return err
}
return nil
})
return localList, err
}
func syncListMetadata(record *core.Record, data map[string]any) {
delete(data, "id")
delete(data, "avatar")
delete(data, "author")
delete(data, "iri")
record.Load(data)
}
func syncListRecordFiles(ctx context.Context, record *core.Record, collection, remoteID, origin string, data map[string]any) {
if gpx, ok := data["avatar"].(string); ok && record.GetString("avatar") == "" {
if f, err := downloadFile(ctx, origin, collection, remoteID, gpx); err == nil {
record.Set("avatar", f)
}
}
}
func syncTrails(txApp core.App, ctx context.Context, list *core.Record, origin string, trails []any) error {
col, _ := txApp.FindCollectionByNameOrId("trails")
localTrails := make([]string, 0, len(trails))
for _, tData := range trails {
raw := tData.(map[string]any)
tID, _ := raw["id"].(string)
iri, _ := raw["iri"].(string)
if iri == "" {
iri = fmt.Sprintf("%s/api/v1/trail/%s", origin, tID)
}
trail, _ := txApp.FindFirstRecordByData("trails", "iri", iri)
if trail == nil {
trail = core.NewRecord(col)
trail.Set("needs_full_sync", true)
}
syncTrailMetadata(txApp, trail, raw)
author := list.GetString("author")
if expand, ok := raw["expand"].(map[string]any); ok {
if authorMap, ok := expand["author"].(map[string]any); ok {
actor, err := federation.GetActorByIRI(txApp, ctx, authorMap["iri"].(string), false)
if err != nil {
return err
}
author = actor.Id
}
}
trail.Set("author", author)
trail.Set("iri", iri)
if err := txApp.Save(trail); err != nil {
return err
}
localTrails = append(localTrails, trail.Id)
}
list.Set("trails", localTrails)
return nil
}

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,
})
}

317
db/routes/remote_trail.go Normal file
View File

@@ -0,0 +1,317 @@
package routes
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"path"
"pocketbase/federation"
"pocketbase/util"
"strings"
"time"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/tools/filesystem"
)
// --- Main Handler ---
func RemoteTrailGet(e *core.RequestEvent) error {
handle := e.Request.URL.Query().Get("handle")
trailID := e.Request.PathValue("id")
expandQuery := e.Request.URL.Query().Get("expand")
var record *core.Record
var err error
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 the "Actual" Record or Shell
if handle != "" {
// If we have a handle, we are looking for a remote trail.
// Construct the IRI first to see if we already know this trail.
record, err = findLocalTrailByRemoteInfo(e, ctx, handle, trailID)
if err != nil {
return e.InternalServerError("Failed to resolve trail", err)
}
// If the record has no ID, it's a new Shell
if record.Id == "" || record.GetBool("needs_full_sync") {
// Blocking sync for new records
record, err = performFullSync(e.App, ctx, e.Request.URL, record)
if err != nil {
if errors.Is(err, util.ErrRateLimited) {
return e.TooManyRequestsError("Too many requests", err)
}
return e.InternalServerError("Sync failed", err)
}
} else {
// We already have it locally. Show and update background.
updatedAt := record.GetDateTime("updated").Time()
if time.Now().UTC().Sub(updatedAt) > 60*time.Minute {
go performFullSync(e.App, ctx, e.Request.URL, record)
}
}
} else {
// Standard local fetch by ID
record, err = e.App.FindRecordById("trails", trailID)
if err != nil {
return e.NotFoundError("Trail not found", nil)
}
}
return expandAndReturn(e, record, expandQuery)
}
func findLocalTrailByRemoteInfo(e *core.RequestEvent, ctx context.Context, handle, trailID string) (*core.Record, error) {
// 1. Get Actor to build the IRI
actor, err := federation.GetActorByHandle(e.App, ctx, handle, false)
if err != nil {
return nil, err
}
actorURL, _ := url.Parse(actor.GetString("iri"))
iri := fmt.Sprintf("%s://%s/api/v1/trail/%s", actorURL.Scheme, actorURL.Host, trailID)
// 2. Check if this IRI already exists in our DB
existing, _ := e.App.FindFirstRecordByFilter("trails", "iri={:iri}||id={:id}", dbx.Params{"id": trailID, "iri": iri})
if existing != nil {
return existing, nil
}
// 3. Not found? Return a new Shell
collection, _ := e.App.FindCollectionByNameOrId("trails")
shell := core.NewRecord(collection)
shell.Set("iri", iri)
shell.Set("author", actor.Id)
shell.Set("like_count", 0)
return shell, nil
}
// --- Core Sync Logic ---
func performFullSync(app core.App, ctx context.Context, reqURL *url.URL, localTrail *core.Record) (*core.Record, error) {
client := util.SafeHTTPClient()
iri := localTrail.GetString("iri")
remoteUrl, _ := url.Parse(iri)
remoteUrl.RawQuery = reqURL.RawQuery // Forward params
origin := fmt.Sprintf("%s://%s", remoteUrl.Scheme, remoteUrl.Host)
req, _ := http.NewRequestWithContext(ctx, "GET", remoteUrl.String(), nil)
res, err := client.Do(req)
if err != nil || res.StatusCode != 200 {
return localTrail, err
}
defer res.Body.Close()
var remoteMap map[string]any
if err := json.NewDecoder(res.Body).Decode(&remoteMap); err != nil {
return localTrail, err
}
err = app.RunInTransaction(func(txApp core.App) error {
remoteID, _ := remoteMap["id"].(string)
// 1. Sync Files
syncRecordFiles(ctx, localTrail, "trails", remoteID, origin, remoteMap)
// 2. Map Relations & Simple Fields
syncTrailMetadata(txApp, localTrail, remoteMap)
localTrail.Set("needs_full_sync", false)
if err := txApp.Save(localTrail); err != nil {
return err
}
// 3. Sync Waypoints
if expand, ok := remoteMap["expand"].(map[string]any); ok {
if wps, ok := expand["waypoints_via_trail"].([]any); ok {
err = syncWaypoints(txApp, ctx, localTrail, origin, wps)
if err != nil {
return err
}
}
}
// 3. Sync SummitLogs
if expand, ok := remoteMap["expand"].(map[string]any); ok {
if sls, ok := expand["summit_logs_via_trail"].([]any); ok {
err = syncSummitLogs(txApp, ctx, localTrail, origin, sls)
if err != nil {
return err
}
}
}
return nil
})
return localTrail, err
}
// --- Sub-Sync Helpers ---
func syncTrailMetadata(app core.App, record *core.Record, data map[string]any) {
// Resolve Category if present in expand
if expand, ok := data["expand"].(map[string]any); ok {
if cat, ok := expand["category"].(map[string]any); ok {
if name, ok := cat["name"].(string); ok {
if c, _ := app.FindFirstRecordByData("categories", "name", name); c != nil {
record.Set("category", c.Id)
}
}
}
}
// Clean protected/complex fields before bulk load
delete(data, "id")
delete(data, "photos")
delete(data, "gpx")
delete(data, "author")
delete(data, "category")
delete(data, "iri")
record.Load(data)
}
func syncWaypoints(txApp core.App, ctx context.Context, trail *core.Record, origin string, waypoints []any) error {
col, _ := txApp.FindCollectionByNameOrId("waypoints")
for _, wData := range waypoints {
raw := wData.(map[string]any)
wpID, _ := raw["id"].(string)
iri, _ := raw["iri"].(string)
if iri == "" {
iri = fmt.Sprintf("%s/api/v1/waypoint/%s", origin, wpID)
}
wp, _ := txApp.FindFirstRecordByData("waypoints", "iri", iri)
if wp == nil {
wp = core.NewRecord(col)
}
syncRecordFiles(ctx, wp, "waypoints", wpID, origin, raw)
delete(raw, "id")
delete(raw, "photos")
wp.Load(raw)
wp.Set("author", trail.GetString("author"))
wp.Set("trail", trail.Id)
wp.Set("iri", iri)
if err := txApp.Save(wp); err != nil {
return err
}
}
return nil
}
func syncSummitLogs(txApp core.App, ctx context.Context, trail *core.Record, origin string, summitLogs []any) error {
col, _ := txApp.FindCollectionByNameOrId("summit_logs")
for _, slData := range summitLogs {
raw := slData.(map[string]any)
slID, _ := raw["id"].(string)
iri, _ := raw["iri"].(string)
if iri == "" {
iri = fmt.Sprintf("%s/api/v1/summit_logs/%s", origin, slID)
}
remoteSummitLogUrl, _ := url.Parse(iri)
possibleLocalId := path.Base(remoteSummitLogUrl.Path)
sl, _ := txApp.FindFirstRecordByFilter("summit_logs", "iri={:iri} || id={:id}", dbx.Params{"id": possibleLocalId, "iri": iri})
if sl == nil {
sl = core.NewRecord(col)
}
author := trail.GetString("author")
if expand, ok := raw["expand"].(map[string]any); ok {
if authorMap, ok := expand["author"].(map[string]any); ok {
actor, err := federation.GetActorByIRI(txApp, ctx, authorMap["iri"].(string), false)
if err != nil {
return err
}
author = actor.Id
}
}
syncRecordFiles(ctx, sl, "summit_logs", slID, origin, raw)
delete(raw, "id")
delete(raw, "photos")
delete(raw, "gpx")
sl.Load(raw)
sl.Set("author", author)
sl.Set("trail", trail.Id)
sl.Set("iri", iri)
if err := txApp.Save(sl); err != nil {
return err
}
}
return nil
}
func syncRecordFiles(ctx context.Context, record *core.Record, collection, remoteID, origin string, data map[string]any) {
// Handle GPX
if gpx, ok := data["gpx"].(string); ok && record.GetString("gpx") == "" {
if f, err := downloadFile(ctx, origin, collection, remoteID, gpx); err == nil {
record.Set("gpx", f)
}
}
// Handle Photos
if photos, ok := data["photos"].([]any); ok && len(record.GetStringSlice("photos")) == 0 {
var files []*filesystem.File
for _, p := range photos {
if f, err := downloadFile(ctx, origin, collection, remoteID, p.(string)); err == nil {
files = append(files, f)
}
}
if len(files) > 0 {
record.Set("photos", files)
}
}
}
func downloadFile(ctx context.Context, origin, col, id, name string) (*filesystem.File, error) {
client := util.SafeHTTPClient()
url := fmt.Sprintf("%s/api/v1/files/%s/%s/%s", origin, col, id, name)
req, _ := http.NewRequestWithContext(ctx, "GET", url, nil)
res, err := client.Do(req)
if err != nil || res.StatusCode != 200 {
return nil, fmt.Errorf("download failed")
}
defer res.Body.Close()
data, _ := io.ReadAll(res.Body)
return filesystem.NewFileFromBytes(data, name)
}
func expandAndReturn(e *core.RequestEvent, record *core.Record, query string) error {
if query != "" {
e.App.ExpandRecord(record, strings.Split(query, ","), nil)
}
return e.JSON(http.StatusOK, record)
}

View File

@@ -0,0 +1,186 @@
package routes
import (
"encoding/json"
"errors"
"fmt"
"net/http"
"net/url"
"path"
"pocketbase/federation"
"pocketbase/util"
"strconv"
"strings"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/core"
)
func RemoteTrailCommentsList(e *core.RequestEvent) error {
trailID := e.Request.PathValue("id")
expandQuery := e.Request.URL.Query().Get("expand")
sort := e.Request.URL.Query().Get("sort")
if sort == "" {
sort = "-created"
}
page, _ := strconv.Atoi(e.Request.URL.Query().Get("page"))
if page < 1 {
page = 1
}
perPage, _ := strconv.Atoi(e.Request.URL.Query().Get("perPage"))
if perPage < 1 {
perPage = 30
}
trail, err := e.App.FindRecordById("trails", trailID)
if err != nil {
return err
}
// Sync remote data first (Fetch + Save)
if trail.GetString("iri") != "" {
_ = syncRemoteComments(e, trail)
}
// 1. Calculate Offset
offset := (page - 1) * perPage
// 2. Fetch the records using FindRecordsByFilter
records, err := e.App.FindRecordsByFilter(
"comments",
"trail = {:trailId}",
sort,
perPage,
offset,
dbx.Params{"trailId": trail.Id},
)
if err != nil {
return err
}
// 3. Get total count for pagination metadata
var totalItems int
err = e.App.DB().
Select("count(*)").
From("comments").
Where(dbx.HashExp{"trail": trail.Id}).
Row(&totalItems)
if err != nil {
return err
}
// 4. Handle Expand
if expandQuery != "" {
errs := e.App.ExpandRecords(records, strings.Split(expandQuery, ","), nil)
if len(errs) > 0 {
fmt.Printf("Expand errors: %v\n", errs)
}
}
// 5. Manually construct the response object
return e.JSON(http.StatusOK, map[string]any{
"page": page,
"perPage": perPage,
"totalItems": totalItems,
"totalPages": (totalItems + perPage - 1) / perPage,
"items": records,
})
}
func syncRemoteComments(e *core.RequestEvent, trail *core.Record) error {
client := util.SafeHTTPClient()
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
}
trailIRI := trail.GetString("iri")
u, _ := url.Parse(trailIRI)
remoteTrailID := path.Base(u.Path)
remoteURL := fmt.Sprintf("%s://%s/api/v1/comment?filter=trail='%s'&expand=author", u.Scheme, u.Host, remoteTrailID)
req, _ := http.NewRequestWithContext(ctx, "GET", remoteURL, nil)
res, err := client.Do(req)
if err != nil || res.StatusCode != 200 {
if errors.Is(err, util.ErrRateLimited) {
return e.TooManyRequestsError("Too many requests", err)
}
return fmt.Errorf("remote fetch failed: %w", err)
}
defer res.Body.Close()
var remoteData struct {
Items []map[string]any `json:"items"`
}
if err := json.NewDecoder(res.Body).Decode(&remoteData); err != nil {
return err
}
collection, _ := e.App.FindCollectionByNameOrId("comments")
return e.App.RunInTransaction(func(txApp core.App) error {
for _, raw := range remoteData.Items {
remoteIRI, _ := raw["iri"].(string)
if remoteIRI == "" {
remoteID, _ := raw["id"].(string)
remoteIRI = fmt.Sprintf("%s://%s/api/v1/comment/%s", u.Scheme, u.Host, remoteID)
}
remoteCommentUrl, _ := url.Parse(remoteIRI)
possibleLocalId := path.Base(remoteCommentUrl.Path)
// Find existing record by IRI or ID to avoid duplicates
commentRecord, _ := txApp.FindFirstRecordByFilter("comments", "iri={:iri} || id={:id}", dbx.Params{"id": possibleLocalId, "iri": remoteIRI})
if commentRecord == nil {
commentRecord = core.NewRecord(collection)
commentRecord.Set("iri", remoteIRI)
commentRecord.Set("trail", trail.Id)
}
// Resolve federated author
if expand, ok := raw["expand"].(map[string]any); ok {
if author, ok := expand["author"].(map[string]any); ok {
authorIRI, _ := author["iri"].(string)
actor, err := federation.GetActorByIRI(txApp, ctx, authorIRI, false)
if err == nil {
raw["author"] = actor.Id
}
}
}
delete(raw, "id")
delete(raw, "trail")
delete(raw, "expand")
delete(raw, "iri")
commentRecord.Load(raw)
if err := txApp.Save(commentRecord); err != nil {
continue
}
}
return nil
})
}
func expandAndReturnList(e *core.RequestEvent, records []*core.Record, query string) error {
if query != "" {
expandPaths := strings.Split(query, ",")
errs := e.App.ExpandRecords(records, expandPaths, nil)
if len(errs) > 0 {
fmt.Printf("Expand errors: %v\n", errs)
}
}
return e.JSON(http.StatusOK, records)
}

44
db/routes/search_token.go Normal file
View File

@@ -0,0 +1,44 @@
package routes
import (
"net/http"
"pocketbase/util"
"github.com/meilisearch/meilisearch-go"
"github.com/pocketbase/pocketbase/core"
)
func SearchToken(client meilisearch.ServiceManager) func(e *core.RequestEvent) error {
return func(e *core.RequestEvent) error {
searchRules := map[string]interface{}{
"lists": map[string]string{"filter": "public = true"},
"trails": map[string]string{"filter": "public = true"},
}
if e.Auth != nil {
userId := e.Auth.Id
userActor, err := e.App.FindFirstRecordByData("activitypub_actors", "user", e.Auth.Id)
if err != nil {
return err
}
searchRules = map[string]any{
"lists": map[string]string{
"filter": "public = true OR author = " + userActor.Id + " OR shares = " + userId,
},
"trails": map[string]string{
"filter": "public = true OR author = " + userActor.Id + " OR shares = " + userId,
},
}
}
token, err := util.GenerateMeilisearchToken(searchRules, client)
if err != nil {
return e.InternalServerError("Failed to generate search token", err)
}
return e.JSON(http.StatusOK, map[string]string{
"token": token,
})
}
}

View File

@@ -0,0 +1,90 @@
package routes
import (
"net/http"
"pocketbase/services/trailmerge"
"pocketbase/util"
"github.com/meilisearch/meilisearch-go"
"github.com/pocketbase/pocketbase/apis"
"github.com/pocketbase/pocketbase/core"
)
type mergeExecuteRequest struct {
SourceTrailID string `json:"sourceTrailId"`
TargetTrailID string `json:"targetTrailId"`
Settings trailmerge.MergeSettings `json:"settings"`
}
func TrailMergeSuggest(e *core.RequestEvent) error {
if e.Auth == nil {
return apis.NewUnauthorizedError("trail_merge_auth_required", nil)
}
userActor, err := e.App.FindFirstRecordByData("activitypub_actors", "user", e.Auth.Id)
if err != nil {
return apis.NewBadRequestError("trail_merge_actor_not_found", err)
}
var request trailmerge.SuggestRequest
if err := e.BindBody(&request); err != nil {
return apis.NewBadRequestError("trail_merge_invalid_request", err)
}
if request.Mode == trailmerge.SuggestModeMaintenance {
response, err := trailmerge.SuggestGroups(e.App, userActor.Id, request)
if err != nil {
return apis.NewBadRequestError(err.Error(), err)
}
return e.JSON(http.StatusOK, response)
}
response, err := trailmerge.Suggest(e.App, userActor.Id, request)
if err != nil {
return apis.NewBadRequestError(err.Error(), err)
}
return e.JSON(http.StatusOK, response)
}
func TrailMerge(client meilisearch.ServiceManager) func(e *core.RequestEvent) error {
return func(e *core.RequestEvent) error {
if e.Auth == nil {
return apis.NewUnauthorizedError("trail_merge_auth_required", nil)
}
userActor, err := e.App.FindFirstRecordByData("activitypub_actors", "user", e.Auth.Id)
if err != nil {
return apis.NewBadRequestError("trail_merge_actor_not_found", err)
}
var request mergeExecuteRequest
if err := e.BindBody(&request); err != nil {
return apis.NewBadRequestError("trail_merge_invalid_request", err)
}
source, err := e.App.FindRecordById("trails", request.SourceTrailID)
if err != nil {
return apis.NewBadRequestError("trail_merge_source_not_found", err)
}
target, err := e.App.FindRecordById("trails", request.TargetTrailID)
if err != nil {
return apis.NewBadRequestError("trail_merge_target_not_found", err)
}
if !trailmerge.CanMerge(e.App, userActor.Id, source, target, request.Settings.Delete) {
return apis.NewForbiddenError("trail_merge_not_allowed", nil)
}
ctx, err := util.GetSafeActorContext(e.Request, userActor)
if err := trailmerge.Merge(e.App, client, ctx, userActor, request.SourceTrailID, request.TargetTrailID, request.Settings); err != nil {
return apis.NewBadRequestError(err.Error(), err)
}
return e.JSON(http.StatusOK, map[string]any{
"acknowledged": true,
})
}
}

View File

@@ -0,0 +1,197 @@
package routes
import (
"net/http"
"github.com/pocketbase/pocketbase/apis"
"github.com/pocketbase/pocketbase/core"
"pocketbase/util"
)
const defaultWaypointMergeRadius = 50
type waypointMergeSettings struct {
Enabled bool
Radius float64
}
type waypointClusterRequest struct {
Category string `json:"category"`
Photos []waypointClusterPhoto `json:"photos"`
Waypoints []waypointClusterWaypoint `json:"waypoints"`
}
type waypointClusterPhoto struct {
ID string `json:"id"`
Lat float64 `json:"lat"`
Lon float64 `json:"lon"`
}
type waypointClusterWaypoint struct {
ID string `json:"id"`
Lat float64 `json:"lat"`
Lon float64 `json:"lon"`
}
type waypointPhotoCluster struct {
Waypoint string `json:"waypoint,omitempty"`
Photos []string `json:"photos"`
SumLat float64 `json:"-"`
SumLon float64 `json:"-"`
Count int `json:"-"`
Lat float64 `json:"lat"`
Lon float64 `json:"lon"`
}
type categorySettings struct {
WaypointMergeEnabled *bool `json:"wp_merge_enabled"`
WaypointMergeRadius *float64 `json:"wp_merge_radius"`
}
func WaypointCluster(e *core.RequestEvent) error {
if e.Auth == nil {
return apis.NewUnauthorizedError("authentication required", nil)
}
var data waypointClusterRequest
if err := e.BindBody(&data); err != nil {
return apis.NewBadRequestError("Failed to read request data", err)
}
if data.Category != "" && len(data.Category) != 15 {
return apis.NewBadRequestError("Invalid category", nil)
}
for _, photo := range data.Photos {
if photo.ID == "" {
return apis.NewBadRequestError("Invalid photo id", nil)
}
if photo.Lat < -90 || photo.Lat > 90 {
return apis.NewBadRequestError("Invalid photo latitude", nil)
}
if photo.Lon < -180 || photo.Lon > 180 {
return apis.NewBadRequestError("Invalid photo longitude", nil)
}
}
for _, waypoint := range data.Waypoints {
if waypoint.ID == "" {
return apis.NewBadRequestError("Invalid waypoint id", nil)
}
if waypoint.Lat < -90 || waypoint.Lat > 90 {
return apis.NewBadRequestError("Invalid waypoint latitude", nil)
}
if waypoint.Lon < -180 || waypoint.Lon > 180 {
return apis.NewBadRequestError("Invalid waypoint longitude", nil)
}
}
mergeSettings, err := getWaypointMergeSettings(e.App, data.Category)
if err != nil {
return err
}
return e.JSON(http.StatusOK, map[string]any{
"mergeEnabled": mergeSettings.Enabled,
"mergeRadius": mergeSettings.Radius,
"clusters": clusterWaypointPhotos(data.Photos, data.Waypoints, mergeSettings),
})
}
func getWaypointMergeSettings(app core.App, categoryId string) (waypointMergeSettings, error) {
defaultSettings := waypointMergeSettings{
Enabled: true,
Radius: defaultWaypointMergeRadius,
}
if categoryId == "" {
return defaultSettings, nil
}
category, err := app.FindRecordById("categories", categoryId)
if err != nil {
return waypointMergeSettings{}, err
}
var settings categorySettings
if err := category.UnmarshalJSONField("settings", &settings); err != nil {
return defaultSettings, nil
}
if settings.WaypointMergeEnabled != nil {
defaultSettings.Enabled = *settings.WaypointMergeEnabled
}
if settings.WaypointMergeRadius != nil && *settings.WaypointMergeRadius >= 0 {
defaultSettings.Radius = *settings.WaypointMergeRadius
}
return defaultSettings, nil
}
func clusterWaypointPhotos(photos []waypointClusterPhoto, waypoints []waypointClusterWaypoint, mergeSettings waypointMergeSettings) []waypointPhotoCluster {
clusters := []waypointPhotoCluster{}
if mergeSettings.Enabled {
for _, waypoint := range waypoints {
clusters = append(clusters, newWaypointCluster(waypoint))
}
}
for _, photo := range photos {
if !mergeSettings.Enabled {
clusters = append(clusters, newWaypointPhotoCluster(photo))
continue
}
matchingClusterIndex := -1
for i, cluster := range clusters {
distanceToCenter := util.HaversineDistance(cluster.Lat, cluster.Lon, photo.Lat, photo.Lon)
if distanceToCenter <= mergeSettings.Radius {
matchingClusterIndex = i
break
}
}
if matchingClusterIndex >= 0 {
addPhotoToWaypointCluster(&clusters[matchingClusterIndex], photo)
} else {
clusters = append(clusters, newWaypointPhotoCluster(photo))
}
}
return clusters
}
func newWaypointPhotoCluster(photo waypointClusterPhoto) waypointPhotoCluster {
return waypointPhotoCluster{
Photos: []string{photo.ID},
SumLat: photo.Lat,
SumLon: photo.Lon,
Count: 1,
Lat: photo.Lat,
Lon: photo.Lon,
}
}
func newWaypointCluster(waypoint waypointClusterWaypoint) waypointPhotoCluster {
return waypointPhotoCluster{
Waypoint: waypoint.ID,
Photos: []string{},
SumLat: waypoint.Lat,
SumLon: waypoint.Lon,
Count: 1,
Lat: waypoint.Lat,
Lon: waypoint.Lon,
}
}
func addPhotoToWaypointCluster(cluster *waypointPhotoCluster, photo waypointClusterPhoto) {
cluster.Photos = append(cluster.Photos, photo.ID)
cluster.SumLat += photo.Lat
cluster.SumLon += photo.Lon
cluster.Count++
cluster.Lat = cluster.SumLat / float64(cluster.Count)
cluster.Lon = cluster.SumLon / float64(cluster.Count)
}