config.go

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