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