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

@@ -1,13 +1,12 @@
*
!commands
!federation
!hooks
!go.*
!integrations
!main.go
!trail_merge_routes.go
!migrations
!routes
!templates
!trailmerge
!waypointcluster
!waypointcluster/**
!services
!util

View File

@@ -4,12 +4,9 @@ import (
"bytes"
"context"
"crypto/x509"
"database/sql"
"encoding/pem"
"fmt"
"io"
"net/http"
"net/url"
"os"
"slices"
"strings"
@@ -127,105 +124,3 @@ func PostActivity(app core.App, actor *core.Record, activity *pub.Activity, reci
}()
return nil
}
func ProcessActivity(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
activity.UnmarshalJSON(body)
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 {
actor, err = GetActorByIRI(e.App, recipient, activity.Actor.GetID().String(), false)
if err != nil {
return err
}
} else {
return err
}
}
verified, err := 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 = ProcessFollowActivity(e.App, actor, activity)
case pub.AcceptType:
err = ProcessAcceptActivity(e.App, actor, activity)
case pub.UndoType:
err = ProcessUndoActivity(e.App, actor, activity)
case pub.UpdateType:
fallthrough
case pub.CreateType:
err = ProcessCreateOrUpdateActivity(e.App, actor, recipient, activity)
case pub.DeleteType:
err = ProcessDeleteActivity(e.App, actor, activity)
case pub.AnnounceType:
err = ProcessAnnounceActivity(e.App, actor, activity)
case pub.LikeType:
err = ProcessLikeActivity(e.App, actor, activity)
}
return e.JSON(http.StatusOK, err)
}
func verifySignature(app core.App, req *http.Request, publicKeyPem string) (bool, error) {
origin := os.Getenv("ORIGIN")
if origin == "" {
return false, fmt.Errorf("ORIGIN not set")
}
block, _ := pem.Decode([]byte(publicKeyPem))
if block == nil || block.Type != "PUBLIC KEY" {
return false, fmt.Errorf("could not decode publicKeyPem to PUBLIC KEY pem block type")
}
req.URL = &url.URL{
Path: req.Header.Get("X-Forwarded-Path"),
}
url, err := url.Parse(origin)
if err != nil {
return false, err
}
req.Header.Set("Host", url.Host)
req.Host = url.Host
app.Logger().Info(req.Header.Get("signature"))
publicKey, err := x509.ParsePKIXPublicKey(block.Bytes)
if err != nil {
return false, err
}
v, err := httpsig.NewVerifier(req)
if err != nil {
return false, err
}
err = v.Verify(publicKey, httpsig.RSA_SHA256)
if err != nil {
return false, err
}
return true, nil
}

View File

@@ -1,14 +1,17 @@
package federation
import (
"context"
"crypto/x509"
"database/sql"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"os"
"pocketbase/util"
"strings"
"time"
@@ -21,6 +24,7 @@ import (
)
var ErrProfilePrivate = errors.New("profile is private")
var ErrInvalidActorResponse = errors.New("invalid or incomplete actor response")
type WebfingerResponse struct {
Subject string `json:"subject"`
@@ -30,24 +34,36 @@ type WebfingerResponse struct {
} `json:"links"`
}
func SplitHandle(handle string) (string, string) {
cleaned := strings.TrimPrefix(handle, "@")
cleaned = strings.TrimSpace(cleaned)
if !strings.Contains(cleaned, "@") {
return cleaned, ""
func validateActorResponse(actor *pub.Actor) error {
if actor == nil {
return ErrInvalidActorResponse
}
parts := strings.SplitN(cleaned, "@", 2)
user := parts[0]
domain := parts[1]
if actor.GetID().String() == "" {
return fmt.Errorf("%w: missing ID", ErrInvalidActorResponse)
}
return user, domain
if actor.PreferredUsername.String() == "" && actor.Name.String() == "" {
return fmt.Errorf("%w: missing username or name", ErrInvalidActorResponse)
}
if util.ItemID(actor.Inbox) == "" {
return fmt.Errorf("%w: missing inbox", ErrInvalidActorResponse)
}
if util.ItemID(actor.Outbox) == "" {
return fmt.Errorf("%w: missing outbox", ErrInvalidActorResponse)
}
if actor.PublicKey.PublicKeyPem == "" {
return fmt.Errorf("%w: missing public key", ErrInvalidActorResponse)
}
return nil
}
func GetActorByHandle(app core.App, actor *core.Record, handle string, includeFollows bool) (*core.Record, error) {
username, domain := SplitHandle(handle)
func GetActorByHandle(app core.App, ctx context.Context, handle string, includeFollows bool) (*core.Record, error) {
username, domain := util.SplitHandle(handle)
filter := "preferred_username={:username}&&"
if domain != "" {
@@ -66,7 +82,7 @@ func GetActorByHandle(app core.App, actor *core.Record, handle string, includeFo
dbActor = core.NewRecord(collection)
dbActor.Set("isLocal", false)
iri, err := iriFromHandle(domain, username)
iri, err := iriFromHandle(ctx, domain, username)
if err != nil {
return nil, err
}
@@ -76,10 +92,10 @@ func GetActorByHandle(app core.App, actor *core.Record, handle string, includeFo
return nil, err
}
return assembleActor(actor, dbActor, app, includeFollows)
return assembleActor(app, ctx, dbActor, includeFollows || dbActor.Id == "")
}
func GetActorByIRI(app core.App, actor *core.Record, iri string, includeFollows bool) (*core.Record, error) {
func GetActorByIRI(app core.App, ctx context.Context, iri string, includeFollows bool) (*core.Record, error) {
var dbActor *core.Record
dbActor, err := app.FindFirstRecordByFilter("activitypub_actors", "iri={:iri}", dbx.Params{"iri": iri})
if err != nil && err == sql.ErrNoRows {
@@ -96,33 +112,55 @@ func GetActorByIRI(app core.App, actor *core.Record, iri string, includeFollows
return nil, err
}
return assembleActor(actor, dbActor, app, includeFollows)
return assembleActor(app, ctx, dbActor, includeFollows || dbActor.Id == "")
}
func iriFromHandle(domain string, username string) (string, error) {
client := &http.Client{}
func iriFromHandle(ctx context.Context, domain string, username string) (string, error) {
client := util.SafeHTTPClient()
webfingerURL := fmt.Sprintf("https://%s/.well-known/webfinger?resource=acct:%s@%s", domain, username, domain)
resp, err := client.Get(webfingerURL)
if err != nil || resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("webfinger request failed: %v", err)
u := &url.URL{
Scheme: "https",
Host: domain,
Path: "/.well-known/webfinger",
}
q := u.Query()
q.Set("resource", fmt.Sprintf("acct:%s@%s", username, domain))
u.RawQuery = q.Encode()
req, err := http.NewRequestWithContext(ctx, "GET", u.String(), nil)
if err != nil {
return "", err
}
resp, err := client.Do(req)
if err != nil {
return "", fmt.Errorf("webfinger request failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("unexpected status: %d", resp.StatusCode)
}
limitedReader := io.LimitReader(resp.Body, 102400)
var wf WebfingerResponse
if err := json.NewDecoder(resp.Body).Decode(&wf); err != nil {
return "", err
if err := json.NewDecoder(limitedReader).Decode(&wf); err != nil {
return "", fmt.Errorf("failed to decode JSON: %w", err)
}
for _, link := range wf.Links {
if link.Rel == "self" {
if _, err := url.Parse(link.Href); err != nil {
return "", fmt.Errorf("invalid IRI in response")
}
return link.Href, nil
}
}
return "", fmt.Errorf("no iri in response")
}
func assembleActor(actor *core.Record, dbActor *core.Record, app core.App, includeFollows bool) (*core.Record, error) {
func assembleActor(app core.App, ctx context.Context, dbActor *core.Record, includeFollows bool) (*core.Record, error) {
origin := os.Getenv("ORIGIN")
if origin == "" {
return nil, fmt.Errorf("ORIGIN environment variable not set")
@@ -147,12 +185,12 @@ func assembleActor(actor *core.Record, dbActor *core.Record, app core.App, inclu
if err != nil {
return nil, err
}
dbActor.Set("followerCount", followerCount)
dbActor.Set("follower_count", followerCount)
followingCount, err := app.CountRecords("follows", dbx.NewExp("follower={:user} AND status='accepted'", dbx.Params{"user": dbActor.Id}))
if err != nil {
return nil, err
}
dbActor.Set("followingCount", followingCount)
dbActor.Set("following_count", followingCount)
dbActor.Set("last_fetched", time.Now())
@@ -165,11 +203,11 @@ func assembleActor(actor *core.Record, dbActor *core.Record, app core.App, inclu
} else {
// check if value is still cached
twoHoursAgo := time.Now().Add(-2 * time.Hour)
if !includeFollows && dbActor.GetDateTime("last_fetched").Time().After(twoHoursAgo) {
twoHoursAgo := time.Now().UTC().Add(-2 * time.Hour)
if dbActor.GetDateTime("last_fetched").Time().After(twoHoursAgo) {
return dbActor, nil
}
pubActor, followers, following, err := fetchRemoteActor(actor, dbActor.GetString("iri"), includeFollows)
pubActor, followers, following, err := fetchRemoteActor(app, ctx, dbActor.GetString("iri"), includeFollows)
if err != nil {
if dbActor.Id != "" {
return dbActor, err
@@ -191,34 +229,35 @@ func assembleActor(actor *core.Record, dbActor *core.Record, app core.App, inclu
}
domain := strings.TrimPrefix(parsedUrl.Hostname(), "www.")
// this is a race condition that gets triggered when the profile is opened for the first time
existingActor, _ := app.FindFirstRecordByData("activitypub_actors", "iri", dbActor.GetString("iri"))
if existingActor != nil {
dbActor = existingActor
}
dbActor.Set("domain", domain)
dbActor.Set("followers", pubActor.Followers.GetID().String())
dbActor.Set("inbox", pubActor.Inbox.GetID().String())
dbActor.Set("followers", util.ItemID(pubActor.Followers))
dbActor.Set("inbox", util.ItemID(pubActor.Inbox))
dbActor.Set("iri", pubActor.GetID().String())
dbActor.Set("username", pubActor.Name.String())
dbActor.Set("preferred_username", pubActor.PreferredUsername.String())
dbActor.Set("following", pubActor.Following.GetID().String())
dbActor.Set("following", util.ItemID(pubActor.Following))
dbActor.Set("summary", pubActor.Summary.String())
dbActor.Set("outbox", pubActor.Outbox.GetID().String())
dbActor.Set("outbox", util.ItemID(pubActor.Outbox))
dbActor.Set("icon", icon)
dbActor.Set("published", pubActor.Published.String())
dbActor.Set("public_key", pubActor.PublicKey.PublicKeyPem)
dbActor.Set("last_fetched", time.Now())
if includeFollows {
dbActor.Set("followerCount", int(followers.TotalItems))
dbActor.Set("followingCount", int(following.TotalItems))
dbActor.Set("follower_count", int(followers.TotalItems))
dbActor.Set("following_count", int(following.TotalItems))
}
}
err := app.Save(dbActor)
if err != nil && err.Error() == "iri: Value must be unique." {
dbActor, err = app.FindFirstRecordByData("activitypub_actors", "iri", dbActor.GetString("iri"))
if err != nil {
return nil, err
}
return dbActor, nil
} else if err != nil {
if err != nil {
return nil, err
}
@@ -230,14 +269,15 @@ func assembleActor(actor *core.Record, dbActor *core.Record, app core.App, inclu
}
// Fetches an AP actor and optionally followers/following collections
func fetchRemoteActor(actor *core.Record, iri string, includeFollows bool) (*pub.Actor, *pub.OrderedCollection, *pub.OrderedCollection, error) {
func fetchRemoteActor(app core.App, ctx context.Context, iri string, includeFollows bool) (*pub.Actor, *pub.OrderedCollection, *pub.OrderedCollection, error) {
encryptionKey := os.Getenv("POCKETBASE_ENCRYPTION_KEY")
if len(encryptionKey) == 0 {
return nil, nil, nil, fmt.Errorf("POCKETBASE_ENCRYPTION_KEY not set")
}
client := &http.Client{}
req, _ := http.NewRequest("GET", iri, nil)
client := util.SafeHTTPClient()
req, err := http.NewRequestWithContext(ctx, "GET", iri, nil)
headers := map[string]string{
"Accept": "application/ld+json",
@@ -250,8 +290,10 @@ func fetchRemoteActor(actor *core.Record, iri string, includeFollows bool) (*pub
req.Header.Add(k, v)
}
if actor != nil && actor.GetString("private_key") != "" {
dbPrivateKey := actor.GetString("private_key")
userActorId := strings.TrimPrefix(ctx.Value("actor").(string), "actor:")
userActor, err := app.FindRecordById("activitypub_actors", userActorId)
if userActor != nil && userActor.GetString("private_key") != "" {
dbPrivateKey := userActor.GetString("private_key")
algs := []httpsig.Algorithm{httpsig.RSA_SHA256}
postHeaders := []string{"(request-target)", "Date", "Digest", "Content-Type", "Host"}
@@ -271,7 +313,7 @@ func fetchRemoteActor(actor *core.Record, iri string, includeFollows bool) (*pub
return nil, nil, nil, err
}
pubID := actor.GetString("iri") + "#main-key"
pubID := userActor.GetString("iri") + "#main-key"
if err := signer.SignRequest(privateKey, pubID, req, []byte{}); err != nil {
return nil, nil, nil, err
@@ -293,16 +335,21 @@ func fetchRemoteActor(actor *core.Record, iri string, includeFollows bool) (*pub
return nil, nil, nil, err
}
// Validate actor response has required fields
if err := validateActorResponse(&pubActor); err != nil {
return nil, nil, nil, fmt.Errorf("actor validation failed for %s: %w", iri, err)
}
var followers, following pub.OrderedCollection
if includeFollows {
// Fetch followers
if data, err := FetchCollection(actor, pubActor.Followers.GetID().String()); err == nil {
if data, err := FetchCollection(app, ctx, util.ItemID(pubActor.Followers)); err == nil {
followers = *data
}
// Fetch following
if data, err := FetchCollection(actor, pubActor.Following.GetID().String()); err == nil {
if data, err := FetchCollection(app, ctx, util.ItemID(pubActor.Following)); err == nil {
following = *data
}
}
@@ -310,12 +357,13 @@ func fetchRemoteActor(actor *core.Record, iri string, includeFollows bool) (*pub
return &pubActor, &followers, &following, nil
}
func FetchCollection(actor *core.Record, url string) (*pub.OrderedCollection, error) {
func FetchCollection(app core.App, ctx context.Context, collectionURL string) (*pub.OrderedCollection, error) {
encryptionKey := os.Getenv("POCKETBASE_ENCRYPTION_KEY")
if len(encryptionKey) == 0 {
return nil, fmt.Errorf("POCKETBASE_ENCRYPTION_KEY not set")
}
req, _ := http.NewRequest("GET", url, nil)
req, err := http.NewRequestWithContext(ctx, "GET", collectionURL, nil)
headers := map[string]string{
"Accept": "application/ld+json",
@@ -327,9 +375,10 @@ func FetchCollection(actor *core.Record, url string) (*pub.OrderedCollection, er
for k, v := range headers {
req.Header.Add(k, v)
}
if actor != nil && actor.GetString("private_key") != "" {
dbPrivateKey := actor.GetString("private_key")
userActorId := strings.TrimPrefix(ctx.Value("actor").(string), "actor:")
userActor, err := app.FindRecordById("activitypub_actors", userActorId)
if userActor != nil && userActor.GetString("private_key") != "" {
dbPrivateKey := userActor.GetString("private_key")
if dbPrivateKey != "" {
algs := []httpsig.Algorithm{httpsig.RSA_SHA256}
postHeaders := []string{"(request-target)", "Date", "Digest", "Content-Type", "Host"}
@@ -349,7 +398,7 @@ func FetchCollection(actor *core.Record, url string) (*pub.OrderedCollection, er
return nil, err
}
pubID := actor.GetString("iri") + "#main-key"
pubID := userActor.GetString("iri") + "#main-key"
if err := signer.SignRequest(privateKey, pubID, req, []byte{}); err != nil {
return nil, err
@@ -358,15 +407,16 @@ func FetchCollection(actor *core.Record, url string) (*pub.OrderedCollection, er
}
}
resp, err := http.DefaultClient.Do(req)
client := util.SafeHTTPClient()
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("collection fetch failed for %s: %v", url, err)
return nil, fmt.Errorf("collection fetch failed for %s: %v", collectionURL, err)
}
if resp.StatusCode != http.StatusOK {
if resp.StatusCode == http.StatusNotFound {
return nil, ErrProfilePrivate
}
return nil, fmt.Errorf("collection fetch %s returned: %v", url, resp.StatusCode)
return nil, fmt.Errorf("collection fetch %s returned: %v", collectionURL, resp.StatusCode)
}
defer resp.Body.Close()

View File

@@ -21,7 +21,7 @@ import (
"golang.org/x/net/html"
)
func CreateTrailActivity(app core.App, actor *core.Record, trail *core.Record, typ pub.ActivityVocabularyType) error {
func CreateTrailActivity(app core.App, ctx context.Context, trail *core.Record, typ pub.ActivityVocabularyType) error {
if !trail.GetBool("public") {
// only broadcast the trail if it is public
return nil
@@ -46,7 +46,7 @@ func CreateTrailActivity(app core.App, actor *core.Record, trail *core.Record, t
id := fmt.Sprintf("%s/api/v1/activitypub/activity/%s", origin, recordId)
to := "https://www.w3.org/ns/activitystreams#Public"
mentionedActors, err := ActorsFromMentions(app, actor, trail.GetString("description"))
mentionedActors, err := ActorsFromMentions(app, ctx, trail.GetString("description"))
if err != nil {
return err
}
@@ -108,7 +108,7 @@ func CreateTrailActivity(app core.App, actor *core.Record, trail *core.Record, t
return PostActivity(app, trailAuthor, activity, recipients)
}
func CreateCommentActivity(app core.App, actor *core.Record, comment *core.Record, typ pub.ActivityVocabularyType) error {
func CreateCommentActivity(app core.App, ctx context.Context, comment *core.Record, typ pub.ActivityVocabularyType) error {
origin := os.Getenv("ORIGIN")
if origin == "" {
return fmt.Errorf("ORIGIN not set")
@@ -134,7 +134,7 @@ func CreateCommentActivity(app core.App, actor *core.Record, comment *core.Recor
id := fmt.Sprintf("%s/api/v1/activitypub/activity/%s", origin, activityRecordId)
to := "https://www.w3.org/ns/activitystreams#Public"
mentionedActors, err := ActorsFromMentions(app, actor, comment.GetString("text"))
mentionedActors, err := ActorsFromMentions(app, ctx, comment.GetString("text"))
if err != nil {
return err
}
@@ -193,7 +193,7 @@ func CreateCommentActivity(app core.App, actor *core.Record, comment *core.Recor
}
func CreateSummitLogActivity(app core.App, actor *core.Record, summitLog *core.Record, typ pub.ActivityVocabularyType) error {
func CreateSummitLogActivity(app core.App, ctx context.Context, summitLog *core.Record, typ pub.ActivityVocabularyType) error {
origin := os.Getenv("ORIGIN")
if origin == "" {
@@ -245,7 +245,7 @@ func CreateSummitLogActivity(app core.App, actor *core.Record, summitLog *core.R
to.Append(pub.IRI(summitLogTrailAuthor.GetString("iri")))
}
mentionedActors, err := ActorsFromMentions(app, actor, summitLog.GetString("text"))
mentionedActors, err := ActorsFromMentions(app, ctx, summitLog.GetString("text"))
if err != nil {
return err
}
@@ -807,7 +807,7 @@ func processCreateOrUpdateListActivity(activity pub.Activity, app core.App, acto
return err
}
func ActorsFromMentions(app core.App, actor *core.Record, htmlStr string) ([]*core.Record, error) {
func ActorsFromMentions(app core.App, ctx context.Context, htmlStr string) ([]*core.Record, error) {
doc, err := html.Parse(strings.NewReader(htmlStr))
if err != nil {
return nil, err
@@ -842,7 +842,7 @@ func ActorsFromMentions(app core.App, actor *core.Record, htmlStr string) ([]*co
f(doc)
for _, h := range handles {
actor, err := GetActorByHandle(app, actor, h, false)
actor, err := GetActorByHandle(app, ctx, h, false)
if err != nil {
continue
}

View File

@@ -15,7 +15,10 @@ import (
)
func CreateTrailDeleteActivity(app core.App, r *core.Record) error {
if !r.GetBool("public") {
// only broadcast the trail if it is public
return nil
}
origin := os.Getenv("ORIGIN")
if origin == "" {
return fmt.Errorf("ORIGIN not set")

22
db/hooks/api_tokens.go Normal file
View File

@@ -0,0 +1,22 @@
package hooks
import (
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/tools/security"
)
func CreateAPITokenHandler() func(e *core.RecordEvent) error {
return func(e *core.RecordEvent) error {
rawToken := "wanderer_key_" + security.RandomString(32)
hashedKey := security.SHA256(rawToken)
e.Record.Set("token", hashedKey)
// Temporarily store rawToken so we can display it once to the user
e.Record.WithCustomData(true)
e.Record.Set("rawToken", rawToken)
return e.Next()
}
}

47
db/hooks/bootstrap.go Normal file
View File

@@ -0,0 +1,47 @@
package hooks
import (
"cmp"
"os"
"github.com/pocketbase/pocketbase/core"
"github.com/spf13/cast"
)
func OnBootstrapHandler() func(se *core.BootstrapEvent) error {
return func(e *core.BootstrapEvent) error {
if err := e.Next(); err != nil {
return err
}
if e.App.Settings().Meta.AppName == "Acme" {
e.App.Settings().Meta.AppName = "wanderer"
}
if v := os.Getenv("ORIGIN"); v != "" {
e.App.Settings().Meta.AppURL = v
}
if v := cmp.Or(os.Getenv("POCKETBASE_SMTP_SENDER_ADDRESS"), os.Getenv("POCKETBASE_SMTP_SENDER_ADRESS")); v != "" {
e.App.Settings().Meta.SenderAddress = v
}
if v := os.Getenv("POCKETBASE_SMTP_SENDER_NAME"); v != "" {
e.App.Settings().Meta.SenderName = v
}
if v := os.Getenv("POCKETBASE_SMTP_ENABLED"); v != "" {
e.App.Settings().SMTP.Enabled = cast.ToBool(v)
}
if v := os.Getenv("POCKETBASE_SMTP_HOST"); v != "" {
e.App.Settings().SMTP.Host = v
}
if v := os.Getenv("POCKETBASE_SMTP_PORT"); v != "" {
e.App.Settings().SMTP.Port = cast.ToInt(v)
}
if v := os.Getenv("POCKETBASE_SMTP_USERNAME"); v != "" {
e.App.Settings().SMTP.Username = v
}
if v := os.Getenv("POCKETBASE_SMTP_PASSWORD"); v != "" {
e.App.Settings().SMTP.Password = v
}
return e.App.Save(e.App.Settings())
}
}

65
db/hooks/comments.go Normal file
View File

@@ -0,0 +1,65 @@
package hooks
import (
"pocketbase/federation"
"pocketbase/util"
pub "github.com/go-ap/activitypub"
"github.com/meilisearch/meilisearch-go"
"github.com/pocketbase/pocketbase/core"
)
func CreateCommentHandler() func(e *core.RecordRequestEvent) error {
return func(e *core.RecordRequestEvent) error {
e.Next()
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
}
err = federation.CreateCommentActivity(e.App, ctx, e.Record, pub.CreateType)
if err != nil {
return err
}
return nil
}
}
func UpdateCommentHandler() func(e *core.RecordRequestEvent) error {
return func(e *core.RecordRequestEvent) error {
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
}
err = federation.CreateCommentActivity(e.App, ctx, e.Record, pub.UpdateType)
if err != nil {
return err
}
return e.Next()
}
}
func DeleteCommentHandler(client meilisearch.ServiceManager) func(e *core.RecordRequestEvent) error {
return func(e *core.RecordRequestEvent) error {
err := federation.CreateCommentDeleteActivity(e.App, client, e.Record)
if err != nil {
return err
}
return e.Next()
}
}

57
db/hooks/feed.go Normal file
View File

@@ -0,0 +1,57 @@
package hooks
import (
"fmt"
"pocketbase/util"
"strings"
"github.com/pocketbase/pocketbase/core"
)
func ListFeedHandler() func(e *core.RecordsListRequestEvent) error {
return func(e *core.RecordsListRequestEvent) error {
for _, r := range e.Records {
var item *core.Record
var err error
typ := r.GetString("type")
typ = strings.Trim(typ, "\"")
itemId := r.GetString("item")
itemId = strings.Trim(itemId, "\"")
switch typ {
case string(util.TrailFeed):
item, err = e.App.FindRecordById("trails", itemId)
case string(util.ListFeed):
item, err = e.App.FindRecordById("lists", itemId)
case string(util.SummitLogFeed):
item, err = e.App.FindRecordById("summit_logs", itemId)
}
if err != nil {
continue
}
if item == nil {
continue
}
errs := e.App.ExpandRecord(item, []string{"author"}, nil)
if len(errs) > 0 {
return fmt.Errorf("failed to expand author: %v", errs)
}
if typ == string(util.TrailFeed) {
errs := e.App.ExpandRecord(item, []string{"category"}, nil)
if len(errs) > 0 {
return fmt.Errorf("failed to expand category: %v", errs)
}
}
r.MergeExpand(map[string]any{"item": item})
}
return e.Next()
}
}

24
db/hooks/follow.go Normal file
View File

@@ -0,0 +1,24 @@
package hooks
import (
"pocketbase/federation"
"github.com/pocketbase/pocketbase/core"
)
func CreateFollowHandler() func(e *core.RecordRequestEvent) error {
return func(e *core.RecordRequestEvent) error {
e.Next()
federation.CreateFollowActivity(e.App, e.Record)
return nil
}
}
func DeleteFollowHandler() func(e *core.RecordRequestEvent) error {
return func(e *core.RecordRequestEvent) error {
federation.CreateUnfollowActivity(e.App, e.Record)
return e.Next()
}
}

146
db/hooks/integrations.go Normal file
View File

@@ -0,0 +1,146 @@
package hooks
import (
"encoding/json"
"os"
"pocketbase/util"
"github.com/pocketbase/pocketbase/apis"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/tools/security"
)
func ListIntegrationHandler() func(e *core.RecordsListRequestEvent) error {
return func(e *core.RecordsListRequestEvent) error {
if e.HasSuperuserAuth() {
return e.Next()
}
for _, r := range e.Records {
err := censorIntegrationSecrets(r)
if err != nil {
return err
}
}
return e.Next()
}
}
func CreateIntegrationHandler() func(e *core.RecordEvent) error {
return func(e *core.RecordEvent) error {
err := encryptIntegrationSecrets(e.App, e.Record)
if err != nil {
return err
}
return e.Next()
}
}
func CreateUpdateIntegrationSuccessHandler() func(e *core.RecordEvent) error {
return func(e *core.RecordEvent) error {
err := censorIntegrationSecrets(e.Record)
if err != nil {
return err
}
return e.Next()
}
}
func UpdateIntegrationHandler() func(e *core.RecordEvent) error {
return func(e *core.RecordEvent) error {
err := encryptIntegrationSecrets(e.App, e.Record)
if err != nil {
return err
}
return e.Next()
}
}
func censorIntegrationSecrets(r *core.Record) error {
secrets := map[string][]string{
"strava": {"clientSecret", "refreshToken", "accessToken", "expiresAt"},
"komoot": {"password"},
"hammerhead": {"password"},
}
for key, secretKeys := range secrets {
if integrationString := r.GetString(key); integrationString != "" {
var integration map[string]interface{}
if err := json.Unmarshal([]byte(integrationString), &integration); err != nil {
return err
}
if integration == nil {
continue
}
for _, secretKey := range secretKeys {
integration[secretKey] = ""
}
b, err := json.Marshal(integration)
if err != nil {
return err
}
r.Set(key, string(b))
}
}
return nil
}
func encryptIntegrationSecrets(app core.App, r *core.Record) error {
encryptionKey := os.Getenv("POCKETBASE_ENCRYPTION_KEY")
if len(encryptionKey) == 0 {
return apis.NewBadRequestError("POCKETBASE_ENCRYPTION_KEY not set", nil)
}
secrets := map[string][]string{
"strava": {"clientSecret", "refreshToken", "accessToken", "expiresAt"},
"komoot": {"password"},
"hammerhead": {"password"},
}
original, _ := app.FindRecordById("integrations", r.Id)
for key, secretKeys := range secrets {
if integrationString := r.GetString(key); integrationString != "" {
var integration map[string]interface{}
if err := json.Unmarshal([]byte(integrationString), &integration); err != nil {
return err
}
for _, secretKey := range secretKeys {
// If the secret is already encrypted, we don't re-encrypt it.
// TODO: This is a bit of a hack, we should handle this in a more robust way (e.g.
// storing flag on the record or prefixing encrypted strings with enc: or smilar).
// Doing that would also potentially allow us to support key rotation in the future.
if secret, ok := integration[secretKey].(string); ok && len(secret) > 0 && !util.CanDecryptSecret(secret) {
encryptedSecret, err := security.Encrypt([]byte(secret), encryptionKey)
if err != nil {
return err
}
integration[secretKey] = encryptedSecret
} else if original != nil {
originalString := original.GetString(key)
var originalIntegration map[string]interface{}
if err := json.Unmarshal([]byte(originalString), &originalIntegration); err != nil {
return err
}
if integration == nil {
continue
}
integration[secretKey] = originalIntegration[secretKey]
}
}
b, err := json.Marshal(integration)
if err != nil {
return err
}
r.Set(key, string(b))
}
}
return nil
}

105
db/hooks/list.go Normal file
View File

@@ -0,0 +1,105 @@
package hooks
import (
"pocketbase/federation"
"pocketbase/util"
pub "github.com/go-ap/activitypub"
"github.com/meilisearch/meilisearch-go"
"github.com/pocketbase/pocketbase/core"
)
func CreateListHandler(client meilisearch.ServiceManager) func(e *core.RecordEvent) error {
return func(e *core.RecordEvent) error {
record := e.Record
author, err := e.App.FindRecordById("activitypub_actors", record.GetString(("author")))
if err != nil {
return err
}
if err := util.IndexLists(e.App, []*core.Record{record}, client); err != nil {
return err
}
if !author.GetBool("isLocal") {
// this happens if someone fetches a remote list
// we create a stub list record for later reference
// no need to create an activity for that
return e.Next()
}
err = e.Next()
if err != nil {
return err
}
err = federation.CreateListActivity(e.App, e.Record, pub.CreateType)
if err != nil {
return err
}
_, err = util.InsertIntoFeed(e.App, author.Id, author.Id, record.Id, util.ListFeed)
if err != nil {
return err
}
return nil
}
}
func UpdateListHandler(client meilisearch.ServiceManager) func(e *core.RecordEvent) error {
return func(e *core.RecordEvent) error {
record := e.Record
author, err := e.App.FindRecordById("activitypub_actors", record.GetString(("author")))
if err != nil {
return err
}
err = util.UpdateList(e.App, record, author, client)
if err != nil {
return err
}
if !author.GetBool("isLocal") {
// this happens if someone fetches a remote list
// we create a stub list record for later reference
// no need to create an activity for that
return e.Next()
}
err = e.Next()
if err != nil {
return err
}
err = federation.CreateListActivity(e.App, e.Record, pub.CreateType)
if err != nil {
return err
}
return nil
}
}
func DeleteListHandler(client meilisearch.ServiceManager) func(e *core.RecordEvent) error {
return func(e *core.RecordEvent) error {
record := e.Record
_, err := client.Index("lists").DeleteDocument(record.Id, nil)
if err != nil {
return err
}
err = federation.CreateListDeleteActivity(e.App, record)
if err != nil {
return err
}
err = util.DeleteFromFeed(e.App, record.Id)
if err != nil {
return err
}
return e.Next()
}
}

56
db/hooks/list_share.go Normal file
View File

@@ -0,0 +1,56 @@
package hooks
import (
"pocketbase/federation"
"pocketbase/util"
"github.com/meilisearch/meilisearch-go"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/core"
)
func CreateListShareHandler(client meilisearch.ServiceManager) func(e *core.RecordRequestEvent) error {
return func(e *core.RecordRequestEvent) error {
err := e.Next()
if err != nil {
return err
}
record := e.Record
listId := record.GetString("list")
shares, err := e.App.FindAllRecords("list_share",
dbx.NewExp("list = {:listId}", dbx.Params{"listId": listId}),
)
if err != nil {
return err
}
actorIds := make([]string, len(shares))
for i, r := range shares {
actorIds[i] = r.GetString("actor")
}
err = util.UpdateListShares(listId, actorIds, client)
if err != nil {
return err
}
err = federation.CreateAnnounceActivity(e.App, record, federation.ListAnnounceType)
if err != nil {
return err
}
return nil
}
}
func DeleteListShareHandler(client meilisearch.ServiceManager) func(e *core.RecordRequestEvent) error {
return func(e *core.RecordRequestEvent) error {
record := e.Record
listId := record.GetString("list")
err := util.UpdateListShares(listId, []string{}, client)
if err != nil {
return err
}
return e.Next()
}
}

96
db/hooks/summit_logs.go Normal file
View File

@@ -0,0 +1,96 @@
package hooks
import (
"pocketbase/federation"
"pocketbase/util"
pub "github.com/go-ap/activitypub"
"github.com/meilisearch/meilisearch-go"
"github.com/pocketbase/pocketbase/core"
)
func CreateSummitLogHandler(client meilisearch.ServiceManager) func(e *core.RecordRequestEvent) error {
return func(e *core.RecordRequestEvent) error {
err := e.Next()
if err != nil {
return err
}
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
}
trail, err := e.App.FindRecordById("trails", e.Record.GetString("trail"))
if err != nil {
return err
}
if err := util.IndexTrails(e.App, []*core.Record{trail}, client); err != nil {
return err
}
err = federation.CreateSummitLogActivity(e.App, ctx, e.Record, pub.CreateType)
if err != nil {
return err
}
return nil
}
}
func UpdateSummitLogHandler() func(e *core.RecordRequestEvent) error {
return func(e *core.RecordRequestEvent) error {
err := e.Next()
if err != nil {
return err
}
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
}
err = federation.CreateSummitLogActivity(e.App, ctx, e.Record, pub.UpdateType)
if err != nil {
return err
}
return nil
}
}
func DeleteSummitLogHandler(client meilisearch.ServiceManager) func(e *core.RecordRequestEvent) error {
return func(e *core.RecordRequestEvent) error {
err := e.Next()
if err != nil {
return err
}
trail, err := e.App.FindRecordById("trails", e.Record.GetString("trail"))
if err != nil {
return err
}
if err := util.IndexTrails(e.App, []*core.Record{trail}, client); err != nil {
return err
}
err = federation.CreateSummitLogDeleteActivity(e.App, e.Record)
if err != nil {
return err
}
return nil
}
}

118
db/hooks/trail_like.go Normal file
View File

@@ -0,0 +1,118 @@
package hooks
import (
"database/sql"
"pocketbase/federation"
"pocketbase/util"
"github.com/meilisearch/meilisearch-go"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/core"
)
func CreateTrailLikeHandler(client meilisearch.ServiceManager) func(e *core.RecordEvent) error {
return func(e *core.RecordEvent) error {
err := e.Next()
if err != nil {
return err
}
record := e.Record
trailId := record.GetString("trail")
actorId := record.GetString("actor")
actor, err := e.App.FindRecordById("activitypub_actors", actorId)
if err != nil {
return err
}
trail, err := e.App.FindRecordById("trails", trailId)
if err != nil {
return err
}
likes, err := e.App.FindAllRecords("trail_like",
dbx.NewExp("trail = {:trailId}", dbx.Params{"trailId": trailId}),
)
if err != nil {
return err
}
trail.Set("like_count", len(likes))
err = e.App.UnsafeWithoutHooks().Save(trail)
if err != nil {
return err
}
actorIds := make([]string, len(likes))
for i, r := range likes {
actorIds[i] = r.GetString("actor")
}
err = util.UpdateTrailLikes(trailId, actorIds, client)
if err != nil {
return err
}
if !actor.GetBool("isLocal") {
// this happens if someone likes a remote trail
// we create a local copy
// no need to create an activity for that
return nil
}
err = federation.CreateLikeActivity(e.App, record)
if err != nil {
return err
}
return nil
}
}
func DeleteTrailLikeHandler(client meilisearch.ServiceManager) func(e *core.RecordEvent) error {
return func(e *core.RecordEvent) error {
record := e.Record
trailId := record.GetString("trail")
actorId := record.GetString("actor")
actor, err := e.App.FindRecordById("activitypub_actors", actorId)
if err != nil {
return err
}
// trail might deleted be already if this is called as part of a cascade
trail, err := e.App.FindRecordById("trails", trailId)
if err != nil && err == sql.ErrNoRows {
return nil
} else if err != nil {
return err
}
likes, err := e.App.CountRecords("trail_like", dbx.NewExp("trail={:trail}", dbx.Params{"trail": trailId}))
if err != nil {
return err
}
trail.Set("like_count", likes)
err = e.App.UnsafeWithoutHooks().Save(trail)
if err != nil {
return err
}
err = util.UpdateTrailLikes(trailId, []string{}, client)
if err != nil {
return err
}
if !actor.GetBool("isLocal") {
// this happens if someone likes a remote trail
// we create a local copy
// no need to create an activity for that
return nil
}
err = federation.CreateUnlikeActivity(e.App, record)
if err != nil {
return err
}
return e.Next()
}
}

57
db/hooks/trail_share.go Normal file
View File

@@ -0,0 +1,57 @@
package hooks
import (
"pocketbase/federation"
"pocketbase/util"
"github.com/meilisearch/meilisearch-go"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/core"
)
func CreateTrailShareHandler(client meilisearch.ServiceManager) func(e *core.RecordRequestEvent) error {
return func(e *core.RecordRequestEvent) error {
err := e.Next()
if err != nil {
return err
}
record := e.Record
trailId := record.GetString("trail")
shares, err := e.App.FindAllRecords("trail_share",
dbx.NewExp("trail = {:trailId}", dbx.Params{"trailId": trailId}),
)
if err != nil {
return err
}
actorIds := make([]string, len(shares))
for i, r := range shares {
actorIds[i] = r.GetString("actor")
}
err = util.UpdateTrailShares(trailId, actorIds, client)
if err != nil {
return err
}
err = federation.CreateAnnounceActivity(e.App, record, federation.TrailAnnounceType)
if err != nil {
return err
}
return nil
}
}
func DeleteTrailShareHandler(client meilisearch.ServiceManager) func(e *core.RecordRequestEvent) error {
return func(e *core.RecordRequestEvent) error {
record := e.Record
trailId := record.GetString("trail")
err := util.UpdateTrailShares(trailId, []string{}, client)
if err != nil {
return err
}
return e.Next()
}
}

122
db/hooks/trails.go Normal file
View File

@@ -0,0 +1,122 @@
package hooks
import (
"log"
"pocketbase/federation"
"pocketbase/util"
"time"
"github.com/go-ap/activitypub"
pub "github.com/go-ap/activitypub"
"github.com/meilisearch/meilisearch-go"
"github.com/pocketbase/pocketbase/core"
)
func CreateTrailHandler(client meilisearch.ServiceManager) func(e *core.RecordEvent) error {
return func(e *core.RecordEvent) error {
record := e.Record
userActor, err := e.App.FindRecordById("activitypub_actors", record.GetString(("author")))
if err != nil {
return err
}
if err := util.IndexTrails(e.App, []*core.Record{record}, client); err != nil {
return err
}
if !userActor.GetBool("isLocal") {
// this happens if someone fetches a remote trail
// we create a stub trail record for later reference
// no need to create an activity for that
return e.Next()
}
err = e.Next()
if err != nil {
return err
}
ctx, err := util.GetSafeActorContext(nil, userActor)
if err != nil {
return err
}
err = federation.CreateTrailActivity(e.App, ctx, e.Record, activitypub.CreateType)
if err != nil {
return err
}
_, err = util.InsertIntoFeed(e.App, userActor.Id, userActor.Id, record.Id, util.TrailFeed)
if err != nil {
return err
}
return nil
}
}
func UpdateTrailHandler(client meilisearch.ServiceManager) func(e *core.RecordEvent) error {
return func(e *core.RecordEvent) error {
record := e.Record
userActor, err := e.App.FindRecordById("activitypub_actors", record.GetString(("author")))
if err != nil {
return err
}
err = util.UpdateTrail(e.App, record, userActor, client)
if err != nil {
return err
}
if !userActor.GetBool("isLocal") {
// this happens if someone fetches a remote trail
// we create a stub trail record for later reference
// no need to create an activity for that
return e.Next()
}
err = e.Next()
if err != nil {
return err
}
ctx, err := util.GetSafeActorContext(nil, userActor)
if err != nil {
return err
}
err = federation.CreateTrailActivity(e.App, ctx, e.Record, pub.UpdateType)
if err != nil {
return err
}
return nil
}
}
func DeleteTrailHandler(client meilisearch.ServiceManager) func(e *core.RecordEvent) error {
return func(e *core.RecordEvent) error {
record := e.Record
task, err := client.Index("trails").DeleteDocument(record.Id, nil)
if err != nil {
return err
}
interval := 500 * time.Millisecond
_, err = client.WaitForTask(task.TaskUID, interval)
if err != nil {
log.Fatalf("Error waiting for task completion: %v", err)
}
err = federation.CreateTrailDeleteActivity(e.App, e.Record)
if err != nil {
return err
}
err = util.DeleteFromFeed(e.App, record.Id)
if err != nil {
return err
}
return e.Next()
}
}

112
db/hooks/users.go Normal file
View File

@@ -0,0 +1,112 @@
package hooks
import (
"fmt"
"os"
"pocketbase/util"
"github.com/meilisearch/meilisearch-go"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/core"
)
func CreateUserHandler(client meilisearch.ServiceManager) func(e *core.RecordEvent) error {
return func(e *core.RecordEvent) error {
userId := e.Record.Id
err := createDefaultUserSettings(e.App, e.Record.Id)
if err != nil {
return err
}
actor, err := util.ActorFromUser(e.App, e.Record)
if err != nil {
return err
}
searchRules := map[string]interface{}{
"lists": map[string]string{
"filter": "public = true OR author = " + actor.Id + " OR shares = " + userId,
},
"trails": map[string]string{
"filter": "public = true OR author = " + actor.Id + " OR shares = " + userId,
},
}
token, err := util.GenerateMeilisearchToken(searchRules, client)
if err != nil {
return err
}
e.Record.Set("token", token)
if err := e.App.Save(e.Record); err != nil {
return err
}
return e.Next()
}
}
func UpdateUserHandler(client meilisearch.ServiceManager) func(e *core.RecordEvent) error {
return func(e *core.RecordEvent) error {
actor, err := e.App.FindFirstRecordByData("activitypub_actors", "user", e.Record.Id)
if err != nil {
return e.Next()
}
icon := ""
origin := os.Getenv("ORIGIN")
if origin != "" && e.Record.GetString("avatar") != "" {
icon = fmt.Sprintf("%s/api/v1/files/_pb_users_auth_/%s/%s", origin, e.Record.Id, e.Record.GetString("avatar"))
}
actor.Set("icon", icon)
if err := e.App.Save(actor); err != nil {
return err
}
trails, err := e.App.FindRecordsByFilter("trails", "author={:author}", "", -1, 0, dbx.Params{"author": actor.Id})
if err != nil {
return err
}
if len(trails) > 0 {
if err := util.IndexTrails(e.App, trails, client); err != nil {
return err
}
}
lists, err := e.App.FindRecordsByFilter("lists", "author={:author}", "", -1, 0, dbx.Params{"author": actor.Id})
if err != nil {
return err
}
if len(lists) > 0 {
if err := util.IndexLists(e.App, lists, client); err != nil {
return err
}
}
return e.Next()
}
}
func ChangeUserEmailHandler() func(e *core.RecordRequestEmailChangeRequestEvent) error {
return func(e *core.RecordRequestEmailChangeRequestEvent) error {
e.Record.Set("email", e.NewEmail)
if err := e.App.Save(e.Record); err != nil {
return err
}
return nil
}
}
func createDefaultUserSettings(app core.App, userId string) error {
collection, err := app.FindCollectionByNameOrId("settings")
if err != nil {
return err
}
settings := core.NewRecord(collection)
settings.Set("language", "en")
settings.Set("unit", "metric")
settings.Set("mapFocus", "trails")
settings.Set("user", userId)
return app.Save(settings)
}

View File

@@ -2,6 +2,7 @@ package hammerhead
import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"errors"
@@ -24,7 +25,7 @@ import (
"github.com/pocketbase/pocketbase/tools/security"
"github.com/tkrajina/gpxgo/gpx"
"pocketbase/trailmerge"
"pocketbase/services/trailmerge"
"pocketbase/util"
)
@@ -48,6 +49,12 @@ func SyncHammerhead(app core.App, client meilisearch.ServiceManager) error {
app.Logger().Warn(warning)
continue
}
ctx, err := util.GetSafeActorContext(nil, actor)
if err != nil {
continue
}
hammerheadString := i.GetString("hammerhead")
hammerheadIntegration := HammerheadIntegration{
Planned: true,
@@ -111,7 +118,7 @@ func SyncHammerhead(app core.App, client meilisearch.ServiceManager) error {
totalPages = curTotalPages
}
err, stopped = syncTrailWithTours(app, client, h, actor, hammerheadIntegration, tours, after)
err, stopped = syncTrailWithTours(app, client, ctx, h, actor, hammerheadIntegration, tours, after)
if err != nil {
warning := fmt.Sprintf("error syncing Hammerhead tours with trails: %v\n", err)
fmt.Print(warning)
@@ -142,7 +149,7 @@ func SyncHammerhead(app core.App, client meilisearch.ServiceManager) error {
totalPages = curTotalPages
}
err, stopped = syncTrailWithActivities(app, client, h, actor, hammerheadIntegration, tours, after)
err, stopped = syncTrailWithActivities(app, client, ctx, h, actor, hammerheadIntegration, tours, after)
if err != nil {
warning := fmt.Sprintf("error syncing Hammerhead tours with trails: %v\n", err)
fmt.Print(warning)
@@ -409,7 +416,7 @@ func (h *HammerheadApi) fetchDetailedTour(tour HammerheadTourResponse) (*Hammerh
return data, nil
}
func syncTrailWithTours(app core.App, client meilisearch.ServiceManager, k *HammerheadApi, actor *core.Record, integration HammerheadIntegration, tours []HammerheadTourResponse, after int64) (error, bool) {
func syncTrailWithTours(app core.App, client meilisearch.ServiceManager, ctx context.Context, k *HammerheadApi, actor *core.Record, integration HammerheadIntegration, tours []HammerheadTourResponse, after int64) (error, bool) {
for _, tour := range tours {
existingTrail, err := util.FindTrailByExternalReference(app, "hammerhead", tour.ID)
if err != nil {
@@ -445,7 +452,7 @@ func syncTrailWithTours(app core.App, client meilisearch.ServiceManager, k *Hamm
app.Logger().Warn(fmt.Sprintf("Unable to create trail for tour '%s': %v", tour.Name, err))
continue
}
if err := trailmerge.TryAutoMergeImportedTrail(app, client, actor, trailID, integration.Merge); err != nil {
if err := trailmerge.TryAutoMergeImportedTrail(app, client, ctx, actor, trailID, integration.Merge); err != nil {
app.Logger().Warn(fmt.Sprintf("Unable to auto-merge imported Hammerhead tour '%s': %v", tour.Name, err))
}
}
@@ -453,7 +460,7 @@ func syncTrailWithTours(app core.App, client meilisearch.ServiceManager, k *Hamm
return nil, false
}
func syncTrailWithActivities(app core.App, client meilisearch.ServiceManager, k *HammerheadApi, actor *core.Record, integration HammerheadIntegration, tours []HammerheadActivityResponse, after int64) (error, bool) {
func syncTrailWithActivities(app core.App, client meilisearch.ServiceManager, ctx context.Context, k *HammerheadApi, actor *core.Record, integration HammerheadIntegration, tours []HammerheadActivityResponse, after int64) (error, bool) {
for _, tour := range tours {
existingTrail, err := util.FindTrailByExternalReference(app, "hammerhead", tour.ID)
if err != nil {
@@ -490,7 +497,7 @@ func syncTrailWithActivities(app core.App, client meilisearch.ServiceManager, k
app.Logger().Warn(fmt.Sprintf("Unable to create trail for tour '%s': %v", tour.Name, err))
continue
}
if err := trailmerge.TryAutoMergeImportedTrail(app, client, actor, trailID, integration.Merge); err != nil {
if err := trailmerge.TryAutoMergeImportedTrail(app, client, ctx, actor, trailID, integration.Merge); err != nil {
app.Logger().Warn(fmt.Sprintf("Unable to auto-merge imported Hammerhead activity '%s': %v", tour.Name, err))
}
}

View File

@@ -3,7 +3,7 @@ package hammerhead
import (
"time"
"pocketbase/trailmerge"
"pocketbase/services/trailmerge"
)
type HammerheadToursResponse struct {

View File

@@ -1,6 +1,7 @@
package komoot
import (
"context"
"encoding/base64"
"encoding/json"
"errors"
@@ -19,7 +20,7 @@ import (
"github.com/pocketbase/pocketbase/tools/security"
"github.com/tkrajina/gpxgo/gpx"
"pocketbase/trailmerge"
"pocketbase/services/trailmerge"
"pocketbase/util"
)
@@ -43,6 +44,12 @@ func SyncKomoot(app core.App, client meilisearch.ServiceManager) error {
app.Logger().Warn(warning)
continue
}
ctx, err := util.GetSafeActorContext(nil, actor)
if err != nil {
continue
}
komootString := i.GetString("komoot")
komootIntegration := KomootIntegration{
Planned: true,
@@ -82,7 +89,7 @@ func SyncKomoot(app core.App, client meilisearch.ServiceManager) error {
}
totalPages = tp
allAlreadySynced, err := syncTrailWithTours(app, client, k, komootIntegration, userId, actor, tours)
allAlreadySynced, err := syncTrailWithTours(app, client, ctx, k, komootIntegration, userId, actor, tours)
if err != nil {
warning := fmt.Sprintf("error syncing komoot tours with trails: %v\n", err)
fmt.Print(warning)
@@ -191,7 +198,7 @@ func (k *KomootApi) fetchDetailedTour(tour KomootTour) (*DetailedKomootTour, err
// when every tour on this page was already imported, so the caller can stop paginating
// early during incremental syncs. Tours skipped due to type filters do NOT count as
// synced - only tours already present in the DB do.
func syncTrailWithTours(app core.App, client meilisearch.ServiceManager, k *KomootApi, i KomootIntegration, user string, actor *core.Record, tours []KomootTour) (bool, error) {
func syncTrailWithTours(app core.App, client meilisearch.ServiceManager, ctx context.Context, k *KomootApi, i KomootIntegration, user string, actor *core.Record, tours []KomootTour) (bool, error) {
allAlreadySynced := true
for _, tour := range tours {
existingTrail, err := util.FindTrailByExternalReference(app, "komoot", strconv.Itoa(int(tour.ID)))
@@ -226,7 +233,7 @@ func syncTrailWithTours(app core.App, client meilisearch.ServiceManager, k *Komo
app.Logger().Warn(fmt.Sprintf("Unable to create waypoints for tour '%s': %v", tour.Name, err))
continue
}
if err := trailmerge.TryAutoMergeImportedTrail(app, client, actor, trailid, i.Merge); err != nil {
if err := trailmerge.TryAutoMergeImportedTrail(app, client, ctx, actor, trailid, i.Merge); err != nil {
app.Logger().Warn(fmt.Sprintf("Unable to auto-merge imported komoot tour '%s': %v", tour.Name, err))
}

View File

@@ -3,7 +3,7 @@ package komoot
import (
"time"
"pocketbase/trailmerge"
"pocketbase/services/trailmerge"
)
type KomootIntegration struct {

View File

@@ -3,7 +3,7 @@ package strava
import (
"time"
"pocketbase/trailmerge"
"pocketbase/services/trailmerge"
)
type TokenRequest struct {

View File

@@ -2,6 +2,7 @@ package strava
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
@@ -19,7 +20,7 @@ import (
"github.com/tkrajina/gpxgo/gpx"
"github.com/twpayne/go-polyline"
"pocketbase/trailmerge"
"pocketbase/services/trailmerge"
"pocketbase/util"
)
@@ -47,6 +48,12 @@ func SyncStrava(app core.App, client meilisearch.ServiceManager) error {
app.Logger().Warn(warning)
continue
}
ctx, err := util.GetSafeActorContext(nil, actor)
if err != nil {
continue
}
stravaString := i.GetString("strava")
var stravaIntegration StravaIntegration
err = json.Unmarshal([]byte(stravaString), &stravaIntegration)
@@ -104,7 +111,7 @@ func SyncStrava(app core.App, client meilisearch.ServiceManager) error {
app.Logger().Warn(warning)
break
}
err = syncTrailsWithRoutes(app, client, stravaIntegration, r.AccessToken, userId, actor, routes)
err = syncTrailsWithRoutes(app, client, ctx, stravaIntegration, r.AccessToken, userId, actor, routes)
if err != nil {
warning := fmt.Sprintf("error syncing strava routes with trails: %v\n", err)
fmt.Print(warning)
@@ -136,7 +143,7 @@ func SyncStrava(app core.App, client meilisearch.ServiceManager) error {
app.Logger().Warn(warning)
break
}
err = syncTrailsWithActivities(app, client, stravaIntegration, r.AccessToken, userId, actor, activities)
err = syncTrailsWithActivities(app, client, ctx, stravaIntegration, r.AccessToken, userId, actor, activities)
if err != nil {
warning := fmt.Sprintf("error syncing strava activities with trails: %v", err)
@@ -250,7 +257,7 @@ func fetchStravaActivities(accessToken string, page int, after int64) ([]StravaA
return activities, nil
}
func syncTrailsWithRoutes(app core.App, client meilisearch.ServiceManager, i StravaIntegration, accessToken string, user string, actor *core.Record, routes []StravaRoute) error {
func syncTrailsWithRoutes(app core.App, client meilisearch.ServiceManager, ctx context.Context, i StravaIntegration, accessToken string, user string, actor *core.Record, routes []StravaRoute) error {
for _, route := range routes {
existingTrail, err := util.FindTrailByExternalReference(app, "strava", route.IDStr)
if err != nil {
@@ -274,7 +281,7 @@ func syncTrailsWithRoutes(app core.App, client meilisearch.ServiceManager, i Str
app.Logger().Warn(fmt.Sprintf("Unable to create waypoints for route '%s': %v", route.Name, err))
continue
}
if err := trailmerge.TryAutoMergeImportedTrail(app, client, actor, trailid, i.Merge); err != nil {
if err := trailmerge.TryAutoMergeImportedTrail(app, client, ctx, actor, trailid, i.Merge); err != nil {
app.Logger().Warn(fmt.Sprintf("Unable to auto-merge imported Strava route '%s': %v", route.Name, err))
}
}
@@ -426,7 +433,7 @@ func createWaypointsFromRoute(app core.App, route StravaRoute, user string, trai
return nil
}
func syncTrailsWithActivities(app core.App, client meilisearch.ServiceManager, i StravaIntegration, accessToken string, user string, actor *core.Record, activities []StravaActivity) error {
func syncTrailsWithActivities(app core.App, client meilisearch.ServiceManager, ctx context.Context, i StravaIntegration, accessToken string, user string, actor *core.Record, activities []StravaActivity) error {
for _, activity := range activities {
existingTrail, err := util.FindTrailByExternalReference(app, "strava", strconv.Itoa(int(activity.ID)))
if err != nil {
@@ -450,7 +457,7 @@ func syncTrailsWithActivities(app core.App, client meilisearch.ServiceManager, i
app.Logger().Warn(fmt.Sprintf("Unable to create trail from activity '%s': %v", activity.Name, err))
continue
}
if err := trailmerge.TryAutoMergeImportedTrail(app, client, actor, trailID, i.Merge); err != nil {
if err := trailmerge.TryAutoMergeImportedTrail(app, client, ctx, actor, trailID, i.Merge); err != nil {
app.Logger().Warn(fmt.Sprintf("Unable to auto-merge imported Strava activity '%s': %v", activity.Name, err))
}
}

1487
db/main.go

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,60 @@
package migrations
import (
"encoding/json"
"github.com/pocketbase/pocketbase/core"
m "github.com/pocketbase/pocketbase/migrations"
)
func init() {
m.Register(func(app core.App) error {
collection, err := app.FindCollectionByNameOrId("goeo2ubp103rzp9")
if err != nil {
return err
}
// add field
if err := collection.Fields.AddMarshaledJSONAt(8, []byte(`{
"exceptDomains": null,
"hidden": false,
"id": "url2434853685",
"name": "iri",
"onlyDomains": null,
"presentable": false,
"required": false,
"system": false,
"type": "url"
}`)); err != nil {
return err
}
// update collection data
if err := json.Unmarshal([]byte(`{
"indexes": [
"CREATE UNIQUE INDEX `+"`"+`idx_GgX6MsdCJq`+"`"+` ON `+"`"+`waypoints`+"`"+` (`+"`"+`iri`+"`"+`) WHERE iri IS NOT NULL AND iri != \"\";"
]
}`), &collection); err != nil {
return err
}
return app.Save(collection)
}, func(app core.App) error {
collection, err := app.FindCollectionByNameOrId("goeo2ubp103rzp9")
if err != nil {
return err
}
// update collection data
if err := json.Unmarshal([]byte(`{
"indexes": []
}`), &collection); err != nil {
return err
}
// remove field
collection.Fields.RemoveById("url2434853685")
return app.Save(collection)
})
}

View File

@@ -0,0 +1,78 @@
package migrations
import (
"github.com/pocketbase/pocketbase/core"
m "github.com/pocketbase/pocketbase/migrations"
)
func init() {
m.Register(func(app core.App) error {
collection, err := app.FindCollectionByNameOrId("goeo2ubp103rzp9")
if err != nil {
return err
}
// add field
if err := collection.Fields.AddMarshaledJSONAt(11, []byte(`{
"cascadeDelete": true,
"collectionId": "pbc_1295301207",
"hidden": false,
"id": "relation3182418120",
"maxSelect": 1,
"minSelect": 0,
"name": "author",
"presentable": false,
"required": true,
"system": false,
"type": "relation"
}`)); err != nil {
return err
}
// update field
if err := collection.Fields.AddMarshaledJSONAt(9, []byte(`{
"cascadeDelete": true,
"collectionId": "_pb_users_auth_",
"hidden": false,
"id": "8qbxrsd8",
"maxSelect": 1,
"minSelect": 0,
"name": "user",
"presentable": false,
"required": true,
"system": false,
"type": "relation"
}`)); err != nil {
return err
}
return app.Save(collection)
}, func(app core.App) error {
collection, err := app.FindCollectionByNameOrId("goeo2ubp103rzp9")
if err != nil {
return err
}
// remove field
collection.Fields.RemoveById("relation3182418120")
// update field
if err := collection.Fields.AddMarshaledJSONAt(9, []byte(`{
"cascadeDelete": true,
"collectionId": "_pb_users_auth_",
"hidden": false,
"id": "8qbxrsd8",
"maxSelect": 1,
"minSelect": 0,
"name": "author",
"presentable": false,
"required": true,
"system": false,
"type": "relation"
}`)); err != nil {
return err
}
return app.Save(collection)
})
}

View File

@@ -0,0 +1,33 @@
package migrations
import (
"github.com/pocketbase/pocketbase/core"
m "github.com/pocketbase/pocketbase/migrations"
)
func init() {
m.Register(func(app core.App) error {
wps, err := app.FindAllRecords("waypoints")
if err != nil {
return err
}
for _, wp := range wps {
actor, err := app.FindFirstRecordByData("activitypub_actors", "user", wp.GetString("user"))
if err != nil {
return err
}
wp.Set("author", actor.Id)
err = app.UnsafeWithoutHooks().Save(wp)
if err != nil {
return err
}
}
return nil
}, func(app core.App) error {
// add down queries...
return nil
})
}

View File

@@ -0,0 +1,44 @@
package migrations
import (
"github.com/pocketbase/pocketbase/core"
m "github.com/pocketbase/pocketbase/migrations"
)
func init() {
m.Register(func(app core.App) error {
collection, err := app.FindCollectionByNameOrId("goeo2ubp103rzp9")
if err != nil {
return err
}
// remove field
collection.Fields.RemoveById("8qbxrsd8")
return app.Save(collection)
}, func(app core.App) error {
collection, err := app.FindCollectionByNameOrId("goeo2ubp103rzp9")
if err != nil {
return err
}
// add field
if err := collection.Fields.AddMarshaledJSONAt(9, []byte(`{
"cascadeDelete": true,
"collectionId": "_pb_users_auth_",
"hidden": false,
"id": "8qbxrsd8",
"maxSelect": 1,
"minSelect": 0,
"name": "user",
"presentable": false,
"required": true,
"system": false,
"type": "relation"
}`)); err != nil {
return err
}
return app.Save(collection)
})
}

View File

@@ -0,0 +1,40 @@
package migrations
import (
"github.com/pocketbase/pocketbase/core"
m "github.com/pocketbase/pocketbase/migrations"
)
func init() {
m.Register(func(app core.App) error {
collection, err := app.FindCollectionByNameOrId("e864strfxo14pm4")
if err != nil {
return err
}
// add field
if err := collection.Fields.AddMarshaledJSONAt(23, []byte(`{
"hidden": false,
"id": "bool678597678",
"name": "needs_full_sync",
"presentable": false,
"required": false,
"system": false,
"type": "bool"
}`)); err != nil {
return err
}
return app.Save(collection)
}, func(app core.App) error {
collection, err := app.FindCollectionByNameOrId("e864strfxo14pm4")
if err != nil {
return err
}
// remove field
collection.Fields.RemoveById("bool678597678")
return app.Save(collection)
})
}

View File

@@ -0,0 +1,42 @@
package migrations
import (
"encoding/json"
"github.com/pocketbase/pocketbase/core"
m "github.com/pocketbase/pocketbase/migrations"
)
func init() {
m.Register(func(app core.App) error {
collection, err := app.FindCollectionByNameOrId("e864strfxo14pm4")
if err != nil {
return err
}
// update collection data
if err := json.Unmarshal([]byte(`{
"createRule": "@request.auth.id != \"\" && (@request.body.author.user = @request.auth.id)",
"updateRule": "author.user = @request.auth.id || (@request.auth.id != \"\" && trail_share_via_trail.trail = id && trail_share_via_trail.actor.user ?= @request.auth.id && trail_share_via_trail.permission = \"edit\")"
}`), &collection); err != nil {
return err
}
return app.Save(collection)
}, func(app core.App) error {
collection, err := app.FindCollectionByNameOrId("e864strfxo14pm4")
if err != nil {
return err
}
// update collection data
if err := json.Unmarshal([]byte(`{
"createRule": "@request.auth.id != \"\" && (@request.body.author.user = @request.auth.id || author.isLocal = false)",
"updateRule": "author.user = @request.auth.id || (@request.auth.id != \"\" && trail_share_via_trail.trail = id && trail_share_via_trail.actor.user ?= @request.auth.id && trail_share_via_trail.permission = \"edit\") || (@request.auth.id != \"\" && author.isLocal = false)"
}`), &collection); err != nil {
return err
}
return app.Save(collection)
})
}

View File

@@ -0,0 +1,42 @@
package migrations
import (
"encoding/json"
"github.com/pocketbase/pocketbase/core"
m "github.com/pocketbase/pocketbase/migrations"
)
func init() {
m.Register(func(app core.App) error {
collection, err := app.FindCollectionByNameOrId("r6gu2ajyidy1x69")
if err != nil {
return err
}
// update collection data
if err := json.Unmarshal([]byte(`{
"createRule": "@request.auth.id != \"\" && (@request.body.author.user = @request.auth.id)",
"updateRule": "author.user = @request.auth.id || (@request.auth.id != \"\" && list_share_via_list.list = id && list_share_via_list.actor.user ?= @request.auth.id && list_share_via_list.permission = \"edit\")"
}`), &collection); err != nil {
return err
}
return app.Save(collection)
}, func(app core.App) error {
collection, err := app.FindCollectionByNameOrId("r6gu2ajyidy1x69")
if err != nil {
return err
}
// update collection data
if err := json.Unmarshal([]byte(`{
"createRule": "@request.auth.id != \"\" && (@request.body.author.user = @request.auth.id || author.isLocal = false)",
"updateRule": "author.user = @request.auth.id || (@request.auth.id != \"\" && list_share_via_list.list = id && list_share_via_list.actor.user ?= @request.auth.id && list_share_via_list.permission = \"edit\") || (@request.auth.id != \"\" && author.isLocal = false)"
}`), &collection); err != nil {
return err
}
return app.Save(collection)
})
}

View File

@@ -0,0 +1,88 @@
package migrations
import (
"github.com/pocketbase/pocketbase/core"
m "github.com/pocketbase/pocketbase/migrations"
)
func init() {
m.Register(func(app core.App) error {
collection, err := app.FindCollectionByNameOrId("pbc_1295301207")
if err != nil {
return err
}
// update field
if err := collection.Fields.AddMarshaledJSONAt(5, []byte(`{
"hidden": false,
"id": "number1386272118",
"max": null,
"min": null,
"name": "follower_count",
"onlyInt": true,
"presentable": false,
"required": false,
"system": false,
"type": "number"
}`)); err != nil {
return err
}
// update field
if err := collection.Fields.AddMarshaledJSONAt(6, []byte(`{
"hidden": false,
"id": "number3430500629",
"max": null,
"min": null,
"name": "following_count",
"onlyInt": true,
"presentable": false,
"required": false,
"system": false,
"type": "number"
}`)); err != nil {
return err
}
return app.Save(collection)
}, func(app core.App) error {
collection, err := app.FindCollectionByNameOrId("pbc_1295301207")
if err != nil {
return err
}
// update field
if err := collection.Fields.AddMarshaledJSONAt(5, []byte(`{
"hidden": false,
"id": "number1386272118",
"max": null,
"min": null,
"name": "followerCount",
"onlyInt": true,
"presentable": false,
"required": false,
"system": false,
"type": "number"
}`)); err != nil {
return err
}
// update field
if err := collection.Fields.AddMarshaledJSONAt(6, []byte(`{
"hidden": false,
"id": "number3430500629",
"max": null,
"min": null,
"name": "followingCount",
"onlyInt": true,
"presentable": false,
"required": false,
"system": false,
"type": "number"
}`)); err != nil {
return err
}
return app.Save(collection)
})
}

View File

@@ -0,0 +1,40 @@
package migrations
import (
"github.com/pocketbase/pocketbase/core"
m "github.com/pocketbase/pocketbase/migrations"
)
func init() {
m.Register(func(app core.App) error {
collection, err := app.FindCollectionByNameOrId("r6gu2ajyidy1x69")
if err != nil {
return err
}
// add field
if err := collection.Fields.AddMarshaledJSONAt(8, []byte(`{
"hidden": false,
"id": "bool678597678",
"name": "needs_full_sync",
"presentable": false,
"required": false,
"system": false,
"type": "bool"
}`)); err != nil {
return err
}
return app.Save(collection)
}, func(app core.App) error {
collection, err := app.FindCollectionByNameOrId("r6gu2ajyidy1x69")
if err != nil {
return err
}
// remove field
collection.Fields.RemoveById("bool678597678")
return app.Save(collection)
})
}

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

@@ -1,13 +1,13 @@
package main
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"
"pocketbase/trailmerge"
)
type mergeExecuteRequest struct {
@@ -16,45 +16,45 @@ type mergeExecuteRequest struct {
Settings trailmerge.MergeSettings `json:"settings"`
}
func registerTrailMergeRoutes(se *core.ServeEvent, client meilisearch.ServiceManager) {
se.Router.POST("/trail-merge/suggest", func(e *core.RequestEvent) error {
if e.Auth == nil {
return apis.NewUnauthorizedError("trail_merge_auth_required", nil)
}
func TrailMergeSuggest(e *core.RequestEvent) error {
if e.Auth == nil {
return apis.NewUnauthorizedError("trail_merge_auth_required", nil)
}
actor, err := e.App.FindFirstRecordByData("activitypub_actors", "user", e.Auth.Id)
if err != nil {
return apis.NewBadRequestError("trail_merge_actor_not_found", err)
}
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)
}
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, actor.Id, request)
if err != nil {
return apis.NewBadRequestError(err.Error(), err)
}
return e.JSON(http.StatusOK, response)
}
response, err := trailmerge.Suggest(e.App, actor.Id, request)
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)
})
}
se.Router.POST("/trail-merge", func(e *core.RequestEvent) error {
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)
}
actor, err := e.App.FindFirstRecordByData("activitypub_actors", "user", e.Auth.Id)
userActor, err := e.App.FindFirstRecordByData("activitypub_actors", "user", e.Auth.Id)
if err != nil {
return apis.NewBadRequestError("trail_merge_actor_not_found", err)
}
@@ -73,16 +73,18 @@ func registerTrailMergeRoutes(se *core.ServeEvent, client meilisearch.ServiceMan
return apis.NewBadRequestError("trail_merge_target_not_found", err)
}
if !trailmerge.CanMerge(e.App, actor.Id, source, target, request.Settings.Delete) {
if !trailmerge.CanMerge(e.App, userActor.Id, source, target, request.Settings.Delete) {
return apis.NewForbiddenError("trail_merge_not_allowed", nil)
}
if err := trailmerge.Merge(e.App, client, actor, request.SourceTrailID, request.TargetTrailID, request.Settings); err != 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

@@ -1,4 +1,4 @@
package waypointcluster
package routes
import (
"net/http"
@@ -49,7 +49,7 @@ type categorySettings struct {
WaypointMergeRadius *float64 `json:"wp_merge_radius"`
}
func Handler(e *core.RequestEvent) error {
func WaypointCluster(e *core.RequestEvent) error {
if e.Auth == nil {
return apis.NewUnauthorizedError("authentication required", nil)
}

View File

@@ -1,6 +1,8 @@
package trailmerge
import (
"context"
"github.com/meilisearch/meilisearch-go"
"github.com/pocketbase/pocketbase/core"
)
@@ -8,6 +10,7 @@ import (
func TryAutoMergeImportedTrail(
app core.App,
client meilisearch.ServiceManager,
ctx context.Context,
actor *core.Record,
sourceTrailID string,
settings IntegrationAutoMergeSettings,
@@ -40,5 +43,5 @@ func TryAutoMergeImportedTrail(
return nil
}
return Merge(app, client, actor, sourceTrailID, targetTrailID, DefaultIntegrationAutoMergeMergeSettings())
return Merge(app, client, ctx, actor, sourceTrailID, targetTrailID, DefaultIntegrationAutoMergeMergeSettings())
}

View File

@@ -2,16 +2,18 @@ package trailmerge
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"slices"
"strings"
pub "github.com/go-ap/activitypub"
"github.com/meilisearch/meilisearch-go"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/tools/filesystem"
"io"
"slices"
"strings"
"pocketbase/federation"
"pocketbase/util"
@@ -176,7 +178,7 @@ func SuggestGroups(app core.App, actorID string, request SuggestRequest) (*Sugge
// Merge links a source trail into a target trail in a single transaction.
// It moves or recreates trail-related content according to the provided
// settings and keeps the target trail indexed and federated afterwards.
func Merge(app core.App, client meilisearch.ServiceManager, actor *core.Record, sourceTrailID string, targetTrailID string, settings MergeSettings) error {
func Merge(app core.App, client meilisearch.ServiceManager, ctx context.Context, actor *core.Record, sourceTrailID string, targetTrailID string, settings MergeSettings) error {
if actor == nil {
return ErrMissingActor
}
@@ -198,7 +200,7 @@ func Merge(app core.App, client meilisearch.ServiceManager, actor *core.Record,
return err
}
ctx := mergeContext{
mergeCtx := mergeContext{
App: txApp,
Client: client,
Actor: actor,
@@ -208,7 +210,7 @@ func Merge(app core.App, client meilisearch.ServiceManager, actor *core.Record,
Settings: settings,
}
sideEffects, err := mergeTrailIntoTarget(ctx)
sideEffects, err := mergeTrailIntoTarget(mergeCtx)
if err != nil {
return err
}
@@ -233,11 +235,8 @@ func Merge(app core.App, client meilisearch.ServiceManager, actor *core.Record,
if err != nil {
return err
}
logAuthor, err := app.FindRecordById("activitypub_actors", record.GetString("author"))
if err != nil {
return err
}
if err := federation.CreateSummitLogActivity(app, logAuthor, record, pub.CreateType); err != nil {
if err := federation.CreateSummitLogActivity(app, ctx, record, pub.CreateType); err != nil {
return err
}
}
@@ -247,7 +246,7 @@ func Merge(app core.App, client meilisearch.ServiceManager, actor *core.Record,
if err != nil {
return err
}
if err := federation.CreateCommentActivity(app, actor, record, pub.CreateType); err != nil {
if err := federation.CreateCommentActivity(app, ctx, record, pub.CreateType); err != nil {
return err
}
}

View File

@@ -19,6 +19,7 @@ import (
"time"
pub "github.com/go-ap/activitypub"
"github.com/go-fed/httpsig"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/tools/filesystem"
"github.com/pocketbase/pocketbase/tools/security"
@@ -110,53 +111,6 @@ func generateKeyPair() (*rsa.PrivateKey, *rsa.PublicKey, error) {
return priv, pub, nil
}
func SyncOutbox(app core.App, actor *core.Record) error {
return fetchOutboxPage(app, actor, actor.GetString("outbox")+"?page=1")
}
func fetchOutboxPage(app core.App, actor *core.Record, pageURL string) error {
client := &http.Client{}
req, err := http.NewRequest(http.MethodGet, pageURL, nil)
if err != nil {
return err
}
req.Header.Add("Accept", `application/ld+json; profile="https://www.w3.org/ns/activitystreams"`)
resp, err := client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return err
}
var page pub.OrderedCollectionPage
err = json.Unmarshal(body, &page)
if err != nil {
return err
}
for _, item := range page.OrderedItems {
activity, err := pub.ToActivity(item)
if err != nil {
return err
}
if activity.Type != pub.CreateType {
continue
}
}
if page.Next != nil {
return fetchOutboxPage(app, actor, page.Next.GetID().String())
}
return nil
}
func TrailFromActivity(activity pub.Activity, app core.App, actor *core.Record) (*core.Record, error) {
t, err := pub.ToObject(activity.Object)
if err != nil {
@@ -191,7 +145,13 @@ func TrailFromActivity(activity pub.Activity, app core.App, actor *core.Record)
}
} else {
// this trail exists already
// nothing more to do
// ensure that it is fully synced to catch waypoint/summit log updates
record.Set("needs_full_sync", true)
err = app.Save(record)
if err != nil {
return nil, err
}
return record, nil
}
@@ -264,6 +224,7 @@ func TrailFromActivity(activity pub.Activity, app core.App, actor *core.Record)
record.Set("public", true)
record.Set("iri", t.ID.String())
record.Set("author", actor.Id)
record.Set("needs_full_sync", true)
categoryRecord, err := app.FindFirstRecordByData("categories", "name", category)
if err == nil {
@@ -473,7 +434,9 @@ func ListFromActivity(activity pub.Activity, app core.App, actor *core.Record) (
}
} else {
// this list exists already
// nothing more to do
// ensure that it is fully synced to catch trail updates
record.Set("needs_full_sync", true)
return record, nil
}
@@ -483,6 +446,7 @@ func ListFromActivity(activity pub.Activity, app core.App, actor *core.Record) (
record.Set("public", true)
record.Set("iri", iri)
record.Set("author", actor.Id)
record.Set("needs_full_sync", true)
if l.Attachment != nil {
@@ -601,7 +565,7 @@ func ObjectFromComment(app core.App, comment *core.Record, mentions *pub.ItemCol
func TrailObjectFromIRI(iri string) (*pub.Object, error) {
fetchURL := strings.Replace(iri, "api/v1/trail", "api/v1/activitypub/trail", 1)
client := &http.Client{}
client := SafeHTTPClient()
req, err := http.NewRequest(http.MethodGet, fetchURL, nil)
if err != nil {
@@ -627,3 +591,68 @@ func TrailObjectFromIRI(iri string) (*pub.Object, error) {
return &object, nil
}
func VerifySignature(app core.App, req *http.Request, publicKeyPem string) (bool, error) {
origin := os.Getenv("ORIGIN")
if origin == "" {
return false, fmt.Errorf("ORIGIN not set")
}
block, _ := pem.Decode([]byte(publicKeyPem))
if block == nil || block.Type != "PUBLIC KEY" {
return false, fmt.Errorf("could not decode publicKeyPem to PUBLIC KEY pem block type")
}
req.URL = &url.URL{
Path: req.Header.Get("X-Forwarded-Path"),
}
url, err := url.Parse(origin)
if err != nil {
return false, err
}
req.Header.Set("Host", url.Host)
req.Host = url.Host
app.Logger().Info(req.Header.Get("signature"))
publicKey, err := x509.ParsePKIXPublicKey(block.Bytes)
if err != nil {
return false, err
}
v, err := httpsig.NewVerifier(req)
if err != nil {
return false, err
}
err = v.Verify(publicKey, httpsig.RSA_SHA256)
if err != nil {
return false, err
}
return true, nil
}
func SplitHandle(handle string) (string, string) {
cleaned := strings.TrimPrefix(handle, "@")
cleaned = strings.TrimSpace(cleaned)
if !strings.Contains(cleaned, "@") {
return cleaned, ""
}
parts := strings.SplitN(cleaned, "@", 2)
user := parts[0]
domain := parts[1]
return user, domain
}
func ItemID(item pub.Item) string {
if item == nil || item.GetID() == "" {
return ""
}
return item.GetID().String()
}

190
db/util/network.go Normal file
View File

@@ -0,0 +1,190 @@
package util
import (
"context"
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"net"
"net/http"
"sync"
"time"
"github.com/pocketbase/pocketbase/core"
)
var ErrRateLimited = fmt.Errorf("rate limit exceeded for origin")
type RateLimiter struct {
mu sync.RWMutex
requests map[string][]time.Time
maxReqs int
window time.Duration
key []byte
}
func NewRateLimiter(maxReqs int, window time.Duration) *RateLimiter {
rl := &RateLimiter{
requests: make(map[string][]time.Time),
maxReqs: maxReqs,
window: window,
key: make([]byte, 32),
}
rand.Read(rl.key)
// Background worker: Cleans up memory and rotates keys
go rl.maintenanceWorker()
return rl
}
func (rl *RateLimiter) maintenanceWorker() {
ticker := time.NewTicker(rl.window * 2)
for range ticker.C {
rl.mu.Lock()
newKey := make([]byte, 32)
rand.Read(newKey)
rl.key = newKey
rl.requests = make(map[string][]time.Time)
rl.mu.Unlock()
}
}
func (rl *RateLimiter) CheckRateLimit(identifier string, host string) error {
rl.mu.Lock()
defer rl.mu.Unlock()
h := hmac.New(sha256.New, rl.key)
h.Write([]byte(identifier + ":" + host))
key := hex.EncodeToString(h.Sum(nil))
now := time.Now()
threshold := now.Add(-rl.window)
timestamps := rl.requests[key]
w := 0
for _, t := range timestamps {
if t.After(threshold) {
timestamps[w] = t
w++
}
}
timestamps = timestamps[:w]
if len(timestamps) >= rl.maxReqs {
rl.requests[key] = timestamps
return ErrRateLimited
}
rl.requests[key] = append(timestamps, now)
return nil
}
var ActivityPubRateLimiter = NewRateLimiter(30, time.Minute)
type safeTransport struct {
transport http.RoundTripper
}
func (t *safeTransport) RoundTrip(req *http.Request) (*http.Response, error) {
host := req.URL.Hostname()
if host == "" {
return nil, fmt.Errorf("invalid host in request")
}
ips, err := net.LookupIP(host)
if err != nil {
return nil, fmt.Errorf("failed to resolve host: %w", err)
}
for _, ip := range ips {
if isPrivateOrReservedIP(ip) {
return nil, fmt.Errorf("request to private/reserved IP address blocked: %s", ip)
}
}
return t.transport.RoundTrip(req)
}
func isPrivateOrReservedIP(ip net.IP) bool {
if ip.IsLoopback() {
return true
}
if ip.IsPrivate() {
return true
}
if ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() {
return true
}
if ip.IsMulticast() {
return true
}
if ip.IsUnspecified() {
return true
}
return false
}
func SafeHTTPClient() *http.Client {
dialer := &net.Dialer{Timeout: 30 * time.Second}
return &http.Client{
Transport: &http.Transport{
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
host, port, _ := net.SplitHostPort(addr)
identifier, _ := ctx.Value("actor").(string)
if identifier == "" {
identifier = "system"
}
if err := ActivityPubRateLimiter.CheckRateLimit(identifier, host); err != nil {
return nil, err
}
ips, err := net.DefaultResolver.LookupIP(ctx, "ip", host)
if err != nil || len(ips) == 0 {
return nil, fmt.Errorf("failed to resolve: %w", err)
}
for _, ip := range ips {
if isPrivateOrReservedIP(ip) {
return nil, fmt.Errorf("SSRF blocked: %s", ip)
}
}
// Standard practice: Dial the first resolved IP to prevent TOCTOU/Rebinding
return dialer.DialContext(ctx, network, net.JoinHostPort(ips[0].String(), port))
},
},
}
}
func GetSafeActorContext(r *http.Request, userActor *core.Record) (context.Context, error) {
var identifier string
if userActor != nil {
identifier = "actor:" + userActor.Id
} else if r != nil {
ip, _, _ := net.SplitHostPort(r.RemoteAddr)
identifier = "anon:" + ip
} else {
return nil, errors.New("request or actor must be defined")
}
parentCtx := context.Background()
if r != nil {
parentCtx = r.Context()
}
return context.WithValue(parentCtx, "actor", identifier), nil
}

43
db/util/sanitize.go Normal file
View File

@@ -0,0 +1,43 @@
package util
import (
"github.com/microcosm-cc/bluemonday"
"github.com/pocketbase/pocketbase/core"
)
func SanitizeHTML() func(e *core.RecordRequestEvent) error {
return func(e *core.RecordRequestEvent) error {
fieldsToSanitize := map[string][]string{
"lists": {"description"},
"settings": {"bio"},
"summit_logs": {"text"},
"trails": {"description"},
"comments": {"text"},
"waypoints": {"description"},
}
collection := e.Collection.Name
fields, ok := fieldsToSanitize[collection]
if !ok {
return e.Next()
}
p := bluemonday.NewPolicy()
p.AllowStandardAttributes()
p.AllowStandardURLs()
p.AllowLists()
p.AllowElements("br", "div", "hr", "p", "span", "wbr")
p.AllowElements("b", "strong", "em", "u", "blockquote", "a")
p.AllowAttrs("href").OnElements("a")
p.AllowAttrs("target").OnElements("a")
p.AllowAttrs("class").OnElements("a")
for _, field := range fields {
if val, ok := e.Record.Get(field).(string); ok {
sanitizedValue := p.Sanitize(val)
e.Record.Set(field, sanitizedValue)
}
}
return e.Next()
}
}