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