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