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