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