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}