From a0b648085422eb47dc34d7cbe742b825ae246dda Mon Sep 17 00:00:00 2001 From: Christian Beutel <> Date: Tue, 4 Feb 2025 11:43:48 +0100 Subject: [PATCH] adds strava integration --- db/cron/models.go | 372 +++++++++++ db/cron/strava.go | 582 ++++++++++++++++++ db/go.mod | 19 +- db/go.sum | 39 +- db/main.go | 35 +- .../1738498368_created_integrations.go | 76 +++ .../1738509501_updated_integrations.go | 41 ++ db/migrations/1738517634_updated_trails.go | 79 +++ web/src/hooks.client.ts | 4 +- web/src/hooks.server.ts | 6 +- web/src/lib/components/base/toggle.svelte | 47 +- .../components/trail/trail_info_panel.svelte | 2 +- web/src/lib/i18n/locales/de.json | 8 +- web/src/lib/i18n/locales/en.json | 8 +- web/src/lib/i18n/locales/es.json | 4 + web/src/lib/i18n/locales/fr.json | 4 + web/src/lib/i18n/locales/hu.json | 4 + web/src/lib/i18n/locales/it.json | 4 + web/src/lib/i18n/locales/nl.json | 4 + web/src/lib/i18n/locales/pl.json | 4 + web/src/lib/i18n/locales/pt.json | 4 + web/src/lib/i18n/locales/zh.json | 4 + web/src/lib/models/api/integration_schema.ts | 25 + web/src/lib/models/integration.ts | 25 + web/src/lib/stores/integration_store.ts | 74 +++ web/src/lib/util/api_util.ts | 1 + web/src/routes/api/v1/integration/+server.ts | 23 + .../routes/api/v1/integration/[id]/+server.ts | 32 + web/src/routes/settings/+layout.svelte | 1 + .../routes/settings/integrations/+page.svelte | 201 ++++++ web/src/routes/settings/integrations/+page.ts | 7 + .../callback/strava/+page.server.ts | 55 ++ 32 files changed, 1737 insertions(+), 57 deletions(-) create mode 100644 db/cron/models.go create mode 100644 db/cron/strava.go create mode 100644 db/migrations/1738498368_created_integrations.go create mode 100644 db/migrations/1738509501_updated_integrations.go create mode 100644 db/migrations/1738517634_updated_trails.go create mode 100644 web/src/lib/models/api/integration_schema.ts create mode 100644 web/src/lib/models/integration.ts create mode 100644 web/src/lib/stores/integration_store.ts create mode 100644 web/src/routes/api/v1/integration/+server.ts create mode 100644 web/src/routes/api/v1/integration/[id]/+server.ts create mode 100644 web/src/routes/settings/integrations/+page.svelte create mode 100644 web/src/routes/settings/integrations/+page.ts create mode 100644 web/src/routes/settings/integrations/callback/strava/+page.server.ts diff --git a/db/cron/models.go b/db/cron/models.go new file mode 100644 index 00000000..34667ba2 --- /dev/null +++ b/db/cron/models.go @@ -0,0 +1,372 @@ +package cron + +import "time" + +type RefreshTokenRequest struct { + ClientID int32 `json:"client_id"` + ClientSecret string `json:"client_secret"` + RefreshToken string `json:"refresh_token"` + GrantType string `json:"grant_type"` +} +type RefreshTokenResponse struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + ExpiresAt *int64 `json:"expires_at"` +} +type StravaIntegration struct { + Active bool `json:"active"` + Routes bool `json:"routes"` + Activities bool `json:"activities"` + ClientID int32 `json:"clientId"` + ClientSecret string `json:"clientSecret"` + AccessToken *string `json:"accessToken,omitempty"` + RefreshToken *string `json:"refreshToken,omitempty"` + ExpiresAt *int64 `json:"expiresAt,omitempty"` +} +type StravaRoute struct { + Athlete Athlete `json:"athlete"` + Description string `json:"description"` + Distance float32 `json:"distance"` + ElevationGain float32 `json:"elevation_gain"` + ID int `json:"id"` + IDStr string `json:"id_str"` + Map Map `json:"map"` + Name string `json:"name"` + Private bool `json:"private"` + Starred bool `json:"starred"` + Timestamp int `json:"timestamp"` + Type int `json:"type"` + SubType int `json:"sub_type"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + EstimatedMovingTime int `json:"estimated_moving_time"` + Segments []Segments `json:"segments"` + Waypoints []Waypoints `json:"waypoints"` +} + +type Athlete struct { + ID int `json:"id"` + ResourceState int `json:"resource_state"` + Firstname string `json:"firstname"` + Lastname string `json:"lastname"` + ProfileMedium string `json:"profile_medium"` + Profile string `json:"profile"` + City string `json:"city"` + State string `json:"state"` + Country string `json:"country"` + Sex string `json:"sex"` + Premium bool `json:"premium"` + Summit bool `json:"summit"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type Map struct { + ID string `json:"id"` + Polyline string `json:"polyline"` + SummaryPolyline string `json:"summary_polyline"` +} + +type AthletePrEffort struct { + PrActivityID int `json:"pr_activity_id"` + PrElapsedTime int `json:"pr_elapsed_time"` + PrDate time.Time `json:"pr_date"` + EffortCount int `json:"effort_count"` +} + +type AthleteSegmentStats struct { + ID int `json:"id"` + ActivityID int `json:"activity_id"` + ElapsedTime int `json:"elapsed_time"` + StartDate time.Time `json:"start_date"` + StartDateLocal time.Time `json:"start_date_local"` + Distance float32 `json:"distance"` + IsKom bool `json:"is_kom"` +} + +type Segments struct { + ID int `json:"id"` + Name string `json:"name"` + ActivityType string `json:"activity_type"` + Distance float32 `json:"distance"` + AverageGrade float32 `json:"average_grade"` + MaximumGrade float32 `json:"maximum_grade"` + ElevationHigh float32 `json:"elevation_high"` + ElevationLow float32 `json:"elevation_low"` + StartLatlng []float32 `json:"start_latlng"` + EndLatlng []float32 `json:"end_latlng"` + ClimbCategory int `json:"climb_category"` + City string `json:"city"` + State string `json:"state"` + Country string `json:"country"` + Private bool `json:"private"` + AthletePrEffort AthletePrEffort `json:"athlete_pr_effort"` + AthleteSegmentStats AthleteSegmentStats `json:"athlete_segment_stats"` +} + +type Waypoints struct { + Latlng []float32 `json:"latlng"` + TargetLatlng []float32 `json:"target_latlng"` + Categories []string `json:"categories"` + Title string `json:"title"` + Description string `json:"description"` + DistanceIntoRoute int `json:"distance_into_route"` +} + +type StravaActivity struct { + ResourceState int `json:"resource_state"` + Athlete Athlete `json:"athlete"` + Name string `json:"name"` + Distance float64 `json:"distance"` + MovingTime int `json:"moving_time"` + ElapsedTime int `json:"elapsed_time"` + TotalElevationGain float64 `json:"total_elevation_gain"` + Type string `json:"type"` + SportType string `json:"sport_type"` + WorkoutType any `json:"workout_type"` + ID int64 `json:"id"` + ExternalID string `json:"external_id"` + UploadID int64 `json:"upload_id"` + StartDate time.Time `json:"start_date"` + StartDateLocal time.Time `json:"start_date_local"` + Timezone string `json:"timezone"` + StartLatlng any `json:"start_latlng"` + EndLatlng any `json:"end_latlng"` + LocationCity any `json:"location_city"` + LocationState any `json:"location_state"` + LocationCountry string `json:"location_country"` + AchievementCount int `json:"achievement_count"` + KudosCount int `json:"kudos_count"` + CommentCount int `json:"comment_count"` + AthleteCount int `json:"athlete_count"` + PhotoCount int `json:"photo_count"` + Map Map `json:"map"` + Trainer bool `json:"trainer"` + Commute bool `json:"commute"` + Manual bool `json:"manual"` + Private bool `json:"private"` + Flagged bool `json:"flagged"` + GearID string `json:"gear_id"` + FromAcceptedTag bool `json:"from_accepted_tag"` + AverageSpeed float64 `json:"average_speed"` + MaxSpeed float64 `json:"max_speed"` + AverageCadence float64 `json:"average_cadence"` + AverageWatts float64 `json:"average_watts"` + WeightedAverageWatts int `json:"weighted_average_watts"` + Kilojoules float64 `json:"kilojoules"` + DeviceWatts bool `json:"device_watts"` + HasHeartrate bool `json:"has_heartrate"` + AverageHeartrate float64 `json:"average_heartrate"` + MaxHeartrate int `json:"max_heartrate"` + MaxWatts int `json:"max_watts"` + PrCount int `json:"pr_count"` + TotalPhotoCount int `json:"total_photo_count"` + HasKudoed bool `json:"has_kudoed"` + SufferScore int `json:"suffer_score"` +} + +type DetailedStravaActivity struct { + ID int64 `json:"id"` + ResourceState int `json:"resource_state"` + ExternalID string `json:"external_id"` + UploadID int64 `json:"upload_id"` + Athlete Athlete `json:"athlete"` + Name string `json:"name"` + Distance float64 `json:"distance"` + MovingTime int `json:"moving_time"` + ElapsedTime int `json:"elapsed_time"` + TotalElevationGain float64 `json:"total_elevation_gain"` + Type string `json:"type"` + SportType string `json:"sport_type"` + StartDate time.Time `json:"start_date"` + StartDateLocal time.Time `json:"start_date_local"` + Timezone string `json:"timezone"` + StartLatlng []float64 `json:"start_latlng"` + EndLatlng []float64 `json:"end_latlng"` + AchievementCount int `json:"achievement_count"` + KudosCount int `json:"kudos_count"` + CommentCount int `json:"comment_count"` + AthleteCount int `json:"athlete_count"` + PhotoCount int `json:"photo_count"` + Map Map `json:"map"` + Trainer bool `json:"trainer"` + Commute bool `json:"commute"` + Manual bool `json:"manual"` + Private bool `json:"private"` + Flagged bool `json:"flagged"` + GearID string `json:"gear_id"` + FromAcceptedTag bool `json:"from_accepted_tag"` + AverageSpeed float64 `json:"average_speed"` + MaxSpeed float64 `json:"max_speed"` + AverageCadence float64 `json:"average_cadence"` + AverageTemp int `json:"average_temp"` + AverageWatts float64 `json:"average_watts"` + WeightedAverageWatts int `json:"weighted_average_watts"` + Kilojoules float64 `json:"kilojoules"` + DeviceWatts bool `json:"device_watts"` + HasHeartrate bool `json:"has_heartrate"` + MaxWatts int `json:"max_watts"` + ElevHigh float64 `json:"elev_high"` + ElevLow float64 `json:"elev_low"` + PrCount int `json:"pr_count"` + TotalPhotoCount int `json:"total_photo_count"` + HasKudoed bool `json:"has_kudoed"` + WorkoutType int `json:"workout_type"` + SufferScore any `json:"suffer_score"` + Description string `json:"description"` + Calories float64 `json:"calories"` + SegmentEfforts []SegmentEfforts `json:"segment_efforts"` + SplitsMetric []SplitsMetric `json:"splits_metric"` + Laps []Laps `json:"laps"` + Gear Gear `json:"gear"` + PartnerBrandTag any `json:"partner_brand_tag"` + Photos Photos `json:"photos"` + HighlightedKudosers []HighlightedKudosers `json:"highlighted_kudosers"` + HideFromHome bool `json:"hide_from_home"` + DeviceName string `json:"device_name"` + EmbedToken string `json:"embed_token"` + SegmentLeaderboardOptOut bool `json:"segment_leaderboard_opt_out"` + LeaderboardOptOut bool `json:"leaderboard_opt_out"` +} + +type SegmentActivity struct { + ID int64 `json:"id"` + ResourceState int `json:"resource_state"` +} + +type Segment struct { + ID int `json:"id"` + ResourceState int `json:"resource_state"` + Name string `json:"name"` + ActivityType string `json:"activity_type"` + Distance float64 `json:"distance"` + AverageGrade float64 `json:"average_grade"` + MaximumGrade float64 `json:"maximum_grade"` + ElevationHigh float64 `json:"elevation_high"` + ElevationLow float64 `json:"elevation_low"` + StartLatlng []float64 `json:"start_latlng"` + EndLatlng []float64 `json:"end_latlng"` + ClimbCategory int `json:"climb_category"` + City string `json:"city"` + State string `json:"state"` + Country string `json:"country"` + Private bool `json:"private"` + Hazardous bool `json:"hazardous"` + Starred bool `json:"starred"` +} + +type SegmentEfforts struct { + ID int64 `json:"id"` + ResourceState int `json:"resource_state"` + Name string `json:"name"` + Activity SegmentActivity `json:"activity"` + Athlete Athlete `json:"athlete"` + ElapsedTime int `json:"elapsed_time"` + MovingTime int `json:"moving_time"` + StartDate time.Time `json:"start_date"` + StartDateLocal time.Time `json:"start_date_local"` + Distance float64 `json:"distance"` + StartIndex int `json:"start_index"` + EndIndex int `json:"end_index"` + AverageCadence float64 `json:"average_cadence"` + DeviceWatts bool `json:"device_watts"` + AverageWatts float64 `json:"average_watts"` + Segment Segment `json:"segment"` + KomRank any `json:"kom_rank"` + PrRank any `json:"pr_rank"` + Achievements []any `json:"achievements"` + Hidden bool `json:"hidden"` +} + +type SplitsMetric struct { + Distance float64 `json:"distance"` + ElapsedTime int `json:"elapsed_time"` + ElevationDifference float64 `json:"elevation_difference"` + MovingTime int `json:"moving_time"` + Split int `json:"split"` + AverageSpeed float64 `json:"average_speed"` + PaceZone int `json:"pace_zone"` +} + +type Laps struct { + ID int64 `json:"id"` + ResourceState int `json:"resource_state"` + Name string `json:"name"` + Activity SegmentActivity `json:"activity"` + Athlete Athlete `json:"athlete"` + ElapsedTime int `json:"elapsed_time"` + MovingTime int `json:"moving_time"` + StartDate time.Time `json:"start_date"` + StartDateLocal time.Time `json:"start_date_local"` + Distance float64 `json:"distance"` + StartIndex int `json:"start_index"` + EndIndex int `json:"end_index"` + TotalElevationGain float64 `json:"total_elevation_gain"` + AverageSpeed float64 `json:"average_speed"` + MaxSpeed float64 `json:"max_speed"` + AverageCadence float64 `json:"average_cadence"` + DeviceWatts bool `json:"device_watts"` + AverageWatts float64 `json:"average_watts"` + LapIndex int `json:"lap_index"` + Split int `json:"split"` +} + +type Gear struct { + ID string `json:"id"` + Primary bool `json:"primary"` + Name string `json:"name"` + ResourceState int `json:"resource_state"` + Distance int `json:"distance"` +} + +type Urls struct { + Num100 string `json:"100"` + Num600 string `json:"600"` +} + +type Primary struct { + ID any `json:"id"` + UniqueID string `json:"unique_id"` + Urls Urls `json:"urls"` + Source int `json:"source"` +} + +type Photos struct { + Primary Primary `json:"primary"` + UsePrimaryPhoto bool `json:"use_primary_photo"` + Count int `json:"count"` +} + +type HighlightedKudosers struct { + DestinationURL string `json:"destination_url"` + DisplayName string `json:"display_name"` + AvatarURL string `json:"avatar_url"` + ShowName bool `json:"show_name"` +} + +type ActivityStreamResponse struct { + LatLng LatLngStream `json:"latlng"` + Altitude AltitudeStream `json:"altitude"` + Time TimeStream `json:"time"` +} + +type ActivityStream struct { + OriginalSize int `json:"original_size"` + Resolution string `json:"resolution"` + SeriesType string `json:"series_type"` +} + +type TimeStream struct { + ActivityStream + Data []int `json:"data"` +} + +type LatLngStream struct { + ActivityStream + Data [][]float64 `json:"data"` +} + +type AltitudeStream struct { + ActivityStream + Data []float64 `json:"data"` +} diff --git a/db/cron/strava.go b/db/cron/strava.go new file mode 100644 index 00000000..066a1a5b --- /dev/null +++ b/db/cron/strava.go @@ -0,0 +1,582 @@ +package cron + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "strconv" + "time" + + "github.com/pocketbase/dbx" + "github.com/pocketbase/pocketbase" + "github.com/pocketbase/pocketbase/forms" + "github.com/pocketbase/pocketbase/models" + "github.com/pocketbase/pocketbase/tools/filesystem" + "github.com/twpayne/go-gpx" + "github.com/twpayne/go-polyline" +) + +func SyncStrava(app *pocketbase.PocketBase) error { + integrations, err := app.Dao().FindRecordsByExpr("integrations", dbx.NewExp("true")) + if err != nil { + return err + } + + for _, i := range integrations { + userId := i.GetString("user") + stravaString := i.GetString("strava") + var stravaIntegration StravaIntegration + json.Unmarshal([]byte(stravaString), &stravaIntegration) + + if !stravaIntegration.Active || stravaIntegration.RefreshToken == nil { + continue + } + + r, err := refreshStravaToken(stravaIntegration.ClientID, stravaIntegration.ClientSecret, *stravaIntegration.RefreshToken) + if err != nil { + return err + } + stravaIntegration.AccessToken = &r.AccessToken + stravaIntegration.RefreshToken = &r.RefreshToken + stravaIntegration.ExpiresAt = r.ExpiresAt + + b, err := json.Marshal(stravaIntegration) + if err != nil { + return err + } + i.Set("strava", string(b)) + app.Dao().SaveRecord(i) + + if stravaIntegration.Routes { + page := 1 + hasNewRoutes := true + for hasNewRoutes { + + routes, err := fetchStravaRoutes(r.AccessToken, page) + if err != nil { + return err + } + hasNewRoutes, err = syncTrailsWithRoutes(app, r.AccessToken, userId, routes) + if err != nil { + return err + } + page += 1 + } + } + if stravaIntegration.Activities { + page := 1 + hasNewActivities := true + for hasNewActivities { + activities, err := fetchStravaActivities(r.AccessToken, page) + if err != nil { + return err + } + hasNewActivities, err = syncTrailsWithActivities(app, r.AccessToken, userId, activities) + if err != nil { + return err + } + page += 1 + } + } + } + + return nil +} + +func refreshStravaToken(clientID int32, clientSecret, refreshToken string) (*RefreshTokenResponse, error) { + const stravaTokenURL = "https://www.strava.com/oauth/token" + + requestBody, err := json.Marshal(RefreshTokenRequest{ + ClientID: clientID, + ClientSecret: clientSecret, + RefreshToken: refreshToken, + GrantType: "refresh_token", + }) + if err != nil { + return nil, err + } + + req, err := http.NewRequest("POST", stravaTokenURL, bytes.NewBuffer(requestBody)) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/json") + + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("failed to refresh token: received status %d", resp.StatusCode) + } + + var tokenResponse RefreshTokenResponse + if err := json.NewDecoder(resp.Body).Decode(&tokenResponse); err != nil { + return nil, err + } + + return &tokenResponse, nil +} + +func fetchStravaRoutes(accessToken string, page int) ([]StravaRoute, error) { + stravaRoutesURL := fmt.Sprintf("https://www.strava.com/api/v3/athlete/routes?page=%d", page) + + req, err := http.NewRequest("GET", stravaRoutesURL, nil) + if err != nil { + return nil, err + } + req.Header.Set("Authorization", "Bearer "+accessToken) + + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("failed to fetch routes: received status %d", resp.StatusCode) + } + + var routes []StravaRoute + if err := json.NewDecoder(resp.Body).Decode(&routes); err != nil { + return nil, err + } + + return routes, nil +} + +func fetchStravaActivities(accessToken string, page int) ([]StravaActivity, error) { + stravaRoutesURL := fmt.Sprintf("https://www.strava.com/api/v3/athlete/activities?page=%d", page) + req, err := http.NewRequest("GET", stravaRoutesURL, nil) + if err != nil { + return nil, err + } + req.Header.Set("Authorization", "Bearer "+accessToken) + + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("failed to fetch activities: received status %d", resp.StatusCode) + } + + var activities []StravaActivity + if err := json.NewDecoder(resp.Body).Decode(&activities); err != nil { + return nil, err + } + + return activities, nil +} + +func syncTrailsWithRoutes(app *pocketbase.PocketBase, accessToken string, user string, routes []StravaRoute) (bool, error) { + hasNewRoutes := false + for _, route := range routes { + trails, err := app.Dao().FindRecordsByFilter("trails", "external_id = {:id}", "", 1, 0, dbx.Params{"id": route.IDStr}) + if err != nil { + return hasNewRoutes, err + } + if len(trails) != 0 { + continue + } + hasNewRoutes = true + gpx, err := fetchRouteGPX(route, accessToken) + if err != nil { + return hasNewRoutes, err + } + wpIds, err := createWaypointsFromRoute(app, route, user) + if err != nil { + return hasNewRoutes, err + } + err = createTrailFromRoute(app, route, gpx, user, wpIds) + if err != nil { + return hasNewRoutes, err + } + + } + + return hasNewRoutes, nil +} + +func fetchRouteGPX(route StravaRoute, accessToken string) (*filesystem.File, error) { + url := fmt.Sprintf("https://www.strava.com/api/v3/routes/%s/export_gpx", route.IDStr) + + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return nil, err + } + req.Header.Set("Authorization", "Bearer "+accessToken) + + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer func() { + if resp.Body != nil { + resp.Body.Close() + } + }() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("failed to fetch GPX: received status %d", resp.StatusCode) + } + + var buf bytes.Buffer + _, err = io.Copy(&buf, resp.Body) + if err != nil { + return nil, err + } + + gpxFile, err := filesystem.NewFileFromBytes(buf.Bytes(), route.Name+".gpx") + if err != nil { + return nil, err + } + + return gpxFile, nil +} + +func createTrailFromRoute(app *pocketbase.PocketBase, route StravaRoute, gpx *filesystem.File, user string, wpIds []string) error { + collection, err := app.Dao().FindCollectionByNameOrId("trails") + if err != nil { + return err + } + + record := models.NewRecord(collection) + + form := forms.NewRecordUpsert(app, record) + + buf := []byte(route.Map.SummaryPolyline) + coords, _, _ := polyline.DecodeCoords(buf) + + var lat, lon float64 + if len(coords) > 0 && len(coords[0]) >= 2 { + lat = coords[0][0] + lon = coords[0][1] + } else { + fmt.Println("Warning: No coordinates available, setting lat/lon to 0") + lat, lon = 0, 0 + } + + bikeCategory, _ := app.Dao().FindFirstRecordByData("categories", "name", "Biking") + hikeCategory, _ := app.Dao().FindFirstRecordByData("categories", "name", "Walking") + + category := "" + + if route.Type == 1 && bikeCategory != nil { + category = bikeCategory.Id + } else if route.Type == 2 && hikeCategory != nil { + category = hikeCategory.Id + } + + form.LoadData(map[string]any{ + "name": route.Name, + "description": route.Description, + "public": !route.Private, + "distance": route.Distance, + "elevation_gain": route.ElevationGain, + "duration": route.EstimatedMovingTime / 60, + "date": time.Unix(int64(route.Timestamp), 0), + "external_provider": "strava", + "external_id": route.IDStr, + "lat": lat, + "lon": lon, + "waypoints": wpIds, + "difficulty": "easy", + "category": category, + "author": user, + }) + + form.AddFiles("gpx", gpx) + + if err := form.Submit(); err != nil { + return err + } + + return nil +} + +func createWaypointsFromRoute(app *pocketbase.PocketBase, route StravaRoute, user string) ([]string, error) { + collection, err := app.Dao().FindCollectionByNameOrId("waypoints") + if err != nil { + return nil, err + } + + wpIds := make([]string, len(route.Waypoints)) + + for i, wp := range route.Waypoints { + record := models.NewRecord(collection) + + record.Set("name", string(i)) + record.Set("description", wp.Description) + record.Set("lat", wp.Latlng[0]) + record.Set("lon", wp.Latlng[1]) + record.Set("icon", "circle") + record.Set("author", user) + record.Set("distance_from_start", wp.DistanceIntoRoute) + + app.Dao().SaveRecord(record) + + wpIds[i] = record.Id + } + + return wpIds, nil +} + +func syncTrailsWithActivities(app *pocketbase.PocketBase, accessToken string, user string, activities []StravaActivity) (bool, error) { + hasNewActivites := false + for _, activity := range activities { + trails, err := app.Dao().FindRecordsByFilter("trails", "external_id = {:id}", "", 1, 0, dbx.Params{"id": strconv.Itoa(int(activity.ID))}) + if err != nil { + return hasNewActivites, err + } + if len(trails) != 0 { + continue + } + hasNewActivites = true + detailedActivity, err := fetchDetailedActivity(activity, accessToken) + if err != nil { + return hasNewActivites, err + } + gpx, err := generateActivityGPX(detailedActivity, accessToken) + if err != nil { + return hasNewActivites, err + } + err = createTrailFromActivity(app, detailedActivity, gpx, user) + if err != nil { + return hasNewActivites, err + } + + } + + return hasNewActivites, nil +} + +func fetchDetailedActivity(activity StravaActivity, accessToken string) (*DetailedStravaActivity, error) { + url := fmt.Sprintf("https://www.strava.com/api/v3/activities/%d", activity.ID) + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return nil, err + } + req.Header.Set("Authorization", "Bearer "+accessToken) + + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("failed to fetch activity: received status %d", resp.StatusCode) + } + + var detailedActivity DetailedStravaActivity + if err := json.NewDecoder(resp.Body).Decode(&detailedActivity); err != nil { + return nil, err + } + + return &detailedActivity, nil +} + +func createTrailFromActivity(app *pocketbase.PocketBase, activity *DetailedStravaActivity, gpx *filesystem.File, user string) error { + collection, err := app.Dao().FindCollectionByNameOrId("trails") + if err != nil { + return err + } + + var photo *filesystem.File + if len(activity.Photos.Primary.Urls.Num600) > 0 { + photo, err = fetchActivityPhoto(activity) + if err != nil { + return err + } + } + + record := models.NewRecord(collection) + + form := forms.NewRecordUpsert(app, record) + + activityMap := map[string]string{ + "AlpineSki": "Skiing", + "BackcountrySki": "Skiing", + "Canoeing": "Canoeing", + "Crossfit": "Workout", + "EBikeRide": "Biking", + "Elliptical": "Workout", + "Golf": "Walking", + "Handcycle": "Biking", + "Hike": "Hiking", + "IceSkate": "Skiing", + "InlineSkate": "Biking", + "Kayaking": "Canoeing", + "Kitesurf": "Canoeing", + "NordicSki": "Skiing", + "Ride": "Biking", + "RockClimbing": "Climbing", + "RollerSki": "Skiing", + "Rowing": "Canoeing", + "Run": "Walking", + "Sail": "Canoeing", + "Skateboard": "Walking", + "Snowboard": "Skiing", + "Snowshoe": "Hiking", + "Soccer": "Workout", + "StairStepper": "Workout", + "StandUpPaddling": "Canoeing", + "Surfing": "Canoeing", + "Swim": "Workout", + "Velomobile": "Biking", + "VirtualRide": "Biking", + "VirtualRun": "Walking", + "Walk": "Walking", + "WeightTraining": "Workout", + "Wheelchair": "Walking", + "Windsurf": "Canoeing", + "Workout": "Workout", + "Yoga": "Workout", + } + + category, _ := app.Dao().FindFirstRecordByData("categories", "name", activityMap[activity.Type]) + categoryId := "" + if category != nil { + categoryId = category.Id + } + + form.LoadData(map[string]any{ + "name": activity.Name, + "description": activity.Description, + "public": !activity.Private, + "distance": activity.Distance, + "elevation_gain": activity.TotalElevationGain, + "duration": activity.ElapsedTime / 60, + "date": activity.StartDate, + "external_provider": "strava", + "external_id": activity.ID, + "lat": activity.StartLatlng[0], + "lon": activity.StartLatlng[1], + "difficulty": "easy", + "category": categoryId, + "author": user, + }) + + if photo != nil { + form.AddFiles("photos", photo) + } + form.AddFiles("gpx", gpx) + + if err := form.Submit(); err != nil { + return err + } + + return nil +} + +func fetchActivityPhoto(activity *DetailedStravaActivity) (*filesystem.File, error) { + req, err := http.NewRequest("GET", activity.Photos.Primary.Urls.Num600, nil) + if err != nil { + return nil, err + } + + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("failed to fetch photo: received status %d", resp.StatusCode) + } + + var buf bytes.Buffer + _, err = io.Copy(&buf, resp.Body) + if err != nil { + return nil, err + } + + photo, err := filesystem.NewFileFromBytes(buf.Bytes(), "photo") + if err != nil { + return nil, err + } + + return photo, nil +} + +func generateActivityGPX(activity *DetailedStravaActivity, accessToken string) (*filesystem.File, error) { + url := fmt.Sprintf("https://www.strava.com/api/v3/activities/%d/streams?keys=latlng,time,altitude&key_by_type=true", activity.ID) + + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return nil, err + } + req.Header.Set("Authorization", "Bearer "+accessToken) + + client := &http.Client{} + + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("failed to fetch activity: %s", resp.Status) + } + + var streamResponse ActivityStreamResponse + if err := json.NewDecoder(resp.Body).Decode(&streamResponse); err != nil { + return nil, err + } + + latLngStream := streamResponse.LatLng + timeStream := streamResponse.Time + altitudeStream := streamResponse.Altitude + + var points []*gpx.WptType + + for i, latlng := range latLngStream.Data { + lat := latlng[0] + lon := latlng[1] + alt := altitudeStream.Data[i] + t := activity.StartDate.Unix() + int64(timeStream.Data[i]) + + points = append(points, &gpx.WptType{Lat: lat, Lon: lon, Ele: alt, Time: time.Unix(t, 0)}) + } + + gpx := &gpx.GPX{ + Version: "1.1", + Creator: "Strava GPX Exporter", + Trk: []*gpx.TrkType{ + { + Name: activity.Name, + TrkSeg: []*gpx.TrkSegType{ + { + TrkPt: points, + }, + }, + }, + }, + } + var buf bytes.Buffer + err = gpx.Write(&buf) + if err != nil { + return nil, err + } + + gpxFile, err := filesystem.NewFileFromBytes(buf.Bytes(), activity.Name+".gpx") + if err != nil { + return nil, err + } + + return gpxFile, nil +} diff --git a/db/go.mod b/db/go.mod index fe7e2ed4..be9d0fb7 100644 --- a/db/go.mod +++ b/db/go.mod @@ -9,6 +9,11 @@ require ( github.com/pocketbase/pocketbase v0.22.26 ) +require ( + github.com/rogpeppe/go-internal v1.13.1 // indirect + github.com/twpayne/go-geom v1.5.7 // indirect +) + require ( github.com/AlecAivazis/survey/v2 v2.3.7 // indirect github.com/andybalholm/brotli v1.1.0 // indirect @@ -58,18 +63,20 @@ require ( github.com/spf13/cast v1.7.0 // indirect github.com/spf13/cobra v1.8.1 // indirect github.com/spf13/pflag v1.0.5 // indirect + github.com/twpayne/go-gpx v1.4.1 + github.com/twpayne/go-polyline v1.1.1 github.com/valyala/bytebufferpool v1.0.0 // indirect github.com/valyala/fasttemplate v1.2.2 // indirect go.opencensus.io v0.24.0 // indirect gocloud.dev v0.39.0 // indirect - golang.org/x/crypto v0.28.0 // indirect + golang.org/x/crypto v0.31.0 // indirect golang.org/x/image v0.19.0 // indirect - golang.org/x/net v0.30.0 // indirect + golang.org/x/net v0.33.0 // indirect golang.org/x/oauth2 v0.22.0 // indirect - golang.org/x/sync v0.8.0 // indirect - golang.org/x/sys v0.26.0 // indirect - golang.org/x/term v0.25.0 // indirect - golang.org/x/text v0.19.0 // indirect + golang.org/x/sync v0.10.0 // indirect + golang.org/x/sys v0.28.0 // indirect + golang.org/x/term v0.27.0 // indirect + golang.org/x/text v0.21.0 // indirect golang.org/x/time v0.6.0 // indirect golang.org/x/xerrors v0.0.0-20240716161551-93cc26a95ae9 // indirect google.golang.org/api v0.194.0 // indirect diff --git a/db/go.sum b/db/go.sum index ab65ecaf..48459d7f 100644 --- a/db/go.sum +++ b/db/go.sum @@ -78,6 +78,7 @@ github.com/domodwyer/mailyak/v3 v3.6.2 h1:x3tGMsyFhTCaxp6ycgR0FE/bu5QiNp+hetUuCO github.com/domodwyer/mailyak/v3 v3.6.2/go.mod h1:lOm/u9CyCVWHeaAmHIdF4RiKVxKUT/H5XX10lIKAL6c= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/dvyukov/go-fuzz v0.0.0-20200318091601-be3528f3a813/go.mod h1:11Gm+ccJnvAhCNLlf5+cS9KjtbaD5I5zaZpFMsTHWTw= github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= @@ -188,8 +189,8 @@ github.com/pocketbase/pocketbase v0.22.26/go.mod h1:h2ojT2pqBWH9LLl1aiawkwXiICKt github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= -github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= -github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= +github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= +github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/spf13/cast v1.7.0 h1:ntdiHjuueXFgm5nzDRdOS4yfT43P5Fnud6DH50rz/7w= github.com/spf13/cast v1.7.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= @@ -198,15 +199,24 @@ github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3k github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.3.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.8.2 h1:+h33VjcLVPDHtOdpUCuF+7gSuG3yGIftsP1YvFihtJ8= github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/twpayne/go-geom v1.5.7 h1:7fdceDUr03/MP7rAKOaTV6x9njMiQdxB/D0PDzMTCDc= +github.com/twpayne/go-geom v1.5.7/go.mod h1:y4fTAQtLedXW8eG2Yo4tYrIGN1yIwwKkmA+K3iSHKBA= +github.com/twpayne/go-gpx v1.4.1 h1:Y41EDC/r49OH6pTAQUk4Qpcp9z96fOvVLchRq/P4iys= +github.com/twpayne/go-gpx v1.4.1/go.mod h1:6bVeKyVqzHRZ25UdFOWxv0f6SMW0P9lO7GO1aNNznEU= +github.com/twpayne/go-polyline v1.1.1 h1:/tSF1BR7rN4HWj4XKqvRUNrCiYVMCvywxTFVofvDV0w= +github.com/twpayne/go-polyline v1.1.1/go.mod h1:ybd9IWWivW/rlXPXuuckeKUyF3yrIim+iqA7kSl4NFY= github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo= @@ -229,8 +239,8 @@ gocloud.dev v0.39.0/go.mod h1:drz+VyYNBvrMTW0KZiBAYEdl8lbNZx+OQ7oQvdrFmSQ= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= -golang.org/x/crypto v0.28.0 h1:GBDwsMXVQi34v5CCYUm2jkJvu4cbtru2U4TN2PSyQnw= -golang.org/x/crypto v0.28.0/go.mod h1:rmgy+3RHxRZMyY0jjAJShp2zgEdOqj2AO7U0pYmeQ7U= +golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U= +golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= golang.org/x/image v0.19.0 h1:D9FX4QWkLfkeqaC62SonffIIuYdOk/UE2XKUBgRIBIQ= @@ -251,8 +261,8 @@ golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLL golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= -golang.org/x/net v0.30.0 h1:AcW1SDZMkb8IpzCdQUaIq2sP4sZ4zw+55h6ynffypl4= -golang.org/x/net v0.30.0/go.mod h1:2wGyMJ5iFasEhkwi13ChkO/t1ECNC4X4eBKkVFyYFlU= +golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I= +golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.22.0 h1:BzDx2FehcG7jJwgWLELCdmLuxk2i+x9UDpSiss2u0ZA= golang.org/x/oauth2 v0.22.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= @@ -260,8 +270,8 @@ golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ= -golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ= +golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -273,19 +283,19 @@ golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo= -golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA= +golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= -golang.org/x/term v0.25.0 h1:WtHI/ltw4NvSUig5KARz9h521QvRC8RmF/cuYqifU24= -golang.org/x/term v0.25.0/go.mod h1:RPyXicDX+6vLxogjjRxjgD2TKtmAO6NZBsBRfrOLu7M= +golang.org/x/term v0.27.0 h1:WP60Sv1nlK1T6SupCHbXzSaN0b9wUmsPoRS9b61A23Q= +golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= -golang.org/x/text v0.19.0 h1:kTxAhCbGbxhK0IwgSKiMO5awPoDQ0RpfiVYBfK860YM= -golang.org/x/text v0.19.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= +golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= +golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= golang.org/x/time v0.6.0 h1:eTDhh4ZXt5Qf0augr54TN6suAUudPcawVZeIAPU7D4U= golang.org/x/time v0.6.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= @@ -336,6 +346,7 @@ google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWn gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= diff --git a/db/main.go b/db/main.go index 6ee312c6..1297102c 100644 --- a/db/main.go +++ b/db/main.go @@ -18,9 +18,11 @@ import ( "github.com/pocketbase/pocketbase/forms" "github.com/pocketbase/pocketbase/models" "github.com/pocketbase/pocketbase/plugins/migratecmd" + pbCron "github.com/pocketbase/pocketbase/tools/cron" "github.com/pocketbase/pocketbase/tools/filesystem" "github.com/pocketbase/pocketbase/tools/hook" + "pocketbase/cron" _ "pocketbase/migrations" "pocketbase/util" ) @@ -54,7 +56,9 @@ func registerMigrations(app *pocketbase.PocketBase) { func setupEventHandlers(app *pocketbase.PocketBase, client meilisearch.ServiceManager) { app.OnModelAfterCreate("users").Add(createUserHandler(app, client)) - app.OnRecordAfterCreateRequest("trails").Add(createTrailHandler(app, client)) + app.OnModelAfterCreate("trails").Add(createTrailIndexHandler(client)) + + app.OnRecordAfterCreateRequest("trails").Add(createTrailHandler(app)) app.OnRecordAfterUpdateRequest("trails").Add(updateTrailHandler(client)) app.OnRecordAfterDeleteRequest("trails").Add(deleteTrailHandler(client)) @@ -115,11 +119,18 @@ func createDefaultUserSettings(app *pocketbase.PocketBase, userId string) error return app.Dao().SaveRecord(settings) } -func createTrailHandler(app *pocketbase.PocketBase, client meilisearch.ServiceManager) func(e *core.RecordCreateEvent) error { - return func(e *core.RecordCreateEvent) error { - if err := util.IndexTrail(e.Record, client); err != nil { +func createTrailIndexHandler(client meilisearch.ServiceManager) func(e *core.ModelEvent) error { + return func(e *core.ModelEvent) error { + record := e.Model.(*models.Record) + if err := util.IndexTrail(record, client); err != nil { return err } + return nil + } +} + +func createTrailHandler(app *pocketbase.PocketBase) func(e *core.RecordCreateEvent) error { + return func(e *core.RecordCreateEvent) error { if e.Record.GetBool("public") { notification := util.Notification{ Type: util.TrailCreate, @@ -335,6 +346,7 @@ func changeUserEmailHandler(app *pocketbase.PocketBase) func(e *core.RecordReque func onBeforeServeHandler(app *pocketbase.PocketBase, client meilisearch.ServiceManager) func(e *core.ServeEvent) error { return func(e *core.ServeEvent) error { registerRoutes(e, app, client) + registerCronJobs(app) return bootstrapData(app, client) } @@ -391,6 +403,19 @@ func registerRoutes(e *core.ServeEvent, app *pocketbase.PocketBase, client meili }) } +func registerCronJobs(app *pocketbase.PocketBase) { + scheduler := pbCron.New() + + scheduler.MustAdd("strava", "*/15 * * * *", func() { + err := cron.SyncStrava(app) + if err != nil { + app.Logger().Warn(fmt.Sprintf("Error syncing with strava: %v", err)) + } + }) + + scheduler.Start() +} + func bootstrapData(app *pocketbase.PocketBase, client meilisearch.ServiceManager) error { bootstrapCategories(app) bootstrapMeilisearchTrails(app, client) @@ -431,8 +456,6 @@ func bootstrapMeilisearchTrails(app *pocketbase.PocketBase, client meilisearch.S } for _, trail := range trails { - log.Println(trail) - if err := util.UpdateTrail(trail, client); err != nil { return err } diff --git a/db/migrations/1738498368_created_integrations.go b/db/migrations/1738498368_created_integrations.go new file mode 100644 index 00000000..4879343a --- /dev/null +++ b/db/migrations/1738498368_created_integrations.go @@ -0,0 +1,76 @@ +package migrations + +import ( + "encoding/json" + + "github.com/pocketbase/dbx" + "github.com/pocketbase/pocketbase/daos" + m "github.com/pocketbase/pocketbase/migrations" + "github.com/pocketbase/pocketbase/models" +) + +func init() { + m.Register(func(db dbx.Builder) error { + jsonData := `{ + "id": "iz4sezoehde64wp", + "created": "2025-02-02 12:12:48.256Z", + "updated": "2025-02-02 12:12:48.256Z", + "name": "integrations", + "type": "base", + "system": false, + "schema": [ + { + "system": false, + "id": "yhqqdtrf", + "name": "strava", + "type": "json", + "required": false, + "presentable": false, + "unique": false, + "options": { + "maxSize": 2000000 + } + }, + { + "system": false, + "id": "bmktyzqz", + "name": "user", + "type": "relation", + "required": true, + "presentable": false, + "unique": false, + "options": { + "collectionId": "_pb_users_auth_", + "cascadeDelete": true, + "minSelect": null, + "maxSelect": 1, + "displayFields": null + } + } + ], + "indexes": [], + "listRule": "@request.auth.id = user.id", + "viewRule": "@request.auth.id = user.id", + "createRule": "@request.auth.id = user.id", + "updateRule": "@request.auth.id = user.id", + "deleteRule": "@request.auth.id = user.id", + "options": {} + }` + + collection := &models.Collection{} + if err := json.Unmarshal([]byte(jsonData), &collection); err != nil { + return err + } + + return daos.New(db).SaveCollection(collection) + }, func(db dbx.Builder) error { + dao := daos.New(db); + + collection, err := dao.FindCollectionByNameOrId("iz4sezoehde64wp") + if err != nil { + return err + } + + return dao.DeleteCollection(collection) + }) +} diff --git a/db/migrations/1738509501_updated_integrations.go b/db/migrations/1738509501_updated_integrations.go new file mode 100644 index 00000000..4fd3f090 --- /dev/null +++ b/db/migrations/1738509501_updated_integrations.go @@ -0,0 +1,41 @@ +package migrations + +import ( + "encoding/json" + + "github.com/pocketbase/dbx" + "github.com/pocketbase/pocketbase/daos" + m "github.com/pocketbase/pocketbase/migrations" +) + +func init() { + m.Register(func(db dbx.Builder) error { + dao := daos.New(db); + + collection, err := dao.FindCollectionByNameOrId("iz4sezoehde64wp") + if err != nil { + return err + } + + if err := json.Unmarshal([]byte(`[ + "CREATE UNIQUE INDEX ` + "`" + `idx_qMhw0Em` + "`" + ` ON ` + "`" + `integrations` + "`" + ` (` + "`" + `user` + "`" + `)" + ]`), &collection.Indexes); err != nil { + return err + } + + return dao.SaveCollection(collection) + }, func(db dbx.Builder) error { + dao := daos.New(db); + + collection, err := dao.FindCollectionByNameOrId("iz4sezoehde64wp") + if err != nil { + return err + } + + if err := json.Unmarshal([]byte(`[]`), &collection.Indexes); err != nil { + return err + } + + return dao.SaveCollection(collection) + }) +} diff --git a/db/migrations/1738517634_updated_trails.go b/db/migrations/1738517634_updated_trails.go new file mode 100644 index 00000000..dc78ac95 --- /dev/null +++ b/db/migrations/1738517634_updated_trails.go @@ -0,0 +1,79 @@ +package migrations + +import ( + "encoding/json" + + "github.com/pocketbase/dbx" + "github.com/pocketbase/pocketbase/daos" + m "github.com/pocketbase/pocketbase/migrations" + "github.com/pocketbase/pocketbase/models/schema" +) + +func init() { + m.Register(func(db dbx.Builder) error { + dao := daos.New(db); + + collection, err := dao.FindCollectionByNameOrId("e864strfxo14pm4") + if err != nil { + return err + } + + // add + new_external_id := &schema.SchemaField{} + if err := json.Unmarshal([]byte(`{ + "system": false, + "id": "sajmiuau", + "name": "external_id", + "type": "text", + "required": false, + "presentable": false, + "unique": false, + "options": { + "min": null, + "max": null, + "pattern": "" + } + }`), new_external_id); err != nil { + return err + } + collection.Schema.AddField(new_external_id) + + // add + new_external_provider := &schema.SchemaField{} + if err := json.Unmarshal([]byte(`{ + "system": false, + "id": "htr35nha", + "name": "external_provider", + "type": "select", + "required": false, + "presentable": false, + "unique": false, + "options": { + "maxSelect": 1, + "values": [ + "strava" + ] + } + }`), new_external_provider); err != nil { + return err + } + collection.Schema.AddField(new_external_provider) + + return dao.SaveCollection(collection) + }, func(db dbx.Builder) error { + dao := daos.New(db); + + collection, err := dao.FindCollectionByNameOrId("e864strfxo14pm4") + if err != nil { + return err + } + + // remove + collection.Schema.RemoveField("sajmiuau") + + // remove + collection.Schema.RemoveField("htr35nha") + + return dao.SaveCollection(collection) + }) +} diff --git a/web/src/hooks.client.ts b/web/src/hooks.client.ts index e0b1e5d7..2716ef2a 100644 --- a/web/src/hooks.client.ts +++ b/web/src/hooks.client.ts @@ -3,7 +3,7 @@ import { pb } from '$lib/pocketbase'; import { currentUser } from '$lib/stores/user_store'; pb.authStore.loadFromCookie(document.cookie) -pb.authStore.onChange(() => { +pb.authStore.onChange(() => { currentUser.set(pb.authStore.model as User) - document.cookie = pb.authStore.exportToCookie({ httpOnly: false, secure: location.protocol === "https:" }) + document.cookie = pb.authStore.exportToCookie({ httpOnly: false, secure: location.protocol === "https:" || location.hostname === "localhost", sameSite: 'none' }) }, true) \ No newline at end of file diff --git a/web/src/hooks.server.ts b/web/src/hooks.server.ts index 31e36987..c65213d3 100644 --- a/web/src/hooks.server.ts +++ b/web/src/hooks.server.ts @@ -67,7 +67,7 @@ const auth: Handle = async ({ event, resolve }) => { try { // get an up-to-date auth store state by verifying and refreshing the loaded auth model (if any) if (pb.authStore.isValid) { - await pb.collection('users').authRefresh({requestKey: null}) + await pb.collection('users').authRefresh({ requestKey: null }) } } catch (_) { // clear the auth store on failed refresh @@ -78,7 +78,7 @@ const auth: Handle = async ({ event, resolve }) => { let settings: Settings | undefined; if (pb.authStore.model) { meiliApiKey = pb.authStore.model.token - settings = await pb.collection('settings').getFirstListItem(`user="${pb.authStore.model.id}"`, {requestKey: null}) + settings = await pb.collection('settings').getFirstListItem(`user="${pb.authStore.model.id}"`, { requestKey: null }) } else { const r = await event.fetch(pb.buildUrl("/public/search/token")); const response = await r.json(); @@ -105,7 +105,7 @@ const auth: Handle = async ({ event, resolve }) => { // send back the default 'pb_auth' cookie to the client with the latest store state response.headers.set( 'set-cookie', - pb.authStore.exportToCookie({ httpOnly: false, secure: event.url.protocol === "https:" }) + pb.authStore.exportToCookie({ httpOnly: false, secure: event.url.protocol === "https:" || event.url.hostname === "localhost", sameSite: "none" }) ) return response diff --git a/web/src/lib/components/base/toggle.svelte b/web/src/lib/components/base/toggle.svelte index ebb5c958..0bcce3db 100644 --- a/web/src/lib/components/base/toggle.svelte +++ b/web/src/lib/components/base/toggle.svelte @@ -1,11 +1,11 @@ - +
+ - - {error} - +

+ {error} +

+
diff --git a/web/src/lib/components/trail/trail_info_panel.svelte b/web/src/lib/components/trail/trail_info_panel.svelte index f6d5b8ed..341567f3 100644 --- a/web/src/lib/components/trail/trail_info_panel.svelte +++ b/web/src/lib/components/trail/trail_info_panel.svelte @@ -362,7 +362,7 @@ {:else} {/if} -

{$_("route")}

+

{$_("route", { values: { n: 2 } })}

{#if mode === "overview"}
+ +const IntegrationUpdateSchema = z.object({ + strava: StravaSchema.optional(), +}) satisfies ZodType> + +export { StravaSchema, IntegrationCreateSchema, IntegrationUpdateSchema } diff --git a/web/src/lib/models/integration.ts b/web/src/lib/models/integration.ts new file mode 100644 index 00000000..3a021cb1 --- /dev/null +++ b/web/src/lib/models/integration.ts @@ -0,0 +1,25 @@ + +interface BaseIntegration { + active: boolean +} + +interface StravaIntegration extends BaseIntegration { + clientId: string | number; + clientSecret: string; + routes: boolean; + activities: boolean; + accessToken?: string; + refreshToken?: string; + expiresAt?: number; +} + +export class Integration { + id?: string; + user: string; + strava?: StravaIntegration; + + constructor(user: string, strava?: StravaIntegration) { + this.user = user; + this.strava = strava; + } +} \ No newline at end of file diff --git a/web/src/lib/stores/integration_store.ts b/web/src/lib/stores/integration_store.ts new file mode 100644 index 00000000..794e5e1e --- /dev/null +++ b/web/src/lib/stores/integration_store.ts @@ -0,0 +1,74 @@ +import { Integration } from "$lib/models/integration"; +import { pb } from "$lib/pocketbase"; +import { APIError } from "$lib/util/api_util"; +import { type ListResult } from "pocketbase"; +import { writable, type Writable } from "svelte/store"; + +export const integrations: Writable = writable([]) + +export async function integrations_index(f: (url: RequestInfo | URL, config?: RequestInit) => Promise = fetch) { + let r = await f('/api/v1/integration' + new URLSearchParams({ + }), { + method: 'GET', + }) + + if (!r.ok) { + const response = await r.json(); + throw new APIError(r.status, response.message, response.detail) + } + + const fetchedIntegrations: ListResult = await r.json(); + + integrations.set(fetchedIntegrations.items); + + return fetchedIntegrations.items; +} + +export async function integrations_create(integration: Integration) { + if (!pb.authStore.model) { + throw new Error("Unauthenticated"); + } + + integration.user = pb.authStore.model!.id; + + let r = await fetch('/api/v1/integration', { + method: 'PUT', + body: JSON.stringify(integration), + }) + + if (!r.ok) { + const response = await r.json(); + throw new APIError(r.status, response.message, response.detail) + } + + const model: Integration = await r.json(); + + return model; +} + +export async function integrations_update(integration: Integration, f: (url: RequestInfo | URL, config?: RequestInit) => Promise = fetch) { + let r = await f('/api/v1/integration/' + integration.id, { + method: 'POST', + body: JSON.stringify(integration), + }) + + if (!r.ok) { + const response = await r.json(); + throw new APIError(r.status, response.message, response.detail) + } + + const model: Integration = await r.json(); + + return model; +} + +export async function integrations_delete(integration: Integration) { + const r = await fetch('/api/v1/integration/' + integration.id, { + method: 'DELETE', + }) + + if (!r.ok) { + const response = await r.json(); + throw new APIError(r.status, response.message, response.detail) + } +} \ No newline at end of file diff --git a/web/src/lib/util/api_util.ts b/web/src/lib/util/api_util.ts index 7ca5a526..71f56927 100644 --- a/web/src/lib/util/api_util.ts +++ b/web/src/lib/util/api_util.ts @@ -23,6 +23,7 @@ export enum Collection { categories = "categories", comments = "comments", follows = "follows", + integrations = "integrations", list_share = "list_share", lists = "lists", notifications = "notifications", diff --git a/web/src/routes/api/v1/integration/+server.ts b/web/src/routes/api/v1/integration/+server.ts new file mode 100644 index 00000000..5b7b3622 --- /dev/null +++ b/web/src/routes/api/v1/integration/+server.ts @@ -0,0 +1,23 @@ +import { IntegrationCreateSchema } from "$lib/models/api/integration_schema"; +import type { Integration } from "$lib/models/integration"; +import { Collection, create, handleError, list } from "$lib/util/api_util"; +import { json, type RequestEvent } from "@sveltejs/kit"; + +export async function GET(event: RequestEvent) { + try { + const r = await list(event, Collection.integrations); + + return json(r) + } catch (e) { + throw handleError(e) + } +} + +export async function PUT(event: RequestEvent) { + try { + const r = await create(event, IntegrationCreateSchema, Collection.integrations) + return json(r); + } catch (e) { + throw handleError(e) + } +} \ No newline at end of file diff --git a/web/src/routes/api/v1/integration/[id]/+server.ts b/web/src/routes/api/v1/integration/[id]/+server.ts new file mode 100644 index 00000000..8cfd7b26 --- /dev/null +++ b/web/src/routes/api/v1/integration/[id]/+server.ts @@ -0,0 +1,32 @@ +import { IntegrationUpdateSchema } from "$lib/models/api/integration_schema"; +import type { Integration } from "$lib/models/integration"; +import { pb } from "$lib/pocketbase"; +import { Collection, handleError, remove, show, update } from "$lib/util/api_util"; +import { json, type RequestEvent } from "@sveltejs/kit"; + +export async function GET(event: RequestEvent) { + try { + const r = await show(event, Collection.integrations) + return json(r) + } catch (e: any) { + throw handleError(e) + } +} + +export async function POST(event: RequestEvent) { + try { + const r = await update(event, IntegrationUpdateSchema, Collection.integrations) + return json(r); + } catch (e: any) { + throw handleError(e) + } +} + +export async function DELETE(event: RequestEvent) { + try { + const r = await remove(event, Collection.integrations) + return json(r); + } catch (e: any) { + throw handleError(e) + } +} diff --git a/web/src/routes/settings/+layout.svelte b/web/src/routes/settings/+layout.svelte index a9b07093..a64edb01 100644 --- a/web/src/routes/settings/+layout.svelte +++ b/web/src/routes/settings/+layout.svelte @@ -21,6 +21,7 @@ }, { text: $_("notifications"), value: "/settings/notifications" }, { text: $_("map"), value: "/settings/map" }, + { text: $_("integrations"), value: "/settings/integrations" }, { text: `${$_("import")}/${$_("export")}`, value: "/settings/export" }, { text: $_("help"), diff --git a/web/src/routes/settings/integrations/+page.svelte b/web/src/routes/settings/integrations/+page.svelte new file mode 100644 index 00000000..a0664434 --- /dev/null +++ b/web/src/routes/settings/integrations/+page.svelte @@ -0,0 +1,201 @@ + + + + {$_("settings")} | wanderer + + +

{$_("integrations")}

+
+ +
+
+ strava logo +
+
strava
+

+ Syncs your strava routes with wanderer in regular intervals. +

+
+
+ + +
+
+
+ + + {#snippet content()} +
+ + +
+ + +
+
+ {/snippet} + {#snippet footer()} +
+ + +
+ {/snippet}
diff --git a/web/src/routes/settings/integrations/+page.ts b/web/src/routes/settings/integrations/+page.ts new file mode 100644 index 00000000..866b53bb --- /dev/null +++ b/web/src/routes/settings/integrations/+page.ts @@ -0,0 +1,7 @@ +import { integrations_index } from "$lib/stores/integration_store"; +import { type Load } from "@sveltejs/kit"; + +export const load: Load = async ({ params, fetch }) => { + const integrations = await integrations_index(fetch) + return { integration: integrations.at(0) } +}; \ No newline at end of file diff --git a/web/src/routes/settings/integrations/callback/strava/+page.server.ts b/web/src/routes/settings/integrations/callback/strava/+page.server.ts new file mode 100644 index 00000000..c0a83641 --- /dev/null +++ b/web/src/routes/settings/integrations/callback/strava/+page.server.ts @@ -0,0 +1,55 @@ +import { integrations_index, integrations_update } from "$lib/stores/integration_store"; +import { error, redirect, type RequestEvent, type ServerLoad } from "@sveltejs/kit"; + +export const load: ServerLoad = async ({ url, fetch }) => { + const oauthError = url.searchParams.get('error'); + if (oauthError) { + return error(400, { + message: oauthError + }); + } + const code = url.searchParams.get('code'); + if (!code) { + return error(400, { + message: "No code provided" + }); + } + + const integrations = await integrations_index(fetch); + + 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', + 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