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