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