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