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