diff options
| -rw-r--r-- | .gitignore | 2 | ||||
| -rw-r--r-- | README.md | 6 | ||||
| -rw-r--r-- | config.example.yaml | 16 | ||||
| -rw-r--r-- | config/config.go | 120 | ||||
| -rw-r--r-- | config/config_test.go | 38 | ||||
| -rw-r--r-- | config/env.go | 52 | ||||
| -rw-r--r-- | go.mod | 2 | ||||
| -rw-r--r-- | main.go | 16 | ||||
| -rw-r--r-- | web/server.go | 85 | ||||
| -rw-r--r-- | web/server_test.go | 11 |
10 files changed, 246 insertions, 102 deletions
@@ -1,4 +1,4 @@ -.env +config.yaml .strava-last-sync *.db *.png @@ -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 -} @@ -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 @@ -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) { |