1package config
2
3import (
4 "bytes"
5 "encoding/json"
6 "errors"
7 "io/fs"
8 "log"
9 "path/filepath"
10 "strings"
11 "sync"
12 "text/template"
13 "time"
14
15 "golang.org/x/crypto/ssh"
16 "gopkg.in/yaml.v3"
17
18 "fmt"
19 "os"
20
21 "github.com/charmbracelet/soft-serve/git"
22 "github.com/charmbracelet/soft-serve/server/config"
23 "github.com/go-git/go-billy/v5/memfs"
24 ggit "github.com/go-git/go-git/v5"
25 "github.com/go-git/go-git/v5/plumbing/object"
26 "github.com/go-git/go-git/v5/plumbing/transport"
27 "github.com/go-git/go-git/v5/storage/memory"
28)
29
30var (
31 // ErrNoConfig is returned when a repo has no config file.
32 ErrNoConfig = errors.New("no config file found")
33)
34
35// Config is the Soft Serve configuration.
36type Config struct {
37 Name string `yaml:"name" json:"name"`
38 Host string `yaml:"host" json:"host"`
39 Port int `yaml:"port" json:"port"`
40 AnonAccess string `yaml:"anon-access" json:"anon-access"`
41 AllowKeyless bool `yaml:"allow-keyless" json:"allow-keyless"`
42 Users []User `yaml:"users" json:"users"`
43 Repos []RepoConfig `yaml:"repos" json:"repos"`
44 Source *RepoSource `yaml:"-" json:"-"`
45 Cfg *config.Config `yaml:"-" json:"-"`
46 mtx sync.Mutex
47}
48
49// User contains user-level configuration for a repository.
50type User struct {
51 Name string `yaml:"name" json:"name"`
52 Admin bool `yaml:"admin" json:"admin"`
53 PublicKeys []string `yaml:"public-keys" json:"public-keys"`
54 CollabRepos []string `yaml:"collab-repos" json:"collab-repos"`
55}
56
57// RepoConfig is a repository configuration.
58type RepoConfig struct {
59 Name string `yaml:"name" json:"name"`
60 Repo string `yaml:"repo" json:"repo"`
61 Note string `yaml:"note" json:"note"`
62 Private bool `yaml:"private" json:"private"`
63 Readme string `yaml:"readme" json:"readme"`
64 Collabs []string `yaml:"collabs" json:"collabs"`
65}
66
67// NewConfig creates a new internal Config struct.
68func NewConfig(cfg *config.Config) (*Config, error) {
69 var anonAccess string
70 var yamlUsers string
71 var displayHost string
72 host := cfg.Host
73 port := cfg.Port
74
75 pks := make([]string, 0)
76 for _, k := range cfg.InitialAdminKeys {
77 if bts, err := os.ReadFile(k); err == nil {
78 // pk is a file, set its contents as pk
79 k = string(bts)
80 }
81 var pk = strings.TrimSpace(k)
82 if pk == "" {
83 continue
84 }
85 // it is a valid ssh key, nothing to do
86 if _, _, _, _, err := ssh.ParseAuthorizedKey([]byte(pk)); err != nil {
87 return nil, fmt.Errorf("invalid initial admin key %q: %w", k, err)
88 }
89 pks = append(pks, pk)
90 }
91
92 rs := NewRepoSource(cfg.RepoPath)
93 c := &Config{
94 Cfg: cfg,
95 }
96 c.Host = cfg.Host
97 c.Port = port
98 c.Source = rs
99 // Grant read-write access when no keys are provided.
100 if len(pks) == 0 {
101 anonAccess = "read-write"
102 } else {
103 anonAccess = "read-only"
104 }
105 if host == "" {
106 displayHost = "localhost"
107 } else {
108 displayHost = host
109 }
110 yamlConfig := fmt.Sprintf(defaultConfig,
111 displayHost,
112 port,
113 anonAccess,
114 len(pks) == 0,
115 )
116 if len(pks) == 0 {
117 yamlUsers = defaultUserConfig
118 } else {
119 var result string
120 for _, pk := range pks {
121 result += fmt.Sprintf(" - %s\n", pk)
122 }
123 yamlUsers = fmt.Sprintf(hasKeyUserConfig, result)
124 }
125 yaml := fmt.Sprintf("%s%s%s", yamlConfig, yamlUsers, exampleUserConfig)
126 err := c.createDefaultConfigRepo(yaml)
127 if err != nil {
128 return nil, err
129 }
130 return c, nil
131}
132
133// readConfig reads the config file for the repo. All config files are stored in
134// the config repo.
135func (cfg *Config) readConfig(repo string, v interface{}) error {
136 cr, err := cfg.Source.GetRepo("config")
137 if err != nil {
138 return err
139 }
140 // Parse YAML files
141 var cy string
142 for _, ext := range []string{".yaml", ".yml"} {
143 cy, _, err = cr.LatestFile(repo + ext)
144 if err != nil && !errors.Is(err, git.ErrFileNotFound) {
145 return err
146 } else if err == nil {
147 break
148 }
149 }
150 // Parse JSON files
151 cj, _, err := cr.LatestFile(repo + ".json")
152 if err != nil && !errors.Is(err, git.ErrFileNotFound) {
153 return err
154 }
155 if cy != "" {
156 err = yaml.Unmarshal([]byte(cy), v)
157 if err != nil {
158 return err
159 }
160 } else if cj != "" {
161 err = json.Unmarshal([]byte(cj), v)
162 if err != nil {
163 return err
164 }
165 } else {
166 return ErrNoConfig
167 }
168 return nil
169}
170
171// Reload reloads the configuration.
172func (cfg *Config) Reload() error {
173 cfg.mtx.Lock()
174 defer cfg.mtx.Unlock()
175 err := cfg.Source.LoadRepos()
176 if err != nil {
177 return err
178 }
179 if err := cfg.readConfig("config", cfg); err != nil {
180 return fmt.Errorf("error reading config: %w", err)
181 }
182 // sanitize repo configs
183 repos := make(map[string]RepoConfig, 0)
184 for _, r := range cfg.Repos {
185 repos[r.Repo] = r
186 }
187 for _, r := range cfg.Source.AllRepos() {
188 var rc RepoConfig
189 repo := r.Repo()
190 if repo == "config" {
191 continue
192 }
193 if err := cfg.readConfig(repo, &rc); err != nil {
194 if !errors.Is(err, ErrNoConfig) {
195 log.Printf("error reading config: %v", err)
196 }
197 continue
198 }
199 repos[r.Repo()] = rc
200 }
201 cfg.Repos = make([]RepoConfig, 0, len(repos))
202 for n, r := range repos {
203 r.Repo = n
204 cfg.Repos = append(cfg.Repos, r)
205 }
206 // Populate readmes and descriptions
207 for _, r := range cfg.Source.AllRepos() {
208 repo := r.Repo()
209 err = r.UpdateServerInfo()
210 if err != nil {
211 log.Printf("error updating server info for %s: %s", repo, err)
212 }
213 pat := "README*"
214 rp := ""
215 for _, rr := range cfg.Repos {
216 if repo == rr.Repo {
217 rp = rr.Readme
218 r.name = rr.Name
219 r.description = rr.Note
220 r.private = rr.Private
221 break
222 }
223 }
224 if rp != "" {
225 pat = rp
226 }
227 rm := ""
228 fc, fp, _ := r.LatestFile(pat)
229 rm = fc
230 if repo == "config" {
231 md, err := templatize(rm, cfg)
232 if err != nil {
233 return err
234 }
235 rm = md
236 }
237 r.SetReadme(rm, fp)
238 }
239 return nil
240}
241
242func createFile(path string, content string) error {
243 f, err := os.Create(path)
244 if err != nil {
245 return err
246 }
247 defer f.Close()
248 _, err = f.WriteString(content)
249 if err != nil {
250 return err
251 }
252 return f.Sync()
253}
254
255func (cfg *Config) createDefaultConfigRepo(yaml string) error {
256 cn := "config"
257 rp := filepath.Join(cfg.Cfg.RepoPath, cn)
258 rs := cfg.Source
259 err := rs.LoadRepo(cn)
260 if errors.Is(err, fs.ErrNotExist) {
261 repo, err := ggit.PlainInit(rp, true)
262 if err != nil {
263 return err
264 }
265 repo, err = ggit.Clone(memory.NewStorage(), memfs.New(), &ggit.CloneOptions{
266 URL: rp,
267 })
268 if err != nil && err != transport.ErrEmptyRemoteRepository {
269 return err
270 }
271 wt, err := repo.Worktree()
272 if err != nil {
273 return err
274 }
275 rm, err := wt.Filesystem.Create("README.md")
276 if err != nil {
277 return err
278 }
279 _, err = rm.Write([]byte(defaultReadme))
280 if err != nil {
281 return err
282 }
283 _, err = wt.Add("README.md")
284 if err != nil {
285 return err
286 }
287 cf, err := wt.Filesystem.Create("config.yaml")
288 if err != nil {
289 return err
290 }
291 _, err = cf.Write([]byte(yaml))
292 if err != nil {
293 return err
294 }
295 _, err = wt.Add("config.yaml")
296 if err != nil {
297 return err
298 }
299 author := object.Signature{
300 Name: "Soft Serve Server",
301 Email: "vt100@charm.sh",
302 When: time.Now(),
303 }
304 _, err = wt.Commit("Default init", &ggit.CommitOptions{
305 All: true,
306 Author: &author,
307 Committer: &author,
308 })
309 if err != nil {
310 return err
311 }
312 err = repo.Push(&ggit.PushOptions{})
313 if err != nil {
314 return err
315 }
316 } else if err != nil {
317 return err
318 }
319 return cfg.Reload()
320}
321
322func templatize(mdt string, tmpl interface{}) (string, error) {
323 t, err := template.New("readme").Parse(mdt)
324 if err != nil {
325 return "", err
326 }
327 buf := &bytes.Buffer{}
328 err = t.Execute(buf, tmpl)
329 if err != nil {
330 return "", err
331 }
332 return buf.String(), nil
333}