1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
|
package config
import (
"fmt"
"math"
"os"
"path/filepath"
"github.com/martinlehoux/biking_home/official_climb"
"gopkg.in/yaml.v3"
)
type Config struct {
Database DatabaseConfig `yaml:"database"`
Server ServerConfig `yaml:"server"`
Storage StorageConfig `yaml:"storage"`
Strava StravaConfig `yaml:"strava"`
OfficialClimb OfficialClimbConfig `yaml:"official_climb"`
}
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"`
}
type OfficialClimbConfig struct {
MatchRadiusM float64 `yaml:"match_radius_m"`
}
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: "data/rides"},
OfficialClimb: OfficialClimbConfig{MatchRadiusM: official_climb.DefaultMatchRadiusM},
}
}
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)
}
if math.IsNaN(config.OfficialClimb.MatchRadiusM) || math.IsInf(config.OfficialClimb.MatchRadiusM, 0) || config.OfficialClimb.MatchRadiusM <= 0 {
return Config{}, fmt.Errorf("config %q: official_climb.match_radius_m must be greater than zero", 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
}
|