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