diff --git a/db/.dockerignore b/db/.dockerignore index 6fc22d74..9f2708ac 100644 --- a/db/.dockerignore +++ b/db/.dockerignore @@ -1,13 +1,12 @@ * !commands !federation +!hooks !go.* !integrations !main.go -!trail_merge_routes.go !migrations +!routes !templates -!trailmerge -!waypointcluster -!waypointcluster/** +!services !util diff --git a/db/federation/activity.go b/db/federation/activity.go index b346dda7..af359b02 100644 --- a/db/federation/activity.go +++ b/db/federation/activity.go @@ -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 -} diff --git a/db/federation/actor.go b/db/federation/actor.go index 59d03fac..c8ba11f2 100644 --- a/db/federation/actor.go +++ b/db/federation/actor.go @@ -1,14 +1,17 @@ package federation import ( + "context" "crypto/x509" "database/sql" "encoding/json" "errors" "fmt" + "io" "net/http" "net/url" "os" + "pocketbase/util" "strings" "time" @@ -21,6 +24,7 @@ import ( ) var ErrProfilePrivate = errors.New("profile is private") +var ErrInvalidActorResponse = errors.New("invalid or incomplete actor response") type WebfingerResponse struct { Subject string `json:"subject"` @@ -30,24 +34,36 @@ type WebfingerResponse struct { } `json:"links"` } -func SplitHandle(handle string) (string, string) { - - cleaned := strings.TrimPrefix(handle, "@") - cleaned = strings.TrimSpace(cleaned) - - if !strings.Contains(cleaned, "@") { - return cleaned, "" +func validateActorResponse(actor *pub.Actor) error { + if actor == nil { + return ErrInvalidActorResponse } - parts := strings.SplitN(cleaned, "@", 2) - user := parts[0] - domain := parts[1] + if actor.GetID().String() == "" { + return fmt.Errorf("%w: missing ID", ErrInvalidActorResponse) + } - return user, domain + if actor.PreferredUsername.String() == "" && actor.Name.String() == "" { + return fmt.Errorf("%w: missing username or name", ErrInvalidActorResponse) + } + + if util.ItemID(actor.Inbox) == "" { + return fmt.Errorf("%w: missing inbox", ErrInvalidActorResponse) + } + + if util.ItemID(actor.Outbox) == "" { + return fmt.Errorf("%w: missing outbox", ErrInvalidActorResponse) + } + + if actor.PublicKey.PublicKeyPem == "" { + return fmt.Errorf("%w: missing public key", ErrInvalidActorResponse) + } + + return nil } -func GetActorByHandle(app core.App, actor *core.Record, handle string, includeFollows bool) (*core.Record, error) { - username, domain := SplitHandle(handle) +func GetActorByHandle(app core.App, ctx context.Context, handle string, includeFollows bool) (*core.Record, error) { + username, domain := util.SplitHandle(handle) filter := "preferred_username={:username}&&" if domain != "" { @@ -66,7 +82,7 @@ func GetActorByHandle(app core.App, actor *core.Record, handle string, includeFo dbActor = core.NewRecord(collection) dbActor.Set("isLocal", false) - iri, err := iriFromHandle(domain, username) + iri, err := iriFromHandle(ctx, domain, username) if err != nil { return nil, err } @@ -76,10 +92,10 @@ func GetActorByHandle(app core.App, actor *core.Record, handle string, includeFo return nil, err } - return assembleActor(actor, dbActor, app, includeFollows) + return assembleActor(app, ctx, dbActor, includeFollows || dbActor.Id == "") } -func GetActorByIRI(app core.App, actor *core.Record, iri string, includeFollows bool) (*core.Record, error) { +func GetActorByIRI(app core.App, ctx context.Context, iri string, includeFollows bool) (*core.Record, error) { var dbActor *core.Record dbActor, err := app.FindFirstRecordByFilter("activitypub_actors", "iri={:iri}", dbx.Params{"iri": iri}) if err != nil && err == sql.ErrNoRows { @@ -96,33 +112,55 @@ func GetActorByIRI(app core.App, actor *core.Record, iri string, includeFollows return nil, err } - return assembleActor(actor, dbActor, app, includeFollows) + return assembleActor(app, ctx, dbActor, includeFollows || dbActor.Id == "") } -func iriFromHandle(domain string, username string) (string, error) { - client := &http.Client{} +func iriFromHandle(ctx context.Context, domain string, username string) (string, error) { + client := util.SafeHTTPClient() - webfingerURL := fmt.Sprintf("https://%s/.well-known/webfinger?resource=acct:%s@%s", domain, username, domain) - resp, err := client.Get(webfingerURL) - if err != nil || resp.StatusCode != http.StatusOK { - return "", fmt.Errorf("webfinger request failed: %v", err) + u := &url.URL{ + Scheme: "https", + Host: domain, + Path: "/.well-known/webfinger", + } + q := u.Query() + q.Set("resource", fmt.Sprintf("acct:%s@%s", username, domain)) + u.RawQuery = q.Encode() + + req, err := http.NewRequestWithContext(ctx, "GET", u.String(), nil) + if err != nil { + return "", err + } + + resp, err := client.Do(req) + if err != nil { + return "", fmt.Errorf("webfinger request failed: %w", err) } defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("unexpected status: %d", resp.StatusCode) + } + + limitedReader := io.LimitReader(resp.Body, 102400) + var wf WebfingerResponse - if err := json.NewDecoder(resp.Body).Decode(&wf); err != nil { - return "", err + if err := json.NewDecoder(limitedReader).Decode(&wf); err != nil { + return "", fmt.Errorf("failed to decode JSON: %w", err) } for _, link := range wf.Links { if link.Rel == "self" { + if _, err := url.Parse(link.Href); err != nil { + return "", fmt.Errorf("invalid IRI in response") + } return link.Href, nil } } return "", fmt.Errorf("no iri in response") } -func assembleActor(actor *core.Record, dbActor *core.Record, app core.App, includeFollows bool) (*core.Record, error) { +func assembleActor(app core.App, ctx context.Context, dbActor *core.Record, includeFollows bool) (*core.Record, error) { origin := os.Getenv("ORIGIN") if origin == "" { return nil, fmt.Errorf("ORIGIN environment variable not set") @@ -147,12 +185,12 @@ func assembleActor(actor *core.Record, dbActor *core.Record, app core.App, inclu if err != nil { return nil, err } - dbActor.Set("followerCount", followerCount) + dbActor.Set("follower_count", followerCount) followingCount, err := app.CountRecords("follows", dbx.NewExp("follower={:user} AND status='accepted'", dbx.Params{"user": dbActor.Id})) if err != nil { return nil, err } - dbActor.Set("followingCount", followingCount) + dbActor.Set("following_count", followingCount) dbActor.Set("last_fetched", time.Now()) @@ -165,11 +203,11 @@ func assembleActor(actor *core.Record, dbActor *core.Record, app core.App, inclu } else { // check if value is still cached - twoHoursAgo := time.Now().Add(-2 * time.Hour) - if !includeFollows && dbActor.GetDateTime("last_fetched").Time().After(twoHoursAgo) { + twoHoursAgo := time.Now().UTC().Add(-2 * time.Hour) + if dbActor.GetDateTime("last_fetched").Time().After(twoHoursAgo) { return dbActor, nil } - pubActor, followers, following, err := fetchRemoteActor(actor, dbActor.GetString("iri"), includeFollows) + pubActor, followers, following, err := fetchRemoteActor(app, ctx, dbActor.GetString("iri"), includeFollows) if err != nil { if dbActor.Id != "" { return dbActor, err @@ -191,34 +229,35 @@ func assembleActor(actor *core.Record, dbActor *core.Record, app core.App, inclu } domain := strings.TrimPrefix(parsedUrl.Hostname(), "www.") + // this is a race condition that gets triggered when the profile is opened for the first time + existingActor, _ := app.FindFirstRecordByData("activitypub_actors", "iri", dbActor.GetString("iri")) + + if existingActor != nil { + dbActor = existingActor + } + dbActor.Set("domain", domain) - dbActor.Set("followers", pubActor.Followers.GetID().String()) - dbActor.Set("inbox", pubActor.Inbox.GetID().String()) + dbActor.Set("followers", util.ItemID(pubActor.Followers)) + dbActor.Set("inbox", util.ItemID(pubActor.Inbox)) dbActor.Set("iri", pubActor.GetID().String()) dbActor.Set("username", pubActor.Name.String()) dbActor.Set("preferred_username", pubActor.PreferredUsername.String()) - dbActor.Set("following", pubActor.Following.GetID().String()) + dbActor.Set("following", util.ItemID(pubActor.Following)) dbActor.Set("summary", pubActor.Summary.String()) - dbActor.Set("outbox", pubActor.Outbox.GetID().String()) + dbActor.Set("outbox", util.ItemID(pubActor.Outbox)) dbActor.Set("icon", icon) dbActor.Set("published", pubActor.Published.String()) dbActor.Set("public_key", pubActor.PublicKey.PublicKeyPem) dbActor.Set("last_fetched", time.Now()) if includeFollows { - dbActor.Set("followerCount", int(followers.TotalItems)) - dbActor.Set("followingCount", int(following.TotalItems)) + dbActor.Set("follower_count", int(followers.TotalItems)) + dbActor.Set("following_count", int(following.TotalItems)) } } err := app.Save(dbActor) - if err != nil && err.Error() == "iri: Value must be unique." { - dbActor, err = app.FindFirstRecordByData("activitypub_actors", "iri", dbActor.GetString("iri")) - if err != nil { - return nil, err - } - return dbActor, nil - } else if err != nil { + if err != nil { return nil, err } @@ -230,14 +269,15 @@ func assembleActor(actor *core.Record, dbActor *core.Record, app core.App, inclu } // Fetches an AP actor and optionally followers/following collections -func fetchRemoteActor(actor *core.Record, iri string, includeFollows bool) (*pub.Actor, *pub.OrderedCollection, *pub.OrderedCollection, error) { +func fetchRemoteActor(app core.App, ctx context.Context, iri string, includeFollows bool) (*pub.Actor, *pub.OrderedCollection, *pub.OrderedCollection, error) { encryptionKey := os.Getenv("POCKETBASE_ENCRYPTION_KEY") if len(encryptionKey) == 0 { return nil, nil, nil, fmt.Errorf("POCKETBASE_ENCRYPTION_KEY not set") } - client := &http.Client{} - req, _ := http.NewRequest("GET", iri, nil) + client := util.SafeHTTPClient() + + req, err := http.NewRequestWithContext(ctx, "GET", iri, nil) headers := map[string]string{ "Accept": "application/ld+json", @@ -250,8 +290,10 @@ func fetchRemoteActor(actor *core.Record, iri string, includeFollows bool) (*pub req.Header.Add(k, v) } - if actor != nil && actor.GetString("private_key") != "" { - dbPrivateKey := actor.GetString("private_key") + userActorId := strings.TrimPrefix(ctx.Value("actor").(string), "actor:") + userActor, err := app.FindRecordById("activitypub_actors", userActorId) + if userActor != nil && userActor.GetString("private_key") != "" { + dbPrivateKey := userActor.GetString("private_key") algs := []httpsig.Algorithm{httpsig.RSA_SHA256} postHeaders := []string{"(request-target)", "Date", "Digest", "Content-Type", "Host"} @@ -271,7 +313,7 @@ func fetchRemoteActor(actor *core.Record, iri string, includeFollows bool) (*pub return nil, nil, nil, err } - pubID := actor.GetString("iri") + "#main-key" + pubID := userActor.GetString("iri") + "#main-key" if err := signer.SignRequest(privateKey, pubID, req, []byte{}); err != nil { return nil, nil, nil, err @@ -293,16 +335,21 @@ func fetchRemoteActor(actor *core.Record, iri string, includeFollows bool) (*pub return nil, nil, nil, err } + // Validate actor response has required fields + if err := validateActorResponse(&pubActor); err != nil { + return nil, nil, nil, fmt.Errorf("actor validation failed for %s: %w", iri, err) + } + var followers, following pub.OrderedCollection if includeFollows { // Fetch followers - if data, err := FetchCollection(actor, pubActor.Followers.GetID().String()); err == nil { + if data, err := FetchCollection(app, ctx, util.ItemID(pubActor.Followers)); err == nil { followers = *data } // Fetch following - if data, err := FetchCollection(actor, pubActor.Following.GetID().String()); err == nil { + if data, err := FetchCollection(app, ctx, util.ItemID(pubActor.Following)); err == nil { following = *data } } @@ -310,12 +357,13 @@ func fetchRemoteActor(actor *core.Record, iri string, includeFollows bool) (*pub return &pubActor, &followers, &following, nil } -func FetchCollection(actor *core.Record, url string) (*pub.OrderedCollection, error) { +func FetchCollection(app core.App, ctx context.Context, collectionURL string) (*pub.OrderedCollection, error) { encryptionKey := os.Getenv("POCKETBASE_ENCRYPTION_KEY") if len(encryptionKey) == 0 { return nil, fmt.Errorf("POCKETBASE_ENCRYPTION_KEY not set") } - req, _ := http.NewRequest("GET", url, nil) + + req, err := http.NewRequestWithContext(ctx, "GET", collectionURL, nil) headers := map[string]string{ "Accept": "application/ld+json", @@ -327,9 +375,10 @@ func FetchCollection(actor *core.Record, url string) (*pub.OrderedCollection, er for k, v := range headers { req.Header.Add(k, v) } - - if actor != nil && actor.GetString("private_key") != "" { - dbPrivateKey := actor.GetString("private_key") + userActorId := strings.TrimPrefix(ctx.Value("actor").(string), "actor:") + userActor, err := app.FindRecordById("activitypub_actors", userActorId) + if userActor != nil && userActor.GetString("private_key") != "" { + dbPrivateKey := userActor.GetString("private_key") if dbPrivateKey != "" { algs := []httpsig.Algorithm{httpsig.RSA_SHA256} postHeaders := []string{"(request-target)", "Date", "Digest", "Content-Type", "Host"} @@ -349,7 +398,7 @@ func FetchCollection(actor *core.Record, url string) (*pub.OrderedCollection, er return nil, err } - pubID := actor.GetString("iri") + "#main-key" + pubID := userActor.GetString("iri") + "#main-key" if err := signer.SignRequest(privateKey, pubID, req, []byte{}); err != nil { return nil, err @@ -358,15 +407,16 @@ func FetchCollection(actor *core.Record, url string) (*pub.OrderedCollection, er } } - resp, err := http.DefaultClient.Do(req) + client := util.SafeHTTPClient() + resp, err := client.Do(req) if err != nil { - return nil, fmt.Errorf("collection fetch failed for %s: %v", url, err) + return nil, fmt.Errorf("collection fetch failed for %s: %v", collectionURL, err) } if resp.StatusCode != http.StatusOK { if resp.StatusCode == http.StatusNotFound { return nil, ErrProfilePrivate } - return nil, fmt.Errorf("collection fetch %s returned: %v", url, resp.StatusCode) + return nil, fmt.Errorf("collection fetch %s returned: %v", collectionURL, resp.StatusCode) } defer resp.Body.Close() diff --git a/db/federation/create.go b/db/federation/create.go index c452d53d..126349db 100644 --- a/db/federation/create.go +++ b/db/federation/create.go @@ -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 } diff --git a/db/federation/delete.go b/db/federation/delete.go index 6703f550..0c2283ae 100644 --- a/db/federation/delete.go +++ b/db/federation/delete.go @@ -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") diff --git a/db/hooks/api_tokens.go b/db/hooks/api_tokens.go new file mode 100644 index 00000000..d1fe46b5 --- /dev/null +++ b/db/hooks/api_tokens.go @@ -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() + } +} diff --git a/db/hooks/bootstrap.go b/db/hooks/bootstrap.go new file mode 100644 index 00000000..1fa75638 --- /dev/null +++ b/db/hooks/bootstrap.go @@ -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()) + } +} diff --git a/db/hooks/comments.go b/db/hooks/comments.go new file mode 100644 index 00000000..ebd77353 --- /dev/null +++ b/db/hooks/comments.go @@ -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() + } +} diff --git a/db/hooks/feed.go b/db/hooks/feed.go new file mode 100644 index 00000000..3ba9efd5 --- /dev/null +++ b/db/hooks/feed.go @@ -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() + } +} diff --git a/db/hooks/follow.go b/db/hooks/follow.go new file mode 100644 index 00000000..0efcdef9 --- /dev/null +++ b/db/hooks/follow.go @@ -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() + } +} diff --git a/db/hooks/integrations.go b/db/hooks/integrations.go new file mode 100644 index 00000000..7395d711 --- /dev/null +++ b/db/hooks/integrations.go @@ -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 +} diff --git a/db/hooks/list.go b/db/hooks/list.go new file mode 100644 index 00000000..91eabd0c --- /dev/null +++ b/db/hooks/list.go @@ -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() + } +} diff --git a/db/hooks/list_share.go b/db/hooks/list_share.go new file mode 100644 index 00000000..680fe07e --- /dev/null +++ b/db/hooks/list_share.go @@ -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() + } +} diff --git a/db/hooks/summit_logs.go b/db/hooks/summit_logs.go new file mode 100644 index 00000000..a703f150 --- /dev/null +++ b/db/hooks/summit_logs.go @@ -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 + } +} diff --git a/db/hooks/trail_like.go b/db/hooks/trail_like.go new file mode 100644 index 00000000..9f18e612 --- /dev/null +++ b/db/hooks/trail_like.go @@ -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() + } +} diff --git a/db/hooks/trail_share.go b/db/hooks/trail_share.go new file mode 100644 index 00000000..52941e96 --- /dev/null +++ b/db/hooks/trail_share.go @@ -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() + } +} diff --git a/db/hooks/trails.go b/db/hooks/trails.go new file mode 100644 index 00000000..a3045b46 --- /dev/null +++ b/db/hooks/trails.go @@ -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() + } +} diff --git a/db/hooks/users.go b/db/hooks/users.go new file mode 100644 index 00000000..cb2fb2e4 --- /dev/null +++ b/db/hooks/users.go @@ -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) +} diff --git a/db/integrations/hammerhead/hammerhead.go b/db/integrations/hammerhead/hammerhead.go index c42c7f1e..dd855946 100644 --- a/db/integrations/hammerhead/hammerhead.go +++ b/db/integrations/hammerhead/hammerhead.go @@ -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)) } } diff --git a/db/integrations/hammerhead/models.go b/db/integrations/hammerhead/models.go index 6271c29a..1cb8054e 100644 --- a/db/integrations/hammerhead/models.go +++ b/db/integrations/hammerhead/models.go @@ -3,7 +3,7 @@ package hammerhead import ( "time" - "pocketbase/trailmerge" + "pocketbase/services/trailmerge" ) type HammerheadToursResponse struct { diff --git a/db/integrations/komoot/komoot.go b/db/integrations/komoot/komoot.go index 6fbda2c0..afed1803 100644 --- a/db/integrations/komoot/komoot.go +++ b/db/integrations/komoot/komoot.go @@ -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)) } diff --git a/db/integrations/komoot/models.go b/db/integrations/komoot/models.go index 456d0827..7ee15e61 100644 --- a/db/integrations/komoot/models.go +++ b/db/integrations/komoot/models.go @@ -3,7 +3,7 @@ package komoot import ( "time" - "pocketbase/trailmerge" + "pocketbase/services/trailmerge" ) type KomootIntegration struct { diff --git a/db/integrations/strava/models.go b/db/integrations/strava/models.go index 8710dee0..41ccb665 100644 --- a/db/integrations/strava/models.go +++ b/db/integrations/strava/models.go @@ -3,7 +3,7 @@ package strava import ( "time" - "pocketbase/trailmerge" + "pocketbase/services/trailmerge" ) type TokenRequest struct { diff --git a/db/integrations/strava/strava.go b/db/integrations/strava/strava.go index 81f5181d..e4bee208 100644 --- a/db/integrations/strava/strava.go +++ b/db/integrations/strava/strava.go @@ -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)) } } diff --git a/db/main.go b/db/main.go index 02b1d6e3..0056f441 100644 --- a/db/main.go +++ b/db/main.go @@ -1,40 +1,26 @@ package main import ( - "cmp" - "database/sql" - "encoding/json" - "errors" "fmt" "log" - "net/http" "os" - "strconv" "strings" - "time" "github.com/meilisearch/meilisearch-go" - "github.com/pocketbase/dbx" "github.com/pocketbase/pocketbase" - "github.com/pocketbase/pocketbase/apis" "github.com/pocketbase/pocketbase/core" "github.com/pocketbase/pocketbase/plugins/migratecmd" "github.com/pocketbase/pocketbase/tools/filesystem" - "github.com/pocketbase/pocketbase/tools/security" - "github.com/spf13/cast" "pocketbase/commands" - "pocketbase/federation" + "pocketbase/hooks" "pocketbase/integrations/hammerhead" "pocketbase/integrations/komoot" "pocketbase/integrations/strava" + "pocketbase/routes" _ "pocketbase/migrations" "pocketbase/util" - "pocketbase/waypointcluster" - - pub "github.com/go-ap/activitypub" - "github.com/microcosm-cc/bluemonday" ) const ( @@ -100,1303 +86,100 @@ func registerMigrations(app *pocketbase.PocketBase) { } func setupEventHandlers(app *pocketbase.PocketBase, client meilisearch.ServiceManager) { - app.OnRecordAfterCreateSuccess("users").BindFunc(createUserHandler(client)) - app.OnRecordAfterUpdateSuccess("users").BindFunc(updateUserHandler(client)) + app.OnRecordAfterCreateSuccess("users").BindFunc(hooks.CreateUserHandler(client)) + app.OnRecordAfterUpdateSuccess("users").BindFunc(hooks.UpdateUserHandler(client)) + app.OnRecordRequestEmailChangeRequest("users").BindFunc(hooks.ChangeUserEmailHandler()) - app.OnRecordAfterCreateSuccess("trails").BindFunc(createTrailHandler(client)) - app.OnRecordAfterUpdateSuccess("trails").BindFunc(updateTrailHandler(client)) - app.OnRecordAfterDeleteSuccess("trails").BindFunc(deleteTrailHandler(client)) + app.OnRecordAfterCreateSuccess("trails").BindFunc(hooks.CreateTrailHandler(client)) + app.OnRecordAfterUpdateSuccess("trails").BindFunc(hooks.UpdateTrailHandler(client)) + app.OnRecordAfterDeleteSuccess("trails").BindFunc(hooks.DeleteTrailHandler(client)) - app.OnRecordCreateRequest("summit_logs").BindFunc(createSummitLogHandler(client)) - app.OnRecordUpdateRequest("summit_logs").BindFunc(updateSummitLogHandler()) - app.OnRecordDeleteRequest("summit_logs").BindFunc(deleteSummitLogHandler(client)) + app.OnRecordCreateRequest("summit_logs").BindFunc(hooks.CreateSummitLogHandler(client)) + app.OnRecordUpdateRequest("summit_logs").BindFunc(hooks.UpdateSummitLogHandler()) + app.OnRecordDeleteRequest("summit_logs").BindFunc(hooks.DeleteSummitLogHandler(client)) - app.OnRecordCreateRequest("comments").BindFunc(createCommentHandler()) - app.OnRecordUpdateRequest("comments").BindFunc(updateCommentHandler()) - app.OnRecordDeleteRequest("comments").BindFunc(deleteCommentHandler(client)) + app.OnRecordCreateRequest("comments").BindFunc(hooks.CreateCommentHandler()) + app.OnRecordUpdateRequest("comments").BindFunc(hooks.UpdateCommentHandler()) + app.OnRecordDeleteRequest("comments").BindFunc(hooks.DeleteCommentHandler(client)) - app.OnRecordCreateRequest("trail_share").BindFunc(createTrailShareHandler(client)) - app.OnRecordDeleteRequest("trail_share").BindFunc(deleteTrailShareHandler(client)) + app.OnRecordCreateRequest("trail_share").BindFunc(hooks.CreateTrailShareHandler(client)) + app.OnRecordDeleteRequest("trail_share").BindFunc(hooks.DeleteTrailShareHandler(client)) - app.OnRecordAfterCreateSuccess("trail_like").BindFunc(createTrailLikeHandler(client)) - app.OnRecordAfterDeleteSuccess("trail_like").BindFunc(deleteTrailLikeHandler(client)) + app.OnRecordAfterCreateSuccess("trail_like").BindFunc(hooks.CreateTrailLikeHandler(client)) + app.OnRecordAfterDeleteSuccess("trail_like").BindFunc(hooks.DeleteTrailLikeHandler(client)) - app.OnRecordAfterCreateSuccess("lists").BindFunc(createListHandler(client)) - app.OnRecordAfterUpdateSuccess("lists").BindFunc(updateListHandler(client)) - app.OnRecordAfterDeleteSuccess("lists").BindFunc(deleteListHandler(client)) + app.OnRecordAfterCreateSuccess("lists").BindFunc(hooks.CreateListHandler(client)) + app.OnRecordAfterUpdateSuccess("lists").BindFunc(hooks.UpdateListHandler(client)) + app.OnRecordAfterDeleteSuccess("lists").BindFunc(hooks.DeleteListHandler(client)) - app.OnRecordCreateRequest("list_share").BindFunc(createListShareHandler(client)) - app.OnRecordDeleteRequest("list_share").BindFunc(deleteListShareHandler(client)) + app.OnRecordCreateRequest("list_share").BindFunc(hooks.CreateListShareHandler(client)) + app.OnRecordDeleteRequest("list_share").BindFunc(hooks.DeleteListShareHandler(client)) - app.OnRecordCreateRequest("follows").BindFunc(createFollowHandler()) - app.OnRecordDeleteRequest("follows").BindFunc(deleteFollowHandler()) + app.OnRecordCreateRequest("follows").BindFunc(hooks.CreateFollowHandler()) + app.OnRecordDeleteRequest("follows").BindFunc(hooks.DeleteFollowHandler()) - app.OnRecordsListRequest("integrations").BindFunc(listIntegrationHandler()) - app.OnRecordCreate("integrations").BindFunc(createIntegrationHandler()) - app.OnRecordAfterCreateSuccess("integrations").BindFunc(createUpdateIntegrationSuccessHandler()) - app.OnRecordUpdate("integrations").BindFunc(updateIntegrationHandler()) - app.OnRecordAfterUpdateSuccess("integrations").BindFunc(createUpdateIntegrationSuccessHandler()) + app.OnRecordsListRequest("integrations").BindFunc(hooks.ListIntegrationHandler()) + app.OnRecordCreate("integrations").BindFunc(hooks.CreateIntegrationHandler()) + app.OnRecordAfterCreateSuccess("integrations").BindFunc(hooks.CreateUpdateIntegrationSuccessHandler()) + app.OnRecordUpdate("integrations").BindFunc(hooks.UpdateIntegrationHandler()) + app.OnRecordAfterUpdateSuccess("integrations").BindFunc(hooks.CreateUpdateIntegrationSuccessHandler()) - app.OnRecordsListRequest("feed", "profile_feed").BindFunc(listFeedHandler()) + app.OnRecordsListRequest("feed", "profile_feed").BindFunc(hooks.ListFeedHandler()) - app.OnRecordCreate("api_tokens").BindFunc(createAPITokenHandler()) + app.OnRecordCreate("api_tokens").BindFunc(hooks.CreateAPITokenHandler()) - app.OnRecordCreateRequest().BindFunc(sanitizeHTML()) - app.OnRecordUpdateRequest().BindFunc(sanitizeHTML()) + app.OnRecordCreateRequest().BindFunc(util.SanitizeHTML()) + app.OnRecordUpdateRequest().BindFunc(util.SanitizeHTML()) - app.OnRecordRequestEmailChangeRequest("users").BindFunc(changeUserEmailHandler()) app.OnServe().BindFunc(onBeforeServeHandler(client)) - app.OnBootstrap().BindFunc(onBootstrapHandler()) + app.OnBootstrap().BindFunc(hooks.OnBootstrapHandler()) } func setupCommands(app *pocketbase.PocketBase) { app.RootCmd.AddCommand(commands.Dedup(app)) } -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() - } -} - -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 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) -} - -func createTrailHandler(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.IndexTrails(e.App, []*core.Record{record}, client); err != nil { - return err - } - if !author.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 - } - - err = federation.CreateTrailActivity(e.App, author, e.Record, pub.CreateType) - if err != nil { - return err - } - - _, err = util.InsertIntoFeed(e.App, author.Id, author.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 - author, err := e.App.FindRecordById("activitypub_actors", record.GetString(("author"))) - if err != nil { - return err - } - err = util.UpdateTrail(e.App, record, author, client) - if err != nil { - return err - } - if !author.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 - } - - err = federation.CreateTrailActivity(e.App, author, 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() - } -} - -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 - } - - 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, userActor, 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 - } - - err = federation.CreateSummitLogActivity(e.App, userActor, 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 - } -} - -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 - } - - err = federation.CreateCommentActivity(e.App, userActor, 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 - } - - err = federation.CreateCommentActivity(e.App, userActor, 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() - } -} - -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() - } -} - -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() - } -} - -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() - } -} - -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() - } -} - -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() - } -} - -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 -} - -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 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() - } -} - -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() - } -} - func onBeforeServeHandler(client meilisearch.ServiceManager) func(se *core.ServeEvent) error { return func(se *core.ServeEvent) error { registerRoutes(se, client) registerCronJobs(se.App, client) - bootstrapData(se.App, client) + initData(se.App, client) return se.Next() } } -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()) - } -} - func registerRoutes(se *core.ServeEvent, client meilisearch.ServiceManager) { - se.Router.GET("/health", func(e *core.RequestEvent) error { - return e.JSON(http.StatusOK, map[string]string{"status": "ok"}) - }) + se.Router.GET("/health", routes.Health) - registerTrailMergeRoutes(se, client) - se.Router.POST("/waypoint/cluster", waypointcluster.Handler) + se.Router.POST("/auth/token", routes.AuthToken) + se.Router.POST("/waypoint/cluster", routes.WaypointCluster) - se.Router.POST("/auth/token", func(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) - } + se.Router.POST("/trail-merge/suggest", routes.TrailMergeSuggest) + se.Router.POST("/trail-merge", routes.TrailMerge(client)) - hashedAPIToken := security.SHA256(data.APIToken) + se.Router.GET("/search/token", routes.SearchToken(client)) - tokenRecord, err := e.App.FindFirstRecordByFilter( - "api_tokens", - "token = {:hash}", - map[string]any{"hash": hashedAPIToken}, - ) + se.Router.POST("/integration/strava/token", routes.IntegrationStravaToken) + se.Router.POST("/integration/hammerhead/upload", routes.IntegrationHammerheadUpload) + se.Router.GET("/integration/hammerhead/login", routes.IntegrationHammerheadLogin) + se.Router.GET("/integration/komoot/login", routes.IntegrationKommotLogin) - 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) - } + se.Router.POST("/activitypub/activity/process", routes.ActivitypubActivityProcess) + se.Router.GET("/activitypub/actor", routes.ActivitypubActor) + se.Router.GET("/activitypub/actor/{id}/{follow}", routes.ActivitypubActorFollow) + se.Router.GET("/activitypub/trail/{id}", routes.ActivitypubTrail) + se.Router.GET("/activitypub/comment/{id}", routes.ActivitypubComment) - tokenRecord.Set("last_used", time.Now()) - if err := e.App.Save(tokenRecord); err != nil { - return err - } + se.Router.GET("/remote/trail/{id}", routes.RemoteTrailGet) + se.Router.GET("/remote/trail/{id}/comments", routes.RemoteTrailCommentsList) - 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, - }) - }) + se.Router.GET("/remote/list/{id}", routes.RemoteListGet) - se.Router.GET("/search/token", func(e *core.RequestEvent) error { - searchRules := map[string]interface{}{ - "lists": map[string]string{"filter": "public = true"}, - "trails": map[string]string{"filter": "public = true"}, - } + se.Router.GET("/remote/profile/{handle}/follows", routes.RemoteProfileFollowsList) - 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, - }) - }) - - se.Router.POST("/integration/strava/token", func(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 - } - - 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) - }) - - se.Router.POST("/integration/hammerhead/upload", func(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) - }) - - se.Router.GET("/integration/hammerhead/login", func(e *core.RequestEvent) error { - _, err := loginHammerhead(e) - if err != nil { - return err - } - - return e.JSON(http.StatusOK, nil) - }) - - se.Router.GET("/integration/komoot/login", func(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 - } - - 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) - }) - se.Router.POST("/activitypub/activity/process", federation.ProcessActivity) - se.Router.GET("/activitypub/actor", func(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 - } - } - - var actor *core.Record - if resource != "" { - actor, err = federation.GetActorByHandle(e.App, userActor, resource, follows) - } else { - actor, err = federation.GetActorByIRI(e.App, userActor, 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}) - }) - se.Router.GET("/activitypub/actor/{id}/{follow}", func(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 - } - } - - url := actor.GetString(followType) - - if url == "" { - return e.BadRequestError("unknown type: "+followType, nil) - } - collection, err := federation.FetchCollection(userActor, 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"}) - } - return err - } - return e.JSON(http.StatusOK, collection) - }) - se.Router.GET("/activitypub/trail/{id}", func(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) - }) - se.Router.GET("/activitypub/comment/{id}", func(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) - }) } func registerCronJobs(app core.App, client meilisearch.ServiceManager) { @@ -1427,58 +210,14 @@ func registerCronJobs(app core.App, client meilisearch.ServiceManager) { }) } -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 - } - - 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) -} - -func bootstrapData(app core.App, client meilisearch.ServiceManager) error { - bootstrapCategories(app) - bootstrapMeilisearchConfig(client) - go bootstrapMeilisearchDocuments(app, client) +func initData(app core.App, client meilisearch.ServiceManager) error { + initCategories(app) + initMeilisearchConfig(client) + go initMeilisearchDocuments(app, client) return nil } -func bootstrapCategories(app core.App) error { +func initCategories(app core.App) error { query := app.RecordQuery("categories") records := []*core.Record{} @@ -1507,7 +246,59 @@ func bootstrapCategories(app core.App) error { return nil } -func bootstrapMeilisearchDocuments(app core.App, client meilisearch.ServiceManager) error { +func initMeilisearchConfig(client meilisearch.ServiceManager) { + configs := map[string]meilisearch.Settings{ + "trails": { + SearchableAttributes: []string{"author_name", "name", "description", "location", "tags"}, + FilterableAttributes: []string{ + "_geo", "author", "category", "completed", "date", "difficulty", + "distance", "elevation_gain", "elevation_loss", "likes", "public", + "shares", "tags", + }, + SortableAttributes: []string{ + "author", "created", "date", "difficulty", "distance", + "duration", "elevation_gain", "elevation_loss", "like_count", "name", + }, + RankingRules: []string{"words", "typo", "proximity", "attribute", "sort", "exactness"}, + }, + "lists": { + SearchableAttributes: []string{"*"}, + FilterableAttributes: []string{"author", "public", "shares"}, + SortableAttributes: []string{"created", "name"}, + RankingRules: []string{"words", "typo", "proximity", "attribute", "sort", "exactness"}, + }, + } + + for indexName, settings := range configs { + _, err := client.GetIndex(indexName) + if err != nil { + log.Printf("Index [%s] not found, creating it...", indexName) + task, err := client.CreateIndex(&meilisearch.IndexConfig{ + Uid: indexName, + PrimaryKey: "id", + }) + if err != nil { + log.Printf("Failed to create index [%s]: %v", indexName, err) + continue + } + + _, err = client.WaitForTask(task.TaskUID, 0) + if err != nil { + log.Printf("Error waiting for index creation [%s]: %v", indexName, err) + continue + } + } + + _, err = client.Index(indexName).UpdateSettings(&settings) + if err != nil { + log.Printf("Failed to sync settings for index [%s]: %v", indexName, err) + } else { + log.Printf("Settings synced for index [%s]", indexName) + } + } +} + +func initMeilisearchDocuments(app core.App, client meilisearch.ServiceManager) error { // --- Trails --- const pageSize int64 = 100 var page int64 = 0 @@ -1567,55 +358,3 @@ func bootstrapMeilisearchDocuments(app core.App, client meilisearch.ServiceManag return nil } - -func bootstrapMeilisearchConfig(client meilisearch.ServiceManager) { - configs := map[string]meilisearch.Settings{ - "trails": { - SearchableAttributes: []string{"author_name", "name", "description", "location", "tags"}, - FilterableAttributes: []string{ - "_geo", "author", "category", "completed", "date", "difficulty", - "distance", "elevation_gain", "elevation_loss", "likes", "public", - "shares", "tags", - }, - SortableAttributes: []string{ - "author", "created", "date", "difficulty", "distance", - "duration", "elevation_gain", "elevation_loss", "like_count", "name", - }, - RankingRules: []string{"words", "typo", "proximity", "attribute", "sort", "exactness"}, - }, - "lists": { - SearchableAttributes: []string{"*"}, - FilterableAttributes: []string{"author", "public", "shares"}, - SortableAttributes: []string{"created", "name"}, - RankingRules: []string{"words", "typo", "proximity", "attribute", "sort", "exactness"}, - }, - } - - for indexName, settings := range configs { - _, err := client.GetIndex(indexName) - if err != nil { - log.Printf("Index [%s] not found, creating it...", indexName) - task, err := client.CreateIndex(&meilisearch.IndexConfig{ - Uid: indexName, - PrimaryKey: "id", - }) - if err != nil { - log.Printf("Failed to create index [%s]: %v", indexName, err) - continue - } - - _, err = client.WaitForTask(task.TaskUID, 0) - if err != nil { - log.Printf("Error waiting for index creation [%s]: %v", indexName, err) - continue - } - } - - _, err = client.Index(indexName).UpdateSettings(&settings) - if err != nil { - log.Printf("Failed to sync settings for index [%s]: %v", indexName, err) - } else { - log.Printf("Settings synced for index [%s]", indexName) - } - } -} diff --git a/db/migrations/1775994551_updated_waypoints.go b/db/migrations/1775994551_updated_waypoints.go new file mode 100644 index 00000000..f8ad91e2 --- /dev/null +++ b/db/migrations/1775994551_updated_waypoints.go @@ -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) + }) +} diff --git a/db/migrations/1775997520_updated_waypoints.go b/db/migrations/1775997520_updated_waypoints.go new file mode 100644 index 00000000..e81e15f7 --- /dev/null +++ b/db/migrations/1775997520_updated_waypoints.go @@ -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) + }) +} diff --git a/db/migrations/1775997935_set_waypoint_authors.go b/db/migrations/1775997935_set_waypoint_authors.go new file mode 100644 index 00000000..80c2f395 --- /dev/null +++ b/db/migrations/1775997935_set_waypoint_authors.go @@ -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 + }) +} diff --git a/db/migrations/1775997936_updated_waypoints.go b/db/migrations/1775997936_updated_waypoints.go new file mode 100644 index 00000000..ac1e9ee9 --- /dev/null +++ b/db/migrations/1775997936_updated_waypoints.go @@ -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) + }) +} diff --git a/db/migrations/1776170099_updated_trails.go b/db/migrations/1776170099_updated_trails.go new file mode 100644 index 00000000..cf8515c5 --- /dev/null +++ b/db/migrations/1776170099_updated_trails.go @@ -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) + }) +} diff --git a/db/migrations/1776243566_updated_trails.go b/db/migrations/1776243566_updated_trails.go new file mode 100644 index 00000000..4be6fd6b --- /dev/null +++ b/db/migrations/1776243566_updated_trails.go @@ -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) + }) +} diff --git a/db/migrations/1776243589_updated_lists.go b/db/migrations/1776243589_updated_lists.go new file mode 100644 index 00000000..94a3cb97 --- /dev/null +++ b/db/migrations/1776243589_updated_lists.go @@ -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) + }) +} diff --git a/db/migrations/1776675228_updated_activitypub_actors.go b/db/migrations/1776675228_updated_activitypub_actors.go new file mode 100644 index 00000000..5ce1cf94 --- /dev/null +++ b/db/migrations/1776675228_updated_activitypub_actors.go @@ -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) + }) +} diff --git a/db/migrations/1778145631_updated_lists.go b/db/migrations/1778145631_updated_lists.go new file mode 100644 index 00000000..7b3bc9cb --- /dev/null +++ b/db/migrations/1778145631_updated_lists.go @@ -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) + }) +} diff --git a/db/routes/activitypub.go b/db/routes/activitypub.go new file mode 100644 index 00000000..eb511db7 --- /dev/null +++ b/db/routes/activitypub.go @@ -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) +} diff --git a/db/routes/auth_token.go b/db/routes/auth_token.go new file mode 100644 index 00000000..4cd11995 --- /dev/null +++ b/db/routes/auth_token.go @@ -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, + }) +} diff --git a/db/routes/health.go b/db/routes/health.go new file mode 100644 index 00000000..0e025c62 --- /dev/null +++ b/db/routes/health.go @@ -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"}) +} diff --git a/db/routes/integration_hammerhead.go b/db/routes/integration_hammerhead.go new file mode 100644 index 00000000..f1b29287 --- /dev/null +++ b/db/routes/integration_hammerhead.go @@ -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) +} diff --git a/db/routes/integration_komoot.go b/db/routes/integration_komoot.go new file mode 100644 index 00000000..7fc4dc89 --- /dev/null +++ b/db/routes/integration_komoot.go @@ -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) +} diff --git a/db/routes/integration_strava.go b/db/routes/integration_strava.go new file mode 100644 index 00000000..c8771cb3 --- /dev/null +++ b/db/routes/integration_strava.go @@ -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) +} diff --git a/db/routes/remote_list.go b/db/routes/remote_list.go new file mode 100644 index 00000000..9af03599 --- /dev/null +++ b/db/routes/remote_list.go @@ -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 +} diff --git a/db/routes/remote_profile_follow.go b/db/routes/remote_profile_follow.go new file mode 100644 index 00000000..c4e8fb86 --- /dev/null +++ b/db/routes/remote_profile_follow.go @@ -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, + }) +} diff --git a/db/routes/remote_trail.go b/db/routes/remote_trail.go new file mode 100644 index 00000000..35ecc182 --- /dev/null +++ b/db/routes/remote_trail.go @@ -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) +} diff --git a/db/routes/remote_trail_comment.go b/db/routes/remote_trail_comment.go new file mode 100644 index 00000000..2fef156e --- /dev/null +++ b/db/routes/remote_trail_comment.go @@ -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) +} diff --git a/db/routes/search_token.go b/db/routes/search_token.go new file mode 100644 index 00000000..e5cd9709 --- /dev/null +++ b/db/routes/search_token.go @@ -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, + }) + } +} diff --git a/db/trail_merge_routes.go b/db/routes/trail_merge_routes.go similarity index 50% rename from db/trail_merge_routes.go rename to db/routes/trail_merge_routes.go index c231b44d..f945c25c 100644 --- a/db/trail_merge_routes.go +++ b/db/routes/trail_merge_routes.go @@ -1,13 +1,13 @@ -package main +package routes import ( "net/http" + "pocketbase/services/trailmerge" + "pocketbase/util" "github.com/meilisearch/meilisearch-go" "github.com/pocketbase/pocketbase/apis" "github.com/pocketbase/pocketbase/core" - - "pocketbase/trailmerge" ) type mergeExecuteRequest struct { @@ -16,45 +16,45 @@ type mergeExecuteRequest struct { Settings trailmerge.MergeSettings `json:"settings"` } -func registerTrailMergeRoutes(se *core.ServeEvent, client meilisearch.ServiceManager) { - se.Router.POST("/trail-merge/suggest", func(e *core.RequestEvent) error { - if e.Auth == nil { - return apis.NewUnauthorizedError("trail_merge_auth_required", nil) - } +func TrailMergeSuggest(e *core.RequestEvent) error { + if e.Auth == nil { + return apis.NewUnauthorizedError("trail_merge_auth_required", nil) + } - actor, err := e.App.FindFirstRecordByData("activitypub_actors", "user", e.Auth.Id) - if err != nil { - return apis.NewBadRequestError("trail_merge_actor_not_found", err) - } + userActor, err := e.App.FindFirstRecordByData("activitypub_actors", "user", e.Auth.Id) + if err != nil { + return apis.NewBadRequestError("trail_merge_actor_not_found", err) + } - var request trailmerge.SuggestRequest - if err := e.BindBody(&request); err != nil { - return apis.NewBadRequestError("trail_merge_invalid_request", err) - } + var request trailmerge.SuggestRequest + if err := e.BindBody(&request); err != nil { + return apis.NewBadRequestError("trail_merge_invalid_request", err) + } - if request.Mode == trailmerge.SuggestModeMaintenance { - response, err := trailmerge.SuggestGroups(e.App, actor.Id, request) - if err != nil { - return apis.NewBadRequestError(err.Error(), err) - } - - return e.JSON(http.StatusOK, response) - } - - response, err := trailmerge.Suggest(e.App, actor.Id, request) + if request.Mode == trailmerge.SuggestModeMaintenance { + response, err := trailmerge.SuggestGroups(e.App, userActor.Id, request) if err != nil { return apis.NewBadRequestError(err.Error(), err) } return e.JSON(http.StatusOK, response) - }) + } - se.Router.POST("/trail-merge", func(e *core.RequestEvent) error { + response, err := trailmerge.Suggest(e.App, userActor.Id, request) + if err != nil { + return apis.NewBadRequestError(err.Error(), err) + } + + return e.JSON(http.StatusOK, response) +} + +func TrailMerge(client meilisearch.ServiceManager) func(e *core.RequestEvent) error { + return func(e *core.RequestEvent) error { if e.Auth == nil { return apis.NewUnauthorizedError("trail_merge_auth_required", nil) } - actor, err := e.App.FindFirstRecordByData("activitypub_actors", "user", e.Auth.Id) + userActor, err := e.App.FindFirstRecordByData("activitypub_actors", "user", e.Auth.Id) if err != nil { return apis.NewBadRequestError("trail_merge_actor_not_found", err) } @@ -73,16 +73,18 @@ func registerTrailMergeRoutes(se *core.ServeEvent, client meilisearch.ServiceMan return apis.NewBadRequestError("trail_merge_target_not_found", err) } - if !trailmerge.CanMerge(e.App, actor.Id, source, target, request.Settings.Delete) { + if !trailmerge.CanMerge(e.App, userActor.Id, source, target, request.Settings.Delete) { return apis.NewForbiddenError("trail_merge_not_allowed", nil) } - if err := trailmerge.Merge(e.App, client, actor, request.SourceTrailID, request.TargetTrailID, request.Settings); err != nil { + ctx, err := util.GetSafeActorContext(e.Request, userActor) + + if err := trailmerge.Merge(e.App, client, ctx, userActor, request.SourceTrailID, request.TargetTrailID, request.Settings); err != nil { return apis.NewBadRequestError(err.Error(), err) } return e.JSON(http.StatusOK, map[string]any{ "acknowledged": true, }) - }) + } } diff --git a/db/waypointcluster/waypoint_cluster.go b/db/routes/waypoint_cluster.go similarity index 98% rename from db/waypointcluster/waypoint_cluster.go rename to db/routes/waypoint_cluster.go index 3513f613..8910ac5f 100644 --- a/db/waypointcluster/waypoint_cluster.go +++ b/db/routes/waypoint_cluster.go @@ -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) } diff --git a/db/trailmerge/integration_merge.go b/db/services/trailmerge/integration_merge.go similarity index 86% rename from db/trailmerge/integration_merge.go rename to db/services/trailmerge/integration_merge.go index 94d463e0..7754b0a5 100644 --- a/db/trailmerge/integration_merge.go +++ b/db/services/trailmerge/integration_merge.go @@ -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()) } diff --git a/db/trailmerge/service.go b/db/services/trailmerge/service.go similarity index 98% rename from db/trailmerge/service.go rename to db/services/trailmerge/service.go index 675c2402..4205b2c1 100644 --- a/db/trailmerge/service.go +++ b/db/services/trailmerge/service.go @@ -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 } } diff --git a/db/util/activitypub.go b/db/util/activitypub.go index d4b56bd0..a52cd1c1 100644 --- a/db/util/activitypub.go +++ b/db/util/activitypub.go @@ -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() +} diff --git a/db/util/network.go b/db/util/network.go new file mode 100644 index 00000000..4ef888e5 --- /dev/null +++ b/db/util/network.go @@ -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 +} diff --git a/db/util/sanitize.go b/db/util/sanitize.go new file mode 100644 index 00000000..7bbef08e --- /dev/null +++ b/db/util/sanitize.go @@ -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() + } +} diff --git a/web/package-lock.json b/web/package-lock.json index 5fd49609..404f42cb 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -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" diff --git a/web/src/lib/components/base/editor.svelte b/web/src/lib/components/base/editor.svelte index 9632a542..d95f4dad 100644 --- a/web/src/lib/components/base/editor.svelte +++ b/web/src/lib/components/base/editor.svelte @@ -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?.({ @@ -141,8 +143,9 @@ if (!box) { 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); @@ -315,7 +318,7 @@ }) .run(); - modal.closeModal(); + modal.closeModal(); } catch (e) { if ( e instanceof ZodError && diff --git a/web/src/lib/components/trail/map_with_elevation_maplibre.svelte b/web/src/lib/components/trail/map_with_elevation_maplibre.svelte index f28cc09e..8f3c8f68 100644 --- a/web/src/lib/components/trail/map_with_elevation_maplibre.svelte +++ b/web/src/lib/components/trail/map_with_elevation_maplibre.svelte @@ -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); } } diff --git a/web/src/lib/components/trail/trail_info_panel.svelte b/web/src/lib/components/trail/trail_info_panel.svelte index d7280321..2e203190 100644 --- a/web/src/lib/components/trail/trail_info_panel.svelte +++ b/web/src/lib/components/trail/trail_info_panel.svelte @@ -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", diff --git a/web/src/lib/models/activitypub/actor.ts b/web/src/lib/models/activitypub/actor.ts index 862b4b54..c3e52e6a 100644 --- a/web/src/lib/models/activitypub/actor.ts +++ b/web/src/lib/models/activitypub/actor.ts @@ -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; diff --git a/web/src/lib/models/list.ts b/web/src/lib/models/list.ts index 8913550c..f1f97069 100644 --- a/web/src/lib/models/list.ts +++ b/web/src/lib/models/list.ts @@ -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 }) { diff --git a/web/src/lib/stores/comment_store.ts b/web/src/lib/stores/comment_store.ts index 879f71ef..8be0b824 100644 --- a/web/src/lib/stores/comment_store.ts +++ b/web/src/lib/stores/comment_store.ts @@ -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 = 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', }) diff --git a/web/src/lib/stores/follow_store.ts b/web/src/lib/stores/follow_store.ts index 7b0cb8fc..f80959b0 100644 --- a/web/src/lib/stores/follow_store.ts +++ b/web/src/lib/stores/follow_store.ts @@ -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 = fetch) { +export async function follows_index(page: number = 1, perPage: number = 10, f: (url: RequestInfo | URL, config?: RequestInit) => Promise = fetch) { const r = await f(`/api/v1/follow?` + new URLSearchParams({ - handle: data.username, - type: data.type, page: page.toString(), perPage: perPage.toString(), }), { @@ -25,7 +23,7 @@ export async function follows_index(data: { username: string, type: "followers" const result = page > 1 ? [...follows, ...fetchedFollows.items] : fetchedFollows.items - follows = result; + follows = result; return { ...fetchedFollows, items: result }; } diff --git a/web/src/lib/stores/profile_store.ts b/web/src/lib/stores/profile_store.ts index 26322081..66da7b0f 100644 --- a/web/src/lib/stores/profile_store.ts +++ b/web/src/lib/stores/profile_store.ts @@ -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 = fetch) { let r = await f('/api/v1/profile/' + handle, { @@ -132,4 +133,26 @@ 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 = 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 = await r.json(); + + const result = page > 1 ? [...follows, ...fetchedFollows.items] : fetchedFollows.items + + follows = result; + + return { ...fetchedFollows, items: result }; } \ No newline at end of file diff --git a/web/src/lib/stores/waypoint_store.ts b/web/src/lib/stores/waypoint_store.ts index b30a46fa..8137b0e2 100644 --- a/web/src/lib/stores/waypoint_store.ts +++ b/web/src/lib/stores/waypoint_store.ts @@ -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', diff --git a/web/src/routes/api/v1/comment/+server.ts b/web/src/routes/api/v1/comment/+server.ts index fbf86cc8..37ba8b5a 100644 --- a/web/src/routes/api/v1/comment/+server.ts +++ b/web/src/routes/api/v1/comment/+server.ts @@ -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(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(event, Collection.comments); - if (actor.isLocal) { - return json(localComments) - } - - const deduplicationMap: Record = {} - - 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 = 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 = >{ - 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) - } + const comments = await list(event, Collection.comments); + return json(comments) } catch (e) { return handleError(e) } diff --git a/web/src/routes/api/v1/follow/+server.ts b/web/src/routes/api/v1/follow/+server.ts index 081fbaeb..acf3d3b2 100644 --- a/web/src/routes/api/v1/follow/+server.ts +++ b/web/src/routes/api/v1/follow/+server.ts @@ -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(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 = { - items: followerActors, - page: parseInt(page), - perPage: 10, - totalItems: actor.followerCount ?? 0, - totalPages: Math.ceil((actor.followerCount ?? 0) / 10) - } - return json(result) - } - - + const follows = await list(event, Collection.follows); + return json(follows) } catch (e) { return handleError(e) } diff --git a/web/src/routes/api/v1/list/[id]/+server.ts b/web/src/routes/api/v1/list/[id]/+server.ts index 6482c64a..911fbcc4 100644 --- a/web/src/routes/api/v1/list/[id]/+server.ts +++ b/web/src/routes/api/v1/list/[id]/+server.ts @@ -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(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}`; - }) - - 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) - } - - } + let list: List = await event.locals.pb.send(`/remote/list/${params.id}?` + url.searchParams, { + method: "GET", + fetch: event.fetch, + }) + return json(list) } catch (e: any) { - return handleError(e) + return handleError(e); } } diff --git a/web/src/routes/api/v1/profile/[handle]/+server.ts b/web/src/routes/api/v1/profile/[handle]/+server.ts index 7f5daea7..0c5483ef 100644 --- a/web/src/routes/api/v1/profile/[handle]/+server.ts +++ b/web/src/routes/api/v1/profile/[handle]/+server.ts @@ -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 } diff --git a/web/src/routes/api/v1/profile/[handle]/follows/+server.ts b/web/src/routes/api/v1/profile/[handle]/follows/+server.ts new file mode 100644 index 00000000..d46dd0cb --- /dev/null +++ b/web/src/routes/api/v1/profile/[handle]/follows/+server.ts @@ -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) + } +} \ No newline at end of file diff --git a/web/src/routes/api/v1/search/actor/+server.ts b/web/src/routes/api/v1/search/actor/+server.ts index 3a91600c..91d6aae3 100644 --- a/web/src/routes/api/v1/search/actor/+server.ts +++ b/web/src/routes/api/v1/search/actor/+server.ts @@ -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(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) diff --git a/web/src/routes/api/v1/summit-log/+server.ts b/web/src/routes/api/v1/summit-log/+server.ts index 637d909f..8d42ad55 100644 --- a/web/src/routes/api/v1/summit-log/+server.ts +++ b/web/src/routes/api/v1/summit-log/+server.ts @@ -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 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(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(event, Collection.summit_logs); - if (actor.isLocal) { - removeTimeFromDates(localSummitLogs.items) + const summitLogs = await list(event, Collection.summit_logs); + removeTimeFromDates(summitLogs.items) + return json(summitLogs) - return json(localSummitLogs) - } - - const deduplicationMap: Record = {} - - 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 = 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 = >{ - 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) } diff --git a/web/src/routes/api/v1/trail/[id]/+server.ts b/web/src/routes/api/v1/trail/[id]/+server.ts index a11e8699..6cd08584 100644 --- a/web/src/routes/api/v1/trail/[id]/+server.ts +++ b/web/src/routes/api/v1/trail/[id]/+server.ts @@ -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) { + const { url, params } = event; + try { - // try to get the trail simply via the - let t: Trail; - if (!event.url.searchParams.has("handle")) { - t = await show(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") + let trail: Trail = await event.locals.pb.send(`/remote/trail/${params.id}?` + url.searchParams, { + method: "GET", + fetch: event.fetch, + }) - 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}` - - try { - t = await event.locals.pb.collection("trails").getFirstListItem(`iri='${iri}'||id='${event.params.id}'`, { - ...safeSearchParams - }) - } catch (e) { - if (!(e instanceof ClientResponseError) || e.status != 404) { - throw e - } - t = { - iri: iri, - author: actor.id, - like_count: 0 - } as Trail - } - - - } - - if (t.iri) { - const origin = new URL(t.iri).origin - const actor = await event.locals.pb.collection("activitypub_actors").getOne(t.author) - - const localTrailId = t.id; - const localTrailIRI = t.iri; - const localLikeCount = t.like_count; - const localLikes = t.expand?.trail_like_via_trail; - - const response = await event.fetch((t.iri) + '?' + event.url.searchParams, { method: 'GET' }) - if (!response.ok) { - const errorResponse = await response.json() - console.error(errorResponse) - if (t.id) { - return json(t) - } else { - throw new ClientResponseError({ status: response.status, response: errorResponse }) - } - } - t = await response.json() - - // this came directly from the database of the remote instance - // we need to adjust some urls to get photos, gpx etc. - if (!t.iri) { - if (t.gpx) { - t.gpx = `${origin}/api/v1/files/trails/${t.id}/${t.gpx}` - } - t.photos = t.photos.map(p => - `${origin}/api/v1/files/trails/${t.id}/${p}` - ) - t.expand?.summit_logs_via_trail?.forEach(l => { - if (l.gpx) { - l.gpx = `${origin}/api/v1/files/summit_logs/${l.id}/${l.gpx}` - } - l.photos = l.photos.map(p => - `${origin}/api/v1/files/summit_logs/${l.id}/${p}` - ) - - if (l.expand?.author) { - l.expand.author.isLocal = false - } - }) - t.expand?.waypoints_via_trail?.forEach(w => { - - w.photos = w.photos.map(p => - `${origin}/api/v1/files/waypoints/${w.id}/${p}` - ) - }) - - t.author = actor.id! - t.expand!.author = actor as any - t.id = localTrailId - t.iri = localTrailIRI - t.like_count = localLikeCount - t.expand!.trail_like_via_trail = localLikes; - } - - let categoryId: string | undefined; - try { - const category = await event.locals.pb.collection("categories").getFirstListItem(`name='${t.expand?.category?.name}'`) - categoryId = category.id; - t.expand!.category = category as any - } catch (e) { } - - const formData = objectToFormData({ ...t, id: t.id, gpx: undefined, expand: undefined, photos: [], waypoints: [], tags: [], category: categoryId }) - if (t.photos.length) { - const photoURL = t.photos[t.thumbnail ?? 0] - let response = await event.fetch(photoURL, { method: "GET" }) - const photo = await response.blob() - formData.append("photos", photo) - } - if (t.gpx) { - const gpxURL = t.gpx - const response = await event.fetch(gpxURL, { method: "GET" }) - const gpx = await response.blob() - formData.append("gpx", gpx) - } - if (t.id) { - await event.locals.pb.collection("trails").update(t.id, formData) - } else { - const createdTrail = await event.locals.pb.collection("trails").create(formData) - t.id = createdTrail.id; - } - } - - // remove time from dates - await enrichRecord(event.locals.pb, t); - - // sort waypoints by distance - t.expand?.waypoints_via_trail?.sort((a, b) => (a.distance_from_start ?? 0) - (b.distance_from_start ?? 0)) - return json(t) + 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); } } diff --git a/web/src/routes/api/v1/trail/[id]/comment/+server.ts b/web/src/routes/api/v1/trail/[id]/comment/+server.ts new file mode 100644 index 00000000..5478816b --- /dev/null +++ b/web/src/routes/api/v1/trail/[id]/comment/+server.ts @@ -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) + } +} \ No newline at end of file diff --git a/web/src/routes/profile/[handle]/users/[type]/+page.svelte b/web/src/routes/profile/[handle]/users/[type]/+page.svelte index 1b483d9c..4b9e503f 100644 --- a/web/src/routes/profile/[handle]/users/[type]/+page.svelte +++ b/web/src/routes/profile/[handle]/users/[type]/+page.svelte @@ -1,6 +1,6 @@