diff --git a/db/integrations/komoot/komoot.go b/db/integrations/komoot/komoot.go
index 01656a7b..d5f05979 100644
--- a/db/integrations/komoot/komoot.go
+++ b/db/integrations/komoot/komoot.go
@@ -4,9 +4,11 @@ import (
"bytes"
"encoding/base64"
"encoding/json"
+ "errors"
"fmt"
"io"
"net/http"
+ "os"
"strconv"
"strings"
"time"
@@ -16,6 +18,7 @@ import (
"github.com/pocketbase/pocketbase/forms"
"github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/tools/filesystem"
+ "github.com/pocketbase/pocketbase/tools/security"
"github.com/twpayne/go-gpx"
)
@@ -26,6 +29,11 @@ func SyncKomoot(app *pocketbase.PocketBase) error {
}
for _, i := range integrations {
+ encryptionKey := os.Getenv("POCKETBASE_ENCRYPTION_KEY")
+ if len(encryptionKey) == 0 {
+ return errors.New("POCKETBASE_ENCRYPTION_KEY not set")
+ }
+
userId := i.GetString("user")
komootString := i.GetString("komoot")
var komootIntegration KomootIntegration
@@ -35,7 +43,13 @@ func SyncKomoot(app *pocketbase.PocketBase) error {
continue
}
k := &KomootApi{}
- err = k.login(komootIntegration.Email, komootIntegration.Password)
+
+ decryptedPassword, err := security.Decrypt(komootIntegration.Password, encryptionKey)
+ if err != nil {
+ return err
+ }
+
+ err = k.Login(komootIntegration.Email, string(decryptedPassword))
if err != nil {
warning := fmt.Sprintf("komoot login failed: %v\n", err)
fmt.Print(warning)
@@ -114,7 +128,7 @@ func sendRequest(url string, auth *BasicAuthToken) ([]byte, error) {
return io.ReadAll(resp.Body)
}
-func (k *KomootApi) login(email, password string) error {
+func (k *KomootApi) Login(email, password string) error {
url := fmt.Sprintf("https://api.komoot.de/v006/account/email/%s/", email)
body, err := sendRequest(url, &BasicAuthToken{email, password})
@@ -222,6 +236,11 @@ func createTrailFromTour(app *pocketbase.PocketBase, detailedTour *DetailedKomoo
return err
}
+ diffculty := detailedTour.Difficulty.Grade
+ if diffculty == "" {
+ diffculty = "easy"
+ }
+
form.LoadData(map[string]any{
"name": detailedTour.Name,
"public": detailedTour.Status == "public",
@@ -234,7 +253,7 @@ func createTrailFromTour(app *pocketbase.PocketBase, detailedTour *DetailedKomoo
"external_id": strconv.Itoa(detailedTour.ID),
"lat": detailedTour.StartPoint.Lat,
"lon": detailedTour.StartPoint.Lng,
- "difficulty": detailedTour.Difficulty.Grade,
+ "difficulty": diffculty,
"category": categoryId,
"waypoints": wpIds,
"author": user,
diff --git a/db/integrations/strava/models.go b/db/integrations/strava/models.go
index df13f3b8..9c18b113 100644
--- a/db/integrations/strava/models.go
+++ b/db/integrations/strava/models.go
@@ -2,6 +2,13 @@ package strava
import "time"
+type TokenRequest struct {
+ ClientID int32 `json:"client_id"`
+ ClientSecret string `json:"client_secret"`
+ Code string `json:"code"`
+ GrantType string `json:"grant_type"`
+}
+
type RefreshTokenRequest struct {
ClientID int32 `json:"client_id"`
ClientSecret string `json:"client_secret"`
diff --git a/db/integrations/strava/strava.go b/db/integrations/strava/strava.go
index a5e17941..82dd848f 100644
--- a/db/integrations/strava/strava.go
+++ b/db/integrations/strava/strava.go
@@ -3,9 +3,11 @@ package strava
import (
"bytes"
"encoding/json"
+ "errors"
"fmt"
"io"
"net/http"
+ "os"
"strconv"
"time"
@@ -14,6 +16,7 @@ import (
"github.com/pocketbase/pocketbase/forms"
"github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/tools/filesystem"
+ "github.com/pocketbase/pocketbase/tools/security"
"github.com/twpayne/go-gpx"
"github.com/twpayne/go-polyline"
)
@@ -29,16 +32,35 @@ func SyncStrava(app *pocketbase.PocketBase) error {
}
for _, i := range integrations {
+ encryptionKey := os.Getenv("POCKETBASE_ENCRYPTION_KEY")
+ if len(encryptionKey) == 0 {
+ return errors.New("POCKETBASE_ENCRYPTION_KEY not set")
+ }
+
userId := i.GetString("user")
stravaString := i.GetString("strava")
var stravaIntegration StravaIntegration
- json.Unmarshal([]byte(stravaString), &stravaIntegration)
+ err := json.Unmarshal([]byte(stravaString), &stravaIntegration)
+ if err != nil {
+ return err
+ }
if !stravaIntegration.Active || stravaIntegration.RefreshToken == "" {
continue
}
- r, err := refreshStravaToken(stravaIntegration.ClientID, stravaIntegration.ClientSecret, stravaIntegration.RefreshToken)
+ decryptedSecret, err := security.Decrypt(stravaIntegration.ClientSecret, encryptionKey)
+ if err != nil {
+ return err
+ }
+
+ request := RefreshTokenRequest{
+ ClientID: stravaIntegration.ClientID,
+ ClientSecret: string(decryptedSecret),
+ RefreshToken: stravaIntegration.RefreshToken,
+ GrantType: "refresh_token",
+ }
+ r, err := GetStravaToken(request)
if err != nil {
warning := fmt.Sprintf("error refreshing strava access token: %v\n", err)
fmt.Print(warning)
@@ -60,7 +82,10 @@ func SyncStrava(app *pocketbase.PocketBase) error {
return err
}
i.Set("strava", string(b))
- app.Dao().SaveRecord(i)
+ err = app.Dao().SaveRecord(i)
+ if err != nil {
+ return err
+ }
if stravaIntegration.Routes {
page := 1
@@ -110,15 +135,10 @@ func SyncStrava(app *pocketbase.PocketBase) error {
return nil
}
-func refreshStravaToken(clientID int32, clientSecret, refreshToken string) (*RefreshTokenResponse, error) {
+func GetStravaToken(request any) (*RefreshTokenResponse, error) {
const stravaTokenURL = "https://www.strava.com/oauth/token"
- requestBody, err := json.Marshal(RefreshTokenRequest{
- ClientID: clientID,
- ClientSecret: clientSecret,
- RefreshToken: refreshToken,
- GrantType: "refresh_token",
- })
+ requestBody, err := json.Marshal(request)
if err != nil {
return nil, err
}
@@ -137,7 +157,7 @@ func refreshStravaToken(clientID int32, clientSecret, refreshToken string) (*Ref
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
- return nil, fmt.Errorf("failed to refresh token: received status %d", resp.StatusCode)
+ return nil, fmt.Errorf("failed to get token: received status %d", resp.StatusCode)
}
var tokenResponse RefreshTokenResponse
diff --git a/db/main.go b/db/main.go
index 2a01e9ef..2b42f05c 100644
--- a/db/main.go
+++ b/db/main.go
@@ -1,6 +1,7 @@
package main
import (
+ "encoding/json"
"fmt"
"log"
"math/rand/v2"
@@ -21,6 +22,7 @@ import (
"github.com/pocketbase/pocketbase/tools/cron"
"github.com/pocketbase/pocketbase/tools/filesystem"
"github.com/pocketbase/pocketbase/tools/hook"
+ "github.com/pocketbase/pocketbase/tools/security"
"pocketbase/integrations/komoot"
"pocketbase/integrations/strava"
@@ -76,6 +78,12 @@ func setupEventHandlers(app *pocketbase.PocketBase, client meilisearch.ServiceMa
app.OnRecordAfterCreateRequest("follows").Add(createFollowHandler(app))
app.OnRecordAfterCreateRequest("comments").Add(createCommentHandler(app))
+ app.OnRecordsListRequest("integrations").Add(listIntegrationHandler())
+ app.OnRecordAfterCreateRequest("integrations").Add(createIntegrationAfterHandler())
+ app.OnRecordAfterUpdateRequest("integrations").Add(updateIntegrationAfterHandler())
+ app.OnRecordBeforeCreateRequest("integrations").Add(createIntegrationBeforeHandler(app))
+ app.OnRecordBeforeUpdateRequest("integrations").Add(updateIntegrationBeforeHandler(app))
+
app.OnRecordBeforeRequestEmailChangeRequest("users").Add(changeUserEmailHandler(app))
app.OnBeforeServe().Add(onBeforeServeHandler(app, client))
}
@@ -330,6 +338,139 @@ func createCommentHandler(app *pocketbase.PocketBase) func(e *core.RecordCreateE
}
}
+func listIntegrationHandler() func(e *core.RecordsListEvent) error {
+ return func(e *core.RecordsListEvent) error {
+ info := apis.RequestInfo(e.HttpContext)
+ if info.Admin != nil {
+ return nil
+ }
+ for _, r := range e.Records {
+
+ err := censorIntegrationSecrets(r)
+ if err != nil {
+ return err
+ }
+ }
+
+ return nil
+ }
+}
+
+func createIntegrationBeforeHandler(app *pocketbase.PocketBase) func(e *core.RecordCreateEvent) error {
+ return func(e *core.RecordCreateEvent) error {
+ err := encryptIntegrationSecrets(app, e.Record)
+ if err != nil {
+ return err
+ }
+ return nil
+ }
+}
+
+func createIntegrationAfterHandler() func(e *core.RecordCreateEvent) error {
+ return func(e *core.RecordCreateEvent) error {
+ err := censorIntegrationSecrets(e.Record)
+ if err != nil {
+ return err
+ }
+ return nil
+ }
+}
+
+func updateIntegrationBeforeHandler(app *pocketbase.PocketBase) func(e *core.RecordUpdateEvent) error {
+ return func(e *core.RecordUpdateEvent) error {
+ err := encryptIntegrationSecrets(app, e.Record)
+ if err != nil {
+ return err
+ }
+
+ return nil
+ }
+}
+
+func updateIntegrationAfterHandler() func(e *core.RecordUpdateEvent) error {
+ return func(e *core.RecordUpdateEvent) error {
+ err := censorIntegrationSecrets(e.Record)
+ if err != nil {
+ return err
+ }
+ return nil
+ }
+}
+
+func censorIntegrationSecrets(r *models.Record) error {
+ secrets := map[string][]string{
+ "strava": {"clientSecret", "refreshToken", "accessToken", "expiresAt"},
+ "komoot": {"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
+ }
+ 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 *pocketbase.PocketBase, r *models.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"},
+ }
+
+ original, _ := app.Dao().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 secret, ok := integration[secretKey].(string); ok && len(secret) > 0 {
+ 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
+ }
+ integration[secretKey] = originalIntegration[secretKey]
+ }
+ }
+
+ b, err := json.Marshal(integration)
+ if err != nil {
+ return err
+ }
+ r.Set(key, string(b))
+ }
+ }
+
+ return nil
+}
+
func changeUserEmailHandler(app *pocketbase.PocketBase) func(e *core.RecordRequestEmailChangeEvent) error {
return func(e *core.RecordRequestEmailChangeEvent) error {
form := forms.NewRecordEmailChangeRequest(app, e.Record)
@@ -402,22 +543,144 @@ func registerRoutes(e *core.ServeEvent, app *pocketbase.PocketBase, client meili
return c.JSON(http.StatusOK, randomTrails)
})
+
+ e.Router.POST("/integration/strava/token", func(c echo.Context) 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 := c.Bind(&data); err != nil {
+ return apis.NewBadRequestError("Failed to read request data", err)
+ }
+
+ user, success := c.Get(apis.ContextAuthRecordKey).(*models.Record)
+ userId := ""
+ if success {
+ userId = user.Id
+ }
+
+ integrations, err := app.Dao().FindRecordsByExpr("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 = app.Dao().SaveRecord(integration)
+ if err != nil {
+ return err
+ }
+ return c.JSON(http.StatusOK, nil)
+ })
+
+ e.Router.GET("/integration/komoot/login", func(c echo.Context) error {
+ encryptionKey := os.Getenv("POCKETBASE_ENCRYPTION_KEY")
+ if len(encryptionKey) == 0 {
+ return apis.NewBadRequestError("POCKETBASE_ENCRYPTION_KEY not set", nil)
+ }
+
+ user, success := c.Get(apis.ContextAuthRecordKey).(*models.Record)
+ userId := ""
+ if success {
+ userId = user.Id
+ }
+
+ integrations, err := app.Dao().FindRecordsByExpr("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 c.JSON(http.StatusOK, nil)
+ })
}
func registerCronJobs(app *pocketbase.PocketBase) {
scheduler := cron.New()
- scheduler.MustAdd("integrations", "*/5 * * * *", func() {
+ schedule := os.Getenv("POCKETBASE_CRON_SYNC_SCHEDULE")
+ if len(schedule) == 0 {
+ schedule = "0 2 * * *"
+ }
+
+ scheduler.MustAdd("integrations", schedule, func() {
err := strava.SyncStrava(app)
if err != nil {
warning := fmt.Sprintf("Error syncing with strava: %v", err)
- fmt.Print(warning)
+ fmt.Println(warning)
app.Logger().Error(warning)
}
err = komoot.SyncKomoot(app)
if err != nil {
warning := fmt.Sprintf("Error syncing with komoot: %v", err)
- fmt.Print(warning)
+ fmt.Println(warning)
app.Logger().Error(warning)
}
})
diff --git a/web/src/lib/components/settings/integrations/komoot_settings_modal.svelte b/web/src/lib/components/settings/integrations/komoot_settings_modal.svelte
index 8bcb7b4a..6459174c 100644
--- a/web/src/lib/components/settings/integrations/komoot_settings_modal.svelte
+++ b/web/src/lib/components/settings/integrations/komoot_settings_modal.svelte
@@ -60,6 +60,7 @@
>
diff --git a/web/src/routes/settings/integrations/callback/strava/+page.server.ts b/web/src/routes/settings/integrations/callback/strava/+page.server.ts
index 9387ccb0..ac241b16 100644
--- a/web/src/routes/settings/integrations/callback/strava/+page.server.ts
+++ b/web/src/routes/settings/integrations/callback/strava/+page.server.ts
@@ -1,11 +1,13 @@
+import { pb } from "$lib/pocketbase";
import { integrations_index, integrations_update } from "$lib/stores/integration_store";
import { error, redirect, type RequestEvent, type ServerLoad } from "@sveltejs/kit";
+import { ClientResponseError } from "pocketbase";
export const load: ServerLoad = async ({ url, fetch }) => {
const oauthError = url.searchParams.get('error');
if (oauthError) {
// user cancelled
- if(oauthError == "access_denied") {
+ if (oauthError == "access_denied") {
return redirect(302, '/settings/integrations')
}
return error(400, {
@@ -19,41 +21,23 @@ export const load: ServerLoad = async ({ url, fetch }) => {
});
}
- const integrations = await integrations_index(fetch);
-
- if (!integrations.length || !integrations[0].strava) {
- return error(400, {
- message: "Missing integration record"
+ try {
+ await pb.send("/integration/strava/token", {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ code,
+ grant_type: 'authorization_code'
+ })
});
+ } catch (e) {
+ console.error(e)
+
+ if (e instanceof ClientResponseError) {
+ return error(e.status, e.message);
+
+ }
+ throw e
}
-
- const integration = integrations[0]
-
- const tokenResponse = await fetch('https://www.strava.com/oauth/token', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({
- client_id: integration.strava?.clientId,
- client_secret: integration.strava?.clientSecret,
- code,
- grant_type: 'authorization_code'
- })
- });
-
- if (!tokenResponse.ok) {
- const r = await tokenResponse.json()
- console.error(r)
- return error(500, 'Failed to get access token');
- }
-
- const { access_token, refresh_token, expires_at } = await tokenResponse.json();
-
- integration.strava!.accessToken = access_token
- integration.strava!.refreshToken = refresh_token
- integration.strava!.expiresAt = expires_at
- integration.strava!.active = true
-
- await integrations_update(integration, fetch);
-
return redirect(302, '/settings/integrations')
}
\ No newline at end of file