config.go

  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}