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