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:
@@ -1,13 +1,12 @@
|
||||
*
|
||||
!commands
|
||||
!federation
|
||||
!hooks
|
||||
!go.*
|
||||
!integrations
|
||||
!main.go
|
||||
!trail_merge_routes.go
|
||||
!migrations
|
||||
!routes
|
||||
!templates
|
||||
!trailmerge
|
||||
!waypointcluster
|
||||
!waypointcluster/**
|
||||
!services
|
||||
!util
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
package federation
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/x509"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"pocketbase/util"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -21,6 +24,7 @@ import (
|
||||
)
|
||||
|
||||
var ErrProfilePrivate = errors.New("profile is private")
|
||||
var ErrInvalidActorResponse = errors.New("invalid or incomplete actor response")
|
||||
|
||||
type WebfingerResponse struct {
|
||||
Subject string `json:"subject"`
|
||||
@@ -30,24 +34,36 @@ type WebfingerResponse struct {
|
||||
} `json:"links"`
|
||||
}
|
||||
|
||||
func SplitHandle(handle string) (string, string) {
|
||||
|
||||
cleaned := strings.TrimPrefix(handle, "@")
|
||||
cleaned = strings.TrimSpace(cleaned)
|
||||
|
||||
if !strings.Contains(cleaned, "@") {
|
||||
return cleaned, ""
|
||||
func validateActorResponse(actor *pub.Actor) error {
|
||||
if actor == nil {
|
||||
return ErrInvalidActorResponse
|
||||
}
|
||||
|
||||
parts := strings.SplitN(cleaned, "@", 2)
|
||||
user := parts[0]
|
||||
domain := parts[1]
|
||||
if actor.GetID().String() == "" {
|
||||
return fmt.Errorf("%w: missing ID", ErrInvalidActorResponse)
|
||||
}
|
||||
|
||||
return user, domain
|
||||
if actor.PreferredUsername.String() == "" && actor.Name.String() == "" {
|
||||
return fmt.Errorf("%w: missing username or name", ErrInvalidActorResponse)
|
||||
}
|
||||
|
||||
if util.ItemID(actor.Inbox) == "" {
|
||||
return fmt.Errorf("%w: missing inbox", ErrInvalidActorResponse)
|
||||
}
|
||||
|
||||
if util.ItemID(actor.Outbox) == "" {
|
||||
return fmt.Errorf("%w: missing outbox", ErrInvalidActorResponse)
|
||||
}
|
||||
|
||||
if actor.PublicKey.PublicKeyPem == "" {
|
||||
return fmt.Errorf("%w: missing public key", ErrInvalidActorResponse)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func GetActorByHandle(app core.App, actor *core.Record, handle string, includeFollows bool) (*core.Record, error) {
|
||||
username, domain := SplitHandle(handle)
|
||||
func GetActorByHandle(app core.App, ctx context.Context, handle string, includeFollows bool) (*core.Record, error) {
|
||||
username, domain := util.SplitHandle(handle)
|
||||
|
||||
filter := "preferred_username={:username}&&"
|
||||
if domain != "" {
|
||||
@@ -66,7 +82,7 @@ func GetActorByHandle(app core.App, actor *core.Record, handle string, includeFo
|
||||
|
||||
dbActor = core.NewRecord(collection)
|
||||
dbActor.Set("isLocal", false)
|
||||
iri, err := iriFromHandle(domain, username)
|
||||
iri, err := iriFromHandle(ctx, domain, username)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -76,10 +92,10 @@ func GetActorByHandle(app core.App, actor *core.Record, handle string, includeFo
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return assembleActor(actor, dbActor, app, includeFollows)
|
||||
return assembleActor(app, ctx, dbActor, includeFollows || dbActor.Id == "")
|
||||
}
|
||||
|
||||
func GetActorByIRI(app core.App, actor *core.Record, iri string, includeFollows bool) (*core.Record, error) {
|
||||
func GetActorByIRI(app core.App, ctx context.Context, iri string, includeFollows bool) (*core.Record, error) {
|
||||
var dbActor *core.Record
|
||||
dbActor, err := app.FindFirstRecordByFilter("activitypub_actors", "iri={:iri}", dbx.Params{"iri": iri})
|
||||
if err != nil && err == sql.ErrNoRows {
|
||||
@@ -96,33 +112,55 @@ func GetActorByIRI(app core.App, actor *core.Record, iri string, includeFollows
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return assembleActor(actor, dbActor, app, includeFollows)
|
||||
return assembleActor(app, ctx, dbActor, includeFollows || dbActor.Id == "")
|
||||
}
|
||||
|
||||
func iriFromHandle(domain string, username string) (string, error) {
|
||||
client := &http.Client{}
|
||||
func iriFromHandle(ctx context.Context, domain string, username string) (string, error) {
|
||||
client := util.SafeHTTPClient()
|
||||
|
||||
webfingerURL := fmt.Sprintf("https://%s/.well-known/webfinger?resource=acct:%s@%s", domain, username, domain)
|
||||
resp, err := client.Get(webfingerURL)
|
||||
if err != nil || resp.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("webfinger request failed: %v", err)
|
||||
u := &url.URL{
|
||||
Scheme: "https",
|
||||
Host: domain,
|
||||
Path: "/.well-known/webfinger",
|
||||
}
|
||||
q := u.Query()
|
||||
q.Set("resource", fmt.Sprintf("acct:%s@%s", username, domain))
|
||||
u.RawQuery = q.Encode()
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", u.String(), nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("webfinger request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("unexpected status: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
limitedReader := io.LimitReader(resp.Body, 102400)
|
||||
|
||||
var wf WebfingerResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&wf); err != nil {
|
||||
return "", err
|
||||
if err := json.NewDecoder(limitedReader).Decode(&wf); err != nil {
|
||||
return "", fmt.Errorf("failed to decode JSON: %w", err)
|
||||
}
|
||||
|
||||
for _, link := range wf.Links {
|
||||
if link.Rel == "self" {
|
||||
if _, err := url.Parse(link.Href); err != nil {
|
||||
return "", fmt.Errorf("invalid IRI in response")
|
||||
}
|
||||
return link.Href, nil
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("no iri in response")
|
||||
}
|
||||
|
||||
func assembleActor(actor *core.Record, dbActor *core.Record, app core.App, includeFollows bool) (*core.Record, error) {
|
||||
func assembleActor(app core.App, ctx context.Context, dbActor *core.Record, includeFollows bool) (*core.Record, error) {
|
||||
origin := os.Getenv("ORIGIN")
|
||||
if origin == "" {
|
||||
return nil, fmt.Errorf("ORIGIN environment variable not set")
|
||||
@@ -147,12 +185,12 @@ func assembleActor(actor *core.Record, dbActor *core.Record, app core.App, inclu
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
dbActor.Set("followerCount", followerCount)
|
||||
dbActor.Set("follower_count", followerCount)
|
||||
followingCount, err := app.CountRecords("follows", dbx.NewExp("follower={:user} AND status='accepted'", dbx.Params{"user": dbActor.Id}))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
dbActor.Set("followingCount", followingCount)
|
||||
dbActor.Set("following_count", followingCount)
|
||||
|
||||
dbActor.Set("last_fetched", time.Now())
|
||||
|
||||
@@ -165,11 +203,11 @@ func assembleActor(actor *core.Record, dbActor *core.Record, app core.App, inclu
|
||||
} else {
|
||||
|
||||
// check if value is still cached
|
||||
twoHoursAgo := time.Now().Add(-2 * time.Hour)
|
||||
if !includeFollows && dbActor.GetDateTime("last_fetched").Time().After(twoHoursAgo) {
|
||||
twoHoursAgo := time.Now().UTC().Add(-2 * time.Hour)
|
||||
if dbActor.GetDateTime("last_fetched").Time().After(twoHoursAgo) {
|
||||
return dbActor, nil
|
||||
}
|
||||
pubActor, followers, following, err := fetchRemoteActor(actor, dbActor.GetString("iri"), includeFollows)
|
||||
pubActor, followers, following, err := fetchRemoteActor(app, ctx, dbActor.GetString("iri"), includeFollows)
|
||||
if err != nil {
|
||||
if dbActor.Id != "" {
|
||||
return dbActor, err
|
||||
@@ -191,34 +229,35 @@ func assembleActor(actor *core.Record, dbActor *core.Record, app core.App, inclu
|
||||
}
|
||||
domain := strings.TrimPrefix(parsedUrl.Hostname(), "www.")
|
||||
|
||||
// this is a race condition that gets triggered when the profile is opened for the first time
|
||||
existingActor, _ := app.FindFirstRecordByData("activitypub_actors", "iri", dbActor.GetString("iri"))
|
||||
|
||||
if existingActor != nil {
|
||||
dbActor = existingActor
|
||||
}
|
||||
|
||||
dbActor.Set("domain", domain)
|
||||
dbActor.Set("followers", pubActor.Followers.GetID().String())
|
||||
dbActor.Set("inbox", pubActor.Inbox.GetID().String())
|
||||
dbActor.Set("followers", util.ItemID(pubActor.Followers))
|
||||
dbActor.Set("inbox", util.ItemID(pubActor.Inbox))
|
||||
dbActor.Set("iri", pubActor.GetID().String())
|
||||
dbActor.Set("username", pubActor.Name.String())
|
||||
dbActor.Set("preferred_username", pubActor.PreferredUsername.String())
|
||||
dbActor.Set("following", pubActor.Following.GetID().String())
|
||||
dbActor.Set("following", util.ItemID(pubActor.Following))
|
||||
dbActor.Set("summary", pubActor.Summary.String())
|
||||
dbActor.Set("outbox", pubActor.Outbox.GetID().String())
|
||||
dbActor.Set("outbox", util.ItemID(pubActor.Outbox))
|
||||
dbActor.Set("icon", icon)
|
||||
dbActor.Set("published", pubActor.Published.String())
|
||||
dbActor.Set("public_key", pubActor.PublicKey.PublicKeyPem)
|
||||
dbActor.Set("last_fetched", time.Now())
|
||||
|
||||
if includeFollows {
|
||||
dbActor.Set("followerCount", int(followers.TotalItems))
|
||||
dbActor.Set("followingCount", int(following.TotalItems))
|
||||
dbActor.Set("follower_count", int(followers.TotalItems))
|
||||
dbActor.Set("following_count", int(following.TotalItems))
|
||||
}
|
||||
}
|
||||
|
||||
err := app.Save(dbActor)
|
||||
if err != nil && err.Error() == "iri: Value must be unique." {
|
||||
dbActor, err = app.FindFirstRecordByData("activitypub_actors", "iri", dbActor.GetString("iri"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return dbActor, nil
|
||||
} else if err != nil {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -230,14 +269,15 @@ func assembleActor(actor *core.Record, dbActor *core.Record, app core.App, inclu
|
||||
}
|
||||
|
||||
// Fetches an AP actor and optionally followers/following collections
|
||||
func fetchRemoteActor(actor *core.Record, iri string, includeFollows bool) (*pub.Actor, *pub.OrderedCollection, *pub.OrderedCollection, error) {
|
||||
func fetchRemoteActor(app core.App, ctx context.Context, iri string, includeFollows bool) (*pub.Actor, *pub.OrderedCollection, *pub.OrderedCollection, error) {
|
||||
encryptionKey := os.Getenv("POCKETBASE_ENCRYPTION_KEY")
|
||||
if len(encryptionKey) == 0 {
|
||||
return nil, nil, nil, fmt.Errorf("POCKETBASE_ENCRYPTION_KEY not set")
|
||||
}
|
||||
|
||||
client := &http.Client{}
|
||||
req, _ := http.NewRequest("GET", iri, nil)
|
||||
client := util.SafeHTTPClient()
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", iri, nil)
|
||||
|
||||
headers := map[string]string{
|
||||
"Accept": "application/ld+json",
|
||||
@@ -250,8 +290,10 @@ func fetchRemoteActor(actor *core.Record, iri string, includeFollows bool) (*pub
|
||||
req.Header.Add(k, v)
|
||||
}
|
||||
|
||||
if actor != nil && actor.GetString("private_key") != "" {
|
||||
dbPrivateKey := actor.GetString("private_key")
|
||||
userActorId := strings.TrimPrefix(ctx.Value("actor").(string), "actor:")
|
||||
userActor, err := app.FindRecordById("activitypub_actors", userActorId)
|
||||
if userActor != nil && userActor.GetString("private_key") != "" {
|
||||
dbPrivateKey := userActor.GetString("private_key")
|
||||
|
||||
algs := []httpsig.Algorithm{httpsig.RSA_SHA256}
|
||||
postHeaders := []string{"(request-target)", "Date", "Digest", "Content-Type", "Host"}
|
||||
@@ -271,7 +313,7 @@ func fetchRemoteActor(actor *core.Record, iri string, includeFollows bool) (*pub
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
|
||||
pubID := actor.GetString("iri") + "#main-key"
|
||||
pubID := userActor.GetString("iri") + "#main-key"
|
||||
|
||||
if err := signer.SignRequest(privateKey, pubID, req, []byte{}); err != nil {
|
||||
return nil, nil, nil, err
|
||||
@@ -293,16 +335,21 @@ func fetchRemoteActor(actor *core.Record, iri string, includeFollows bool) (*pub
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
|
||||
// Validate actor response has required fields
|
||||
if err := validateActorResponse(&pubActor); err != nil {
|
||||
return nil, nil, nil, fmt.Errorf("actor validation failed for %s: %w", iri, err)
|
||||
}
|
||||
|
||||
var followers, following pub.OrderedCollection
|
||||
|
||||
if includeFollows {
|
||||
// Fetch followers
|
||||
if data, err := FetchCollection(actor, pubActor.Followers.GetID().String()); err == nil {
|
||||
if data, err := FetchCollection(app, ctx, util.ItemID(pubActor.Followers)); err == nil {
|
||||
followers = *data
|
||||
}
|
||||
|
||||
// Fetch following
|
||||
if data, err := FetchCollection(actor, pubActor.Following.GetID().String()); err == nil {
|
||||
if data, err := FetchCollection(app, ctx, util.ItemID(pubActor.Following)); err == nil {
|
||||
following = *data
|
||||
}
|
||||
}
|
||||
@@ -310,12 +357,13 @@ func fetchRemoteActor(actor *core.Record, iri string, includeFollows bool) (*pub
|
||||
return &pubActor, &followers, &following, nil
|
||||
}
|
||||
|
||||
func FetchCollection(actor *core.Record, url string) (*pub.OrderedCollection, error) {
|
||||
func FetchCollection(app core.App, ctx context.Context, collectionURL string) (*pub.OrderedCollection, error) {
|
||||
encryptionKey := os.Getenv("POCKETBASE_ENCRYPTION_KEY")
|
||||
if len(encryptionKey) == 0 {
|
||||
return nil, fmt.Errorf("POCKETBASE_ENCRYPTION_KEY not set")
|
||||
}
|
||||
req, _ := http.NewRequest("GET", url, nil)
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", collectionURL, nil)
|
||||
|
||||
headers := map[string]string{
|
||||
"Accept": "application/ld+json",
|
||||
@@ -327,9 +375,10 @@ func FetchCollection(actor *core.Record, url string) (*pub.OrderedCollection, er
|
||||
for k, v := range headers {
|
||||
req.Header.Add(k, v)
|
||||
}
|
||||
|
||||
if actor != nil && actor.GetString("private_key") != "" {
|
||||
dbPrivateKey := actor.GetString("private_key")
|
||||
userActorId := strings.TrimPrefix(ctx.Value("actor").(string), "actor:")
|
||||
userActor, err := app.FindRecordById("activitypub_actors", userActorId)
|
||||
if userActor != nil && userActor.GetString("private_key") != "" {
|
||||
dbPrivateKey := userActor.GetString("private_key")
|
||||
if dbPrivateKey != "" {
|
||||
algs := []httpsig.Algorithm{httpsig.RSA_SHA256}
|
||||
postHeaders := []string{"(request-target)", "Date", "Digest", "Content-Type", "Host"}
|
||||
@@ -349,7 +398,7 @@ func FetchCollection(actor *core.Record, url string) (*pub.OrderedCollection, er
|
||||
return nil, err
|
||||
}
|
||||
|
||||
pubID := actor.GetString("iri") + "#main-key"
|
||||
pubID := userActor.GetString("iri") + "#main-key"
|
||||
|
||||
if err := signer.SignRequest(privateKey, pubID, req, []byte{}); err != nil {
|
||||
return nil, err
|
||||
@@ -358,15 +407,16 @@ func FetchCollection(actor *core.Record, url string) (*pub.OrderedCollection, er
|
||||
}
|
||||
}
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
client := util.SafeHTTPClient()
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("collection fetch failed for %s: %v", url, err)
|
||||
return nil, fmt.Errorf("collection fetch failed for %s: %v", collectionURL, err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
if resp.StatusCode == http.StatusNotFound {
|
||||
return nil, ErrProfilePrivate
|
||||
}
|
||||
return nil, fmt.Errorf("collection fetch %s returned: %v", url, resp.StatusCode)
|
||||
return nil, fmt.Errorf("collection fetch %s returned: %v", collectionURL, resp.StatusCode)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
22
db/hooks/api_tokens.go
Normal 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
47
db/hooks/bootstrap.go
Normal 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
65
db/hooks/comments.go
Normal 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
57
db/hooks/feed.go
Normal 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
24
db/hooks/follow.go
Normal 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
146
db/hooks/integrations.go
Normal 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
105
db/hooks/list.go
Normal 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
56
db/hooks/list_share.go
Normal 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
96
db/hooks/summit_logs.go
Normal 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
118
db/hooks/trail_like.go
Normal 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
57
db/hooks/trail_share.go
Normal 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
122
db/hooks/trails.go
Normal 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
112
db/hooks/users.go
Normal 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)
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ package hammerhead
|
||||
import (
|
||||
"time"
|
||||
|
||||
"pocketbase/trailmerge"
|
||||
"pocketbase/services/trailmerge"
|
||||
)
|
||||
|
||||
type HammerheadToursResponse struct {
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ package komoot
|
||||
import (
|
||||
"time"
|
||||
|
||||
"pocketbase/trailmerge"
|
||||
"pocketbase/services/trailmerge"
|
||||
)
|
||||
|
||||
type KomootIntegration struct {
|
||||
|
||||
@@ -3,7 +3,7 @@ package strava
|
||||
import (
|
||||
"time"
|
||||
|
||||
"pocketbase/trailmerge"
|
||||
"pocketbase/services/trailmerge"
|
||||
)
|
||||
|
||||
type TokenRequest struct {
|
||||
|
||||
@@ -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
1487
db/main.go
File diff suppressed because it is too large
Load Diff
60
db/migrations/1775994551_updated_waypoints.go
Normal file
60
db/migrations/1775994551_updated_waypoints.go
Normal 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)
|
||||
})
|
||||
}
|
||||
78
db/migrations/1775997520_updated_waypoints.go
Normal file
78
db/migrations/1775997520_updated_waypoints.go
Normal 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)
|
||||
})
|
||||
}
|
||||
33
db/migrations/1775997935_set_waypoint_authors.go
Normal file
33
db/migrations/1775997935_set_waypoint_authors.go
Normal 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
|
||||
})
|
||||
}
|
||||
44
db/migrations/1775997936_updated_waypoints.go
Normal file
44
db/migrations/1775997936_updated_waypoints.go
Normal 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)
|
||||
})
|
||||
}
|
||||
40
db/migrations/1776170099_updated_trails.go
Normal file
40
db/migrations/1776170099_updated_trails.go
Normal 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)
|
||||
})
|
||||
}
|
||||
42
db/migrations/1776243566_updated_trails.go
Normal file
42
db/migrations/1776243566_updated_trails.go
Normal 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)
|
||||
})
|
||||
}
|
||||
42
db/migrations/1776243589_updated_lists.go
Normal file
42
db/migrations/1776243589_updated_lists.go
Normal 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)
|
||||
})
|
||||
}
|
||||
88
db/migrations/1776675228_updated_activitypub_actors.go
Normal file
88
db/migrations/1776675228_updated_activitypub_actors.go
Normal 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)
|
||||
})
|
||||
}
|
||||
40
db/migrations/1778145631_updated_lists.go
Normal file
40
db/migrations/1778145631_updated_lists.go
Normal 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
210
db/routes/activitypub.go
Normal 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
50
db/routes/auth_token.go
Normal 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
11
db/routes/health.go
Normal 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"})
|
||||
}
|
||||
81
db/routes/integration_hammerhead.go
Normal file
81
db/routes/integration_hammerhead.go
Normal 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)
|
||||
}
|
||||
58
db/routes/integration_komoot.go
Normal file
58
db/routes/integration_komoot.go
Normal 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)
|
||||
}
|
||||
87
db/routes/integration_strava.go
Normal file
87
db/routes/integration_strava.go
Normal 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
210
db/routes/remote_list.go
Normal 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
|
||||
}
|
||||
165
db/routes/remote_profile_follow.go
Normal file
165
db/routes/remote_profile_follow.go
Normal 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
317
db/routes/remote_trail.go
Normal 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)
|
||||
}
|
||||
186
db/routes/remote_trail_comment.go
Normal file
186
db/routes/remote_trail_comment.go
Normal 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
44
db/routes/search_token.go
Normal 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,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,13 @@
|
||||
package main
|
||||
package routes
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"pocketbase/services/trailmerge"
|
||||
"pocketbase/util"
|
||||
|
||||
"github.com/meilisearch/meilisearch-go"
|
||||
"github.com/pocketbase/pocketbase/apis"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
|
||||
"pocketbase/trailmerge"
|
||||
)
|
||||
|
||||
type mergeExecuteRequest struct {
|
||||
@@ -16,45 +16,45 @@ type mergeExecuteRequest struct {
|
||||
Settings trailmerge.MergeSettings `json:"settings"`
|
||||
}
|
||||
|
||||
func registerTrailMergeRoutes(se *core.ServeEvent, client meilisearch.ServiceManager) {
|
||||
se.Router.POST("/trail-merge/suggest", func(e *core.RequestEvent) error {
|
||||
if e.Auth == nil {
|
||||
return apis.NewUnauthorizedError("trail_merge_auth_required", nil)
|
||||
}
|
||||
func TrailMergeSuggest(e *core.RequestEvent) error {
|
||||
if e.Auth == nil {
|
||||
return apis.NewUnauthorizedError("trail_merge_auth_required", nil)
|
||||
}
|
||||
|
||||
actor, err := e.App.FindFirstRecordByData("activitypub_actors", "user", e.Auth.Id)
|
||||
if err != nil {
|
||||
return apis.NewBadRequestError("trail_merge_actor_not_found", err)
|
||||
}
|
||||
userActor, err := e.App.FindFirstRecordByData("activitypub_actors", "user", e.Auth.Id)
|
||||
if err != nil {
|
||||
return apis.NewBadRequestError("trail_merge_actor_not_found", err)
|
||||
}
|
||||
|
||||
var request trailmerge.SuggestRequest
|
||||
if err := e.BindBody(&request); err != nil {
|
||||
return apis.NewBadRequestError("trail_merge_invalid_request", err)
|
||||
}
|
||||
var request trailmerge.SuggestRequest
|
||||
if err := e.BindBody(&request); err != nil {
|
||||
return apis.NewBadRequestError("trail_merge_invalid_request", err)
|
||||
}
|
||||
|
||||
if request.Mode == trailmerge.SuggestModeMaintenance {
|
||||
response, err := trailmerge.SuggestGroups(e.App, actor.Id, request)
|
||||
if err != nil {
|
||||
return apis.NewBadRequestError(err.Error(), err)
|
||||
}
|
||||
|
||||
return e.JSON(http.StatusOK, response)
|
||||
}
|
||||
|
||||
response, err := trailmerge.Suggest(e.App, actor.Id, request)
|
||||
if request.Mode == trailmerge.SuggestModeMaintenance {
|
||||
response, err := trailmerge.SuggestGroups(e.App, userActor.Id, request)
|
||||
if err != nil {
|
||||
return apis.NewBadRequestError(err.Error(), err)
|
||||
}
|
||||
|
||||
return e.JSON(http.StatusOK, response)
|
||||
})
|
||||
}
|
||||
|
||||
se.Router.POST("/trail-merge", func(e *core.RequestEvent) error {
|
||||
response, err := trailmerge.Suggest(e.App, userActor.Id, request)
|
||||
if err != nil {
|
||||
return apis.NewBadRequestError(err.Error(), err)
|
||||
}
|
||||
|
||||
return e.JSON(http.StatusOK, response)
|
||||
}
|
||||
|
||||
func TrailMerge(client meilisearch.ServiceManager) func(e *core.RequestEvent) error {
|
||||
return func(e *core.RequestEvent) error {
|
||||
if e.Auth == nil {
|
||||
return apis.NewUnauthorizedError("trail_merge_auth_required", nil)
|
||||
}
|
||||
|
||||
actor, err := e.App.FindFirstRecordByData("activitypub_actors", "user", e.Auth.Id)
|
||||
userActor, err := e.App.FindFirstRecordByData("activitypub_actors", "user", e.Auth.Id)
|
||||
if err != nil {
|
||||
return apis.NewBadRequestError("trail_merge_actor_not_found", err)
|
||||
}
|
||||
@@ -73,16 +73,18 @@ func registerTrailMergeRoutes(se *core.ServeEvent, client meilisearch.ServiceMan
|
||||
return apis.NewBadRequestError("trail_merge_target_not_found", err)
|
||||
}
|
||||
|
||||
if !trailmerge.CanMerge(e.App, actor.Id, source, target, request.Settings.Delete) {
|
||||
if !trailmerge.CanMerge(e.App, userActor.Id, source, target, request.Settings.Delete) {
|
||||
return apis.NewForbiddenError("trail_merge_not_allowed", nil)
|
||||
}
|
||||
|
||||
if err := trailmerge.Merge(e.App, client, actor, request.SourceTrailID, request.TargetTrailID, request.Settings); err != nil {
|
||||
ctx, err := util.GetSafeActorContext(e.Request, userActor)
|
||||
|
||||
if err := trailmerge.Merge(e.App, client, ctx, userActor, request.SourceTrailID, request.TargetTrailID, request.Settings); err != nil {
|
||||
return apis.NewBadRequestError(err.Error(), err)
|
||||
}
|
||||
|
||||
return e.JSON(http.StatusOK, map[string]any{
|
||||
"acknowledged": true,
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
190
db/util/network.go
Normal 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
43
db/util/sanitize.go
Normal 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()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user