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 if bts, err := os.ReadFile(k); err == nil {
64 // pk is a file, set its contents as pk
65 k = string(bts)
66 }
67 var pk = strings.TrimSpace(k)
68 if pk == "" {
69 continue
70 }
71 // it is a valid ssh key, nothing to do
72 if _, _, _, _, err := ssh.ParseAuthorizedKey([]byte(pk)); err != nil {
73 return nil, fmt.Errorf("invalid initial admin key %q: %w", k, err)
74 }
75 pks = append(pks, pk)
76 }
77
78 rs := git.NewRepoSource(cfg.RepoPath)
79 c := &Config{
80 Cfg: cfg,
81 }
82 c.Host = cfg.Host
83 c.Port = port
84 c.Source = rs
85 if len(pks) == 0 {
86 anonAccess = "read-write"
87 } else {
88 anonAccess = "no-access"
89 }
90 if host == "" {
91 displayHost = "localhost"
92 } else {
93 displayHost = host
94 }
95 yamlConfig := fmt.Sprintf(defaultConfig, displayHost, port, anonAccess)
96 if len(pks) == 0 {
97 yamlUsers = defaultUserConfig
98 } else {
99 var result string
100 for _, pk := range pks {
101 result += fmt.Sprintf(" - %s\n", pk)
102 }
103 yamlUsers = fmt.Sprintf(hasKeyUserConfig, result)
104 }
105 yaml := fmt.Sprintf("%s%s%s", yamlConfig, yamlUsers, exampleUserConfig)
106 err := c.createDefaultConfigRepo(yaml)
107 if err != nil {
108 return nil, err
109 }
110 err = c.Reload()
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 rs := cfg.Source
185 err := rs.LoadRepo(cn)
186 if os.IsNotExist(err) {
187 repo, err := rs.InitRepo(cn, true)
188 if err != nil {
189 return err
190 }
191 wt := repo.Path()
192 defer os.RemoveAll(wt)
193 rm, err := os.Create(filepath.Join(wt, "README.md"))
194 if err != nil {
195 return err
196 }
197 _, err = rm.Write([]byte(defaultReadme))
198 if err != nil {
199 return err
200 }
201 cf, err := os.Create(filepath.Join(wt, "config.yaml"))
202 if err != nil {
203 return err
204 }
205 _, err = cf.Write([]byte(yaml))
206 if err != nil {
207 return err
208 }
209 err = gg.Add(wt, gg.AddOptions{All: true})
210 if err != nil {
211 return err
212 }
213 err = gg.CreateCommit(wt, &gg.Signature{
214 Name: "Soft Serve Server",
215 Email: "vt100@charm.sh",
216 }, "Default init")
217 if err != nil {
218 return err
219 }
220 err = repo.Push("origin", "master")
221 if err != nil {
222 return err
223 }
224 } else if err != nil {
225 return err
226 }
227 return nil
228}
229
230func (cfg *Config) isPrivate(repo string) bool {
231 for _, r := range cfg.Repos {
232 if r.Repo == repo {
233 return r.Private
234 }
235 }
236 return false
237}
238
239func templatize(mdt string, tmpl interface{}) (string, error) {
240 t, err := template.New("readme").Parse(mdt)
241 if err != nil {
242 return "", err
243 }
244 buf := &bytes.Buffer{}
245 err = t.Execute(buf, tmpl)
246 if err != nil {
247 return "", err
248 }
249 return buf.String(), nil
250}