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