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,36 +229,37 @@ 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 {
return nil, err
}
if private {
return dbActor, ErrProfilePrivate
@@ -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,13 +16,12 @@ 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 {
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)
userActor, err := e.App.FindFirstRecordByData("activitypub_actors", "user", e.Auth.Id)
if err != nil {
return apis.NewBadRequestError("trail_merge_actor_not_found", err)
}
@@ -33,7 +32,7 @@ func registerTrailMergeRoutes(se *core.ServeEvent, client meilisearch.ServiceMan
}
if request.Mode == trailmerge.SuggestModeMaintenance {
response, err := trailmerge.SuggestGroups(e.App, actor.Id, request)
response, err := trailmerge.SuggestGroups(e.App, userActor.Id, request)
if err != nil {
return apis.NewBadRequestError(err.Error(), err)
}
@@ -41,20 +40,21 @@ func registerTrailMergeRoutes(se *core.ServeEvent, client meilisearch.ServiceMan
return e.JSON(http.StatusOK, response)
}
response, err := trailmerge.Suggest(e.App, actor.Id, request)
response, err := trailmerge.Suggest(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 {
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()
}
}

2
web/package-lock.json generated
View File

@@ -54,7 +54,7 @@
"pocketbase": "^0.26.8",
"qrcode": "^1.4.4",
"svelte-i18n": "^4.0.0",
"tailwindcss": "^4.2.3",
"tailwindcss": "^4.2.4",
"three": "^0.183.1",
"vitest": "^4.1.4",
"zod": "^3.24.1"

View File

@@ -43,7 +43,7 @@
error = "",
placeholder = "",
extraClasses = "",
searchListPosition = "absolute"
searchListPosition = "absolute",
}: Props = $props();
const fontSizes: SelectItem[] = [
@@ -87,7 +87,9 @@
return [
"a",
mergeAttributes(
{ href: `/profile/@${options.HTMLAttributes["data-label"]}` },
{
href: `/profile/@${options.HTMLAttributes["data-label"]}`,
},
options.HTMLAttributes,
),
options.renderText?.({
@@ -142,7 +144,8 @@
return;
}
searchListElement.style.position = searchListPosition;
searchListElement.style.position =
searchListPosition;
searchListElement.style.top = `${box.bottom + window.scrollY + 4}px`;
searchListElement.style.left = `${box.left + window.scrollX}px`;
searchListElement.style.zIndex = "1001";
@@ -179,12 +182,12 @@
onKeyDown(props) {
if (props.event.key === "Escape") {
this.onExit?.({} as unknown as any)
this.onExit?.({} as unknown as any);
return true;
}
return false
return false;
},
onExit() {
unmount(component);

View File

@@ -547,16 +547,19 @@
clusterPopup.on("close", () => {
unHighlightCluster(false);
});
map.on("mousemove", unHighlightClusterDistanceNotifier)
map.on("mousemove", unHighlightClusterDistanceNotifier);
}
function unHighlightClusterDistanceNotifier(e: M.MapMouseEvent) {
if (!clusterPopup || !map) {
return
return;
}
if (map.project(clusterPopup.getLngLat()).dist(map.project(e.lngLat)) > 60) {
if (
map.project(clusterPopup.getLngLat()).dist(map.project(e.lngLat)) >
60
) {
clusterPopup.remove();
map.off("mousemove", unHighlightClusterDistanceNotifier)
map.off("mousemove", unHighlightClusterDistanceNotifier);
}
}

View File

@@ -129,9 +129,8 @@
async function fetchComments() {
commentsLoading = true;
const trailId = trail.iri ? trail.iri : trail.id!;
try {
await comments_index(trailId, handle);
await comments_index(trail.id!);
} catch (e) {
show_toast({
type: "error",

View File

@@ -7,8 +7,8 @@ export interface Actor {
domain?: string;
summary?: string;
published?: string;
followerCount?: number,
followingCount?: number,
follower_count?: number,
following_count?: number,
iri: string;
inbox: string;
outbox?: string;

View File

@@ -22,6 +22,7 @@ export class List {
}
created?: string;
updated?: string;
author: string;
constructor(name: string, trails: Trail[], params?: { description?: string, public?: boolean, avatar?: string, author?: string }) {

View File

@@ -1,25 +1,17 @@
import { Comment } from "$lib/models/comment";
import type { Trail } from "$lib/models/trail";
import { APIError } from "$lib/util/api_util";
import { type ListResult } from "pocketbase";
import { get, writable, type Writable } from "svelte/store";
import { currentUser } from "./user_store";
import { isURL } from "$lib/util/file_util";
export const comments: Writable<Comment[]> = writable([])
export async function comments_index(trailId: string, handle?: string) {
export async function comments_index(trailId: string) {
let filter: string;
if (isURL(trailId)) {
filter = `trail="${trailId}"||trail.iri="${trailId}"||trail="${trailId.substring(trailId.length - 15)}"`
} else {
filter = `trail="${trailId}"`
}
let r = await fetch(`/api/v1/comment?` + new URLSearchParams({
filter,
let r = await fetch(`/api/v1/trail/${trailId}/comment?` + new URLSearchParams({
expand: "author",
sort: "-created",
...(handle ? { handle } : {})
}), {
method: 'GET',
})

View File

@@ -5,11 +5,9 @@ import { type ListResult } from "pocketbase";
let follows: Actor[] = [];
export async function follows_index(data: { username: string, type: "followers" | "following" }, page: number = 1, perPage: number = 10, f: (url: RequestInfo | URL, config?: RequestInit) => Promise<Response> = fetch) {
export async function follows_index(page: number = 1, perPage: number = 10, f: (url: RequestInfo | URL, config?: RequestInit) => Promise<Response> = fetch) {
const r = await f(`/api/v1/follow?` + new URLSearchParams({
handle: data.username,
type: data.type,
page: page.toString(),
perPage: perPage.toString(),
}), {

View File

@@ -9,9 +9,10 @@ import { searchResultToLists } from "./list_store";
import type { ListSearchResult } from "./search_store";
import { buildFilterText } from "./summit_log_store";
import { searchResultToTrailList } from "./trail_store";
import type { Actor } from "$lib/models/activitypub/actor";
let feed: FeedItem[] = []
let follows: Actor[] = [];
export async function profile_show(handle: string, f: (url: RequestInfo | URL, config?: RequestInit) => Promise<Response> = fetch) {
let r = await f('/api/v1/profile/' + handle, {
@@ -133,3 +134,25 @@ export async function profile_stats_index(handle: string, filter: SummitLogFilte
return result;
}
export async function profile_follows_index(handle: string, type: "followers" | "following", page: number, f: (url: RequestInfo | URL, config?: RequestInit) => Promise<Response> = fetch) {
const r = await f(`/api/v1/profile/${handle}/follows?` + new URLSearchParams({
type,
page: page.toString(),
}), {
method: 'GET',
})
if (!r.ok) {
const response = await r.json();
throw new APIError(r.status, response.message, response.detail)
}
const fetchedFollows: ListResult<Actor> = await r.json();
const result = page > 1 ? [...follows, ...fetchedFollows.items] : fetchedFollows.items
follows = result;
return { ...fetchedFollows, items: result };
}

View File

@@ -12,7 +12,7 @@ export async function waypoints_create(waypoint: Waypoint, f: (url: RequestInfo
throw Error("Unauthenticated")
}
waypoint.author = user.id
waypoint.author = user.actor
let r = await f('/api/v1/waypoint', {
method: 'PUT',

View File

@@ -2,7 +2,6 @@ import { CommentCreateSchema } from '$lib/models/api/comment_schema';
import type { Comment } from '$lib/models/comment';
import { Collection, create, handleError, list } from '$lib/util/api_util';
import { json, type RequestEvent } from '@sveltejs/kit';
import { type ListResult } from "pocketbase";
/**
* @swagger
@@ -32,11 +31,6 @@ import { type ListResult } from "pocketbase";
* name: expand
* schema:
* type: string
* - in: query
* name: handle
* schema:
* type: string
* description: Federated query parameter
* responses:
* 200:
* description: List of comments
@@ -51,70 +45,8 @@ import { type ListResult } from "pocketbase";
*/
export async function GET(event: RequestEvent) {
try {
if (!event.url.searchParams.has("handle")) {
const comments = await list<Comment>(event, Collection.comments);
return json(comments)
} else {
const { actor, error } = await event.locals.pb.send(`/activitypub/actor?resource=acct:${event.url.searchParams.get("handle")}`, { method: "GET", fetch: event.fetch, });
event.url.searchParams.delete("handle")
const localComments = await list<Comment>(event, Collection.comments);
if (actor.isLocal) {
return json(localComments)
}
const deduplicationMap: Record<string, Comment> = {}
localComments.items.forEach(c => {
if (c.iri) {
const id = c.iri.substring(c.iri.length - 15)
deduplicationMap[id] = c
} else if (c.id) {
deduplicationMap[c.id] = c
}
})
const origin = new URL(actor.iri).origin
const url = `${origin}/api/v1/comment`
const response = await event.fetch(url + '?' + event.url.searchParams, { method: 'GET' })
if (!response.ok) {
const errorResponse = await response.json()
console.error(errorResponse)
}
const remoteComments: ListResult<Comment> = await response.json()
remoteComments.items = remoteComments.items.filter(c => {
const iriId = c.iri?.substring(c.iri.length - 15) ?? ""
if (deduplicationMap[c.id!] != undefined) {
deduplicationMap[c.id!] = { ...c, author: deduplicationMap[c.id!].author }
return false
} else if (deduplicationMap[iriId] != undefined) {
deduplicationMap[iriId] = { ...c, author: deduplicationMap[iriId].author }
return false
}
return true
})
remoteComments.items.forEach(c => {
if (!c.iri?.length) {
c.iri = `${url}/${c.id}`
}
})
const allCommentItems = <ListResult<Comment>>{
items: localComments.items.concat(remoteComments.items),
page: localComments.page,
perPage: localComments.perPage,
totalItems: localComments.items.length + remoteComments.items.length,
totalPages: Math.ceil((localComments.items.length + remoteComments.items.length) / localComments.perPage)
}
allCommentItems.items = allCommentItems.items.sort((a, b) => {
return new Date(b.created ?? 0).getTime() - new Date(a.created ?? 0).getTime()
})
return json(allCommentItems)
}
} catch (e) {
return handleError(e)
}

View File

@@ -1,18 +1,14 @@
import type { Actor } from '$lib/models/activitypub/actor';
import { FollowCreateSchema } from '$lib/models/api/follow_schema';
import type { Follow } from '$lib/models/follow';
import { getActorResponseForHandle } from '$lib/util/activitypub_server_util';
import { APIError, Collection, handleError, list } from '$lib/util/api_util';
import { Collection, handleError, list } from '$lib/util/api_util';
import { json, type RequestEvent } from '@sveltejs/kit';
import type { APOrderedCollectionPage } from 'activitypub-types';
import { ClientResponseError, type ListResult } from "pocketbase";
/**
* @swagger
* /api/v1/follow:
* get:
* summary: List follows
* description: Retrieves follows or ActivityPub follower/following collections. Supports federated queries via handle parameter
* tags:
* - Follows
* parameters:
@@ -36,18 +32,9 @@ import { ClientResponseError, type ListResult } from "pocketbase";
* name: expand
* schema:
* type: string
* - in: query
* name: handle
* schema:
* type: string
* - in: query
* name: type
* schema:
* type: string
* enum: [followers, following]
* responses:
* 200:
* description: List of follows or ActivityPub collection
* description: List of follows
* content:
* application/json:
* schema:
@@ -59,60 +46,8 @@ import { ClientResponseError, type ListResult } from "pocketbase";
*/
export async function GET(event: RequestEvent) {
try {
if (!event.url.searchParams.has("handle")) {
const follows = await list<Follow>(event, Collection.follows);
return json(follows)
} else {
const handle = event.url.searchParams.get("handle");
const type = event.url.searchParams.get("type");
if (!handle || (type !== "followers" && type !== "following")) {
throw new APIError(400, "invalid params")
}
const { actor } = await getActorResponseForHandle(event, handle);
const page = event.url.searchParams.get("page") ?? "1"
let followers: APOrderedCollectionPage;
// fetch followers locally to not run into auth issues with private profiles
if (actor.id === event.locals.user?.actor) {
const r = await event.fetch(actor[type as "followers" | "following"]! + '?' + new URLSearchParams({ page }))
if (!r.ok) {
const errorResponse = await r.json()
throw new ClientResponseError({ status: r.status, response: errorResponse });
}
followers = await r.json()
} else {
followers = await event.locals.pb.send(`/activitypub/actor/${actor.id}/${type}?page=${page}`, { method: "GET", fetch: event.fetch, });
}
const followerActors: Actor[] = []
for (const f of followers.orderedItems ?? []) {
try {
const { actor }: { actor: Actor } = await event.locals.pb.send(`/activitypub/actor?iri=${f}`, { method: "GET", fetch: event.fetch, });
followerActors.push(actor)
} catch (e) {
continue
}
}
const result: ListResult<Actor> = {
items: followerActors,
page: parseInt(page),
perPage: 10,
totalItems: actor.followerCount ?? 0,
totalPages: Math.ceil((actor.followerCount ?? 0) / 10)
}
return json(result)
}
} catch (e) {
return handleError(e)
}

View File

@@ -1,16 +1,14 @@
import { ListUpdateSchema } from "$lib/models/api/list_schema";
import type { List } from "$lib/models/list";
import { Collection, handleError, remove, show, update } from "$lib/util/api_util";
import { objectToFormData } from "$lib/util/file_util";
import { Collection, handleError, remove, update } from "$lib/util/api_util";
import { json, type RequestEvent } from "@sveltejs/kit";
import { ClientResponseError } from "pocketbase";
/**
* @swagger
* /api/v1/list/{id}:
* get:
* summary: Get list
* description: Retrieves a list by ID. Supports federated queries via handle parameter, fetching from remote instances and remapping file URLs
* description: Retrieves a list by ID
* tags:
* - Lists
* parameters:
@@ -23,13 +21,9 @@ import { ClientResponseError } from "pocketbase";
* name: expand
* schema:
* type: string
* - in: query
* name: handle
* schema:
* type: string
* responses:
* 200:
* description: List with optional federated data
* description: List
* 404:
* description: Not Found
* 500:
@@ -80,77 +74,17 @@ import { ClientResponseError } from "pocketbase";
* description: Internal Server Error
*/
export async function GET(event: RequestEvent) {
const { url, params } = event;
try {
if (!event.url.searchParams.has("handle")) {
const l = await show<List>(event, Collection.lists)
return json(l)
} else {
const {actor, error} = await event.locals.pb.send(`/activitypub/actor?resource=acct:${event.url.searchParams.get("handle")}`, { method: "GET", fetch: event.fetch, });
event.url.searchParams.delete("handle")
const origin = new URL(actor.iri).origin
const url = `${origin}/api/v1/list/${event.params.id}`
let dbList: List | undefined;
try {
dbList = await event.locals.pb.collection("lists").getFirstListItem(`iri='${url}'||id='${event.params.id}'`, {
...Object.fromEntries(event.url.searchParams)
})
} catch (e) {
if (!(e instanceof ClientResponseError) || e.status != 404) {
throw e
}
}
if (actor.isLocal) {
return json(dbList)
} else {
const response = await event.fetch((dbList?.iri ?? url) + '?' + event.url.searchParams, { method: 'GET' })
if (!response.ok) {
const errorResponse = await response.json()
console.error(errorResponse)
const cachedList = await event.locals.pb.collection("lists").getOne(`${event.params.id}`)
return json(cachedList)
}
const l: List = await response.json()
l.avatar = l.avatar ? `${origin}/api/v1/files/lists/${l.id}/${l.avatar}` : undefined
l.author = actor.id!
l.expand!.author = actor
l.iri = dbList?.iri ?? url;
l.expand?.trails?.forEach(t => {
t.gpx = t.gpx ? `${origin}/api/v1/files/trails/${t.id}/${t.gpx}` : undefined
t.photos = t.photos.map(p =>
`${origin}/api/v1/files/trails/${t.id}/${p}`
)
t.iri = t.iri || `${origin}/api/v1/trails/${t.id}`;
let list: List = await event.locals.pb.send(`/remote/list/${params.id}?` + url.searchParams, {
method: "GET",
fetch: event.fetch,
})
const formData = objectToFormData({ ...l, id: dbList?.id, expand: undefined, trails: [] })
if (l.avatar) {
const avatarURL = l.avatar
let response = await event.fetch(avatarURL, { method: "GET" })
const avatar = await response.blob()
formData.append("avatar", avatar)
}
if (dbList !== undefined) {
dbList = await event.locals.pb.collection("lists").update(dbList.id!, formData)
} else {
dbList = await event.locals.pb.collection("lists").create(formData)
}
l.id = dbList!.id
return json(l)
}
}
return json(list)
} catch (e: any) {
return handleError(e)
return handleError(e);
}
}

View File

@@ -46,8 +46,8 @@ export async function GET(event: RequestEvent) {
createdAt: actor.published ?? "",
bio: actor.summary ?? "",
uri: actor.iri,
followers: actor.followerCount ?? 0,
following: actor.followingCount ?? 0,
followers: actor.follower_count ?? 0,
following: actor.following_count ?? 0,
icon: actor.icon ?? "",
error: actorError ?? undefined
}

View File

@@ -0,0 +1,56 @@
import type { Comment } from '$lib/models/comment';
import { handleError } from '$lib/util/api_util';
import { json, type RequestEvent } from '@sveltejs/kit';
/**
* @swagger
* /api/v1/profile/{handle}/follows:
* get:
* summary: Get profile follows
* tags:
* - Profiles
* parameters:
* - in: path
* name: handle
* required: true
* schema:
* type: string
* - in: query
* name: page
* schema:
* type: integer
* - in: query
* name: perPage
* schema:
* type: integer
* - in: query
* name: sort
* schema:
* type: string
* - in: query
* name: filter
* schema:
* type: string
* responses:
* 200:
* description: List of follows for the profile
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/ListResult'
* 400:
* description: Bad Request
* 500:
* description: Internal Server Error
*/
export async function GET(event: RequestEvent) {
try {
let comments: Comment = await event.locals.pb.send(`/remote/profile/${event.params.handle}/follows?` + event.url.searchParams, {
method: "GET",
fetch: event.fetch,
})
return json(comments)
} catch (e) {
return handleError(e)
}
}

View File

@@ -1,4 +1,5 @@
import type { Actor } from '$lib/models/activitypub/actor';
import { getActorResponseForHandle } from '$lib/util/activitypub_server_util';
import { splitUsername } from '$lib/util/activitypub_util';
import { handleError } from '$lib/util/api_util';
import { error, json, type RequestEvent } from '@sveltejs/kit';
@@ -53,10 +54,10 @@ export async function GET(event: RequestEvent) {
filter += `&& id != "${event.locals.pb.authStore.record.actor}"`
}
const response = await event.locals.pb.collection("activitypub_actors").getList(1, 3, { filter: filter })
const response = await event.locals.pb.collection("activitypub_actors").getList<Actor>(1, 3, { filter: filter })
try {
const { actor, error } = await event.locals.pb.send(`/activitypub/actor?resource=acct:${q}&follows=false`, { method: "GET", fetch: event.fetch, });
const { actor } = await getActorResponseForHandle(event, q!);
if (!response.items.find(i => i.iri == actor.iri)) {
response.items.push(actor)

View File

@@ -2,14 +2,12 @@ import { SummitLogCreateSchema } from '$lib/models/api/summit_log_schema';
import type { SummitLog } from '$lib/models/summit_log';
import { Collection, create, handleError, list } from '$lib/util/api_util';
import { json, type RequestEvent } from '@sveltejs/kit';
import { type ListResult } from "pocketbase";
/**
* @swagger
* /api/v1/summit-log:
* get:
* summary: List summit logs
* description: Retrieves a paginated list of summit logs with deduplication of federated data
* tags:
* - Summit Logs
* parameters:
@@ -33,13 +31,9 @@ import { type ListResult } from "pocketbase";
* name: expand
* schema:
* type: string
* - in: query
* name: handle
* schema:
* type: string
* responses:
* 200:
* description: ListResult<SummitLog> with local/remote items deduplicated
* description: List of summit logs
* 400:
* description: Bad Request
* 500:
@@ -47,84 +41,10 @@ import { type ListResult } from "pocketbase";
*/
export async function GET(event: RequestEvent) {
try {
if (!event.url.searchParams.has("handle")) {
const summitLogs = await list<SummitLog>(event, Collection.summit_logs);
removeTimeFromDates(summitLogs.items)
return json(summitLogs)
} else {
const { actor, error } = await event.locals.pb.send(`/activitypub/actor?resource=acct:${event.url.searchParams.get("handle")}`, { method: "GET", fetch: event.fetch, });
event.url.searchParams.delete("handle")
const localSummitLogs = await list<SummitLog>(event, Collection.summit_logs);
if (actor.isLocal) {
removeTimeFromDates(localSummitLogs.items)
return json(localSummitLogs)
}
const deduplicationMap: Record<string, SummitLog> = {}
localSummitLogs.items.forEach(l => {
if (l.iri) {
const id = l.iri.substring(l.iri.length - 15)
deduplicationMap[id] = l
} else if (l.id) {
deduplicationMap[l.id] = l
}
l.date = l.date.substring(0, 10);
})
const origin = new URL(actor.iri).origin
const url = `${origin}/api/v1/summit-log`
const response = await event.fetch(url + '?' + event.url.searchParams, { method: 'GET' })
if (!response.ok) {
const errorResponse = await response.json()
console.error(errorResponse)
}
const remoteSummitLogs: ListResult<SummitLog> = await response.json()
remoteSummitLogs.items = remoteSummitLogs.items.filter(l => {
const iriId = l.iri?.substring(l.iri.length - 15) ?? ""
if (deduplicationMap[l.id!] != undefined) {
deduplicationMap[l.id!] = {...l, author: deduplicationMap[l.id!].author}
return false
} else if (deduplicationMap[iriId] != undefined) {
deduplicationMap[iriId] = {...l, author: deduplicationMap[iriId].author}
return false
}
return true
})
remoteSummitLogs.items.forEach(l => {
if (l.gpx) {
l.gpx = `${origin}/api/v1/files/summit_logs/${l.id}/${l.gpx}`
}
l.photos = l.photos.map(p =>
`${origin}/api/v1/files/summit_logs/${l.id}/${p}`
)
if (l.expand?.author) {
l.expand.author.isLocal = false
}
})
const allSummitLogItems = <ListResult<SummitLog>>{
items: localSummitLogs.items.concat(remoteSummitLogs.items),
page: localSummitLogs.page,
perPage: localSummitLogs.perPage,
totalItems: localSummitLogs.items.length + remoteSummitLogs.items.length,
totalPages: Math.ceil((localSummitLogs.items.length + remoteSummitLogs.items.length) / localSummitLogs.perPage)
}
allSummitLogItems.items = allSummitLogItems.items.sort((a, b) => {
return new Date(a.created ?? 0).getTime() - new Date(b.created ?? 0).getTime()
})
return json(allSummitLogItems)
}
} catch (e) {
return handleError(e)
}

View File

@@ -1,18 +1,15 @@
import { RecordOptionsSchema } from '$lib/models/api/base_schema';
import { TrailUpdateSchema } from '$lib/models/api/trail_schema';
import type { Trail } from "$lib/models/trail";
import { APIError, Collection, handleError, remove, show, update } from "$lib/util/api_util";
import { objectToFormData } from "$lib/util/file_util";
import { Collection, handleError, remove, update } from "$lib/util/api_util";
import { json, type RequestEvent } from "@sveltejs/kit";
import type PocketBase from "pocketbase";
import { ClientResponseError } from "pocketbase";
/**
* @swagger
* /api/v1/trail/{id}:
* get:
* summary: Get trail
* description: Retrieves a trail by ID. Supports federated queries via handle parameter, fetching from remote instances and remapping file URLs
* description: Retrieves a trail by ID
* tags:
* - Trails
* parameters:
@@ -26,149 +23,31 @@ import { ClientResponseError } from "pocketbase";
* schema:
* type: string
* - in: query
* name: handle
* schema:
* type: string
* - in: query
* name: share
* schema:
* type: string
* responses:
* 200:
* description: Trail with optional federated data
* description: Trail
* 404:
* description: Not Found
* 500:
* description: Internal Server Error
*/
export async function GET(event: RequestEvent) {
try {
// try to get the trail simply via the
let t: Trail;
if (!event.url.searchParams.has("handle")) {
t = await show<Trail>(event, Collection.trails)
} else {
let { actor, error } = await event.locals.pb.send(`/activitypub/actor?resource=acct:${event.url.searchParams.get("handle")}`, { method: "GET", fetch: event.fetch, });
event.url.searchParams.delete("handle")
const safeSearchParams = RecordOptionsSchema.parse(Object.fromEntries(event.url.searchParams));
if (event.url.searchParams.has("share")) {
safeSearchParams.query = { share: event.url.searchParams.get("share")! }
}
let origin = new URL(actor.iri).origin
let iri = `${origin}/api/v1/trail/${event.params.id}`
const { url, params } = event;
try {
t = await event.locals.pb.collection("trails").getFirstListItem(`iri='${iri}'||id='${event.params.id}'`, {
...safeSearchParams
})
} catch (e) {
if (!(e instanceof ClientResponseError) || e.status != 404) {
throw e
}
t = {
iri: iri,
author: actor.id,
like_count: 0
} as Trail
}
}
if (t.iri) {
const origin = new URL(t.iri).origin
const actor = await event.locals.pb.collection("activitypub_actors").getOne(t.author)
const localTrailId = t.id;
const localTrailIRI = t.iri;
const localLikeCount = t.like_count;
const localLikes = t.expand?.trail_like_via_trail;
const response = await event.fetch((t.iri) + '?' + event.url.searchParams, { method: 'GET' })
if (!response.ok) {
const errorResponse = await response.json()
console.error(errorResponse)
if (t.id) {
return json(t)
} else {
throw new ClientResponseError({ status: response.status, response: errorResponse })
}
}
t = await response.json()
// this came directly from the database of the remote instance
// we need to adjust some urls to get photos, gpx etc.
if (!t.iri) {
if (t.gpx) {
t.gpx = `${origin}/api/v1/files/trails/${t.id}/${t.gpx}`
}
t.photos = t.photos.map(p =>
`${origin}/api/v1/files/trails/${t.id}/${p}`
)
t.expand?.summit_logs_via_trail?.forEach(l => {
if (l.gpx) {
l.gpx = `${origin}/api/v1/files/summit_logs/${l.id}/${l.gpx}`
}
l.photos = l.photos.map(p =>
`${origin}/api/v1/files/summit_logs/${l.id}/${p}`
)
if (l.expand?.author) {
l.expand.author.isLocal = false
}
})
t.expand?.waypoints_via_trail?.forEach(w => {
w.photos = w.photos.map(p =>
`${origin}/api/v1/files/waypoints/${w.id}/${p}`
)
let trail: Trail = await event.locals.pb.send(`/remote/trail/${params.id}?` + url.searchParams, {
method: "GET",
fetch: event.fetch,
})
t.author = actor.id!
t.expand!.author = actor as any
t.id = localTrailId
t.iri = localTrailIRI
t.like_count = localLikeCount
t.expand!.trail_like_via_trail = localLikes;
}
let categoryId: string | undefined;
try {
const category = await event.locals.pb.collection("categories").getFirstListItem(`name='${t.expand?.category?.name}'`)
categoryId = category.id;
t.expand!.category = category as any
} catch (e) { }
const formData = objectToFormData({ ...t, id: t.id, gpx: undefined, expand: undefined, photos: [], waypoints: [], tags: [], category: categoryId })
if (t.photos.length) {
const photoURL = t.photos[t.thumbnail ?? 0]
let response = await event.fetch(photoURL, { method: "GET" })
const photo = await response.blob()
formData.append("photos", photo)
}
if (t.gpx) {
const gpxURL = t.gpx
const response = await event.fetch(gpxURL, { method: "GET" })
const gpx = await response.blob()
formData.append("gpx", gpx)
}
if (t.id) {
await event.locals.pb.collection("trails").update(t.id, formData)
} else {
const createdTrail = await event.locals.pb.collection("trails").create(formData)
t.id = createdTrail.id;
}
}
// remove time from dates
await enrichRecord(event.locals.pb, t);
// sort waypoints by distance
t.expand?.waypoints_via_trail?.sort((a, b) => (a.distance_from_start ?? 0) - (b.distance_from_start ?? 0))
return json(t)
await enrichRecord(event.locals.pb, trail);
trail.expand?.waypoints_via_trail?.sort((a, b) => (a.distance_from_start ?? 0) - (b.distance_from_start ?? 0))
return json(trail)
} catch (e: any) {
return handleError(e)
return handleError(e);
}
}

View File

@@ -0,0 +1,56 @@
import type { Comment } from '$lib/models/comment';
import { handleError } from '$lib/util/api_util';
import { json, type RequestEvent } from '@sveltejs/kit';
/**
* @swagger
* /api/v1/trail/{id}/comment:
* get:
* summary: Get trail comments
* tags:
* - Trails
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: string
* - in: query
* name: page
* schema:
* type: integer
* - in: query
* name: perPage
* schema:
* type: integer
* - in: query
* name: sort
* schema:
* type: string
* - in: query
* name: filter
* schema:
* type: string
* responses:
* 200:
* description: List of comments for the trail
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/ListResult'
* 400:
* description: Bad Request
* 500:
* description: Internal Server Error
*/
export async function GET(event: RequestEvent) {
try {
let comments: Comment = await event.locals.pb.send(`/remote/trail/${event.params.id}/comments?` + event.url.searchParams, {
method: "GET",
fetch: event.fetch,
})
return json(comments)
} catch (e) {
return handleError(e)
}
}

View File

@@ -1,6 +1,6 @@
<script lang="ts">
import { page } from "$app/state";
import { follows_index } from "$lib/stores/follow_store.js";
import { profile_follows_index } from "$lib/stores/profile_store.js";
import { show_toast } from "$lib/stores/toast_store.svelte.js";
import { APIError } from "$lib/util/api_util.js";
import { untrack } from "svelte";
@@ -37,13 +37,10 @@
async function loadNextPage() {
pagination.page += 1;
try {
follows = await follows_index(
{
type: page.params.type as "followers" | "following",
username: page.params.handle!,
},
follows = await profile_follows_index(
page.params.handle!,
page.params.type as "followers" | "following",
pagination.page,
10,
fetch,
);
} catch (e) {
@@ -51,8 +48,8 @@
show_toast({
icon: "close",
text: `${e.status}: ${e.message}`,
type: "error"
})
type: "error",
});
}
}
}

View File

@@ -1,4 +1,5 @@
import { follows_index } from "$lib/stores/follow_store";
import { profile_follows_index } from "$lib/stores/profile_store";
import { APIError } from "$lib/util/api_util";
import { error, type Load } from "@sveltejs/kit";
import { ClientResponseError } from "pocketbase";
@@ -9,8 +10,12 @@ export const load: Load = async ({ params, fetch }) => {
}
try {
const follows = await follows_index({ type: params.type, username: params.handle! },
1, 10, fetch)
const follows = await profile_follows_index(
params.handle!,
params.type as "followers" | "following",
1,
fetch,
);
return { follows }
} catch (e) {