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