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