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
|
!commands
|
||||||
!federation
|
!federation
|
||||||
|
!hooks
|
||||||
!go.*
|
!go.*
|
||||||
!integrations
|
!integrations
|
||||||
!main.go
|
!main.go
|
||||||
!trail_merge_routes.go
|
|
||||||
!migrations
|
!migrations
|
||||||
|
!routes
|
||||||
!templates
|
!templates
|
||||||
!trailmerge
|
!services
|
||||||
!waypointcluster
|
|
||||||
!waypointcluster/**
|
|
||||||
!util
|
!util
|
||||||
|
|||||||
@@ -4,12 +4,9 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"crypto/x509"
|
"crypto/x509"
|
||||||
"database/sql"
|
|
||||||
"encoding/pem"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
|
||||||
"os"
|
"os"
|
||||||
"slices"
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -127,105 +124,3 @@ func PostActivity(app core.App, actor *core.Record, activity *pub.Activity, reci
|
|||||||
}()
|
}()
|
||||||
return nil
|
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
|
package federation
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"crypto/x509"
|
"crypto/x509"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
|
"pocketbase/util"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -21,6 +24,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
var ErrProfilePrivate = errors.New("profile is private")
|
var ErrProfilePrivate = errors.New("profile is private")
|
||||||
|
var ErrInvalidActorResponse = errors.New("invalid or incomplete actor response")
|
||||||
|
|
||||||
type WebfingerResponse struct {
|
type WebfingerResponse struct {
|
||||||
Subject string `json:"subject"`
|
Subject string `json:"subject"`
|
||||||
@@ -30,24 +34,36 @@ type WebfingerResponse struct {
|
|||||||
} `json:"links"`
|
} `json:"links"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func SplitHandle(handle string) (string, string) {
|
func validateActorResponse(actor *pub.Actor) error {
|
||||||
|
if actor == nil {
|
||||||
cleaned := strings.TrimPrefix(handle, "@")
|
return ErrInvalidActorResponse
|
||||||
cleaned = strings.TrimSpace(cleaned)
|
|
||||||
|
|
||||||
if !strings.Contains(cleaned, "@") {
|
|
||||||
return cleaned, ""
|
|
||||||
}
|
}
|
||||||
|
|
||||||
parts := strings.SplitN(cleaned, "@", 2)
|
if actor.GetID().String() == "" {
|
||||||
user := parts[0]
|
return fmt.Errorf("%w: missing ID", ErrInvalidActorResponse)
|
||||||
domain := parts[1]
|
}
|
||||||
|
|
||||||
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) {
|
func GetActorByHandle(app core.App, ctx context.Context, handle string, includeFollows bool) (*core.Record, error) {
|
||||||
username, domain := SplitHandle(handle)
|
username, domain := util.SplitHandle(handle)
|
||||||
|
|
||||||
filter := "preferred_username={:username}&&"
|
filter := "preferred_username={:username}&&"
|
||||||
if domain != "" {
|
if domain != "" {
|
||||||
@@ -66,7 +82,7 @@ func GetActorByHandle(app core.App, actor *core.Record, handle string, includeFo
|
|||||||
|
|
||||||
dbActor = core.NewRecord(collection)
|
dbActor = core.NewRecord(collection)
|
||||||
dbActor.Set("isLocal", false)
|
dbActor.Set("isLocal", false)
|
||||||
iri, err := iriFromHandle(domain, username)
|
iri, err := iriFromHandle(ctx, domain, username)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -76,10 +92,10 @@ func GetActorByHandle(app core.App, actor *core.Record, handle string, includeFo
|
|||||||
return nil, err
|
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
|
var dbActor *core.Record
|
||||||
dbActor, err := app.FindFirstRecordByFilter("activitypub_actors", "iri={:iri}", dbx.Params{"iri": iri})
|
dbActor, err := app.FindFirstRecordByFilter("activitypub_actors", "iri={:iri}", dbx.Params{"iri": iri})
|
||||||
if err != nil && err == sql.ErrNoRows {
|
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 nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
return assembleActor(actor, dbActor, app, includeFollows)
|
return assembleActor(app, ctx, dbActor, includeFollows || dbActor.Id == "")
|
||||||
}
|
}
|
||||||
|
|
||||||
func iriFromHandle(domain string, username string) (string, error) {
|
func iriFromHandle(ctx context.Context, domain string, username string) (string, error) {
|
||||||
client := &http.Client{}
|
client := util.SafeHTTPClient()
|
||||||
|
|
||||||
webfingerURL := fmt.Sprintf("https://%s/.well-known/webfinger?resource=acct:%s@%s", domain, username, domain)
|
u := &url.URL{
|
||||||
resp, err := client.Get(webfingerURL)
|
Scheme: "https",
|
||||||
if err != nil || resp.StatusCode != http.StatusOK {
|
Host: domain,
|
||||||
return "", fmt.Errorf("webfinger request failed: %v", err)
|
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()
|
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
|
var wf WebfingerResponse
|
||||||
if err := json.NewDecoder(resp.Body).Decode(&wf); err != nil {
|
if err := json.NewDecoder(limitedReader).Decode(&wf); err != nil {
|
||||||
return "", err
|
return "", fmt.Errorf("failed to decode JSON: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, link := range wf.Links {
|
for _, link := range wf.Links {
|
||||||
if link.Rel == "self" {
|
if link.Rel == "self" {
|
||||||
|
if _, err := url.Parse(link.Href); err != nil {
|
||||||
|
return "", fmt.Errorf("invalid IRI in response")
|
||||||
|
}
|
||||||
return link.Href, nil
|
return link.Href, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return "", fmt.Errorf("no iri in response")
|
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")
|
origin := os.Getenv("ORIGIN")
|
||||||
if origin == "" {
|
if origin == "" {
|
||||||
return nil, fmt.Errorf("ORIGIN environment variable not set")
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
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}))
|
followingCount, err := app.CountRecords("follows", dbx.NewExp("follower={:user} AND status='accepted'", dbx.Params{"user": dbActor.Id}))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
dbActor.Set("followingCount", followingCount)
|
dbActor.Set("following_count", followingCount)
|
||||||
|
|
||||||
dbActor.Set("last_fetched", time.Now())
|
dbActor.Set("last_fetched", time.Now())
|
||||||
|
|
||||||
@@ -165,11 +203,11 @@ func assembleActor(actor *core.Record, dbActor *core.Record, app core.App, inclu
|
|||||||
} else {
|
} else {
|
||||||
|
|
||||||
// check if value is still cached
|
// check if value is still cached
|
||||||
twoHoursAgo := time.Now().Add(-2 * time.Hour)
|
twoHoursAgo := time.Now().UTC().Add(-2 * time.Hour)
|
||||||
if !includeFollows && dbActor.GetDateTime("last_fetched").Time().After(twoHoursAgo) {
|
if dbActor.GetDateTime("last_fetched").Time().After(twoHoursAgo) {
|
||||||
return dbActor, nil
|
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 err != nil {
|
||||||
if dbActor.Id != "" {
|
if dbActor.Id != "" {
|
||||||
return dbActor, err
|
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.")
|
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("domain", domain)
|
||||||
dbActor.Set("followers", pubActor.Followers.GetID().String())
|
dbActor.Set("followers", util.ItemID(pubActor.Followers))
|
||||||
dbActor.Set("inbox", pubActor.Inbox.GetID().String())
|
dbActor.Set("inbox", util.ItemID(pubActor.Inbox))
|
||||||
dbActor.Set("iri", pubActor.GetID().String())
|
dbActor.Set("iri", pubActor.GetID().String())
|
||||||
dbActor.Set("username", pubActor.Name.String())
|
dbActor.Set("username", pubActor.Name.String())
|
||||||
dbActor.Set("preferred_username", pubActor.PreferredUsername.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("summary", pubActor.Summary.String())
|
||||||
dbActor.Set("outbox", pubActor.Outbox.GetID().String())
|
dbActor.Set("outbox", util.ItemID(pubActor.Outbox))
|
||||||
dbActor.Set("icon", icon)
|
dbActor.Set("icon", icon)
|
||||||
dbActor.Set("published", pubActor.Published.String())
|
dbActor.Set("published", pubActor.Published.String())
|
||||||
dbActor.Set("public_key", pubActor.PublicKey.PublicKeyPem)
|
dbActor.Set("public_key", pubActor.PublicKey.PublicKeyPem)
|
||||||
dbActor.Set("last_fetched", time.Now())
|
dbActor.Set("last_fetched", time.Now())
|
||||||
|
|
||||||
if includeFollows {
|
if includeFollows {
|
||||||
dbActor.Set("followerCount", int(followers.TotalItems))
|
dbActor.Set("follower_count", int(followers.TotalItems))
|
||||||
dbActor.Set("followingCount", int(following.TotalItems))
|
dbActor.Set("following_count", int(following.TotalItems))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
err := app.Save(dbActor)
|
err := app.Save(dbActor)
|
||||||
if err != nil && err.Error() == "iri: Value must be unique." {
|
if err != nil {
|
||||||
dbActor, err = app.FindFirstRecordByData("activitypub_actors", "iri", dbActor.GetString("iri"))
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return dbActor, nil
|
|
||||||
} else if err != nil {
|
|
||||||
return nil, err
|
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
|
// 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")
|
encryptionKey := os.Getenv("POCKETBASE_ENCRYPTION_KEY")
|
||||||
if len(encryptionKey) == 0 {
|
if len(encryptionKey) == 0 {
|
||||||
return nil, nil, nil, fmt.Errorf("POCKETBASE_ENCRYPTION_KEY not set")
|
return nil, nil, nil, fmt.Errorf("POCKETBASE_ENCRYPTION_KEY not set")
|
||||||
}
|
}
|
||||||
|
|
||||||
client := &http.Client{}
|
client := util.SafeHTTPClient()
|
||||||
req, _ := http.NewRequest("GET", iri, nil)
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, "GET", iri, nil)
|
||||||
|
|
||||||
headers := map[string]string{
|
headers := map[string]string{
|
||||||
"Accept": "application/ld+json",
|
"Accept": "application/ld+json",
|
||||||
@@ -250,8 +290,10 @@ func fetchRemoteActor(actor *core.Record, iri string, includeFollows bool) (*pub
|
|||||||
req.Header.Add(k, v)
|
req.Header.Add(k, v)
|
||||||
}
|
}
|
||||||
|
|
||||||
if actor != nil && actor.GetString("private_key") != "" {
|
userActorId := strings.TrimPrefix(ctx.Value("actor").(string), "actor:")
|
||||||
dbPrivateKey := actor.GetString("private_key")
|
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}
|
algs := []httpsig.Algorithm{httpsig.RSA_SHA256}
|
||||||
postHeaders := []string{"(request-target)", "Date", "Digest", "Content-Type", "Host"}
|
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
|
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 {
|
if err := signer.SignRequest(privateKey, pubID, req, []byte{}); err != nil {
|
||||||
return nil, nil, nil, err
|
return nil, nil, nil, err
|
||||||
@@ -293,16 +335,21 @@ func fetchRemoteActor(actor *core.Record, iri string, includeFollows bool) (*pub
|
|||||||
return nil, nil, nil, err
|
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
|
var followers, following pub.OrderedCollection
|
||||||
|
|
||||||
if includeFollows {
|
if includeFollows {
|
||||||
// Fetch followers
|
// 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
|
followers = *data
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch following
|
// 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
|
following = *data
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -310,12 +357,13 @@ func fetchRemoteActor(actor *core.Record, iri string, includeFollows bool) (*pub
|
|||||||
return &pubActor, &followers, &following, nil
|
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")
|
encryptionKey := os.Getenv("POCKETBASE_ENCRYPTION_KEY")
|
||||||
if len(encryptionKey) == 0 {
|
if len(encryptionKey) == 0 {
|
||||||
return nil, fmt.Errorf("POCKETBASE_ENCRYPTION_KEY not set")
|
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{
|
headers := map[string]string{
|
||||||
"Accept": "application/ld+json",
|
"Accept": "application/ld+json",
|
||||||
@@ -327,9 +375,10 @@ func FetchCollection(actor *core.Record, url string) (*pub.OrderedCollection, er
|
|||||||
for k, v := range headers {
|
for k, v := range headers {
|
||||||
req.Header.Add(k, v)
|
req.Header.Add(k, v)
|
||||||
}
|
}
|
||||||
|
userActorId := strings.TrimPrefix(ctx.Value("actor").(string), "actor:")
|
||||||
if actor != nil && actor.GetString("private_key") != "" {
|
userActor, err := app.FindRecordById("activitypub_actors", userActorId)
|
||||||
dbPrivateKey := actor.GetString("private_key")
|
if userActor != nil && userActor.GetString("private_key") != "" {
|
||||||
|
dbPrivateKey := userActor.GetString("private_key")
|
||||||
if dbPrivateKey != "" {
|
if dbPrivateKey != "" {
|
||||||
algs := []httpsig.Algorithm{httpsig.RSA_SHA256}
|
algs := []httpsig.Algorithm{httpsig.RSA_SHA256}
|
||||||
postHeaders := []string{"(request-target)", "Date", "Digest", "Content-Type", "Host"}
|
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
|
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 {
|
if err := signer.SignRequest(privateKey, pubID, req, []byte{}); err != nil {
|
||||||
return nil, err
|
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 {
|
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.StatusOK {
|
||||||
if resp.StatusCode == http.StatusNotFound {
|
if resp.StatusCode == http.StatusNotFound {
|
||||||
return nil, ErrProfilePrivate
|
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()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ import (
|
|||||||
"golang.org/x/net/html"
|
"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") {
|
if !trail.GetBool("public") {
|
||||||
// only broadcast the trail if it is public
|
// only broadcast the trail if it is public
|
||||||
return nil
|
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)
|
id := fmt.Sprintf("%s/api/v1/activitypub/activity/%s", origin, recordId)
|
||||||
to := "https://www.w3.org/ns/activitystreams#Public"
|
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 {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -108,7 +108,7 @@ func CreateTrailActivity(app core.App, actor *core.Record, trail *core.Record, t
|
|||||||
return PostActivity(app, trailAuthor, activity, recipients)
|
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")
|
origin := os.Getenv("ORIGIN")
|
||||||
if origin == "" {
|
if origin == "" {
|
||||||
return fmt.Errorf("ORIGIN not set")
|
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)
|
id := fmt.Sprintf("%s/api/v1/activitypub/activity/%s", origin, activityRecordId)
|
||||||
to := "https://www.w3.org/ns/activitystreams#Public"
|
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 {
|
if err != nil {
|
||||||
return err
|
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")
|
origin := os.Getenv("ORIGIN")
|
||||||
if 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")))
|
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 {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -807,7 +807,7 @@ func processCreateOrUpdateListActivity(activity pub.Activity, app core.App, acto
|
|||||||
return err
|
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))
|
doc, err := html.Parse(strings.NewReader(htmlStr))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -842,7 +842,7 @@ func ActorsFromMentions(app core.App, actor *core.Record, htmlStr string) ([]*co
|
|||||||
f(doc)
|
f(doc)
|
||||||
|
|
||||||
for _, h := range handles {
|
for _, h := range handles {
|
||||||
actor, err := GetActorByHandle(app, actor, h, false)
|
actor, err := GetActorByHandle(app, ctx, h, false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,10 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func CreateTrailDeleteActivity(app core.App, r *core.Record) error {
|
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")
|
origin := os.Getenv("ORIGIN")
|
||||||
if origin == "" {
|
if origin == "" {
|
||||||
return fmt.Errorf("ORIGIN not set")
|
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 (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"context"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
@@ -24,7 +25,7 @@ import (
|
|||||||
"github.com/pocketbase/pocketbase/tools/security"
|
"github.com/pocketbase/pocketbase/tools/security"
|
||||||
"github.com/tkrajina/gpxgo/gpx"
|
"github.com/tkrajina/gpxgo/gpx"
|
||||||
|
|
||||||
"pocketbase/trailmerge"
|
"pocketbase/services/trailmerge"
|
||||||
"pocketbase/util"
|
"pocketbase/util"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -48,6 +49,12 @@ func SyncHammerhead(app core.App, client meilisearch.ServiceManager) error {
|
|||||||
app.Logger().Warn(warning)
|
app.Logger().Warn(warning)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ctx, err := util.GetSafeActorContext(nil, actor)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
hammerheadString := i.GetString("hammerhead")
|
hammerheadString := i.GetString("hammerhead")
|
||||||
hammerheadIntegration := HammerheadIntegration{
|
hammerheadIntegration := HammerheadIntegration{
|
||||||
Planned: true,
|
Planned: true,
|
||||||
@@ -111,7 +118,7 @@ func SyncHammerhead(app core.App, client meilisearch.ServiceManager) error {
|
|||||||
totalPages = curTotalPages
|
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 {
|
if err != nil {
|
||||||
warning := fmt.Sprintf("error syncing Hammerhead tours with trails: %v\n", err)
|
warning := fmt.Sprintf("error syncing Hammerhead tours with trails: %v\n", err)
|
||||||
fmt.Print(warning)
|
fmt.Print(warning)
|
||||||
@@ -142,7 +149,7 @@ func SyncHammerhead(app core.App, client meilisearch.ServiceManager) error {
|
|||||||
totalPages = curTotalPages
|
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 {
|
if err != nil {
|
||||||
warning := fmt.Sprintf("error syncing Hammerhead tours with trails: %v\n", err)
|
warning := fmt.Sprintf("error syncing Hammerhead tours with trails: %v\n", err)
|
||||||
fmt.Print(warning)
|
fmt.Print(warning)
|
||||||
@@ -409,7 +416,7 @@ func (h *HammerheadApi) fetchDetailedTour(tour HammerheadTourResponse) (*Hammerh
|
|||||||
return data, nil
|
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 {
|
for _, tour := range tours {
|
||||||
existingTrail, err := util.FindTrailByExternalReference(app, "hammerhead", tour.ID)
|
existingTrail, err := util.FindTrailByExternalReference(app, "hammerhead", tour.ID)
|
||||||
if err != nil {
|
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))
|
app.Logger().Warn(fmt.Sprintf("Unable to create trail for tour '%s': %v", tour.Name, err))
|
||||||
continue
|
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))
|
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
|
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 {
|
for _, tour := range tours {
|
||||||
existingTrail, err := util.FindTrailByExternalReference(app, "hammerhead", tour.ID)
|
existingTrail, err := util.FindTrailByExternalReference(app, "hammerhead", tour.ID)
|
||||||
if err != nil {
|
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))
|
app.Logger().Warn(fmt.Sprintf("Unable to create trail for tour '%s': %v", tour.Name, err))
|
||||||
continue
|
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))
|
app.Logger().Warn(fmt.Sprintf("Unable to auto-merge imported Hammerhead activity '%s': %v", tour.Name, err))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ package hammerhead
|
|||||||
import (
|
import (
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"pocketbase/trailmerge"
|
"pocketbase/services/trailmerge"
|
||||||
)
|
)
|
||||||
|
|
||||||
type HammerheadToursResponse struct {
|
type HammerheadToursResponse struct {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package komoot
|
package komoot
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
@@ -19,7 +20,7 @@ import (
|
|||||||
"github.com/pocketbase/pocketbase/tools/security"
|
"github.com/pocketbase/pocketbase/tools/security"
|
||||||
"github.com/tkrajina/gpxgo/gpx"
|
"github.com/tkrajina/gpxgo/gpx"
|
||||||
|
|
||||||
"pocketbase/trailmerge"
|
"pocketbase/services/trailmerge"
|
||||||
"pocketbase/util"
|
"pocketbase/util"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -43,6 +44,12 @@ func SyncKomoot(app core.App, client meilisearch.ServiceManager) error {
|
|||||||
app.Logger().Warn(warning)
|
app.Logger().Warn(warning)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ctx, err := util.GetSafeActorContext(nil, actor)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
komootString := i.GetString("komoot")
|
komootString := i.GetString("komoot")
|
||||||
komootIntegration := KomootIntegration{
|
komootIntegration := KomootIntegration{
|
||||||
Planned: true,
|
Planned: true,
|
||||||
@@ -82,7 +89,7 @@ func SyncKomoot(app core.App, client meilisearch.ServiceManager) error {
|
|||||||
}
|
}
|
||||||
totalPages = tp
|
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 {
|
if err != nil {
|
||||||
warning := fmt.Sprintf("error syncing komoot tours with trails: %v\n", err)
|
warning := fmt.Sprintf("error syncing komoot tours with trails: %v\n", err)
|
||||||
fmt.Print(warning)
|
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
|
// 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
|
// early during incremental syncs. Tours skipped due to type filters do NOT count as
|
||||||
// synced - only tours already present in the DB do.
|
// 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
|
allAlreadySynced := true
|
||||||
for _, tour := range tours {
|
for _, tour := range tours {
|
||||||
existingTrail, err := util.FindTrailByExternalReference(app, "komoot", strconv.Itoa(int(tour.ID)))
|
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))
|
app.Logger().Warn(fmt.Sprintf("Unable to create waypoints for tour '%s': %v", tour.Name, err))
|
||||||
continue
|
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))
|
app.Logger().Warn(fmt.Sprintf("Unable to auto-merge imported komoot tour '%s': %v", tour.Name, err))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ package komoot
|
|||||||
import (
|
import (
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"pocketbase/trailmerge"
|
"pocketbase/services/trailmerge"
|
||||||
)
|
)
|
||||||
|
|
||||||
type KomootIntegration struct {
|
type KomootIntegration struct {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ package strava
|
|||||||
import (
|
import (
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"pocketbase/trailmerge"
|
"pocketbase/services/trailmerge"
|
||||||
)
|
)
|
||||||
|
|
||||||
type TokenRequest struct {
|
type TokenRequest struct {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package strava
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -19,7 +20,7 @@ import (
|
|||||||
"github.com/tkrajina/gpxgo/gpx"
|
"github.com/tkrajina/gpxgo/gpx"
|
||||||
"github.com/twpayne/go-polyline"
|
"github.com/twpayne/go-polyline"
|
||||||
|
|
||||||
"pocketbase/trailmerge"
|
"pocketbase/services/trailmerge"
|
||||||
"pocketbase/util"
|
"pocketbase/util"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -47,6 +48,12 @@ func SyncStrava(app core.App, client meilisearch.ServiceManager) error {
|
|||||||
app.Logger().Warn(warning)
|
app.Logger().Warn(warning)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ctx, err := util.GetSafeActorContext(nil, actor)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
stravaString := i.GetString("strava")
|
stravaString := i.GetString("strava")
|
||||||
var stravaIntegration StravaIntegration
|
var stravaIntegration StravaIntegration
|
||||||
err = json.Unmarshal([]byte(stravaString), &stravaIntegration)
|
err = json.Unmarshal([]byte(stravaString), &stravaIntegration)
|
||||||
@@ -104,7 +111,7 @@ func SyncStrava(app core.App, client meilisearch.ServiceManager) error {
|
|||||||
app.Logger().Warn(warning)
|
app.Logger().Warn(warning)
|
||||||
break
|
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 {
|
if err != nil {
|
||||||
warning := fmt.Sprintf("error syncing strava routes with trails: %v\n", err)
|
warning := fmt.Sprintf("error syncing strava routes with trails: %v\n", err)
|
||||||
fmt.Print(warning)
|
fmt.Print(warning)
|
||||||
@@ -136,7 +143,7 @@ func SyncStrava(app core.App, client meilisearch.ServiceManager) error {
|
|||||||
app.Logger().Warn(warning)
|
app.Logger().Warn(warning)
|
||||||
break
|
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 {
|
if err != nil {
|
||||||
warning := fmt.Sprintf("error syncing strava activities with trails: %v", err)
|
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
|
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 {
|
for _, route := range routes {
|
||||||
existingTrail, err := util.FindTrailByExternalReference(app, "strava", route.IDStr)
|
existingTrail, err := util.FindTrailByExternalReference(app, "strava", route.IDStr)
|
||||||
if err != nil {
|
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))
|
app.Logger().Warn(fmt.Sprintf("Unable to create waypoints for route '%s': %v", route.Name, err))
|
||||||
continue
|
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))
|
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
|
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 {
|
for _, activity := range activities {
|
||||||
existingTrail, err := util.FindTrailByExternalReference(app, "strava", strconv.Itoa(int(activity.ID)))
|
existingTrail, err := util.FindTrailByExternalReference(app, "strava", strconv.Itoa(int(activity.ID)))
|
||||||
if err != nil {
|
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))
|
app.Logger().Warn(fmt.Sprintf("Unable to create trail from activity '%s': %v", activity.Name, err))
|
||||||
continue
|
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))
|
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 (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"pocketbase/services/trailmerge"
|
||||||
|
"pocketbase/util"
|
||||||
|
|
||||||
"github.com/meilisearch/meilisearch-go"
|
"github.com/meilisearch/meilisearch-go"
|
||||||
"github.com/pocketbase/pocketbase/apis"
|
"github.com/pocketbase/pocketbase/apis"
|
||||||
"github.com/pocketbase/pocketbase/core"
|
"github.com/pocketbase/pocketbase/core"
|
||||||
|
|
||||||
"pocketbase/trailmerge"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type mergeExecuteRequest struct {
|
type mergeExecuteRequest struct {
|
||||||
@@ -16,45 +16,45 @@ type mergeExecuteRequest struct {
|
|||||||
Settings trailmerge.MergeSettings `json:"settings"`
|
Settings trailmerge.MergeSettings `json:"settings"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func registerTrailMergeRoutes(se *core.ServeEvent, client meilisearch.ServiceManager) {
|
func TrailMergeSuggest(e *core.RequestEvent) error {
|
||||||
se.Router.POST("/trail-merge/suggest", func(e *core.RequestEvent) error {
|
if e.Auth == nil {
|
||||||
if e.Auth == nil {
|
return apis.NewUnauthorizedError("trail_merge_auth_required", 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 {
|
if err != nil {
|
||||||
return apis.NewBadRequestError("trail_merge_actor_not_found", err)
|
return apis.NewBadRequestError("trail_merge_actor_not_found", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
var request trailmerge.SuggestRequest
|
var request trailmerge.SuggestRequest
|
||||||
if err := e.BindBody(&request); err != nil {
|
if err := e.BindBody(&request); err != nil {
|
||||||
return apis.NewBadRequestError("trail_merge_invalid_request", err)
|
return apis.NewBadRequestError("trail_merge_invalid_request", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if request.Mode == trailmerge.SuggestModeMaintenance {
|
if request.Mode == trailmerge.SuggestModeMaintenance {
|
||||||
response, err := trailmerge.SuggestGroups(e.App, actor.Id, request)
|
response, err := trailmerge.SuggestGroups(e.App, userActor.Id, request)
|
||||||
if err != nil {
|
|
||||||
return apis.NewBadRequestError(err.Error(), err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return e.JSON(http.StatusOK, response)
|
|
||||||
}
|
|
||||||
|
|
||||||
response, err := trailmerge.Suggest(e.App, actor.Id, request)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return apis.NewBadRequestError(err.Error(), err)
|
return apis.NewBadRequestError(err.Error(), err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return e.JSON(http.StatusOK, response)
|
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 {
|
if e.Auth == nil {
|
||||||
return apis.NewUnauthorizedError("trail_merge_auth_required", 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 {
|
if err != nil {
|
||||||
return apis.NewBadRequestError("trail_merge_actor_not_found", err)
|
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)
|
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)
|
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 apis.NewBadRequestError(err.Error(), err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return e.JSON(http.StatusOK, map[string]any{
|
return e.JSON(http.StatusOK, map[string]any{
|
||||||
"acknowledged": true,
|
"acknowledged": true,
|
||||||
})
|
})
|
||||||
})
|
}
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package waypointcluster
|
package routes
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -49,7 +49,7 @@ type categorySettings struct {
|
|||||||
WaypointMergeRadius *float64 `json:"wp_merge_radius"`
|
WaypointMergeRadius *float64 `json:"wp_merge_radius"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func Handler(e *core.RequestEvent) error {
|
func WaypointCluster(e *core.RequestEvent) error {
|
||||||
if e.Auth == nil {
|
if e.Auth == nil {
|
||||||
return apis.NewUnauthorizedError("authentication required", nil)
|
return apis.NewUnauthorizedError("authentication required", nil)
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
package trailmerge
|
package trailmerge
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
"github.com/meilisearch/meilisearch-go"
|
"github.com/meilisearch/meilisearch-go"
|
||||||
"github.com/pocketbase/pocketbase/core"
|
"github.com/pocketbase/pocketbase/core"
|
||||||
)
|
)
|
||||||
@@ -8,6 +10,7 @@ import (
|
|||||||
func TryAutoMergeImportedTrail(
|
func TryAutoMergeImportedTrail(
|
||||||
app core.App,
|
app core.App,
|
||||||
client meilisearch.ServiceManager,
|
client meilisearch.ServiceManager,
|
||||||
|
ctx context.Context,
|
||||||
actor *core.Record,
|
actor *core.Record,
|
||||||
sourceTrailID string,
|
sourceTrailID string,
|
||||||
settings IntegrationAutoMergeSettings,
|
settings IntegrationAutoMergeSettings,
|
||||||
@@ -40,5 +43,5 @@ func TryAutoMergeImportedTrail(
|
|||||||
return nil
|
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 (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
|
||||||
pub "github.com/go-ap/activitypub"
|
pub "github.com/go-ap/activitypub"
|
||||||
"github.com/meilisearch/meilisearch-go"
|
"github.com/meilisearch/meilisearch-go"
|
||||||
"github.com/pocketbase/dbx"
|
"github.com/pocketbase/dbx"
|
||||||
"github.com/pocketbase/pocketbase/core"
|
"github.com/pocketbase/pocketbase/core"
|
||||||
"github.com/pocketbase/pocketbase/tools/filesystem"
|
"github.com/pocketbase/pocketbase/tools/filesystem"
|
||||||
"io"
|
|
||||||
"slices"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"pocketbase/federation"
|
"pocketbase/federation"
|
||||||
"pocketbase/util"
|
"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.
|
// Merge links a source trail into a target trail in a single transaction.
|
||||||
// It moves or recreates trail-related content according to the provided
|
// It moves or recreates trail-related content according to the provided
|
||||||
// settings and keeps the target trail indexed and federated afterwards.
|
// 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 {
|
if actor == nil {
|
||||||
return ErrMissingActor
|
return ErrMissingActor
|
||||||
}
|
}
|
||||||
@@ -198,7 +200,7 @@ func Merge(app core.App, client meilisearch.ServiceManager, actor *core.Record,
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx := mergeContext{
|
mergeCtx := mergeContext{
|
||||||
App: txApp,
|
App: txApp,
|
||||||
Client: client,
|
Client: client,
|
||||||
Actor: actor,
|
Actor: actor,
|
||||||
@@ -208,7 +210,7 @@ func Merge(app core.App, client meilisearch.ServiceManager, actor *core.Record,
|
|||||||
Settings: settings,
|
Settings: settings,
|
||||||
}
|
}
|
||||||
|
|
||||||
sideEffects, err := mergeTrailIntoTarget(ctx)
|
sideEffects, err := mergeTrailIntoTarget(mergeCtx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -233,11 +235,8 @@ func Merge(app core.App, client meilisearch.ServiceManager, actor *core.Record,
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
logAuthor, err := app.FindRecordById("activitypub_actors", record.GetString("author"))
|
|
||||||
if err != nil {
|
if err := federation.CreateSummitLogActivity(app, ctx, record, pub.CreateType); err != nil {
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := federation.CreateSummitLogActivity(app, logAuthor, record, pub.CreateType); err != nil {
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -247,7 +246,7 @@ func Merge(app core.App, client meilisearch.ServiceManager, actor *core.Record,
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
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
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -19,6 +19,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
pub "github.com/go-ap/activitypub"
|
pub "github.com/go-ap/activitypub"
|
||||||
|
"github.com/go-fed/httpsig"
|
||||||
"github.com/pocketbase/pocketbase/core"
|
"github.com/pocketbase/pocketbase/core"
|
||||||
"github.com/pocketbase/pocketbase/tools/filesystem"
|
"github.com/pocketbase/pocketbase/tools/filesystem"
|
||||||
"github.com/pocketbase/pocketbase/tools/security"
|
"github.com/pocketbase/pocketbase/tools/security"
|
||||||
@@ -110,53 +111,6 @@ func generateKeyPair() (*rsa.PrivateKey, *rsa.PublicKey, error) {
|
|||||||
return priv, pub, nil
|
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) {
|
func TrailFromActivity(activity pub.Activity, app core.App, actor *core.Record) (*core.Record, error) {
|
||||||
t, err := pub.ToObject(activity.Object)
|
t, err := pub.ToObject(activity.Object)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -191,7 +145,13 @@ func TrailFromActivity(activity pub.Activity, app core.App, actor *core.Record)
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// this trail exists already
|
// 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
|
return record, nil
|
||||||
}
|
}
|
||||||
@@ -264,6 +224,7 @@ func TrailFromActivity(activity pub.Activity, app core.App, actor *core.Record)
|
|||||||
record.Set("public", true)
|
record.Set("public", true)
|
||||||
record.Set("iri", t.ID.String())
|
record.Set("iri", t.ID.String())
|
||||||
record.Set("author", actor.Id)
|
record.Set("author", actor.Id)
|
||||||
|
record.Set("needs_full_sync", true)
|
||||||
|
|
||||||
categoryRecord, err := app.FindFirstRecordByData("categories", "name", category)
|
categoryRecord, err := app.FindFirstRecordByData("categories", "name", category)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
@@ -473,7 +434,9 @@ func ListFromActivity(activity pub.Activity, app core.App, actor *core.Record) (
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// this list exists already
|
// 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
|
return record, nil
|
||||||
}
|
}
|
||||||
@@ -483,6 +446,7 @@ func ListFromActivity(activity pub.Activity, app core.App, actor *core.Record) (
|
|||||||
record.Set("public", true)
|
record.Set("public", true)
|
||||||
record.Set("iri", iri)
|
record.Set("iri", iri)
|
||||||
record.Set("author", actor.Id)
|
record.Set("author", actor.Id)
|
||||||
|
record.Set("needs_full_sync", true)
|
||||||
|
|
||||||
if l.Attachment != nil {
|
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) {
|
func TrailObjectFromIRI(iri string) (*pub.Object, error) {
|
||||||
fetchURL := strings.Replace(iri, "api/v1/trail", "api/v1/activitypub/trail", 1)
|
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)
|
req, err := http.NewRequest(http.MethodGet, fetchURL, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -627,3 +591,68 @@ func TrailObjectFromIRI(iri string) (*pub.Object, error) {
|
|||||||
|
|
||||||
return &object, nil
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
2
web/package-lock.json
generated
2
web/package-lock.json
generated
@@ -54,7 +54,7 @@
|
|||||||
"pocketbase": "^0.26.8",
|
"pocketbase": "^0.26.8",
|
||||||
"qrcode": "^1.4.4",
|
"qrcode": "^1.4.4",
|
||||||
"svelte-i18n": "^4.0.0",
|
"svelte-i18n": "^4.0.0",
|
||||||
"tailwindcss": "^4.2.3",
|
"tailwindcss": "^4.2.4",
|
||||||
"three": "^0.183.1",
|
"three": "^0.183.1",
|
||||||
"vitest": "^4.1.4",
|
"vitest": "^4.1.4",
|
||||||
"zod": "^3.24.1"
|
"zod": "^3.24.1"
|
||||||
|
|||||||
@@ -43,7 +43,7 @@
|
|||||||
error = "",
|
error = "",
|
||||||
placeholder = "",
|
placeholder = "",
|
||||||
extraClasses = "",
|
extraClasses = "",
|
||||||
searchListPosition = "absolute"
|
searchListPosition = "absolute",
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
const fontSizes: SelectItem[] = [
|
const fontSizes: SelectItem[] = [
|
||||||
@@ -87,7 +87,9 @@
|
|||||||
return [
|
return [
|
||||||
"a",
|
"a",
|
||||||
mergeAttributes(
|
mergeAttributes(
|
||||||
{ href: `/profile/@${options.HTMLAttributes["data-label"]}` },
|
{
|
||||||
|
href: `/profile/@${options.HTMLAttributes["data-label"]}`,
|
||||||
|
},
|
||||||
options.HTMLAttributes,
|
options.HTMLAttributes,
|
||||||
),
|
),
|
||||||
options.renderText?.({
|
options.renderText?.({
|
||||||
@@ -142,7 +144,8 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
searchListElement.style.position = searchListPosition;
|
searchListElement.style.position =
|
||||||
|
searchListPosition;
|
||||||
searchListElement.style.top = `${box.bottom + window.scrollY + 4}px`;
|
searchListElement.style.top = `${box.bottom + window.scrollY + 4}px`;
|
||||||
searchListElement.style.left = `${box.left + window.scrollX}px`;
|
searchListElement.style.left = `${box.left + window.scrollX}px`;
|
||||||
searchListElement.style.zIndex = "1001";
|
searchListElement.style.zIndex = "1001";
|
||||||
@@ -179,12 +182,12 @@
|
|||||||
|
|
||||||
onKeyDown(props) {
|
onKeyDown(props) {
|
||||||
if (props.event.key === "Escape") {
|
if (props.event.key === "Escape") {
|
||||||
this.onExit?.({} as unknown as any)
|
this.onExit?.({} as unknown as any);
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
return false
|
return false;
|
||||||
},
|
},
|
||||||
onExit() {
|
onExit() {
|
||||||
unmount(component);
|
unmount(component);
|
||||||
@@ -315,7 +318,7 @@
|
|||||||
})
|
})
|
||||||
.run();
|
.run();
|
||||||
|
|
||||||
modal.closeModal();
|
modal.closeModal();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (
|
if (
|
||||||
e instanceof ZodError &&
|
e instanceof ZodError &&
|
||||||
|
|||||||
@@ -547,16 +547,19 @@
|
|||||||
clusterPopup.on("close", () => {
|
clusterPopup.on("close", () => {
|
||||||
unHighlightCluster(false);
|
unHighlightCluster(false);
|
||||||
});
|
});
|
||||||
map.on("mousemove", unHighlightClusterDistanceNotifier)
|
map.on("mousemove", unHighlightClusterDistanceNotifier);
|
||||||
}
|
}
|
||||||
|
|
||||||
function unHighlightClusterDistanceNotifier(e: M.MapMouseEvent) {
|
function unHighlightClusterDistanceNotifier(e: M.MapMouseEvent) {
|
||||||
if (!clusterPopup || !map) {
|
if (!clusterPopup || !map) {
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
if (map.project(clusterPopup.getLngLat()).dist(map.project(e.lngLat)) > 60) {
|
if (
|
||||||
|
map.project(clusterPopup.getLngLat()).dist(map.project(e.lngLat)) >
|
||||||
|
60
|
||||||
|
) {
|
||||||
clusterPopup.remove();
|
clusterPopup.remove();
|
||||||
map.off("mousemove", unHighlightClusterDistanceNotifier)
|
map.off("mousemove", unHighlightClusterDistanceNotifier);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -129,9 +129,8 @@
|
|||||||
|
|
||||||
async function fetchComments() {
|
async function fetchComments() {
|
||||||
commentsLoading = true;
|
commentsLoading = true;
|
||||||
const trailId = trail.iri ? trail.iri : trail.id!;
|
|
||||||
try {
|
try {
|
||||||
await comments_index(trailId, handle);
|
await comments_index(trail.id!);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
show_toast({
|
show_toast({
|
||||||
type: "error",
|
type: "error",
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ export interface Actor {
|
|||||||
domain?: string;
|
domain?: string;
|
||||||
summary?: string;
|
summary?: string;
|
||||||
published?: string;
|
published?: string;
|
||||||
followerCount?: number,
|
follower_count?: number,
|
||||||
followingCount?: number,
|
following_count?: number,
|
||||||
iri: string;
|
iri: string;
|
||||||
inbox: string;
|
inbox: string;
|
||||||
outbox?: string;
|
outbox?: string;
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ export class List {
|
|||||||
|
|
||||||
}
|
}
|
||||||
created?: string;
|
created?: string;
|
||||||
|
updated?: string;
|
||||||
author: string;
|
author: string;
|
||||||
|
|
||||||
constructor(name: string, trails: Trail[], params?: { description?: string, public?: boolean, avatar?: string, author?: string }) {
|
constructor(name: string, trails: Trail[], params?: { description?: string, public?: boolean, avatar?: string, author?: string }) {
|
||||||
|
|||||||
@@ -1,25 +1,17 @@
|
|||||||
import { Comment } from "$lib/models/comment";
|
import { Comment } from "$lib/models/comment";
|
||||||
import type { Trail } from "$lib/models/trail";
|
|
||||||
import { APIError } from "$lib/util/api_util";
|
import { APIError } from "$lib/util/api_util";
|
||||||
import { type ListResult } from "pocketbase";
|
import { type ListResult } from "pocketbase";
|
||||||
import { get, writable, type Writable } from "svelte/store";
|
import { get, writable, type Writable } from "svelte/store";
|
||||||
import { currentUser } from "./user_store";
|
import { currentUser } from "./user_store";
|
||||||
import { isURL } from "$lib/util/file_util";
|
|
||||||
|
|
||||||
export const comments: Writable<Comment[]> = writable([])
|
export const comments: Writable<Comment[]> = writable([])
|
||||||
|
|
||||||
export async function comments_index(trailId: string, handle?: string) {
|
export async function comments_index(trailId: string) {
|
||||||
let filter: string;
|
let filter: string;
|
||||||
if (isURL(trailId)) {
|
|
||||||
filter = `trail="${trailId}"||trail.iri="${trailId}"||trail="${trailId.substring(trailId.length - 15)}"`
|
let r = await fetch(`/api/v1/trail/${trailId}/comment?` + new URLSearchParams({
|
||||||
} else {
|
|
||||||
filter = `trail="${trailId}"`
|
|
||||||
}
|
|
||||||
let r = await fetch(`/api/v1/comment?` + new URLSearchParams({
|
|
||||||
filter,
|
|
||||||
expand: "author",
|
expand: "author",
|
||||||
sort: "-created",
|
sort: "-created",
|
||||||
...(handle ? { handle } : {})
|
|
||||||
}), {
|
}), {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -5,11 +5,9 @@ import { type ListResult } from "pocketbase";
|
|||||||
|
|
||||||
let follows: Actor[] = [];
|
let follows: Actor[] = [];
|
||||||
|
|
||||||
export async function follows_index(data: { username: string, type: "followers" | "following" }, page: number = 1, perPage: number = 10, f: (url: RequestInfo | URL, config?: RequestInit) => Promise<Response> = fetch) {
|
export async function follows_index(page: number = 1, perPage: number = 10, f: (url: RequestInfo | URL, config?: RequestInit) => Promise<Response> = fetch) {
|
||||||
|
|
||||||
const r = await f(`/api/v1/follow?` + new URLSearchParams({
|
const r = await f(`/api/v1/follow?` + new URLSearchParams({
|
||||||
handle: data.username,
|
|
||||||
type: data.type,
|
|
||||||
page: page.toString(),
|
page: page.toString(),
|
||||||
perPage: perPage.toString(),
|
perPage: perPage.toString(),
|
||||||
}), {
|
}), {
|
||||||
|
|||||||
@@ -9,9 +9,10 @@ import { searchResultToLists } from "./list_store";
|
|||||||
import type { ListSearchResult } from "./search_store";
|
import type { ListSearchResult } from "./search_store";
|
||||||
import { buildFilterText } from "./summit_log_store";
|
import { buildFilterText } from "./summit_log_store";
|
||||||
import { searchResultToTrailList } from "./trail_store";
|
import { searchResultToTrailList } from "./trail_store";
|
||||||
|
import type { Actor } from "$lib/models/activitypub/actor";
|
||||||
|
|
||||||
let feed: FeedItem[] = []
|
let feed: FeedItem[] = []
|
||||||
|
let follows: Actor[] = [];
|
||||||
|
|
||||||
export async function profile_show(handle: string, f: (url: RequestInfo | URL, config?: RequestInit) => Promise<Response> = fetch) {
|
export async function profile_show(handle: string, f: (url: RequestInfo | URL, config?: RequestInit) => Promise<Response> = fetch) {
|
||||||
let r = await f('/api/v1/profile/' + handle, {
|
let r = await f('/api/v1/profile/' + handle, {
|
||||||
@@ -133,3 +134,25 @@ export async function profile_stats_index(handle: string, filter: SummitLogFilte
|
|||||||
return result;
|
return result;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function profile_follows_index(handle: string, type: "followers" | "following", page: number, f: (url: RequestInfo | URL, config?: RequestInit) => Promise<Response> = fetch) {
|
||||||
|
const r = await f(`/api/v1/profile/${handle}/follows?` + new URLSearchParams({
|
||||||
|
type,
|
||||||
|
page: page.toString(),
|
||||||
|
}), {
|
||||||
|
method: 'GET',
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!r.ok) {
|
||||||
|
const response = await r.json();
|
||||||
|
throw new APIError(r.status, response.message, response.detail)
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchedFollows: ListResult<Actor> = await r.json();
|
||||||
|
|
||||||
|
const result = page > 1 ? [...follows, ...fetchedFollows.items] : fetchedFollows.items
|
||||||
|
|
||||||
|
follows = result;
|
||||||
|
|
||||||
|
return { ...fetchedFollows, items: result };
|
||||||
|
}
|
||||||
@@ -12,7 +12,7 @@ export async function waypoints_create(waypoint: Waypoint, f: (url: RequestInfo
|
|||||||
throw Error("Unauthenticated")
|
throw Error("Unauthenticated")
|
||||||
}
|
}
|
||||||
|
|
||||||
waypoint.author = user.id
|
waypoint.author = user.actor
|
||||||
|
|
||||||
let r = await f('/api/v1/waypoint', {
|
let r = await f('/api/v1/waypoint', {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { CommentCreateSchema } from '$lib/models/api/comment_schema';
|
|||||||
import type { Comment } from '$lib/models/comment';
|
import type { Comment } from '$lib/models/comment';
|
||||||
import { Collection, create, handleError, list } from '$lib/util/api_util';
|
import { Collection, create, handleError, list } from '$lib/util/api_util';
|
||||||
import { json, type RequestEvent } from '@sveltejs/kit';
|
import { json, type RequestEvent } from '@sveltejs/kit';
|
||||||
import { type ListResult } from "pocketbase";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @swagger
|
* @swagger
|
||||||
@@ -32,11 +31,6 @@ import { type ListResult } from "pocketbase";
|
|||||||
* name: expand
|
* name: expand
|
||||||
* schema:
|
* schema:
|
||||||
* type: string
|
* type: string
|
||||||
* - in: query
|
|
||||||
* name: handle
|
|
||||||
* schema:
|
|
||||||
* type: string
|
|
||||||
* description: Federated query parameter
|
|
||||||
* responses:
|
* responses:
|
||||||
* 200:
|
* 200:
|
||||||
* description: List of comments
|
* description: List of comments
|
||||||
@@ -51,70 +45,8 @@ import { type ListResult } from "pocketbase";
|
|||||||
*/
|
*/
|
||||||
export async function GET(event: RequestEvent) {
|
export async function GET(event: RequestEvent) {
|
||||||
try {
|
try {
|
||||||
if (!event.url.searchParams.has("handle")) {
|
const comments = await list<Comment>(event, Collection.comments);
|
||||||
const comments = await list<Comment>(event, Collection.comments);
|
return json(comments)
|
||||||
return json(comments)
|
|
||||||
} else {
|
|
||||||
const { actor, error } = await event.locals.pb.send(`/activitypub/actor?resource=acct:${event.url.searchParams.get("handle")}`, { method: "GET", fetch: event.fetch, });
|
|
||||||
event.url.searchParams.delete("handle")
|
|
||||||
const localComments = await list<Comment>(event, Collection.comments);
|
|
||||||
if (actor.isLocal) {
|
|
||||||
return json(localComments)
|
|
||||||
}
|
|
||||||
|
|
||||||
const deduplicationMap: Record<string, Comment> = {}
|
|
||||||
|
|
||||||
localComments.items.forEach(c => {
|
|
||||||
if (c.iri) {
|
|
||||||
const id = c.iri.substring(c.iri.length - 15)
|
|
||||||
deduplicationMap[id] = c
|
|
||||||
} else if (c.id) {
|
|
||||||
deduplicationMap[c.id] = c
|
|
||||||
}
|
|
||||||
})
|
|
||||||
const origin = new URL(actor.iri).origin
|
|
||||||
const url = `${origin}/api/v1/comment`
|
|
||||||
|
|
||||||
const response = await event.fetch(url + '?' + event.url.searchParams, { method: 'GET' })
|
|
||||||
if (!response.ok) {
|
|
||||||
const errorResponse = await response.json()
|
|
||||||
console.error(errorResponse)
|
|
||||||
|
|
||||||
}
|
|
||||||
const remoteComments: ListResult<Comment> = await response.json()
|
|
||||||
|
|
||||||
remoteComments.items = remoteComments.items.filter(c => {
|
|
||||||
const iriId = c.iri?.substring(c.iri.length - 15) ?? ""
|
|
||||||
if (deduplicationMap[c.id!] != undefined) {
|
|
||||||
deduplicationMap[c.id!] = { ...c, author: deduplicationMap[c.id!].author }
|
|
||||||
return false
|
|
||||||
} else if (deduplicationMap[iriId] != undefined) {
|
|
||||||
deduplicationMap[iriId] = { ...c, author: deduplicationMap[iriId].author }
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
})
|
|
||||||
|
|
||||||
remoteComments.items.forEach(c => {
|
|
||||||
if (!c.iri?.length) {
|
|
||||||
c.iri = `${url}/${c.id}`
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const allCommentItems = <ListResult<Comment>>{
|
|
||||||
items: localComments.items.concat(remoteComments.items),
|
|
||||||
page: localComments.page,
|
|
||||||
perPage: localComments.perPage,
|
|
||||||
totalItems: localComments.items.length + remoteComments.items.length,
|
|
||||||
totalPages: Math.ceil((localComments.items.length + remoteComments.items.length) / localComments.perPage)
|
|
||||||
}
|
|
||||||
|
|
||||||
allCommentItems.items = allCommentItems.items.sort((a, b) => {
|
|
||||||
return new Date(b.created ?? 0).getTime() - new Date(a.created ?? 0).getTime()
|
|
||||||
})
|
|
||||||
|
|
||||||
return json(allCommentItems)
|
|
||||||
}
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return handleError(e)
|
return handleError(e)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,18 +1,14 @@
|
|||||||
import type { Actor } from '$lib/models/activitypub/actor';
|
import type { Actor } from '$lib/models/activitypub/actor';
|
||||||
import { FollowCreateSchema } from '$lib/models/api/follow_schema';
|
import { FollowCreateSchema } from '$lib/models/api/follow_schema';
|
||||||
import type { Follow } from '$lib/models/follow';
|
import type { Follow } from '$lib/models/follow';
|
||||||
import { getActorResponseForHandle } from '$lib/util/activitypub_server_util';
|
import { Collection, handleError, list } from '$lib/util/api_util';
|
||||||
import { APIError, Collection, handleError, list } from '$lib/util/api_util';
|
|
||||||
import { json, type RequestEvent } from '@sveltejs/kit';
|
import { json, type RequestEvent } from '@sveltejs/kit';
|
||||||
import type { APOrderedCollectionPage } from 'activitypub-types';
|
|
||||||
import { ClientResponseError, type ListResult } from "pocketbase";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @swagger
|
* @swagger
|
||||||
* /api/v1/follow:
|
* /api/v1/follow:
|
||||||
* get:
|
* get:
|
||||||
* summary: List follows
|
* summary: List follows
|
||||||
* description: Retrieves follows or ActivityPub follower/following collections. Supports federated queries via handle parameter
|
|
||||||
* tags:
|
* tags:
|
||||||
* - Follows
|
* - Follows
|
||||||
* parameters:
|
* parameters:
|
||||||
@@ -36,18 +32,9 @@ import { ClientResponseError, type ListResult } from "pocketbase";
|
|||||||
* name: expand
|
* name: expand
|
||||||
* schema:
|
* schema:
|
||||||
* type: string
|
* type: string
|
||||||
* - in: query
|
|
||||||
* name: handle
|
|
||||||
* schema:
|
|
||||||
* type: string
|
|
||||||
* - in: query
|
|
||||||
* name: type
|
|
||||||
* schema:
|
|
||||||
* type: string
|
|
||||||
* enum: [followers, following]
|
|
||||||
* responses:
|
* responses:
|
||||||
* 200:
|
* 200:
|
||||||
* description: List of follows or ActivityPub collection
|
* description: List of follows
|
||||||
* content:
|
* content:
|
||||||
* application/json:
|
* application/json:
|
||||||
* schema:
|
* schema:
|
||||||
@@ -59,60 +46,8 @@ import { ClientResponseError, type ListResult } from "pocketbase";
|
|||||||
*/
|
*/
|
||||||
export async function GET(event: RequestEvent) {
|
export async function GET(event: RequestEvent) {
|
||||||
try {
|
try {
|
||||||
if (!event.url.searchParams.has("handle")) {
|
const follows = await list<Follow>(event, Collection.follows);
|
||||||
const follows = await list<Follow>(event, Collection.follows);
|
return json(follows)
|
||||||
return json(follows)
|
|
||||||
} else {
|
|
||||||
const handle = event.url.searchParams.get("handle");
|
|
||||||
const type = event.url.searchParams.get("type");
|
|
||||||
|
|
||||||
if (!handle || (type !== "followers" && type !== "following")) {
|
|
||||||
throw new APIError(400, "invalid params")
|
|
||||||
}
|
|
||||||
|
|
||||||
const { actor } = await getActorResponseForHandle(event, handle);
|
|
||||||
|
|
||||||
const page = event.url.searchParams.get("page") ?? "1"
|
|
||||||
|
|
||||||
let followers: APOrderedCollectionPage;
|
|
||||||
|
|
||||||
// fetch followers locally to not run into auth issues with private profiles
|
|
||||||
if (actor.id === event.locals.user?.actor) {
|
|
||||||
const r = await event.fetch(actor[type as "followers" | "following"]! + '?' + new URLSearchParams({ page }))
|
|
||||||
|
|
||||||
if (!r.ok) {
|
|
||||||
const errorResponse = await r.json()
|
|
||||||
throw new ClientResponseError({ status: r.status, response: errorResponse });
|
|
||||||
}
|
|
||||||
followers = await r.json()
|
|
||||||
} else {
|
|
||||||
followers = await event.locals.pb.send(`/activitypub/actor/${actor.id}/${type}?page=${page}`, { method: "GET", fetch: event.fetch, });
|
|
||||||
}
|
|
||||||
|
|
||||||
const followerActors: Actor[] = []
|
|
||||||
for (const f of followers.orderedItems ?? []) {
|
|
||||||
try {
|
|
||||||
const { actor }: { actor: Actor } = await event.locals.pb.send(`/activitypub/actor?iri=${f}`, { method: "GET", fetch: event.fetch, });
|
|
||||||
followerActors.push(actor)
|
|
||||||
|
|
||||||
} catch (e) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
const result: ListResult<Actor> = {
|
|
||||||
items: followerActors,
|
|
||||||
page: parseInt(page),
|
|
||||||
perPage: 10,
|
|
||||||
totalItems: actor.followerCount ?? 0,
|
|
||||||
totalPages: Math.ceil((actor.followerCount ?? 0) / 10)
|
|
||||||
}
|
|
||||||
return json(result)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return handleError(e)
|
return handleError(e)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,14 @@
|
|||||||
import { ListUpdateSchema } from "$lib/models/api/list_schema";
|
import { ListUpdateSchema } from "$lib/models/api/list_schema";
|
||||||
import type { List } from "$lib/models/list";
|
import type { List } from "$lib/models/list";
|
||||||
import { Collection, handleError, remove, show, update } from "$lib/util/api_util";
|
import { Collection, handleError, remove, update } from "$lib/util/api_util";
|
||||||
import { objectToFormData } from "$lib/util/file_util";
|
|
||||||
import { json, type RequestEvent } from "@sveltejs/kit";
|
import { json, type RequestEvent } from "@sveltejs/kit";
|
||||||
import { ClientResponseError } from "pocketbase";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @swagger
|
* @swagger
|
||||||
* /api/v1/list/{id}:
|
* /api/v1/list/{id}:
|
||||||
* get:
|
* get:
|
||||||
* summary: Get list
|
* summary: Get list
|
||||||
* description: Retrieves a list by ID. Supports federated queries via handle parameter, fetching from remote instances and remapping file URLs
|
* description: Retrieves a list by ID
|
||||||
* tags:
|
* tags:
|
||||||
* - Lists
|
* - Lists
|
||||||
* parameters:
|
* parameters:
|
||||||
@@ -23,13 +21,9 @@ import { ClientResponseError } from "pocketbase";
|
|||||||
* name: expand
|
* name: expand
|
||||||
* schema:
|
* schema:
|
||||||
* type: string
|
* type: string
|
||||||
* - in: query
|
|
||||||
* name: handle
|
|
||||||
* schema:
|
|
||||||
* type: string
|
|
||||||
* responses:
|
* responses:
|
||||||
* 200:
|
* 200:
|
||||||
* description: List with optional federated data
|
* description: List
|
||||||
* 404:
|
* 404:
|
||||||
* description: Not Found
|
* description: Not Found
|
||||||
* 500:
|
* 500:
|
||||||
@@ -80,77 +74,17 @@ import { ClientResponseError } from "pocketbase";
|
|||||||
* description: Internal Server Error
|
* description: Internal Server Error
|
||||||
*/
|
*/
|
||||||
export async function GET(event: RequestEvent) {
|
export async function GET(event: RequestEvent) {
|
||||||
|
const { url, params } = event;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (!event.url.searchParams.has("handle")) {
|
let list: List = await event.locals.pb.send(`/remote/list/${params.id}?` + url.searchParams, {
|
||||||
const l = await show<List>(event, Collection.lists)
|
method: "GET",
|
||||||
return json(l)
|
fetch: event.fetch,
|
||||||
} else {
|
})
|
||||||
const {actor, error} = await event.locals.pb.send(`/activitypub/actor?resource=acct:${event.url.searchParams.get("handle")}`, { method: "GET", fetch: event.fetch, });
|
|
||||||
event.url.searchParams.delete("handle")
|
|
||||||
|
|
||||||
const origin = new URL(actor.iri).origin
|
|
||||||
const url = `${origin}/api/v1/list/${event.params.id}`
|
|
||||||
|
|
||||||
let dbList: List | undefined;
|
|
||||||
try {
|
|
||||||
dbList = await event.locals.pb.collection("lists").getFirstListItem(`iri='${url}'||id='${event.params.id}'`, {
|
|
||||||
...Object.fromEntries(event.url.searchParams)
|
|
||||||
})
|
|
||||||
} catch (e) {
|
|
||||||
if (!(e instanceof ClientResponseError) || e.status != 404) {
|
|
||||||
throw e
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (actor.isLocal) {
|
|
||||||
return json(dbList)
|
|
||||||
} else {
|
|
||||||
const response = await event.fetch((dbList?.iri ?? url) + '?' + event.url.searchParams, { method: 'GET' })
|
|
||||||
if (!response.ok) {
|
|
||||||
const errorResponse = await response.json()
|
|
||||||
console.error(errorResponse)
|
|
||||||
const cachedList = await event.locals.pb.collection("lists").getOne(`${event.params.id}`)
|
|
||||||
return json(cachedList)
|
|
||||||
}
|
|
||||||
const l: List = await response.json()
|
|
||||||
l.avatar = l.avatar ? `${origin}/api/v1/files/lists/${l.id}/${l.avatar}` : undefined
|
|
||||||
|
|
||||||
l.author = actor.id!
|
|
||||||
l.expand!.author = actor
|
|
||||||
l.iri = dbList?.iri ?? url;
|
|
||||||
|
|
||||||
l.expand?.trails?.forEach(t => {
|
|
||||||
t.gpx = t.gpx ? `${origin}/api/v1/files/trails/${t.id}/${t.gpx}` : undefined
|
|
||||||
t.photos = t.photos.map(p =>
|
|
||||||
`${origin}/api/v1/files/trails/${t.id}/${p}`
|
|
||||||
)
|
|
||||||
t.iri = t.iri || `${origin}/api/v1/trails/${t.id}`;
|
|
||||||
})
|
|
||||||
|
|
||||||
const formData = objectToFormData({ ...l, id: dbList?.id, expand: undefined, trails: [] })
|
|
||||||
if (l.avatar) {
|
|
||||||
const avatarURL = l.avatar
|
|
||||||
let response = await event.fetch(avatarURL, { method: "GET" })
|
|
||||||
const avatar = await response.blob()
|
|
||||||
|
|
||||||
|
|
||||||
formData.append("avatar", avatar)
|
|
||||||
}
|
|
||||||
if (dbList !== undefined) {
|
|
||||||
dbList = await event.locals.pb.collection("lists").update(dbList.id!, formData)
|
|
||||||
} else {
|
|
||||||
dbList = await event.locals.pb.collection("lists").create(formData)
|
|
||||||
}
|
|
||||||
|
|
||||||
l.id = dbList!.id
|
|
||||||
|
|
||||||
return json(l)
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
|
return json(list)
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
return handleError(e)
|
return handleError(e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -46,8 +46,8 @@ export async function GET(event: RequestEvent) {
|
|||||||
createdAt: actor.published ?? "",
|
createdAt: actor.published ?? "",
|
||||||
bio: actor.summary ?? "",
|
bio: actor.summary ?? "",
|
||||||
uri: actor.iri,
|
uri: actor.iri,
|
||||||
followers: actor.followerCount ?? 0,
|
followers: actor.follower_count ?? 0,
|
||||||
following: actor.followingCount ?? 0,
|
following: actor.following_count ?? 0,
|
||||||
icon: actor.icon ?? "",
|
icon: actor.icon ?? "",
|
||||||
error: actorError ?? undefined
|
error: actorError ?? undefined
|
||||||
}
|
}
|
||||||
|
|||||||
56
web/src/routes/api/v1/profile/[handle]/follows/+server.ts
Normal file
56
web/src/routes/api/v1/profile/[handle]/follows/+server.ts
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import type { Comment } from '$lib/models/comment';
|
||||||
|
import { handleError } from '$lib/util/api_util';
|
||||||
|
import { json, type RequestEvent } from '@sveltejs/kit';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @swagger
|
||||||
|
* /api/v1/profile/{handle}/follows:
|
||||||
|
* get:
|
||||||
|
* summary: Get profile follows
|
||||||
|
* tags:
|
||||||
|
* - Profiles
|
||||||
|
* parameters:
|
||||||
|
* - in: path
|
||||||
|
* name: handle
|
||||||
|
* required: true
|
||||||
|
* schema:
|
||||||
|
* type: string
|
||||||
|
* - in: query
|
||||||
|
* name: page
|
||||||
|
* schema:
|
||||||
|
* type: integer
|
||||||
|
* - in: query
|
||||||
|
* name: perPage
|
||||||
|
* schema:
|
||||||
|
* type: integer
|
||||||
|
* - in: query
|
||||||
|
* name: sort
|
||||||
|
* schema:
|
||||||
|
* type: string
|
||||||
|
* - in: query
|
||||||
|
* name: filter
|
||||||
|
* schema:
|
||||||
|
* type: string
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: List of follows for the profile
|
||||||
|
* content:
|
||||||
|
* application/json:
|
||||||
|
* schema:
|
||||||
|
* $ref: '#/components/schemas/ListResult'
|
||||||
|
* 400:
|
||||||
|
* description: Bad Request
|
||||||
|
* 500:
|
||||||
|
* description: Internal Server Error
|
||||||
|
*/
|
||||||
|
export async function GET(event: RequestEvent) {
|
||||||
|
try {
|
||||||
|
let comments: Comment = await event.locals.pb.send(`/remote/profile/${event.params.handle}/follows?` + event.url.searchParams, {
|
||||||
|
method: "GET",
|
||||||
|
fetch: event.fetch,
|
||||||
|
})
|
||||||
|
return json(comments)
|
||||||
|
} catch (e) {
|
||||||
|
return handleError(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { Actor } from '$lib/models/activitypub/actor';
|
import type { Actor } from '$lib/models/activitypub/actor';
|
||||||
|
import { getActorResponseForHandle } from '$lib/util/activitypub_server_util';
|
||||||
import { splitUsername } from '$lib/util/activitypub_util';
|
import { splitUsername } from '$lib/util/activitypub_util';
|
||||||
import { handleError } from '$lib/util/api_util';
|
import { handleError } from '$lib/util/api_util';
|
||||||
import { error, json, type RequestEvent } from '@sveltejs/kit';
|
import { error, json, type RequestEvent } from '@sveltejs/kit';
|
||||||
@@ -53,10 +54,10 @@ export async function GET(event: RequestEvent) {
|
|||||||
filter += `&& id != "${event.locals.pb.authStore.record.actor}"`
|
filter += `&& id != "${event.locals.pb.authStore.record.actor}"`
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await event.locals.pb.collection("activitypub_actors").getList(1, 3, { filter: filter })
|
const response = await event.locals.pb.collection("activitypub_actors").getList<Actor>(1, 3, { filter: filter })
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { actor, error } = await event.locals.pb.send(`/activitypub/actor?resource=acct:${q}&follows=false`, { method: "GET", fetch: event.fetch, });
|
const { actor } = await getActorResponseForHandle(event, q!);
|
||||||
|
|
||||||
if (!response.items.find(i => i.iri == actor.iri)) {
|
if (!response.items.find(i => i.iri == actor.iri)) {
|
||||||
response.items.push(actor)
|
response.items.push(actor)
|
||||||
|
|||||||
@@ -2,14 +2,12 @@ import { SummitLogCreateSchema } from '$lib/models/api/summit_log_schema';
|
|||||||
import type { SummitLog } from '$lib/models/summit_log';
|
import type { SummitLog } from '$lib/models/summit_log';
|
||||||
import { Collection, create, handleError, list } from '$lib/util/api_util';
|
import { Collection, create, handleError, list } from '$lib/util/api_util';
|
||||||
import { json, type RequestEvent } from '@sveltejs/kit';
|
import { json, type RequestEvent } from '@sveltejs/kit';
|
||||||
import { type ListResult } from "pocketbase";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @swagger
|
* @swagger
|
||||||
* /api/v1/summit-log:
|
* /api/v1/summit-log:
|
||||||
* get:
|
* get:
|
||||||
* summary: List summit logs
|
* summary: List summit logs
|
||||||
* description: Retrieves a paginated list of summit logs with deduplication of federated data
|
|
||||||
* tags:
|
* tags:
|
||||||
* - Summit Logs
|
* - Summit Logs
|
||||||
* parameters:
|
* parameters:
|
||||||
@@ -33,13 +31,9 @@ import { type ListResult } from "pocketbase";
|
|||||||
* name: expand
|
* name: expand
|
||||||
* schema:
|
* schema:
|
||||||
* type: string
|
* type: string
|
||||||
* - in: query
|
|
||||||
* name: handle
|
|
||||||
* schema:
|
|
||||||
* type: string
|
|
||||||
* responses:
|
* responses:
|
||||||
* 200:
|
* 200:
|
||||||
* description: ListResult<SummitLog> with local/remote items deduplicated
|
* description: List of summit logs
|
||||||
* 400:
|
* 400:
|
||||||
* description: Bad Request
|
* description: Bad Request
|
||||||
* 500:
|
* 500:
|
||||||
@@ -47,84 +41,10 @@ import { type ListResult } from "pocketbase";
|
|||||||
*/
|
*/
|
||||||
export async function GET(event: RequestEvent) {
|
export async function GET(event: RequestEvent) {
|
||||||
try {
|
try {
|
||||||
if (!event.url.searchParams.has("handle")) {
|
const summitLogs = await list<SummitLog>(event, Collection.summit_logs);
|
||||||
const summitLogs = await list<SummitLog>(event, Collection.summit_logs);
|
removeTimeFromDates(summitLogs.items)
|
||||||
removeTimeFromDates(summitLogs.items)
|
return json(summitLogs)
|
||||||
return json(summitLogs)
|
|
||||||
} else {
|
|
||||||
const { actor, error } = await event.locals.pb.send(`/activitypub/actor?resource=acct:${event.url.searchParams.get("handle")}`, { method: "GET", fetch: event.fetch, });
|
|
||||||
event.url.searchParams.delete("handle")
|
|
||||||
const localSummitLogs = await list<SummitLog>(event, Collection.summit_logs);
|
|
||||||
if (actor.isLocal) {
|
|
||||||
removeTimeFromDates(localSummitLogs.items)
|
|
||||||
|
|
||||||
return json(localSummitLogs)
|
|
||||||
}
|
|
||||||
|
|
||||||
const deduplicationMap: Record<string, SummitLog> = {}
|
|
||||||
|
|
||||||
localSummitLogs.items.forEach(l => {
|
|
||||||
if (l.iri) {
|
|
||||||
const id = l.iri.substring(l.iri.length - 15)
|
|
||||||
deduplicationMap[id] = l
|
|
||||||
} else if (l.id) {
|
|
||||||
deduplicationMap[l.id] = l
|
|
||||||
}
|
|
||||||
|
|
||||||
l.date = l.date.substring(0, 10);
|
|
||||||
|
|
||||||
})
|
|
||||||
const origin = new URL(actor.iri).origin
|
|
||||||
const url = `${origin}/api/v1/summit-log`
|
|
||||||
|
|
||||||
const response = await event.fetch(url + '?' + event.url.searchParams, { method: 'GET' })
|
|
||||||
if (!response.ok) {
|
|
||||||
const errorResponse = await response.json()
|
|
||||||
console.error(errorResponse)
|
|
||||||
|
|
||||||
}
|
|
||||||
const remoteSummitLogs: ListResult<SummitLog> = await response.json()
|
|
||||||
|
|
||||||
remoteSummitLogs.items = remoteSummitLogs.items.filter(l => {
|
|
||||||
const iriId = l.iri?.substring(l.iri.length - 15) ?? ""
|
|
||||||
if (deduplicationMap[l.id!] != undefined) {
|
|
||||||
deduplicationMap[l.id!] = {...l, author: deduplicationMap[l.id!].author}
|
|
||||||
return false
|
|
||||||
} else if (deduplicationMap[iriId] != undefined) {
|
|
||||||
deduplicationMap[iriId] = {...l, author: deduplicationMap[iriId].author}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
})
|
|
||||||
|
|
||||||
remoteSummitLogs.items.forEach(l => {
|
|
||||||
if (l.gpx) {
|
|
||||||
l.gpx = `${origin}/api/v1/files/summit_logs/${l.id}/${l.gpx}`
|
|
||||||
}
|
|
||||||
l.photos = l.photos.map(p =>
|
|
||||||
`${origin}/api/v1/files/summit_logs/${l.id}/${p}`
|
|
||||||
)
|
|
||||||
|
|
||||||
if (l.expand?.author) {
|
|
||||||
l.expand.author.isLocal = false
|
|
||||||
}
|
|
||||||
|
|
||||||
})
|
|
||||||
|
|
||||||
const allSummitLogItems = <ListResult<SummitLog>>{
|
|
||||||
items: localSummitLogs.items.concat(remoteSummitLogs.items),
|
|
||||||
page: localSummitLogs.page,
|
|
||||||
perPage: localSummitLogs.perPage,
|
|
||||||
totalItems: localSummitLogs.items.length + remoteSummitLogs.items.length,
|
|
||||||
totalPages: Math.ceil((localSummitLogs.items.length + remoteSummitLogs.items.length) / localSummitLogs.perPage)
|
|
||||||
}
|
|
||||||
|
|
||||||
allSummitLogItems.items = allSummitLogItems.items.sort((a, b) => {
|
|
||||||
return new Date(a.created ?? 0).getTime() - new Date(b.created ?? 0).getTime()
|
|
||||||
})
|
|
||||||
|
|
||||||
return json(allSummitLogItems)
|
|
||||||
}
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return handleError(e)
|
return handleError(e)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,18 +1,15 @@
|
|||||||
import { RecordOptionsSchema } from '$lib/models/api/base_schema';
|
|
||||||
import { TrailUpdateSchema } from '$lib/models/api/trail_schema';
|
import { TrailUpdateSchema } from '$lib/models/api/trail_schema';
|
||||||
import type { Trail } from "$lib/models/trail";
|
import type { Trail } from "$lib/models/trail";
|
||||||
import { APIError, Collection, handleError, remove, show, update } from "$lib/util/api_util";
|
import { Collection, handleError, remove, update } from "$lib/util/api_util";
|
||||||
import { objectToFormData } from "$lib/util/file_util";
|
|
||||||
import { json, type RequestEvent } from "@sveltejs/kit";
|
import { json, type RequestEvent } from "@sveltejs/kit";
|
||||||
import type PocketBase from "pocketbase";
|
import type PocketBase from "pocketbase";
|
||||||
import { ClientResponseError } from "pocketbase";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @swagger
|
* @swagger
|
||||||
* /api/v1/trail/{id}:
|
* /api/v1/trail/{id}:
|
||||||
* get:
|
* get:
|
||||||
* summary: Get trail
|
* summary: Get trail
|
||||||
* description: Retrieves a trail by ID. Supports federated queries via handle parameter, fetching from remote instances and remapping file URLs
|
* description: Retrieves a trail by ID
|
||||||
* tags:
|
* tags:
|
||||||
* - Trails
|
* - Trails
|
||||||
* parameters:
|
* parameters:
|
||||||
@@ -26,149 +23,31 @@ import { ClientResponseError } from "pocketbase";
|
|||||||
* schema:
|
* schema:
|
||||||
* type: string
|
* type: string
|
||||||
* - in: query
|
* - in: query
|
||||||
* name: handle
|
|
||||||
* schema:
|
|
||||||
* type: string
|
|
||||||
* - in: query
|
|
||||||
* name: share
|
* name: share
|
||||||
* schema:
|
* schema:
|
||||||
* type: string
|
* type: string
|
||||||
* responses:
|
* responses:
|
||||||
* 200:
|
* 200:
|
||||||
* description: Trail with optional federated data
|
* description: Trail
|
||||||
* 404:
|
* 404:
|
||||||
* description: Not Found
|
* description: Not Found
|
||||||
* 500:
|
* 500:
|
||||||
* description: Internal Server Error
|
* description: Internal Server Error
|
||||||
*/
|
*/
|
||||||
export async function GET(event: RequestEvent) {
|
export async function GET(event: RequestEvent) {
|
||||||
|
const { url, params } = event;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// try to get the trail simply via the
|
let trail: Trail = await event.locals.pb.send(`/remote/trail/${params.id}?` + url.searchParams, {
|
||||||
let t: Trail;
|
method: "GET",
|
||||||
if (!event.url.searchParams.has("handle")) {
|
fetch: event.fetch,
|
||||||
t = await show<Trail>(event, Collection.trails)
|
})
|
||||||
} else {
|
|
||||||
let { actor, error } = await event.locals.pb.send(`/activitypub/actor?resource=acct:${event.url.searchParams.get("handle")}`, { method: "GET", fetch: event.fetch, });
|
|
||||||
event.url.searchParams.delete("handle")
|
|
||||||
|
|
||||||
const safeSearchParams = RecordOptionsSchema.parse(Object.fromEntries(event.url.searchParams));
|
await enrichRecord(event.locals.pb, trail);
|
||||||
if (event.url.searchParams.has("share")) {
|
trail.expand?.waypoints_via_trail?.sort((a, b) => (a.distance_from_start ?? 0) - (b.distance_from_start ?? 0))
|
||||||
safeSearchParams.query = { share: event.url.searchParams.get("share")! }
|
return json(trail)
|
||||||
}
|
|
||||||
let origin = new URL(actor.iri).origin
|
|
||||||
let iri = `${origin}/api/v1/trail/${event.params.id}`
|
|
||||||
|
|
||||||
try {
|
|
||||||
t = await event.locals.pb.collection("trails").getFirstListItem(`iri='${iri}'||id='${event.params.id}'`, {
|
|
||||||
...safeSearchParams
|
|
||||||
})
|
|
||||||
} catch (e) {
|
|
||||||
if (!(e instanceof ClientResponseError) || e.status != 404) {
|
|
||||||
throw e
|
|
||||||
}
|
|
||||||
t = {
|
|
||||||
iri: iri,
|
|
||||||
author: actor.id,
|
|
||||||
like_count: 0
|
|
||||||
} as Trail
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
if (t.iri) {
|
|
||||||
const origin = new URL(t.iri).origin
|
|
||||||
const actor = await event.locals.pb.collection("activitypub_actors").getOne(t.author)
|
|
||||||
|
|
||||||
const localTrailId = t.id;
|
|
||||||
const localTrailIRI = t.iri;
|
|
||||||
const localLikeCount = t.like_count;
|
|
||||||
const localLikes = t.expand?.trail_like_via_trail;
|
|
||||||
|
|
||||||
const response = await event.fetch((t.iri) + '?' + event.url.searchParams, { method: 'GET' })
|
|
||||||
if (!response.ok) {
|
|
||||||
const errorResponse = await response.json()
|
|
||||||
console.error(errorResponse)
|
|
||||||
if (t.id) {
|
|
||||||
return json(t)
|
|
||||||
} else {
|
|
||||||
throw new ClientResponseError({ status: response.status, response: errorResponse })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
t = await response.json()
|
|
||||||
|
|
||||||
// this came directly from the database of the remote instance
|
|
||||||
// we need to adjust some urls to get photos, gpx etc.
|
|
||||||
if (!t.iri) {
|
|
||||||
if (t.gpx) {
|
|
||||||
t.gpx = `${origin}/api/v1/files/trails/${t.id}/${t.gpx}`
|
|
||||||
}
|
|
||||||
t.photos = t.photos.map(p =>
|
|
||||||
`${origin}/api/v1/files/trails/${t.id}/${p}`
|
|
||||||
)
|
|
||||||
t.expand?.summit_logs_via_trail?.forEach(l => {
|
|
||||||
if (l.gpx) {
|
|
||||||
l.gpx = `${origin}/api/v1/files/summit_logs/${l.id}/${l.gpx}`
|
|
||||||
}
|
|
||||||
l.photos = l.photos.map(p =>
|
|
||||||
`${origin}/api/v1/files/summit_logs/${l.id}/${p}`
|
|
||||||
)
|
|
||||||
|
|
||||||
if (l.expand?.author) {
|
|
||||||
l.expand.author.isLocal = false
|
|
||||||
}
|
|
||||||
})
|
|
||||||
t.expand?.waypoints_via_trail?.forEach(w => {
|
|
||||||
|
|
||||||
w.photos = w.photos.map(p =>
|
|
||||||
`${origin}/api/v1/files/waypoints/${w.id}/${p}`
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
t.author = actor.id!
|
|
||||||
t.expand!.author = actor as any
|
|
||||||
t.id = localTrailId
|
|
||||||
t.iri = localTrailIRI
|
|
||||||
t.like_count = localLikeCount
|
|
||||||
t.expand!.trail_like_via_trail = localLikes;
|
|
||||||
}
|
|
||||||
|
|
||||||
let categoryId: string | undefined;
|
|
||||||
try {
|
|
||||||
const category = await event.locals.pb.collection("categories").getFirstListItem(`name='${t.expand?.category?.name}'`)
|
|
||||||
categoryId = category.id;
|
|
||||||
t.expand!.category = category as any
|
|
||||||
} catch (e) { }
|
|
||||||
|
|
||||||
const formData = objectToFormData({ ...t, id: t.id, gpx: undefined, expand: undefined, photos: [], waypoints: [], tags: [], category: categoryId })
|
|
||||||
if (t.photos.length) {
|
|
||||||
const photoURL = t.photos[t.thumbnail ?? 0]
|
|
||||||
let response = await event.fetch(photoURL, { method: "GET" })
|
|
||||||
const photo = await response.blob()
|
|
||||||
formData.append("photos", photo)
|
|
||||||
}
|
|
||||||
if (t.gpx) {
|
|
||||||
const gpxURL = t.gpx
|
|
||||||
const response = await event.fetch(gpxURL, { method: "GET" })
|
|
||||||
const gpx = await response.blob()
|
|
||||||
formData.append("gpx", gpx)
|
|
||||||
}
|
|
||||||
if (t.id) {
|
|
||||||
await event.locals.pb.collection("trails").update(t.id, formData)
|
|
||||||
} else {
|
|
||||||
const createdTrail = await event.locals.pb.collection("trails").create(formData)
|
|
||||||
t.id = createdTrail.id;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// remove time from dates
|
|
||||||
await enrichRecord(event.locals.pb, t);
|
|
||||||
|
|
||||||
// sort waypoints by distance
|
|
||||||
t.expand?.waypoints_via_trail?.sort((a, b) => (a.distance_from_start ?? 0) - (b.distance_from_start ?? 0))
|
|
||||||
return json(t)
|
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
return handleError(e)
|
return handleError(e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
56
web/src/routes/api/v1/trail/[id]/comment/+server.ts
Normal file
56
web/src/routes/api/v1/trail/[id]/comment/+server.ts
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import type { Comment } from '$lib/models/comment';
|
||||||
|
import { handleError } from '$lib/util/api_util';
|
||||||
|
import { json, type RequestEvent } from '@sveltejs/kit';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @swagger
|
||||||
|
* /api/v1/trail/{id}/comment:
|
||||||
|
* get:
|
||||||
|
* summary: Get trail comments
|
||||||
|
* tags:
|
||||||
|
* - Trails
|
||||||
|
* parameters:
|
||||||
|
* - in: path
|
||||||
|
* name: id
|
||||||
|
* required: true
|
||||||
|
* schema:
|
||||||
|
* type: string
|
||||||
|
* - in: query
|
||||||
|
* name: page
|
||||||
|
* schema:
|
||||||
|
* type: integer
|
||||||
|
* - in: query
|
||||||
|
* name: perPage
|
||||||
|
* schema:
|
||||||
|
* type: integer
|
||||||
|
* - in: query
|
||||||
|
* name: sort
|
||||||
|
* schema:
|
||||||
|
* type: string
|
||||||
|
* - in: query
|
||||||
|
* name: filter
|
||||||
|
* schema:
|
||||||
|
* type: string
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: List of comments for the trail
|
||||||
|
* content:
|
||||||
|
* application/json:
|
||||||
|
* schema:
|
||||||
|
* $ref: '#/components/schemas/ListResult'
|
||||||
|
* 400:
|
||||||
|
* description: Bad Request
|
||||||
|
* 500:
|
||||||
|
* description: Internal Server Error
|
||||||
|
*/
|
||||||
|
export async function GET(event: RequestEvent) {
|
||||||
|
try {
|
||||||
|
let comments: Comment = await event.locals.pb.send(`/remote/trail/${event.params.id}/comments?` + event.url.searchParams, {
|
||||||
|
method: "GET",
|
||||||
|
fetch: event.fetch,
|
||||||
|
})
|
||||||
|
return json(comments)
|
||||||
|
} catch (e) {
|
||||||
|
return handleError(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { page } from "$app/state";
|
import { page } from "$app/state";
|
||||||
import { follows_index } from "$lib/stores/follow_store.js";
|
import { profile_follows_index } from "$lib/stores/profile_store.js";
|
||||||
import { show_toast } from "$lib/stores/toast_store.svelte.js";
|
import { show_toast } from "$lib/stores/toast_store.svelte.js";
|
||||||
import { APIError } from "$lib/util/api_util.js";
|
import { APIError } from "$lib/util/api_util.js";
|
||||||
import { untrack } from "svelte";
|
import { untrack } from "svelte";
|
||||||
@@ -37,13 +37,10 @@
|
|||||||
async function loadNextPage() {
|
async function loadNextPage() {
|
||||||
pagination.page += 1;
|
pagination.page += 1;
|
||||||
try {
|
try {
|
||||||
follows = await follows_index(
|
follows = await profile_follows_index(
|
||||||
{
|
page.params.handle!,
|
||||||
type: page.params.type as "followers" | "following",
|
page.params.type as "followers" | "following",
|
||||||
username: page.params.handle!,
|
|
||||||
},
|
|
||||||
pagination.page,
|
pagination.page,
|
||||||
10,
|
|
||||||
fetch,
|
fetch,
|
||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -51,8 +48,8 @@
|
|||||||
show_toast({
|
show_toast({
|
||||||
icon: "close",
|
icon: "close",
|
||||||
text: `${e.status}: ${e.message}`,
|
text: `${e.status}: ${e.message}`,
|
||||||
type: "error"
|
type: "error",
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { follows_index } from "$lib/stores/follow_store";
|
import { follows_index } from "$lib/stores/follow_store";
|
||||||
|
import { profile_follows_index } from "$lib/stores/profile_store";
|
||||||
import { APIError } from "$lib/util/api_util";
|
import { APIError } from "$lib/util/api_util";
|
||||||
import { error, type Load } from "@sveltejs/kit";
|
import { error, type Load } from "@sveltejs/kit";
|
||||||
import { ClientResponseError } from "pocketbase";
|
import { ClientResponseError } from "pocketbase";
|
||||||
@@ -9,8 +10,12 @@ export const load: Load = async ({ params, fetch }) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const follows = await follows_index({ type: params.type, username: params.handle! },
|
const follows = await profile_follows_index(
|
||||||
1, 10, fetch)
|
params.handle!,
|
||||||
|
params.type as "followers" | "following",
|
||||||
|
1,
|
||||||
|
fetch,
|
||||||
|
);
|
||||||
return { follows }
|
return { follows }
|
||||||
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
Reference in New Issue
Block a user