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