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 gg "github.com/gogs/git-module"
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 mtx 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 err = c.Reload()
110 if err != nil {
111 return nil, err
112 }
113 return c, nil
114}
115
116// Reload reloads the configuration.
117func (cfg *Config) Reload() error {
118 cfg.mtx.Lock()
119 defer cfg.mtx.Unlock()
120 err := cfg.Source.LoadRepos()
121 if err != nil {
122 return err
123 }
124 cr, err := cfg.Source.GetRepo("config")
125 if err != nil {
126 return err
127 }
128 cs, _, err := cr.LatestFile("config.yaml")
129 if err != nil {
130 return err
131 }
132 err = yaml.Unmarshal([]byte(cs), cfg)
133 if err != nil {
134 return fmt.Errorf("bad yaml in config.yaml: %s", err)
135 }
136 for _, r := range cfg.Source.AllRepos() {
137 name := r.Name()
138 err = r.UpdateServerInfo()
139 if err != nil {
140 log.Printf("error updating server info for %s: %s", name, err)
141 }
142 pat := "README*"
143 rp := ""
144 for _, rr := range cfg.Repos {
145 if name == rr.Repo {
146 rp = rr.Readme
147 break
148 }
149 }
150 if rp != "" {
151 pat = rp
152 }
153 rm := ""
154 fc, fp, _ := r.LatestFile(pat)
155 rm = fc
156 if name == "config" {
157 md, err := templatize(rm, cfg)
158 if err != nil {
159 return err
160 }
161 rm = md
162 }
163 r.SetReadme(rm, fp)
164 }
165 return nil
166}
167
168func createFile(path string, content string) error {
169 f, err := os.Create(path)
170 if err != nil {
171 return err
172 }
173 defer f.Close()
174 _, err = f.WriteString(content)
175 if err != nil {
176 return err
177 }
178 return f.Sync()
179}
180
181func (cfg *Config) createDefaultConfigRepo(yaml string) error {
182 cn := "config"
183 rs := cfg.Source
184 err := rs.LoadRepo(cn)
185 if os.IsNotExist(err) {
186 repo, err := rs.InitRepo(cn, true)
187 if err != nil {
188 return err
189 }
190 wt := repo.Path()
191 defer os.RemoveAll(wt)
192 rm, err := os.Create(filepath.Join(wt, "README.md"))
193 if err != nil {
194 return err
195 }
196 _, err = rm.Write([]byte(defaultReadme))
197 if err != nil {
198 return err
199 }
200 cf, err := os.Create(filepath.Join(wt, "config.yaml"))
201 if err != nil {
202 return err
203 }
204 _, err = cf.Write([]byte(yaml))
205 if err != nil {
206 return err
207 }
208 err = gg.Add(wt, gg.AddOptions{All: true})
209 if err != nil {
210 return err
211 }
212 err = gg.CreateCommit(wt, &gg.Signature{
213 Name: "Soft Serve Server",
214 Email: "vt100@charm.sh",
215 }, "Default init")
216 if err != nil {
217 return err
218 }
219 err = repo.Push("origin", "master")
220 if err != nil {
221 return err
222 }
223 } else if err != nil {
224 return err
225 }
226 return nil
227}
228
229func (cfg *Config) isPrivate(repo string) bool {
230 for _, r := range cfg.Repos {
231 if r.Repo == repo {
232 return r.Private
233 }
234 }
235 return false
236}
237
238func templatize(mdt string, tmpl interface{}) (string, error) {
239 t, err := template.New("readme").Parse(mdt)
240 if err != nil {
241 return "", err
242 }
243 buf := &bytes.Buffer{}
244 err = t.Execute(buf, tmpl)
245 if err != nil {
246 return "", err
247 }
248 return buf.String(), nil
249}