Federation Refactoring & Architecture Improvements (#930)
* initial commit * add lists * update permissions * fix waypoint create * needs_full_sync for activitypub trails * fixes build issues * fix list get * further CSRF protection * update API docs * adds rate limiter * fix tiptap mentions * a bit more cleanup of main.go * Fix migration order * fixes reviewed notes * require context for activitpub server calls * improve hashing for identifier * fix copy paste error * fix sync trail/list issues * fix summit log/comment duplicates * adaptions after trail merge * fix dockerignore --------- Co-authored-by: Christian Beutel <> Co-authored-by: slothful-vassal <89943360+slothful-vassal@users.noreply.github.com>
This commit is contained in:
@@ -1,13 +1,12 @@
|
||||
*
|
||||
!commands
|
||||
!federation
|
||||
!hooks
|
||||
!go.*
|
||||
!integrations
|
||||
!main.go
|
||||
!trail_merge_routes.go
|
||||
!migrations
|
||||
!routes
|
||||
!templates
|
||||
!trailmerge
|
||||
!waypointcluster
|
||||
!waypointcluster/**
|
||||
!services
|
||||
!util
|
||||
|
||||
@@ -4,12 +4,9 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/x509"
|
||||
"database/sql"
|
||||
"encoding/pem"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"slices"
|
||||
"strings"
|
||||
@@ -127,105 +124,3 @@ func PostActivity(app core.App, actor *core.Record, activity *pub.Activity, reci
|
||||
}()
|
||||
return nil
|
||||
}
|
||||
|
||||
func ProcessActivity(e *core.RequestEvent) error {
|
||||
origin := os.Getenv("ORIGIN")
|
||||
if origin == "" {
|
||||
return fmt.Errorf("ORIGIN not set")
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(e.Request.Body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var activity pub.Activity
|
||||
activity.UnmarshalJSON(body)
|
||||
|
||||
inbox := fmt.Sprintf("%s%s", origin, e.Request.Header.Get("X-Forwarded-Path"))
|
||||
|
||||
recipient, err := e.App.FindFirstRecordByData("activitypub_actors", "inbox", inbox)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
actor, err := e.App.FindFirstRecordByData("activitypub_actors", "iri", activity.Actor.GetID().String())
|
||||
if err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
actor, err = GetActorByIRI(e.App, recipient, activity.Actor.GetID().String(), false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
return err
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
verified, err := verifySignature(e.App, e.Request, actor.GetString("public_key"))
|
||||
if err != nil || !verified {
|
||||
e.App.Logger().Error(err.Error())
|
||||
return e.UnauthorizedError("Invalid http signature", err)
|
||||
}
|
||||
|
||||
switch activity.Type {
|
||||
case pub.FollowType:
|
||||
err = ProcessFollowActivity(e.App, actor, activity)
|
||||
case pub.AcceptType:
|
||||
err = ProcessAcceptActivity(e.App, actor, activity)
|
||||
case pub.UndoType:
|
||||
err = ProcessUndoActivity(e.App, actor, activity)
|
||||
case pub.UpdateType:
|
||||
fallthrough
|
||||
case pub.CreateType:
|
||||
err = ProcessCreateOrUpdateActivity(e.App, actor, recipient, activity)
|
||||
case pub.DeleteType:
|
||||
err = ProcessDeleteActivity(e.App, actor, activity)
|
||||
case pub.AnnounceType:
|
||||
err = ProcessAnnounceActivity(e.App, actor, activity)
|
||||
case pub.LikeType:
|
||||
err = ProcessLikeActivity(e.App, actor, activity)
|
||||
}
|
||||
return e.JSON(http.StatusOK, err)
|
||||
}
|
||||
|
||||
func verifySignature(app core.App, req *http.Request, publicKeyPem string) (bool, error) {
|
||||
origin := os.Getenv("ORIGIN")
|
||||
if origin == "" {
|
||||
return false, fmt.Errorf("ORIGIN not set")
|
||||
}
|
||||
block, _ := pem.Decode([]byte(publicKeyPem))
|
||||
if block == nil || block.Type != "PUBLIC KEY" {
|
||||
return false, fmt.Errorf("could not decode publicKeyPem to PUBLIC KEY pem block type")
|
||||
}
|
||||
|
||||
req.URL = &url.URL{
|
||||
Path: req.Header.Get("X-Forwarded-Path"),
|
||||
}
|
||||
|
||||
url, err := url.Parse(origin)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
req.Header.Set("Host", url.Host)
|
||||
req.Host = url.Host
|
||||
|
||||
app.Logger().Info(req.Header.Get("signature"))
|
||||
|
||||
publicKey, err := x509.ParsePKIXPublicKey(block.Bytes)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
v, err := httpsig.NewVerifier(req)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
err = v.Verify(publicKey, httpsig.RSA_SHA256)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
return true, nil
|
||||
}
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
package federation
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/x509"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"pocketbase/util"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -21,6 +24,7 @@ import (
|
||||
)
|
||||
|
||||
var ErrProfilePrivate = errors.New("profile is private")
|
||||
var ErrInvalidActorResponse = errors.New("invalid or incomplete actor response")
|
||||
|
||||
type WebfingerResponse struct {
|
||||
Subject string `json:"subject"`
|
||||
@@ -30,24 +34,36 @@ type WebfingerResponse struct {
|
||||
} `json:"links"`
|
||||
}
|
||||
|
||||
func SplitHandle(handle string) (string, string) {
|
||||
|
||||
cleaned := strings.TrimPrefix(handle, "@")
|
||||
cleaned = strings.TrimSpace(cleaned)
|
||||
|
||||
if !strings.Contains(cleaned, "@") {
|
||||
return cleaned, ""
|
||||
func validateActorResponse(actor *pub.Actor) error {
|
||||
if actor == nil {
|
||||
return ErrInvalidActorResponse
|
||||
}
|
||||
|
||||
parts := strings.SplitN(cleaned, "@", 2)
|
||||
user := parts[0]
|
||||
domain := parts[1]
|
||||
if actor.GetID().String() == "" {
|
||||
return fmt.Errorf("%w: missing ID", ErrInvalidActorResponse)
|
||||
}
|
||||
|
||||
return user, domain
|
||||
if actor.PreferredUsername.String() == "" && actor.Name.String() == "" {
|
||||
return fmt.Errorf("%w: missing username or name", ErrInvalidActorResponse)
|
||||
}
|
||||
|
||||
if util.ItemID(actor.Inbox) == "" {
|
||||
return fmt.Errorf("%w: missing inbox", ErrInvalidActorResponse)
|
||||
}
|
||||
|
||||
if util.ItemID(actor.Outbox) == "" {
|
||||
return fmt.Errorf("%w: missing outbox", ErrInvalidActorResponse)
|
||||
}
|
||||
|
||||
if actor.PublicKey.PublicKeyPem == "" {
|
||||
return fmt.Errorf("%w: missing public key", ErrInvalidActorResponse)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func GetActorByHandle(app core.App, actor *core.Record, handle string, includeFollows bool) (*core.Record, error) {
|
||||
username, domain := SplitHandle(handle)
|
||||
func GetActorByHandle(app core.App, ctx context.Context, handle string, includeFollows bool) (*core.Record, error) {
|
||||
username, domain := util.SplitHandle(handle)
|
||||
|
||||
filter := "preferred_username={:username}&&"
|
||||
if domain != "" {
|
||||
@@ -66,7 +82,7 @@ func GetActorByHandle(app core.App, actor *core.Record, handle string, includeFo
|
||||
|
||||
dbActor = core.NewRecord(collection)
|
||||
dbActor.Set("isLocal", false)
|
||||
iri, err := iriFromHandle(domain, username)
|
||||
iri, err := iriFromHandle(ctx, domain, username)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -76,10 +92,10 @@ func GetActorByHandle(app core.App, actor *core.Record, handle string, includeFo
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return assembleActor(actor, dbActor, app, includeFollows)
|
||||
return assembleActor(app, ctx, dbActor, includeFollows || dbActor.Id == "")
|
||||
}
|
||||
|
||||
func GetActorByIRI(app core.App, actor *core.Record, iri string, includeFollows bool) (*core.Record, error) {
|
||||
func GetActorByIRI(app core.App, ctx context.Context, iri string, includeFollows bool) (*core.Record, error) {
|
||||
var dbActor *core.Record
|
||||
dbActor, err := app.FindFirstRecordByFilter("activitypub_actors", "iri={:iri}", dbx.Params{"iri": iri})
|
||||
if err != nil && err == sql.ErrNoRows {
|
||||
@@ -96,33 +112,55 @@ func GetActorByIRI(app core.App, actor *core.Record, iri string, includeFollows
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return assembleActor(actor, dbActor, app, includeFollows)
|
||||
return assembleActor(app, ctx, dbActor, includeFollows || dbActor.Id == "")
|
||||
}
|
||||
|
||||
func iriFromHandle(domain string, username string) (string, error) {
|
||||
client := &http.Client{}
|
||||
func iriFromHandle(ctx context.Context, domain string, username string) (string, error) {
|
||||
client := util.SafeHTTPClient()
|
||||
|
||||
webfingerURL := fmt.Sprintf("https://%s/.well-known/webfinger?resource=acct:%s@%s", domain, username, domain)
|
||||
resp, err := client.Get(webfingerURL)
|
||||
if err != nil || resp.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("webfinger request failed: %v", err)
|
||||
u := &url.URL{
|
||||
Scheme: "https",
|
||||
Host: domain,
|
||||
Path: "/.well-known/webfinger",
|
||||
}
|
||||
q := u.Query()
|
||||
q.Set("resource", fmt.Sprintf("acct:%s@%s", username, domain))
|
||||
u.RawQuery = q.Encode()
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", u.String(), nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("webfinger request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("unexpected status: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
limitedReader := io.LimitReader(resp.Body, 102400)
|
||||
|
||||
var wf WebfingerResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&wf); err != nil {
|
||||
return "", err
|
||||
if err := json.NewDecoder(limitedReader).Decode(&wf); err != nil {
|
||||
return "", fmt.Errorf("failed to decode JSON: %w", err)
|
||||
}
|
||||
|
||||
for _, link := range wf.Links {
|
||||
if link.Rel == "self" {
|
||||
if _, err := url.Parse(link.Href); err != nil {
|
||||
return "", fmt.Errorf("invalid IRI in response")
|
||||
}
|
||||
return link.Href, nil
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("no iri in response")
|
||||
}
|
||||
|
||||
func assembleActor(actor *core.Record, dbActor *core.Record, app core.App, includeFollows bool) (*core.Record, error) {
|
||||
func assembleActor(app core.App, ctx context.Context, dbActor *core.Record, includeFollows bool) (*core.Record, error) {
|
||||
origin := os.Getenv("ORIGIN")
|
||||
if origin == "" {
|
||||
return nil, fmt.Errorf("ORIGIN environment variable not set")
|
||||
@@ -147,12 +185,12 @@ func assembleActor(actor *core.Record, dbActor *core.Record, app core.App, inclu
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
dbActor.Set("followerCount", followerCount)
|
||||
dbActor.Set("follower_count", followerCount)
|
||||
followingCount, err := app.CountRecords("follows", dbx.NewExp("follower={:user} AND status='accepted'", dbx.Params{"user": dbActor.Id}))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
dbActor.Set("followingCount", followingCount)
|
||||
dbActor.Set("following_count", followingCount)
|
||||
|
||||
dbActor.Set("last_fetched", time.Now())
|
||||
|
||||
@@ -165,11 +203,11 @@ func assembleActor(actor *core.Record, dbActor *core.Record, app core.App, inclu
|
||||
} else {
|
||||
|
||||
// check if value is still cached
|
||||
twoHoursAgo := time.Now().Add(-2 * time.Hour)
|
||||
if !includeFollows && dbActor.GetDateTime("last_fetched").Time().After(twoHoursAgo) {
|
||||
twoHoursAgo := time.Now().UTC().Add(-2 * time.Hour)
|
||||
if dbActor.GetDateTime("last_fetched").Time().After(twoHoursAgo) {
|
||||
return dbActor, nil
|
||||
}
|
||||
pubActor, followers, following, err := fetchRemoteActor(actor, dbActor.GetString("iri"), includeFollows)
|
||||
pubActor, followers, following, err := fetchRemoteActor(app, ctx, dbActor.GetString("iri"), includeFollows)
|
||||
if err != nil {
|
||||
if dbActor.Id != "" {
|
||||
return dbActor, err
|
||||
@@ -191,36 +229,37 @@ func assembleActor(actor *core.Record, dbActor *core.Record, app core.App, inclu
|
||||
}
|
||||
domain := strings.TrimPrefix(parsedUrl.Hostname(), "www.")
|
||||
|
||||
// this is a race condition that gets triggered when the profile is opened for the first time
|
||||
existingActor, _ := app.FindFirstRecordByData("activitypub_actors", "iri", dbActor.GetString("iri"))
|
||||
|
||||
if existingActor != nil {
|
||||
dbActor = existingActor
|
||||
}
|
||||
|
||||
dbActor.Set("domain", domain)
|
||||
dbActor.Set("followers", pubActor.Followers.GetID().String())
|
||||
dbActor.Set("inbox", pubActor.Inbox.GetID().String())
|
||||
dbActor.Set("followers", util.ItemID(pubActor.Followers))
|
||||
dbActor.Set("inbox", util.ItemID(pubActor.Inbox))
|
||||
dbActor.Set("iri", pubActor.GetID().String())
|
||||
dbActor.Set("username", pubActor.Name.String())
|
||||
dbActor.Set("preferred_username", pubActor.PreferredUsername.String())
|
||||
dbActor.Set("following", pubActor.Following.GetID().String())
|
||||
dbActor.Set("following", util.ItemID(pubActor.Following))
|
||||
dbActor.Set("summary", pubActor.Summary.String())
|
||||
dbActor.Set("outbox", pubActor.Outbox.GetID().String())
|
||||
dbActor.Set("outbox", util.ItemID(pubActor.Outbox))
|
||||
dbActor.Set("icon", icon)
|
||||
dbActor.Set("published", pubActor.Published.String())
|
||||
dbActor.Set("public_key", pubActor.PublicKey.PublicKeyPem)
|
||||
dbActor.Set("last_fetched", time.Now())
|
||||
|
||||
if includeFollows {
|
||||
dbActor.Set("followerCount", int(followers.TotalItems))
|
||||
dbActor.Set("followingCount", int(following.TotalItems))
|
||||
dbActor.Set("follower_count", int(followers.TotalItems))
|
||||
dbActor.Set("following_count", int(following.TotalItems))
|
||||
}
|
||||
}
|
||||
|
||||
err := app.Save(dbActor)
|
||||
if err != nil && err.Error() == "iri: Value must be unique." {
|
||||
dbActor, err = app.FindFirstRecordByData("activitypub_actors", "iri", dbActor.GetString("iri"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return dbActor, nil
|
||||
} else if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if private {
|
||||
return dbActor, ErrProfilePrivate
|
||||
@@ -230,14 +269,15 @@ func assembleActor(actor *core.Record, dbActor *core.Record, app core.App, inclu
|
||||
}
|
||||
|
||||
// Fetches an AP actor and optionally followers/following collections
|
||||
func fetchRemoteActor(actor *core.Record, iri string, includeFollows bool) (*pub.Actor, *pub.OrderedCollection, *pub.OrderedCollection, error) {
|
||||
func fetchRemoteActor(app core.App, ctx context.Context, iri string, includeFollows bool) (*pub.Actor, *pub.OrderedCollection, *pub.OrderedCollection, error) {
|
||||
encryptionKey := os.Getenv("POCKETBASE_ENCRYPTION_KEY")
|
||||
if len(encryptionKey) == 0 {
|
||||
return nil, nil, nil, fmt.Errorf("POCKETBASE_ENCRYPTION_KEY not set")
|
||||
}
|
||||
|
||||
client := &http.Client{}
|
||||
req, _ := http.NewRequest("GET", iri, nil)
|
||||
client := util.SafeHTTPClient()
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", iri, nil)
|
||||
|
||||
headers := map[string]string{
|
||||
"Accept": "application/ld+json",
|
||||
@@ -250,8 +290,10 @@ func fetchRemoteActor(actor *core.Record, iri string, includeFollows bool) (*pub
|
||||
req.Header.Add(k, v)
|
||||
}
|
||||
|
||||
if actor != nil && actor.GetString("private_key") != "" {
|
||||
dbPrivateKey := actor.GetString("private_key")
|
||||
userActorId := strings.TrimPrefix(ctx.Value("actor").(string), "actor:")
|
||||
userActor, err := app.FindRecordById("activitypub_actors", userActorId)
|
||||
if userActor != nil && userActor.GetString("private_key") != "" {
|
||||
dbPrivateKey := userActor.GetString("private_key")
|
||||
|
||||
algs := []httpsig.Algorithm{httpsig.RSA_SHA256}
|
||||
postHeaders := []string{"(request-target)", "Date", "Digest", "Content-Type", "Host"}
|
||||
@@ -271,7 +313,7 @@ func fetchRemoteActor(actor *core.Record, iri string, includeFollows bool) (*pub
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
|
||||
pubID := actor.GetString("iri") + "#main-key"
|
||||
pubID := userActor.GetString("iri") + "#main-key"
|
||||
|
||||
if err := signer.SignRequest(privateKey, pubID, req, []byte{}); err != nil {
|
||||
return nil, nil, nil, err
|
||||
@@ -293,16 +335,21 @@ func fetchRemoteActor(actor *core.Record, iri string, includeFollows bool) (*pub
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
|
||||
// Validate actor response has required fields
|
||||
if err := validateActorResponse(&pubActor); err != nil {
|
||||
return nil, nil, nil, fmt.Errorf("actor validation failed for %s: %w", iri, err)
|
||||
}
|
||||
|
||||
var followers, following pub.OrderedCollection
|
||||
|
||||
if includeFollows {
|
||||
// Fetch followers
|
||||
if data, err := FetchCollection(actor, pubActor.Followers.GetID().String()); err == nil {
|
||||
if data, err := FetchCollection(app, ctx, util.ItemID(pubActor.Followers)); err == nil {
|
||||
followers = *data
|
||||
}
|
||||
|
||||
// Fetch following
|
||||
if data, err := FetchCollection(actor, pubActor.Following.GetID().String()); err == nil {
|
||||
if data, err := FetchCollection(app, ctx, util.ItemID(pubActor.Following)); err == nil {
|
||||
following = *data
|
||||
}
|
||||
}
|
||||
@@ -310,12 +357,13 @@ func fetchRemoteActor(actor *core.Record, iri string, includeFollows bool) (*pub
|
||||
return &pubActor, &followers, &following, nil
|
||||
}
|
||||
|
||||
func FetchCollection(actor *core.Record, url string) (*pub.OrderedCollection, error) {
|
||||
func FetchCollection(app core.App, ctx context.Context, collectionURL string) (*pub.OrderedCollection, error) {
|
||||
encryptionKey := os.Getenv("POCKETBASE_ENCRYPTION_KEY")
|
||||
if len(encryptionKey) == 0 {
|
||||
return nil, fmt.Errorf("POCKETBASE_ENCRYPTION_KEY not set")
|
||||
}
|
||||
req, _ := http.NewRequest("GET", url, nil)
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", collectionURL, nil)
|
||||
|
||||
headers := map[string]string{
|
||||
"Accept": "application/ld+json",
|
||||
@@ -327,9 +375,10 @@ func FetchCollection(actor *core.Record, url string) (*pub.OrderedCollection, er
|
||||
for k, v := range headers {
|
||||
req.Header.Add(k, v)
|
||||
}
|
||||
|
||||
if actor != nil && actor.GetString("private_key") != "" {
|
||||
dbPrivateKey := actor.GetString("private_key")
|
||||
userActorId := strings.TrimPrefix(ctx.Value("actor").(string), "actor:")
|
||||
userActor, err := app.FindRecordById("activitypub_actors", userActorId)
|
||||
if userActor != nil && userActor.GetString("private_key") != "" {
|
||||
dbPrivateKey := userActor.GetString("private_key")
|
||||
if dbPrivateKey != "" {
|
||||
algs := []httpsig.Algorithm{httpsig.RSA_SHA256}
|
||||
postHeaders := []string{"(request-target)", "Date", "Digest", "Content-Type", "Host"}
|
||||
@@ -349,7 +398,7 @@ func FetchCollection(actor *core.Record, url string) (*pub.OrderedCollection, er
|
||||
return nil, err
|
||||
}
|
||||
|
||||
pubID := actor.GetString("iri") + "#main-key"
|
||||
pubID := userActor.GetString("iri") + "#main-key"
|
||||
|
||||
if err := signer.SignRequest(privateKey, pubID, req, []byte{}); err != nil {
|
||||
return nil, err
|
||||
@@ -358,15 +407,16 @@ func FetchCollection(actor *core.Record, url string) (*pub.OrderedCollection, er
|
||||
}
|
||||
}
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
client := util.SafeHTTPClient()
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("collection fetch failed for %s: %v", url, err)
|
||||
return nil, fmt.Errorf("collection fetch failed for %s: %v", collectionURL, err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
if resp.StatusCode == http.StatusNotFound {
|
||||
return nil, ErrProfilePrivate
|
||||
}
|
||||
return nil, fmt.Errorf("collection fetch %s returned: %v", url, resp.StatusCode)
|
||||
return nil, fmt.Errorf("collection fetch %s returned: %v", collectionURL, resp.StatusCode)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ import (
|
||||
"golang.org/x/net/html"
|
||||
)
|
||||
|
||||
func CreateTrailActivity(app core.App, actor *core.Record, trail *core.Record, typ pub.ActivityVocabularyType) error {
|
||||
func CreateTrailActivity(app core.App, ctx context.Context, trail *core.Record, typ pub.ActivityVocabularyType) error {
|
||||
if !trail.GetBool("public") {
|
||||
// only broadcast the trail if it is public
|
||||
return nil
|
||||
@@ -46,7 +46,7 @@ func CreateTrailActivity(app core.App, actor *core.Record, trail *core.Record, t
|
||||
id := fmt.Sprintf("%s/api/v1/activitypub/activity/%s", origin, recordId)
|
||||
to := "https://www.w3.org/ns/activitystreams#Public"
|
||||
|
||||
mentionedActors, err := ActorsFromMentions(app, actor, trail.GetString("description"))
|
||||
mentionedActors, err := ActorsFromMentions(app, ctx, trail.GetString("description"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -108,7 +108,7 @@ func CreateTrailActivity(app core.App, actor *core.Record, trail *core.Record, t
|
||||
return PostActivity(app, trailAuthor, activity, recipients)
|
||||
}
|
||||
|
||||
func CreateCommentActivity(app core.App, actor *core.Record, comment *core.Record, typ pub.ActivityVocabularyType) error {
|
||||
func CreateCommentActivity(app core.App, ctx context.Context, comment *core.Record, typ pub.ActivityVocabularyType) error {
|
||||
origin := os.Getenv("ORIGIN")
|
||||
if origin == "" {
|
||||
return fmt.Errorf("ORIGIN not set")
|
||||
@@ -134,7 +134,7 @@ func CreateCommentActivity(app core.App, actor *core.Record, comment *core.Recor
|
||||
id := fmt.Sprintf("%s/api/v1/activitypub/activity/%s", origin, activityRecordId)
|
||||
to := "https://www.w3.org/ns/activitystreams#Public"
|
||||
|
||||
mentionedActors, err := ActorsFromMentions(app, actor, comment.GetString("text"))
|
||||
mentionedActors, err := ActorsFromMentions(app, ctx, comment.GetString("text"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -193,7 +193,7 @@ func CreateCommentActivity(app core.App, actor *core.Record, comment *core.Recor
|
||||
|
||||
}
|
||||
|
||||
func CreateSummitLogActivity(app core.App, actor *core.Record, summitLog *core.Record, typ pub.ActivityVocabularyType) error {
|
||||
func CreateSummitLogActivity(app core.App, ctx context.Context, summitLog *core.Record, typ pub.ActivityVocabularyType) error {
|
||||
|
||||
origin := os.Getenv("ORIGIN")
|
||||
if origin == "" {
|
||||
@@ -245,7 +245,7 @@ func CreateSummitLogActivity(app core.App, actor *core.Record, summitLog *core.R
|
||||
to.Append(pub.IRI(summitLogTrailAuthor.GetString("iri")))
|
||||
}
|
||||
|
||||
mentionedActors, err := ActorsFromMentions(app, actor, summitLog.GetString("text"))
|
||||
mentionedActors, err := ActorsFromMentions(app, ctx, summitLog.GetString("text"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -807,7 +807,7 @@ func processCreateOrUpdateListActivity(activity pub.Activity, app core.App, acto
|
||||
return err
|
||||
}
|
||||
|
||||
func ActorsFromMentions(app core.App, actor *core.Record, htmlStr string) ([]*core.Record, error) {
|
||||
func ActorsFromMentions(app core.App, ctx context.Context, htmlStr string) ([]*core.Record, error) {
|
||||
doc, err := html.Parse(strings.NewReader(htmlStr))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -842,7 +842,7 @@ func ActorsFromMentions(app core.App, actor *core.Record, htmlStr string) ([]*co
|
||||
f(doc)
|
||||
|
||||
for _, h := range handles {
|
||||
actor, err := GetActorByHandle(app, actor, h, false)
|
||||
actor, err := GetActorByHandle(app, ctx, h, false)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -15,7 +15,10 @@ import (
|
||||
)
|
||||
|
||||
func CreateTrailDeleteActivity(app core.App, r *core.Record) error {
|
||||
|
||||
if !r.GetBool("public") {
|
||||
// only broadcast the trail if it is public
|
||||
return nil
|
||||
}
|
||||
origin := os.Getenv("ORIGIN")
|
||||
if origin == "" {
|
||||
return fmt.Errorf("ORIGIN not set")
|
||||
|
||||
22
db/hooks/api_tokens.go
Normal file
22
db/hooks/api_tokens.go
Normal file
@@ -0,0 +1,22 @@
|
||||
package hooks
|
||||
|
||||
import (
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/tools/security"
|
||||
)
|
||||
|
||||
func CreateAPITokenHandler() func(e *core.RecordEvent) error {
|
||||
return func(e *core.RecordEvent) error {
|
||||
rawToken := "wanderer_key_" + security.RandomString(32)
|
||||
|
||||
hashedKey := security.SHA256(rawToken)
|
||||
|
||||
e.Record.Set("token", hashedKey)
|
||||
|
||||
// Temporarily store rawToken so we can display it once to the user
|
||||
e.Record.WithCustomData(true)
|
||||
e.Record.Set("rawToken", rawToken)
|
||||
|
||||
return e.Next()
|
||||
}
|
||||
}
|
||||
47
db/hooks/bootstrap.go
Normal file
47
db/hooks/bootstrap.go
Normal file
@@ -0,0 +1,47 @@
|
||||
package hooks
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"os"
|
||||
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/spf13/cast"
|
||||
)
|
||||
|
||||
func OnBootstrapHandler() func(se *core.BootstrapEvent) error {
|
||||
return func(e *core.BootstrapEvent) error {
|
||||
if err := e.Next(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if e.App.Settings().Meta.AppName == "Acme" {
|
||||
e.App.Settings().Meta.AppName = "wanderer"
|
||||
}
|
||||
if v := os.Getenv("ORIGIN"); v != "" {
|
||||
e.App.Settings().Meta.AppURL = v
|
||||
}
|
||||
if v := cmp.Or(os.Getenv("POCKETBASE_SMTP_SENDER_ADDRESS"), os.Getenv("POCKETBASE_SMTP_SENDER_ADRESS")); v != "" {
|
||||
e.App.Settings().Meta.SenderAddress = v
|
||||
}
|
||||
if v := os.Getenv("POCKETBASE_SMTP_SENDER_NAME"); v != "" {
|
||||
e.App.Settings().Meta.SenderName = v
|
||||
}
|
||||
if v := os.Getenv("POCKETBASE_SMTP_ENABLED"); v != "" {
|
||||
e.App.Settings().SMTP.Enabled = cast.ToBool(v)
|
||||
}
|
||||
if v := os.Getenv("POCKETBASE_SMTP_HOST"); v != "" {
|
||||
e.App.Settings().SMTP.Host = v
|
||||
}
|
||||
if v := os.Getenv("POCKETBASE_SMTP_PORT"); v != "" {
|
||||
e.App.Settings().SMTP.Port = cast.ToInt(v)
|
||||
}
|
||||
if v := os.Getenv("POCKETBASE_SMTP_USERNAME"); v != "" {
|
||||
e.App.Settings().SMTP.Username = v
|
||||
}
|
||||
if v := os.Getenv("POCKETBASE_SMTP_PASSWORD"); v != "" {
|
||||
e.App.Settings().SMTP.Password = v
|
||||
}
|
||||
|
||||
return e.App.Save(e.App.Settings())
|
||||
}
|
||||
}
|
||||
65
db/hooks/comments.go
Normal file
65
db/hooks/comments.go
Normal file
@@ -0,0 +1,65 @@
|
||||
package hooks
|
||||
|
||||
import (
|
||||
"pocketbase/federation"
|
||||
"pocketbase/util"
|
||||
|
||||
pub "github.com/go-ap/activitypub"
|
||||
"github.com/meilisearch/meilisearch-go"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
)
|
||||
|
||||
func CreateCommentHandler() func(e *core.RecordRequestEvent) error {
|
||||
return func(e *core.RecordRequestEvent) error {
|
||||
|
||||
e.Next()
|
||||
|
||||
userActor, err := e.App.FindFirstRecordByData("activitypub_actors", "user", e.Auth.Id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ctx, err := util.GetSafeActorContext(e.Request, userActor)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = federation.CreateCommentActivity(e.App, ctx, e.Record, pub.CreateType)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func UpdateCommentHandler() func(e *core.RecordRequestEvent) error {
|
||||
return func(e *core.RecordRequestEvent) error {
|
||||
userActor, err := e.App.FindFirstRecordByData("activitypub_actors", "user", e.Auth.Id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ctx, err := util.GetSafeActorContext(e.Request, userActor)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = federation.CreateCommentActivity(e.App, ctx, e.Record, pub.UpdateType)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return e.Next()
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
func DeleteCommentHandler(client meilisearch.ServiceManager) func(e *core.RecordRequestEvent) error {
|
||||
return func(e *core.RecordRequestEvent) error {
|
||||
|
||||
err := federation.CreateCommentDeleteActivity(e.App, client, e.Record)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return e.Next()
|
||||
}
|
||||
}
|
||||
57
db/hooks/feed.go
Normal file
57
db/hooks/feed.go
Normal file
@@ -0,0 +1,57 @@
|
||||
package hooks
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"pocketbase/util"
|
||||
"strings"
|
||||
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
)
|
||||
|
||||
func ListFeedHandler() func(e *core.RecordsListRequestEvent) error {
|
||||
return func(e *core.RecordsListRequestEvent) error {
|
||||
|
||||
for _, r := range e.Records {
|
||||
var item *core.Record
|
||||
var err error
|
||||
|
||||
typ := r.GetString("type")
|
||||
typ = strings.Trim(typ, "\"")
|
||||
|
||||
itemId := r.GetString("item")
|
||||
itemId = strings.Trim(itemId, "\"")
|
||||
|
||||
switch typ {
|
||||
case string(util.TrailFeed):
|
||||
item, err = e.App.FindRecordById("trails", itemId)
|
||||
case string(util.ListFeed):
|
||||
item, err = e.App.FindRecordById("lists", itemId)
|
||||
case string(util.SummitLogFeed):
|
||||
item, err = e.App.FindRecordById("summit_logs", itemId)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if item == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
errs := e.App.ExpandRecord(item, []string{"author"}, nil)
|
||||
if len(errs) > 0 {
|
||||
return fmt.Errorf("failed to expand author: %v", errs)
|
||||
}
|
||||
|
||||
if typ == string(util.TrailFeed) {
|
||||
errs := e.App.ExpandRecord(item, []string{"category"}, nil)
|
||||
if len(errs) > 0 {
|
||||
return fmt.Errorf("failed to expand category: %v", errs)
|
||||
}
|
||||
}
|
||||
|
||||
r.MergeExpand(map[string]any{"item": item})
|
||||
}
|
||||
return e.Next()
|
||||
}
|
||||
}
|
||||
24
db/hooks/follow.go
Normal file
24
db/hooks/follow.go
Normal file
@@ -0,0 +1,24 @@
|
||||
package hooks
|
||||
|
||||
import (
|
||||
"pocketbase/federation"
|
||||
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
)
|
||||
|
||||
func CreateFollowHandler() func(e *core.RecordRequestEvent) error {
|
||||
return func(e *core.RecordRequestEvent) error {
|
||||
e.Next()
|
||||
federation.CreateFollowActivity(e.App, e.Record)
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func DeleteFollowHandler() func(e *core.RecordRequestEvent) error {
|
||||
return func(e *core.RecordRequestEvent) error {
|
||||
federation.CreateUnfollowActivity(e.App, e.Record)
|
||||
|
||||
return e.Next()
|
||||
}
|
||||
}
|
||||
146
db/hooks/integrations.go
Normal file
146
db/hooks/integrations.go
Normal file
@@ -0,0 +1,146 @@
|
||||
package hooks
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"pocketbase/util"
|
||||
|
||||
"github.com/pocketbase/pocketbase/apis"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/tools/security"
|
||||
)
|
||||
|
||||
func ListIntegrationHandler() func(e *core.RecordsListRequestEvent) error {
|
||||
return func(e *core.RecordsListRequestEvent) error {
|
||||
if e.HasSuperuserAuth() {
|
||||
return e.Next()
|
||||
}
|
||||
for _, r := range e.Records {
|
||||
|
||||
err := censorIntegrationSecrets(r)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return e.Next()
|
||||
}
|
||||
}
|
||||
|
||||
func CreateIntegrationHandler() func(e *core.RecordEvent) error {
|
||||
return func(e *core.RecordEvent) error {
|
||||
err := encryptIntegrationSecrets(e.App, e.Record)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return e.Next()
|
||||
}
|
||||
}
|
||||
|
||||
func CreateUpdateIntegrationSuccessHandler() func(e *core.RecordEvent) error {
|
||||
return func(e *core.RecordEvent) error {
|
||||
err := censorIntegrationSecrets(e.Record)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return e.Next()
|
||||
}
|
||||
}
|
||||
|
||||
func UpdateIntegrationHandler() func(e *core.RecordEvent) error {
|
||||
return func(e *core.RecordEvent) error {
|
||||
err := encryptIntegrationSecrets(e.App, e.Record)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return e.Next()
|
||||
}
|
||||
}
|
||||
|
||||
func censorIntegrationSecrets(r *core.Record) error {
|
||||
secrets := map[string][]string{
|
||||
"strava": {"clientSecret", "refreshToken", "accessToken", "expiresAt"},
|
||||
"komoot": {"password"},
|
||||
"hammerhead": {"password"},
|
||||
}
|
||||
for key, secretKeys := range secrets {
|
||||
if integrationString := r.GetString(key); integrationString != "" {
|
||||
var integration map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(integrationString), &integration); err != nil {
|
||||
return err
|
||||
}
|
||||
if integration == nil {
|
||||
continue
|
||||
}
|
||||
for _, secretKey := range secretKeys {
|
||||
integration[secretKey] = ""
|
||||
}
|
||||
b, err := json.Marshal(integration)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
r.Set(key, string(b))
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func encryptIntegrationSecrets(app core.App, r *core.Record) error {
|
||||
encryptionKey := os.Getenv("POCKETBASE_ENCRYPTION_KEY")
|
||||
if len(encryptionKey) == 0 {
|
||||
return apis.NewBadRequestError("POCKETBASE_ENCRYPTION_KEY not set", nil)
|
||||
}
|
||||
|
||||
secrets := map[string][]string{
|
||||
"strava": {"clientSecret", "refreshToken", "accessToken", "expiresAt"},
|
||||
"komoot": {"password"},
|
||||
"hammerhead": {"password"},
|
||||
}
|
||||
|
||||
original, _ := app.FindRecordById("integrations", r.Id)
|
||||
|
||||
for key, secretKeys := range secrets {
|
||||
if integrationString := r.GetString(key); integrationString != "" {
|
||||
var integration map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(integrationString), &integration); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, secretKey := range secretKeys {
|
||||
// If the secret is already encrypted, we don't re-encrypt it.
|
||||
// TODO: This is a bit of a hack, we should handle this in a more robust way (e.g.
|
||||
// storing flag on the record or prefixing encrypted strings with enc: or smilar).
|
||||
// Doing that would also potentially allow us to support key rotation in the future.
|
||||
if secret, ok := integration[secretKey].(string); ok && len(secret) > 0 && !util.CanDecryptSecret(secret) {
|
||||
encryptedSecret, err := security.Encrypt([]byte(secret), encryptionKey)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
integration[secretKey] = encryptedSecret
|
||||
} else if original != nil {
|
||||
|
||||
originalString := original.GetString(key)
|
||||
var originalIntegration map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(originalString), &originalIntegration); err != nil {
|
||||
return err
|
||||
}
|
||||
if integration == nil {
|
||||
continue
|
||||
}
|
||||
integration[secretKey] = originalIntegration[secretKey]
|
||||
}
|
||||
}
|
||||
|
||||
b, err := json.Marshal(integration)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
r.Set(key, string(b))
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
105
db/hooks/list.go
Normal file
105
db/hooks/list.go
Normal file
@@ -0,0 +1,105 @@
|
||||
package hooks
|
||||
|
||||
import (
|
||||
"pocketbase/federation"
|
||||
"pocketbase/util"
|
||||
|
||||
pub "github.com/go-ap/activitypub"
|
||||
"github.com/meilisearch/meilisearch-go"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
)
|
||||
|
||||
func CreateListHandler(client meilisearch.ServiceManager) func(e *core.RecordEvent) error {
|
||||
return func(e *core.RecordEvent) error {
|
||||
record := e.Record
|
||||
|
||||
author, err := e.App.FindRecordById("activitypub_actors", record.GetString(("author")))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := util.IndexLists(e.App, []*core.Record{record}, client); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !author.GetBool("isLocal") {
|
||||
// this happens if someone fetches a remote list
|
||||
// we create a stub list record for later reference
|
||||
// no need to create an activity for that
|
||||
return e.Next()
|
||||
}
|
||||
|
||||
err = e.Next()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = federation.CreateListActivity(e.App, e.Record, pub.CreateType)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = util.InsertIntoFeed(e.App, author.Id, author.Id, record.Id, util.ListFeed)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func UpdateListHandler(client meilisearch.ServiceManager) func(e *core.RecordEvent) error {
|
||||
return func(e *core.RecordEvent) error {
|
||||
record := e.Record
|
||||
author, err := e.App.FindRecordById("activitypub_actors", record.GetString(("author")))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = util.UpdateList(e.App, record, author, client)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !author.GetBool("isLocal") {
|
||||
// this happens if someone fetches a remote list
|
||||
// we create a stub list record for later reference
|
||||
// no need to create an activity for that
|
||||
return e.Next()
|
||||
}
|
||||
|
||||
err = e.Next()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = federation.CreateListActivity(e.App, e.Record, pub.CreateType)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func DeleteListHandler(client meilisearch.ServiceManager) func(e *core.RecordEvent) error {
|
||||
return func(e *core.RecordEvent) error {
|
||||
record := e.Record
|
||||
_, err := client.Index("lists").DeleteDocument(record.Id, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = federation.CreateListDeleteActivity(e.App, record)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = util.DeleteFromFeed(e.App, record.Id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return e.Next()
|
||||
}
|
||||
}
|
||||
56
db/hooks/list_share.go
Normal file
56
db/hooks/list_share.go
Normal file
@@ -0,0 +1,56 @@
|
||||
package hooks
|
||||
|
||||
import (
|
||||
"pocketbase/federation"
|
||||
"pocketbase/util"
|
||||
|
||||
"github.com/meilisearch/meilisearch-go"
|
||||
"github.com/pocketbase/dbx"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
)
|
||||
|
||||
func CreateListShareHandler(client meilisearch.ServiceManager) func(e *core.RecordRequestEvent) error {
|
||||
return func(e *core.RecordRequestEvent) error {
|
||||
err := e.Next()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
record := e.Record
|
||||
listId := record.GetString("list")
|
||||
shares, err := e.App.FindAllRecords("list_share",
|
||||
dbx.NewExp("list = {:listId}", dbx.Params{"listId": listId}),
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
actorIds := make([]string, len(shares))
|
||||
for i, r := range shares {
|
||||
actorIds[i] = r.GetString("actor")
|
||||
}
|
||||
err = util.UpdateListShares(listId, actorIds, client)
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = federation.CreateAnnounceActivity(e.App, record, federation.ListAnnounceType)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func DeleteListShareHandler(client meilisearch.ServiceManager) func(e *core.RecordRequestEvent) error {
|
||||
return func(e *core.RecordRequestEvent) error {
|
||||
record := e.Record
|
||||
listId := record.GetString("list")
|
||||
err := util.UpdateListShares(listId, []string{}, client)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return e.Next()
|
||||
}
|
||||
}
|
||||
96
db/hooks/summit_logs.go
Normal file
96
db/hooks/summit_logs.go
Normal file
@@ -0,0 +1,96 @@
|
||||
package hooks
|
||||
|
||||
import (
|
||||
"pocketbase/federation"
|
||||
"pocketbase/util"
|
||||
|
||||
pub "github.com/go-ap/activitypub"
|
||||
"github.com/meilisearch/meilisearch-go"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
)
|
||||
|
||||
func CreateSummitLogHandler(client meilisearch.ServiceManager) func(e *core.RecordRequestEvent) error {
|
||||
return func(e *core.RecordRequestEvent) error {
|
||||
|
||||
err := e.Next()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
userActor, err := e.App.FindFirstRecordByData("activitypub_actors", "user", e.Auth.Id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ctx, err := util.GetSafeActorContext(e.Request, userActor)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
trail, err := e.App.FindRecordById("trails", e.Record.GetString("trail"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := util.IndexTrails(e.App, []*core.Record{trail}, client); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = federation.CreateSummitLogActivity(e.App, ctx, e.Record, pub.CreateType)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func UpdateSummitLogHandler() func(e *core.RecordRequestEvent) error {
|
||||
return func(e *core.RecordRequestEvent) error {
|
||||
|
||||
err := e.Next()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
userActor, err := e.App.FindFirstRecordByData("activitypub_actors", "user", e.Auth.Id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ctx, err := util.GetSafeActorContext(e.Request, userActor)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = federation.CreateSummitLogActivity(e.App, ctx, e.Record, pub.UpdateType)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func DeleteSummitLogHandler(client meilisearch.ServiceManager) func(e *core.RecordRequestEvent) error {
|
||||
return func(e *core.RecordRequestEvent) error {
|
||||
err := e.Next()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
trail, err := e.App.FindRecordById("trails", e.Record.GetString("trail"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := util.IndexTrails(e.App, []*core.Record{trail}, client); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = federation.CreateSummitLogDeleteActivity(e.App, e.Record)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
118
db/hooks/trail_like.go
Normal file
118
db/hooks/trail_like.go
Normal file
@@ -0,0 +1,118 @@
|
||||
package hooks
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"pocketbase/federation"
|
||||
"pocketbase/util"
|
||||
|
||||
"github.com/meilisearch/meilisearch-go"
|
||||
"github.com/pocketbase/dbx"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
)
|
||||
|
||||
func CreateTrailLikeHandler(client meilisearch.ServiceManager) func(e *core.RecordEvent) error {
|
||||
return func(e *core.RecordEvent) error {
|
||||
err := e.Next()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
record := e.Record
|
||||
|
||||
trailId := record.GetString("trail")
|
||||
actorId := record.GetString("actor")
|
||||
actor, err := e.App.FindRecordById("activitypub_actors", actorId)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
trail, err := e.App.FindRecordById("trails", trailId)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
likes, err := e.App.FindAllRecords("trail_like",
|
||||
dbx.NewExp("trail = {:trailId}", dbx.Params{"trailId": trailId}),
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
trail.Set("like_count", len(likes))
|
||||
err = e.App.UnsafeWithoutHooks().Save(trail)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
actorIds := make([]string, len(likes))
|
||||
for i, r := range likes {
|
||||
actorIds[i] = r.GetString("actor")
|
||||
}
|
||||
err = util.UpdateTrailLikes(trailId, actorIds, client)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !actor.GetBool("isLocal") {
|
||||
// this happens if someone likes a remote trail
|
||||
// we create a local copy
|
||||
// no need to create an activity for that
|
||||
return nil
|
||||
}
|
||||
|
||||
err = federation.CreateLikeActivity(e.App, record)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func DeleteTrailLikeHandler(client meilisearch.ServiceManager) func(e *core.RecordEvent) error {
|
||||
return func(e *core.RecordEvent) error {
|
||||
|
||||
record := e.Record
|
||||
|
||||
trailId := record.GetString("trail")
|
||||
actorId := record.GetString("actor")
|
||||
actor, err := e.App.FindRecordById("activitypub_actors", actorId)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// trail might deleted be already if this is called as part of a cascade
|
||||
trail, err := e.App.FindRecordById("trails", trailId)
|
||||
if err != nil && err == sql.ErrNoRows {
|
||||
return nil
|
||||
} else if err != nil {
|
||||
return err
|
||||
}
|
||||
likes, err := e.App.CountRecords("trail_like", dbx.NewExp("trail={:trail}", dbx.Params{"trail": trailId}))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
trail.Set("like_count", likes)
|
||||
err = e.App.UnsafeWithoutHooks().Save(trail)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = util.UpdateTrailLikes(trailId, []string{}, client)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !actor.GetBool("isLocal") {
|
||||
// this happens if someone likes a remote trail
|
||||
// we create a local copy
|
||||
// no need to create an activity for that
|
||||
return nil
|
||||
}
|
||||
|
||||
err = federation.CreateUnlikeActivity(e.App, record)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return e.Next()
|
||||
}
|
||||
}
|
||||
57
db/hooks/trail_share.go
Normal file
57
db/hooks/trail_share.go
Normal file
@@ -0,0 +1,57 @@
|
||||
package hooks
|
||||
|
||||
import (
|
||||
"pocketbase/federation"
|
||||
"pocketbase/util"
|
||||
|
||||
"github.com/meilisearch/meilisearch-go"
|
||||
"github.com/pocketbase/dbx"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
)
|
||||
|
||||
func CreateTrailShareHandler(client meilisearch.ServiceManager) func(e *core.RecordRequestEvent) error {
|
||||
return func(e *core.RecordRequestEvent) error {
|
||||
err := e.Next()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
record := e.Record
|
||||
|
||||
trailId := record.GetString("trail")
|
||||
shares, err := e.App.FindAllRecords("trail_share",
|
||||
dbx.NewExp("trail = {:trailId}", dbx.Params{"trailId": trailId}),
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
actorIds := make([]string, len(shares))
|
||||
for i, r := range shares {
|
||||
actorIds[i] = r.GetString("actor")
|
||||
}
|
||||
err = util.UpdateTrailShares(trailId, actorIds, client)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = federation.CreateAnnounceActivity(e.App, record, federation.TrailAnnounceType)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func DeleteTrailShareHandler(client meilisearch.ServiceManager) func(e *core.RecordRequestEvent) error {
|
||||
return func(e *core.RecordRequestEvent) error {
|
||||
record := e.Record
|
||||
|
||||
trailId := record.GetString("trail")
|
||||
err := util.UpdateTrailShares(trailId, []string{}, client)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return e.Next()
|
||||
}
|
||||
}
|
||||
122
db/hooks/trails.go
Normal file
122
db/hooks/trails.go
Normal file
@@ -0,0 +1,122 @@
|
||||
package hooks
|
||||
|
||||
import (
|
||||
"log"
|
||||
"pocketbase/federation"
|
||||
"pocketbase/util"
|
||||
"time"
|
||||
|
||||
"github.com/go-ap/activitypub"
|
||||
pub "github.com/go-ap/activitypub"
|
||||
"github.com/meilisearch/meilisearch-go"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
)
|
||||
|
||||
func CreateTrailHandler(client meilisearch.ServiceManager) func(e *core.RecordEvent) error {
|
||||
return func(e *core.RecordEvent) error {
|
||||
record := e.Record
|
||||
|
||||
userActor, err := e.App.FindRecordById("activitypub_actors", record.GetString(("author")))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := util.IndexTrails(e.App, []*core.Record{record}, client); err != nil {
|
||||
return err
|
||||
}
|
||||
if !userActor.GetBool("isLocal") {
|
||||
// this happens if someone fetches a remote trail
|
||||
// we create a stub trail record for later reference
|
||||
// no need to create an activity for that
|
||||
return e.Next()
|
||||
}
|
||||
|
||||
err = e.Next()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ctx, err := util.GetSafeActorContext(nil, userActor)
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = federation.CreateTrailActivity(e.App, ctx, e.Record, activitypub.CreateType)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = util.InsertIntoFeed(e.App, userActor.Id, userActor.Id, record.Id, util.TrailFeed)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func UpdateTrailHandler(client meilisearch.ServiceManager) func(e *core.RecordEvent) error {
|
||||
return func(e *core.RecordEvent) error {
|
||||
record := e.Record
|
||||
userActor, err := e.App.FindRecordById("activitypub_actors", record.GetString(("author")))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = util.UpdateTrail(e.App, record, userActor, client)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !userActor.GetBool("isLocal") {
|
||||
// this happens if someone fetches a remote trail
|
||||
// we create a stub trail record for later reference
|
||||
// no need to create an activity for that
|
||||
return e.Next()
|
||||
}
|
||||
|
||||
err = e.Next()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ctx, err := util.GetSafeActorContext(nil, userActor)
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = federation.CreateTrailActivity(e.App, ctx, e.Record, pub.UpdateType)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func DeleteTrailHandler(client meilisearch.ServiceManager) func(e *core.RecordEvent) error {
|
||||
return func(e *core.RecordEvent) error {
|
||||
record := e.Record
|
||||
task, err := client.Index("trails").DeleteDocument(record.Id, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
interval := 500 * time.Millisecond
|
||||
_, err = client.WaitForTask(task.TaskUID, interval)
|
||||
if err != nil {
|
||||
log.Fatalf("Error waiting for task completion: %v", err)
|
||||
}
|
||||
|
||||
err = federation.CreateTrailDeleteActivity(e.App, e.Record)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = util.DeleteFromFeed(e.App, record.Id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return e.Next()
|
||||
}
|
||||
}
|
||||
112
db/hooks/users.go
Normal file
112
db/hooks/users.go
Normal file
@@ -0,0 +1,112 @@
|
||||
package hooks
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"pocketbase/util"
|
||||
|
||||
"github.com/meilisearch/meilisearch-go"
|
||||
"github.com/pocketbase/dbx"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
)
|
||||
|
||||
func CreateUserHandler(client meilisearch.ServiceManager) func(e *core.RecordEvent) error {
|
||||
return func(e *core.RecordEvent) error {
|
||||
userId := e.Record.Id
|
||||
|
||||
err := createDefaultUserSettings(e.App, e.Record.Id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
actor, err := util.ActorFromUser(e.App, e.Record)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
searchRules := map[string]interface{}{
|
||||
"lists": map[string]string{
|
||||
"filter": "public = true OR author = " + actor.Id + " OR shares = " + userId,
|
||||
},
|
||||
"trails": map[string]string{
|
||||
"filter": "public = true OR author = " + actor.Id + " OR shares = " + userId,
|
||||
},
|
||||
}
|
||||
|
||||
token, err := util.GenerateMeilisearchToken(searchRules, client)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
e.Record.Set("token", token)
|
||||
if err := e.App.Save(e.Record); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return e.Next()
|
||||
}
|
||||
}
|
||||
|
||||
func UpdateUserHandler(client meilisearch.ServiceManager) func(e *core.RecordEvent) error {
|
||||
return func(e *core.RecordEvent) error {
|
||||
actor, err := e.App.FindFirstRecordByData("activitypub_actors", "user", e.Record.Id)
|
||||
if err != nil {
|
||||
return e.Next()
|
||||
}
|
||||
|
||||
icon := ""
|
||||
origin := os.Getenv("ORIGIN")
|
||||
if origin != "" && e.Record.GetString("avatar") != "" {
|
||||
icon = fmt.Sprintf("%s/api/v1/files/_pb_users_auth_/%s/%s", origin, e.Record.Id, e.Record.GetString("avatar"))
|
||||
}
|
||||
actor.Set("icon", icon)
|
||||
if err := e.App.Save(actor); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
trails, err := e.App.FindRecordsByFilter("trails", "author={:author}", "", -1, 0, dbx.Params{"author": actor.Id})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(trails) > 0 {
|
||||
if err := util.IndexTrails(e.App, trails, client); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
lists, err := e.App.FindRecordsByFilter("lists", "author={:author}", "", -1, 0, dbx.Params{"author": actor.Id})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(lists) > 0 {
|
||||
if err := util.IndexLists(e.App, lists, client); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return e.Next()
|
||||
}
|
||||
}
|
||||
|
||||
func ChangeUserEmailHandler() func(e *core.RecordRequestEmailChangeRequestEvent) error {
|
||||
return func(e *core.RecordRequestEmailChangeRequestEvent) error {
|
||||
|
||||
e.Record.Set("email", e.NewEmail)
|
||||
if err := e.App.Save(e.Record); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func createDefaultUserSettings(app core.App, userId string) error {
|
||||
collection, err := app.FindCollectionByNameOrId("settings")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
settings := core.NewRecord(collection)
|
||||
settings.Set("language", "en")
|
||||
settings.Set("unit", "metric")
|
||||
settings.Set("mapFocus", "trails")
|
||||
settings.Set("user", userId)
|
||||
return app.Save(settings)
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package hammerhead
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
@@ -24,7 +25,7 @@ import (
|
||||
"github.com/pocketbase/pocketbase/tools/security"
|
||||
"github.com/tkrajina/gpxgo/gpx"
|
||||
|
||||
"pocketbase/trailmerge"
|
||||
"pocketbase/services/trailmerge"
|
||||
"pocketbase/util"
|
||||
)
|
||||
|
||||
@@ -48,6 +49,12 @@ func SyncHammerhead(app core.App, client meilisearch.ServiceManager) error {
|
||||
app.Logger().Warn(warning)
|
||||
continue
|
||||
}
|
||||
|
||||
ctx, err := util.GetSafeActorContext(nil, actor)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
hammerheadString := i.GetString("hammerhead")
|
||||
hammerheadIntegration := HammerheadIntegration{
|
||||
Planned: true,
|
||||
@@ -111,7 +118,7 @@ func SyncHammerhead(app core.App, client meilisearch.ServiceManager) error {
|
||||
totalPages = curTotalPages
|
||||
}
|
||||
|
||||
err, stopped = syncTrailWithTours(app, client, h, actor, hammerheadIntegration, tours, after)
|
||||
err, stopped = syncTrailWithTours(app, client, ctx, h, actor, hammerheadIntegration, tours, after)
|
||||
if err != nil {
|
||||
warning := fmt.Sprintf("error syncing Hammerhead tours with trails: %v\n", err)
|
||||
fmt.Print(warning)
|
||||
@@ -142,7 +149,7 @@ func SyncHammerhead(app core.App, client meilisearch.ServiceManager) error {
|
||||
totalPages = curTotalPages
|
||||
}
|
||||
|
||||
err, stopped = syncTrailWithActivities(app, client, h, actor, hammerheadIntegration, tours, after)
|
||||
err, stopped = syncTrailWithActivities(app, client, ctx, h, actor, hammerheadIntegration, tours, after)
|
||||
if err != nil {
|
||||
warning := fmt.Sprintf("error syncing Hammerhead tours with trails: %v\n", err)
|
||||
fmt.Print(warning)
|
||||
@@ -409,7 +416,7 @@ func (h *HammerheadApi) fetchDetailedTour(tour HammerheadTourResponse) (*Hammerh
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func syncTrailWithTours(app core.App, client meilisearch.ServiceManager, k *HammerheadApi, actor *core.Record, integration HammerheadIntegration, tours []HammerheadTourResponse, after int64) (error, bool) {
|
||||
func syncTrailWithTours(app core.App, client meilisearch.ServiceManager, ctx context.Context, k *HammerheadApi, actor *core.Record, integration HammerheadIntegration, tours []HammerheadTourResponse, after int64) (error, bool) {
|
||||
for _, tour := range tours {
|
||||
existingTrail, err := util.FindTrailByExternalReference(app, "hammerhead", tour.ID)
|
||||
if err != nil {
|
||||
@@ -445,7 +452,7 @@ func syncTrailWithTours(app core.App, client meilisearch.ServiceManager, k *Hamm
|
||||
app.Logger().Warn(fmt.Sprintf("Unable to create trail for tour '%s': %v", tour.Name, err))
|
||||
continue
|
||||
}
|
||||
if err := trailmerge.TryAutoMergeImportedTrail(app, client, actor, trailID, integration.Merge); err != nil {
|
||||
if err := trailmerge.TryAutoMergeImportedTrail(app, client, ctx, actor, trailID, integration.Merge); err != nil {
|
||||
app.Logger().Warn(fmt.Sprintf("Unable to auto-merge imported Hammerhead tour '%s': %v", tour.Name, err))
|
||||
}
|
||||
}
|
||||
@@ -453,7 +460,7 @@ func syncTrailWithTours(app core.App, client meilisearch.ServiceManager, k *Hamm
|
||||
return nil, false
|
||||
}
|
||||
|
||||
func syncTrailWithActivities(app core.App, client meilisearch.ServiceManager, k *HammerheadApi, actor *core.Record, integration HammerheadIntegration, tours []HammerheadActivityResponse, after int64) (error, bool) {
|
||||
func syncTrailWithActivities(app core.App, client meilisearch.ServiceManager, ctx context.Context, k *HammerheadApi, actor *core.Record, integration HammerheadIntegration, tours []HammerheadActivityResponse, after int64) (error, bool) {
|
||||
for _, tour := range tours {
|
||||
existingTrail, err := util.FindTrailByExternalReference(app, "hammerhead", tour.ID)
|
||||
if err != nil {
|
||||
@@ -490,7 +497,7 @@ func syncTrailWithActivities(app core.App, client meilisearch.ServiceManager, k
|
||||
app.Logger().Warn(fmt.Sprintf("Unable to create trail for tour '%s': %v", tour.Name, err))
|
||||
continue
|
||||
}
|
||||
if err := trailmerge.TryAutoMergeImportedTrail(app, client, actor, trailID, integration.Merge); err != nil {
|
||||
if err := trailmerge.TryAutoMergeImportedTrail(app, client, ctx, actor, trailID, integration.Merge); err != nil {
|
||||
app.Logger().Warn(fmt.Sprintf("Unable to auto-merge imported Hammerhead activity '%s': %v", tour.Name, err))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ package hammerhead
|
||||
import (
|
||||
"time"
|
||||
|
||||
"pocketbase/trailmerge"
|
||||
"pocketbase/services/trailmerge"
|
||||
)
|
||||
|
||||
type HammerheadToursResponse struct {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package komoot
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
@@ -19,7 +20,7 @@ import (
|
||||
"github.com/pocketbase/pocketbase/tools/security"
|
||||
"github.com/tkrajina/gpxgo/gpx"
|
||||
|
||||
"pocketbase/trailmerge"
|
||||
"pocketbase/services/trailmerge"
|
||||
"pocketbase/util"
|
||||
)
|
||||
|
||||
@@ -43,6 +44,12 @@ func SyncKomoot(app core.App, client meilisearch.ServiceManager) error {
|
||||
app.Logger().Warn(warning)
|
||||
continue
|
||||
}
|
||||
|
||||
ctx, err := util.GetSafeActorContext(nil, actor)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
komootString := i.GetString("komoot")
|
||||
komootIntegration := KomootIntegration{
|
||||
Planned: true,
|
||||
@@ -82,7 +89,7 @@ func SyncKomoot(app core.App, client meilisearch.ServiceManager) error {
|
||||
}
|
||||
totalPages = tp
|
||||
|
||||
allAlreadySynced, err := syncTrailWithTours(app, client, k, komootIntegration, userId, actor, tours)
|
||||
allAlreadySynced, err := syncTrailWithTours(app, client, ctx, k, komootIntegration, userId, actor, tours)
|
||||
if err != nil {
|
||||
warning := fmt.Sprintf("error syncing komoot tours with trails: %v\n", err)
|
||||
fmt.Print(warning)
|
||||
@@ -191,7 +198,7 @@ func (k *KomootApi) fetchDetailedTour(tour KomootTour) (*DetailedKomootTour, err
|
||||
// when every tour on this page was already imported, so the caller can stop paginating
|
||||
// early during incremental syncs. Tours skipped due to type filters do NOT count as
|
||||
// synced - only tours already present in the DB do.
|
||||
func syncTrailWithTours(app core.App, client meilisearch.ServiceManager, k *KomootApi, i KomootIntegration, user string, actor *core.Record, tours []KomootTour) (bool, error) {
|
||||
func syncTrailWithTours(app core.App, client meilisearch.ServiceManager, ctx context.Context, k *KomootApi, i KomootIntegration, user string, actor *core.Record, tours []KomootTour) (bool, error) {
|
||||
allAlreadySynced := true
|
||||
for _, tour := range tours {
|
||||
existingTrail, err := util.FindTrailByExternalReference(app, "komoot", strconv.Itoa(int(tour.ID)))
|
||||
@@ -226,7 +233,7 @@ func syncTrailWithTours(app core.App, client meilisearch.ServiceManager, k *Komo
|
||||
app.Logger().Warn(fmt.Sprintf("Unable to create waypoints for tour '%s': %v", tour.Name, err))
|
||||
continue
|
||||
}
|
||||
if err := trailmerge.TryAutoMergeImportedTrail(app, client, actor, trailid, i.Merge); err != nil {
|
||||
if err := trailmerge.TryAutoMergeImportedTrail(app, client, ctx, actor, trailid, i.Merge); err != nil {
|
||||
app.Logger().Warn(fmt.Sprintf("Unable to auto-merge imported komoot tour '%s': %v", tour.Name, err))
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ package komoot
|
||||
import (
|
||||
"time"
|
||||
|
||||
"pocketbase/trailmerge"
|
||||
"pocketbase/services/trailmerge"
|
||||
)
|
||||
|
||||
type KomootIntegration struct {
|
||||
|
||||
@@ -3,7 +3,7 @@ package strava
|
||||
import (
|
||||
"time"
|
||||
|
||||
"pocketbase/trailmerge"
|
||||
"pocketbase/services/trailmerge"
|
||||
)
|
||||
|
||||
type TokenRequest struct {
|
||||
|
||||
@@ -2,6 +2,7 @@ package strava
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -19,7 +20,7 @@ import (
|
||||
"github.com/tkrajina/gpxgo/gpx"
|
||||
"github.com/twpayne/go-polyline"
|
||||
|
||||
"pocketbase/trailmerge"
|
||||
"pocketbase/services/trailmerge"
|
||||
"pocketbase/util"
|
||||
)
|
||||
|
||||
@@ -47,6 +48,12 @@ func SyncStrava(app core.App, client meilisearch.ServiceManager) error {
|
||||
app.Logger().Warn(warning)
|
||||
continue
|
||||
}
|
||||
|
||||
ctx, err := util.GetSafeActorContext(nil, actor)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
stravaString := i.GetString("strava")
|
||||
var stravaIntegration StravaIntegration
|
||||
err = json.Unmarshal([]byte(stravaString), &stravaIntegration)
|
||||
@@ -104,7 +111,7 @@ func SyncStrava(app core.App, client meilisearch.ServiceManager) error {
|
||||
app.Logger().Warn(warning)
|
||||
break
|
||||
}
|
||||
err = syncTrailsWithRoutes(app, client, stravaIntegration, r.AccessToken, userId, actor, routes)
|
||||
err = syncTrailsWithRoutes(app, client, ctx, stravaIntegration, r.AccessToken, userId, actor, routes)
|
||||
if err != nil {
|
||||
warning := fmt.Sprintf("error syncing strava routes with trails: %v\n", err)
|
||||
fmt.Print(warning)
|
||||
@@ -136,7 +143,7 @@ func SyncStrava(app core.App, client meilisearch.ServiceManager) error {
|
||||
app.Logger().Warn(warning)
|
||||
break
|
||||
}
|
||||
err = syncTrailsWithActivities(app, client, stravaIntegration, r.AccessToken, userId, actor, activities)
|
||||
err = syncTrailsWithActivities(app, client, ctx, stravaIntegration, r.AccessToken, userId, actor, activities)
|
||||
|
||||
if err != nil {
|
||||
warning := fmt.Sprintf("error syncing strava activities with trails: %v", err)
|
||||
@@ -250,7 +257,7 @@ func fetchStravaActivities(accessToken string, page int, after int64) ([]StravaA
|
||||
return activities, nil
|
||||
}
|
||||
|
||||
func syncTrailsWithRoutes(app core.App, client meilisearch.ServiceManager, i StravaIntegration, accessToken string, user string, actor *core.Record, routes []StravaRoute) error {
|
||||
func syncTrailsWithRoutes(app core.App, client meilisearch.ServiceManager, ctx context.Context, i StravaIntegration, accessToken string, user string, actor *core.Record, routes []StravaRoute) error {
|
||||
for _, route := range routes {
|
||||
existingTrail, err := util.FindTrailByExternalReference(app, "strava", route.IDStr)
|
||||
if err != nil {
|
||||
@@ -274,7 +281,7 @@ func syncTrailsWithRoutes(app core.App, client meilisearch.ServiceManager, i Str
|
||||
app.Logger().Warn(fmt.Sprintf("Unable to create waypoints for route '%s': %v", route.Name, err))
|
||||
continue
|
||||
}
|
||||
if err := trailmerge.TryAutoMergeImportedTrail(app, client, actor, trailid, i.Merge); err != nil {
|
||||
if err := trailmerge.TryAutoMergeImportedTrail(app, client, ctx, actor, trailid, i.Merge); err != nil {
|
||||
app.Logger().Warn(fmt.Sprintf("Unable to auto-merge imported Strava route '%s': %v", route.Name, err))
|
||||
}
|
||||
}
|
||||
@@ -426,7 +433,7 @@ func createWaypointsFromRoute(app core.App, route StravaRoute, user string, trai
|
||||
return nil
|
||||
}
|
||||
|
||||
func syncTrailsWithActivities(app core.App, client meilisearch.ServiceManager, i StravaIntegration, accessToken string, user string, actor *core.Record, activities []StravaActivity) error {
|
||||
func syncTrailsWithActivities(app core.App, client meilisearch.ServiceManager, ctx context.Context, i StravaIntegration, accessToken string, user string, actor *core.Record, activities []StravaActivity) error {
|
||||
for _, activity := range activities {
|
||||
existingTrail, err := util.FindTrailByExternalReference(app, "strava", strconv.Itoa(int(activity.ID)))
|
||||
if err != nil {
|
||||
@@ -450,7 +457,7 @@ func syncTrailsWithActivities(app core.App, client meilisearch.ServiceManager, i
|
||||
app.Logger().Warn(fmt.Sprintf("Unable to create trail from activity '%s': %v", activity.Name, err))
|
||||
continue
|
||||
}
|
||||
if err := trailmerge.TryAutoMergeImportedTrail(app, client, actor, trailID, i.Merge); err != nil {
|
||||
if err := trailmerge.TryAutoMergeImportedTrail(app, client, ctx, actor, trailID, i.Merge); err != nil {
|
||||
app.Logger().Warn(fmt.Sprintf("Unable to auto-merge imported Strava activity '%s': %v", activity.Name, err))
|
||||
}
|
||||
}
|
||||
|
||||
1487
db/main.go
1487
db/main.go
File diff suppressed because it is too large
Load Diff
60
db/migrations/1775994551_updated_waypoints.go
Normal file
60
db/migrations/1775994551_updated_waypoints.go
Normal file
@@ -0,0 +1,60 @@
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
m "github.com/pocketbase/pocketbase/migrations"
|
||||
)
|
||||
|
||||
func init() {
|
||||
m.Register(func(app core.App) error {
|
||||
collection, err := app.FindCollectionByNameOrId("goeo2ubp103rzp9")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// add field
|
||||
if err := collection.Fields.AddMarshaledJSONAt(8, []byte(`{
|
||||
"exceptDomains": null,
|
||||
"hidden": false,
|
||||
"id": "url2434853685",
|
||||
"name": "iri",
|
||||
"onlyDomains": null,
|
||||
"presentable": false,
|
||||
"required": false,
|
||||
"system": false,
|
||||
"type": "url"
|
||||
}`)); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// update collection data
|
||||
if err := json.Unmarshal([]byte(`{
|
||||
"indexes": [
|
||||
"CREATE UNIQUE INDEX `+"`"+`idx_GgX6MsdCJq`+"`"+` ON `+"`"+`waypoints`+"`"+` (`+"`"+`iri`+"`"+`) WHERE iri IS NOT NULL AND iri != \"\";"
|
||||
]
|
||||
}`), &collection); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return app.Save(collection)
|
||||
}, func(app core.App) error {
|
||||
collection, err := app.FindCollectionByNameOrId("goeo2ubp103rzp9")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// update collection data
|
||||
if err := json.Unmarshal([]byte(`{
|
||||
"indexes": []
|
||||
}`), &collection); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// remove field
|
||||
collection.Fields.RemoveById("url2434853685")
|
||||
|
||||
return app.Save(collection)
|
||||
})
|
||||
}
|
||||
78
db/migrations/1775997520_updated_waypoints.go
Normal file
78
db/migrations/1775997520_updated_waypoints.go
Normal file
@@ -0,0 +1,78 @@
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
m "github.com/pocketbase/pocketbase/migrations"
|
||||
)
|
||||
|
||||
func init() {
|
||||
m.Register(func(app core.App) error {
|
||||
collection, err := app.FindCollectionByNameOrId("goeo2ubp103rzp9")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// add field
|
||||
if err := collection.Fields.AddMarshaledJSONAt(11, []byte(`{
|
||||
"cascadeDelete": true,
|
||||
"collectionId": "pbc_1295301207",
|
||||
"hidden": false,
|
||||
"id": "relation3182418120",
|
||||
"maxSelect": 1,
|
||||
"minSelect": 0,
|
||||
"name": "author",
|
||||
"presentable": false,
|
||||
"required": true,
|
||||
"system": false,
|
||||
"type": "relation"
|
||||
}`)); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// update field
|
||||
if err := collection.Fields.AddMarshaledJSONAt(9, []byte(`{
|
||||
"cascadeDelete": true,
|
||||
"collectionId": "_pb_users_auth_",
|
||||
"hidden": false,
|
||||
"id": "8qbxrsd8",
|
||||
"maxSelect": 1,
|
||||
"minSelect": 0,
|
||||
"name": "user",
|
||||
"presentable": false,
|
||||
"required": true,
|
||||
"system": false,
|
||||
"type": "relation"
|
||||
}`)); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return app.Save(collection)
|
||||
}, func(app core.App) error {
|
||||
collection, err := app.FindCollectionByNameOrId("goeo2ubp103rzp9")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// remove field
|
||||
collection.Fields.RemoveById("relation3182418120")
|
||||
|
||||
// update field
|
||||
if err := collection.Fields.AddMarshaledJSONAt(9, []byte(`{
|
||||
"cascadeDelete": true,
|
||||
"collectionId": "_pb_users_auth_",
|
||||
"hidden": false,
|
||||
"id": "8qbxrsd8",
|
||||
"maxSelect": 1,
|
||||
"minSelect": 0,
|
||||
"name": "author",
|
||||
"presentable": false,
|
||||
"required": true,
|
||||
"system": false,
|
||||
"type": "relation"
|
||||
}`)); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return app.Save(collection)
|
||||
})
|
||||
}
|
||||
33
db/migrations/1775997935_set_waypoint_authors.go
Normal file
33
db/migrations/1775997935_set_waypoint_authors.go
Normal file
@@ -0,0 +1,33 @@
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
m "github.com/pocketbase/pocketbase/migrations"
|
||||
)
|
||||
|
||||
func init() {
|
||||
m.Register(func(app core.App) error {
|
||||
wps, err := app.FindAllRecords("waypoints")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, wp := range wps {
|
||||
actor, err := app.FindFirstRecordByData("activitypub_actors", "user", wp.GetString("user"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
wp.Set("author", actor.Id)
|
||||
err = app.UnsafeWithoutHooks().Save(wp)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}, func(app core.App) error {
|
||||
// add down queries...
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
44
db/migrations/1775997936_updated_waypoints.go
Normal file
44
db/migrations/1775997936_updated_waypoints.go
Normal file
@@ -0,0 +1,44 @@
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
m "github.com/pocketbase/pocketbase/migrations"
|
||||
)
|
||||
|
||||
func init() {
|
||||
m.Register(func(app core.App) error {
|
||||
collection, err := app.FindCollectionByNameOrId("goeo2ubp103rzp9")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// remove field
|
||||
collection.Fields.RemoveById("8qbxrsd8")
|
||||
|
||||
return app.Save(collection)
|
||||
}, func(app core.App) error {
|
||||
collection, err := app.FindCollectionByNameOrId("goeo2ubp103rzp9")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// add field
|
||||
if err := collection.Fields.AddMarshaledJSONAt(9, []byte(`{
|
||||
"cascadeDelete": true,
|
||||
"collectionId": "_pb_users_auth_",
|
||||
"hidden": false,
|
||||
"id": "8qbxrsd8",
|
||||
"maxSelect": 1,
|
||||
"minSelect": 0,
|
||||
"name": "user",
|
||||
"presentable": false,
|
||||
"required": true,
|
||||
"system": false,
|
||||
"type": "relation"
|
||||
}`)); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return app.Save(collection)
|
||||
})
|
||||
}
|
||||
40
db/migrations/1776170099_updated_trails.go
Normal file
40
db/migrations/1776170099_updated_trails.go
Normal file
@@ -0,0 +1,40 @@
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
m "github.com/pocketbase/pocketbase/migrations"
|
||||
)
|
||||
|
||||
func init() {
|
||||
m.Register(func(app core.App) error {
|
||||
collection, err := app.FindCollectionByNameOrId("e864strfxo14pm4")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// add field
|
||||
if err := collection.Fields.AddMarshaledJSONAt(23, []byte(`{
|
||||
"hidden": false,
|
||||
"id": "bool678597678",
|
||||
"name": "needs_full_sync",
|
||||
"presentable": false,
|
||||
"required": false,
|
||||
"system": false,
|
||||
"type": "bool"
|
||||
}`)); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return app.Save(collection)
|
||||
}, func(app core.App) error {
|
||||
collection, err := app.FindCollectionByNameOrId("e864strfxo14pm4")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// remove field
|
||||
collection.Fields.RemoveById("bool678597678")
|
||||
|
||||
return app.Save(collection)
|
||||
})
|
||||
}
|
||||
42
db/migrations/1776243566_updated_trails.go
Normal file
42
db/migrations/1776243566_updated_trails.go
Normal file
@@ -0,0 +1,42 @@
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
m "github.com/pocketbase/pocketbase/migrations"
|
||||
)
|
||||
|
||||
func init() {
|
||||
m.Register(func(app core.App) error {
|
||||
collection, err := app.FindCollectionByNameOrId("e864strfxo14pm4")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// update collection data
|
||||
if err := json.Unmarshal([]byte(`{
|
||||
"createRule": "@request.auth.id != \"\" && (@request.body.author.user = @request.auth.id)",
|
||||
"updateRule": "author.user = @request.auth.id || (@request.auth.id != \"\" && trail_share_via_trail.trail = id && trail_share_via_trail.actor.user ?= @request.auth.id && trail_share_via_trail.permission = \"edit\")"
|
||||
}`), &collection); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return app.Save(collection)
|
||||
}, func(app core.App) error {
|
||||
collection, err := app.FindCollectionByNameOrId("e864strfxo14pm4")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// update collection data
|
||||
if err := json.Unmarshal([]byte(`{
|
||||
"createRule": "@request.auth.id != \"\" && (@request.body.author.user = @request.auth.id || author.isLocal = false)",
|
||||
"updateRule": "author.user = @request.auth.id || (@request.auth.id != \"\" && trail_share_via_trail.trail = id && trail_share_via_trail.actor.user ?= @request.auth.id && trail_share_via_trail.permission = \"edit\") || (@request.auth.id != \"\" && author.isLocal = false)"
|
||||
}`), &collection); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return app.Save(collection)
|
||||
})
|
||||
}
|
||||
42
db/migrations/1776243589_updated_lists.go
Normal file
42
db/migrations/1776243589_updated_lists.go
Normal file
@@ -0,0 +1,42 @@
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
m "github.com/pocketbase/pocketbase/migrations"
|
||||
)
|
||||
|
||||
func init() {
|
||||
m.Register(func(app core.App) error {
|
||||
collection, err := app.FindCollectionByNameOrId("r6gu2ajyidy1x69")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// update collection data
|
||||
if err := json.Unmarshal([]byte(`{
|
||||
"createRule": "@request.auth.id != \"\" && (@request.body.author.user = @request.auth.id)",
|
||||
"updateRule": "author.user = @request.auth.id || (@request.auth.id != \"\" && list_share_via_list.list = id && list_share_via_list.actor.user ?= @request.auth.id && list_share_via_list.permission = \"edit\")"
|
||||
}`), &collection); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return app.Save(collection)
|
||||
}, func(app core.App) error {
|
||||
collection, err := app.FindCollectionByNameOrId("r6gu2ajyidy1x69")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// update collection data
|
||||
if err := json.Unmarshal([]byte(`{
|
||||
"createRule": "@request.auth.id != \"\" && (@request.body.author.user = @request.auth.id || author.isLocal = false)",
|
||||
"updateRule": "author.user = @request.auth.id || (@request.auth.id != \"\" && list_share_via_list.list = id && list_share_via_list.actor.user ?= @request.auth.id && list_share_via_list.permission = \"edit\") || (@request.auth.id != \"\" && author.isLocal = false)"
|
||||
}`), &collection); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return app.Save(collection)
|
||||
})
|
||||
}
|
||||
88
db/migrations/1776675228_updated_activitypub_actors.go
Normal file
88
db/migrations/1776675228_updated_activitypub_actors.go
Normal file
@@ -0,0 +1,88 @@
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
m "github.com/pocketbase/pocketbase/migrations"
|
||||
)
|
||||
|
||||
func init() {
|
||||
m.Register(func(app core.App) error {
|
||||
collection, err := app.FindCollectionByNameOrId("pbc_1295301207")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// update field
|
||||
if err := collection.Fields.AddMarshaledJSONAt(5, []byte(`{
|
||||
"hidden": false,
|
||||
"id": "number1386272118",
|
||||
"max": null,
|
||||
"min": null,
|
||||
"name": "follower_count",
|
||||
"onlyInt": true,
|
||||
"presentable": false,
|
||||
"required": false,
|
||||
"system": false,
|
||||
"type": "number"
|
||||
}`)); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// update field
|
||||
if err := collection.Fields.AddMarshaledJSONAt(6, []byte(`{
|
||||
"hidden": false,
|
||||
"id": "number3430500629",
|
||||
"max": null,
|
||||
"min": null,
|
||||
"name": "following_count",
|
||||
"onlyInt": true,
|
||||
"presentable": false,
|
||||
"required": false,
|
||||
"system": false,
|
||||
"type": "number"
|
||||
}`)); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return app.Save(collection)
|
||||
}, func(app core.App) error {
|
||||
collection, err := app.FindCollectionByNameOrId("pbc_1295301207")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// update field
|
||||
if err := collection.Fields.AddMarshaledJSONAt(5, []byte(`{
|
||||
"hidden": false,
|
||||
"id": "number1386272118",
|
||||
"max": null,
|
||||
"min": null,
|
||||
"name": "followerCount",
|
||||
"onlyInt": true,
|
||||
"presentable": false,
|
||||
"required": false,
|
||||
"system": false,
|
||||
"type": "number"
|
||||
}`)); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// update field
|
||||
if err := collection.Fields.AddMarshaledJSONAt(6, []byte(`{
|
||||
"hidden": false,
|
||||
"id": "number3430500629",
|
||||
"max": null,
|
||||
"min": null,
|
||||
"name": "followingCount",
|
||||
"onlyInt": true,
|
||||
"presentable": false,
|
||||
"required": false,
|
||||
"system": false,
|
||||
"type": "number"
|
||||
}`)); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return app.Save(collection)
|
||||
})
|
||||
}
|
||||
40
db/migrations/1778145631_updated_lists.go
Normal file
40
db/migrations/1778145631_updated_lists.go
Normal file
@@ -0,0 +1,40 @@
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
m "github.com/pocketbase/pocketbase/migrations"
|
||||
)
|
||||
|
||||
func init() {
|
||||
m.Register(func(app core.App) error {
|
||||
collection, err := app.FindCollectionByNameOrId("r6gu2ajyidy1x69")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// add field
|
||||
if err := collection.Fields.AddMarshaledJSONAt(8, []byte(`{
|
||||
"hidden": false,
|
||||
"id": "bool678597678",
|
||||
"name": "needs_full_sync",
|
||||
"presentable": false,
|
||||
"required": false,
|
||||
"system": false,
|
||||
"type": "bool"
|
||||
}`)); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return app.Save(collection)
|
||||
}, func(app core.App) error {
|
||||
collection, err := app.FindCollectionByNameOrId("r6gu2ajyidy1x69")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// remove field
|
||||
collection.Fields.RemoveById("bool678597678")
|
||||
|
||||
return app.Save(collection)
|
||||
})
|
||||
}
|
||||
210
db/routes/activitypub.go
Normal file
210
db/routes/activitypub.go
Normal file
@@ -0,0 +1,210 @@
|
||||
package routes
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"pocketbase/federation"
|
||||
"pocketbase/util"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
pub "github.com/go-ap/activitypub"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
)
|
||||
|
||||
func ActivitypubActor(e *core.RequestEvent) error {
|
||||
resource := e.Request.URL.Query().Get("resource")
|
||||
resource = strings.TrimPrefix(resource, "acct:")
|
||||
|
||||
iri := e.Request.URL.Query().Get("iri")
|
||||
follows := e.Request.URL.Query().Get("follows") == "true"
|
||||
|
||||
var userActor *core.Record
|
||||
var err error
|
||||
if e.Auth != nil {
|
||||
userActor, err = e.App.FindFirstRecordByData("activitypub_actors", "user", e.Auth.Id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
ctx, err := util.GetSafeActorContext(e.Request, userActor)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var actor *core.Record
|
||||
if resource != "" {
|
||||
actor, err = federation.GetActorByHandle(e.App, ctx, resource, follows)
|
||||
} else {
|
||||
actor, err = federation.GetActorByIRI(e.App, ctx, iri, follows)
|
||||
}
|
||||
if err != nil && actor == nil {
|
||||
if strings.HasPrefix(err.Error(), "webfinger") {
|
||||
return e.NotFoundError("Not found", err)
|
||||
}
|
||||
return err
|
||||
} else if err != nil && actor != nil {
|
||||
if errors.Is(err, federation.ErrProfilePrivate) {
|
||||
// this is our own profile
|
||||
if e.Auth != nil && actor.GetString("user") == e.Auth.Id {
|
||||
return e.JSON(http.StatusOK, map[string]any{"actor": actor, "error": nil})
|
||||
} else {
|
||||
return e.JSON(http.StatusNotFound, map[string]any{"error": "profile is private"})
|
||||
}
|
||||
}
|
||||
// we could not fetch the remote actor so we return our local cached copy
|
||||
return e.JSON(http.StatusOK, map[string]any{"actor": actor, "error": err.Error()})
|
||||
}
|
||||
|
||||
return e.JSON(http.StatusOK, map[string]any{"actor": actor, "error": nil})
|
||||
}
|
||||
|
||||
func ActivitypubActivityProcess(e *core.RequestEvent) error {
|
||||
origin := os.Getenv("ORIGIN")
|
||||
if origin == "" {
|
||||
return fmt.Errorf("ORIGIN not set")
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(e.Request.Body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var activity pub.Activity
|
||||
err = activity.UnmarshalJSON(body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
inbox := fmt.Sprintf("%s%s", origin, e.Request.Header.Get("X-Forwarded-Path"))
|
||||
|
||||
recipient, err := e.App.FindFirstRecordByData("activitypub_actors", "inbox", inbox)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
actor, err := e.App.FindFirstRecordByData("activitypub_actors", "iri", activity.Actor.GetID().String())
|
||||
if err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
ctx, err := util.GetSafeActorContext(e.Request, recipient)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
actor, err = federation.GetActorByIRI(e.App, ctx, activity.Actor.GetID().String(), false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
return err
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
verified, err := util.VerifySignature(e.App, e.Request, actor.GetString("public_key"))
|
||||
if err != nil || !verified {
|
||||
e.App.Logger().Error(err.Error())
|
||||
return e.UnauthorizedError("Invalid http signature", err)
|
||||
}
|
||||
|
||||
switch activity.Type {
|
||||
case pub.FollowType:
|
||||
err = federation.ProcessFollowActivity(e.App, actor, activity)
|
||||
case pub.AcceptType:
|
||||
err = federation.ProcessAcceptActivity(e.App, actor, activity)
|
||||
case pub.UndoType:
|
||||
err = federation.ProcessUndoActivity(e.App, actor, activity)
|
||||
case pub.UpdateType:
|
||||
fallthrough
|
||||
case pub.CreateType:
|
||||
err = federation.ProcessCreateOrUpdateActivity(e.App, actor, recipient, activity)
|
||||
case pub.DeleteType:
|
||||
err = federation.ProcessDeleteActivity(e.App, actor, activity)
|
||||
case pub.AnnounceType:
|
||||
err = federation.ProcessAnnounceActivity(e.App, actor, activity)
|
||||
case pub.LikeType:
|
||||
err = federation.ProcessLikeActivity(e.App, actor, activity)
|
||||
}
|
||||
return e.JSON(http.StatusOK, err)
|
||||
}
|
||||
|
||||
func ActivitypubActorFollow(e *core.RequestEvent) error {
|
||||
id := e.Request.PathValue("id")
|
||||
followType := e.Request.PathValue("follow")
|
||||
page := e.Request.URL.Query().Get("page")
|
||||
intPage := 0
|
||||
|
||||
if page != "" {
|
||||
var err error
|
||||
intPage, err = strconv.Atoi(page)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
actor, err := e.App.FindRecordById("activitypub_actors", id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var userActor *core.Record
|
||||
if e.Auth != nil {
|
||||
userActor, err = e.App.FindFirstRecordByData("activitypub_actors", "user", e.Auth.Id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
ctx, err := util.GetSafeActorContext(e.Request, userActor)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
url := actor.GetString(followType)
|
||||
|
||||
if url == "" {
|
||||
return e.BadRequestError("unknown type: "+followType, nil)
|
||||
}
|
||||
collection, err := federation.FetchCollection(e.App, ctx, fmt.Sprintf("%s?page=%d", url, intPage))
|
||||
if err != nil {
|
||||
if errors.Is(err, federation.ErrProfilePrivate) {
|
||||
return e.JSON(http.StatusNotFound, map[string]any{"error": "profile is private"})
|
||||
} else if errors.Is(err, util.ErrRateLimited) {
|
||||
return e.TooManyRequestsError("Too many requests", err)
|
||||
}
|
||||
return err
|
||||
}
|
||||
return e.JSON(http.StatusOK, collection)
|
||||
}
|
||||
|
||||
func ActivitypubTrail(e *core.RequestEvent) error {
|
||||
id := e.Request.PathValue("id")
|
||||
|
||||
trail, err := e.App.FindRecordById("trails", id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
trailObject, err := util.ObjectFromTrail(e.App, trail, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return e.JSON(http.StatusOK, trailObject)
|
||||
}
|
||||
|
||||
func ActivitypubComment(e *core.RequestEvent) error {
|
||||
id := e.Request.PathValue("id")
|
||||
|
||||
comment, err := e.App.FindRecordById("comments", id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
commentObject, err := util.ObjectFromComment(e.App, comment, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return e.JSON(http.StatusOK, commentObject)
|
||||
}
|
||||
50
db/routes/auth_token.go
Normal file
50
db/routes/auth_token.go
Normal file
@@ -0,0 +1,50 @@
|
||||
package routes
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/pocketbase/pocketbase/apis"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/tools/security"
|
||||
)
|
||||
|
||||
func AuthToken(e *core.RequestEvent) error {
|
||||
var data struct {
|
||||
APIToken string `json:"api_token"`
|
||||
}
|
||||
if err := e.BindBody(&data); err != nil {
|
||||
return apis.NewBadRequestError("Failed to read request data", err)
|
||||
}
|
||||
|
||||
hashedAPIToken := security.SHA256(data.APIToken)
|
||||
|
||||
tokenRecord, err := e.App.FindFirstRecordByFilter(
|
||||
"api_tokens",
|
||||
"token = {:hash}",
|
||||
map[string]any{"hash": hashedAPIToken},
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return apis.NewNotFoundError("Invalid or revoked API token", nil)
|
||||
}
|
||||
if !tokenRecord.GetDateTime("expiration").IsZero() &&
|
||||
tokenRecord.GetDateTime("expiration").Time().Before(time.Now()) {
|
||||
return apis.NewBadRequestError("Key has expired", nil)
|
||||
}
|
||||
|
||||
tokenRecord.Set("last_used", time.Now())
|
||||
if err := e.App.Save(tokenRecord); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
userRecord, _ := e.App.FindRecordById("users", tokenRecord.GetString("user"))
|
||||
token, err := userRecord.NewAuthToken()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return e.JSON(http.StatusOK, map[string]any{
|
||||
"token": token,
|
||||
"record": userRecord,
|
||||
})
|
||||
}
|
||||
11
db/routes/health.go
Normal file
11
db/routes/health.go
Normal file
@@ -0,0 +1,11 @@
|
||||
package routes
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
)
|
||||
|
||||
func Health(e *core.RequestEvent) error {
|
||||
return e.JSON(http.StatusOK, map[string]string{"status": "ok"})
|
||||
}
|
||||
81
db/routes/integration_hammerhead.go
Normal file
81
db/routes/integration_hammerhead.go
Normal file
@@ -0,0 +1,81 @@
|
||||
package routes
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"os"
|
||||
"pocketbase/integrations/hammerhead"
|
||||
|
||||
"github.com/pocketbase/dbx"
|
||||
"github.com/pocketbase/pocketbase/apis"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/tools/security"
|
||||
)
|
||||
|
||||
func IntegrationHammerheadUpload(e *core.RequestEvent) error {
|
||||
h, err := loginHammerhead(e)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := h.UploadActivities(e); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return e.JSON(http.StatusOK, nil)
|
||||
}
|
||||
|
||||
func IntegrationHammerheadLogin(e *core.RequestEvent) error {
|
||||
_, err := loginHammerhead(e)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return e.JSON(http.StatusOK, nil)
|
||||
}
|
||||
|
||||
func loginHammerhead(e *core.RequestEvent) (*hammerhead.HammerheadApi, error) {
|
||||
|
||||
encryptionKey := os.Getenv("POCKETBASE_ENCRYPTION_KEY")
|
||||
if len(encryptionKey) == 0 {
|
||||
return nil, apis.NewBadRequestError("POCKETBASE_ENCRYPTION_KEY not set", nil)
|
||||
}
|
||||
|
||||
userId := ""
|
||||
if e.Auth != nil {
|
||||
userId = e.Auth.Id
|
||||
} else {
|
||||
return nil, e.UnauthorizedError("authentication required", nil)
|
||||
}
|
||||
|
||||
integrations, err := e.App.FindAllRecords("integrations", dbx.NewExp("user = {:id}", dbx.Params{"id": userId}))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(integrations) == 0 {
|
||||
return nil, apis.NewBadRequestError("user has no integration", nil)
|
||||
}
|
||||
integration := integrations[0]
|
||||
hammerheadString := integration.GetString("hammerhead")
|
||||
if len(hammerheadString) == 0 {
|
||||
return nil, apis.NewBadRequestError("hammerhead integration missing", nil)
|
||||
}
|
||||
var hammerheadIntegration hammerhead.HammerheadIntegration
|
||||
err = json.Unmarshal([]byte(hammerheadString), &hammerheadIntegration)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
decryptedPassword, err := security.Decrypt(hammerheadIntegration.Password, encryptionKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
k := &hammerhead.HammerheadApi{}
|
||||
|
||||
err = k.Login(hammerheadIntegration.Email, string(decryptedPassword))
|
||||
if err != nil {
|
||||
return nil, apis.NewUnauthorizedError("invalid credentials", nil)
|
||||
}
|
||||
|
||||
return k, e.JSON(http.StatusOK, nil)
|
||||
}
|
||||
58
db/routes/integration_komoot.go
Normal file
58
db/routes/integration_komoot.go
Normal file
@@ -0,0 +1,58 @@
|
||||
package routes
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"os"
|
||||
"pocketbase/integrations/komoot"
|
||||
|
||||
"github.com/pocketbase/dbx"
|
||||
"github.com/pocketbase/pocketbase/apis"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/tools/security"
|
||||
)
|
||||
|
||||
func IntegrationKommotLogin(e *core.RequestEvent) error {
|
||||
encryptionKey := os.Getenv("POCKETBASE_ENCRYPTION_KEY")
|
||||
if len(encryptionKey) == 0 {
|
||||
return apis.NewBadRequestError("POCKETBASE_ENCRYPTION_KEY not set", nil)
|
||||
}
|
||||
|
||||
userId := ""
|
||||
if e.Auth != nil {
|
||||
userId = e.Auth.Id
|
||||
} else {
|
||||
return e.UnauthorizedError("authentication required", nil)
|
||||
}
|
||||
|
||||
integrations, err := e.App.FindAllRecords("integrations", dbx.NewExp("user = {:id}", dbx.Params{"id": userId}))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(integrations) == 0 {
|
||||
return apis.NewBadRequestError("user has no integration", nil)
|
||||
}
|
||||
integration := integrations[0]
|
||||
komootString := integration.GetString("komoot")
|
||||
if len(komootString) == 0 {
|
||||
return apis.NewBadRequestError("komoot integration missing", nil)
|
||||
}
|
||||
var komootIntegration komoot.KomootIntegration
|
||||
err = json.Unmarshal([]byte(komootString), &komootIntegration)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
decryptedPassword, err := security.Decrypt(komootIntegration.Password, encryptionKey)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
k := &komoot.KomootApi{}
|
||||
|
||||
err = k.Login(komootIntegration.Email, string(decryptedPassword))
|
||||
if err != nil {
|
||||
return apis.NewUnauthorizedError("invalid credentials", nil)
|
||||
}
|
||||
|
||||
return e.JSON(http.StatusOK, nil)
|
||||
}
|
||||
87
db/routes/integration_strava.go
Normal file
87
db/routes/integration_strava.go
Normal file
@@ -0,0 +1,87 @@
|
||||
package routes
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"os"
|
||||
"pocketbase/integrations/strava"
|
||||
|
||||
"github.com/pocketbase/dbx"
|
||||
"github.com/pocketbase/pocketbase/apis"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/tools/security"
|
||||
)
|
||||
|
||||
func IntegrationStravaToken(e *core.RequestEvent) error {
|
||||
encryptionKey := os.Getenv("POCKETBASE_ENCRYPTION_KEY")
|
||||
if len(encryptionKey) == 0 {
|
||||
return apis.NewBadRequestError("POCKETBASE_ENCRYPTION_KEY not set", nil)
|
||||
}
|
||||
|
||||
var data strava.TokenRequest
|
||||
if err := e.BindBody(&data); err != nil {
|
||||
return apis.NewBadRequestError("Failed to read request data", err)
|
||||
}
|
||||
|
||||
userId := ""
|
||||
if e.Auth != nil {
|
||||
userId = e.Auth.Id
|
||||
} else {
|
||||
return e.UnauthorizedError("authentication required", nil)
|
||||
}
|
||||
|
||||
integrations, err := e.App.FindAllRecords("integrations", dbx.NewExp("user = {:id}", dbx.Params{"id": userId}))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(integrations) == 0 {
|
||||
return apis.NewBadRequestError("user has no integration", nil)
|
||||
}
|
||||
integration := integrations[0]
|
||||
stravaString := integration.GetString("strava")
|
||||
if len(stravaString) == 0 {
|
||||
return apis.NewBadRequestError("strava integration missing", nil)
|
||||
}
|
||||
var stravaIntegration strava.StravaIntegration
|
||||
err = json.Unmarshal([]byte(stravaString), &stravaIntegration)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
decryptedSecret, err := security.Decrypt(stravaIntegration.ClientSecret, encryptionKey)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
request := strava.TokenRequest{
|
||||
ClientID: stravaIntegration.ClientID,
|
||||
ClientSecret: string(decryptedSecret),
|
||||
Code: data.Code,
|
||||
GrantType: "authorization_code",
|
||||
}
|
||||
r, err := strava.GetStravaToken(request)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if r.AccessToken != "" {
|
||||
stravaIntegration.AccessToken = r.AccessToken
|
||||
}
|
||||
if r.RefreshToken != "" {
|
||||
stravaIntegration.RefreshToken = r.RefreshToken
|
||||
}
|
||||
if r.AccessToken != "" {
|
||||
stravaIntegration.ExpiresAt = r.ExpiresAt
|
||||
}
|
||||
|
||||
stravaIntegration.Active = true
|
||||
|
||||
b, err := json.Marshal(stravaIntegration)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
integration.Set("strava", string(b))
|
||||
err = e.App.Save(integration)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return e.JSON(http.StatusOK, nil)
|
||||
}
|
||||
210
db/routes/remote_list.go
Normal file
210
db/routes/remote_list.go
Normal file
@@ -0,0 +1,210 @@
|
||||
package routes
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"pocketbase/federation"
|
||||
"pocketbase/util"
|
||||
"time"
|
||||
|
||||
"github.com/pocketbase/dbx"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
)
|
||||
|
||||
// --- Main Handler ---
|
||||
|
||||
func RemoteListGet(e *core.RequestEvent) error {
|
||||
handle := e.Request.URL.Query().Get("handle")
|
||||
listID := e.Request.PathValue("id")
|
||||
expandQuery := e.Request.URL.Query().Get("expand")
|
||||
|
||||
var record *core.Record
|
||||
var err error
|
||||
|
||||
var userActor *core.Record
|
||||
if e.Auth != nil {
|
||||
userActor, _ = e.App.FindFirstRecordByData("activitypub_actors", "user", e.Auth.Id)
|
||||
}
|
||||
|
||||
ctx, err := util.GetSafeActorContext(e.Request, userActor)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if handle != "" {
|
||||
record, err = findLocalListByRemoteInfo(e, ctx, handle, listID)
|
||||
if err != nil {
|
||||
return e.InternalServerError("Failed to resolve trail", err)
|
||||
}
|
||||
|
||||
if record.Id == "" || record.GetBool("needs_full_sync") {
|
||||
record, err = performFullListSync(e.App, ctx, e.Request.URL, record)
|
||||
if err != nil {
|
||||
if errors.Is(err, util.ErrRateLimited) {
|
||||
return e.TooManyRequestsError("Too many requests", err)
|
||||
}
|
||||
return e.InternalServerError("Sync failed", err)
|
||||
}
|
||||
} else {
|
||||
updatedAt := record.GetDateTime("updated").Time()
|
||||
if time.Now().UTC().Sub(updatedAt) > 60*time.Minute {
|
||||
go performFullListSync(e.App, ctx, e.Request.URL, record)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
record, err = e.App.FindRecordById("lists", listID)
|
||||
if err != nil {
|
||||
return e.NotFoundError("List not found", nil)
|
||||
}
|
||||
}
|
||||
|
||||
return expandAndReturn(e, record, expandQuery)
|
||||
}
|
||||
|
||||
func findLocalListByRemoteInfo(e *core.RequestEvent, ctx context.Context, handle, trailID string) (*core.Record, error) {
|
||||
// 1. Get Actor to build the IRI
|
||||
actor, err := federation.GetActorByHandle(e.App, ctx, handle, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
actorURL, _ := url.Parse(actor.GetString("iri"))
|
||||
iri := fmt.Sprintf("%s://%s/api/v1/list/%s", actorURL.Scheme, actorURL.Host, trailID)
|
||||
|
||||
// 2. Check if this IRI already exists in our DB
|
||||
existing, _ := e.App.FindFirstRecordByFilter("lists", "iri={:iri}||id={:id}", dbx.Params{"id": trailID, "iri": iri})
|
||||
if existing != nil {
|
||||
return existing, nil
|
||||
}
|
||||
|
||||
// 3. Not found? Return a new Shell
|
||||
collection, _ := e.App.FindCollectionByNameOrId("lists")
|
||||
shell := core.NewRecord(collection)
|
||||
shell.Set("iri", iri)
|
||||
shell.Set("author", actor.Id)
|
||||
|
||||
return shell, nil
|
||||
}
|
||||
|
||||
func performFullListSync(app core.App, ctx context.Context, reqURL *url.URL, localList *core.Record) (*core.Record, error) {
|
||||
client := util.SafeHTTPClient()
|
||||
|
||||
iri := localList.GetString("iri")
|
||||
remoteUrl, _ := url.Parse(iri)
|
||||
remoteUrl.RawQuery = reqURL.RawQuery
|
||||
origin := fmt.Sprintf("%s://%s", remoteUrl.Scheme, remoteUrl.Host)
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, remoteUrl.String(), nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
res, err := client.Do(req)
|
||||
if err != nil || res.StatusCode != 200 {
|
||||
return localList, err
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
var remoteMap map[string]any
|
||||
if err := json.NewDecoder(res.Body).Decode(&remoteMap); err != nil {
|
||||
return localList, err
|
||||
}
|
||||
|
||||
err = app.RunInTransaction(func(txApp core.App) error {
|
||||
remoteID, _ := remoteMap["id"].(string)
|
||||
|
||||
// 1. Sync Files
|
||||
syncListRecordFiles(ctx, localList, "lists", remoteID, origin, remoteMap)
|
||||
|
||||
// 2. Map Relations & Simple Fields
|
||||
syncListMetadata(localList, remoteMap)
|
||||
|
||||
localList.Set("needs_full_sync", false)
|
||||
|
||||
// 3. Sync Trails
|
||||
if expand, ok := remoteMap["expand"].(map[string]any); ok {
|
||||
if trails, ok := expand["trails"].([]any); ok {
|
||||
err = syncTrails(txApp, ctx, localList, origin, trails)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err := txApp.Save(localList); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
return localList, err
|
||||
}
|
||||
|
||||
func syncListMetadata(record *core.Record, data map[string]any) {
|
||||
delete(data, "id")
|
||||
delete(data, "avatar")
|
||||
delete(data, "author")
|
||||
delete(data, "iri")
|
||||
|
||||
record.Load(data)
|
||||
}
|
||||
|
||||
func syncListRecordFiles(ctx context.Context, record *core.Record, collection, remoteID, origin string, data map[string]any) {
|
||||
if gpx, ok := data["avatar"].(string); ok && record.GetString("avatar") == "" {
|
||||
if f, err := downloadFile(ctx, origin, collection, remoteID, gpx); err == nil {
|
||||
record.Set("avatar", f)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func syncTrails(txApp core.App, ctx context.Context, list *core.Record, origin string, trails []any) error {
|
||||
col, _ := txApp.FindCollectionByNameOrId("trails")
|
||||
|
||||
localTrails := make([]string, 0, len(trails))
|
||||
|
||||
for _, tData := range trails {
|
||||
raw := tData.(map[string]any)
|
||||
tID, _ := raw["id"].(string)
|
||||
iri, _ := raw["iri"].(string)
|
||||
if iri == "" {
|
||||
iri = fmt.Sprintf("%s/api/v1/trail/%s", origin, tID)
|
||||
}
|
||||
|
||||
trail, _ := txApp.FindFirstRecordByData("trails", "iri", iri)
|
||||
if trail == nil {
|
||||
trail = core.NewRecord(col)
|
||||
trail.Set("needs_full_sync", true)
|
||||
}
|
||||
|
||||
syncTrailMetadata(txApp, trail, raw)
|
||||
|
||||
author := list.GetString("author")
|
||||
if expand, ok := raw["expand"].(map[string]any); ok {
|
||||
if authorMap, ok := expand["author"].(map[string]any); ok {
|
||||
actor, err := federation.GetActorByIRI(txApp, ctx, authorMap["iri"].(string), false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
author = actor.Id
|
||||
}
|
||||
}
|
||||
|
||||
trail.Set("author", author)
|
||||
trail.Set("iri", iri)
|
||||
|
||||
if err := txApp.Save(trail); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
localTrails = append(localTrails, trail.Id)
|
||||
|
||||
}
|
||||
|
||||
list.Set("trails", localTrails)
|
||||
return nil
|
||||
}
|
||||
165
db/routes/remote_profile_follow.go
Normal file
165
db/routes/remote_profile_follow.go
Normal file
@@ -0,0 +1,165 @@
|
||||
package routes
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"net/http"
|
||||
"pocketbase/federation"
|
||||
"pocketbase/util"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
pub "github.com/go-ap/activitypub"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
)
|
||||
|
||||
func RemoteProfileFollowsList(e *core.RequestEvent) error {
|
||||
handle := e.Request.PathValue("handle")
|
||||
if handle == "" {
|
||||
return e.BadRequestError("Missing required parameter 'handle'", nil)
|
||||
}
|
||||
|
||||
followType := e.Request.URL.Query().Get("type")
|
||||
if followType != "following" {
|
||||
followType = "followers"
|
||||
}
|
||||
|
||||
pageQuery := e.Request.URL.Query().Get("page")
|
||||
if pageQuery == "" {
|
||||
pageQuery = "1"
|
||||
}
|
||||
page, _ := strconv.Atoi(pageQuery)
|
||||
|
||||
var userActor *core.Record
|
||||
if e.Auth != nil {
|
||||
userActor, _ = e.App.FindFirstRecordByData("activitypub_actors", "user", e.Auth.Id)
|
||||
}
|
||||
|
||||
ctx, err := util.GetSafeActorContext(e.Request, userActor)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 1. Resolve Target Actor
|
||||
actor, err := federation.GetActorByHandle(e.App, ctx, handle, false)
|
||||
if err != nil {
|
||||
return e.NotFoundError("Actor not found", err)
|
||||
}
|
||||
|
||||
collectionIRI := actor.GetString(followType)
|
||||
if collectionIRI == "" {
|
||||
return e.BadRequestError(fmt.Sprintf("Actor has no %s collection", followType), nil)
|
||||
}
|
||||
|
||||
// 2. Fetch Remote Content
|
||||
client := util.SafeHTTPClient()
|
||||
req, _ := http.NewRequestWithContext(ctx, "GET", fmt.Sprintf("%s?page=%d", collectionIRI, page), nil)
|
||||
req.Header.Set("Accept", "application/activity+json")
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil || resp.StatusCode != http.StatusOK {
|
||||
if errors.Is(err, util.ErrRateLimited) {
|
||||
return e.TooManyRequestsError("Too many requests", err)
|
||||
}
|
||||
return e.InternalServerError("Failed to fetch remote collection", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return e.InternalServerError("Failed to read response body", err)
|
||||
}
|
||||
|
||||
// 3. Proper Unmarshaling using go-ap
|
||||
// This returns a pub.Item interface which could be an OrderedCollection,
|
||||
// OrderedCollectionPage, or even a simple Object.
|
||||
data, err := pub.UnmarshalJSON(body)
|
||||
if err != nil {
|
||||
return e.InternalServerError("Failed to unmarshal ActivityPub JSON", err)
|
||||
}
|
||||
|
||||
var items pub.ItemCollection
|
||||
var totalItems uint = 0
|
||||
|
||||
// 4. Type assertion using go-ap's type switch pattern
|
||||
err = pub.OnOrderedCollectionPage(data, func(p *pub.OrderedCollectionPage) error {
|
||||
items = p.OrderedItems
|
||||
totalItems = p.TotalItems
|
||||
return nil
|
||||
})
|
||||
|
||||
// Fallback: some instances might return a plain OrderedCollection
|
||||
// if the page isn't strictly formatted as a Page object
|
||||
if err != nil || items == nil {
|
||||
_ = pub.OnOrderedCollection(data, func(c *pub.OrderedCollection) error {
|
||||
items = c.OrderedItems
|
||||
totalItems = c.TotalItems
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// 5. Resolve IRIs to Local Records
|
||||
timeoutCtx, cancel := context.WithTimeout(ctx, 3*time.Second)
|
||||
defer cancel()
|
||||
|
||||
var mu sync.Mutex
|
||||
var wg sync.WaitGroup
|
||||
resolvedItems := make([]*core.Record, 0, len(items))
|
||||
|
||||
for _, item := range items {
|
||||
iri := item.GetLink().String()
|
||||
if iri == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
wg.Add(1)
|
||||
go func(actorIRI string) {
|
||||
defer wg.Done()
|
||||
|
||||
// We use a channel to wrap the GetActorByIRI call
|
||||
// so we can respect the context timeout
|
||||
done := make(chan *core.Record, 1)
|
||||
go func() {
|
||||
// Pass false to sync to prevent deep recursion/heavy syncing if possible
|
||||
res, err := federation.GetActorByIRI(e.App, timeoutCtx, actorIRI, false)
|
||||
if err == nil {
|
||||
done <- res
|
||||
} else {
|
||||
done <- nil
|
||||
}
|
||||
}()
|
||||
|
||||
select {
|
||||
case itemActor := <-done:
|
||||
if itemActor != nil {
|
||||
mu.Lock()
|
||||
resolvedItems = append(resolvedItems, itemActor)
|
||||
mu.Unlock()
|
||||
}
|
||||
case <-ctx.Done():
|
||||
// Timeout reached for this specific resolution
|
||||
return
|
||||
}
|
||||
}(iri)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
|
||||
// 6. Pagination Metadata
|
||||
perPage := 10
|
||||
if len(items) > 0 {
|
||||
perPage = len(items)
|
||||
}
|
||||
|
||||
return e.JSON(http.StatusOK, map[string]any{
|
||||
"page": page,
|
||||
"perPage": perPage,
|
||||
"totalItems": totalItems,
|
||||
"totalPages": math.Ceil(float64(totalItems) / float64(perPage)),
|
||||
"items": resolvedItems,
|
||||
})
|
||||
}
|
||||
317
db/routes/remote_trail.go
Normal file
317
db/routes/remote_trail.go
Normal file
@@ -0,0 +1,317 @@
|
||||
package routes
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"path"
|
||||
"pocketbase/federation"
|
||||
"pocketbase/util"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/pocketbase/dbx"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/tools/filesystem"
|
||||
)
|
||||
|
||||
// --- Main Handler ---
|
||||
|
||||
func RemoteTrailGet(e *core.RequestEvent) error {
|
||||
handle := e.Request.URL.Query().Get("handle")
|
||||
trailID := e.Request.PathValue("id")
|
||||
expandQuery := e.Request.URL.Query().Get("expand")
|
||||
|
||||
var record *core.Record
|
||||
var err error
|
||||
|
||||
var userActor *core.Record
|
||||
if e.Auth != nil {
|
||||
userActor, _ = e.App.FindFirstRecordByData("activitypub_actors", "user", e.Auth.Id)
|
||||
}
|
||||
|
||||
ctx, err := util.GetSafeActorContext(e.Request, userActor)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 1. Resolve the "Actual" Record or Shell
|
||||
if handle != "" {
|
||||
// If we have a handle, we are looking for a remote trail.
|
||||
// Construct the IRI first to see if we already know this trail.
|
||||
record, err = findLocalTrailByRemoteInfo(e, ctx, handle, trailID)
|
||||
if err != nil {
|
||||
return e.InternalServerError("Failed to resolve trail", err)
|
||||
}
|
||||
|
||||
// If the record has no ID, it's a new Shell
|
||||
if record.Id == "" || record.GetBool("needs_full_sync") {
|
||||
// Blocking sync for new records
|
||||
record, err = performFullSync(e.App, ctx, e.Request.URL, record)
|
||||
if err != nil {
|
||||
if errors.Is(err, util.ErrRateLimited) {
|
||||
return e.TooManyRequestsError("Too many requests", err)
|
||||
}
|
||||
return e.InternalServerError("Sync failed", err)
|
||||
}
|
||||
} else {
|
||||
// We already have it locally. Show and update background.
|
||||
updatedAt := record.GetDateTime("updated").Time()
|
||||
if time.Now().UTC().Sub(updatedAt) > 60*time.Minute {
|
||||
go performFullSync(e.App, ctx, e.Request.URL, record)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Standard local fetch by ID
|
||||
record, err = e.App.FindRecordById("trails", trailID)
|
||||
if err != nil {
|
||||
return e.NotFoundError("Trail not found", nil)
|
||||
}
|
||||
}
|
||||
|
||||
return expandAndReturn(e, record, expandQuery)
|
||||
}
|
||||
|
||||
func findLocalTrailByRemoteInfo(e *core.RequestEvent, ctx context.Context, handle, trailID string) (*core.Record, error) {
|
||||
// 1. Get Actor to build the IRI
|
||||
actor, err := federation.GetActorByHandle(e.App, ctx, handle, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
actorURL, _ := url.Parse(actor.GetString("iri"))
|
||||
iri := fmt.Sprintf("%s://%s/api/v1/trail/%s", actorURL.Scheme, actorURL.Host, trailID)
|
||||
|
||||
// 2. Check if this IRI already exists in our DB
|
||||
existing, _ := e.App.FindFirstRecordByFilter("trails", "iri={:iri}||id={:id}", dbx.Params{"id": trailID, "iri": iri})
|
||||
if existing != nil {
|
||||
return existing, nil
|
||||
}
|
||||
|
||||
// 3. Not found? Return a new Shell
|
||||
collection, _ := e.App.FindCollectionByNameOrId("trails")
|
||||
shell := core.NewRecord(collection)
|
||||
shell.Set("iri", iri)
|
||||
shell.Set("author", actor.Id)
|
||||
shell.Set("like_count", 0)
|
||||
|
||||
return shell, nil
|
||||
}
|
||||
|
||||
// --- Core Sync Logic ---
|
||||
|
||||
func performFullSync(app core.App, ctx context.Context, reqURL *url.URL, localTrail *core.Record) (*core.Record, error) {
|
||||
client := util.SafeHTTPClient()
|
||||
|
||||
iri := localTrail.GetString("iri")
|
||||
remoteUrl, _ := url.Parse(iri)
|
||||
remoteUrl.RawQuery = reqURL.RawQuery // Forward params
|
||||
origin := fmt.Sprintf("%s://%s", remoteUrl.Scheme, remoteUrl.Host)
|
||||
|
||||
req, _ := http.NewRequestWithContext(ctx, "GET", remoteUrl.String(), nil)
|
||||
res, err := client.Do(req)
|
||||
if err != nil || res.StatusCode != 200 {
|
||||
return localTrail, err
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
var remoteMap map[string]any
|
||||
if err := json.NewDecoder(res.Body).Decode(&remoteMap); err != nil {
|
||||
return localTrail, err
|
||||
}
|
||||
|
||||
err = app.RunInTransaction(func(txApp core.App) error {
|
||||
remoteID, _ := remoteMap["id"].(string)
|
||||
|
||||
// 1. Sync Files
|
||||
syncRecordFiles(ctx, localTrail, "trails", remoteID, origin, remoteMap)
|
||||
|
||||
// 2. Map Relations & Simple Fields
|
||||
syncTrailMetadata(txApp, localTrail, remoteMap)
|
||||
|
||||
localTrail.Set("needs_full_sync", false)
|
||||
|
||||
if err := txApp.Save(localTrail); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 3. Sync Waypoints
|
||||
if expand, ok := remoteMap["expand"].(map[string]any); ok {
|
||||
if wps, ok := expand["waypoints_via_trail"].([]any); ok {
|
||||
err = syncWaypoints(txApp, ctx, localTrail, origin, wps)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Sync SummitLogs
|
||||
if expand, ok := remoteMap["expand"].(map[string]any); ok {
|
||||
if sls, ok := expand["summit_logs_via_trail"].([]any); ok {
|
||||
err = syncSummitLogs(txApp, ctx, localTrail, origin, sls)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
return localTrail, err
|
||||
}
|
||||
|
||||
// --- Sub-Sync Helpers ---
|
||||
|
||||
func syncTrailMetadata(app core.App, record *core.Record, data map[string]any) {
|
||||
// Resolve Category if present in expand
|
||||
if expand, ok := data["expand"].(map[string]any); ok {
|
||||
if cat, ok := expand["category"].(map[string]any); ok {
|
||||
if name, ok := cat["name"].(string); ok {
|
||||
if c, _ := app.FindFirstRecordByData("categories", "name", name); c != nil {
|
||||
record.Set("category", c.Id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Clean protected/complex fields before bulk load
|
||||
delete(data, "id")
|
||||
delete(data, "photos")
|
||||
delete(data, "gpx")
|
||||
delete(data, "author")
|
||||
delete(data, "category")
|
||||
delete(data, "iri")
|
||||
|
||||
record.Load(data)
|
||||
}
|
||||
|
||||
func syncWaypoints(txApp core.App, ctx context.Context, trail *core.Record, origin string, waypoints []any) error {
|
||||
col, _ := txApp.FindCollectionByNameOrId("waypoints")
|
||||
|
||||
for _, wData := range waypoints {
|
||||
raw := wData.(map[string]any)
|
||||
wpID, _ := raw["id"].(string)
|
||||
iri, _ := raw["iri"].(string)
|
||||
if iri == "" {
|
||||
iri = fmt.Sprintf("%s/api/v1/waypoint/%s", origin, wpID)
|
||||
}
|
||||
|
||||
wp, _ := txApp.FindFirstRecordByData("waypoints", "iri", iri)
|
||||
if wp == nil {
|
||||
wp = core.NewRecord(col)
|
||||
}
|
||||
|
||||
syncRecordFiles(ctx, wp, "waypoints", wpID, origin, raw)
|
||||
|
||||
delete(raw, "id")
|
||||
delete(raw, "photos")
|
||||
wp.Load(raw)
|
||||
wp.Set("author", trail.GetString("author"))
|
||||
wp.Set("trail", trail.Id)
|
||||
wp.Set("iri", iri)
|
||||
|
||||
if err := txApp.Save(wp); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func syncSummitLogs(txApp core.App, ctx context.Context, trail *core.Record, origin string, summitLogs []any) error {
|
||||
col, _ := txApp.FindCollectionByNameOrId("summit_logs")
|
||||
|
||||
for _, slData := range summitLogs {
|
||||
raw := slData.(map[string]any)
|
||||
slID, _ := raw["id"].(string)
|
||||
iri, _ := raw["iri"].(string)
|
||||
if iri == "" {
|
||||
iri = fmt.Sprintf("%s/api/v1/summit_logs/%s", origin, slID)
|
||||
}
|
||||
|
||||
remoteSummitLogUrl, _ := url.Parse(iri)
|
||||
possibleLocalId := path.Base(remoteSummitLogUrl.Path)
|
||||
|
||||
sl, _ := txApp.FindFirstRecordByFilter("summit_logs", "iri={:iri} || id={:id}", dbx.Params{"id": possibleLocalId, "iri": iri})
|
||||
if sl == nil {
|
||||
sl = core.NewRecord(col)
|
||||
}
|
||||
|
||||
author := trail.GetString("author")
|
||||
if expand, ok := raw["expand"].(map[string]any); ok {
|
||||
if authorMap, ok := expand["author"].(map[string]any); ok {
|
||||
actor, err := federation.GetActorByIRI(txApp, ctx, authorMap["iri"].(string), false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
author = actor.Id
|
||||
}
|
||||
}
|
||||
|
||||
syncRecordFiles(ctx, sl, "summit_logs", slID, origin, raw)
|
||||
|
||||
delete(raw, "id")
|
||||
delete(raw, "photos")
|
||||
delete(raw, "gpx")
|
||||
|
||||
sl.Load(raw)
|
||||
sl.Set("author", author)
|
||||
sl.Set("trail", trail.Id)
|
||||
sl.Set("iri", iri)
|
||||
|
||||
if err := txApp.Save(sl); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func syncRecordFiles(ctx context.Context, record *core.Record, collection, remoteID, origin string, data map[string]any) {
|
||||
// Handle GPX
|
||||
if gpx, ok := data["gpx"].(string); ok && record.GetString("gpx") == "" {
|
||||
if f, err := downloadFile(ctx, origin, collection, remoteID, gpx); err == nil {
|
||||
record.Set("gpx", f)
|
||||
}
|
||||
}
|
||||
|
||||
// Handle Photos
|
||||
if photos, ok := data["photos"].([]any); ok && len(record.GetStringSlice("photos")) == 0 {
|
||||
var files []*filesystem.File
|
||||
for _, p := range photos {
|
||||
if f, err := downloadFile(ctx, origin, collection, remoteID, p.(string)); err == nil {
|
||||
files = append(files, f)
|
||||
}
|
||||
}
|
||||
if len(files) > 0 {
|
||||
record.Set("photos", files)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func downloadFile(ctx context.Context, origin, col, id, name string) (*filesystem.File, error) {
|
||||
client := util.SafeHTTPClient()
|
||||
|
||||
url := fmt.Sprintf("%s/api/v1/files/%s/%s/%s", origin, col, id, name)
|
||||
|
||||
req, _ := http.NewRequestWithContext(ctx, "GET", url, nil)
|
||||
|
||||
res, err := client.Do(req)
|
||||
if err != nil || res.StatusCode != 200 {
|
||||
return nil, fmt.Errorf("download failed")
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
data, _ := io.ReadAll(res.Body)
|
||||
return filesystem.NewFileFromBytes(data, name)
|
||||
}
|
||||
|
||||
func expandAndReturn(e *core.RequestEvent, record *core.Record, query string) error {
|
||||
if query != "" {
|
||||
e.App.ExpandRecord(record, strings.Split(query, ","), nil)
|
||||
}
|
||||
return e.JSON(http.StatusOK, record)
|
||||
}
|
||||
186
db/routes/remote_trail_comment.go
Normal file
186
db/routes/remote_trail_comment.go
Normal file
@@ -0,0 +1,186 @@
|
||||
package routes
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"path"
|
||||
"pocketbase/federation"
|
||||
"pocketbase/util"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/pocketbase/dbx"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
)
|
||||
|
||||
func RemoteTrailCommentsList(e *core.RequestEvent) error {
|
||||
trailID := e.Request.PathValue("id")
|
||||
expandQuery := e.Request.URL.Query().Get("expand")
|
||||
sort := e.Request.URL.Query().Get("sort")
|
||||
|
||||
if sort == "" {
|
||||
sort = "-created"
|
||||
}
|
||||
|
||||
page, _ := strconv.Atoi(e.Request.URL.Query().Get("page"))
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
perPage, _ := strconv.Atoi(e.Request.URL.Query().Get("perPage"))
|
||||
if perPage < 1 {
|
||||
perPage = 30
|
||||
}
|
||||
|
||||
trail, err := e.App.FindRecordById("trails", trailID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Sync remote data first (Fetch + Save)
|
||||
if trail.GetString("iri") != "" {
|
||||
_ = syncRemoteComments(e, trail)
|
||||
}
|
||||
|
||||
// 1. Calculate Offset
|
||||
offset := (page - 1) * perPage
|
||||
|
||||
// 2. Fetch the records using FindRecordsByFilter
|
||||
records, err := e.App.FindRecordsByFilter(
|
||||
"comments",
|
||||
"trail = {:trailId}",
|
||||
sort,
|
||||
perPage,
|
||||
offset,
|
||||
dbx.Params{"trailId": trail.Id},
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 3. Get total count for pagination metadata
|
||||
var totalItems int
|
||||
err = e.App.DB().
|
||||
Select("count(*)").
|
||||
From("comments").
|
||||
Where(dbx.HashExp{"trail": trail.Id}).
|
||||
Row(&totalItems)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 4. Handle Expand
|
||||
if expandQuery != "" {
|
||||
errs := e.App.ExpandRecords(records, strings.Split(expandQuery, ","), nil)
|
||||
if len(errs) > 0 {
|
||||
fmt.Printf("Expand errors: %v\n", errs)
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Manually construct the response object
|
||||
return e.JSON(http.StatusOK, map[string]any{
|
||||
"page": page,
|
||||
"perPage": perPage,
|
||||
"totalItems": totalItems,
|
||||
"totalPages": (totalItems + perPage - 1) / perPage,
|
||||
"items": records,
|
||||
})
|
||||
}
|
||||
|
||||
func syncRemoteComments(e *core.RequestEvent, trail *core.Record) error {
|
||||
client := util.SafeHTTPClient()
|
||||
|
||||
var userActor *core.Record
|
||||
if e.Auth != nil {
|
||||
userActor, _ = e.App.FindFirstRecordByData("activitypub_actors", "user", e.Auth.Id)
|
||||
}
|
||||
|
||||
ctx, err := util.GetSafeActorContext(e.Request, userActor)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
trailIRI := trail.GetString("iri")
|
||||
u, _ := url.Parse(trailIRI)
|
||||
|
||||
remoteTrailID := path.Base(u.Path)
|
||||
|
||||
remoteURL := fmt.Sprintf("%s://%s/api/v1/comment?filter=trail='%s'&expand=author", u.Scheme, u.Host, remoteTrailID)
|
||||
|
||||
req, _ := http.NewRequestWithContext(ctx, "GET", remoteURL, nil)
|
||||
res, err := client.Do(req)
|
||||
if err != nil || res.StatusCode != 200 {
|
||||
if errors.Is(err, util.ErrRateLimited) {
|
||||
return e.TooManyRequestsError("Too many requests", err)
|
||||
}
|
||||
return fmt.Errorf("remote fetch failed: %w", err)
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
var remoteData struct {
|
||||
Items []map[string]any `json:"items"`
|
||||
}
|
||||
if err := json.NewDecoder(res.Body).Decode(&remoteData); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
collection, _ := e.App.FindCollectionByNameOrId("comments")
|
||||
|
||||
return e.App.RunInTransaction(func(txApp core.App) error {
|
||||
for _, raw := range remoteData.Items {
|
||||
remoteIRI, _ := raw["iri"].(string)
|
||||
if remoteIRI == "" {
|
||||
remoteID, _ := raw["id"].(string)
|
||||
remoteIRI = fmt.Sprintf("%s://%s/api/v1/comment/%s", u.Scheme, u.Host, remoteID)
|
||||
}
|
||||
|
||||
remoteCommentUrl, _ := url.Parse(remoteIRI)
|
||||
possibleLocalId := path.Base(remoteCommentUrl.Path)
|
||||
|
||||
// Find existing record by IRI or ID to avoid duplicates
|
||||
commentRecord, _ := txApp.FindFirstRecordByFilter("comments", "iri={:iri} || id={:id}", dbx.Params{"id": possibleLocalId, "iri": remoteIRI})
|
||||
if commentRecord == nil {
|
||||
commentRecord = core.NewRecord(collection)
|
||||
commentRecord.Set("iri", remoteIRI)
|
||||
commentRecord.Set("trail", trail.Id)
|
||||
}
|
||||
|
||||
// Resolve federated author
|
||||
if expand, ok := raw["expand"].(map[string]any); ok {
|
||||
if author, ok := expand["author"].(map[string]any); ok {
|
||||
authorIRI, _ := author["iri"].(string)
|
||||
actor, err := federation.GetActorByIRI(txApp, ctx, authorIRI, false)
|
||||
if err == nil {
|
||||
raw["author"] = actor.Id
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
delete(raw, "id")
|
||||
delete(raw, "trail")
|
||||
delete(raw, "expand")
|
||||
delete(raw, "iri")
|
||||
commentRecord.Load(raw)
|
||||
|
||||
if err := txApp.Save(commentRecord); err != nil {
|
||||
continue
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func expandAndReturnList(e *core.RequestEvent, records []*core.Record, query string) error {
|
||||
if query != "" {
|
||||
expandPaths := strings.Split(query, ",")
|
||||
|
||||
errs := e.App.ExpandRecords(records, expandPaths, nil)
|
||||
if len(errs) > 0 {
|
||||
fmt.Printf("Expand errors: %v\n", errs)
|
||||
}
|
||||
}
|
||||
|
||||
return e.JSON(http.StatusOK, records)
|
||||
}
|
||||
44
db/routes/search_token.go
Normal file
44
db/routes/search_token.go
Normal file
@@ -0,0 +1,44 @@
|
||||
package routes
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"pocketbase/util"
|
||||
|
||||
"github.com/meilisearch/meilisearch-go"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
)
|
||||
|
||||
func SearchToken(client meilisearch.ServiceManager) func(e *core.RequestEvent) error {
|
||||
return func(e *core.RequestEvent) error {
|
||||
searchRules := map[string]interface{}{
|
||||
"lists": map[string]string{"filter": "public = true"},
|
||||
"trails": map[string]string{"filter": "public = true"},
|
||||
}
|
||||
|
||||
if e.Auth != nil {
|
||||
userId := e.Auth.Id
|
||||
userActor, err := e.App.FindFirstRecordByData("activitypub_actors", "user", e.Auth.Id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
searchRules = map[string]any{
|
||||
"lists": map[string]string{
|
||||
"filter": "public = true OR author = " + userActor.Id + " OR shares = " + userId,
|
||||
},
|
||||
"trails": map[string]string{
|
||||
"filter": "public = true OR author = " + userActor.Id + " OR shares = " + userId,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
token, err := util.GenerateMeilisearchToken(searchRules, client)
|
||||
if err != nil {
|
||||
return e.InternalServerError("Failed to generate search token", err)
|
||||
}
|
||||
|
||||
return e.JSON(http.StatusOK, map[string]string{
|
||||
"token": token,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,13 @@
|
||||
package main
|
||||
package routes
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"pocketbase/services/trailmerge"
|
||||
"pocketbase/util"
|
||||
|
||||
"github.com/meilisearch/meilisearch-go"
|
||||
"github.com/pocketbase/pocketbase/apis"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
|
||||
"pocketbase/trailmerge"
|
||||
)
|
||||
|
||||
type mergeExecuteRequest struct {
|
||||
@@ -16,13 +16,12 @@ type mergeExecuteRequest struct {
|
||||
Settings trailmerge.MergeSettings `json:"settings"`
|
||||
}
|
||||
|
||||
func registerTrailMergeRoutes(se *core.ServeEvent, client meilisearch.ServiceManager) {
|
||||
se.Router.POST("/trail-merge/suggest", func(e *core.RequestEvent) error {
|
||||
func TrailMergeSuggest(e *core.RequestEvent) error {
|
||||
if e.Auth == nil {
|
||||
return apis.NewUnauthorizedError("trail_merge_auth_required", nil)
|
||||
}
|
||||
|
||||
actor, err := e.App.FindFirstRecordByData("activitypub_actors", "user", e.Auth.Id)
|
||||
userActor, err := e.App.FindFirstRecordByData("activitypub_actors", "user", e.Auth.Id)
|
||||
if err != nil {
|
||||
return apis.NewBadRequestError("trail_merge_actor_not_found", err)
|
||||
}
|
||||
@@ -33,7 +32,7 @@ func registerTrailMergeRoutes(se *core.ServeEvent, client meilisearch.ServiceMan
|
||||
}
|
||||
|
||||
if request.Mode == trailmerge.SuggestModeMaintenance {
|
||||
response, err := trailmerge.SuggestGroups(e.App, actor.Id, request)
|
||||
response, err := trailmerge.SuggestGroups(e.App, userActor.Id, request)
|
||||
if err != nil {
|
||||
return apis.NewBadRequestError(err.Error(), err)
|
||||
}
|
||||
@@ -41,20 +40,21 @@ func registerTrailMergeRoutes(se *core.ServeEvent, client meilisearch.ServiceMan
|
||||
return e.JSON(http.StatusOK, response)
|
||||
}
|
||||
|
||||
response, err := trailmerge.Suggest(e.App, actor.Id, request)
|
||||
response, err := trailmerge.Suggest(e.App, userActor.Id, request)
|
||||
if err != nil {
|
||||
return apis.NewBadRequestError(err.Error(), err)
|
||||
}
|
||||
|
||||
return e.JSON(http.StatusOK, response)
|
||||
})
|
||||
}
|
||||
|
||||
se.Router.POST("/trail-merge", func(e *core.RequestEvent) error {
|
||||
func TrailMerge(client meilisearch.ServiceManager) func(e *core.RequestEvent) error {
|
||||
return func(e *core.RequestEvent) error {
|
||||
if e.Auth == nil {
|
||||
return apis.NewUnauthorizedError("trail_merge_auth_required", nil)
|
||||
}
|
||||
|
||||
actor, err := e.App.FindFirstRecordByData("activitypub_actors", "user", e.Auth.Id)
|
||||
userActor, err := e.App.FindFirstRecordByData("activitypub_actors", "user", e.Auth.Id)
|
||||
if err != nil {
|
||||
return apis.NewBadRequestError("trail_merge_actor_not_found", err)
|
||||
}
|
||||
@@ -73,16 +73,18 @@ func registerTrailMergeRoutes(se *core.ServeEvent, client meilisearch.ServiceMan
|
||||
return apis.NewBadRequestError("trail_merge_target_not_found", err)
|
||||
}
|
||||
|
||||
if !trailmerge.CanMerge(e.App, actor.Id, source, target, request.Settings.Delete) {
|
||||
if !trailmerge.CanMerge(e.App, userActor.Id, source, target, request.Settings.Delete) {
|
||||
return apis.NewForbiddenError("trail_merge_not_allowed", nil)
|
||||
}
|
||||
|
||||
if err := trailmerge.Merge(e.App, client, actor, request.SourceTrailID, request.TargetTrailID, request.Settings); err != nil {
|
||||
ctx, err := util.GetSafeActorContext(e.Request, userActor)
|
||||
|
||||
if err := trailmerge.Merge(e.App, client, ctx, userActor, request.SourceTrailID, request.TargetTrailID, request.Settings); err != nil {
|
||||
return apis.NewBadRequestError(err.Error(), err)
|
||||
}
|
||||
|
||||
return e.JSON(http.StatusOK, map[string]any{
|
||||
"acknowledged": true,
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package waypointcluster
|
||||
package routes
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
@@ -49,7 +49,7 @@ type categorySettings struct {
|
||||
WaypointMergeRadius *float64 `json:"wp_merge_radius"`
|
||||
}
|
||||
|
||||
func Handler(e *core.RequestEvent) error {
|
||||
func WaypointCluster(e *core.RequestEvent) error {
|
||||
if e.Auth == nil {
|
||||
return apis.NewUnauthorizedError("authentication required", nil)
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
package trailmerge
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/meilisearch/meilisearch-go"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
)
|
||||
@@ -8,6 +10,7 @@ import (
|
||||
func TryAutoMergeImportedTrail(
|
||||
app core.App,
|
||||
client meilisearch.ServiceManager,
|
||||
ctx context.Context,
|
||||
actor *core.Record,
|
||||
sourceTrailID string,
|
||||
settings IntegrationAutoMergeSettings,
|
||||
@@ -40,5 +43,5 @@ func TryAutoMergeImportedTrail(
|
||||
return nil
|
||||
}
|
||||
|
||||
return Merge(app, client, actor, sourceTrailID, targetTrailID, DefaultIntegrationAutoMergeMergeSettings())
|
||||
return Merge(app, client, ctx, actor, sourceTrailID, targetTrailID, DefaultIntegrationAutoMergeMergeSettings())
|
||||
}
|
||||
@@ -2,16 +2,18 @@ package trailmerge
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
pub "github.com/go-ap/activitypub"
|
||||
"github.com/meilisearch/meilisearch-go"
|
||||
"github.com/pocketbase/dbx"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/tools/filesystem"
|
||||
"io"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"pocketbase/federation"
|
||||
"pocketbase/util"
|
||||
@@ -176,7 +178,7 @@ func SuggestGroups(app core.App, actorID string, request SuggestRequest) (*Sugge
|
||||
// Merge links a source trail into a target trail in a single transaction.
|
||||
// It moves or recreates trail-related content according to the provided
|
||||
// settings and keeps the target trail indexed and federated afterwards.
|
||||
func Merge(app core.App, client meilisearch.ServiceManager, actor *core.Record, sourceTrailID string, targetTrailID string, settings MergeSettings) error {
|
||||
func Merge(app core.App, client meilisearch.ServiceManager, ctx context.Context, actor *core.Record, sourceTrailID string, targetTrailID string, settings MergeSettings) error {
|
||||
if actor == nil {
|
||||
return ErrMissingActor
|
||||
}
|
||||
@@ -198,7 +200,7 @@ func Merge(app core.App, client meilisearch.ServiceManager, actor *core.Record,
|
||||
return err
|
||||
}
|
||||
|
||||
ctx := mergeContext{
|
||||
mergeCtx := mergeContext{
|
||||
App: txApp,
|
||||
Client: client,
|
||||
Actor: actor,
|
||||
@@ -208,7 +210,7 @@ func Merge(app core.App, client meilisearch.ServiceManager, actor *core.Record,
|
||||
Settings: settings,
|
||||
}
|
||||
|
||||
sideEffects, err := mergeTrailIntoTarget(ctx)
|
||||
sideEffects, err := mergeTrailIntoTarget(mergeCtx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -233,11 +235,8 @@ func Merge(app core.App, client meilisearch.ServiceManager, actor *core.Record,
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
logAuthor, err := app.FindRecordById("activitypub_actors", record.GetString("author"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := federation.CreateSummitLogActivity(app, logAuthor, record, pub.CreateType); err != nil {
|
||||
|
||||
if err := federation.CreateSummitLogActivity(app, ctx, record, pub.CreateType); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
@@ -247,7 +246,7 @@ func Merge(app core.App, client meilisearch.ServiceManager, actor *core.Record,
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := federation.CreateCommentActivity(app, actor, record, pub.CreateType); err != nil {
|
||||
if err := federation.CreateCommentActivity(app, ctx, record, pub.CreateType); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,7 @@ import (
|
||||
"time"
|
||||
|
||||
pub "github.com/go-ap/activitypub"
|
||||
"github.com/go-fed/httpsig"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/tools/filesystem"
|
||||
"github.com/pocketbase/pocketbase/tools/security"
|
||||
@@ -110,53 +111,6 @@ func generateKeyPair() (*rsa.PrivateKey, *rsa.PublicKey, error) {
|
||||
return priv, pub, nil
|
||||
}
|
||||
|
||||
func SyncOutbox(app core.App, actor *core.Record) error {
|
||||
return fetchOutboxPage(app, actor, actor.GetString("outbox")+"?page=1")
|
||||
}
|
||||
|
||||
func fetchOutboxPage(app core.App, actor *core.Record, pageURL string) error {
|
||||
client := &http.Client{}
|
||||
|
||||
req, err := http.NewRequest(http.MethodGet, pageURL, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Add("Accept", `application/ld+json; profile="https://www.w3.org/ns/activitystreams"`)
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var page pub.OrderedCollectionPage
|
||||
err = json.Unmarshal(body, &page)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, item := range page.OrderedItems {
|
||||
activity, err := pub.ToActivity(item)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if activity.Type != pub.CreateType {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
if page.Next != nil {
|
||||
return fetchOutboxPage(app, actor, page.Next.GetID().String())
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func TrailFromActivity(activity pub.Activity, app core.App, actor *core.Record) (*core.Record, error) {
|
||||
t, err := pub.ToObject(activity.Object)
|
||||
if err != nil {
|
||||
@@ -191,7 +145,13 @@ func TrailFromActivity(activity pub.Activity, app core.App, actor *core.Record)
|
||||
}
|
||||
} else {
|
||||
// this trail exists already
|
||||
// nothing more to do
|
||||
// ensure that it is fully synced to catch waypoint/summit log updates
|
||||
|
||||
record.Set("needs_full_sync", true)
|
||||
err = app.Save(record)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return record, nil
|
||||
}
|
||||
@@ -264,6 +224,7 @@ func TrailFromActivity(activity pub.Activity, app core.App, actor *core.Record)
|
||||
record.Set("public", true)
|
||||
record.Set("iri", t.ID.String())
|
||||
record.Set("author", actor.Id)
|
||||
record.Set("needs_full_sync", true)
|
||||
|
||||
categoryRecord, err := app.FindFirstRecordByData("categories", "name", category)
|
||||
if err == nil {
|
||||
@@ -473,7 +434,9 @@ func ListFromActivity(activity pub.Activity, app core.App, actor *core.Record) (
|
||||
}
|
||||
} else {
|
||||
// this list exists already
|
||||
// nothing more to do
|
||||
// ensure that it is fully synced to catch trail updates
|
||||
|
||||
record.Set("needs_full_sync", true)
|
||||
|
||||
return record, nil
|
||||
}
|
||||
@@ -483,6 +446,7 @@ func ListFromActivity(activity pub.Activity, app core.App, actor *core.Record) (
|
||||
record.Set("public", true)
|
||||
record.Set("iri", iri)
|
||||
record.Set("author", actor.Id)
|
||||
record.Set("needs_full_sync", true)
|
||||
|
||||
if l.Attachment != nil {
|
||||
|
||||
@@ -601,7 +565,7 @@ func ObjectFromComment(app core.App, comment *core.Record, mentions *pub.ItemCol
|
||||
func TrailObjectFromIRI(iri string) (*pub.Object, error) {
|
||||
fetchURL := strings.Replace(iri, "api/v1/trail", "api/v1/activitypub/trail", 1)
|
||||
|
||||
client := &http.Client{}
|
||||
client := SafeHTTPClient()
|
||||
|
||||
req, err := http.NewRequest(http.MethodGet, fetchURL, nil)
|
||||
if err != nil {
|
||||
@@ -627,3 +591,68 @@ func TrailObjectFromIRI(iri string) (*pub.Object, error) {
|
||||
|
||||
return &object, nil
|
||||
}
|
||||
|
||||
func VerifySignature(app core.App, req *http.Request, publicKeyPem string) (bool, error) {
|
||||
origin := os.Getenv("ORIGIN")
|
||||
if origin == "" {
|
||||
return false, fmt.Errorf("ORIGIN not set")
|
||||
}
|
||||
block, _ := pem.Decode([]byte(publicKeyPem))
|
||||
if block == nil || block.Type != "PUBLIC KEY" {
|
||||
return false, fmt.Errorf("could not decode publicKeyPem to PUBLIC KEY pem block type")
|
||||
}
|
||||
|
||||
req.URL = &url.URL{
|
||||
Path: req.Header.Get("X-Forwarded-Path"),
|
||||
}
|
||||
|
||||
url, err := url.Parse(origin)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
req.Header.Set("Host", url.Host)
|
||||
req.Host = url.Host
|
||||
|
||||
app.Logger().Info(req.Header.Get("signature"))
|
||||
|
||||
publicKey, err := x509.ParsePKIXPublicKey(block.Bytes)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
v, err := httpsig.NewVerifier(req)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
err = v.Verify(publicKey, httpsig.RSA_SHA256)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func SplitHandle(handle string) (string, string) {
|
||||
|
||||
cleaned := strings.TrimPrefix(handle, "@")
|
||||
cleaned = strings.TrimSpace(cleaned)
|
||||
|
||||
if !strings.Contains(cleaned, "@") {
|
||||
return cleaned, ""
|
||||
}
|
||||
|
||||
parts := strings.SplitN(cleaned, "@", 2)
|
||||
user := parts[0]
|
||||
domain := parts[1]
|
||||
|
||||
return user, domain
|
||||
}
|
||||
|
||||
func ItemID(item pub.Item) string {
|
||||
if item == nil || item.GetID() == "" {
|
||||
return ""
|
||||
}
|
||||
return item.GetID().String()
|
||||
}
|
||||
|
||||
190
db/util/network.go
Normal file
190
db/util/network.go
Normal file
@@ -0,0 +1,190 @@
|
||||
package util
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
)
|
||||
|
||||
var ErrRateLimited = fmt.Errorf("rate limit exceeded for origin")
|
||||
|
||||
type RateLimiter struct {
|
||||
mu sync.RWMutex
|
||||
requests map[string][]time.Time
|
||||
maxReqs int
|
||||
window time.Duration
|
||||
key []byte
|
||||
}
|
||||
|
||||
func NewRateLimiter(maxReqs int, window time.Duration) *RateLimiter {
|
||||
rl := &RateLimiter{
|
||||
requests: make(map[string][]time.Time),
|
||||
maxReqs: maxReqs,
|
||||
window: window,
|
||||
key: make([]byte, 32),
|
||||
}
|
||||
rand.Read(rl.key)
|
||||
|
||||
// Background worker: Cleans up memory and rotates keys
|
||||
go rl.maintenanceWorker()
|
||||
return rl
|
||||
}
|
||||
|
||||
func (rl *RateLimiter) maintenanceWorker() {
|
||||
ticker := time.NewTicker(rl.window * 2)
|
||||
for range ticker.C {
|
||||
rl.mu.Lock()
|
||||
|
||||
newKey := make([]byte, 32)
|
||||
rand.Read(newKey)
|
||||
rl.key = newKey
|
||||
|
||||
rl.requests = make(map[string][]time.Time)
|
||||
|
||||
rl.mu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
func (rl *RateLimiter) CheckRateLimit(identifier string, host string) error {
|
||||
rl.mu.Lock()
|
||||
defer rl.mu.Unlock()
|
||||
|
||||
h := hmac.New(sha256.New, rl.key)
|
||||
h.Write([]byte(identifier + ":" + host))
|
||||
key := hex.EncodeToString(h.Sum(nil))
|
||||
|
||||
now := time.Now()
|
||||
threshold := now.Add(-rl.window)
|
||||
|
||||
timestamps := rl.requests[key]
|
||||
w := 0
|
||||
for _, t := range timestamps {
|
||||
if t.After(threshold) {
|
||||
timestamps[w] = t
|
||||
w++
|
||||
}
|
||||
}
|
||||
timestamps = timestamps[:w]
|
||||
|
||||
if len(timestamps) >= rl.maxReqs {
|
||||
rl.requests[key] = timestamps
|
||||
return ErrRateLimited
|
||||
}
|
||||
|
||||
rl.requests[key] = append(timestamps, now)
|
||||
return nil
|
||||
}
|
||||
|
||||
var ActivityPubRateLimiter = NewRateLimiter(30, time.Minute)
|
||||
|
||||
type safeTransport struct {
|
||||
transport http.RoundTripper
|
||||
}
|
||||
|
||||
func (t *safeTransport) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
host := req.URL.Hostname()
|
||||
if host == "" {
|
||||
return nil, fmt.Errorf("invalid host in request")
|
||||
}
|
||||
|
||||
ips, err := net.LookupIP(host)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to resolve host: %w", err)
|
||||
}
|
||||
|
||||
for _, ip := range ips {
|
||||
if isPrivateOrReservedIP(ip) {
|
||||
return nil, fmt.Errorf("request to private/reserved IP address blocked: %s", ip)
|
||||
}
|
||||
}
|
||||
|
||||
return t.transport.RoundTrip(req)
|
||||
}
|
||||
|
||||
func isPrivateOrReservedIP(ip net.IP) bool {
|
||||
if ip.IsLoopback() {
|
||||
return true
|
||||
}
|
||||
|
||||
if ip.IsPrivate() {
|
||||
return true
|
||||
}
|
||||
|
||||
if ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() {
|
||||
return true
|
||||
}
|
||||
|
||||
if ip.IsMulticast() {
|
||||
return true
|
||||
}
|
||||
|
||||
if ip.IsUnspecified() {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func SafeHTTPClient() *http.Client {
|
||||
dialer := &net.Dialer{Timeout: 30 * time.Second}
|
||||
|
||||
return &http.Client{
|
||||
Transport: &http.Transport{
|
||||
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||
host, port, _ := net.SplitHostPort(addr)
|
||||
|
||||
identifier, _ := ctx.Value("actor").(string)
|
||||
if identifier == "" {
|
||||
identifier = "system"
|
||||
}
|
||||
|
||||
if err := ActivityPubRateLimiter.CheckRateLimit(identifier, host); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ips, err := net.DefaultResolver.LookupIP(ctx, "ip", host)
|
||||
if err != nil || len(ips) == 0 {
|
||||
return nil, fmt.Errorf("failed to resolve: %w", err)
|
||||
}
|
||||
|
||||
for _, ip := range ips {
|
||||
if isPrivateOrReservedIP(ip) {
|
||||
return nil, fmt.Errorf("SSRF blocked: %s", ip)
|
||||
}
|
||||
}
|
||||
|
||||
// Standard practice: Dial the first resolved IP to prevent TOCTOU/Rebinding
|
||||
return dialer.DialContext(ctx, network, net.JoinHostPort(ips[0].String(), port))
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func GetSafeActorContext(r *http.Request, userActor *core.Record) (context.Context, error) {
|
||||
var identifier string
|
||||
|
||||
if userActor != nil {
|
||||
identifier = "actor:" + userActor.Id
|
||||
} else if r != nil {
|
||||
ip, _, _ := net.SplitHostPort(r.RemoteAddr)
|
||||
identifier = "anon:" + ip
|
||||
} else {
|
||||
return nil, errors.New("request or actor must be defined")
|
||||
}
|
||||
|
||||
parentCtx := context.Background()
|
||||
if r != nil {
|
||||
parentCtx = r.Context()
|
||||
}
|
||||
return context.WithValue(parentCtx, "actor", identifier), nil
|
||||
}
|
||||
43
db/util/sanitize.go
Normal file
43
db/util/sanitize.go
Normal file
@@ -0,0 +1,43 @@
|
||||
package util
|
||||
|
||||
import (
|
||||
"github.com/microcosm-cc/bluemonday"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
)
|
||||
|
||||
func SanitizeHTML() func(e *core.RecordRequestEvent) error {
|
||||
return func(e *core.RecordRequestEvent) error {
|
||||
fieldsToSanitize := map[string][]string{
|
||||
"lists": {"description"},
|
||||
"settings": {"bio"},
|
||||
"summit_logs": {"text"},
|
||||
"trails": {"description"},
|
||||
"comments": {"text"},
|
||||
"waypoints": {"description"},
|
||||
}
|
||||
collection := e.Collection.Name
|
||||
fields, ok := fieldsToSanitize[collection]
|
||||
if !ok {
|
||||
return e.Next()
|
||||
}
|
||||
|
||||
p := bluemonday.NewPolicy()
|
||||
p.AllowStandardAttributes()
|
||||
p.AllowStandardURLs()
|
||||
p.AllowLists()
|
||||
p.AllowElements("br", "div", "hr", "p", "span", "wbr")
|
||||
p.AllowElements("b", "strong", "em", "u", "blockquote", "a")
|
||||
p.AllowAttrs("href").OnElements("a")
|
||||
p.AllowAttrs("target").OnElements("a")
|
||||
p.AllowAttrs("class").OnElements("a")
|
||||
|
||||
for _, field := range fields {
|
||||
if val, ok := e.Record.Get(field).(string); ok {
|
||||
sanitizedValue := p.Sanitize(val)
|
||||
e.Record.Set(field, sanitizedValue)
|
||||
}
|
||||
}
|
||||
|
||||
return e.Next()
|
||||
}
|
||||
}
|
||||
2
web/package-lock.json
generated
2
web/package-lock.json
generated
@@ -54,7 +54,7 @@
|
||||
"pocketbase": "^0.26.8",
|
||||
"qrcode": "^1.4.4",
|
||||
"svelte-i18n": "^4.0.0",
|
||||
"tailwindcss": "^4.2.3",
|
||||
"tailwindcss": "^4.2.4",
|
||||
"three": "^0.183.1",
|
||||
"vitest": "^4.1.4",
|
||||
"zod": "^3.24.1"
|
||||
|
||||
@@ -43,7 +43,7 @@
|
||||
error = "",
|
||||
placeholder = "",
|
||||
extraClasses = "",
|
||||
searchListPosition = "absolute"
|
||||
searchListPosition = "absolute",
|
||||
}: Props = $props();
|
||||
|
||||
const fontSizes: SelectItem[] = [
|
||||
@@ -87,7 +87,9 @@
|
||||
return [
|
||||
"a",
|
||||
mergeAttributes(
|
||||
{ href: `/profile/@${options.HTMLAttributes["data-label"]}` },
|
||||
{
|
||||
href: `/profile/@${options.HTMLAttributes["data-label"]}`,
|
||||
},
|
||||
options.HTMLAttributes,
|
||||
),
|
||||
options.renderText?.({
|
||||
@@ -142,7 +144,8 @@
|
||||
return;
|
||||
}
|
||||
|
||||
searchListElement.style.position = searchListPosition;
|
||||
searchListElement.style.position =
|
||||
searchListPosition;
|
||||
searchListElement.style.top = `${box.bottom + window.scrollY + 4}px`;
|
||||
searchListElement.style.left = `${box.left + window.scrollX}px`;
|
||||
searchListElement.style.zIndex = "1001";
|
||||
@@ -179,12 +182,12 @@
|
||||
|
||||
onKeyDown(props) {
|
||||
if (props.event.key === "Escape") {
|
||||
this.onExit?.({} as unknown as any)
|
||||
this.onExit?.({} as unknown as any);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return false
|
||||
return false;
|
||||
},
|
||||
onExit() {
|
||||
unmount(component);
|
||||
|
||||
@@ -547,16 +547,19 @@
|
||||
clusterPopup.on("close", () => {
|
||||
unHighlightCluster(false);
|
||||
});
|
||||
map.on("mousemove", unHighlightClusterDistanceNotifier)
|
||||
map.on("mousemove", unHighlightClusterDistanceNotifier);
|
||||
}
|
||||
|
||||
function unHighlightClusterDistanceNotifier(e: M.MapMouseEvent) {
|
||||
if (!clusterPopup || !map) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
if (map.project(clusterPopup.getLngLat()).dist(map.project(e.lngLat)) > 60) {
|
||||
if (
|
||||
map.project(clusterPopup.getLngLat()).dist(map.project(e.lngLat)) >
|
||||
60
|
||||
) {
|
||||
clusterPopup.remove();
|
||||
map.off("mousemove", unHighlightClusterDistanceNotifier)
|
||||
map.off("mousemove", unHighlightClusterDistanceNotifier);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -129,9 +129,8 @@
|
||||
|
||||
async function fetchComments() {
|
||||
commentsLoading = true;
|
||||
const trailId = trail.iri ? trail.iri : trail.id!;
|
||||
try {
|
||||
await comments_index(trailId, handle);
|
||||
await comments_index(trail.id!);
|
||||
} catch (e) {
|
||||
show_toast({
|
||||
type: "error",
|
||||
|
||||
@@ -7,8 +7,8 @@ export interface Actor {
|
||||
domain?: string;
|
||||
summary?: string;
|
||||
published?: string;
|
||||
followerCount?: number,
|
||||
followingCount?: number,
|
||||
follower_count?: number,
|
||||
following_count?: number,
|
||||
iri: string;
|
||||
inbox: string;
|
||||
outbox?: string;
|
||||
|
||||
@@ -22,6 +22,7 @@ export class List {
|
||||
|
||||
}
|
||||
created?: string;
|
||||
updated?: string;
|
||||
author: string;
|
||||
|
||||
constructor(name: string, trails: Trail[], params?: { description?: string, public?: boolean, avatar?: string, author?: string }) {
|
||||
|
||||
@@ -1,25 +1,17 @@
|
||||
import { Comment } from "$lib/models/comment";
|
||||
import type { Trail } from "$lib/models/trail";
|
||||
import { APIError } from "$lib/util/api_util";
|
||||
import { type ListResult } from "pocketbase";
|
||||
import { get, writable, type Writable } from "svelte/store";
|
||||
import { currentUser } from "./user_store";
|
||||
import { isURL } from "$lib/util/file_util";
|
||||
|
||||
export const comments: Writable<Comment[]> = writable([])
|
||||
|
||||
export async function comments_index(trailId: string, handle?: string) {
|
||||
export async function comments_index(trailId: string) {
|
||||
let filter: string;
|
||||
if (isURL(trailId)) {
|
||||
filter = `trail="${trailId}"||trail.iri="${trailId}"||trail="${trailId.substring(trailId.length - 15)}"`
|
||||
} else {
|
||||
filter = `trail="${trailId}"`
|
||||
}
|
||||
let r = await fetch(`/api/v1/comment?` + new URLSearchParams({
|
||||
filter,
|
||||
|
||||
let r = await fetch(`/api/v1/trail/${trailId}/comment?` + new URLSearchParams({
|
||||
expand: "author",
|
||||
sort: "-created",
|
||||
...(handle ? { handle } : {})
|
||||
}), {
|
||||
method: 'GET',
|
||||
})
|
||||
|
||||
@@ -5,11 +5,9 @@ import { type ListResult } from "pocketbase";
|
||||
|
||||
let follows: Actor[] = [];
|
||||
|
||||
export async function follows_index(data: { username: string, type: "followers" | "following" }, page: number = 1, perPage: number = 10, f: (url: RequestInfo | URL, config?: RequestInit) => Promise<Response> = fetch) {
|
||||
export async function follows_index(page: number = 1, perPage: number = 10, f: (url: RequestInfo | URL, config?: RequestInit) => Promise<Response> = fetch) {
|
||||
|
||||
const r = await f(`/api/v1/follow?` + new URLSearchParams({
|
||||
handle: data.username,
|
||||
type: data.type,
|
||||
page: page.toString(),
|
||||
perPage: perPage.toString(),
|
||||
}), {
|
||||
|
||||
@@ -9,9 +9,10 @@ import { searchResultToLists } from "./list_store";
|
||||
import type { ListSearchResult } from "./search_store";
|
||||
import { buildFilterText } from "./summit_log_store";
|
||||
import { searchResultToTrailList } from "./trail_store";
|
||||
import type { Actor } from "$lib/models/activitypub/actor";
|
||||
|
||||
let feed: FeedItem[] = []
|
||||
|
||||
let follows: Actor[] = [];
|
||||
|
||||
export async function profile_show(handle: string, f: (url: RequestInfo | URL, config?: RequestInit) => Promise<Response> = fetch) {
|
||||
let r = await f('/api/v1/profile/' + handle, {
|
||||
@@ -133,3 +134,25 @@ export async function profile_stats_index(handle: string, filter: SummitLogFilte
|
||||
return result;
|
||||
|
||||
}
|
||||
|
||||
export async function profile_follows_index(handle: string, type: "followers" | "following", page: number, f: (url: RequestInfo | URL, config?: RequestInit) => Promise<Response> = fetch) {
|
||||
const r = await f(`/api/v1/profile/${handle}/follows?` + new URLSearchParams({
|
||||
type,
|
||||
page: page.toString(),
|
||||
}), {
|
||||
method: 'GET',
|
||||
})
|
||||
|
||||
if (!r.ok) {
|
||||
const response = await r.json();
|
||||
throw new APIError(r.status, response.message, response.detail)
|
||||
}
|
||||
|
||||
const fetchedFollows: ListResult<Actor> = await r.json();
|
||||
|
||||
const result = page > 1 ? [...follows, ...fetchedFollows.items] : fetchedFollows.items
|
||||
|
||||
follows = result;
|
||||
|
||||
return { ...fetchedFollows, items: result };
|
||||
}
|
||||
@@ -12,7 +12,7 @@ export async function waypoints_create(waypoint: Waypoint, f: (url: RequestInfo
|
||||
throw Error("Unauthenticated")
|
||||
}
|
||||
|
||||
waypoint.author = user.id
|
||||
waypoint.author = user.actor
|
||||
|
||||
let r = await f('/api/v1/waypoint', {
|
||||
method: 'PUT',
|
||||
|
||||
@@ -2,7 +2,6 @@ import { CommentCreateSchema } from '$lib/models/api/comment_schema';
|
||||
import type { Comment } from '$lib/models/comment';
|
||||
import { Collection, create, handleError, list } from '$lib/util/api_util';
|
||||
import { json, type RequestEvent } from '@sveltejs/kit';
|
||||
import { type ListResult } from "pocketbase";
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
@@ -32,11 +31,6 @@ import { type ListResult } from "pocketbase";
|
||||
* name: expand
|
||||
* schema:
|
||||
* type: string
|
||||
* - in: query
|
||||
* name: handle
|
||||
* schema:
|
||||
* type: string
|
||||
* description: Federated query parameter
|
||||
* responses:
|
||||
* 200:
|
||||
* description: List of comments
|
||||
@@ -51,70 +45,8 @@ import { type ListResult } from "pocketbase";
|
||||
*/
|
||||
export async function GET(event: RequestEvent) {
|
||||
try {
|
||||
if (!event.url.searchParams.has("handle")) {
|
||||
const comments = await list<Comment>(event, Collection.comments);
|
||||
return json(comments)
|
||||
} else {
|
||||
const { actor, error } = await event.locals.pb.send(`/activitypub/actor?resource=acct:${event.url.searchParams.get("handle")}`, { method: "GET", fetch: event.fetch, });
|
||||
event.url.searchParams.delete("handle")
|
||||
const localComments = await list<Comment>(event, Collection.comments);
|
||||
if (actor.isLocal) {
|
||||
return json(localComments)
|
||||
}
|
||||
|
||||
const deduplicationMap: Record<string, Comment> = {}
|
||||
|
||||
localComments.items.forEach(c => {
|
||||
if (c.iri) {
|
||||
const id = c.iri.substring(c.iri.length - 15)
|
||||
deduplicationMap[id] = c
|
||||
} else if (c.id) {
|
||||
deduplicationMap[c.id] = c
|
||||
}
|
||||
})
|
||||
const origin = new URL(actor.iri).origin
|
||||
const url = `${origin}/api/v1/comment`
|
||||
|
||||
const response = await event.fetch(url + '?' + event.url.searchParams, { method: 'GET' })
|
||||
if (!response.ok) {
|
||||
const errorResponse = await response.json()
|
||||
console.error(errorResponse)
|
||||
|
||||
}
|
||||
const remoteComments: ListResult<Comment> = await response.json()
|
||||
|
||||
remoteComments.items = remoteComments.items.filter(c => {
|
||||
const iriId = c.iri?.substring(c.iri.length - 15) ?? ""
|
||||
if (deduplicationMap[c.id!] != undefined) {
|
||||
deduplicationMap[c.id!] = { ...c, author: deduplicationMap[c.id!].author }
|
||||
return false
|
||||
} else if (deduplicationMap[iriId] != undefined) {
|
||||
deduplicationMap[iriId] = { ...c, author: deduplicationMap[iriId].author }
|
||||
return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
remoteComments.items.forEach(c => {
|
||||
if (!c.iri?.length) {
|
||||
c.iri = `${url}/${c.id}`
|
||||
}
|
||||
})
|
||||
|
||||
const allCommentItems = <ListResult<Comment>>{
|
||||
items: localComments.items.concat(remoteComments.items),
|
||||
page: localComments.page,
|
||||
perPage: localComments.perPage,
|
||||
totalItems: localComments.items.length + remoteComments.items.length,
|
||||
totalPages: Math.ceil((localComments.items.length + remoteComments.items.length) / localComments.perPage)
|
||||
}
|
||||
|
||||
allCommentItems.items = allCommentItems.items.sort((a, b) => {
|
||||
return new Date(b.created ?? 0).getTime() - new Date(a.created ?? 0).getTime()
|
||||
})
|
||||
|
||||
return json(allCommentItems)
|
||||
}
|
||||
} catch (e) {
|
||||
return handleError(e)
|
||||
}
|
||||
|
||||
@@ -1,18 +1,14 @@
|
||||
import type { Actor } from '$lib/models/activitypub/actor';
|
||||
import { FollowCreateSchema } from '$lib/models/api/follow_schema';
|
||||
import type { Follow } from '$lib/models/follow';
|
||||
import { getActorResponseForHandle } from '$lib/util/activitypub_server_util';
|
||||
import { APIError, Collection, handleError, list } from '$lib/util/api_util';
|
||||
import { Collection, handleError, list } from '$lib/util/api_util';
|
||||
import { json, type RequestEvent } from '@sveltejs/kit';
|
||||
import type { APOrderedCollectionPage } from 'activitypub-types';
|
||||
import { ClientResponseError, type ListResult } from "pocketbase";
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /api/v1/follow:
|
||||
* get:
|
||||
* summary: List follows
|
||||
* description: Retrieves follows or ActivityPub follower/following collections. Supports federated queries via handle parameter
|
||||
* tags:
|
||||
* - Follows
|
||||
* parameters:
|
||||
@@ -36,18 +32,9 @@ import { ClientResponseError, type ListResult } from "pocketbase";
|
||||
* name: expand
|
||||
* schema:
|
||||
* type: string
|
||||
* - in: query
|
||||
* name: handle
|
||||
* schema:
|
||||
* type: string
|
||||
* - in: query
|
||||
* name: type
|
||||
* schema:
|
||||
* type: string
|
||||
* enum: [followers, following]
|
||||
* responses:
|
||||
* 200:
|
||||
* description: List of follows or ActivityPub collection
|
||||
* description: List of follows
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
@@ -59,60 +46,8 @@ import { ClientResponseError, type ListResult } from "pocketbase";
|
||||
*/
|
||||
export async function GET(event: RequestEvent) {
|
||||
try {
|
||||
if (!event.url.searchParams.has("handle")) {
|
||||
const follows = await list<Follow>(event, Collection.follows);
|
||||
return json(follows)
|
||||
} else {
|
||||
const handle = event.url.searchParams.get("handle");
|
||||
const type = event.url.searchParams.get("type");
|
||||
|
||||
if (!handle || (type !== "followers" && type !== "following")) {
|
||||
throw new APIError(400, "invalid params")
|
||||
}
|
||||
|
||||
const { actor } = await getActorResponseForHandle(event, handle);
|
||||
|
||||
const page = event.url.searchParams.get("page") ?? "1"
|
||||
|
||||
let followers: APOrderedCollectionPage;
|
||||
|
||||
// fetch followers locally to not run into auth issues with private profiles
|
||||
if (actor.id === event.locals.user?.actor) {
|
||||
const r = await event.fetch(actor[type as "followers" | "following"]! + '?' + new URLSearchParams({ page }))
|
||||
|
||||
if (!r.ok) {
|
||||
const errorResponse = await r.json()
|
||||
throw new ClientResponseError({ status: r.status, response: errorResponse });
|
||||
}
|
||||
followers = await r.json()
|
||||
} else {
|
||||
followers = await event.locals.pb.send(`/activitypub/actor/${actor.id}/${type}?page=${page}`, { method: "GET", fetch: event.fetch, });
|
||||
}
|
||||
|
||||
const followerActors: Actor[] = []
|
||||
for (const f of followers.orderedItems ?? []) {
|
||||
try {
|
||||
const { actor }: { actor: Actor } = await event.locals.pb.send(`/activitypub/actor?iri=${f}`, { method: "GET", fetch: event.fetch, });
|
||||
followerActors.push(actor)
|
||||
|
||||
} catch (e) {
|
||||
continue
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
const result: ListResult<Actor> = {
|
||||
items: followerActors,
|
||||
page: parseInt(page),
|
||||
perPage: 10,
|
||||
totalItems: actor.followerCount ?? 0,
|
||||
totalPages: Math.ceil((actor.followerCount ?? 0) / 10)
|
||||
}
|
||||
return json(result)
|
||||
}
|
||||
|
||||
|
||||
} catch (e) {
|
||||
return handleError(e)
|
||||
}
|
||||
|
||||
@@ -1,16 +1,14 @@
|
||||
import { ListUpdateSchema } from "$lib/models/api/list_schema";
|
||||
import type { List } from "$lib/models/list";
|
||||
import { Collection, handleError, remove, show, update } from "$lib/util/api_util";
|
||||
import { objectToFormData } from "$lib/util/file_util";
|
||||
import { Collection, handleError, remove, update } from "$lib/util/api_util";
|
||||
import { json, type RequestEvent } from "@sveltejs/kit";
|
||||
import { ClientResponseError } from "pocketbase";
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /api/v1/list/{id}:
|
||||
* get:
|
||||
* summary: Get list
|
||||
* description: Retrieves a list by ID. Supports federated queries via handle parameter, fetching from remote instances and remapping file URLs
|
||||
* description: Retrieves a list by ID
|
||||
* tags:
|
||||
* - Lists
|
||||
* parameters:
|
||||
@@ -23,13 +21,9 @@ import { ClientResponseError } from "pocketbase";
|
||||
* name: expand
|
||||
* schema:
|
||||
* type: string
|
||||
* - in: query
|
||||
* name: handle
|
||||
* schema:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: List with optional federated data
|
||||
* description: List
|
||||
* 404:
|
||||
* description: Not Found
|
||||
* 500:
|
||||
@@ -80,77 +74,17 @@ import { ClientResponseError } from "pocketbase";
|
||||
* description: Internal Server Error
|
||||
*/
|
||||
export async function GET(event: RequestEvent) {
|
||||
const { url, params } = event;
|
||||
|
||||
try {
|
||||
if (!event.url.searchParams.has("handle")) {
|
||||
const l = await show<List>(event, Collection.lists)
|
||||
return json(l)
|
||||
} else {
|
||||
const {actor, error} = await event.locals.pb.send(`/activitypub/actor?resource=acct:${event.url.searchParams.get("handle")}`, { method: "GET", fetch: event.fetch, });
|
||||
event.url.searchParams.delete("handle")
|
||||
|
||||
const origin = new URL(actor.iri).origin
|
||||
const url = `${origin}/api/v1/list/${event.params.id}`
|
||||
|
||||
let dbList: List | undefined;
|
||||
try {
|
||||
dbList = await event.locals.pb.collection("lists").getFirstListItem(`iri='${url}'||id='${event.params.id}'`, {
|
||||
...Object.fromEntries(event.url.searchParams)
|
||||
})
|
||||
} catch (e) {
|
||||
if (!(e instanceof ClientResponseError) || e.status != 404) {
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
if (actor.isLocal) {
|
||||
return json(dbList)
|
||||
} else {
|
||||
const response = await event.fetch((dbList?.iri ?? url) + '?' + event.url.searchParams, { method: 'GET' })
|
||||
if (!response.ok) {
|
||||
const errorResponse = await response.json()
|
||||
console.error(errorResponse)
|
||||
const cachedList = await event.locals.pb.collection("lists").getOne(`${event.params.id}`)
|
||||
return json(cachedList)
|
||||
}
|
||||
const l: List = await response.json()
|
||||
l.avatar = l.avatar ? `${origin}/api/v1/files/lists/${l.id}/${l.avatar}` : undefined
|
||||
|
||||
l.author = actor.id!
|
||||
l.expand!.author = actor
|
||||
l.iri = dbList?.iri ?? url;
|
||||
|
||||
l.expand?.trails?.forEach(t => {
|
||||
t.gpx = t.gpx ? `${origin}/api/v1/files/trails/${t.id}/${t.gpx}` : undefined
|
||||
t.photos = t.photos.map(p =>
|
||||
`${origin}/api/v1/files/trails/${t.id}/${p}`
|
||||
)
|
||||
t.iri = t.iri || `${origin}/api/v1/trails/${t.id}`;
|
||||
let list: List = await event.locals.pb.send(`/remote/list/${params.id}?` + url.searchParams, {
|
||||
method: "GET",
|
||||
fetch: event.fetch,
|
||||
})
|
||||
|
||||
const formData = objectToFormData({ ...l, id: dbList?.id, expand: undefined, trails: [] })
|
||||
if (l.avatar) {
|
||||
const avatarURL = l.avatar
|
||||
let response = await event.fetch(avatarURL, { method: "GET" })
|
||||
const avatar = await response.blob()
|
||||
|
||||
|
||||
formData.append("avatar", avatar)
|
||||
}
|
||||
if (dbList !== undefined) {
|
||||
dbList = await event.locals.pb.collection("lists").update(dbList.id!, formData)
|
||||
} else {
|
||||
dbList = await event.locals.pb.collection("lists").create(formData)
|
||||
}
|
||||
|
||||
l.id = dbList!.id
|
||||
|
||||
return json(l)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
return json(list)
|
||||
} catch (e: any) {
|
||||
return handleError(e)
|
||||
return handleError(e);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -46,8 +46,8 @@ export async function GET(event: RequestEvent) {
|
||||
createdAt: actor.published ?? "",
|
||||
bio: actor.summary ?? "",
|
||||
uri: actor.iri,
|
||||
followers: actor.followerCount ?? 0,
|
||||
following: actor.followingCount ?? 0,
|
||||
followers: actor.follower_count ?? 0,
|
||||
following: actor.following_count ?? 0,
|
||||
icon: actor.icon ?? "",
|
||||
error: actorError ?? undefined
|
||||
}
|
||||
|
||||
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 { getActorResponseForHandle } from '$lib/util/activitypub_server_util';
|
||||
import { splitUsername } from '$lib/util/activitypub_util';
|
||||
import { handleError } from '$lib/util/api_util';
|
||||
import { error, json, type RequestEvent } from '@sveltejs/kit';
|
||||
@@ -53,10 +54,10 @@ export async function GET(event: RequestEvent) {
|
||||
filter += `&& id != "${event.locals.pb.authStore.record.actor}"`
|
||||
}
|
||||
|
||||
const response = await event.locals.pb.collection("activitypub_actors").getList(1, 3, { filter: filter })
|
||||
const response = await event.locals.pb.collection("activitypub_actors").getList<Actor>(1, 3, { filter: filter })
|
||||
|
||||
try {
|
||||
const { actor, error } = await event.locals.pb.send(`/activitypub/actor?resource=acct:${q}&follows=false`, { method: "GET", fetch: event.fetch, });
|
||||
const { actor } = await getActorResponseForHandle(event, q!);
|
||||
|
||||
if (!response.items.find(i => i.iri == actor.iri)) {
|
||||
response.items.push(actor)
|
||||
|
||||
@@ -2,14 +2,12 @@ import { SummitLogCreateSchema } from '$lib/models/api/summit_log_schema';
|
||||
import type { SummitLog } from '$lib/models/summit_log';
|
||||
import { Collection, create, handleError, list } from '$lib/util/api_util';
|
||||
import { json, type RequestEvent } from '@sveltejs/kit';
|
||||
import { type ListResult } from "pocketbase";
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /api/v1/summit-log:
|
||||
* get:
|
||||
* summary: List summit logs
|
||||
* description: Retrieves a paginated list of summit logs with deduplication of federated data
|
||||
* tags:
|
||||
* - Summit Logs
|
||||
* parameters:
|
||||
@@ -33,13 +31,9 @@ import { type ListResult } from "pocketbase";
|
||||
* name: expand
|
||||
* schema:
|
||||
* type: string
|
||||
* - in: query
|
||||
* name: handle
|
||||
* schema:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: ListResult<SummitLog> with local/remote items deduplicated
|
||||
* description: List of summit logs
|
||||
* 400:
|
||||
* description: Bad Request
|
||||
* 500:
|
||||
@@ -47,84 +41,10 @@ import { type ListResult } from "pocketbase";
|
||||
*/
|
||||
export async function GET(event: RequestEvent) {
|
||||
try {
|
||||
if (!event.url.searchParams.has("handle")) {
|
||||
const summitLogs = await list<SummitLog>(event, Collection.summit_logs);
|
||||
removeTimeFromDates(summitLogs.items)
|
||||
return json(summitLogs)
|
||||
} else {
|
||||
const { actor, error } = await event.locals.pb.send(`/activitypub/actor?resource=acct:${event.url.searchParams.get("handle")}`, { method: "GET", fetch: event.fetch, });
|
||||
event.url.searchParams.delete("handle")
|
||||
const localSummitLogs = await list<SummitLog>(event, Collection.summit_logs);
|
||||
if (actor.isLocal) {
|
||||
removeTimeFromDates(localSummitLogs.items)
|
||||
|
||||
return json(localSummitLogs)
|
||||
}
|
||||
|
||||
const deduplicationMap: Record<string, SummitLog> = {}
|
||||
|
||||
localSummitLogs.items.forEach(l => {
|
||||
if (l.iri) {
|
||||
const id = l.iri.substring(l.iri.length - 15)
|
||||
deduplicationMap[id] = l
|
||||
} else if (l.id) {
|
||||
deduplicationMap[l.id] = l
|
||||
}
|
||||
|
||||
l.date = l.date.substring(0, 10);
|
||||
|
||||
})
|
||||
const origin = new URL(actor.iri).origin
|
||||
const url = `${origin}/api/v1/summit-log`
|
||||
|
||||
const response = await event.fetch(url + '?' + event.url.searchParams, { method: 'GET' })
|
||||
if (!response.ok) {
|
||||
const errorResponse = await response.json()
|
||||
console.error(errorResponse)
|
||||
|
||||
}
|
||||
const remoteSummitLogs: ListResult<SummitLog> = await response.json()
|
||||
|
||||
remoteSummitLogs.items = remoteSummitLogs.items.filter(l => {
|
||||
const iriId = l.iri?.substring(l.iri.length - 15) ?? ""
|
||||
if (deduplicationMap[l.id!] != undefined) {
|
||||
deduplicationMap[l.id!] = {...l, author: deduplicationMap[l.id!].author}
|
||||
return false
|
||||
} else if (deduplicationMap[iriId] != undefined) {
|
||||
deduplicationMap[iriId] = {...l, author: deduplicationMap[iriId].author}
|
||||
return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
remoteSummitLogs.items.forEach(l => {
|
||||
if (l.gpx) {
|
||||
l.gpx = `${origin}/api/v1/files/summit_logs/${l.id}/${l.gpx}`
|
||||
}
|
||||
l.photos = l.photos.map(p =>
|
||||
`${origin}/api/v1/files/summit_logs/${l.id}/${p}`
|
||||
)
|
||||
|
||||
if (l.expand?.author) {
|
||||
l.expand.author.isLocal = false
|
||||
}
|
||||
|
||||
})
|
||||
|
||||
const allSummitLogItems = <ListResult<SummitLog>>{
|
||||
items: localSummitLogs.items.concat(remoteSummitLogs.items),
|
||||
page: localSummitLogs.page,
|
||||
perPage: localSummitLogs.perPage,
|
||||
totalItems: localSummitLogs.items.length + remoteSummitLogs.items.length,
|
||||
totalPages: Math.ceil((localSummitLogs.items.length + remoteSummitLogs.items.length) / localSummitLogs.perPage)
|
||||
}
|
||||
|
||||
allSummitLogItems.items = allSummitLogItems.items.sort((a, b) => {
|
||||
return new Date(a.created ?? 0).getTime() - new Date(b.created ?? 0).getTime()
|
||||
})
|
||||
|
||||
return json(allSummitLogItems)
|
||||
}
|
||||
} catch (e) {
|
||||
return handleError(e)
|
||||
}
|
||||
|
||||
@@ -1,18 +1,15 @@
|
||||
import { RecordOptionsSchema } from '$lib/models/api/base_schema';
|
||||
import { TrailUpdateSchema } from '$lib/models/api/trail_schema';
|
||||
import type { Trail } from "$lib/models/trail";
|
||||
import { APIError, Collection, handleError, remove, show, update } from "$lib/util/api_util";
|
||||
import { objectToFormData } from "$lib/util/file_util";
|
||||
import { Collection, handleError, remove, update } from "$lib/util/api_util";
|
||||
import { json, type RequestEvent } from "@sveltejs/kit";
|
||||
import type PocketBase from "pocketbase";
|
||||
import { ClientResponseError } from "pocketbase";
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /api/v1/trail/{id}:
|
||||
* get:
|
||||
* summary: Get trail
|
||||
* description: Retrieves a trail by ID. Supports federated queries via handle parameter, fetching from remote instances and remapping file URLs
|
||||
* description: Retrieves a trail by ID
|
||||
* tags:
|
||||
* - Trails
|
||||
* parameters:
|
||||
@@ -26,149 +23,31 @@ import { ClientResponseError } from "pocketbase";
|
||||
* schema:
|
||||
* type: string
|
||||
* - in: query
|
||||
* name: handle
|
||||
* schema:
|
||||
* type: string
|
||||
* - in: query
|
||||
* name: share
|
||||
* schema:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Trail with optional federated data
|
||||
* description: Trail
|
||||
* 404:
|
||||
* description: Not Found
|
||||
* 500:
|
||||
* description: Internal Server Error
|
||||
*/
|
||||
export async function GET(event: RequestEvent) {
|
||||
try {
|
||||
// try to get the trail simply via the
|
||||
let t: Trail;
|
||||
if (!event.url.searchParams.has("handle")) {
|
||||
t = await show<Trail>(event, Collection.trails)
|
||||
} else {
|
||||
let { actor, error } = await event.locals.pb.send(`/activitypub/actor?resource=acct:${event.url.searchParams.get("handle")}`, { method: "GET", fetch: event.fetch, });
|
||||
event.url.searchParams.delete("handle")
|
||||
|
||||
const safeSearchParams = RecordOptionsSchema.parse(Object.fromEntries(event.url.searchParams));
|
||||
if (event.url.searchParams.has("share")) {
|
||||
safeSearchParams.query = { share: event.url.searchParams.get("share")! }
|
||||
}
|
||||
let origin = new URL(actor.iri).origin
|
||||
let iri = `${origin}/api/v1/trail/${event.params.id}`
|
||||
const { url, params } = event;
|
||||
|
||||
try {
|
||||
t = await event.locals.pb.collection("trails").getFirstListItem(`iri='${iri}'||id='${event.params.id}'`, {
|
||||
...safeSearchParams
|
||||
})
|
||||
} catch (e) {
|
||||
if (!(e instanceof ClientResponseError) || e.status != 404) {
|
||||
throw e
|
||||
}
|
||||
t = {
|
||||
iri: iri,
|
||||
author: actor.id,
|
||||
like_count: 0
|
||||
} as Trail
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
if (t.iri) {
|
||||
const origin = new URL(t.iri).origin
|
||||
const actor = await event.locals.pb.collection("activitypub_actors").getOne(t.author)
|
||||
|
||||
const localTrailId = t.id;
|
||||
const localTrailIRI = t.iri;
|
||||
const localLikeCount = t.like_count;
|
||||
const localLikes = t.expand?.trail_like_via_trail;
|
||||
|
||||
const response = await event.fetch((t.iri) + '?' + event.url.searchParams, { method: 'GET' })
|
||||
if (!response.ok) {
|
||||
const errorResponse = await response.json()
|
||||
console.error(errorResponse)
|
||||
if (t.id) {
|
||||
return json(t)
|
||||
} else {
|
||||
throw new ClientResponseError({ status: response.status, response: errorResponse })
|
||||
}
|
||||
}
|
||||
t = await response.json()
|
||||
|
||||
// this came directly from the database of the remote instance
|
||||
// we need to adjust some urls to get photos, gpx etc.
|
||||
if (!t.iri) {
|
||||
if (t.gpx) {
|
||||
t.gpx = `${origin}/api/v1/files/trails/${t.id}/${t.gpx}`
|
||||
}
|
||||
t.photos = t.photos.map(p =>
|
||||
`${origin}/api/v1/files/trails/${t.id}/${p}`
|
||||
)
|
||||
t.expand?.summit_logs_via_trail?.forEach(l => {
|
||||
if (l.gpx) {
|
||||
l.gpx = `${origin}/api/v1/files/summit_logs/${l.id}/${l.gpx}`
|
||||
}
|
||||
l.photos = l.photos.map(p =>
|
||||
`${origin}/api/v1/files/summit_logs/${l.id}/${p}`
|
||||
)
|
||||
|
||||
if (l.expand?.author) {
|
||||
l.expand.author.isLocal = false
|
||||
}
|
||||
})
|
||||
t.expand?.waypoints_via_trail?.forEach(w => {
|
||||
|
||||
w.photos = w.photos.map(p =>
|
||||
`${origin}/api/v1/files/waypoints/${w.id}/${p}`
|
||||
)
|
||||
let trail: Trail = await event.locals.pb.send(`/remote/trail/${params.id}?` + url.searchParams, {
|
||||
method: "GET",
|
||||
fetch: event.fetch,
|
||||
})
|
||||
|
||||
t.author = actor.id!
|
||||
t.expand!.author = actor as any
|
||||
t.id = localTrailId
|
||||
t.iri = localTrailIRI
|
||||
t.like_count = localLikeCount
|
||||
t.expand!.trail_like_via_trail = localLikes;
|
||||
}
|
||||
|
||||
let categoryId: string | undefined;
|
||||
try {
|
||||
const category = await event.locals.pb.collection("categories").getFirstListItem(`name='${t.expand?.category?.name}'`)
|
||||
categoryId = category.id;
|
||||
t.expand!.category = category as any
|
||||
} catch (e) { }
|
||||
|
||||
const formData = objectToFormData({ ...t, id: t.id, gpx: undefined, expand: undefined, photos: [], waypoints: [], tags: [], category: categoryId })
|
||||
if (t.photos.length) {
|
||||
const photoURL = t.photos[t.thumbnail ?? 0]
|
||||
let response = await event.fetch(photoURL, { method: "GET" })
|
||||
const photo = await response.blob()
|
||||
formData.append("photos", photo)
|
||||
}
|
||||
if (t.gpx) {
|
||||
const gpxURL = t.gpx
|
||||
const response = await event.fetch(gpxURL, { method: "GET" })
|
||||
const gpx = await response.blob()
|
||||
formData.append("gpx", gpx)
|
||||
}
|
||||
if (t.id) {
|
||||
await event.locals.pb.collection("trails").update(t.id, formData)
|
||||
} else {
|
||||
const createdTrail = await event.locals.pb.collection("trails").create(formData)
|
||||
t.id = createdTrail.id;
|
||||
}
|
||||
}
|
||||
|
||||
// remove time from dates
|
||||
await enrichRecord(event.locals.pb, t);
|
||||
|
||||
// sort waypoints by distance
|
||||
t.expand?.waypoints_via_trail?.sort((a, b) => (a.distance_from_start ?? 0) - (b.distance_from_start ?? 0))
|
||||
return json(t)
|
||||
await enrichRecord(event.locals.pb, trail);
|
||||
trail.expand?.waypoints_via_trail?.sort((a, b) => (a.distance_from_start ?? 0) - (b.distance_from_start ?? 0))
|
||||
return json(trail)
|
||||
} catch (e: any) {
|
||||
return handleError(e)
|
||||
return handleError(e);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
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">
|
||||
import { page } from "$app/state";
|
||||
import { follows_index } from "$lib/stores/follow_store.js";
|
||||
import { profile_follows_index } from "$lib/stores/profile_store.js";
|
||||
import { show_toast } from "$lib/stores/toast_store.svelte.js";
|
||||
import { APIError } from "$lib/util/api_util.js";
|
||||
import { untrack } from "svelte";
|
||||
@@ -37,13 +37,10 @@
|
||||
async function loadNextPage() {
|
||||
pagination.page += 1;
|
||||
try {
|
||||
follows = await follows_index(
|
||||
{
|
||||
type: page.params.type as "followers" | "following",
|
||||
username: page.params.handle!,
|
||||
},
|
||||
follows = await profile_follows_index(
|
||||
page.params.handle!,
|
||||
page.params.type as "followers" | "following",
|
||||
pagination.page,
|
||||
10,
|
||||
fetch,
|
||||
);
|
||||
} catch (e) {
|
||||
@@ -51,8 +48,8 @@
|
||||
show_toast({
|
||||
icon: "close",
|
||||
text: `${e.status}: ${e.message}`,
|
||||
type: "error"
|
||||
})
|
||||
type: "error",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { follows_index } from "$lib/stores/follow_store";
|
||||
import { profile_follows_index } from "$lib/stores/profile_store";
|
||||
import { APIError } from "$lib/util/api_util";
|
||||
import { error, type Load } from "@sveltejs/kit";
|
||||
import { ClientResponseError } from "pocketbase";
|
||||
@@ -9,8 +10,12 @@ export const load: Load = async ({ params, fetch }) => {
|
||||
}
|
||||
|
||||
try {
|
||||
const follows = await follows_index({ type: params.type, username: params.handle! },
|
||||
1, 10, fetch)
|
||||
const follows = await profile_follows_index(
|
||||
params.handle!,
|
||||
params.type as "followers" | "following",
|
||||
1,
|
||||
fetch,
|
||||
);
|
||||
return { follows }
|
||||
|
||||
} catch (e) {
|
||||
|
||||
Reference in New Issue
Block a user