encrypts integration secrets

This commit is contained in:
Christian Beutel
2025-02-08 11:48:21 +01:00
parent d01ca30932
commit d790f06216
20 changed files with 405 additions and 72 deletions

View File

@@ -4,9 +4,11 @@ import (
"bytes" "bytes"
"encoding/base64" "encoding/base64"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
"os"
"strconv" "strconv"
"strings" "strings"
"time" "time"
@@ -16,6 +18,7 @@ import (
"github.com/pocketbase/pocketbase/forms" "github.com/pocketbase/pocketbase/forms"
"github.com/pocketbase/pocketbase/models" "github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/tools/filesystem" "github.com/pocketbase/pocketbase/tools/filesystem"
"github.com/pocketbase/pocketbase/tools/security"
"github.com/twpayne/go-gpx" "github.com/twpayne/go-gpx"
) )
@@ -26,6 +29,11 @@ func SyncKomoot(app *pocketbase.PocketBase) error {
} }
for _, i := range integrations { 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") userId := i.GetString("user")
komootString := i.GetString("komoot") komootString := i.GetString("komoot")
var komootIntegration KomootIntegration var komootIntegration KomootIntegration
@@ -35,7 +43,13 @@ func SyncKomoot(app *pocketbase.PocketBase) error {
continue continue
} }
k := &KomootApi{} 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 { if err != nil {
warning := fmt.Sprintf("komoot login failed: %v\n", err) warning := fmt.Sprintf("komoot login failed: %v\n", err)
fmt.Print(warning) fmt.Print(warning)
@@ -114,7 +128,7 @@ func sendRequest(url string, auth *BasicAuthToken) ([]byte, error) {
return io.ReadAll(resp.Body) 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) url := fmt.Sprintf("https://api.komoot.de/v006/account/email/%s/", email)
body, err := sendRequest(url, &BasicAuthToken{email, password}) body, err := sendRequest(url, &BasicAuthToken{email, password})
@@ -222,6 +236,11 @@ func createTrailFromTour(app *pocketbase.PocketBase, detailedTour *DetailedKomoo
return err return err
} }
diffculty := detailedTour.Difficulty.Grade
if diffculty == "" {
diffculty = "easy"
}
form.LoadData(map[string]any{ form.LoadData(map[string]any{
"name": detailedTour.Name, "name": detailedTour.Name,
"public": detailedTour.Status == "public", "public": detailedTour.Status == "public",
@@ -234,7 +253,7 @@ func createTrailFromTour(app *pocketbase.PocketBase, detailedTour *DetailedKomoo
"external_id": strconv.Itoa(detailedTour.ID), "external_id": strconv.Itoa(detailedTour.ID),
"lat": detailedTour.StartPoint.Lat, "lat": detailedTour.StartPoint.Lat,
"lon": detailedTour.StartPoint.Lng, "lon": detailedTour.StartPoint.Lng,
"difficulty": detailedTour.Difficulty.Grade, "difficulty": diffculty,
"category": categoryId, "category": categoryId,
"waypoints": wpIds, "waypoints": wpIds,
"author": user, "author": user,

View File

@@ -2,6 +2,13 @@ package strava
import "time" 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 { type RefreshTokenRequest struct {
ClientID int32 `json:"client_id"` ClientID int32 `json:"client_id"`
ClientSecret string `json:"client_secret"` ClientSecret string `json:"client_secret"`

View File

@@ -3,9 +3,11 @@ package strava
import ( import (
"bytes" "bytes"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
"os"
"strconv" "strconv"
"time" "time"
@@ -14,6 +16,7 @@ import (
"github.com/pocketbase/pocketbase/forms" "github.com/pocketbase/pocketbase/forms"
"github.com/pocketbase/pocketbase/models" "github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/tools/filesystem" "github.com/pocketbase/pocketbase/tools/filesystem"
"github.com/pocketbase/pocketbase/tools/security"
"github.com/twpayne/go-gpx" "github.com/twpayne/go-gpx"
"github.com/twpayne/go-polyline" "github.com/twpayne/go-polyline"
) )
@@ -29,16 +32,35 @@ func SyncStrava(app *pocketbase.PocketBase) error {
} }
for _, i := range integrations { 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") userId := i.GetString("user")
stravaString := i.GetString("strava") stravaString := i.GetString("strava")
var stravaIntegration StravaIntegration var stravaIntegration StravaIntegration
json.Unmarshal([]byte(stravaString), &stravaIntegration) err := json.Unmarshal([]byte(stravaString), &stravaIntegration)
if err != nil {
return err
}
if !stravaIntegration.Active || stravaIntegration.RefreshToken == "" { if !stravaIntegration.Active || stravaIntegration.RefreshToken == "" {
continue 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 { if err != nil {
warning := fmt.Sprintf("error refreshing strava access token: %v\n", err) warning := fmt.Sprintf("error refreshing strava access token: %v\n", err)
fmt.Print(warning) fmt.Print(warning)
@@ -60,7 +82,10 @@ func SyncStrava(app *pocketbase.PocketBase) error {
return err return err
} }
i.Set("strava", string(b)) i.Set("strava", string(b))
app.Dao().SaveRecord(i) err = app.Dao().SaveRecord(i)
if err != nil {
return err
}
if stravaIntegration.Routes { if stravaIntegration.Routes {
page := 1 page := 1
@@ -110,15 +135,10 @@ func SyncStrava(app *pocketbase.PocketBase) error {
return nil 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" const stravaTokenURL = "https://www.strava.com/oauth/token"
requestBody, err := json.Marshal(RefreshTokenRequest{ requestBody, err := json.Marshal(request)
ClientID: clientID,
ClientSecret: clientSecret,
RefreshToken: refreshToken,
GrantType: "refresh_token",
})
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -137,7 +157,7 @@ func refreshStravaToken(clientID int32, clientSecret, refreshToken string) (*Ref
defer resp.Body.Close() defer resp.Body.Close()
if resp.StatusCode != http.StatusOK { 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 var tokenResponse RefreshTokenResponse

View File

@@ -1,6 +1,7 @@
package main package main
import ( import (
"encoding/json"
"fmt" "fmt"
"log" "log"
"math/rand/v2" "math/rand/v2"
@@ -21,6 +22,7 @@ import (
"github.com/pocketbase/pocketbase/tools/cron" "github.com/pocketbase/pocketbase/tools/cron"
"github.com/pocketbase/pocketbase/tools/filesystem" "github.com/pocketbase/pocketbase/tools/filesystem"
"github.com/pocketbase/pocketbase/tools/hook" "github.com/pocketbase/pocketbase/tools/hook"
"github.com/pocketbase/pocketbase/tools/security"
"pocketbase/integrations/komoot" "pocketbase/integrations/komoot"
"pocketbase/integrations/strava" "pocketbase/integrations/strava"
@@ -76,6 +78,12 @@ func setupEventHandlers(app *pocketbase.PocketBase, client meilisearch.ServiceMa
app.OnRecordAfterCreateRequest("follows").Add(createFollowHandler(app)) app.OnRecordAfterCreateRequest("follows").Add(createFollowHandler(app))
app.OnRecordAfterCreateRequest("comments").Add(createCommentHandler(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.OnRecordBeforeRequestEmailChangeRequest("users").Add(changeUserEmailHandler(app))
app.OnBeforeServe().Add(onBeforeServeHandler(app, client)) 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 { func changeUserEmailHandler(app *pocketbase.PocketBase) func(e *core.RecordRequestEmailChangeEvent) error {
return func(e *core.RecordRequestEmailChangeEvent) error { return func(e *core.RecordRequestEmailChangeEvent) error {
form := forms.NewRecordEmailChangeRequest(app, e.Record) 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) 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) { func registerCronJobs(app *pocketbase.PocketBase) {
scheduler := cron.New() 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) err := strava.SyncStrava(app)
if err != nil { if err != nil {
warning := fmt.Sprintf("Error syncing with strava: %v", err) warning := fmt.Sprintf("Error syncing with strava: %v", err)
fmt.Print(warning) fmt.Println(warning)
app.Logger().Error(warning) app.Logger().Error(warning)
} }
err = komoot.SyncKomoot(app) err = komoot.SyncKomoot(app)
if err != nil { if err != nil {
warning := fmt.Sprintf("Error syncing with komoot: %v", err) warning := fmt.Sprintf("Error syncing with komoot: %v", err)
fmt.Print(warning) fmt.Println(warning)
app.Logger().Error(warning) app.Logger().Error(warning)
} }
}) })

View File

@@ -60,6 +60,7 @@
></TextField> ></TextField>
<TextField <TextField
label={$_("password")} label={$_("password")}
placeholder={integration?.komoot ? `(${$_("unchanged")})` : ""}
name="password" name="password"
type="password" type="password"
error={$errors.password} error={$errors.password}

View File

@@ -21,14 +21,11 @@
let modal: Modal; let modal: Modal;
export function openModal() { export function openModal() {
errors.set({}) errors.set({});
modal.openModal(); modal.openModal();
} }
const { const { form, errors } = createForm({
form,
errors,
} = createForm({
initialValues: { initialValues: {
clientId: integration?.strava?.clientId ?? "", clientId: integration?.strava?.clientId ?? "",
clientSecret: integration?.strava?.clientSecret ?? "", clientSecret: integration?.strava?.clientSecret ?? "",
@@ -62,7 +59,7 @@
></TextField> ></TextField>
<TextField <TextField
label="Client Secret" label="Client Secret"
placeholder="de8b3789bd7116d..." placeholder={integration?.strava ? `(${$_("unchanged")})` : "de8b3789bd7116d..."}
name="clientSecret" name="clientSecret"
type="password" type="password"
error={$errors.clientSecret} error={$errors.clientSecret}

View File

@@ -147,7 +147,9 @@
"include-description": "Beschreibung übernehmen", "include-description": "Beschreibung übernehmen",
"integration-description-komoot": "", "integration-description-komoot": "",
"integration-description-strava": "", "integration-description-strava": "",
"integration-disabled": "",
"integrations": "", "integrations": "",
"integration-enabled": "",
"invalid-date": "Ungültiges Datum", "invalid-date": "Ungültiges Datum",
"invalid-username": "Ungültiger Nutzername", "invalid-username": "Ungültiger Nutzername",
"italian": "Italienisch", "italian": "Italienisch",
@@ -289,6 +291,7 @@
"trail-not-shared": "Route mit niemandem geteilt", "trail-not-shared": "Route mit niemandem geteilt",
"trail-saved-successfully": "Route gespeichert", "trail-saved-successfully": "Route gespeichert",
"trails-for-you": "Routen für dich", "trails-for-you": "Routen für dich",
"unchanged": "",
"units": "Einheiten", "units": "Einheiten",
"upload-file": "Datei hochladen", "upload-file": "Datei hochladen",
"upload-gpx": "GPX hochladen", "upload-gpx": "GPX hochladen",

View File

@@ -147,7 +147,9 @@
"include-description": "Include description", "include-description": "Include description",
"integration-description-komoot": "Syncs your komoot tours with wanderer in regular intervals.", "integration-description-komoot": "Syncs your komoot tours with wanderer in regular intervals.",
"integration-description-strava": "Syncs your strava routes & activities with wanderer in regular intervals.", "integration-description-strava": "Syncs your strava routes & activities with wanderer in regular intervals.",
"integration-disabled": "integration disabled",
"integrations": "Integrations", "integrations": "Integrations",
"integration-enabled": "integration enabled",
"invalid-date": "Invalid Date", "invalid-date": "Invalid Date",
"invalid-username": "Invalid username", "invalid-username": "Invalid username",
"italian": "Italian", "italian": "Italian",
@@ -289,6 +291,7 @@
"trail-not-shared": "Not shared with anyone", "trail-not-shared": "Not shared with anyone",
"trail-saved-successfully": "Trail saved successfully", "trail-saved-successfully": "Trail saved successfully",
"trails-for-you": "Trails for you", "trails-for-you": "Trails for you",
"unchanged": "unchanged",
"units": "Units", "units": "Units",
"upload-file": "Upload file", "upload-file": "Upload file",
"upload-gpx": "Upload GPX", "upload-gpx": "Upload GPX",

View File

@@ -147,7 +147,9 @@
"include-description": "Incluir descripción", "include-description": "Incluir descripción",
"integration-description-komoot": "", "integration-description-komoot": "",
"integration-description-strava": "", "integration-description-strava": "",
"integration-disabled": "",
"integrations": "", "integrations": "",
"integration-enabled": "",
"invalid-date": "Fecha no válida", "invalid-date": "Fecha no válida",
"invalid-username": "Usuario no válido", "invalid-username": "Usuario no válido",
"italian": "Italiano", "italian": "Italiano",
@@ -289,6 +291,7 @@
"trail-not-shared": "No compartida con nadie", "trail-not-shared": "No compartida con nadie",
"trail-saved-successfully": "Ruta guardada con éxito", "trail-saved-successfully": "Ruta guardada con éxito",
"trails-for-you": "Rutas para ti", "trails-for-you": "Rutas para ti",
"unchanged": "",
"units": "Unidades", "units": "Unidades",
"upload-file": "Cargar archivo", "upload-file": "Cargar archivo",
"upload-gpx": "Cargar GPX", "upload-gpx": "Cargar GPX",

View File

@@ -147,7 +147,9 @@
"include-description": "Inclure la description", "include-description": "Inclure la description",
"integration-description-komoot": "", "integration-description-komoot": "",
"integration-description-strava": "", "integration-description-strava": "",
"integration-disabled": "",
"integrations": "", "integrations": "",
"integration-enabled": "",
"invalid-date": "Date invalide", "invalid-date": "Date invalide",
"invalid-username": "Nom d'utilisateur invalide", "invalid-username": "Nom d'utilisateur invalide",
"italian": "Italien", "italian": "Italien",
@@ -289,6 +291,7 @@
"trail-not-shared": "L'itinéraire n'a pas été partagé", "trail-not-shared": "L'itinéraire n'a pas été partagé",
"trail-saved-successfully": "Itinéraire enregistrée", "trail-saved-successfully": "Itinéraire enregistrée",
"trails-for-you": "Itinéraire pour vous", "trails-for-you": "Itinéraire pour vous",
"unchanged": "",
"units": "Unités", "units": "Unités",
"upload-file": "Importer un fichier", "upload-file": "Importer un fichier",
"upload-gpx": "Envoyer un GPX", "upload-gpx": "Envoyer un GPX",

View File

@@ -147,7 +147,9 @@
"include-description": "Include description", "include-description": "Include description",
"integration-description-komoot": "", "integration-description-komoot": "",
"integration-description-strava": "", "integration-description-strava": "",
"integration-disabled": "",
"integrations": "", "integrations": "",
"integration-enabled": "",
"invalid-date": "Érvénytelen dátum", "invalid-date": "Érvénytelen dátum",
"invalid-username": "Érvénytelen felhasználó", "invalid-username": "Érvénytelen felhasználó",
"italian": "Olasz", "italian": "Olasz",
@@ -289,6 +291,7 @@
"trail-not-shared": "Not shared with anyone", "trail-not-shared": "Not shared with anyone",
"trail-saved-successfully": "Trail saved successfully", "trail-saved-successfully": "Trail saved successfully",
"trails-for-you": "Útvonalak önnek", "trails-for-you": "Útvonalak önnek",
"unchanged": "",
"units": "Mértékegységek", "units": "Mértékegységek",
"upload-file": "Fájl feltöltése", "upload-file": "Fájl feltöltése",
"upload-gpx": "GPX feltöltése", "upload-gpx": "GPX feltöltése",

View File

@@ -147,7 +147,9 @@
"include-description": "Adotta descrizione", "include-description": "Adotta descrizione",
"integration-description-komoot": "", "integration-description-komoot": "",
"integration-description-strava": "", "integration-description-strava": "",
"integration-disabled": "",
"integrations": "", "integrations": "",
"integration-enabled": "",
"invalid-date": "Data non valida", "invalid-date": "Data non valida",
"invalid-username": "Nome utente non valido", "invalid-username": "Nome utente non valido",
"italian": "Italiano", "italian": "Italiano",
@@ -289,6 +291,7 @@
"trail-not-shared": "Percorso non condiviso con nessuno", "trail-not-shared": "Percorso non condiviso con nessuno",
"trail-saved-successfully": "Percorso salvato con successo", "trail-saved-successfully": "Percorso salvato con successo",
"trails-for-you": "Percorsi per te", "trails-for-you": "Percorsi per te",
"unchanged": "",
"units": "Unità", "units": "Unità",
"upload-file": "Carica file", "upload-file": "Carica file",
"upload-gpx": "Carica file GPX", "upload-gpx": "Carica file GPX",

View File

@@ -147,7 +147,9 @@
"include-description": "Inclusief beschrijving", "include-description": "Inclusief beschrijving",
"integration-description-komoot": "", "integration-description-komoot": "",
"integration-description-strava": "", "integration-description-strava": "",
"integration-disabled": "",
"integrations": "", "integrations": "",
"integration-enabled": "",
"invalid-date": "Ongeldige datum", "invalid-date": "Ongeldige datum",
"invalid-username": "Ongeldige gebruikersnaam", "invalid-username": "Ongeldige gebruikersnaam",
"italian": "Italiaans", "italian": "Italiaans",
@@ -289,6 +291,7 @@
"trail-not-shared": "Not shared with anyone", "trail-not-shared": "Not shared with anyone",
"trail-saved-successfully": "Trail saved successfully", "trail-saved-successfully": "Trail saved successfully",
"trails-for-you": "Wandelroutes voor jou", "trails-for-you": "Wandelroutes voor jou",
"unchanged": "",
"units": "Eenheden", "units": "Eenheden",
"upload-file": "Bestand uploaden", "upload-file": "Bestand uploaden",
"upload-gpx": "GPX-bestand uploaden", "upload-gpx": "GPX-bestand uploaden",

View File

@@ -147,7 +147,9 @@
"include-description": "Dołącz opis", "include-description": "Dołącz opis",
"integration-description-komoot": "", "integration-description-komoot": "",
"integration-description-strava": "", "integration-description-strava": "",
"integration-disabled": "",
"integrations": "", "integrations": "",
"integration-enabled": "",
"invalid-date": "Nieprawidłowa data", "invalid-date": "Nieprawidłowa data",
"invalid-username": "Błędna nazwa użytkownika", "invalid-username": "Błędna nazwa użytkownika",
"italian": "Włoski", "italian": "Włoski",
@@ -289,6 +291,7 @@
"trail-not-shared": "Szlak nie udostępniony", "trail-not-shared": "Szlak nie udostępniony",
"trail-saved-successfully": "Szlak pomyślnie zapisany", "trail-saved-successfully": "Szlak pomyślnie zapisany",
"trails-for-you": "Szlaki dla ciebie", "trails-for-you": "Szlaki dla ciebie",
"unchanged": "",
"units": "Jednostki", "units": "Jednostki",
"upload-file": "Przesyłanie pliku", "upload-file": "Przesyłanie pliku",
"upload-gpx": "Importuj GPX", "upload-gpx": "Importuj GPX",

View File

@@ -147,7 +147,9 @@
"include-description": "Incluir descrição", "include-description": "Incluir descrição",
"integration-description-komoot": "", "integration-description-komoot": "",
"integration-description-strava": "", "integration-description-strava": "",
"integration-disabled": "",
"integrations": "", "integrations": "",
"integration-enabled": "",
"invalid-date": "Data inválida", "invalid-date": "Data inválida",
"invalid-username": "Nome de usuário inválido", "invalid-username": "Nome de usuário inválido",
"italian": "Italiano", "italian": "Italiano",
@@ -289,6 +291,7 @@
"trail-not-shared": "Não partilhado com ninguém", "trail-not-shared": "Não partilhado com ninguém",
"trail-saved-successfully": "Percurso gravado com sucesso", "trail-saved-successfully": "Percurso gravado com sucesso",
"trails-for-you": "Trilhos para si", "trails-for-you": "Trilhos para si",
"unchanged": "",
"units": "Unidades", "units": "Unidades",
"upload-file": "Subir arquivo", "upload-file": "Subir arquivo",
"upload-gpx": "Carregar GPX", "upload-gpx": "Carregar GPX",

View File

@@ -147,7 +147,9 @@
"include-description": "包含描述", "include-description": "包含描述",
"integration-description-komoot": "", "integration-description-komoot": "",
"integration-description-strava": "", "integration-description-strava": "",
"integration-disabled": "",
"integrations": "", "integrations": "",
"integration-enabled": "",
"invalid-date": "无效日期", "invalid-date": "无效日期",
"invalid-username": "无效用户名", "invalid-username": "无效用户名",
"italian": "意大利语", "italian": "意大利语",
@@ -289,6 +291,7 @@
"trail-not-shared": "未与任何人分享", "trail-not-shared": "未与任何人分享",
"trail-saved-successfully": "路线保存成功", "trail-saved-successfully": "路线保存成功",
"trails-for-you": "推荐路线", "trails-for-you": "推荐路线",
"unchanged": "",
"units": "单位", "units": "单位",
"upload-file": "上传文件", "upload-file": "上传文件",
"upload-gpx": "上传 GPX", "upload-gpx": "上传 GPX",

View File

@@ -3,12 +3,9 @@ import type { Integration } from "../integration";
const StravaSchema = z.object({ const StravaSchema = z.object({
clientId: z.number({ coerce: true }).int().positive(), clientId: z.number({ coerce: true }).int().positive(),
clientSecret: z.string().length(40), clientSecret: z.string().length(40).optional().or(z.literal('')),
routes: z.boolean(), routes: z.boolean(),
activities: z.boolean(), activities: z.boolean(),
accessToken: z.string().length(40).optional(),
refreshToken: z.string().length(40).optional(),
expiresAt: z.number().int().positive().optional(),
active: z.boolean() active: z.boolean()
}) })

View File

@@ -5,7 +5,7 @@ export interface BaseIntegration {
export interface StravaIntegration extends BaseIntegration { export interface StravaIntegration extends BaseIntegration {
clientId: string | number; clientId: string | number;
clientSecret: string; clientSecret?: string;
routes: boolean; routes: boolean;
activities: boolean; activities: boolean;
accessToken?: string; accessToken?: string;

View File

@@ -9,6 +9,7 @@
type KomootIntegration, type KomootIntegration,
type StravaIntegration, type StravaIntegration,
} from "$lib/models/integration.js"; } from "$lib/models/integration.js";
import { pb } from "$lib/pocketbase.js";
import { import {
integrations_create, integrations_create,
integrations_update, integrations_update,
@@ -29,7 +30,9 @@
); );
let komootSettingsModal: KomootSettingsModal; let komootSettingsModal: KomootSettingsModal;
let komootToggleValue: boolean = $state(data.integration?.komoot?.active ?? false); let komootToggleValue: boolean = $state(
data.integration?.komoot?.active ?? false,
);
async function onSettingsSave( async function onSettingsSave(
form: StravaIntegration | KomootIntegration, form: StravaIntegration | KomootIntegration,
@@ -97,6 +100,14 @@
type: "error", type: "error",
}); });
} }
show_toast({
text:
"strava " +
$_("integration-disabled"),
icon: "check",
type: "success",
});
} }
} }
@@ -105,14 +116,11 @@
return; return;
} }
if (value) { if (value) {
const authUrl = `https://api.komoot.de/v006/account/email/${integration.komoot.email}/`; try {
const r = await fetch(authUrl, { await pb.send("/integration/komoot/login", {
method: "GET", method: "GET",
headers: {
Authorization: `Basic ${btoa(integration.komoot.email + ":" + integration.komoot.password)}`,
},
}); });
if (!r.ok) { } catch (e) {
komootToggleValue = false; komootToggleValue = false;
show_toast({ show_toast({
text: $_("error-logging-in-to-komoot"), text: $_("error-logging-in-to-komoot"),
@@ -133,6 +141,13 @@
type: "error", type: "error",
}); });
} }
show_toast({
text:
"komoot " + $_(`integration-${value ? "enabled" : "disabled"}`),
icon: "check",
type: "success",
});
} }
</script> </script>

View File

@@ -1,5 +1,7 @@
import { pb } from "$lib/pocketbase";
import { integrations_index, integrations_update } from "$lib/stores/integration_store"; import { integrations_index, integrations_update } from "$lib/stores/integration_store";
import { error, redirect, type RequestEvent, type ServerLoad } from "@sveltejs/kit"; import { error, redirect, type RequestEvent, type ServerLoad } from "@sveltejs/kit";
import { ClientResponseError } from "pocketbase";
export const load: ServerLoad = async ({ url, fetch }) => { export const load: ServerLoad = async ({ url, fetch }) => {
const oauthError = url.searchParams.get('error'); const oauthError = url.searchParams.get('error');
@@ -19,41 +21,23 @@ export const load: ServerLoad = async ({ url, fetch }) => {
}); });
} }
const integrations = await integrations_index(fetch); try {
await pb.send("/integration/strava/token", {
if (!integrations.length || !integrations[0].strava) {
return error(400, {
message: "Missing integration record"
});
}
const integration = integrations[0]
const tokenResponse = await fetch('https://www.strava.com/oauth/token', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
client_id: integration.strava?.clientId,
client_secret: integration.strava?.clientSecret,
code, code,
grant_type: 'authorization_code' grant_type: 'authorization_code'
}) })
}); });
} catch (e) {
console.error(e)
if (e instanceof ClientResponseError) {
return error(e.status, e.message);
if (!tokenResponse.ok) {
const r = await tokenResponse.json()
console.error(r)
return error(500, 'Failed to get access token');
} }
throw e
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') return redirect(302, '/settings/integrations')
} }