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}