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