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