config.go

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