aboutsummaryrefslogtreecommitdiff
path: root/config/config.go
blob: 8a61d43931a4e7ff0e538f32581bcf5db812aeeb (plain) (blame)
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
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148

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"`
	Pyroscope     PyroscopeConfig     `yaml:"pyroscope"`
}

type DatabaseConfig struct {
	Path string `yaml:"path"`
}

type ServerConfig struct {
	Address   string `yaml:"address"`
	PublicURL string `yaml:"public_url"`
}

type StorageConfig struct {
	RidesDir string `yaml:"rides_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"`
}

type PyroscopeConfig struct {
	Enabled         bool   `yaml:"enabled"`
	ApplicationName string `yaml:"application_name"`
	ServerAddress   string `yaml:"server_address"`
}

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{RidesDir: "data/rides"},
		OfficialClimb: OfficialClimbConfig{MatchRadiusM: official_climb.DefaultMatchRadiusM},
		Pyroscope: PyroscopeConfig{
			ApplicationName: "biking_home",
			ServerAddress:   "http://localhost:4040",
		},
	}
}

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.RidesDir == "" {
		return Config{}, fmt.Errorf("config %q: storage.rides_dir is required", filename)
	}
	if config.Pyroscope.Enabled && config.Pyroscope.ApplicationName == "" {
		return Config{}, fmt.Errorf("config %q: pyroscope.application_name is required when pyroscope is enabled", filename)
	}
	if config.Pyroscope.Enabled && config.Pyroscope.ServerAddress == "" {
		return Config{}, fmt.Errorf("config %q: pyroscope.server_address is required when pyroscope is enabled", 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
}