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