config.go

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