1package main
2
3import (
4 "encoding/json"
5 "errors"
6 "fmt"
7 "io"
8 "os"
9 "path/filepath"
10 "strings"
11 "time"
12
13 "github.com/charmbracelet/log"
14 "github.com/charmbracelet/soft-serve/git"
15 "github.com/charmbracelet/soft-serve/server/backend"
16 "github.com/charmbracelet/soft-serve/server/backend/sqlite"
17 "github.com/charmbracelet/soft-serve/server/config"
18 "github.com/charmbracelet/soft-serve/server/utils"
19 gitm "github.com/gogs/git-module"
20 "github.com/spf13/cobra"
21 "golang.org/x/crypto/ssh"
22 "gopkg.in/yaml.v3"
23)
24
25var (
26 migrateConfig = &cobra.Command{
27 Use: "migrate-config",
28 Short: "Migrate config to new format",
29 Hidden: true,
30 RunE: func(cmd *cobra.Command, _ []string) error {
31 ctx := cmd.Context()
32
33 logger := log.FromContext(ctx)
34 // Disable logging timestamp
35 logger.SetReportTimestamp(false)
36
37 keyPath := os.Getenv("SOFT_SERVE_KEY_PATH")
38 reposPath := os.Getenv("SOFT_SERVE_REPO_PATH")
39 bindAddr := os.Getenv("SOFT_SERVE_BIND_ADDRESS")
40 cfg := config.DefaultConfig()
41 ctx = config.WithContext(ctx, cfg)
42 sb, err := sqlite.NewSqliteBackend(ctx)
43 if err != nil {
44 return fmt.Errorf("failed to create sqlite backend: %w", err)
45 }
46
47 cfg = cfg.WithBackend(sb)
48
49 // Set SSH listen address
50 logger.Info("Setting SSH listen address...")
51 if bindAddr != "" {
52 cfg.SSH.ListenAddr = bindAddr
53 }
54
55 // Copy SSH host key
56 logger.Info("Copying SSH host key...")
57 if keyPath != "" {
58 if err := os.MkdirAll(filepath.Join(cfg.DataPath, "ssh"), os.ModePerm); err != nil {
59 return fmt.Errorf("failed to create ssh directory: %w", err)
60 }
61
62 if err := copyFile(keyPath, filepath.Join(cfg.DataPath, "ssh", filepath.Base(keyPath))); err != nil {
63 return fmt.Errorf("failed to copy ssh key: %w", err)
64 }
65
66 if err := copyFile(keyPath+".pub", filepath.Join(cfg.DataPath, "ssh", filepath.Base(keyPath))+".pub"); err != nil {
67 logger.Errorf("failed to copy ssh key: %s", err)
68 }
69
70 cfg.SSH.KeyPath = filepath.Join(cfg.DataPath, "ssh", filepath.Base(keyPath))
71 }
72
73 // Read config
74 logger.Info("Reading config repository...")
75 r, err := git.Open(filepath.Join(reposPath, "config"))
76 if err != nil {
77 return fmt.Errorf("failed to open config repo: %w", err)
78 }
79
80 head, err := r.HEAD()
81 if err != nil {
82 return fmt.Errorf("failed to get head: %w", err)
83 }
84
85 tree, err := r.TreePath(head, "")
86 if err != nil {
87 return fmt.Errorf("failed to get tree: %w", err)
88 }
89
90 isJson := false // nolint: revive
91 te, err := tree.TreeEntry("config.yaml")
92 if err != nil {
93 te, err = tree.TreeEntry("config.json")
94 if err != nil {
95 return fmt.Errorf("failed to get config file: %w", err)
96 }
97 isJson = true
98 }
99
100 cc, err := te.Contents()
101 if err != nil {
102 return fmt.Errorf("failed to get config contents: %w", err)
103 }
104
105 var ocfg Config
106 if isJson {
107 if err := json.Unmarshal(cc, &ocfg); err != nil {
108 return fmt.Errorf("failed to unmarshal config: %w", err)
109 }
110 } else {
111 if err := yaml.Unmarshal(cc, &ocfg); err != nil {
112 return fmt.Errorf("failed to unmarshal config: %w", err)
113 }
114 }
115
116 readme, readmePath, err := git.LatestFile(r, "README*")
117 hasReadme := err == nil
118
119 // Set server name
120 cfg.Name = ocfg.Name
121
122 // Set server public url
123 cfg.SSH.PublicURL = fmt.Sprintf("ssh://%s:%d", ocfg.Host, ocfg.Port)
124
125 // Set server settings
126 logger.Info("Setting server settings...")
127 if cfg.Backend.SetAllowKeyless(ocfg.AllowKeyless) != nil {
128 fmt.Fprintf(os.Stderr, "failed to set allow keyless\n")
129 }
130 anon := backend.ParseAccessLevel(ocfg.AnonAccess)
131 if anon >= 0 {
132 if err := sb.SetAnonAccess(anon); err != nil {
133 fmt.Fprintf(os.Stderr, "failed to set anon access: %s\n", err)
134 }
135 }
136
137 // Copy repos
138 if reposPath != "" {
139 logger.Info("Copying repos...")
140 if err := os.MkdirAll(filepath.Join(cfg.DataPath, "repos"), os.ModePerm); err != nil {
141 return fmt.Errorf("failed to create repos directory: %w", err)
142 }
143
144 dirs, err := os.ReadDir(reposPath)
145 if err != nil {
146 return fmt.Errorf("failed to read repos directory: %w", err)
147 }
148
149 for _, dir := range dirs {
150 if !dir.IsDir() || dir.Name() == "config" {
151 continue
152 }
153
154 if !isGitDir(filepath.Join(reposPath, dir.Name())) {
155 continue
156 }
157
158 logger.Infof(" Copying repo %s", dir.Name())
159 src := filepath.Join(reposPath, utils.SanitizeRepo(dir.Name()))
160 dst := filepath.Join(cfg.DataPath, "repos", utils.SanitizeRepo(dir.Name())) + ".git"
161 if err := os.MkdirAll(dst, os.ModePerm); err != nil {
162 return fmt.Errorf("failed to create repo directory: %w", err)
163 }
164
165 if err := copyDir(src, dst); err != nil {
166 return fmt.Errorf("failed to copy repo: %w", err)
167 }
168
169 if _, err := sb.CreateRepository(dir.Name(), backend.RepositoryOptions{}); err != nil {
170 fmt.Fprintf(os.Stderr, "failed to create repository: %s\n", err)
171 }
172 }
173
174 if hasReadme {
175 logger.Infof(" Copying readme from \"config\" to \".soft-serve\"")
176
177 // Switch to main branch
178 bcmd := git.NewCommand("branch", "-M", "main")
179
180 rp := filepath.Join(cfg.DataPath, "repos", ".soft-serve.git")
181 nr, err := git.Init(rp, true)
182 if err != nil {
183 return fmt.Errorf("failed to init repo: %w", err)
184 }
185
186 if _, err := nr.SymbolicRef("HEAD", gitm.RefsHeads+"main"); err != nil {
187 return fmt.Errorf("failed to set HEAD: %w", err)
188 }
189
190 tmpDir, err := os.MkdirTemp("", "soft-serve")
191 if err != nil {
192 return fmt.Errorf("failed to create temp dir: %w", err)
193 }
194
195 r, err := git.Init(tmpDir, false)
196 if err != nil {
197 return fmt.Errorf("failed to clone repo: %w", err)
198 }
199
200 if _, err := bcmd.RunInDir(tmpDir); err != nil {
201 return fmt.Errorf("failed to create main branch: %w", err)
202 }
203
204 if err := os.WriteFile(filepath.Join(tmpDir, readmePath), []byte(readme), 0o644); err != nil {
205 return fmt.Errorf("failed to write readme: %w", err)
206 }
207
208 if err := r.Add(gitm.AddOptions{
209 All: true,
210 }); err != nil {
211 return fmt.Errorf("failed to add readme: %w", err)
212 }
213
214 if err := r.Commit(&gitm.Signature{
215 Name: "Soft Serve",
216 Email: "vt100@charm.sh",
217 When: time.Now(),
218 }, "Add readme"); err != nil {
219 return fmt.Errorf("failed to commit readme: %w", err)
220 }
221
222 if err := r.RemoteAdd("origin", "file://"+rp); err != nil {
223 return fmt.Errorf("failed to add remote: %w", err)
224 }
225
226 if err := r.Push("origin", "main"); err != nil {
227 return fmt.Errorf("failed to push readme: %w", err)
228 }
229
230 // Create `.soft-serve` repository and add readme
231 if _, err := sb.CreateRepository(".soft-serve", backend.RepositoryOptions{
232 ProjectName: "Home",
233 Description: "Soft Serve home repository",
234 Hidden: true,
235 Private: false,
236 }); err != nil {
237 fmt.Fprintf(os.Stderr, "failed to create repository: %s\n", err)
238 }
239 }
240 }
241
242 // Set repos metadata & collabs
243 logger.Info("Setting repos metadata & collabs...")
244 for _, r := range ocfg.Repos {
245 repo, name := r.Repo, r.Name
246 // Special case for config repo
247 if repo == "config" {
248 repo = ".soft-serve"
249 r.Private = false
250 }
251
252 if err := sb.SetProjectName(repo, name); err != nil {
253 logger.Errorf("failed to set repo name to %s: %s", repo, err)
254 }
255
256 if err := sb.SetDescription(repo, r.Note); err != nil {
257 logger.Errorf("failed to set repo description to %s: %s", repo, err)
258 }
259
260 if err := sb.SetPrivate(repo, r.Private); err != nil {
261 logger.Errorf("failed to set repo private to %s: %s", repo, err)
262 }
263
264 for _, collab := range r.Collabs {
265 if err := sb.AddCollaborator(repo, collab); err != nil {
266 logger.Errorf("failed to add repo collab to %s: %s", repo, err)
267 }
268 }
269 }
270
271 // Create users & collabs
272 logger.Info("Creating users & collabs...")
273 for _, user := range ocfg.Users {
274 keys := make(map[string]ssh.PublicKey)
275 for _, key := range user.PublicKeys {
276 pk, _, err := backend.ParseAuthorizedKey(key)
277 if err != nil {
278 continue
279 }
280 ak := backend.MarshalAuthorizedKey(pk)
281 keys[ak] = pk
282 }
283
284 pubkeys := make([]ssh.PublicKey, 0)
285 for _, pk := range keys {
286 pubkeys = append(pubkeys, pk)
287 }
288
289 username := strings.ToLower(user.Name)
290 username = strings.ReplaceAll(username, " ", "-")
291 logger.Infof("Creating user %q", username)
292 if _, err := sb.CreateUser(username, backend.UserOptions{
293 Admin: user.Admin,
294 PublicKeys: pubkeys,
295 }); err != nil {
296 logger.Errorf("failed to create user: %s", err)
297 }
298
299 for _, repo := range user.CollabRepos {
300 if err := sb.AddCollaborator(repo, username); err != nil {
301 logger.Errorf("failed to add user collab to %s: %s\n", repo, err)
302 }
303 }
304 }
305
306 logger.Info("Writing config...")
307 defer logger.Info("Done!")
308 return config.WriteConfig(filepath.Join(cfg.DataPath, "config.yaml"), cfg)
309 },
310 }
311)
312
313// Returns true if path is a directory containing an `objects` directory and a
314// `HEAD` file.
315func isGitDir(path string) bool {
316 stat, err := os.Stat(filepath.Join(path, "objects"))
317 if err != nil {
318 return false
319 }
320 if !stat.IsDir() {
321 return false
322 }
323
324 stat, err = os.Stat(filepath.Join(path, "HEAD"))
325 if err != nil {
326 return false
327 }
328 if stat.IsDir() {
329 return false
330 }
331
332 return true
333}
334
335// copyFile copies a single file from src to dst.
336func copyFile(src, dst string) error {
337 var err error
338 var srcfd *os.File
339 var dstfd *os.File
340 var srcinfo os.FileInfo
341
342 if srcfd, err = os.Open(src); err != nil {
343 return err
344 }
345 defer srcfd.Close() // nolint: errcheck
346
347 if dstfd, err = os.Create(dst); err != nil {
348 return err
349 }
350 defer dstfd.Close() // nolint: errcheck
351
352 if _, err = io.Copy(dstfd, srcfd); err != nil {
353 return err
354 }
355 if srcinfo, err = os.Stat(src); err != nil {
356 return err
357 }
358 return os.Chmod(dst, srcinfo.Mode())
359}
360
361// copyDir copies a whole directory recursively.
362func copyDir(src string, dst string) error {
363 var err error
364 var fds []os.DirEntry
365 var srcinfo os.FileInfo
366
367 if srcinfo, err = os.Stat(src); err != nil {
368 return err
369 }
370
371 if err = os.MkdirAll(dst, srcinfo.Mode()); err != nil {
372 return err
373 }
374
375 if fds, err = os.ReadDir(src); err != nil {
376 return err
377 }
378
379 for _, fd := range fds {
380 srcfp := filepath.Join(src, fd.Name())
381 dstfp := filepath.Join(dst, fd.Name())
382
383 if fd.IsDir() {
384 if err = copyDir(srcfp, dstfp); err != nil {
385 err = errors.Join(err, err)
386 }
387 } else {
388 if err = copyFile(srcfp, dstfp); err != nil {
389 err = errors.Join(err, err)
390 }
391 }
392 }
393
394 return err
395}
396
397// Config is the configuration for the server.
398type Config struct {
399 Name string `yaml:"name" json:"name"`
400 Host string `yaml:"host" json:"host"`
401 Port int `yaml:"port" json:"port"`
402 AnonAccess string `yaml:"anon-access" json:"anon-access"`
403 AllowKeyless bool `yaml:"allow-keyless" json:"allow-keyless"`
404 Users []User `yaml:"users" json:"users"`
405 Repos []RepoConfig `yaml:"repos" json:"repos"`
406}
407
408// User contains user-level configuration for a repository.
409type User struct {
410 Name string `yaml:"name" json:"name"`
411 Admin bool `yaml:"admin" json:"admin"`
412 PublicKeys []string `yaml:"public-keys" json:"public-keys"`
413 CollabRepos []string `yaml:"collab-repos" json:"collab-repos"`
414}
415
416// RepoConfig is a repository configuration.
417type RepoConfig struct {
418 Name string `yaml:"name" json:"name"`
419 Repo string `yaml:"repo" json:"repo"`
420 Note string `yaml:"note" json:"note"`
421 Private bool `yaml:"private" json:"private"`
422 Readme string `yaml:"readme" json:"readme"`
423 Collabs []string `yaml:"collabs" json:"collabs"`
424}