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