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