summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.gitignore2
-rw-r--r--README.md6
-rw-r--r--config.example.yaml16
-rw-r--r--config/config.go120
-rw-r--r--config/config_test.go38
-rw-r--r--config/env.go52
-rw-r--r--go.mod2
-rw-r--r--main.go16
-rw-r--r--web/server.go85
-rw-r--r--web/server_test.go11
10 files changed, 246 insertions, 102 deletions
diff --git a/.gitignore b/.gitignore
index b9d0e86..01a5307 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,4 +1,4 @@
-.env
+config.yaml
.strava-last-sync
*.db
*.png
diff --git a/README.md b/README.md
index f832251..a2a2889 100644
--- a/README.md
+++ b/README.md
@@ -63,6 +63,8 @@ go build -o biking_home .
| `-demo` | Run the climb-similarity demo | `false` |
| `-cpuprofile` | Write a CPU profile to file | `""` |
+Configuration is stored in `config.yaml`. Start from `config.example.yaml` and set the Strava credentials before launching the server.
+
## Strava API
The Strava v3 API (OAuth2) is the platform's public access point; scopes gate each resource (`read`, `activity:read_all`, `activity:write`, `push:subscriptions`). It offers:
@@ -83,7 +85,7 @@ Standard apps are rate-limited to 100 calls per 15 minutes and 1,000 per day.
- `osmpass` — OSM PBF extraction (`mountain_pass=yes` nodes) and pass coordinate enrichment
- `strava` — OAuth2 client returning activity metadata and GPX data
- `rides` — SQLite persistence for imported ride metadata
-- `config` — `.env` configuration persistence
+- `config` — typed YAML configuration and atomic persistence
- `web` — HTTP server, OAuth callback, sync orchestration, and templ pages
- **Notable choices** — the difficulty score follows the Cotacol method: the ride is split into fixed 100 m segments and each scores `distance_km × slope²`, so steep sections weigh exponentially more than long flat ones
@@ -98,7 +100,7 @@ flowchart TB
osmpass["osmpass (OSM PBF, enrichment)"]
strava["strava (OAuth, List, Get)"]
rides["rides (SQLite persistence)"]
- config["config (.env)"]
+ config["config (YAML)"]
web["web (HTTP + templ)"]
main --> ride
diff --git a/config.example.yaml b/config.example.yaml
new file mode 100644
index 0000000..74fd06e
--- /dev/null
+++ b/config.example.yaml
@@ -0,0 +1,16 @@
+database:
+ path: biking_home.db
+
+server:
+ address: 127.0.0.1:8080
+ public_url: http://localhost:8080
+
+storage:
+ gpx_dir: rides
+
+strava:
+ client_id: ""
+ client_secret: ""
+ access_token: ""
+ refresh_token: ""
+ expires_at: 0
diff --git a/config/config.go b/config/config.go
new file mode 100644
index 0000000..98ccebc
--- /dev/null
+++ b/config/config.go
@@ -0,0 +1,120 @@
+package config
+
+import (
+ "fmt"
+ "os"
+ "path/filepath"
+
+ "gopkg.in/yaml.v3"
+)
+
+type Config struct {
+ Database DatabaseConfig `yaml:"database"`
+ Server ServerConfig `yaml:"server"`
+ Storage StorageConfig `yaml:"storage"`
+ Strava StravaConfig `yaml:"strava"`
+}
+
+type DatabaseConfig struct {
+ Path string `yaml:"path"`
+}
+
+type ServerConfig struct {
+ Address string `yaml:"address"`
+ PublicURL string `yaml:"public_url"`
+}
+
+type StorageConfig struct {
+ GPXDir string `yaml:"gpx_dir"`
+}
+
+type StravaConfig struct {
+ ClientID string `yaml:"client_id"`
+ ClientSecret string `yaml:"client_secret"`
+ AccessToken string `yaml:"access_token"`
+ RefreshToken string `yaml:"refresh_token"`
+ ExpiresAt int64 `yaml:"expires_at"`
+}
+
+func Default() Config {
+ return Config{
+ Database: DatabaseConfig{Path: "biking_home.db"},
+ Server: ServerConfig{
+ Address: "127.0.0.1:8080",
+ PublicURL: "http://localhost:8080",
+ },
+ Storage: StorageConfig{GPXDir: "rides"},
+ }
+}
+
+func Load(filename string) (Config, error) {
+ data, err := os.ReadFile(filename)
+ if err != nil {
+ return Config{}, fmt.Errorf("read config %q: %w", filename, err)
+ }
+ config := Default()
+ if err := yaml.Unmarshal(data, &config); err != nil {
+ return Config{}, fmt.Errorf("parse config %q: %w", filename, err)
+ }
+ if config.Database.Path == "" {
+ return Config{}, fmt.Errorf("config %q: database.path is required", filename)
+ }
+ if config.Server.Address == "" || config.Server.PublicURL == "" {
+ return Config{}, fmt.Errorf("config %q: server.address and server.public_url are required", filename)
+ }
+ if config.Storage.GPXDir == "" {
+ return Config{}, fmt.Errorf("config %q: storage.gpx_dir is required", filename)
+ }
+ return config, nil
+}
+
+func Save(filename string, config Config) error {
+ data, err := yaml.Marshal(config)
+ if err != nil {
+ return fmt.Errorf("marshal config: %w", err)
+ }
+ directory := filepath.Dir(filename)
+ temporary, err := os.CreateTemp(directory, "."+filepath.Base(filename)+".tmp-*")
+ if err != nil {
+ return fmt.Errorf("create temporary config: %w", err)
+ }
+ temporaryName := temporary.Name()
+ removeTemporary := true
+ defer func() {
+ if removeTemporary {
+ _ = os.Remove(temporaryName)
+ }
+ }()
+ if err := temporary.Chmod(0o600); err != nil {
+ _ = temporary.Close()
+ return fmt.Errorf("set config permissions: %w", err)
+ }
+ if _, err := temporary.Write(data); err != nil {
+ _ = temporary.Close()
+ return fmt.Errorf("write temporary config: %w", err)
+ }
+ if err := temporary.Sync(); err != nil {
+ _ = temporary.Close()
+ return fmt.Errorf("sync temporary config: %w", err)
+ }
+ if err := temporary.Close(); err != nil {
+ return fmt.Errorf("close temporary config: %w", err)
+ }
+ if err := os.Rename(temporaryName, filename); err != nil {
+ return fmt.Errorf("replace config: %w", err)
+ }
+ removeTemporary = false
+ return syncDirectory(directory)
+}
+
+func syncDirectory(directory string) error {
+ dir, err := os.Open(directory)
+ if err != nil {
+ return fmt.Errorf("open config directory: %w", err)
+ }
+ defer dir.Close()
+ if err := dir.Sync(); err != nil {
+ return fmt.Errorf("sync config directory: %w", err)
+ }
+ return nil
+}
diff --git a/config/config_test.go b/config/config_test.go
new file mode 100644
index 0000000..6b99030
--- /dev/null
+++ b/config/config_test.go
@@ -0,0 +1,38 @@
+package config
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestSaveAndLoad(t *testing.T) {
+ filename := filepath.Join(t.TempDir(), "config.yaml")
+ want := Default()
+ want.Database.Path = "test.db"
+ want.Strava.ClientID = "123"
+ want.Strava.ExpiresAt = 1_700_000_000
+
+ require.NoError(t, Save(filename, want))
+ got, err := Load(filename)
+ require.NoError(t, err)
+ assert.Equal(t, want, got)
+ info, err := os.Stat(filename)
+ require.NoError(t, err)
+ assert.Equal(t, os.FileMode(0o600), info.Mode().Perm())
+}
+
+func TestSaveReplacesExistingConfigAtomically(t *testing.T) {
+ filename := filepath.Join(t.TempDir(), "config.yaml")
+ require.NoError(t, Save(filename, Default()))
+ updated := Default()
+ updated.Server.Address = "127.0.0.1:9090"
+ require.NoError(t, Save(filename, updated))
+
+ got, err := Load(filename)
+ require.NoError(t, err)
+ assert.Equal(t, "127.0.0.1:9090", got.Server.Address)
+}
diff --git a/config/env.go b/config/env.go
deleted file mode 100644
index b9d9f76..0000000
--- a/config/env.go
+++ /dev/null
@@ -1,52 +0,0 @@
-package config
-
-import (
- "fmt"
- "os"
- "strings"
-)
-
-func LoadEnv(filename string) (map[string]string, error) {
- data, err := os.ReadFile(filename)
- if err != nil {
- return nil, err
- }
- values := map[string]string{}
- for _, line := range strings.Split(string(data), "\n") {
- line = strings.TrimSpace(line)
- if line == "" || strings.HasPrefix(line, "#") {
- continue
- }
- key, value, found := strings.Cut(line, "=")
- if found {
- values[strings.TrimSpace(key)] = strings.Trim(strings.TrimSpace(value), `"`)
- }
- }
- return values, nil
-}
-
-func UpdateEnv(filename string, updates map[string]string) error {
- data, err := os.ReadFile(filename)
- if err != nil {
- return err
- }
- lines := strings.Split(string(data), "\n")
- for key, value := range updates {
- found := false
- for i, line := range lines {
- existingKey, _, cut := strings.Cut(strings.TrimSpace(line), "=")
- if cut && strings.TrimSpace(existingKey) == key {
- lines[i] = key + "=" + value
- found = true
- break
- }
- }
- if !found {
- lines = append(lines, key+"="+value)
- }
- }
- if err := os.WriteFile(filename, []byte(strings.Join(lines, "\n")+"\n"), 0o600); err != nil {
- return fmt.Errorf("write env file: %w", err)
- }
- return nil
-}
diff --git a/go.mod b/go.mod
index 2d4c4d4..f937343 100644
--- a/go.mod
+++ b/go.mod
@@ -16,6 +16,7 @@ require (
github.com/tkrajina/gpxgo v1.4.0
golang.org/x/text v0.23.0
gonum.org/v1/plot v0.16.0
+ gopkg.in/yaml.v3 v3.0.1
)
require (
@@ -57,7 +58,6 @@ require (
google.golang.org/protobuf v1.36.10 // indirect
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect
- gopkg.in/yaml.v3 v3.0.1 // indirect
)
tool github.com/amacneil/dbmate
diff --git a/main.go b/main.go
index 072941f..36b3d28 100644
--- a/main.go
+++ b/main.go
@@ -11,6 +11,7 @@ import (
"strings"
"time"
+ "github.com/martinlehoux/biking_home/config"
"github.com/martinlehoux/biking_home/mountain_pass"
"github.com/martinlehoux/biking_home/osmpass"
"github.com/martinlehoux/biking_home/ride"
@@ -29,12 +30,15 @@ var (
demo = flag.Bool("demo", false, "run the climb similarity demo")
chartFile = flag.String("chart", "", "render a climb/pass chart for a GPX file")
cpuprofile = flag.String("cpuprofile", "", "write cpu profile to file")
+ configFile = flag.String("config", "config.yaml", "path to the YAML configuration file")
parser = ride.GPXRideParser{}
)
func main() {
flag.Parse()
- db, err := sql.Open("sqlite3", "biking_home.db")
+ appConfig, err := config.Load(*configFile)
+ kcore.Expect(err, "failed to load configuration")
+ db, err := sql.Open("sqlite3", appConfig.Database.Path)
kcore.Expect(err, "failed to open database")
defer db.Close()
switch {
@@ -58,14 +62,14 @@ func main() {
case *chartFile != "":
runChart(db, *chartFile)
default:
- runServer(db)
+ runServer(db, *configFile, appConfig)
}
}
-func runServer(db *sql.DB) {
- server := web.NewServer(db, ".env", "rides", "http://localhost:8080")
- slog.Info("Starting web server", "address", "http://localhost:8080")
- kcore.Expect(server.ListenAndServe(":8080"), "web server stopped")
+func runServer(db *sql.DB, configPath string, appConfig config.Config) {
+ server := web.NewServer(db, configPath)
+ slog.Info("Starting web server", "address", appConfig.Server.PublicURL)
+ kcore.Expect(server.ListenAndServe(appConfig.Server.Address), "web server stopped")
}
func runChart(db *sql.DB, filename string) {
diff --git a/web/server.go b/web/server.go
index e394eec..10cf30e 100644
--- a/web/server.go
+++ b/web/server.go
@@ -26,18 +26,16 @@ import (
const dateFormat = "2006-01-02"
type Server struct {
- db *sql.DB
- envPath string
- gpxDir string
- baseURL string
+ db *sql.DB
+ configPath string
oauthMu sync.Mutex
oauthState string
returnToURL string
}
-func NewServer(db *sql.DB, envPath, gpxDir, baseURL string) *Server {
- return &Server{db: db, envPath: envPath, gpxDir: gpxDir, baseURL: strings.TrimRight(baseURL, "/")}
+func NewServer(db *sql.DB, configPath string) *Server {
+ return &Server{db: db, configPath: configPath}
}
func (s *Server) Handler() http.Handler {
@@ -109,7 +107,12 @@ func (s *Server) handleSync(w http.ResponseWriter, r *http.Request) {
s.renderSyncError(w, r, r.FormValue("from"), r.FormValue("to"), err)
return
}
- client, authorized, err := s.stravaClient()
+ appConfig, err := s.loadConfig()
+ if err != nil {
+ s.renderSyncError(w, r, r.FormValue("from"), r.FormValue("to"), err)
+ return
+ }
+ client, authorized, err := newStravaClient(appConfig)
if err != nil {
s.renderSyncError(w, r, r.FormValue("from"), r.FormValue("to"), err)
return
@@ -130,7 +133,7 @@ func (s *Server) handleSync(w http.ResponseWriter, r *http.Request) {
return
}
}
- imported, skipped, err := s.syncRides(client, from, to)
+ imported, skipped, err := s.syncRides(client, appConfig.Storage.GPXDir, from, to)
if err != nil {
s.renderSyncError(w, r, r.FormValue("from"), r.FormValue("to"), err)
return
@@ -144,13 +147,13 @@ func (s *Server) handleSync(w http.ResponseWriter, r *http.Request) {
}
func (s *Server) handleStravaLogin(w http.ResponseWriter, r *http.Request) {
- env, err := config.LoadEnv(s.envPath)
+ appConfig, err := s.loadConfig()
if err != nil {
http.Error(w, "failed to load configuration", http.StatusInternalServerError)
return
}
- clientID := env["STRAVA_CLIENT_ID"]
- clientSecret := env["STRAVA_CLIENT_SECRET"]
+ clientID := appConfig.Strava.ClientID
+ clientSecret := appConfig.Strava.ClientSecret
if clientID == "" || clientSecret == "" {
http.Error(w, "STRAVA_CLIENT_ID and STRAVA_CLIENT_SECRET are required", http.StatusInternalServerError)
return
@@ -165,7 +168,7 @@ func (s *Server) handleStravaLogin(w http.ResponseWriter, r *http.Request) {
s.oauthState = state
s.returnToURL = returnTo
s.oauthMu.Unlock()
- redirectURI := s.baseURL + "/strava/callback"
+ redirectURI := appConfig.Server.PublicURL + "/strava/callback"
http.Redirect(w, r, strava.AuthorizeURLWithState(clientID, redirectURI, state), http.StatusFound)
}
@@ -184,22 +187,21 @@ func (s *Server) handleStravaCallback(w http.ResponseWriter, r *http.Request) {
http.Error(w, "missing authorization code", http.StatusBadRequest)
return
}
- env, err := config.LoadEnv(s.envPath)
+ appConfig, err := s.loadConfig()
if err != nil {
http.Error(w, "failed to load configuration", http.StatusInternalServerError)
return
}
- redirectURI := s.baseURL + "/strava/callback"
- token, err := strava.ExchangeCode(env["STRAVA_CLIENT_ID"], env["STRAVA_CLIENT_SECRET"], code, redirectURI)
+ redirectURI := appConfig.Server.PublicURL + "/strava/callback"
+ token, err := strava.ExchangeCode(appConfig.Strava.ClientID, appConfig.Strava.ClientSecret, code, redirectURI)
if err != nil {
http.Error(w, "failed to exchange Strava authorization code", http.StatusBadGateway)
return
}
- if err := config.UpdateEnv(s.envPath, map[string]string{
- "STRAVA_ACCESS_TOKEN": token.AccessToken,
- "STRAVA_REFRESH_TOKEN": token.RefreshToken,
- "STRAVA_EXPIRES_AT": strconv.FormatInt(token.ExpiresAt.Unix(), 10),
- }); err != nil {
+ appConfig.Strava.AccessToken = token.AccessToken
+ appConfig.Strava.RefreshToken = token.RefreshToken
+ appConfig.Strava.ExpiresAt = token.ExpiresAt.Unix()
+ if err := config.Save(s.configPath, appConfig); err != nil {
http.Error(w, "failed to store Strava token", http.StatusInternalServerError)
return
}
@@ -207,12 +209,12 @@ func (s *Server) handleStravaCallback(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, returnTo, http.StatusSeeOther)
}
-func (s *Server) syncRides(client *strava.Client, from, to time.Time) (imported, skipped int, err error) {
+func (s *Server) syncRides(client *strava.Client, gpxDir string, from, to time.Time) (imported, skipped int, err error) {
activities, err := client.List(from, to)
if err != nil {
return 0, 0, err
}
- if err := os.MkdirAll(s.gpxDir, 0o755); err != nil {
+ if err := os.MkdirAll(gpxDir, 0o755); err != nil {
return 0, 0, fmt.Errorf("create GPX directory: %w", err)
}
for _, summary := range activities {
@@ -229,7 +231,7 @@ func (s *Server) syncRides(client *strava.Client, from, to time.Time) (imported,
if err != nil {
return imported, skipped, err
}
- gpxPath := filepath.Join(s.gpxDir, fmt.Sprintf("activity_%d.gpx", activity.ID))
+ gpxPath := filepath.Join(gpxDir, fmt.Sprintf("activity_%d.gpx", activity.ID))
if err := os.WriteFile(gpxPath, gpxData, 0o644); err != nil {
return imported, skipped, fmt.Errorf("write GPX for activity %d: %w", activity.ID, err)
}
@@ -258,34 +260,45 @@ func (s *Server) syncRides(client *strava.Client, from, to time.Time) (imported,
}
func (s *Server) stravaClient() (*strava.Client, bool, error) {
- env, err := config.LoadEnv(s.envPath)
+ appConfig, err := s.loadConfig()
if err != nil {
return nil, false, fmt.Errorf("load configuration: %w", err)
}
- if env["STRAVA_CLIENT_ID"] == "" || env["STRAVA_CLIENT_SECRET"] == "" {
+ return newStravaClient(appConfig)
+}
+
+func (s *Server) loadConfig() (config.Config, error) {
+ return config.Load(s.configPath)
+}
+
+func newStravaClient(appConfig config.Config) (*strava.Client, bool, error) {
+ if appConfig.Strava.ClientID == "" || appConfig.Strava.ClientSecret == "" {
return nil, false, errors.New("STRAVA_CLIENT_ID and STRAVA_CLIENT_SECRET are required")
}
- if env["STRAVA_ACCESS_TOKEN"] == "" || env["STRAVA_REFRESH_TOKEN"] == "" {
+ if appConfig.Strava.AccessToken == "" || appConfig.Strava.RefreshToken == "" {
return nil, false, nil
}
var expiresAt time.Time
- if unix, err := strconv.ParseInt(env["STRAVA_EXPIRES_AT"], 10, 64); err == nil && unix != 0 {
- expiresAt = time.Unix(unix, 0)
+ if appConfig.Strava.ExpiresAt != 0 {
+ expiresAt = time.Unix(appConfig.Strava.ExpiresAt, 0)
}
- return strava.NewClient(env["STRAVA_CLIENT_ID"], env["STRAVA_CLIENT_SECRET"], strava.Token{
- AccessToken: env["STRAVA_ACCESS_TOKEN"],
- RefreshToken: env["STRAVA_REFRESH_TOKEN"],
+ return strava.NewClient(appConfig.Strava.ClientID, appConfig.Strava.ClientSecret, strava.Token{
+ AccessToken: appConfig.Strava.AccessToken,
+ RefreshToken: appConfig.Strava.RefreshToken,
ExpiresAt: expiresAt,
}), true, nil
}
func (s *Server) saveStravaToken(client *strava.Client) error {
+ appConfig, err := s.loadConfig()
+ if err != nil {
+ return err
+ }
token := client.Tokens()
- return config.UpdateEnv(s.envPath, map[string]string{
- "STRAVA_ACCESS_TOKEN": token.AccessToken,
- "STRAVA_REFRESH_TOKEN": token.RefreshToken,
- "STRAVA_EXPIRES_AT": strconv.FormatInt(token.ExpiresAt.Unix(), 10),
- })
+ appConfig.Strava.AccessToken = token.AccessToken
+ appConfig.Strava.RefreshToken = token.RefreshToken
+ appConfig.Strava.ExpiresAt = token.ExpiresAt.Unix()
+ return config.Save(s.configPath, appConfig)
}
func (s *Server) hasStravaToken() bool {
diff --git a/web/server_test.go b/web/server_test.go
index 8d3f653..0324558 100644
--- a/web/server_test.go
+++ b/web/server_test.go
@@ -5,12 +5,12 @@ import (
"net/http"
"net/http/httptest"
"net/url"
- "os"
"path/filepath"
"strings"
"testing"
"time"
+ "github.com/martinlehoux/biking_home/config"
"github.com/martinlehoux/biking_home/rides"
_ "github.com/mattn/go-sqlite3"
"github.com/stretchr/testify/assert"
@@ -40,9 +40,12 @@ func newWebTestServer(t *testing.T) (*Server, *sql.DB) {
)
`)
require.NoError(t, err)
- envPath := filepath.Join(t.TempDir(), ".env")
- require.NoError(t, os.WriteFile(envPath, []byte("STRAVA_CLIENT_ID=123\nSTRAVA_CLIENT_SECRET=secret\n"), 0o600))
- return NewServer(db, envPath, t.TempDir(), "http://localhost:8080"), db
+ configPath := filepath.Join(t.TempDir(), "config.yaml")
+ appConfig := config.Default()
+ appConfig.Strava.ClientID = "123"
+ appConfig.Strava.ClientSecret = "secret"
+ require.NoError(t, config.Save(configPath, appConfig))
+ return NewServer(db, configPath), db
}
func TestHandlerRendersRidesPage(t *testing.T) {