config.go

  1package config
  2
  3import (
  4	"bytes"
  5	"strings"
  6	"sync"
  7	"text/template"
  8
  9	"golang.org/x/crypto/ssh"
 10	"gopkg.in/yaml.v2"
 11
 12	"fmt"
 13	"os"
 14
 15	"github.com/charmbracelet/soft-serve/config"
 16	"github.com/charmbracelet/soft-serve/internal/git"
 17	gg "github.com/go-git/go-git/v5"
 18	"github.com/go-git/go-git/v5/plumbing/object"
 19)
 20
 21// Config is the Soft Serve configuration.
 22type Config struct {
 23	Name         string `yaml:"name"`
 24	Host         string `yaml:"host"`
 25	Port         int    `yaml:"port"`
 26	AnonAccess   string `yaml:"anon-access"`
 27	AllowKeyless bool   `yaml:"allow-keyless"`
 28	Users        []User `yaml:"users"`
 29	Repos        []Repo `yaml:"repos"`
 30	Source       *git.RepoSource
 31	Cfg          *config.Config
 32	reloadMtx    sync.Mutex
 33}
 34
 35// User contains user-level configuration for a repository.
 36type User struct {
 37	Name        string   `yaml:"name"`
 38	Admin       bool     `yaml:"admin"`
 39	PublicKeys  []string `yaml:"public-keys"`
 40	CollabRepos []string `yaml:"collab-repos"`
 41}
 42
 43// Repo contains repository configuration information.
 44type Repo struct {
 45	Name    string `yaml:"name"`
 46	Repo    string `yaml:"repo"`
 47	Note    string `yaml:"note"`
 48	Private bool   `yaml:"private"`
 49	Readme  string `yaml:"readme"`
 50}
 51
 52// NewConfig creates a new internal Config struct.
 53func NewConfig(cfg *config.Config) (*Config, error) {
 54	var anonAccess string
 55	var yamlUsers string
 56	var displayHost string
 57	host := cfg.Host
 58	port := cfg.Port
 59
 60	pks := make([]string, 0)
 61	for _, k := range cfg.InitialAdminKeys {
 62		var pk = strings.TrimSpace(k)
 63		if pk != "" {
 64			if bts, err := os.ReadFile(k); err == nil {
 65				// pk is a file, set its contents as pk
 66				pk = string(bts)
 67			}
 68			// it is a valid ssh key, nothing to do
 69			if _, _, _, _, err := ssh.ParseAuthorizedKey([]byte(pk)); err != nil {
 70				return nil, fmt.Errorf("invalid initial admin key %q: %w", k, err)
 71			}
 72			pks = append(pks, pk)
 73		}
 74	}
 75
 76	rs := git.NewRepoSource(cfg.RepoPath)
 77	c := &Config{
 78		Cfg: cfg,
 79	}
 80	c.Host = cfg.Host
 81	c.Port = port
 82	c.Source = rs
 83	if len(pks) == 0 {
 84		anonAccess = "read-write"
 85	} else {
 86		anonAccess = "no-access"
 87	}
 88	if host == "" {
 89		displayHost = "localhost"
 90	} else {
 91		displayHost = host
 92	}
 93	yamlConfig := fmt.Sprintf(defaultConfig, displayHost, port, anonAccess)
 94	if len(pks) == 0 {
 95		yamlUsers = defaultUserConfig
 96	} else {
 97		var result string
 98		for _, pk := range pks {
 99			result += fmt.Sprintf("      - %s\n", pk)
100		}
101		yamlUsers = fmt.Sprintf(hasKeyUserConfig, result)
102	}
103	yaml := fmt.Sprintf("%s%s%s", yamlConfig, yamlUsers, exampleUserConfig)
104	err := c.createDefaultConfigRepo(yaml)
105	if err != nil {
106		return nil, err
107	}
108	return c, nil
109}
110
111// Reload reloads the configuration.
112func (cfg *Config) Reload() error {
113	cfg.reloadMtx.Lock()
114	defer cfg.reloadMtx.Unlock()
115	err := cfg.Source.LoadRepos()
116	if err != nil {
117		return err
118	}
119	cr, err := cfg.Source.GetRepo("config")
120	if err != nil {
121		return err
122	}
123	cs, err := cr.LatestFile("config.yaml")
124	if err != nil {
125		return err
126	}
127	err = yaml.Unmarshal([]byte(cs), cfg)
128	if err != nil {
129		return fmt.Errorf("bad yaml in config.yaml: %s", err)
130	}
131	for _, r := range cfg.Source.AllRepos() {
132		name := r.Name()
133		pat := "README*"
134		rp := ""
135		for _, rr := range cfg.Repos {
136			if name == rr.Repo {
137				rp = rr.Readme
138				break
139			}
140		}
141		if rp != "" {
142			pat = rp
143		}
144		rm := ""
145		f, err := r.FindLatestFile(pat)
146		if err != nil && err != object.ErrFileNotFound {
147			return err
148		}
149		if err == nil {
150			fc, err := f.Contents()
151			if err != nil {
152				return err
153			}
154			rm = fc
155			r.ReadmePath = f.Name
156		}
157		if name == "config" {
158			md, err := templatize(rm, cfg)
159			if err != nil {
160				return err
161			}
162			rm = md
163		}
164		r.Readme = rm
165	}
166	return nil
167}
168
169func createFile(path string, content string) error {
170	f, err := os.Create(path)
171	if err != nil {
172		return err
173	}
174	defer f.Close()
175	_, err = f.WriteString(content)
176	if err != nil {
177		return err
178	}
179	return f.Sync()
180}
181
182func (cfg *Config) createDefaultConfigRepo(yaml string) error {
183	cn := "config"
184	rs := cfg.Source
185	err := rs.LoadRepos()
186	if err != nil {
187		return err
188	}
189	r, err := rs.GetRepo(cn)
190	if err == git.ErrMissingRepo {
191		cr, err := rs.InitRepo(cn, true)
192		if err != nil {
193			return err
194		}
195		wt, err := cr.Repository().Worktree()
196		if err != nil {
197			return err
198		}
199		rm, err := wt.Filesystem.Create("README.md")
200		if err != nil {
201			return err
202		}
203		_, err = rm.Write([]byte(defaultReadme))
204		if err != nil {
205			return err
206		}
207		cf, err := wt.Filesystem.Create("config.yaml")
208		if err != nil {
209			return err
210		}
211		_, err = cf.Write([]byte(yaml))
212		if err != nil {
213			return err
214		}
215		_, err = wt.Add("README.md")
216		if err != nil {
217			return err
218		}
219		_, err = wt.Add("config.yaml")
220		if err != nil {
221			return err
222		}
223		_, err = wt.Commit("Default init", &gg.CommitOptions{
224			All: true,
225			Author: &object.Signature{
226				Name:  "Soft Serve Server",
227				Email: "vt100@charm.sh",
228			},
229		})
230		if err != nil {
231			return err
232		}
233		err = cr.Repository().Push(&gg.PushOptions{})
234		if err != nil {
235			return err
236		}
237		cmd := exec.Command("git", "update-server-info")
238		cmd.Dir = filepath.Join(rs.Path, cn)
239		err = cmd.Run()
240		if err != nil {
241			return err
242		}
243	} else if err != nil {
244		return err
245	}
246	return cfg.Reload()
247}
248
249func (cfg *Config) isPrivate(repo string) bool {
250	for _, r := range cfg.Repos {
251		if r.Repo == repo {
252			return r.Private
253		}
254	}
255	return false
256}
257
258func templatize(mdt string, tmpl interface{}) (string, error) {
259	t, err := template.New("readme").Parse(mdt)
260	if err != nil {
261		return "", err
262	}
263	buf := &bytes.Buffer{}
264	err = t.Execute(buf, tmpl)
265	if err != nil {
266		return "", err
267	}
268	return buf.String(), nil
269}