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