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