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