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