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	if len(pks) == 0 {
100		anonAccess = "read-write"
101	} else {
102		anonAccess = "no-access"
103	}
104	if host == "" {
105		displayHost = "localhost"
106	} else {
107		displayHost = host
108	}
109	yamlConfig := fmt.Sprintf(defaultConfig,
110		displayHost,
111		port,
112		anonAccess,
113		len(pks) == 0,
114	)
115	if len(pks) == 0 {
116		yamlUsers = defaultUserConfig
117	} else {
118		var result string
119		for _, pk := range pks {
120			result += fmt.Sprintf("      - %s\n", pk)
121		}
122		yamlUsers = fmt.Sprintf(hasKeyUserConfig, result)
123	}
124	yaml := fmt.Sprintf("%s%s%s", yamlConfig, yamlUsers, exampleUserConfig)
125	err := c.createDefaultConfigRepo(yaml)
126	if err != nil {
127		return nil, err
128	}
129	return c, nil
130}
131
132// readConfig reads the config file for the repo. All config files are stored in
133// the config repo.
134func (cfg *Config) readConfig(repo string, v interface{}) error {
135	cr, err := cfg.Source.GetRepo("config")
136	if err != nil {
137		return err
138	}
139	// Parse YAML files
140	var cy string
141	for _, ext := range []string{".yaml", ".yml"} {
142		cy, _, err = cr.LatestFile(repo + ext)
143		if err != nil && !errors.Is(err, git.ErrFileNotFound) {
144			return err
145		} else if err == nil {
146			break
147		}
148	}
149	// Parse JSON files
150	cj, _, err := cr.LatestFile(repo + ".json")
151	if err != nil && !errors.Is(err, git.ErrFileNotFound) {
152		return err
153	}
154	if cy != "" {
155		err = yaml.Unmarshal([]byte(cy), v)
156		if err != nil {
157			return err
158		}
159	} else if cj != "" {
160		err = json.Unmarshal([]byte(cj), v)
161		if err != nil {
162			return err
163		}
164	} else {
165		return ErrNoConfig
166	}
167	return nil
168}
169
170// Reload reloads the configuration.
171func (cfg *Config) Reload() error {
172	cfg.mtx.Lock()
173	defer cfg.mtx.Unlock()
174	err := cfg.Source.LoadRepos()
175	if err != nil {
176		return err
177	}
178	if err := cfg.readConfig("config", cfg); err != nil {
179		return fmt.Errorf("error reading config: %w", err)
180	}
181	// sanitize repo configs
182	repos := make(map[string]RepoConfig, 0)
183	for _, r := range cfg.Repos {
184		repos[r.Repo] = r
185	}
186	for _, r := range cfg.Source.AllRepos() {
187		var rc RepoConfig
188		repo := r.Repo()
189		if repo == "config" {
190			continue
191		}
192		if err := cfg.readConfig(repo, &rc); err != nil {
193			if !errors.Is(err, ErrNoConfig) {
194				log.Printf("error reading config: %v", err)
195			}
196			continue
197		}
198		repos[r.Repo()] = rc
199	}
200	cfg.Repos = make([]RepoConfig, 0, len(repos))
201	for n, r := range repos {
202		r.Repo = n
203		cfg.Repos = append(cfg.Repos, r)
204	}
205	// Populate readmes and descriptions
206	for _, r := range cfg.Source.AllRepos() {
207		repo := r.Repo()
208		err = r.UpdateServerInfo()
209		if err != nil {
210			log.Printf("error updating server info for %s: %s", repo, err)
211		}
212		pat := "README*"
213		rp := ""
214		for _, rr := range cfg.Repos {
215			if repo == rr.Repo {
216				rp = rr.Readme
217				r.name = rr.Name
218				r.description = rr.Note
219				r.private = rr.Private
220				break
221			}
222		}
223		if rp != "" {
224			pat = rp
225		}
226		rm := ""
227		fc, fp, _ := r.LatestFile(pat)
228		rm = fc
229		if repo == "config" {
230			md, err := templatize(rm, cfg)
231			if err != nil {
232				return err
233			}
234			rm = md
235		}
236		r.SetReadme(rm, fp)
237	}
238	return nil
239}
240
241func createFile(path string, content string) error {
242	f, err := os.Create(path)
243	if err != nil {
244		return err
245	}
246	defer f.Close()
247	_, err = f.WriteString(content)
248	if err != nil {
249		return err
250	}
251	return f.Sync()
252}
253
254func (cfg *Config) createDefaultConfigRepo(yaml string) error {
255	cn := "config"
256	rp := filepath.Join(cfg.Cfg.RepoPath, cn)
257	rs := cfg.Source
258	err := rs.LoadRepo(cn)
259	if errors.Is(err, fs.ErrNotExist) {
260		repo, err := ggit.PlainInit(rp, true)
261		if err != nil {
262			return err
263		}
264		repo, err = ggit.Clone(memory.NewStorage(), memfs.New(), &ggit.CloneOptions{
265			URL: rp,
266		})
267		if err != nil && err != transport.ErrEmptyRemoteRepository {
268			return err
269		}
270		wt, err := repo.Worktree()
271		if err != nil {
272			return err
273		}
274		rm, err := wt.Filesystem.Create("README.md")
275		if err != nil {
276			return err
277		}
278		_, err = rm.Write([]byte(defaultReadme))
279		if err != nil {
280			return err
281		}
282		_, err = wt.Add("README.md")
283		if err != nil {
284			return err
285		}
286		cf, err := wt.Filesystem.Create("config.yaml")
287		if err != nil {
288			return err
289		}
290		_, err = cf.Write([]byte(yaml))
291		if err != nil {
292			return err
293		}
294		_, err = wt.Add("config.yaml")
295		if err != nil {
296			return err
297		}
298		author := object.Signature{
299			Name:  "Soft Serve Server",
300			Email: "vt100@charm.sh",
301			When:  time.Now(),
302		}
303		_, err = wt.Commit("Default init", &ggit.CommitOptions{
304			All:       true,
305			Author:    &author,
306			Committer: &author,
307		})
308		if err != nil {
309			return err
310		}
311		err = repo.Push(&ggit.PushOptions{})
312		if err != nil {
313			return err
314		}
315	} else if err != nil {
316		return err
317	}
318	return cfg.Reload()
319}
320
321func templatize(mdt string, tmpl interface{}) (string, error) {
322	t, err := template.New("readme").Parse(mdt)
323	if err != nil {
324		return "", err
325	}
326	buf := &bytes.Buffer{}
327	err = t.Execute(buf, tmpl)
328	if err != nil {
329		return "", err
330	}
331	return buf.String(), nil
332}