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