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