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, displayHost, port, anonAccess)
101 if len(pks) == 0 {
102 yamlUsers = defaultUserConfig
103 } else {
104 var result string
105 for _, pk := range pks {
106 result += fmt.Sprintf(" - %s\n", pk)
107 }
108 yamlUsers = fmt.Sprintf(hasKeyUserConfig, result)
109 }
110 yaml := fmt.Sprintf("%s%s%s", yamlConfig, yamlUsers, exampleUserConfig)
111 err := c.createDefaultConfigRepo(yaml)
112 if err != nil {
113 return nil, err
114 }
115 return c, nil
116}
117
118// Reload reloads the configuration.
119func (cfg *Config) Reload() error {
120 cfg.mtx.Lock()
121 defer cfg.mtx.Unlock()
122 err := cfg.Source.LoadRepos()
123 if err != nil {
124 return err
125 }
126 cr, err := cfg.Source.GetRepo("config")
127 if err != nil {
128 return err
129 }
130 cs, _, err := cr.LatestFile("config.yaml")
131 if err != nil {
132 return err
133 }
134 err = yaml.Unmarshal([]byte(cs), cfg)
135 if err != nil {
136 return fmt.Errorf("bad yaml in config.yaml: %s", err)
137 }
138 for _, r := range cfg.Source.AllRepos() {
139 name := r.Name()
140 err = r.UpdateServerInfo()
141 if err != nil {
142 log.Printf("error updating server info for %s: %s", name, err)
143 }
144 pat := "README*"
145 rp := ""
146 for _, rr := range cfg.Repos {
147 if name == rr.Repo {
148 rp = rr.Readme
149 break
150 }
151 }
152 if rp != "" {
153 pat = rp
154 }
155 rm := ""
156 fc, fp, _ := r.LatestFile(pat)
157 rm = fc
158 if name == "config" {
159 md, err := templatize(rm, cfg)
160 if err != nil {
161 return err
162 }
163 rm = md
164 }
165 r.SetReadme(rm, fp)
166 }
167 return nil
168}
169
170func createFile(path string, content string) error {
171 f, err := os.Create(path)
172 if err != nil {
173 return err
174 }
175 defer f.Close()
176 _, err = f.WriteString(content)
177 if err != nil {
178 return err
179 }
180 return f.Sync()
181}
182
183func (cfg *Config) createDefaultConfigRepo(yaml string) error {
184 cn := "config"
185 rp := filepath.Join(cfg.Cfg.RepoPath, cn)
186 rs := cfg.Source
187 err := rs.LoadRepo(cn)
188 if os.IsNotExist(err) {
189 repo, err := ggit.PlainInit(rp, true)
190 if err != nil {
191 return err
192 }
193 repo, err = ggit.Clone(memory.NewStorage(), memfs.New(), &ggit.CloneOptions{
194 URL: rp,
195 })
196 if err != nil && err != transport.ErrEmptyRemoteRepository {
197 return err
198 }
199 wt, err := repo.Worktree()
200 if err != nil {
201 return err
202 }
203 rm, err := wt.Filesystem.Create("README.md")
204 if err != nil {
205 return err
206 }
207 _, err = rm.Write([]byte(defaultReadme))
208 if err != nil {
209 return err
210 }
211 _, err = wt.Add("README.md")
212 if err != nil {
213 return err
214 }
215 cf, err := wt.Filesystem.Create("config.yaml")
216 if err != nil {
217 return err
218 }
219 _, err = cf.Write([]byte(yaml))
220 if err != nil {
221 return err
222 }
223 _, err = wt.Add("config.yaml")
224 if err != nil {
225 return err
226 }
227 author := &object.Signature{
228 Name: "Soft Serve Server",
229 Email: "vt100@charm.sh",
230 When: time.Now(),
231 }
232 _, err = wt.Commit("Default init", &ggit.CommitOptions{
233 All: true,
234 Author: author,
235 })
236 if err != nil {
237 return err
238 }
239 err = repo.Push(&ggit.PushOptions{})
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}