diff --git a/.gitignore b/.gitignore index 7f4876717..ae70eac83 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,10 @@ cmd/soft/soft +./soft .ssh .repos dist testdata +data/ completions/ -manpages/ \ No newline at end of file +manpages/ +soft_serve_ed25519* diff --git a/cmd/soft/man.go b/cmd/soft/man.go index ae59fec1b..71b1dfb2c 100644 --- a/cmd/soft/man.go +++ b/cmd/soft/man.go @@ -20,7 +20,7 @@ var ( return err } - manPage = manPage.WithSection("Copyright", "(C) 2021-2022 Charmbracelet, Inc.\n"+ + manPage = manPage.WithSection("Copyright", "(C) 2021-2023 Charmbracelet, Inc.\n"+ "Released under MIT license.") fmt.Println(manPage.Build(roff.NewDocument())) return nil diff --git a/cmd/soft/serve.go b/cmd/soft/serve.go index 5b0b49787..469ba8546 100644 --- a/cmd/soft/serve.go +++ b/cmd/soft/serve.go @@ -2,14 +2,12 @@ package main import ( "context" - "fmt" "os" "os/signal" "syscall" "time" "github.com/charmbracelet/log" - "github.com/charmbracelet/soft-serve/server" "github.com/charmbracelet/soft-serve/server/config" "github.com/spf13/cobra" @@ -23,9 +21,14 @@ var ( Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { cfg := config.DefaultConfig() - s := server.NewServer(cfg) + s, err := server.NewServer(cfg) + if err != nil { + return err + } - log.Print("Starting SSH server", "addr", fmt.Sprintf("%s:%d", cfg.BindAddr, cfg.Port)) + if cfg.Debug { + log.SetLevel(log.DebugLevel) + } done := make(chan os.Signal, 1) lch := make(chan error, 1) @@ -38,7 +41,6 @@ var ( signal.Notify(done, os.Interrupt, syscall.SIGINT, syscall.SIGTERM) <-done - log.Print("Stopping SSH server", "addr", fmt.Sprintf("%s:%d", cfg.BindAddr, cfg.Port)) ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() if err := s.Shutdown(ctx); err != nil { diff --git a/config/auth.go b/config/auth.go deleted file mode 100644 index 84da840f4..000000000 --- a/config/auth.go +++ /dev/null @@ -1,155 +0,0 @@ -package config - -import ( - "strings" - - "github.com/charmbracelet/log" - - gm "github.com/charmbracelet/wish/git" - "github.com/gliderlabs/ssh" - gossh "golang.org/x/crypto/ssh" -) - -// Push registers Git push functionality for the given repo and key. -func (cfg *Config) Push(repo string, pk ssh.PublicKey) { - go func() { - err := cfg.Reload() - if err != nil { - log.Error("error reloading after push", "err", err) - } - if cfg.Cfg.Callbacks != nil { - cfg.Cfg.Callbacks.Push(repo) - } - r, err := cfg.Source.GetRepo(repo) - if err != nil { - log.Error("error getting repo after push", "err", err) - return - } - err = r.UpdateServerInfo() - if err != nil { - log.Error("error updating server info after push", "err", err) - } - }() -} - -// Fetch registers Git fetch functionality for the given repo and key. -func (cfg *Config) Fetch(repo string, pk ssh.PublicKey) { - if cfg.Cfg.Callbacks != nil { - cfg.Cfg.Callbacks.Fetch(repo) - } -} - -// AuthRepo grants repo authorization to the given key. -func (cfg *Config) AuthRepo(repo string, pk ssh.PublicKey) gm.AccessLevel { - return cfg.accessForKey(repo, pk) -} - -// PasswordHandler returns whether or not password access is allowed. -func (cfg *Config) PasswordHandler(ctx ssh.Context, password string) bool { - return (cfg.AnonAccess != "no-access") && cfg.AllowKeyless -} - -// KeyboardInteractiveHandler returns whether or not keyboard interactive is allowed. -func (cfg *Config) KeyboardInteractiveHandler(ctx ssh.Context, _ gossh.KeyboardInteractiveChallenge) bool { - return (cfg.AnonAccess != "no-access") && cfg.AllowKeyless -} - -// PublicKeyHandler returns whether or not the given public key may access the -// repo. -func (cfg *Config) PublicKeyHandler(ctx ssh.Context, pk ssh.PublicKey) bool { - return cfg.accessForKey("", pk) != gm.NoAccess -} - -func (cfg *Config) anonAccessLevel() gm.AccessLevel { - switch cfg.AnonAccess { - case "no-access": - return gm.NoAccess - case "read-only": - return gm.ReadOnlyAccess - case "read-write": - return gm.ReadWriteAccess - case "admin-access": - return gm.AdminAccess - default: - return gm.NoAccess - } -} - -// accessForKey returns the access level for the given repo. -// -// If repo doesn't exist, then access is based on user's admin privileges, or -// config.AnonAccess. -// If repo exists, and private, then admins and collabs are allowed access. -// If repo exists, and not private, then access is based on config.AnonAccess. -func (cfg *Config) accessForKey(repo string, pk ssh.PublicKey) gm.AccessLevel { - anon := cfg.anonAccessLevel() - private := cfg.isPrivate(repo) - // Find user - for _, user := range cfg.Users { - for _, k := range user.PublicKeys { - apk, _, _, _, err := ssh.ParseAuthorizedKey([]byte(strings.TrimSpace(k))) - if err != nil { - log.Error("malformed authorized key", "key", k) - return gm.NoAccess - } - if ssh.KeysEqual(pk, apk) { - if user.Admin { - return gm.AdminAccess - } - u := user - if cfg.isCollab(repo, &u) { - if anon > gm.ReadWriteAccess { - return anon - } - return gm.ReadWriteAccess - } - if !private { - if anon > gm.ReadOnlyAccess { - return anon - } - return gm.ReadOnlyAccess - } - } - } - } - // Don't restrict access to private repos if no users are configured. - // Return anon access level. - if private && len(cfg.Users) > 0 { - return gm.NoAccess - } - return anon -} - -func (cfg *Config) findRepo(repo string) *RepoConfig { - for _, r := range cfg.Repos { - if r.Repo == repo { - return &r - } - } - return nil -} - -func (cfg *Config) isPrivate(repo string) bool { - if r := cfg.findRepo(repo); r != nil { - return r.Private - } - return false -} - -func (cfg *Config) isCollab(repo string, user *User) bool { - if user != nil { - for _, r := range user.CollabRepos { - if r == repo { - return true - } - } - if r := cfg.findRepo(repo); r != nil { - for _, c := range r.Collabs { - if c == user.Name { - return true - } - } - } - } - return false -} diff --git a/config/auth_test.go b/config/auth_test.go deleted file mode 100644 index 4f48bafb4..000000000 --- a/config/auth_test.go +++ /dev/null @@ -1,669 +0,0 @@ -package config - -import ( - "testing" - - "github.com/charmbracelet/wish/git" - "github.com/gliderlabs/ssh" - "github.com/matryer/is" -) - -func TestAuth(t *testing.T) { - adminKey := "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAINMwLvyV3ouVrTysUYGoJdl5Vgn5BACKov+n9PlzfPwH a@b" - adminPk, _, _, _, _ := ssh.ParseAuthorizedKey([]byte(adminKey)) - dummyKey := "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFxIobhwtfdwN7m1TFt9wx3PsfvcAkISGPxmbmbauST8 a@b" - dummyPk, _, _, _, _ := ssh.ParseAuthorizedKey([]byte(dummyKey)) - cases := []struct { - name string - cfg Config - repo string - key ssh.PublicKey - access git.AccessLevel - }{ - // Repo access - { - name: "anon access: no-access, anonymous user", - access: git.NoAccess, - repo: "foo", - cfg: Config{ - AnonAccess: "no-access", - Repos: []RepoConfig{ - { - Repo: "foo", - }, - }, - }, - }, - { - name: "anon access: no-access, anonymous user with admin user", - access: git.NoAccess, - repo: "foo", - cfg: Config{ - AnonAccess: "no-access", - Repos: []RepoConfig{ - { - Repo: "foo", - }, - }, - Users: []User{ - { - Admin: true, - PublicKeys: []string{ - adminKey, - }, - }, - }, - }, - }, - { - name: "anon access: no-access, authd user", - key: dummyPk, - repo: "foo", - access: git.ReadOnlyAccess, - cfg: Config{ - AnonAccess: "no-access", - Repos: []RepoConfig{ - { - Repo: "foo", - }, - }, - Users: []User{ - { - PublicKeys: []string{ - dummyKey, - }, - }, - }, - }, - }, - { - name: "anon access: no-access, anonymous user with admin user", - key: dummyPk, - repo: "foo", - access: git.NoAccess, - cfg: Config{ - AnonAccess: "no-access", - Repos: []RepoConfig{ - { - Repo: "foo", - }, - }, - Users: []User{ - { - Admin: true, - PublicKeys: []string{ - adminKey, - }, - }, - }, - }, - }, - { - name: "anon access: no-access, admin user", - repo: "foo", - key: adminPk, - access: git.AdminAccess, - cfg: Config{ - AnonAccess: "no-access", - Repos: []RepoConfig{ - { - Repo: "foo", - }, - }, - Users: []User{ - { - Admin: true, - PublicKeys: []string{ - adminKey, - }, - }, - }, - }, - }, - { - name: "anon access: read-only, anonymous user", - repo: "foo", - access: git.ReadOnlyAccess, - cfg: Config{ - AnonAccess: "read-only", - Repos: []RepoConfig{ - { - Repo: "foo", - }, - }, - }, - }, - { - name: "anon access: read-only, authd user", - repo: "foo", - key: dummyPk, - access: git.ReadOnlyAccess, - cfg: Config{ - AnonAccess: "read-only", - Repos: []RepoConfig{ - { - Repo: "foo", - }, - }, - Users: []User{ - { - PublicKeys: []string{ - dummyKey, - }, - }, - }, - }, - }, - { - name: "anon access: read-only, admin user", - repo: "foo", - key: adminPk, - access: git.AdminAccess, - cfg: Config{ - AnonAccess: "read-only", - Repos: []RepoConfig{ - { - Repo: "foo", - }, - }, - Users: []User{ - { - Admin: true, - PublicKeys: []string{ - adminKey, - }, - }, - }, - }, - }, - { - name: "anon access: read-write, anonymous user", - repo: "foo", - access: git.ReadWriteAccess, - cfg: Config{ - AnonAccess: "read-write", - Repos: []RepoConfig{ - { - Repo: "foo", - }, - }, - }, - }, - { - name: "anon access: read-write, authd user", - repo: "foo", - key: dummyPk, - access: git.ReadWriteAccess, - cfg: Config{ - AnonAccess: "read-write", - Repos: []RepoConfig{ - { - Repo: "foo", - }, - }, - Users: []User{ - { - PublicKeys: []string{ - dummyKey, - }, - }, - }, - }, - }, { - name: "anon access: read-write, admin user", - repo: "foo", - key: adminPk, - access: git.AdminAccess, - cfg: Config{ - AnonAccess: "read-write", - Repos: []RepoConfig{ - { - Repo: "foo", - }, - }, - Users: []User{ - { - Admin: true, - PublicKeys: []string{ - adminKey, - }, - }, - }, - }, - }, - { - name: "anon access: admin-access, anonymous user", - repo: "foo", - access: git.AdminAccess, - cfg: Config{ - AnonAccess: "admin-access", - Repos: []RepoConfig{ - { - Repo: "foo", - }, - }, - }, - }, - { - name: "anon access: admin-access, authd user", - repo: "foo", - key: dummyPk, - access: git.AdminAccess, - cfg: Config{ - AnonAccess: "admin-access", - Repos: []RepoConfig{ - { - Repo: "foo", - }, - }, - Users: []User{ - { - PublicKeys: []string{ - dummyKey, - }, - }, - }, - }, - }, { - name: "anon access: admin-access, admin user", - repo: "foo", - key: adminPk, - access: git.AdminAccess, - cfg: Config{ - AnonAccess: "admin-access", - Repos: []RepoConfig{ - { - Repo: "foo", - }, - }, - Users: []User{ - { - Admin: true, - PublicKeys: []string{ - adminKey, - }, - }, - }, - }, - }, - - // Collabs - { - name: "anon access: no-access, authd user, collab", - key: dummyPk, - repo: "foo", - access: git.ReadWriteAccess, - cfg: Config{ - AnonAccess: "no-access", - Repos: []RepoConfig{ - { - Repo: "foo", - Collabs: []string{ - "user", - }, - }, - }, - Users: []User{ - { - Name: "user", - PublicKeys: []string{ - dummyKey, - }, - }, - }, - }, - }, - { - name: "anon access: no-access, authd user, collab, private repo", - key: dummyPk, - repo: "foo", - access: git.ReadWriteAccess, - cfg: Config{ - AnonAccess: "no-access", - Repos: []RepoConfig{ - { - Repo: "foo", - Private: true, - Collabs: []string{ - "user", - }, - }, - }, - Users: []User{ - { - Name: "user", - PublicKeys: []string{ - dummyKey, - }, - }, - }, - }, - }, - { - name: "anon access: no-access, admin user, collab, private repo", - repo: "foo", - key: adminPk, - access: git.AdminAccess, - cfg: Config{ - AnonAccess: "no-access", - Repos: []RepoConfig{ - { - Repo: "foo", - Private: true, - Collabs: []string{ - "user", - }, - }, - }, - Users: []User{ - { - Name: "admin", - Admin: true, - PublicKeys: []string{ - adminKey, - }, - }, - }, - }, - }, - { - name: "anon access: read-only, authd user, collab, private repo", - repo: "foo", - key: dummyPk, - access: git.ReadWriteAccess, - cfg: Config{ - AnonAccess: "read-only", - Repos: []RepoConfig{ - { - Repo: "foo", - Private: true, - Collabs: []string{ - "user", - }, - }, - }, - Users: []User{ - { - Name: "user", - PublicKeys: []string{ - dummyKey, - }, - }, - }, - }, - }, - { - name: "anon access: admin-access, anonymous user, collab", - repo: "foo", - access: git.AdminAccess, - cfg: Config{ - AnonAccess: "admin-access", - Repos: []RepoConfig{ - { - Repo: "foo", - Collabs: []string{ - "user", - }, - }, - }, - }, - }, - { - name: "anon access: admin-access, authd user, collab", - repo: "foo", - key: dummyPk, - access: git.AdminAccess, - cfg: Config{ - AnonAccess: "admin-access", - Repos: []RepoConfig{ - { - Repo: "foo", - Collabs: []string{ - "user", - }, - }, - }, - Users: []User{ - { - Name: "user", - PublicKeys: []string{ - dummyKey, - }, - }, - }, - }, - }, { - name: "anon access: admin-access, admin user, collab", - repo: "foo", - key: adminPk, - access: git.AdminAccess, - cfg: Config{ - AnonAccess: "admin-access", - Repos: []RepoConfig{ - { - Repo: "foo", - Collabs: []string{ - "user", - }, - }, - }, - Users: []User{ - { - Name: "admin", - Admin: true, - PublicKeys: []string{ - adminKey, - }, - }, - }, - }, - }, - - // New repo - { - name: "anon access: no-access, anonymous user, new repo", - access: git.NoAccess, - repo: "foo", - cfg: Config{ - AnonAccess: "no-access", - }, - }, - { - name: "anon access: no-access, authd user, new repo", - key: dummyPk, - repo: "foo", - access: git.ReadOnlyAccess, - cfg: Config{ - AnonAccess: "no-access", - Users: []User{ - { - PublicKeys: []string{ - dummyKey, - }, - }, - }, - }, - }, - { - name: "anon access: no-access, authd user, new repo, with user", - key: dummyPk, - repo: "foo", - access: git.NoAccess, - cfg: Config{ - AnonAccess: "no-access", - Users: []User{ - { - PublicKeys: []string{ - adminKey, - }, - }, - }, - }, - }, - { - name: "anon access: no-access, admin user, new repo", - repo: "foo", - key: adminPk, - access: git.AdminAccess, - cfg: Config{ - AnonAccess: "no-access", - Users: []User{ - { - Admin: true, - PublicKeys: []string{ - adminKey, - }, - }, - }, - }, - }, - { - name: "anon access: read-only, anonymous user, new repo", - repo: "foo", - access: git.ReadOnlyAccess, - cfg: Config{ - AnonAccess: "read-only", - }, - }, - { - name: "anon access: read-only, authd user, new repo", - repo: "foo", - key: dummyPk, - access: git.ReadOnlyAccess, - cfg: Config{ - AnonAccess: "read-only", - Users: []User{ - { - PublicKeys: []string{ - dummyKey, - }, - }, - }, - }, - }, - { - name: "anon access: read-only, admin user, new repo", - repo: "foo", - key: adminPk, - access: git.AdminAccess, - cfg: Config{ - AnonAccess: "read-only", - Users: []User{ - { - Admin: true, - PublicKeys: []string{ - adminKey, - }, - }, - }, - }, - }, - { - name: "anon access: read-write, anonymous user, new repo", - repo: "foo", - access: git.ReadWriteAccess, - cfg: Config{ - AnonAccess: "read-write", - }, - }, - { - name: "anon access: read-write, authd user, new repo", - repo: "foo", - key: dummyPk, - access: git.ReadWriteAccess, - cfg: Config{ - AnonAccess: "read-write", - Users: []User{ - { - PublicKeys: []string{ - dummyKey, - }, - }, - }, - }, - }, - { - name: "anon access: read-write, admin user, new repo", - repo: "foo", - key: adminPk, - access: git.AdminAccess, - cfg: Config{ - AnonAccess: "read-write", - Users: []User{ - { - Admin: true, - PublicKeys: []string{ - adminKey, - }, - }, - }, - }, - }, - { - name: "anon access: admin-access, anonymous user, new repo", - repo: "foo", - access: git.AdminAccess, - cfg: Config{ - AnonAccess: "admin-access", - }, - }, - { - name: "anon access: admin-access, authd user, new repo", - repo: "foo", - key: dummyPk, - access: git.AdminAccess, - cfg: Config{ - AnonAccess: "admin-access", - Users: []User{ - { - PublicKeys: []string{ - dummyKey, - }, - }, - }, - }, - }, - { - name: "anon access: admin-access, admin user, new repo", - repo: "foo", - key: adminPk, - access: git.AdminAccess, - cfg: Config{ - AnonAccess: "admin-access", - Users: []User{ - { - Admin: true, - PublicKeys: []string{ - adminKey, - }, - }, - }, - }, - }, - - // No users - { - name: "anon access: read-only, no users", - repo: "foo", - access: git.ReadOnlyAccess, - cfg: Config{ - AnonAccess: "read-only", - }, - }, - { - name: "anon access: read-write, no users", - repo: "foo", - access: git.ReadWriteAccess, - cfg: Config{ - AnonAccess: "read-write", - }, - }, - } - for _, c := range cases { - t.Run(c.name, func(t *testing.T) { - is := is.New(t) - al := c.cfg.accessForKey(c.repo, c.key) - is.Equal(al, c.access) - }) - } -} diff --git a/config/config.go b/config/config.go deleted file mode 100644 index 27e083d6c..000000000 --- a/config/config.go +++ /dev/null @@ -1,334 +0,0 @@ -package config - -import ( - "bytes" - "encoding/json" - "errors" - "io/fs" - "path/filepath" - "strings" - "sync" - "text/template" - "time" - - "github.com/charmbracelet/log" - - "golang.org/x/crypto/ssh" - "gopkg.in/yaml.v3" - - "fmt" - "os" - - "github.com/charmbracelet/soft-serve/git" - "github.com/charmbracelet/soft-serve/server/config" - "github.com/go-git/go-billy/v5/memfs" - ggit "github.com/go-git/go-git/v5" - "github.com/go-git/go-git/v5/plumbing/object" - "github.com/go-git/go-git/v5/plumbing/transport" - "github.com/go-git/go-git/v5/storage/memory" -) - -var ( - // ErrNoConfig is returned when a repo has no config file. - ErrNoConfig = errors.New("no config file found") -) - -// Config is the Soft Serve configuration. -type Config struct { - Name string `yaml:"name" json:"name"` - Host string `yaml:"host" json:"host"` - Port int `yaml:"port" json:"port"` - AnonAccess string `yaml:"anon-access" json:"anon-access"` - AllowKeyless bool `yaml:"allow-keyless" json:"allow-keyless"` - Users []User `yaml:"users" json:"users"` - Repos []RepoConfig `yaml:"repos" json:"repos"` - Source *RepoSource `yaml:"-" json:"-"` - Cfg *config.Config `yaml:"-" json:"-"` - mtx sync.Mutex -} - -// User contains user-level configuration for a repository. -type User struct { - Name string `yaml:"name" json:"name"` - Admin bool `yaml:"admin" json:"admin"` - PublicKeys []string `yaml:"public-keys" json:"public-keys"` - CollabRepos []string `yaml:"collab-repos" json:"collab-repos"` -} - -// RepoConfig is a repository configuration. -type RepoConfig struct { - Name string `yaml:"name" json:"name"` - Repo string `yaml:"repo" json:"repo"` - Note string `yaml:"note" json:"note"` - Private bool `yaml:"private" json:"private"` - Readme string `yaml:"readme" json:"readme"` - Collabs []string `yaml:"collabs" json:"collabs"` -} - -// NewConfig creates a new internal Config struct. -func NewConfig(cfg *config.Config) (*Config, error) { - var anonAccess string - var yamlUsers string - var displayHost string - host := cfg.Host - port := cfg.Port - - pks := make([]string, 0) - for _, k := range cfg.InitialAdminKeys { - if bts, err := os.ReadFile(k); err == nil { - // pk is a file, set its contents as pk - k = string(bts) - } - var pk = strings.TrimSpace(k) - if pk == "" { - continue - } - // it is a valid ssh key, nothing to do - if _, _, _, _, err := ssh.ParseAuthorizedKey([]byte(pk)); err != nil { - return nil, fmt.Errorf("invalid initial admin key %q: %w", k, err) - } - pks = append(pks, pk) - } - - rs := NewRepoSource(cfg.RepoPath) - c := &Config{ - Cfg: cfg, - } - c.Host = cfg.Host - c.Port = port - c.Source = rs - // Grant read-write access when no keys are provided. - if len(pks) == 0 { - anonAccess = "read-write" - } else { - anonAccess = "read-only" - } - if host == "" { - displayHost = "localhost" - } else { - displayHost = host - } - yamlConfig := fmt.Sprintf(defaultConfig, - displayHost, - port, - anonAccess, - len(pks) == 0, - ) - if len(pks) == 0 { - yamlUsers = defaultUserConfig - } else { - var result string - for _, pk := range pks { - result += fmt.Sprintf(" - %s\n", pk) - } - yamlUsers = fmt.Sprintf(hasKeyUserConfig, result) - } - yaml := fmt.Sprintf("%s%s%s", yamlConfig, yamlUsers, exampleUserConfig) - err := c.createDefaultConfigRepo(yaml) - if err != nil { - return nil, err - } - return c, nil -} - -// readConfig reads the config file for the repo. All config files are stored in -// the config repo. -func (cfg *Config) readConfig(repo string, v interface{}) error { - cr, err := cfg.Source.GetRepo("config") - if err != nil { - return err - } - // Parse YAML files - var cy string - for _, ext := range []string{".yaml", ".yml"} { - cy, _, err = cr.LatestFile(repo + ext) - if err != nil && !errors.Is(err, git.ErrFileNotFound) { - return err - } else if err == nil { - break - } - } - // Parse JSON files - cj, _, err := cr.LatestFile(repo + ".json") - if err != nil && !errors.Is(err, git.ErrFileNotFound) { - return err - } - if cy != "" { - err = yaml.Unmarshal([]byte(cy), v) - if err != nil { - return err - } - } else if cj != "" { - err = json.Unmarshal([]byte(cj), v) - if err != nil { - return err - } - } else { - return ErrNoConfig - } - return nil -} - -// Reload reloads the configuration. -func (cfg *Config) Reload() error { - cfg.mtx.Lock() - defer cfg.mtx.Unlock() - err := cfg.Source.LoadRepos() - if err != nil { - return err - } - if err := cfg.readConfig("config", cfg); err != nil { - return fmt.Errorf("error reading config: %w", err) - } - // sanitize repo configs - repos := make(map[string]RepoConfig, 0) - for _, r := range cfg.Repos { - repos[r.Repo] = r - } - for _, r := range cfg.Source.AllRepos() { - var rc RepoConfig - repo := r.Repo() - if repo == "config" { - continue - } - if err := cfg.readConfig(repo, &rc); err != nil { - if !errors.Is(err, ErrNoConfig) { - log.Error("error reading config", "err", err) - } - continue - } - repos[r.Repo()] = rc - } - cfg.Repos = make([]RepoConfig, 0, len(repos)) - for n, r := range repos { - r.Repo = n - cfg.Repos = append(cfg.Repos, r) - } - // Populate readmes and descriptions - for _, r := range cfg.Source.AllRepos() { - repo := r.Repo() - err = r.UpdateServerInfo() - if err != nil { - log.Error("error updating server info", "repo", repo, "err", err) - } - pat := "README*" - rp := "" - for _, rr := range cfg.Repos { - if repo == rr.Repo { - rp = rr.Readme - r.name = rr.Name - r.description = rr.Note - r.private = rr.Private - break - } - } - if rp != "" { - pat = rp - } - rm := "" - fc, fp, _ := r.LatestFile(pat) - rm = fc - if repo == "config" { - md, err := templatize(rm, cfg) - if err != nil { - return err - } - rm = md - } - r.SetReadme(rm, fp) - } - return nil -} - -func createFile(path string, content string) error { - f, err := os.Create(path) - if err != nil { - return err - } - defer f.Close() - _, err = f.WriteString(content) - if err != nil { - return err - } - return f.Sync() -} - -func (cfg *Config) createDefaultConfigRepo(yaml string) error { - cn := "config" - rp := filepath.Join(cfg.Cfg.RepoPath, cn) - rs := cfg.Source - err := rs.LoadRepo(cn) - if errors.Is(err, fs.ErrNotExist) { - repo, err := ggit.PlainInit(rp, true) - if err != nil { - return err - } - repo, err = ggit.Clone(memory.NewStorage(), memfs.New(), &ggit.CloneOptions{ - URL: rp, - }) - if err != nil && err != transport.ErrEmptyRemoteRepository { - return err - } - wt, err := repo.Worktree() - if err != nil { - return err - } - rm, err := wt.Filesystem.Create("README.md") - if err != nil { - return err - } - _, err = rm.Write([]byte(defaultReadme)) - if err != nil { - return err - } - _, err = wt.Add("README.md") - if err != nil { - return err - } - cf, err := wt.Filesystem.Create("config.yaml") - if err != nil { - return err - } - _, err = cf.Write([]byte(yaml)) - if err != nil { - return err - } - _, err = wt.Add("config.yaml") - if err != nil { - return err - } - author := object.Signature{ - Name: "Soft Serve Server", - Email: "vt100@charm.sh", - When: time.Now(), - } - _, err = wt.Commit("Default init", &ggit.CommitOptions{ - All: true, - Author: &author, - Committer: &author, - }) - if err != nil { - return err - } - err = repo.Push(&ggit.PushOptions{}) - if err != nil { - return err - } - } else if err != nil { - return err - } - return cfg.Reload() -} - -func templatize(mdt string, tmpl interface{}) (string, error) { - t, err := template.New("readme").Parse(mdt) - if err != nil { - return "", err - } - buf := &bytes.Buffer{} - err = t.Execute(buf, tmpl) - if err != nil { - return "", err - } - return buf.String(), nil -} diff --git a/config/config_test.go b/config/config_test.go deleted file mode 100644 index 12ddd8c24..000000000 --- a/config/config_test.go +++ /dev/null @@ -1,37 +0,0 @@ -package config - -import ( - "testing" - - "github.com/charmbracelet/soft-serve/server/config" - "github.com/matryer/is" -) - -func TestMultipleInitialKeys(t *testing.T) { - cfg, err := NewConfig(&config.Config{ - RepoPath: t.TempDir(), - KeyPath: t.TempDir(), - InitialAdminKeys: []string{ - "testdata/k1.pub", - "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFxIobhwtfdwN7m1TFt9wx3PsfvcAkISGPxmbmbauST8 a@b", - }, - }) - is := is.New(t) - is.NoErr(err) - err = cfg.Reload() - is.NoErr(err) - is.Equal(cfg.Users[0].PublicKeys, []string{ - "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAINMwLvyV3ouVrTysUYGoJdl5Vgn5BACKov+n9PlzfPwH a@b", - "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFxIobhwtfdwN7m1TFt9wx3PsfvcAkISGPxmbmbauST8 a@b", - }) // should have both keys -} - -func TestEmptyInitialKeys(t *testing.T) { - cfg, err := NewConfig(&config.Config{ - RepoPath: t.TempDir(), - KeyPath: t.TempDir(), - }) - is := is.New(t) - is.NoErr(err) - is.Equal(len(cfg.Users), 0) // should not have any users -} diff --git a/config/defaults.go b/config/defaults.go deleted file mode 100644 index a471fab58..000000000 --- a/config/defaults.go +++ /dev/null @@ -1,58 +0,0 @@ -package config - -const defaultReadme = "# Soft Serve\n\n Welcome! You can configure your Soft Serve server by cloning this repo and pushing changes.\n\n```\ngit clone ssh://{{.Host}}:{{.Port}}/config\n```" - -const defaultConfig = `# The name of the server to show in the TUI. -name: Soft Serve - -# The host and port to display in the TUI. You may want to change this if your -# server is accessible from a different host and/or port that what it's -# actually listening on (for example, if it's behind a reverse proxy). -host: %s -port: %d - -# Access level for anonymous users. Options are: admin-access, read-write, -# read-only, and no-access. -anon-access: %s - -# You can grant read-only access to users without private keys. Any password -# will be accepted. -allow-keyless: %t - -# Customize repo display in the menu. -repos: - - name: Home - repo: config - private: true - note: "Configuration and content repo for this server" - readme: README.md -` - -const hasKeyUserConfig = ` - -# Authorized users. Admins have full access to all repos. Private repos are only -# accessible by admins and collab users. Regular users can read public repos -# based on your anon-access setting. -users: - - name: Admin - admin: true - public-keys: -%s -` - -const defaultUserConfig = ` -# users: -# - name: Admin -# admin: true -# public-keys: -# - ssh-ed25519 AAAA... # redacted -# - ssh-rsa AAAAB3Nz... # redacted` - -const exampleUserConfig = ` -# - name: Example User -# collab-repos: -# - REPO -# public-keys: -# - ssh-ed25519 AAAA... # redacted -# - ssh-rsa AAAAB3Nz... # redacted -` diff --git a/config/git.go b/config/git.go deleted file mode 100644 index 9835a4f1d..000000000 --- a/config/git.go +++ /dev/null @@ -1,304 +0,0 @@ -package config - -import ( - "errors" - "os" - "path/filepath" - "sync" - - "github.com/charmbracelet/log" - - "github.com/charmbracelet/soft-serve/git" - "github.com/gobwas/glob" - "github.com/golang/groupcache/lru" -) - -// ErrMissingRepo indicates that the requested repository could not be found. -var ErrMissingRepo = errors.New("missing repo") - -// Repo represents a Git repository. -type Repo struct { - name string - description string - path string - repository *git.Repository - readme string - readmePath string - head *git.Reference - headCommit string - refs []*git.Reference - patchCache *lru.Cache - private bool -} - -// open opens a Git repository. -func (rs *RepoSource) open(path string) (*Repo, error) { - rg, err := git.Open(path) - if err != nil { - return nil, err - } - r := &Repo{ - path: path, - repository: rg, - patchCache: lru.New(1000), - } - _, err = r.HEAD() - if err != nil { - return nil, err - } - _, err = r.References() - if err != nil { - return nil, err - } - return r, nil -} - -// IsPrivate returns true if the repository is private. -func (r *Repo) IsPrivate() bool { - return r.private -} - -// Path returns the path to the repository. -func (r *Repo) Path() string { - return r.path -} - -// Repo returns the repository directory name. -func (r *Repo) Repo() string { - return filepath.Base(r.path) -} - -// Name returns the name of the repository. -func (r *Repo) Name() string { - if r.name == "" { - return r.Repo() - } - return r.name -} - -// Description returns the description for a repository. -func (r *Repo) Description() string { - return r.description -} - -// Readme returns the readme and its path for the repository. -func (r *Repo) Readme() (readme string, path string) { - return r.readme, r.readmePath -} - -// SetReadme sets the readme for the repository. -func (r *Repo) SetReadme(readme, path string) { - r.readme = readme - r.readmePath = path -} - -// HEAD returns the reference for a repository. -func (r *Repo) HEAD() (*git.Reference, error) { - if r.head != nil { - return r.head, nil - } - h, err := r.repository.HEAD() - if err != nil { - return nil, err - } - r.head = h - return h, nil -} - -// GetReferences returns the references for a repository. -func (r *Repo) References() ([]*git.Reference, error) { - if r.refs != nil { - return r.refs, nil - } - refs, err := r.repository.References() - if err != nil { - return nil, err - } - r.refs = refs - return refs, nil -} - -// Tree returns the git tree for a given path. -func (r *Repo) Tree(ref *git.Reference, path string) (*git.Tree, error) { - return r.repository.TreePath(ref, path) -} - -// Diff returns the diff for a given commit. -func (r *Repo) Diff(commit *git.Commit) (*git.Diff, error) { - hash := commit.Hash.String() - c, ok := r.patchCache.Get(hash) - if ok { - return c.(*git.Diff), nil - } - diff, err := r.repository.Diff(commit) - if err != nil { - return nil, err - } - r.patchCache.Add(hash, diff) - return diff, nil -} - -// CountCommits returns the number of commits for a repository. -func (r *Repo) CountCommits(ref *git.Reference) (int64, error) { - tc, err := r.repository.CountCommits(ref) - if err != nil { - return 0, err - } - return tc, nil -} - -// Commit returns the commit for a given hash. -func (r *Repo) Commit(hash string) (*git.Commit, error) { - if hash == "HEAD" && r.headCommit != "" { - hash = r.headCommit - } - c, err := r.repository.CatFileCommit(hash) - if err != nil { - return nil, err - } - r.headCommit = c.ID.String() - return &git.Commit{ - Commit: c, - Hash: git.Hash(c.ID.String()), - }, nil -} - -// CommitsByPage returns the commits for a repository. -func (r *Repo) CommitsByPage(ref *git.Reference, page, size int) (git.Commits, error) { - return r.repository.CommitsByPage(ref, page, size) -} - -// Push pushes the repository to the remote. -func (r *Repo) Push(remote, branch string) error { - return r.repository.Push(remote, branch) -} - -// RepoSource is a reference to an on-disk repositories. -type RepoSource struct { - Path string - mtx sync.Mutex - repos map[string]*Repo -} - -// NewRepoSource creates a new RepoSource. -func NewRepoSource(repoPath string) *RepoSource { - err := os.MkdirAll(repoPath, os.ModeDir|os.FileMode(0700)) - if err != nil { - log.Fatal(err) - } - rs := &RepoSource{Path: repoPath} - rs.repos = make(map[string]*Repo, 0) - return rs -} - -// AllRepos returns all repositories for the given RepoSource. -func (rs *RepoSource) AllRepos() []*Repo { - rs.mtx.Lock() - defer rs.mtx.Unlock() - repos := make([]*Repo, 0, len(rs.repos)) - for _, r := range rs.repos { - repos = append(repos, r) - } - return repos -} - -// GetRepo returns a repository by name. -func (rs *RepoSource) GetRepo(name string) (*Repo, error) { - rs.mtx.Lock() - defer rs.mtx.Unlock() - r, ok := rs.repos[name] - if !ok { - return nil, ErrMissingRepo - } - return r, nil -} - -// InitRepo initializes a new Git repository. -func (rs *RepoSource) InitRepo(name string, bare bool) (*Repo, error) { - rs.mtx.Lock() - defer rs.mtx.Unlock() - rp := filepath.Join(rs.Path, name) - rg, err := git.Init(rp, bare) - if err != nil { - return nil, err - } - r := &Repo{ - path: rp, - repository: rg, - refs: []*git.Reference{ - git.NewReference(rp, git.RefsHeads+"master"), - }, - } - rs.repos[name] = r - return r, nil -} - -// LoadRepo loads a repository from disk. -func (rs *RepoSource) LoadRepo(name string) error { - rs.mtx.Lock() - defer rs.mtx.Unlock() - rp := filepath.Join(rs.Path, name) - r, err := rs.open(rp) - if err != nil { - return err - } - rs.repos[name] = r - return nil -} - -// LoadRepos opens Git repositories. -func (rs *RepoSource) LoadRepos() error { - rd, err := os.ReadDir(rs.Path) - if err != nil { - return err - } - for _, de := range rd { - if !de.IsDir() { - log.Warn("not a directory", "path", filepath.Join(rs.Path, de.Name())) - continue - } - err = rs.LoadRepo(de.Name()) - if err == git.ErrNotAGitRepository { - continue - } - if err != nil { - log.Warn("error loading repository", "path", filepath.Join(rs.Path, de.Name()), "err", err) - continue - } - } - return nil -} - -// LatestFile returns the contents of the latest file at the specified path in -// the repository and its file path. -func (r *Repo) LatestFile(pattern string) (string, string, error) { - g := glob.MustCompile(pattern) - dir := filepath.Dir(pattern) - t, err := r.repository.TreePath(r.head, dir) - if err != nil { - return "", "", err - } - ents, err := t.Entries() - if err != nil { - return "", "", err - } - for _, e := range ents { - fp := filepath.Join(dir, e.Name()) - if e.IsTree() { - continue - } - if g.Match(fp) { - bts, err := e.Contents() - if err != nil { - return "", "", err - } - return string(bts), fp, nil - } - } - return "", "", git.ErrFileNotFound -} - -// UpdateServerInfo updates the server info for the repository. -func (r *Repo) UpdateServerInfo() error { - return r.repository.UpdateServerInfo() -} diff --git a/config/testdata/k1.pub b/config/testdata/k1.pub deleted file mode 100644 index d82e29394..000000000 --- a/config/testdata/k1.pub +++ /dev/null @@ -1 +0,0 @@ -ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAINMwLvyV3ouVrTysUYGoJdl5Vgn5BACKov+n9PlzfPwH a@b diff --git a/examples/setuid/main.go b/examples/setuid/main.go index 4a722eb9b..8999dbcc2 100644 --- a/examples/setuid/main.go +++ b/examples/setuid/main.go @@ -45,22 +45,25 @@ func main() { log.Fatal("Setuid error", "err", err) } cfg := config.DefaultConfig() - cfg.Port = *port - s := server.NewServer(cfg) + cfg.SSH.ListenAddr = fmt.Sprintf(":%d", *port) + s, err := server.NewServer(cfg) + if err != nil { + log.Fatal(err) + } done := make(chan os.Signal, 1) signal.Notify(done, os.Interrupt, syscall.SIGINT, syscall.SIGTERM) - log.Print("Starting SSH server", "addr", fmt.Sprintf("%s:%d", cfg.BindAddr, cfg.Port)) + log.Print("Starting SSH server", "addr", cfg.SSH.ListenAddr) go func() { - if err := s.Serve(ls); err != nil { + if err := s.SSHServer.Serve(ls); err != nil { log.Fatal(err) } }() <-done - log.Print("Stopping SSH server", fmt.Sprintf("%s:%d", cfg.BindAddr, cfg.Port)) + log.Print("Stopping SSH server", "addr", cfg.SSH.ListenAddr) ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer func() { cancel() }() if err := s.Shutdown(ctx); err != nil { diff --git a/git/command.go b/git/command.go new file mode 100644 index 000000000..eb4f0d17a --- /dev/null +++ b/git/command.go @@ -0,0 +1,11 @@ +package git + +import "github.com/gogs/git-module" + +// RunInDirOptions are options for RunInDir. +type RunInDirOptions = git.RunInDirOptions + +// NewCommand creates a new git command. +func NewCommand(args ...string) *git.Command { + return git.NewCommand(args...) +} diff --git a/git/config.go b/git/config.go new file mode 100644 index 000000000..4e9af6ed5 --- /dev/null +++ b/git/config.go @@ -0,0 +1,51 @@ +package git + +// ConfigOptions are options for Config. +type ConfigOptions struct { + File string + All bool + Add bool + CommandOptions +} + +// Config gets a git configuration. +func Config(key string, opts ...ConfigOptions) (string, error) { + var opt ConfigOptions + if len(opts) > 0 { + opt = opts[0] + } + cmd := NewCommand("config") + if opt.File != "" { + cmd.AddArgs("--file", opt.File) + } + if opt.All { + cmd.AddArgs("--get-all") + } + for _, a := range opt.Args { + cmd.AddArgs(a) + } + cmd.AddArgs(key) + bts, err := cmd.Run() + if err != nil { + return "", err + } + return string(bts), nil +} + +// SetConfig sets a git configuration. +func SetConfig(key string, value string, opts ...ConfigOptions) error { + var opt ConfigOptions + if len(opts) > 0 { + opt = opts[0] + } + cmd := NewCommand("config") + if opt.File != "" { + cmd.AddArgs("--file", opt.File) + } + for _, a := range opt.Args { + cmd.AddArgs(a) + } + cmd.AddArgs(key, value) + _, err := cmd.Run() + return err +} diff --git a/git/errors.go b/git/errors.go index e4c2ec35c..40b0d390f 100644 --- a/git/errors.go +++ b/git/errors.go @@ -11,8 +11,8 @@ var ( ErrFileNotFound = errors.New("file not found") // ErrDirectoryNotFound is returned when a directory is not found. ErrDirectoryNotFound = errors.New("directory not found") - // ErrReferenceNotFound is returned when a reference is not found. - ErrReferenceNotFound = errors.New("reference not found") + // ErrReferenceNotExist is returned when a reference does not exist. + ErrReferenceNotExist = git.ErrReferenceNotExist // ErrRevisionNotExist is returned when a revision is not found. ErrRevisionNotExist = git.ErrRevisionNotExist // ErrNotAGitRepository is returned when the given path is not a Git repository. diff --git a/git/repo.go b/git/repo.go index 9dd674032..9d1f49d77 100644 --- a/git/repo.go +++ b/git/repo.go @@ -79,7 +79,7 @@ func (r *Repository) Name() string { // HEAD returns the HEAD reference for a repository. func (r *Repository) HEAD() (*Reference, error) { - rn, err := r.SymbolicRef() + rn, err := r.Repository.SymbolicRef(git.SymbolicRefOptions{Name: "HEAD"}) if err != nil { return nil, err } @@ -212,3 +212,41 @@ func (r *Repository) UpdateServerInfo() error { _, err := cmd.RunInDir(r.Path) return err } + +// Config returns the config value for the given key. +func (r *Repository) Config(key string, opts ...ConfigOptions) (string, error) { + dir, err := gitDir(r.Repository) + if err != nil { + return "", err + } + var opt ConfigOptions + if len(opts) > 0 { + opt = opts[0] + } + opt.File = filepath.Join(dir, "config") + return Config(key, opt) +} + +// SetConfig sets the config value for the given key. +func (r *Repository) SetConfig(key, value string, opts ...ConfigOptions) error { + dir, err := gitDir(r.Repository) + if err != nil { + return err + } + var opt ConfigOptions + if len(opts) > 0 { + opt = opts[0] + } + opt.File = filepath.Join(dir, "config") + return SetConfig(key, value, opt) +} + +// SymbolicRef returns or updates the symbolic reference for the given name. +// Both name and ref can be empty. +func (r *Repository) SymbolicRef(name string, ref string) (string, error) { + opt := git.SymbolicRefOptions{ + Name: name, + Ref: ref, + } + return r.Repository.SymbolicRef(opt) +} diff --git a/git/types.go b/git/types.go new file mode 100644 index 000000000..daf89b03e --- /dev/null +++ b/git/types.go @@ -0,0 +1,9 @@ +package git + +import "github.com/gogs/git-module" + +// CommandOptions contain options for running a git command. +type CommandOptions = git.CommandOptions + +// CloneOptions contain options for cloning a repository. +type CloneOptions = git.CloneOptions diff --git a/go.mod b/go.mod index 3deb348a0..5f3309903 100644 --- a/go.mod +++ b/go.mod @@ -9,10 +9,8 @@ require ( github.com/charmbracelet/bubbletea v0.23.2 github.com/charmbracelet/glamour v0.6.0 github.com/charmbracelet/lipgloss v0.7.1 - github.com/charmbracelet/wish v0.7.0 + github.com/charmbracelet/wish v1.1.0 github.com/dustin/go-humanize v1.0.1 - github.com/gliderlabs/ssh v0.3.5 - github.com/go-git/go-billy/v5 v5.4.1 github.com/go-git/go-git/v5 v5.6.1 github.com/matryer/is v1.4.1 github.com/muesli/reflow v0.3.0 @@ -22,39 +20,30 @@ require ( require ( github.com/aymanbagabas/go-osc52 v1.2.2 - github.com/charmbracelet/keygen v0.3.0 github.com/charmbracelet/log v0.2.1 + github.com/charmbracelet/ssh v0.0.0-20221117183211-483d43d97103 github.com/gobwas/glob v0.2.3 github.com/gogs/git-module v1.8.1 - github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da github.com/lrstanley/bubblezone v0.0.0-20220716194435-3cb8c52f6a8f github.com/muesli/mango-cobra v1.2.0 github.com/muesli/roff v0.1.0 github.com/spf13/cobra v1.6.1 golang.org/x/crypto v0.7.0 - gopkg.in/yaml.v3 v3.0.1 + golang.org/x/sync v0.1.0 ) require ( - github.com/Microsoft/go-winio v0.5.2 // indirect - github.com/ProtonMail/go-crypto v0.0.0-20230217124315-7d5c6f04bbb8 // indirect - github.com/acomagu/bufpipe v1.0.4 // indirect github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect github.com/atotto/clipboard v0.1.4 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/aymerick/douceur v0.2.0 // indirect github.com/caarlos0/sshmarshal v0.1.0 // indirect - github.com/cloudflare/circl v1.1.0 // indirect + github.com/charmbracelet/keygen v0.3.0 // indirect github.com/containerd/console v1.0.3 // indirect github.com/dlclark/regexp2 v1.4.0 // indirect - github.com/emirpasic/gods v1.18.1 // indirect - github.com/go-git/gcfg v1.5.0 // indirect github.com/go-logfmt/logfmt v0.6.0 // indirect github.com/gorilla/css v1.0.0 // indirect - github.com/imdario/mergo v0.3.13 // indirect github.com/inconshreveable/mousetrap v1.0.1 // indirect - github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect - github.com/kevinburke/ssh_config v1.2.0 // indirect github.com/lucasb-eyer/go-colorful v1.2.0 // indirect github.com/mattn/go-isatty v0.0.18 // indirect github.com/mattn/go-localereader v0.0.1 // indirect @@ -67,18 +56,13 @@ require ( github.com/muesli/mango v0.1.0 // indirect github.com/muesli/mango-pflag v0.1.0 // indirect github.com/olekukonko/tablewriter v0.0.5 // indirect - github.com/pjbgf/sha1cd v0.3.0 // indirect github.com/rivo/uniseg v0.2.0 // indirect github.com/sahilm/fuzzy v0.1.0 // indirect - github.com/skeema/knownhosts v1.1.0 // indirect github.com/spf13/pflag v1.0.5 // indirect - github.com/xanzy/ssh-agent v0.3.3 // indirect github.com/yuin/goldmark v1.5.2 // indirect github.com/yuin/goldmark-emoji v1.0.1 // indirect golang.org/x/net v0.8.0 // indirect - golang.org/x/sync v0.1.0 // indirect golang.org/x/sys v0.6.0 // indirect golang.org/x/term v0.6.0 // indirect golang.org/x/text v0.8.0 // indirect - gopkg.in/warnings.v0 v0.1.2 // indirect ) diff --git a/go.sum b/go.sum index 9ecb2eaee..626d920b1 100644 --- a/go.sum +++ b/go.sum @@ -1,19 +1,10 @@ -github.com/Microsoft/go-winio v0.4.14/go.mod h1:qXqCSQ3Xa7+6tgxaGTIe4Kpcdsi+P8jBhyzoq1bpyYA= -github.com/Microsoft/go-winio v0.4.16/go.mod h1:XB6nPKklQyQ7GC9LdcBEcBl8PF76WugXOPRXwdLnMv0= -github.com/Microsoft/go-winio v0.5.2 h1:a9IhgEQBCUEk6QCdml9CiJGhAws+YwffDHEMp1VMrpA= github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY= -github.com/ProtonMail/go-crypto v0.0.0-20210428141323-04723f9f07d7/go.mod h1:z4/9nQmJSSwwds7ejkxaJwO37dru3geImFUdJlaLzQo= -github.com/ProtonMail/go-crypto v0.0.0-20230217124315-7d5c6f04bbb8 h1:wPbRQzjjwFc0ih8puEVAOFGELsn1zoIIYdxvML7mDxA= github.com/ProtonMail/go-crypto v0.0.0-20230217124315-7d5c6f04bbb8/go.mod h1:I0gYDMZ6Z5GRU7l58bNFSkPTFN6Yl12dsUlAZ8xy98g= -github.com/acomagu/bufpipe v1.0.3/go.mod h1:mxdxdup/WdsKVreO5GpW4+M/1CE2sMG4jeGJ2sYmHc4= -github.com/acomagu/bufpipe v1.0.4 h1:e3H4WUzM3npvo5uv95QuJM3cQspFNtFBzvJ2oNjKIDQ= github.com/acomagu/bufpipe v1.0.4/go.mod h1:mxdxdup/WdsKVreO5GpW4+M/1CE2sMG4jeGJ2sYmHc4= github.com/alecthomas/chroma v0.10.0 h1:7XDcGkCQopCNKjZHfYrNLraA+M7e0fMiJ/Mfikbfjek= github.com/alecthomas/chroma v0.10.0/go.mod h1:jtJATyUxlIORhUOFNA9NZDWGAQ8wpxQQqNSB4rjA/1s= -github.com/anmitsu/go-shlex v0.0.0-20161002113705-648efa622239/go.mod h1:2FmKhYUyUczH0OGQWaF5ceTx0UBShxjsH6f8oGKYe2c= github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8= github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4= -github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio= github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= @@ -46,11 +37,13 @@ github.com/charmbracelet/lipgloss v0.5.0/go.mod h1:EZLha/HbzEt7cYqdFPovlqy5FZPj0 github.com/charmbracelet/lipgloss v0.6.0/go.mod h1:tHh2wr34xcHjC2HCXIlGSG1jaDF0S0atAUvBMP6Ppuk= github.com/charmbracelet/lipgloss v0.7.1 h1:17WMwi7N1b1rVWOjMT+rCh7sQkvDU75B2hbZpc5Kc1E= github.com/charmbracelet/lipgloss v0.7.1/go.mod h1:yG0k3giv8Qj8edTCbbg6AlQ5e8KNWpFujkNawKNhE2c= +github.com/charmbracelet/log v0.1.2/go.mod h1:86XdIdmrubqtL/6u0z+jGFol1bQejBGG/qPSTwGZuQQ= github.com/charmbracelet/log v0.2.1 h1:1z7jpkk4yKyjwlmKmKMM5qnEDSpV32E7XtWhuv0mTZE= github.com/charmbracelet/log v0.2.1/go.mod h1:GwFfjewhcVDWLrpAbY5A0Hin9YOlEn40eWT4PNaxFT4= -github.com/charmbracelet/wish v0.7.0 h1:rdfacCWaKCQpCMPbOKfi68GYqsb+9CnUzN1Ov/INZJ0= -github.com/charmbracelet/wish v0.7.0/go.mod h1:16EQz7k3hEgPkPENghcpEddvlrmucIudE0jnczKr+k4= -github.com/cloudflare/circl v1.1.0 h1:bZgT/A+cikZnKIwn7xL2OBj012Bmvho/o6RpRvv3GKY= +github.com/charmbracelet/ssh v0.0.0-20221117183211-483d43d97103 h1:wpHMERIN0pQZE635jWwT1dISgfjbpUcEma+fbPKSMCU= +github.com/charmbracelet/ssh v0.0.0-20221117183211-483d43d97103/go.mod h1:0Vm2/8yBljiLDnGJHU8ehswfawrEybGk33j5ssqKQVM= +github.com/charmbracelet/wish v1.1.0 h1:0ArX9SOG70saqd23NYjoS56oLPVNgqcQegkz1Lw+4zY= +github.com/charmbracelet/wish v1.1.0/go.mod h1:yHbm0hs/qX4lFE7nrhAcXjFYc8bxMIfSqJOfOYfwyYo= github.com/cloudflare/circl v1.1.0/go.mod h1:prBCrKB9DV4poKZY1l9zBXg2QJY7mvgRvtMxxK7fi4I= github.com/containerd/console v1.0.3 h1:lIr7SlA5PxZyMV30bDW0MGbiOPXwc63yRuCP0ARubLw= github.com/containerd/console v1.0.3/go.mod h1:7LqA/THxQ86k76b8c/EMSiaJ3h1eZkMkXar0TQ1gf3U= @@ -63,23 +56,12 @@ github.com/dlclark/regexp2 v1.4.0 h1:F1rxgk7p4uKjwIQxBs9oAXe5CqrXlCduYEJvrF4u93E github.com/dlclark/regexp2 v1.4.0/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= -github.com/emirpasic/gods v1.12.0/go.mod h1:YfzfFFoVP/catgzJb4IKIqXjX78Ha8FMSDh3ymbK86o= -github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc= github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ= -github.com/flynn/go-shlex v0.0.0-20150515145356-3f9db97f8568/go.mod h1:xEzjJPgXI435gkrCt3MPfRiAkVrwSbHsst4LCFVfpJc= -github.com/gliderlabs/ssh v0.2.2/go.mod h1:U7qILu1NlMHj9FlMhZLlkCdDnU1DBEAqr0aevW3Awn0= -github.com/gliderlabs/ssh v0.3.5 h1:OcaySEmAQJgyYcArR+gGGTHCyE7nvhEMTlYY+Dp8CpY= github.com/gliderlabs/ssh v0.3.5/go.mod h1:8XB4KraRrX39qHhT6yxPsHedjA08I/uBVwj4xC+/+z4= -github.com/go-git/gcfg v1.5.0 h1:Q5ViNfGF8zFgyJWPqYwA7qGFoMTEiBmdlkcfRmpIMa4= github.com/go-git/gcfg v1.5.0/go.mod h1:5m20vg6GwYabIxaOonVkTdrILxQMpEShl1xiMF4ua+E= -github.com/go-git/go-billy/v5 v5.2.0/go.mod h1:pmpqyWchKfYfrkb/UVH4otLvyi/5gJlGI4Hb3ZqZ3W0= github.com/go-git/go-billy/v5 v5.3.1/go.mod h1:pmpqyWchKfYfrkb/UVH4otLvyi/5gJlGI4Hb3ZqZ3W0= -github.com/go-git/go-billy/v5 v5.4.1 h1:Uwp5tDRkPr+l/TnbHOQzp+tmJfLceOlbVucgpTz8ix4= github.com/go-git/go-billy/v5 v5.4.1/go.mod h1:vjbugF6Fz7JIflbVpl1hJsGjSHNltrSw45YK/ukIvQg= -github.com/go-git/go-git-fixtures/v4 v4.2.1/go.mod h1:K8zd3kDUAykwTdDCr+I0per6Y6vMiRR/nnVTBtavnB0= -github.com/go-git/go-git-fixtures/v4 v4.3.1 h1:y5z6dd3qi8Hl+stezc8p3JxDkoTRqMAlKnXHuzrfjTQ= github.com/go-git/go-git-fixtures/v4 v4.3.1/go.mod h1:8LHG1a3SRW71ettAD/jW13h8c6AqjVSeL11RAdgaqpo= -github.com/go-git/go-git/v5 v5.4.2/go.mod h1:gQ1kArt6d+n+BGd+/B/I74HwRTLhth2+zti4ihgckDc= github.com/go-git/go-git/v5 v5.6.1 h1:q4ZRqQl4pR/ZJHc1L5CFjGA1a10u76aV1iC+nh+bHsk= github.com/go-git/go-git/v5 v5.6.1/go.mod h1:mvyoL6Unz0PiTQrGQfSfiLFhBH1c1e84ylC2MDs4ee8= github.com/go-logfmt/logfmt v0.6.0 h1:wGYYu3uicYdqXVgoYbvnkrPVXkuLM1p1ifugDMEdRi4= @@ -88,28 +70,17 @@ github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y= github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8= github.com/gogs/git-module v1.8.1 h1:yC5BZ3unJOXC8N6/FgGQ8EtJXpOd217lgDcd2aPOxkc= github.com/gogs/git-module v1.8.1/go.mod h1:Y3rsSqtFZEbn7lp+3gWf42GKIY1eNTtLt7JrmOy0yAQ= -github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE= -github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= -github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= -github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/gorilla/css v1.0.0 h1:BQqNyPTi50JCFMTw/b67hByjMVXZRwGha6wxVGkeihY= github.com/gorilla/css v1.0.0/go.mod h1:Dn721qIggHpt4+EFCcTLTU/vk5ySda2ReITrtgBl60c= -github.com/hashicorp/golang-lru/v2 v2.0.1/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= -github.com/imdario/mergo v0.3.12/go.mod h1:jmQim1M+e3UYxmgPu/WyfjB3N3VflVyUjjjwH0dnCYA= -github.com/imdario/mergo v0.3.13 h1:lFzP57bqS/wsqKssCGmtLAb8A0wKjLGrve2q3PPVcBk= +github.com/hashicorp/golang-lru/v2 v2.0.2/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= github.com/imdario/mergo v0.3.13/go.mod h1:4lJ1jqUDcsbIECGy0RUJAXNIhg+6ocWgb1ALK2O4oXg= github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8= github.com/inconshreveable/mousetrap v1.0.1 h1:U3uMjPSQEBMNp1lFxmllqCPM6P5u/Xq7Pgzkat/bFNc= github.com/inconshreveable/mousetrap v1.0.1/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= -github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A= github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo= github.com/jessevdk/go-flags v1.5.0/go.mod h1:Fw0T6WPc1dYxT4mKEZRfG5kJhaTDP9pj1c2EWnYs/m4= -github.com/kevinburke/ssh_config v0.0.0-20201106050909-4977a11b4351/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM= -github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4= github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM= -github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.2.1 h1:Fmg33tUaq4/8ym9TJN1x7sLJnHVwhP33CNkpYV/7rwI= github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= @@ -124,7 +95,6 @@ github.com/lrstanley/bubblezone v0.0.0-20220716194435-3cb8c52f6a8f/go.mod h1:Cxa github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/matryer/is v1.2.0/go.mod h1:2fLPjFQM9rhQ15aVEtbuwhJinnOqrmgXPNdZsdwlWXA= -github.com/matryer/is v1.4.0/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU= github.com/matryer/is v1.4.1 h1:55ehd8zaGABKLXQUe2awZ99BD/PTc2ls+KV/dXphgEQ= github.com/matryer/is v1.4.1/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU= github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= @@ -173,10 +143,7 @@ github.com/muesli/termenv v0.15.1/go.mod h1:HeAQPTzpfs016yGtA4g00CsdYnVLJvxsS4AN github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec= github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY= -github.com/pjbgf/sha1cd v0.3.0 h1:4D5XXmUUBUl/xQ6IjCkEAbqXskkq/4O7LmGn0AqMDs4= github.com/pjbgf/sha1cd v0.3.0/go.mod h1:nZ1rrWOcGJ5uZgEEVL1VUM9iRQiZvWdbZjkKyFzPPsI= -github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= -github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= @@ -188,9 +155,7 @@ github.com/sahilm/fuzzy v0.1.0 h1:FzWGaw2Opqyu+794ZQ9SYifWv2EIXpwP4q8dY1kDAwI= github.com/sahilm/fuzzy v0.1.0/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y= github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0= github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM= -github.com/sirupsen/logrus v1.4.1/go.mod h1:ni0Sbl8bgC9z8RoU9G6nDWqqs/fq4eDPysMBDgk/93Q= github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= -github.com/skeema/knownhosts v1.1.0 h1:Wvr9V0MxhjRbl3f9nMnKnFfiWTJmtECJ9Njkea3ysW0= github.com/skeema/knownhosts v1.1.0/go.mod h1:sKFq3RD6/TKZkSWn8boUbDC7Qkgcv+8XXijpFO6roag= github.com/spf13/cobra v1.5.0/go.mod h1:dWXEIy2H428czQCjInthrTRUg7yKbok+2Qi/yBIJoUM= github.com/spf13/cobra v1.6.1 h1:o94oiPyS4KD1mPy2fmcYYHHfCxLqYjJOhGsCHFZtEzA= @@ -198,7 +163,6 @@ github.com/spf13/cobra v1.6.1/go.mod h1:IOw/AERYS7UzyrGinqmz6HLUo219MORXGxhbaJUq github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= @@ -209,8 +173,6 @@ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.8.2 h1:+h33VjcLVPDHtOdpUCuF+7gSuG3yGIftsP1YvFihtJ8= github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= -github.com/xanzy/ssh-agent v0.3.0/go.mod h1:3s9xbODqPuuhK9JV1R321M/FlMZSBvE5aY6eAcqrDh0= -github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM= github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= @@ -219,10 +181,7 @@ github.com/yuin/goldmark v1.5.2/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5ta github.com/yuin/goldmark-emoji v1.0.1 h1:ctuWEyzGBwiucEqxzwe0SOYDXPAucOrE9NQC18Wa1os= github.com/yuin/goldmark-emoji v1.0.1/go.mod h1:2w1E6FEWLcDQkoTE+7HU6QF1F6SLlNGjRIBbIZQFqkQ= golang.org/x/arch v0.1.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= -golang.org/x/crypto v0.0.0-20190219172222-a4c6cb3142f2/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= -golang.org/x/crypto v0.0.0-20210322153248-0c34fe9e7dc2/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4= -golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20220307211146-efcb8507fb70/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.0.0-20220525230936-793ad666bf5e/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= @@ -237,7 +196,6 @@ golang.org/x/mod v0.6.0/go.mod h1:4mET923SAdbXp2ki8ey+zGs1SLqsuM2Y0uvdZR/fUNI= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= -golang.org/x/net v0.0.0-20210326060303-6b1517762897/go.mod h1:uSPa2vr4CLtc/ILN5odXGNXS6mhrKVzTaCXzk9m6W3k= golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.0.0-20220826154423-83b083e8dc8b/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk= @@ -249,22 +207,16 @@ golang.org/x/net v0.8.0 h1:Zrh2ngAOFYneWTAIAPethzeaQLuHwhuBkuV6ZiRnUaQ= golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200302150141-5c8b2ff67527/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210320140829-1e4c9ba3b0c4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210324051608-47abb6519492/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210502180810-71e4cd670f79/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -297,24 +249,21 @@ golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.8.0 h1:57P1ETyNKtuIjB4SRd15iJxuhj8Gc416Y78H3qgMh68= golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= -golang.org/x/time v0.0.0-20220411224347-583f2d630306/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/time v0.3.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.2.0/go.mod h1:y4OqIKeOV/fWJetJ8bXPU1sEVniLMIyDAZWeHdV+NTA= golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= -gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME= gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.0/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/server/backend/file/file.go b/server/backend/file/file.go index 9cdcd3e18..c29bc3258 100644 --- a/server/backend/file/file.go +++ b/server/backend/file/file.go @@ -32,7 +32,7 @@ import ( "github.com/charmbracelet/log" "github.com/charmbracelet/soft-serve/git" "github.com/charmbracelet/soft-serve/server/backend" - "github.com/gliderlabs/ssh" + "github.com/charmbracelet/ssh" gitm "github.com/gogs/git-module" gossh "golang.org/x/crypto/ssh" ) @@ -53,6 +53,14 @@ const ( var ( logger = log.WithPrefix("backend.file") + + defaults = map[string]string{ + serverName: "Soft Serve", + serverHost: "localhost", + serverPort: "23231", + anonAccess: backend.ReadOnlyAccess.String(), + allowKeyless: "true", + } ) var _ backend.Backend = &FileBackend{} @@ -114,8 +122,17 @@ func NewFileBackend(path string) (*FileBackend, error) { } } for _, file := range []string{admins, anonAccess, allowKeyless, serverHost, serverName, serverPort} { - if _, err := os.OpenFile(filepath.Join(path, file), os.O_RDONLY|os.O_CREATE, 0644); err != nil { - return nil, err + fp := filepath.Join(path, file) + _, err := os.Stat(fp) + if errors.Is(err, fs.ErrNotExist) { + f, err := os.Create(fp) + if err != nil { + return nil, err + } + if c, ok := defaults[file]; ok { + io.WriteString(f, c) // nolint:errcheck + } + _ = f.Close() } } return fb, nil @@ -586,10 +603,7 @@ func (fb *FileBackend) SetDefaultBranch(repo string, branch string) error { return err } - if _, err := r.SymbolicRef(gitm.SymbolicRefOptions{ - Name: "HEAD", - Ref: gitm.RefsHeads + branch, - }); err != nil { + if _, err := r.SymbolicRef("HEAD", gitm.RefsHeads+branch); err != nil { logger.Debug("failed to set default branch", "err", err) return err } diff --git a/server/backend/noop/noop.go b/server/backend/noop/noop.go new file mode 100644 index 000000000..c85e2b54b --- /dev/null +++ b/server/backend/noop/noop.go @@ -0,0 +1,163 @@ +package noop + +import ( + "fmt" + "os" + "path/filepath" + + "github.com/charmbracelet/soft-serve/git" + "github.com/charmbracelet/soft-serve/server/backend" + "golang.org/x/crypto/ssh" +) + +var ErrNotImpl = fmt.Errorf("not implemented") + +var _ backend.Backend = (*Noop)(nil) + +var _ backend.AccessMethod = (*Noop)(nil) + +// Noop is a backend that does nothing. It's used for testing. +type Noop struct { + Port string +} + +// AccessLevel implements backend.AccessMethod +func (*Noop) AccessLevel(repo string, pk ssh.PublicKey) backend.AccessLevel { + return backend.AdminAccess +} + +// AddAdmin implements backend.Backend +func (*Noop) AddAdmin(pk ssh.PublicKey) error { + return ErrNotImpl +} + +// AddCollaborator implements backend.Backend +func (*Noop) AddCollaborator(pk ssh.PublicKey, repo string) error { + return ErrNotImpl +} + +// AllowKeyless implements backend.Backend +func (*Noop) AllowKeyless() bool { + return true +} + +// AnonAccess implements backend.Backend +func (*Noop) AnonAccess() backend.AccessLevel { + return backend.AdminAccess +} + +// CreateRepository implements backend.Backend +func (*Noop) CreateRepository(name string, private bool) (backend.Repository, error) { + temp, err := os.MkdirTemp("", "soft-serve") + if err != nil { + return nil, err + } + + rp := filepath.Join(temp, name) + _, err = git.Init(rp, private) + if err != nil { + return nil, err + } + + return &repo{path: rp}, nil +} + +// DefaultBranch implements backend.Backend +func (*Noop) DefaultBranch(repo string) (string, error) { + return "", ErrNotImpl +} + +// DeleteRepository implements backend.Backend +func (*Noop) DeleteRepository(name string) error { + return ErrNotImpl +} + +// Description implements backend.Backend +func (*Noop) Description(repo string) string { + return "" +} + +// IsAdmin implements backend.Backend +func (*Noop) IsAdmin(pk ssh.PublicKey) bool { + return true +} + +// IsCollaborator implements backend.Backend +func (*Noop) IsCollaborator(pk ssh.PublicKey, repo string) bool { + return true +} + +// IsPrivate implements backend.Backend +func (*Noop) IsPrivate(repo string) bool { + return false +} + +// RenameRepository implements backend.Backend +func (*Noop) RenameRepository(oldName string, newName string) error { + return ErrNotImpl +} + +// Repositories implements backend.Backend +func (*Noop) Repositories() ([]backend.Repository, error) { + return nil, ErrNotImpl +} + +// Repository implements backend.Backend +func (*Noop) Repository(repo string) (backend.Repository, error) { + return nil, ErrNotImpl +} + +// ServerHost implements backend.Backend +func (*Noop) ServerHost() string { + return "localhost" +} + +// ServerName implements backend.Backend +func (*Noop) ServerName() string { + return "Soft Serve" +} + +// ServerPort implements backend.Backend +func (n *Noop) ServerPort() string { + return n.Port +} + +// SetAllowKeyless implements backend.Backend +func (*Noop) SetAllowKeyless(allow bool) error { + return ErrNotImpl +} + +// SetAnonAccess implements backend.Backend +func (*Noop) SetAnonAccess(level backend.AccessLevel) error { + return ErrNotImpl +} + +// SetDefaultBranch implements backend.Backend +func (*Noop) SetDefaultBranch(repo string, branch string) error { + return ErrNotImpl +} + +// SetDescription implements backend.Backend +func (*Noop) SetDescription(repo string, desc string) error { + return ErrNotImpl +} + +// SetPrivate implements backend.Backend +func (*Noop) SetPrivate(repo string, priv bool) error { + return ErrNotImpl +} + +// SetServerHost implements backend.Backend +func (*Noop) SetServerHost(host string) error { + return ErrNotImpl +} + +// SetServerName implements backend.Backend +func (*Noop) SetServerName(name string) error { + return ErrNotImpl +} + +// SetServerPort implements backend.Backend +func (*Noop) SetServerPort(port string) error { + return ErrNotImpl +} diff --git a/server/backend/noop/repo.go b/server/backend/noop/repo.go new file mode 100644 index 000000000..80cbbcf2b --- /dev/null +++ b/server/backend/noop/repo.go @@ -0,0 +1,32 @@ +package noop + +import ( + "github.com/charmbracelet/soft-serve/git" + "github.com/charmbracelet/soft-serve/server/backend" +) + +var _ backend.Repository = (*repo)(nil) + +type repo struct { + path string +} + +// Description implements backend.Repository +func (*repo) Description() string { + return "" +} + +// IsPrivate implements backend.Repository +func (*repo) IsPrivate() bool { + return false +} + +// Name implements backend.Repository +func (*repo) Name() string { + return "" +} + +// Repository implements backend.Repository +func (r *repo) Repository() (*git.Repository, error) { + return git.Open(r.path) +} diff --git a/server/cmd/cat.go b/server/cmd/cat.go deleted file mode 100644 index 750089186..000000000 --- a/server/cmd/cat.go +++ /dev/null @@ -1,123 +0,0 @@ -package cmd - -import ( - "fmt" - "strings" - - "github.com/alecthomas/chroma/lexers" - gansi "github.com/charmbracelet/glamour/ansi" - "github.com/charmbracelet/lipgloss" - "github.com/charmbracelet/soft-serve/config" - "github.com/charmbracelet/soft-serve/ui/common" - gitwish "github.com/charmbracelet/wish/git" - "github.com/muesli/termenv" - "github.com/spf13/cobra" -) - -var ( - lineDigitStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("239")) - lineBarStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("236")) - dirnameStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#00AAFF")) - filenameStyle = lipgloss.NewStyle() - filemodeStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#777777")) -) - -// CatCommand returns a command that prints the contents of a file. -func CatCommand() *cobra.Command { - var linenumber bool - var color bool - - catCmd := &cobra.Command{ - Use: "cat PATH", - Short: "Outputs the contents of the file at path.", - Args: cobra.ExactArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - ac, s := fromContext(cmd) - ps := strings.Split(args[0], "/") - rn := ps[0] - fp := strings.Join(ps[1:], "/") - auth := ac.AuthRepo(rn, s.PublicKey()) - if auth < gitwish.ReadOnlyAccess { - return ErrUnauthorized - } - var repo *config.Repo - repoExists := false - for _, rp := range ac.Source.AllRepos() { - if rp.Repo() == rn { - repoExists = true - repo = rp - break - } - } - if !repoExists { - return ErrRepoNotFound - } - c, _, err := repo.LatestFile(fp) - if err != nil { - return err - } - if color { - c, err = withFormatting(fp, c) - if err != nil { - return err - } - } - if linenumber { - c = withLineNumber(c, color) - } - fmt.Fprint(s, c) - return nil - }, - } - catCmd.Flags().BoolVarP(&linenumber, "linenumber", "l", false, "Print line numbers") - catCmd.Flags().BoolVarP(&color, "color", "c", false, "Colorize output") - - return catCmd -} - -func withLineNumber(s string, color bool) string { - lines := strings.Split(s, "\n") - // NB: len() is not a particularly safe way to count string width (because - // it's counting bytes instead of runes) but in this case it's okay - // because we're only dealing with digits, which are one byte each. - mll := len(fmt.Sprintf("%d", len(lines))) - for i, l := range lines { - digit := fmt.Sprintf("%*d", mll, i+1) - bar := "│" - if color { - digit = lineDigitStyle.Render(digit) - bar = lineBarStyle.Render(bar) - } - if i < len(lines)-1 || len(l) != 0 { - // If the final line was a newline we'll get an empty string for - // the final line, so drop the newline altogether. - lines[i] = fmt.Sprintf(" %s %s %s", digit, bar, l) - } - } - return strings.Join(lines, "\n") -} - -func withFormatting(p, c string) (string, error) { - zero := uint(0) - lang := "" - lexer := lexers.Match(p) - if lexer != nil && lexer.Config() != nil { - lang = lexer.Config().Name - } - formatter := &gansi.CodeBlockElement{ - Code: c, - Language: lang, - } - r := strings.Builder{} - styles := common.StyleConfig() - styles.CodeBlock.Margin = &zero - rctx := gansi.NewRenderContext(gansi.Options{ - Styles: styles, - ColorProfile: termenv.TrueColor, - }) - err := formatter.Render(&r, rctx) - if err != nil { - return "", err - } - return r.String(), nil -} diff --git a/server/cmd/cmd.go b/server/cmd/cmd.go index 684fe7ec6..00cfc3f26 100644 --- a/server/cmd/cmd.go +++ b/server/cmd/cmd.go @@ -3,8 +3,8 @@ package cmd import ( "fmt" - appCfg "github.com/charmbracelet/soft-serve/config" - "github.com/gliderlabs/ssh" + "github.com/charmbracelet/soft-serve/server/config" + "github.com/charmbracelet/ssh" "github.com/spf13/cobra" ) @@ -68,18 +68,15 @@ func RootCommand() *cobra.Command { rootCmd.SetUsageTemplate(usageTemplate) rootCmd.CompletionOptions.DisableDefaultCmd = true rootCmd.AddCommand( - ReloadCommand(), - CatCommand(), - ListCommand(), - GitCommand(), + RepoCommand(), ) return rootCmd } -func fromContext(cmd *cobra.Command) (*appCfg.Config, ssh.Session) { +func fromContext(cmd *cobra.Command) (*config.Config, ssh.Session) { ctx := cmd.Context() - ac := ctx.Value(ConfigCtxKey).(*appCfg.Config) + cfg := ctx.Value(ConfigCtxKey).(*config.Config) s := ctx.Value(SessionCtxKey).(ssh.Session) - return ac, s + return cfg, s } diff --git a/server/cmd/git.go b/server/cmd/git.go deleted file mode 100644 index d9a60070e..000000000 --- a/server/cmd/git.go +++ /dev/null @@ -1,54 +0,0 @@ -package cmd - -import ( - "io" - "os/exec" - - "github.com/charmbracelet/soft-serve/config" - gitwish "github.com/charmbracelet/wish/git" - "github.com/spf13/cobra" -) - -// GitCommand returns a command that handles Git operations. -func GitCommand() *cobra.Command { - gitCmd := &cobra.Command{ - Use: "git REPO COMMAND", - Short: "Perform Git operations on a repository.", - RunE: func(cmd *cobra.Command, args []string) error { - ac, s := fromContext(cmd) - auth := ac.AuthRepo("config", s.PublicKey()) - if auth < gitwish.AdminAccess { - return ErrUnauthorized - } - if len(args) < 1 { - return runGit(nil, s, s, "") - } - var repo *config.Repo - rn := args[0] - repoExists := false - for _, rp := range ac.Source.AllRepos() { - if rp.Repo() == rn { - repoExists = true - repo = rp - break - } - } - if !repoExists { - return ErrRepoNotFound - } - return runGit(nil, s, s, repo.Path(), args[1:]...) - }, - } - gitCmd.Flags().SetInterspersed(false) - - return gitCmd -} - -func runGit(in io.Reader, out, err io.Writer, dir string, args ...string) error { - cmd := exec.Command("git", args...) - cmd.Stdin = in - cmd.Stdout = out - cmd.Stderr = err - cmd.Dir = dir - return cmd.Run() -} diff --git a/server/cmd/list.go b/server/cmd/list.go deleted file mode 100644 index ff4045c5b..000000000 --- a/server/cmd/list.go +++ /dev/null @@ -1,83 +0,0 @@ -package cmd - -import ( - "fmt" - "path/filepath" - "strings" - - "github.com/charmbracelet/soft-serve/git" - gitwish "github.com/charmbracelet/wish/git" - "github.com/spf13/cobra" -) - -// ListCommand returns a command that list file or directory at path. -func ListCommand() *cobra.Command { - lsCmd := &cobra.Command{ - Use: "ls PATH", - Aliases: []string{"list"}, - Short: "List file or directory at path.", - Args: cobra.RangeArgs(0, 1), - RunE: func(cmd *cobra.Command, args []string) error { - ac, s := fromContext(cmd) - rn := "" - path := "" - ps := []string{} - if len(args) > 0 { - path = filepath.Clean(args[0]) - ps = strings.Split(path, "/") - rn = ps[0] - auth := ac.AuthRepo(rn, s.PublicKey()) - if auth < gitwish.ReadOnlyAccess { - return ErrUnauthorized - } - } - if path == "" || path == "." || path == "/" { - for _, r := range ac.Source.AllRepos() { - if ac.AuthRepo(r.Repo(), s.PublicKey()) >= gitwish.ReadOnlyAccess { - fmt.Fprintln(s, r.Repo()) - } - } - return nil - } - r, err := ac.Source.GetRepo(rn) - if err != nil { - return err - } - head, err := r.HEAD() - if err != nil { - return err - } - tree, err := r.Tree(head, "") - if err != nil { - return err - } - subpath := strings.Join(ps[1:], "/") - ents := git.Entries{} - te, err := tree.TreeEntry(subpath) - if err == git.ErrRevisionNotExist { - return ErrFileNotFound - } - if err != nil { - return err - } - if te.Type() == "tree" { - tree, err = tree.SubTree(subpath) - if err != nil { - return err - } - ents, err = tree.Entries() - if err != nil { - return err - } - } else { - ents = append(ents, te) - } - ents.Sort() - for _, ent := range ents { - fmt.Fprintf(s, "%s\t%d\t %s\n", ent.Mode(), ent.Size(), ent.Name()) - } - return nil - }, - } - return lsCmd -} diff --git a/server/middleware.go b/server/cmd/middleware.go similarity index 50% rename from server/middleware.go rename to server/cmd/middleware.go index a4969f5fe..8635a6650 100644 --- a/server/middleware.go +++ b/server/cmd/middleware.go @@ -1,17 +1,16 @@ -package server +package cmd import ( "context" "fmt" - appCfg "github.com/charmbracelet/soft-serve/config" - "github.com/charmbracelet/soft-serve/server/cmd" + "github.com/charmbracelet/soft-serve/server/config" + "github.com/charmbracelet/ssh" "github.com/charmbracelet/wish" - "github.com/gliderlabs/ssh" ) -// softMiddleware is the Soft Serve middleware that handles SSH commands. -func softMiddleware(ac *appCfg.Config) wish.Middleware { +// Middleware is the Soft Serve middleware that handles SSH commands. +func Middleware(cfg *config.Config) wish.Middleware { return func(sh ssh.Handler) ssh.Handler { return func(s ssh.Session) { func() { @@ -19,16 +18,16 @@ func softMiddleware(ac *appCfg.Config) wish.Middleware { if active { return } - ctx := context.WithValue(s.Context(), cmd.ConfigCtxKey, ac) - ctx = context.WithValue(ctx, cmd.SessionCtxKey, s) + ctx := context.WithValue(s.Context(), ConfigCtxKey, cfg) + ctx = context.WithValue(ctx, SessionCtxKey, s) use := "ssh" - port := ac.Port - if port != 22 { - use += fmt.Sprintf(" -p%d", port) + port := cfg.Backend.ServerPort() + if port != "22" { + use += fmt.Sprintf(" -p%s", port) } - use += fmt.Sprintf(" %s", ac.Host) - cmd := cmd.RootCommand() + use += fmt.Sprintf(" %s", cfg.Backend.ServerHost()) + cmd := RootCommand() cmd.Use = use cmd.CompletionOptions.DisableDefaultCmd = true cmd.SetIn(s) diff --git a/server/cmd/reload.go b/server/cmd/reload.go deleted file mode 100644 index 7f2312ab6..000000000 --- a/server/cmd/reload.go +++ /dev/null @@ -1,23 +0,0 @@ -package cmd - -import ( - gitwish "github.com/charmbracelet/wish/git" - "github.com/spf13/cobra" -) - -// ReloadCommand returns a command that reloads the server configuration. -func ReloadCommand() *cobra.Command { - reloadCmd := &cobra.Command{ - Use: "reload", - Short: "Reloads the configuration", - RunE: func(cmd *cobra.Command, args []string) error { - ac, s := fromContext(cmd) - auth := ac.AuthRepo("config", s.PublicKey()) - if auth < gitwish.AdminAccess { - return ErrUnauthorized - } - return ac.Reload() - }, - } - return reloadCmd -} diff --git a/server/cmd/repo.go b/server/cmd/repo.go new file mode 100644 index 000000000..c2324bee7 --- /dev/null +++ b/server/cmd/repo.go @@ -0,0 +1,408 @@ +package cmd + +import ( + "fmt" + "path/filepath" + "strconv" + "strings" + + "github.com/alecthomas/chroma/lexers" + gansi "github.com/charmbracelet/glamour/ansi" + "github.com/charmbracelet/lipgloss" + "github.com/charmbracelet/soft-serve/git" + "github.com/charmbracelet/soft-serve/server/backend" + "github.com/charmbracelet/soft-serve/ui/common" + "github.com/muesli/termenv" + "github.com/spf13/cobra" +) + +// RepoCommand is the command for managing repositories. +func RepoCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "repo COMMAND", + Aliases: []string{"repository", "repositories"}, + Short: "Manage repositories.", + } + cmd.AddCommand( + setCommand(), + createCommand(), + deleteCommand(), + listCommand(), + showCommand(), + ) + return cmd +} + +func setCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "set", + Short: "Set repository properties.", + } + cmd.AddCommand( + setName(), + setDescription(), + setPrivate(), + setDefaultBranch(), + ) + return cmd +} + +// createCommand is the command for creating a new repository. +func createCommand() *cobra.Command { + var private bool + var description string + var projectName string + cmd := &cobra.Command{ + Use: "create REPOSITORY", + Short: "Create a new repository.", + Args: cobra.ExactArgs(1), + PersistentPreRunE: func(cmd *cobra.Command, args []string) error { + cfg, s := fromContext(cmd) + if !cfg.Backend.IsAdmin(s.PublicKey()) { + return ErrUnauthorized + } + return nil + }, + RunE: func(cmd *cobra.Command, args []string) error { + cfg, _ := fromContext(cmd) + name := args[0] + if _, err := cfg.Backend.CreateRepository(name, private); err != nil { + return err + } + return nil + }, + } + cmd.Flags().BoolVarP(&private, "private", "p", false, "make the repository private") + cmd.Flags().StringVarP(&description, "description", "d", "", "set the repository description") + cmd.Flags().StringVarP(&projectName, "project-name", "n", "", "set the project name") + return cmd +} + +func deleteCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "delete REPOSITORY", + Short: "Delete a repository.", + Args: cobra.ExactArgs(1), + PersistentPreRunE: checkIfAdmin, + RunE: func(cmd *cobra.Command, args []string) error { + cfg, _ := fromContext(cmd) + name := args[0] + if err := cfg.Backend.DeleteRepository(name); err != nil { + return err + } + return nil + }, + } + return cmd +} + +func checkIfReadable(cmd *cobra.Command, args []string) error { + var repo string + if len(args) > 0 { + repo = args[0] + } + cfg, s := fromContext(cmd) + rn := strings.TrimSuffix(repo, ".git") + auth := cfg.Access.AccessLevel(rn, s.PublicKey()) + if auth < backend.ReadOnlyAccess { + return ErrUnauthorized + } + return nil +} + +func checkIfAdmin(cmd *cobra.Command, args []string) error { + cfg, s := fromContext(cmd) + if !cfg.Backend.IsAdmin(s.PublicKey()) { + return ErrUnauthorized + } + return nil +} + +func checkIfCollab(cmd *cobra.Command, args []string) error { + var repo string + if len(args) > 0 { + repo = args[0] + } + cfg, s := fromContext(cmd) + rn := strings.TrimSuffix(repo, ".git") + auth := cfg.Access.AccessLevel(rn, s.PublicKey()) + if auth < backend.ReadWriteAccess { + return ErrUnauthorized + } + return nil +} + +func setName() *cobra.Command { + cmd := &cobra.Command{ + Use: "name REPOSITORY NEW_NAME", + Short: "Set the name for a repository.", + Args: cobra.ExactArgs(2), + PersistentPreRunE: checkIfAdmin, + RunE: func(cmd *cobra.Command, args []string) error { + cfg, _ := fromContext(cmd) + oldName := args[0] + newName := args[1] + if err := cfg.Backend.RenameRepository(oldName, newName); err != nil { + return err + } + return nil + }, + } + return cmd +} + +func setDescription() *cobra.Command { + cmd := &cobra.Command{ + Use: "description REPOSITORY DESCRIPTION", + Short: "Set the description for a repository.", + Args: cobra.MinimumNArgs(2), + PersistentPreRunE: checkIfCollab, + RunE: func(cmd *cobra.Command, args []string) error { + cfg, _ := fromContext(cmd) + rn := strings.TrimSuffix(args[0], ".git") + if err := cfg.Backend.SetDescription(rn, strings.Join(args[1:], " ")); err != nil { + return err + } + return nil + }, + } + return cmd +} + +func setPrivate() *cobra.Command { + cmd := &cobra.Command{ + Use: "private REPOSITORY [true|false]", + Short: "Set a repository to private.", + Args: cobra.ExactArgs(2), + PersistentPreRunE: checkIfCollab, + RunE: func(cmd *cobra.Command, args []string) error { + cfg, _ := fromContext(cmd) + rn := strings.TrimSuffix(args[0], ".git") + isPrivate, err := strconv.ParseBool(args[1]) + if err != nil { + return err + } + if err := cfg.Backend.SetPrivate(rn, isPrivate); err != nil { + return err + } + return nil + }, + } + return cmd +} + +func setDefaultBranch() *cobra.Command { + cmd := &cobra.Command{ + Use: "default-branch REPOSITORY BRANCH", + Short: "Set the default branch for a repository.", + Args: cobra.ExactArgs(2), + PersistentPreRunE: checkIfAdmin, + RunE: func(cmd *cobra.Command, args []string) error { + cfg, _ := fromContext(cmd) + rn := strings.TrimSuffix(args[0], ".git") + if err := cfg.Backend.SetDefaultBranch(rn, args[1]); err != nil { + return err + } + return nil + }, + } + return cmd +} + +// listCommand returns a command that list file or directory at path. +func listCommand() *cobra.Command { + listCmd := &cobra.Command{ + Use: "list PATH", + Aliases: []string{"ls"}, + Short: "List file or directory at path.", + Args: cobra.RangeArgs(0, 1), + PersistentPreRunE: checkIfReadable, + RunE: func(cmd *cobra.Command, args []string) error { + cfg, s := fromContext(cmd) + rn := "" + path := "" + ps := []string{} + if len(args) > 0 { + path = filepath.Clean(args[0]) + ps = strings.Split(path, "/") + rn = strings.TrimSuffix(ps[0], ".git") + auth := cfg.Access.AccessLevel(rn, s.PublicKey()) + if auth < backend.ReadOnlyAccess { + return ErrUnauthorized + } + } + if path == "" || path == "." || path == "/" { + repos, err := cfg.Backend.Repositories() + if err != nil { + return err + } + for _, r := range repos { + if cfg.Access.AccessLevel(r.Name(), s.PublicKey()) >= backend.ReadOnlyAccess { + fmt.Fprintln(s, r.Name()) + } + } + return nil + } + rr, err := cfg.Backend.Repository(rn) + if err != nil { + return err + } + r, err := rr.Repository() + if err != nil { + return err + } + head, err := r.HEAD() + if err != nil { + if bs, err := r.Branches(); err != nil && len(bs) == 0 { + return fmt.Errorf("repository is empty") + } + return err + } + tree, err := r.TreePath(head, "") + if err != nil { + return err + } + subpath := strings.Join(ps[1:], "/") + ents := git.Entries{} + te, err := tree.TreeEntry(subpath) + if err == git.ErrRevisionNotExist { + return ErrFileNotFound + } + if err != nil { + return err + } + if te.Type() == "tree" { + tree, err = tree.SubTree(subpath) + if err != nil { + return err + } + ents, err = tree.Entries() + if err != nil { + return err + } + } else { + ents = append(ents, te) + } + ents.Sort() + for _, ent := range ents { + fmt.Fprintf(s, "%s\t%d\t %s\n", ent.Mode(), ent.Size(), ent.Name()) + } + return nil + }, + } + return listCmd +} + +var ( + lineDigitStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("239")) + lineBarStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("236")) + dirnameStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#00AAFF")) + filenameStyle = lipgloss.NewStyle() + filemodeStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#777777")) +) + +// showCommand returns a command that prints the contents of a file. +func showCommand() *cobra.Command { + var linenumber bool + var color bool + + showCmd := &cobra.Command{ + Use: "show PATH", + Aliases: []string{"cat"}, + Short: "Outputs the contents of the file at path.", + Args: cobra.ExactArgs(1), + PersistentPreRunE: checkIfReadable, + RunE: func(cmd *cobra.Command, args []string) error { + cfg, s := fromContext(cmd) + ps := strings.Split(args[0], "/") + rn := strings.TrimSuffix(ps[0], ".git") + fp := strings.Join(ps[1:], "/") + auth := cfg.Access.AccessLevel(rn, s.PublicKey()) + if auth < backend.ReadOnlyAccess { + return ErrUnauthorized + } + var repo backend.Repository + repoExists := false + repos, err := cfg.Backend.Repositories() + if err != nil { + return err + } + for _, rp := range repos { + if rp.Name() == rn { + repoExists = true + repo = rp + break + } + } + if !repoExists { + return ErrRepoNotFound + } + c, _, err := backend.LatestFile(repo, fp) + if err != nil { + return err + } + if color { + c, err = withFormatting(fp, c) + if err != nil { + return err + } + } + if linenumber { + c = withLineNumber(c, color) + } + fmt.Fprint(s, c) + return nil + }, + } + showCmd.Flags().BoolVarP(&linenumber, "linenumber", "l", false, "Print line numbers") + showCmd.Flags().BoolVarP(&color, "color", "c", false, "Colorize output") + + return showCmd +} + +func withLineNumber(s string, color bool) string { + lines := strings.Split(s, "\n") + // NB: len() is not a particularly safe way to count string width (because + // it's counting bytes instead of runes) but in this case it's okay + // because we're only dealing with digits, which are one byte each. + mll := len(fmt.Sprintf("%d", len(lines))) + for i, l := range lines { + digit := fmt.Sprintf("%*d", mll, i+1) + bar := "│" + if color { + digit = lineDigitStyle.Render(digit) + bar = lineBarStyle.Render(bar) + } + if i < len(lines)-1 || len(l) != 0 { + // If the final line was a newline we'll get an empty string for + // the final line, so drop the newline altogether. + lines[i] = fmt.Sprintf(" %s %s %s", digit, bar, l) + } + } + return strings.Join(lines, "\n") +} + +func withFormatting(p, c string) (string, error) { + zero := uint(0) + lang := "" + lexer := lexers.Match(p) + if lexer != nil && lexer.Config() != nil { + lang = lexer.Config().Name + } + formatter := &gansi.CodeBlockElement{ + Code: c, + Language: lang, + } + r := strings.Builder{} + styles := common.StyleConfig() + styles.CodeBlock.Margin = &zero + rctx := gansi.NewRenderContext(gansi.Options{ + Styles: styles, + ColorProfile: termenv.TrueColor, + }) + err := formatter.Render(&r, rctx) + if err != nil { + return "", err + } + return r.String(), nil +} diff --git a/server/config/config.go b/server/config/config.go index 69b3a7098..14dcff657 100644 --- a/server/config/config.go +++ b/server/config/config.go @@ -1,58 +1,93 @@ package config import ( - glog "log" - "path/filepath" - "github.com/caarlos0/env/v6" "github.com/charmbracelet/log" + "github.com/charmbracelet/soft-serve/server/backend" + "github.com/charmbracelet/soft-serve/server/backend/file" ) -// Callbacks provides an interface that can be used to run callbacks on different events. -type Callbacks interface { - Tui(action string) - Push(repo string) - Fetch(repo string) +// SSHConfig is the configuration for the SSH server. +type SSHConfig struct { + // ListenAddr is the address on which the SSH server will listen. + ListenAddr string `env:"LISTEN_ADDR" envDefault:":23231"` + + // KeyPath is the path to the SSH server's private key. + KeyPath string `env:"KEY_PATH" envDefault:"soft_serve"` + + // MaxTimeout is the maximum number of seconds a connection can take. + MaxTimeout int `env:"MAX_TIMEOUT" envDefault:"0"` + + // IdleTimeout is the number of seconds a connection can be idle before it is closed. + IdleTimeout int `env:"IDLE_TIMEOUT" envDefault:"120"` +} + +// GitConfig is the Git daemon configuration for the server. +type GitConfig struct { + // ListenAddr is the address on which the Git daemon will listen. + ListenAddr string `env:"LISTEN_ADDR" envDefault:":9418"` + + // MaxTimeout is the maximum number of seconds a connection can take. + MaxTimeout int `env:"MAX_TIMEOUT" envDefault:"0"` + + // IdleTimeout is the number of seconds a connection can be idle before it is closed. + IdleTimeout int `env:"IDLE_TIMEOUT" envDefault:"3"` + + // MaxConnections is the maximum number of concurrent connections. + MaxConnections int `env:"MAX_CONNECTIONS" envDefault:"32"` } // Config is the configuration for Soft Serve. type Config struct { - BindAddr string `env:"SOFT_SERVE_BIND_ADDRESS" envDefault:""` - Host string `env:"SOFT_SERVE_HOST" envDefault:"localhost"` - Port int `env:"SOFT_SERVE_PORT" envDefault:"23231"` - KeyPath string `env:"SOFT_SERVE_KEY_PATH"` - RepoPath string `env:"SOFT_SERVE_REPO_PATH" envDefault:".repos"` - Debug bool `env:"SOFT_SERVE_DEBUG" envDefault:"false"` - InitialAdminKeys []string `env:"SOFT_SERVE_INITIAL_ADMIN_KEY" envSeparator:"\n"` - Callbacks Callbacks - ErrorLog *glog.Logger + // SSH is the configuration for the SSH server. + SSH SSHConfig `envPrefix:"SSH_"` + + // Git is the configuration for the Git daemon. + Git GitConfig `envPrefix:"GIT_"` + + // InitialAdminKeys is a list of public keys that will be added to the list of admins. + InitialAdminKeys []string `env:"INITIAL_ADMIN_KEY" envSeparator:"\n"` + + // DataPath is the path to the directory where Soft Serve will store its data. + DataPath string `env:"DATA_PATH" envDefault:"data"` + + // Debug enables debug logging. + Debug bool `env:"DEBUG" envDefault:"false"` + + // Backend is the Git backend to use. + Backend backend.Backend + + // Access is the access control backend to use. + Access backend.AccessMethod } // DefaultConfig returns a Config with the values populated with the defaults // or specified environment variables. func DefaultConfig() *Config { - cfg := &Config{ErrorLog: log.StandardLog(log.StandardLogOptions{ForceLevel: log.ErrorLevel})} - if err := env.Parse(cfg); err != nil { + cfg := &Config{} + if err := env.Parse(cfg, env.Options{ + Prefix: "SOFT_SERVE_", + }); err != nil { log.Fatal(err) } if cfg.Debug { log.SetLevel(log.DebugLevel) } - if cfg.KeyPath == "" { - // NB: cross-platform-compatible path - cfg.KeyPath = filepath.Join(".ssh", "soft_serve_server_ed25519") + fb, err := file.NewFileBackend(cfg.DataPath) + if err != nil { + log.Fatal(err) } - return cfg.WithCallbacks(nil) + return cfg.WithBackend(fb).WithAccessMethod(fb) } -// WithCallbacks applies the given Callbacks to the configuration. -func (c *Config) WithCallbacks(callbacks Callbacks) *Config { - c.Callbacks = callbacks +// WithBackend sets the backend for the configuration. +func (c *Config) WithBackend(backend backend.Backend) *Config { + c.Backend = backend return c } -// WithErrorLogger sets the error logger for the configuration. -func (c *Config) WithErrorLogger(logger *glog.Logger) *Config { - c.ErrorLog = logger +// WithAccessMethod sets the access control method for the configuration. +func (c *Config) WithAccessMethod(access backend.AccessMethod) *Config { + c.Access = access return c } diff --git a/server/daemon.go b/server/daemon.go new file mode 100644 index 000000000..0055ff9c6 --- /dev/null +++ b/server/daemon.go @@ -0,0 +1,299 @@ +package server + +import ( + "bytes" + "context" + "errors" + "io" + "log" + "net" + "path/filepath" + "strings" + "sync" + "time" + + "github.com/charmbracelet/soft-serve/server/backend" + "github.com/charmbracelet/soft-serve/server/config" + "github.com/go-git/go-git/v5/plumbing/format/pktline" +) + +// ErrServerClosed indicates that the server has been closed. +var ErrServerClosed = errors.New("git: Server closed") + +// connections synchronizes access to to a net.Conn pool. +type connections struct { + m map[net.Conn]struct{} + mu sync.Mutex +} + +func (m *connections) Add(c net.Conn) { + m.mu.Lock() + defer m.mu.Unlock() + m.m[c] = struct{}{} +} + +func (m *connections) Close(c net.Conn) { + m.mu.Lock() + defer m.mu.Unlock() + _ = c.Close() + delete(m.m, c) +} + +func (m *connections) Size() int { + m.mu.Lock() + defer m.mu.Unlock() + return len(m.m) +} + +func (m *connections) CloseAll() { + m.mu.Lock() + defer m.mu.Unlock() + for c := range m.m { + _ = c.Close() + delete(m.m, c) + } +} + +// GitDaemon represents a Git daemon. +type GitDaemon struct { + listener net.Listener + addr string + finished chan struct{} + conns connections + cfg *config.Config + wg sync.WaitGroup + once sync.Once +} + +// NewDaemon returns a new Git daemon. +func NewGitDaemon(cfg *config.Config) (*GitDaemon, error) { + addr := cfg.Git.ListenAddr + d := &GitDaemon{ + addr: addr, + finished: make(chan struct{}, 1), + cfg: cfg, + conns: connections{m: make(map[net.Conn]struct{})}, + } + listener, err := net.Listen("tcp", d.addr) + if err != nil { + return nil, err + } + d.listener = listener + return d, nil +} + +// Start starts the Git TCP daemon. +func (d *GitDaemon) Start() error { + defer d.listener.Close() // nolint: errcheck + + d.wg.Add(1) + defer d.wg.Done() + + var tempDelay time.Duration + for { + conn, err := d.listener.Accept() + if err != nil { + select { + case <-d.finished: + return ErrServerClosed + default: + log.Printf("git: error accepting connection: %v", err) + } + if ne, ok := err.(net.Error); ok && ne.Temporary() { + if tempDelay == 0 { + tempDelay = 5 * time.Millisecond + } else { + tempDelay *= 2 + } + if max := 1 * time.Second; tempDelay > max { + tempDelay = max + } + time.Sleep(tempDelay) + continue + } + return err + } + + // Close connection if there are too many open connections. + if d.conns.Size()+1 >= d.cfg.Git.MaxConnections { + log.Printf("git: max connections reached, closing %s", conn.RemoteAddr()) + fatal(conn, ErrMaxConnections) + continue + } + + d.wg.Add(1) + go func() { + d.handleClient(conn) + d.wg.Done() + }() + } +} + +func fatal(c net.Conn, err error) { + WritePktline(c, err) + if err := c.Close(); err != nil { + log.Printf("git: error closing connection: %v", err) + } +} + +// handleClient handles a git protocol client. +func (d *GitDaemon) handleClient(conn net.Conn) { + ctx, cancel := context.WithCancel(context.Background()) + idleTimeout := time.Duration(d.cfg.Git.IdleTimeout) * time.Second + c := &serverConn{ + Conn: conn, + idleTimeout: idleTimeout, + closeCanceler: cancel, + } + if d.cfg.Git.MaxTimeout > 0 { + dur := time.Duration(d.cfg.Git.MaxTimeout) * time.Second + c.maxDeadline = time.Now().Add(dur) + } + d.conns.Add(c) + defer func() { + d.conns.Close(c) + }() + + readc := make(chan struct{}, 1) + s := pktline.NewScanner(c) + go func() { + if !s.Scan() { + if err := s.Err(); err != nil { + if nerr, ok := err.(net.Error); ok && nerr.Timeout() { + fatal(c, ErrTimeout) + } else { + log.Printf("git: error scanning pktline: %v", err) + fatal(c, ErrSystemMalfunction) + } + } + return + } + readc <- struct{}{} + }() + + select { + case <-ctx.Done(): + if err := ctx.Err(); err != nil { + log.Printf("git: connection context error: %v", err) + } + return + case <-readc: + line := s.Bytes() + split := bytes.SplitN(line, []byte{' '}, 2) + if len(split) != 2 { + fatal(c, ErrInvalidRequest) + return + } + + var gitPack func(io.Reader, io.Writer, io.Writer, string) error + var repo string + cmd := string(split[0]) + switch cmd { + case UploadPackBin: + gitPack = UploadPack + case UploadArchiveBin: + gitPack = UploadArchive + default: + fatal(c, ErrInvalidRequest) + return + } + + opts := bytes.Split(split[1], []byte{'\x00'}) + if len(opts) == 0 { + fatal(c, ErrInvalidRequest) + return + } + + repo = filepath.Clean(string(opts[0])) + log.Printf("git: connect %s %s %s", c.RemoteAddr(), cmd, repo) + defer log.Printf("git: disconnect %s %s %s", c.RemoteAddr(), cmd, repo) + repo = strings.TrimPrefix(repo, "/") + auth := d.cfg.Access.AccessLevel(strings.TrimSuffix(repo, ".git"), nil) + if auth < backend.ReadOnlyAccess { + fatal(c, ErrNotAuthed) + return + } + // git bare repositories should end in ".git" + // https://git-scm.com/docs/gitrepository-layout + repo = strings.TrimSuffix(repo, ".git") + ".git" + // FIXME: determine repositories path + repoDir := filepath.Join(d.cfg.DataPath, "repos", repo) + if err := gitPack(c, c, c, repoDir); err != nil { + fatal(c, err) + return + } + } +} + +// Close closes the underlying listener. +func (d *GitDaemon) Close() error { + d.once.Do(func() { close(d.finished) }) + err := d.listener.Close() + d.conns.CloseAll() + return err +} + +// Shutdown gracefully shuts down the daemon. +func (d *GitDaemon) Shutdown(ctx context.Context) error { + d.once.Do(func() { close(d.finished) }) + err := d.listener.Close() + finished := make(chan struct{}, 1) + go func() { + d.wg.Wait() + finished <- struct{}{} + }() + select { + case <-ctx.Done(): + return ctx.Err() + case <-finished: + return err + } +} + +type serverConn struct { + net.Conn + + idleTimeout time.Duration + maxDeadline time.Time + closeCanceler context.CancelFunc +} + +func (c *serverConn) Write(p []byte) (n int, err error) { + c.updateDeadline() + n, err = c.Conn.Write(p) + if _, isNetErr := err.(net.Error); isNetErr && c.closeCanceler != nil { + c.closeCanceler() + } + return +} + +func (c *serverConn) Read(b []byte) (n int, err error) { + c.updateDeadline() + n, err = c.Conn.Read(b) + if _, isNetErr := err.(net.Error); isNetErr && c.closeCanceler != nil { + c.closeCanceler() + } + return +} + +func (c *serverConn) Close() (err error) { + err = c.Conn.Close() + if c.closeCanceler != nil { + c.closeCanceler() + } + return +} + +func (c *serverConn) updateDeadline() { + switch { + case c.idleTimeout > 0: + idleDeadline := time.Now().Add(c.idleTimeout) + if idleDeadline.Unix() < c.maxDeadline.Unix() || c.maxDeadline.IsZero() { + c.Conn.SetDeadline(idleDeadline) + return + } + fallthrough + default: + c.Conn.SetDeadline(c.maxDeadline) + } +} diff --git a/server/daemon_test.go b/server/daemon_test.go new file mode 100644 index 000000000..e2e059e42 --- /dev/null +++ b/server/daemon_test.go @@ -0,0 +1,95 @@ +package server + +import ( + "bytes" + "errors" + "fmt" + "io" + "log" + "net" + "os" + "strings" + "testing" + "time" + + "github.com/charmbracelet/soft-serve/server/config" + "github.com/go-git/go-git/v5/plumbing/format/pktline" +) + +var testDaemon *GitDaemon + +func TestMain(m *testing.M) { + tmp, err := os.MkdirTemp("", "soft-serve-test") + if err != nil { + log.Fatal(err) + } + defer os.RemoveAll(tmp) + os.Setenv("SOFT_SERVE_DATA_PATH", tmp) + os.Setenv("SOFT_SERVE_GIT_MAX_CONNECTIONS", "3") + os.Setenv("SOFT_SERVE_GIT_MAX_TIMEOUT", "100") + os.Setenv("SOFT_SERVE_GIT_IDLE_TIMEOUT", "1") + os.Setenv("SOFT_SERVE_GIT_LISTEN_ADDR", fmt.Sprintf(":%d", randomPort())) + cfg := config.DefaultConfig() + d, err := NewGitDaemon(cfg) + if err != nil { + log.Fatal(err) + } + testDaemon = d + go func() { + if err := d.Start(); err != ErrServerClosed { + log.Fatal(err) + } + }() + code := m.Run() + os.Unsetenv("SOFT_SERVE_DATA_PATH") + os.Unsetenv("SOFT_SERVE_GIT_MAX_CONNECTIONS") + os.Unsetenv("SOFT_SERVE_GIT_MAX_TIMEOUT") + os.Unsetenv("SOFT_SERVE_GIT_IDLE_TIMEOUT") + os.Unsetenv("SOFT_SERVE_GIT_LISTEN_ADDR") + _ = d.Close() + os.Exit(code) +} + +func TestIdleTimeout(t *testing.T) { + c, err := net.Dial("tcp", testDaemon.addr) + if err != nil { + t.Fatal(err) + } + time.Sleep(2 * time.Second) + out, err := readPktline(c) + if err != nil && !errors.Is(err, io.EOF) { + t.Fatalf("expected nil, got error: %v", err) + } + if out != ErrTimeout.Error() { + t.Fatalf("expected %q error, got %q", ErrTimeout, out) + } +} + +func TestInvalidRepo(t *testing.T) { + c, err := net.Dial("tcp", testDaemon.addr) + if err != nil { + t.Fatal(err) + } + if err := pktline.NewEncoder(c).EncodeString("git-upload-pack /test.git\x00"); err != nil { + t.Fatalf("expected nil, got error: %v", err) + } + out, err := readPktline(c) + if err != nil { + t.Fatalf("expected nil, got error: %v", err) + } + if out != ErrInvalidRepo.Error() { + t.Fatalf("expected %q error, got %q", ErrInvalidRepo, out) + } +} + +func readPktline(c net.Conn) (string, error) { + buf, err := io.ReadAll(c) + if err != nil { + return "", err + } + pktout := pktline.NewScanner(bytes.NewReader(buf)) + if !pktout.Scan() { + return "", pktout.Err() + } + return strings.TrimSpace(string(pktout.Bytes())), nil +} diff --git a/server/git.go b/server/git.go new file mode 100644 index 000000000..812dddfc4 --- /dev/null +++ b/server/git.go @@ -0,0 +1,162 @@ +package server + +import ( + "errors" + "fmt" + "io" + "log" + "os" + "path/filepath" + + "github.com/charmbracelet/soft-serve/git" + "github.com/go-git/go-git/v5/plumbing/format/pktline" +) + +var ( + + // ErrNotAuthed represents unauthorized access. + ErrNotAuthed = errors.New("you are not authorized to do this") + + // ErrSystemMalfunction represents a general system error returned to clients. + ErrSystemMalfunction = errors.New("something went wrong") + + // ErrInvalidRepo represents an attempt to access a non-existent repo. + ErrInvalidRepo = errors.New("invalid repo") + + // ErrInvalidRequest represents an invalid request. + ErrInvalidRequest = errors.New("invalid request") + + // ErrMaxConnections represents a maximum connection limit being reached. + ErrMaxConnections = errors.New("too many connections, try again later") + + // ErrTimeout is returned when the maximum read timeout is exceeded. + ErrTimeout = errors.New("I/O timeout reached") +) + +// Git protocol commands. +const ( + ReceivePackBin = "git-receive-pack" + UploadPackBin = "git-upload-pack" + UploadArchiveBin = "git-upload-archive" +) + +// UploadPack runs the git upload-pack protocol against the provided repo. +func UploadPack(in io.Reader, out io.Writer, er io.Writer, repoDir string) error { + exists, err := fileExists(repoDir) + if !exists { + return ErrInvalidRepo + } + if err != nil { + return err + } + return RunGit(in, out, er, "", UploadPackBin[4:], repoDir) +} + +// UploadArchive runs the git upload-archive protocol against the provided repo. +func UploadArchive(in io.Reader, out io.Writer, er io.Writer, repoDir string) error { + exists, err := fileExists(repoDir) + if !exists { + return ErrInvalidRepo + } + if err != nil { + return err + } + return RunGit(in, out, er, "", UploadArchiveBin[4:], repoDir) +} + +// ReceivePack runs the git receive-pack protocol against the provided repo. +func ReceivePack(in io.Reader, out io.Writer, er io.Writer, repoDir string) error { + if err := ensureRepo(repoDir, ""); err != nil { + return err + } + if err := RunGit(in, out, er, "", ReceivePackBin[4:], repoDir); err != nil { + return err + } + return ensureDefaultBranch(in, out, er, repoDir) +} + +// RunGit runs a git command in the given repo. +func RunGit(in io.Reader, out io.Writer, err io.Writer, dir string, args ...string) error { + c := git.NewCommand(args...) + return c.RunInDirWithOptions(dir, git.RunInDirOptions{ + Stdin: in, + Stdout: out, + Stderr: err, + }) +} + +// WritePktline encodes and writes a pktline to the given writer. +func WritePktline(w io.Writer, v ...interface{}) { + msg := fmt.Sprintln(v...) + pkt := pktline.NewEncoder(w) + if err := pkt.EncodeString(msg); err != nil { + log.Printf("git: error writing pkt-line message: %s", err) + } + if err := pkt.Flush(); err != nil { + log.Printf("git: error flushing pkt-line message: %s", err) + } +} + +func fileExists(path string) (bool, error) { + _, err := os.Stat(path) + if err == nil { + return true, nil + } + if os.IsNotExist(err) { + return false, nil + } + return true, err +} + +func ensureRepo(dir string, repo string) error { + exists, err := fileExists(dir) + if err != nil { + return err + } + if !exists { + err = os.MkdirAll(dir, os.ModeDir|os.FileMode(0700)) + if err != nil { + return err + } + } + rp := filepath.Join(dir, repo) + exists, err = fileExists(rp) + if err != nil { + return err + } + // FIXME: use backend.CreateRepository + if !exists { + _, err := git.Init(rp, true) + if err != nil { + return err + } + } + return nil +} + +func ensureDefaultBranch(in io.Reader, out io.Writer, er io.Writer, repoPath string) error { + r, err := git.Open(repoPath) + if err != nil { + return err + } + brs, err := r.Branches() + if err != nil { + return err + } + if len(brs) == 0 { + return fmt.Errorf("no branches found") + } + // Rename the default branch to the first branch available + _, err = r.HEAD() + if err == git.ErrReferenceNotExist { + // FIXME: use backend.SetDefaultBranch + err = RunGit(in, out, er, repoPath, "branch", "-M", brs[0]) + if err != nil { + return err + } + } + if err != nil && err != git.ErrReferenceNotExist { + return err + } + return nil +} diff --git a/server/git/daemon/conn.go b/server/git/daemon/conn.go new file mode 100644 index 000000000..1ab352424 --- /dev/null +++ b/server/git/daemon/conn.go @@ -0,0 +1,55 @@ +package daemon + +import ( + "context" + "net" + "time" +) + +type serverConn struct { + net.Conn + + idleTimeout time.Duration + maxDeadline time.Time + closeCanceler context.CancelFunc +} + +func (c *serverConn) Write(p []byte) (n int, err error) { + c.updateDeadline() + n, err = c.Conn.Write(p) + if _, isNetErr := err.(net.Error); isNetErr && c.closeCanceler != nil { + c.closeCanceler() + } + return +} + +func (c *serverConn) Read(b []byte) (n int, err error) { + c.updateDeadline() + n, err = c.Conn.Read(b) + if _, isNetErr := err.(net.Error); isNetErr && c.closeCanceler != nil { + c.closeCanceler() + } + return +} + +func (c *serverConn) Close() (err error) { + err = c.Conn.Close() + if c.closeCanceler != nil { + c.closeCanceler() + } + return +} + +func (c *serverConn) updateDeadline() { + switch { + case c.idleTimeout > 0: + idleDeadline := time.Now().Add(c.idleTimeout) + if idleDeadline.Unix() < c.maxDeadline.Unix() || c.maxDeadline.IsZero() { + c.Conn.SetDeadline(idleDeadline) + return + } + fallthrough + default: + c.Conn.SetDeadline(c.maxDeadline) + } +} diff --git a/server/middleware_test.go b/server/middleware_test.go deleted file mode 100644 index 6895d3d10..000000000 --- a/server/middleware_test.go +++ /dev/null @@ -1,38 +0,0 @@ -package server - -import ( - "os" - "testing" - - "github.com/charmbracelet/soft-serve/config" - sconfig "github.com/charmbracelet/soft-serve/server/config" - "github.com/charmbracelet/wish/testsession" - "github.com/gliderlabs/ssh" - "github.com/matryer/is" -) - -var () - -func TestMiddleware(t *testing.T) { - t.Cleanup(func() { - os.RemoveAll("testmiddleware") - }) - is := is.New(t) - appCfg, err := config.NewConfig(&sconfig.Config{ - Host: "localhost", - Port: 22223, - RepoPath: "testmiddleware/repos", - KeyPath: "testmiddleware/key", - }) - is.NoErr(err) - _ = testsession.New(t, &ssh.Server{ - Handler: softMiddleware(appCfg)(func(s ssh.Session) { - t.Run("TestCatConfig", func(t *testing.T) { - _, err := s.Write([]byte("cat config/config.json")) - if err == nil { - t.Errorf("Expected error, got nil") - } - }) - }), - }, nil) -} diff --git a/server/server.go b/server/server.go index 1ba07f7f8..b52d09f28 100644 --- a/server/server.go +++ b/server/server.go @@ -2,29 +2,26 @@ package server import ( "context" - "fmt" - "net" - "path/filepath" - "strings" "github.com/charmbracelet/log" - appCfg "github.com/charmbracelet/soft-serve/config" + "github.com/charmbracelet/soft-serve/server/backend" "github.com/charmbracelet/soft-serve/server/config" - "github.com/charmbracelet/wish" - bm "github.com/charmbracelet/wish/bubbletea" - gm "github.com/charmbracelet/wish/git" - lm "github.com/charmbracelet/wish/logging" - rm "github.com/charmbracelet/wish/recover" - "github.com/gliderlabs/ssh" - "github.com/muesli/termenv" + "github.com/charmbracelet/ssh" + "golang.org/x/sync/errgroup" +) + +var ( + logger = log.WithPrefix("server") ) // Server is the Soft Serve server. type Server struct { - SSHServer *ssh.Server + SSHServer *SSHServer + GitDaemon *GitDaemon Config *config.Config - config *appCfg.Config + Backend backend.Backend + Access backend.AccessMethod } // NewServer returns a new *ssh.Server configured to serve Soft Serve. The SSH @@ -32,85 +29,66 @@ type Server struct { // key can be provided with authKey. If authKey is provided, access will be // restricted to that key. If authKey is not provided, the server will be // publicly writable until configured otherwise by cloning the `config` repo. -func NewServer(cfg *config.Config) *Server { - ac, err := appCfg.NewConfig(cfg) - if err != nil { - log.Fatal(err) +func NewServer(cfg *config.Config) (*Server, error) { + var err error + srv := &Server{ + Config: cfg, + Backend: cfg.Backend, + Access: cfg.Access, } - mw := []wish.Middleware{ - rm.MiddlewareWithLogger( - cfg.ErrorLog, - softMiddleware(ac), - bm.MiddlewareWithProgramHandler(SessionHandler(ac), termenv.ANSI256), - gm.Middleware(cfg.RepoPath, ac), - // Note: disable pushing to subdirectories as it can create - // conflicts with existing repos. This only affects the git - // middleware. - // - // This is related to - // https://github.com/charmbracelet/soft-serve/issues/120 - // https://github.com/charmbracelet/wish/commit/8808de520d3ea21931f13113c6b0b6d0141272d4 - func(sh ssh.Handler) ssh.Handler { - return func(s ssh.Session) { - cmds := s.Command() - if len(cmds) == 2 && strings.HasPrefix(cmds[0], "git") { - repo := strings.TrimSuffix(strings.TrimPrefix(cmds[1], "/"), "/") - repo = filepath.Clean(repo) - if n := strings.Count(repo, "/"); n != 0 { - wish.Fatalln(s, fmt.Errorf("invalid repo path: subdirectories not allowed")) - return - } - } - sh(s) - } - }, - lm.MiddlewareWithLogger(log.StandardLog(log.StandardLogOptions{ForceLevel: log.DebugLevel})), - ), - } - s, err := wish.NewServer( - ssh.PublicKeyAuth(ac.PublicKeyHandler), - ssh.KeyboardInteractiveAuth(ac.KeyboardInteractiveHandler), - wish.WithAddress(fmt.Sprintf("%s:%d", cfg.BindAddr, cfg.Port)), - wish.WithHostKeyPath(cfg.KeyPath), - wish.WithMiddleware(mw...), - ) + srv.SSHServer, err = NewSSHServer(cfg) if err != nil { - log.Fatal(err) + return nil, err } - return &Server{ - SSHServer: s, - Config: cfg, - config: ac, + + srv.GitDaemon, err = NewGitDaemon(cfg) + if err != nil { + return nil, err } -} -// Reload reloads the server configuration. -func (srv *Server) Reload() error { - return srv.config.Reload() + return srv, nil } // Start starts the SSH server. -func (srv *Server) Start() error { - if err := srv.SSHServer.ListenAndServe(); err != ssh.ErrServerClosed { - return err - } - return nil -} - -// Serve serves the SSH server using the provided listener. -func (srv *Server) Serve(l net.Listener) error { - if err := srv.SSHServer.Serve(l); err != ssh.ErrServerClosed { - return err - } - return nil +func (s *Server) Start() error { + var errg errgroup.Group + errg.Go(func() error { + log.Print("Starting Git daemon", "addr", s.Config.Git.ListenAddr) + if err := s.GitDaemon.Start(); err != ErrServerClosed { + return err + } + return nil + }) + errg.Go(func() error { + log.Print("Starting SSH server", "addr", s.Config.SSH.ListenAddr) + if err := s.SSHServer.ListenAndServe(); err != ssh.ErrServerClosed { + return err + } + return nil + }) + return errg.Wait() } // Shutdown lets the server gracefully shutdown. -func (srv *Server) Shutdown(ctx context.Context) error { - return srv.SSHServer.Shutdown(ctx) +func (s *Server) Shutdown(ctx context.Context) error { + var errg errgroup.Group + errg.Go(func() error { + return s.GitDaemon.Shutdown(ctx) + }) + errg.Go(func() error { + return s.SSHServer.Shutdown(ctx) + }) + return errg.Wait() } // Close closes the SSH server. -func (srv *Server) Close() error { - return srv.SSHServer.Close() +func (s *Server) Close() error { + var errg errgroup.Group + errg.Go(func() error { + return s.SSHServer.Close() + }) + errg.Go(func() error { + return s.GitDaemon.Close() + }) + return errg.Wait() } diff --git a/server/server_test.go b/server/server_test.go index 6f126ce9b..b3bdb9ebb 100644 --- a/server/server_test.go +++ b/server/server_test.go @@ -2,116 +2,64 @@ package server import ( "fmt" - "os" + "net" "path/filepath" + "strings" "testing" "github.com/charmbracelet/keygen" + "github.com/charmbracelet/soft-serve/server/backend/noop" "github.com/charmbracelet/soft-serve/server/config" - "github.com/gliderlabs/ssh" - "github.com/go-git/go-git/v5" - gconfig "github.com/go-git/go-git/v5/config" - "github.com/go-git/go-git/v5/plumbing/object" - gssh "github.com/go-git/go-git/v5/plumbing/transport/ssh" + "github.com/charmbracelet/ssh" "github.com/matryer/is" - cssh "golang.org/x/crypto/ssh" + gossh "golang.org/x/crypto/ssh" ) -var ( - testdata = "testdata" - cfg = &config.Config{ - BindAddr: "", - Host: "localhost", - Port: 22222, - RepoPath: fmt.Sprintf("%s/repos", testdata), - KeyPath: fmt.Sprintf("%s/key", testdata), - } - pkPath = "" -) - -func TestServer(t *testing.T) { - t.Cleanup(func() { - os.RemoveAll(testdata) - }) - is := is.New(t) - _, pkPath = createKeyPair(t) - s := setupServer(t) - err := s.Reload() - is.NoErr(err) - t.Run("TestPushRepo", testPushRepo) - t.Run("TestCloneRepo", testCloneRepo) -} - -func testPushRepo(t *testing.T) { - is := is.New(t) - rp := t.TempDir() - r, err := git.PlainInit(rp, false) - is.NoErr(err) - wt, err := r.Worktree() - is.NoErr(err) - _, err = wt.Filesystem.Create("testfile") - is.NoErr(err) - _, err = wt.Add("testfile") - is.NoErr(err) - author := &object.Signature{ - Name: "test", - Email: "", - } - _, err = wt.Commit("test commit", &git.CommitOptions{ - All: true, - Author: author, - Committer: author, - }) - is.NoErr(err) - _, err = r.CreateRemote(&gconfig.RemoteConfig{ - Name: "origin", - URLs: []string{fmt.Sprintf("ssh://%s:%d/%s", cfg.Host, cfg.Port, "testrepo")}, - }) - auth, err := gssh.NewPublicKeysFromFile("git", pkPath, "") - is.NoErr(err) - auth.HostKeyCallbackHelper = gssh.HostKeyCallbackHelper{ - HostKeyCallback: cssh.InsecureIgnoreHostKey(), - } - err = r.Push(&git.PushOptions{ - RemoteName: "origin", - Auth: auth, - }) - is.NoErr(err) +func randomPort() int { + addr, _ := net.Listen("tcp", ":0") //nolint:gosec + _ = addr.Close() + return addr.Addr().(*net.TCPAddr).Port } -func testCloneRepo(t *testing.T) { - is := is.New(t) - auth, err := gssh.NewPublicKeysFromFile("git", pkPath, "") - is.NoErr(err) - auth.HostKeyCallbackHelper = gssh.HostKeyCallbackHelper{ - HostKeyCallback: cssh.InsecureIgnoreHostKey(), +func setupServer(tb testing.TB) (*Server, *config.Config, string) { + tb.Helper() + tb.Log("creating keypair") + pub, pkPath := createKeyPair(tb) + dp := tb.TempDir() + sshPort := fmt.Sprintf(":%d", randomPort()) + tb.Setenv("SOFT_SERVE_DATA_PATH", dp) + tb.Setenv("SOFT_SERVE_INITIAL_ADMIN_KEY", authorizedKey(pub)) + tb.Setenv("SOFT_SERVE_SSH_LISTEN_ADDR", sshPort) + tb.Setenv("SOFT_SERVE_GIT_LISTEN_ADDR", fmt.Sprintf(":%d", randomPort())) + cfg := config.DefaultConfig() + nop := &noop.Noop{Port: sshPort[1:]} + tb.Log("configuring server") + cfg = cfg.WithBackend(nop).WithAccessMethod(nop) + s, err := NewServer(cfg) + if err != nil { + tb.Fatal(err) } - dst := t.TempDir() - _, err = git.PlainClone(dst, false, &git.CloneOptions{ - URL: fmt.Sprintf("ssh://%s:%d/config", cfg.Host, cfg.Port), - Auth: auth, - }) - is.NoErr(err) -} - -func setupServer(t *testing.T) *Server { - s := NewServer(cfg) go func() { + tb.Log("starting server") s.Start() }() - t.Cleanup(func() { + tb.Cleanup(func() { s.Close() }) - return s + return s, cfg, pkPath } -func createKeyPair(t *testing.T) (ssh.PublicKey, string) { - is := is.New(t) - t.Helper() - keyDir := t.TempDir() +func createKeyPair(tb testing.TB) (ssh.PublicKey, string) { + tb.Helper() + is := is.New(tb) + keyDir := tb.TempDir() kp, err := keygen.NewWithWrite(filepath.Join(keyDir, "id"), nil, keygen.Ed25519) is.NoErr(err) pubkey, _, _, _, err := ssh.ParseAuthorizedKey(kp.PublicKey()) is.NoErr(err) return pubkey, filepath.Join(keyDir, "id_ed25519") } + +func authorizedKey(pk ssh.PublicKey) string { + return strings.TrimSpace(string(gossh.MarshalAuthorizedKey(pk))) +} diff --git a/server/session.go b/server/session.go index a645c09c9..56bc02ed8 100644 --- a/server/session.go +++ b/server/session.go @@ -5,21 +5,18 @@ import ( "github.com/aymanbagabas/go-osc52" tea "github.com/charmbracelet/bubbletea" - appCfg "github.com/charmbracelet/soft-serve/config" + "github.com/charmbracelet/soft-serve/server/backend" cm "github.com/charmbracelet/soft-serve/server/cmd" + "github.com/charmbracelet/soft-serve/server/config" "github.com/charmbracelet/soft-serve/ui" "github.com/charmbracelet/soft-serve/ui/common" - "github.com/charmbracelet/soft-serve/ui/keymap" - "github.com/charmbracelet/soft-serve/ui/styles" + "github.com/charmbracelet/ssh" "github.com/charmbracelet/wish" bm "github.com/charmbracelet/wish/bubbletea" - gm "github.com/charmbracelet/wish/git" - "github.com/gliderlabs/ssh" - zone "github.com/lrstanley/bubblezone" ) // SessionHandler is the soft-serve bubbletea ssh session handler. -func SessionHandler(ac *appCfg.Config) bm.ProgramHandler { +func SessionHandler(cfg *config.Config) bm.ProgramHandler { return func(s ssh.Session) *tea.Program { pty, _, active := s.Pty() if !active { @@ -29,32 +26,18 @@ func SessionHandler(ac *appCfg.Config) bm.ProgramHandler { initialRepo := "" if len(cmd) == 1 { initialRepo = cmd[0] - auth := ac.AuthRepo(initialRepo, s.PublicKey()) - if auth < gm.ReadOnlyAccess { + auth := cfg.Access.AccessLevel(initialRepo, s.PublicKey()) + if auth < backend.ReadOnlyAccess { wish.Fatalln(s, cm.ErrUnauthorized) return nil } } - if ac.Cfg.Callbacks != nil { - ac.Cfg.Callbacks.Tui("new session") - } envs := s.Environ() envs = append(envs, fmt.Sprintf("TERM=%s", pty.Term)) output := osc52.NewOutput(s, envs) - c := common.Common{ - Copy: output, - Styles: styles.DefaultStyles(), - KeyMap: keymap.DefaultKeyMap(), - Width: pty.Window.Width, - Height: pty.Window.Height, - Zone: zone.New(), - } - m := ui.New( - ac, - s, - c, - initialRepo, - ) + c := common.NewCommon(s.Context(), output, pty.Window.Width, pty.Window.Height) + c.SetValue(common.ConfigKey, cfg) + m := ui.New(c, initialRepo) p := tea.NewProgram(m, tea.WithInput(s), tea.WithOutput(s), diff --git a/server/session_test.go b/server/session_test.go index 324402e65..bd81a2320 100644 --- a/server/session_test.go +++ b/server/session_test.go @@ -1,19 +1,16 @@ package server import ( - "bytes" "errors" + "fmt" "os" - "strings" "testing" "time" - appCfg "github.com/charmbracelet/soft-serve/config" - cm "github.com/charmbracelet/soft-serve/server/cmd" "github.com/charmbracelet/soft-serve/server/config" + "github.com/charmbracelet/ssh" bm "github.com/charmbracelet/wish/bubbletea" "github.com/charmbracelet/wish/testsession" - "github.com/gliderlabs/ssh" "github.com/matryer/is" "github.com/muesli/termenv" gossh "golang.org/x/crypto/ssh" @@ -21,53 +18,40 @@ import ( func TestSession(t *testing.T) { is := is.New(t) - t.Run("unauthorized repo access", func(t *testing.T) { - var out bytes.Buffer - s := setup(t) - s.Stderr = &out - defer s.Close() - err := s.RequestPty("xterm", 80, 40, nil) - is.NoErr(err) - err = s.Run("config") - // Session writes error and exits - is.True(strings.Contains(out.String(), cm.ErrUnauthorized.Error())) - var ee *gossh.ExitError - is.True(errors.As(err, &ee) && ee.ExitStatus() == 1) - }) t.Run("authorized repo access", func(t *testing.T) { s := setup(t) s.Stderr = os.Stderr defer s.Close() err := s.RequestPty("xterm", 80, 40, nil) is.NoErr(err) - in, err := s.StdinPipe() - is.NoErr(err) go func() { - <-time.After(time.Second) - // Send "q" to exit the config command - in.Write([]byte("q")) + time.Sleep(1 * time.Second) + s.Signal(gossh.SIGTERM) + // FIXME: exit with code 0 instead of forcibly closing the session + s.Close() }() - err = s.Shell() - is.NoErr(err) + err = s.Run("test") + var ee *gossh.ExitMissingError + is.True(errors.As(err, &ee)) }) } func setup(tb testing.TB) *gossh.Session { - is := is.New(tb) tb.Helper() - cfg.RepoPath = tb.TempDir() - ac, err := appCfg.NewConfig(&config.Config{ - Port: 22226, - KeyPath: tb.TempDir(), - RepoPath: tb.TempDir(), - InitialAdminKeys: []string{ - "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIMJlb/qf2B2kMNdBxfpCQqI2ctPcsOkdZGVh5zTRhKtH", - }, + is := is.New(tb) + dp := tb.TempDir() + is.NoErr(os.Setenv("SOFT_SERVE_DATA_PATH", dp)) + is.NoErr(os.Setenv("SOFT_SERVE_GIT_LISTEN_ADDR", ":9418")) + is.NoErr(os.Setenv("SOFT_SERVE_SSH_LISTEN_ADDR", fmt.Sprintf(":%d", randomPort()))) + tb.Cleanup(func() { + is.NoErr(os.Unsetenv("SOFT_SERVE_DATA_PATH")) + is.NoErr(os.Unsetenv("SOFT_SERVE_GIT_LISTEN_ADDR")) + is.NoErr(os.Unsetenv("SOFT_SERVE_SSH_LISTEN_ADDR")) + is.NoErr(os.RemoveAll(dp)) }) - ac.AnonAccess = "read-only" - is.NoErr(err) + cfg := config.DefaultConfig() return testsession.New(tb, &ssh.Server{ - Handler: bm.MiddlewareWithProgramHandler(SessionHandler(ac), termenv.ANSI256)(func(s ssh.Session) { + Handler: bm.MiddlewareWithProgramHandler(SessionHandler(cfg), termenv.ANSI256)(func(s ssh.Session) { _, _, active := s.Pty() tb.Logf("PTY active %v", active) tb.Log(s.Command()) diff --git a/server/ssh.go b/server/ssh.go new file mode 100644 index 000000000..1c7392825 --- /dev/null +++ b/server/ssh.go @@ -0,0 +1,157 @@ +package server + +import ( + "errors" + "fmt" + "path/filepath" + "strings" + "time" + + "github.com/charmbracelet/log" + "github.com/charmbracelet/soft-serve/server/backend" + cm "github.com/charmbracelet/soft-serve/server/cmd" + "github.com/charmbracelet/soft-serve/server/config" + "github.com/charmbracelet/ssh" + "github.com/charmbracelet/wish" + bm "github.com/charmbracelet/wish/bubbletea" + lm "github.com/charmbracelet/wish/logging" + rm "github.com/charmbracelet/wish/recover" + "github.com/muesli/termenv" + gossh "golang.org/x/crypto/ssh" +) + +// SSHServer is a SSH server that implements the git protocol. +type SSHServer struct { + *ssh.Server + cfg *config.Config +} + +// NewSSHServer returns a new SSHServer. +func NewSSHServer(cfg *config.Config) (*SSHServer, error) { + var err error + s := &SSHServer{cfg: cfg} + logger := logger.StandardLog(log.StandardLogOptions{ForceLevel: log.DebugLevel}) + mw := []wish.Middleware{ + rm.MiddlewareWithLogger( + logger, + // BubbleTea middleware. + bm.MiddlewareWithProgramHandler(SessionHandler(cfg), termenv.ANSI256), + // Command middleware must come after the git middleware. + cm.Middleware(cfg), + // Git middleware. + s.Middleware(cfg), + lm.MiddlewareWithLogger(logger), + ), + } + s.Server, err = wish.NewServer( + ssh.PublicKeyAuth(s.PublicKeyHandler), + ssh.KeyboardInteractiveAuth(s.KeyboardInteractiveHandler), + wish.WithAddress(cfg.SSH.ListenAddr), + wish.WithHostKeyPath(cfg.SSH.KeyPath), + wish.WithMiddleware(mw...), + ) + if err != nil { + return nil, err + } + + if cfg.SSH.MaxTimeout > 0 { + s.Server.MaxTimeout = time.Duration(cfg.SSH.MaxTimeout) * time.Second + } + if cfg.SSH.IdleTimeout > 0 { + s.Server.IdleTimeout = time.Duration(cfg.SSH.IdleTimeout) * time.Second + } + + return s, nil +} + +// PublicKeyAuthHandler handles public key authentication. +func (s *SSHServer) PublicKeyHandler(ctx ssh.Context, pk ssh.PublicKey) bool { + al := s.cfg.Access.AccessLevel("", pk) + logger.Debug("publickey handler", "level", al) + return al > backend.NoAccess +} + +// KeyboardInteractiveHandler handles keyboard interactive authentication. +func (s *SSHServer) KeyboardInteractiveHandler(_ ssh.Context, _ gossh.KeyboardInteractiveChallenge) bool { + return true +} + +// Middleware adds Git server functionality to the ssh.Server. Repos are stored +// in the specified repo directory. The provided Hooks implementation will be +// checked for access on a per repo basis for a ssh.Session public key. +// Hooks.Push and Hooks.Fetch will be called on successful completion of +// their commands. +func (s *SSHServer) Middleware(cfg *config.Config) wish.Middleware { + return func(sh ssh.Handler) ssh.Handler { + return func(s ssh.Session) { + func() { + cmd := s.Command() + if len(cmd) >= 2 && strings.HasPrefix(cmd[0], "git") { + gc := cmd[0] + // repo should be in the form of "repo.git" + repo := sanitizeRepoName(cmd[1]) + name := repo + if strings.Contains(repo, "/") { + log.Printf("invalid repo: %s", repo) + sshFatal(s, fmt.Errorf("%s: %s", ErrInvalidRepo, "user repos not supported")) + return + } + pk := s.PublicKey() + access := cfg.Access.AccessLevel(name, pk) + // git bare repositories should end in ".git" + // https://git-scm.com/docs/gitrepository-layout + repo = strings.TrimSuffix(repo, ".git") + ".git" + // FIXME: determine repositories path + repoDir := filepath.Join(cfg.DataPath, "repos", repo) + switch gc { + case ReceivePackBin: + if access < backend.ReadWriteAccess { + sshFatal(s, ErrNotAuthed) + return + } + if _, err := cfg.Backend.Repository(name); err != nil { + if _, err := cfg.Backend.CreateRepository(name, false); err != nil { + log.Printf("failed to create repo: %s", err) + sshFatal(s, err) + return + } + } + if err := ReceivePack(s, s, s.Stderr(), repoDir); err != nil { + sshFatal(s, ErrSystemMalfunction) + } + return + case UploadPackBin, UploadArchiveBin: + if access < backend.ReadOnlyAccess { + sshFatal(s, ErrNotAuthed) + return + } + gitPack := UploadPack + if gc == UploadArchiveBin { + gitPack = UploadArchive + } + err := gitPack(s, s, s.Stderr(), repoDir) + if errors.Is(err, ErrInvalidRepo) { + sshFatal(s, ErrInvalidRepo) + } else if err != nil { + sshFatal(s, ErrSystemMalfunction) + } + } + } + }() + sh(s) + } + } +} + +// sshFatal prints to the session's STDOUT as a git response and exit 1. +func sshFatal(s ssh.Session, v ...interface{}) { + WritePktline(s, v...) + s.Exit(1) // nolint: errcheck +} + +func sanitizeRepoName(repo string) string { + repo = strings.TrimPrefix(repo, "/") + repo = filepath.Clean(repo) + repo = strings.TrimSuffix(repo, ".git") + return repo +} diff --git a/ui/common/common.go b/ui/common/common.go index 8a5cb451a..660b6fdab 100644 --- a/ui/common/common.go +++ b/ui/common/common.go @@ -1,20 +1,56 @@ package common import ( + "context" + "github.com/aymanbagabas/go-osc52" + "github.com/charmbracelet/soft-serve/git" + "github.com/charmbracelet/soft-serve/server/config" "github.com/charmbracelet/soft-serve/ui/keymap" "github.com/charmbracelet/soft-serve/ui/styles" + "github.com/charmbracelet/ssh" zone "github.com/lrstanley/bubblezone" ) +type contextKey struct { + name string +} + +// Keys to use for context.Context. +var ( + ConfigKey = &contextKey{"config"} + RepoKey = &contextKey{"repo"} +) + // Common is a struct all components should embed. type Common struct { - Copy *osc52.Output - Styles *styles.Styles - KeyMap *keymap.KeyMap - Width int - Height int - Zone *zone.Manager + ctx context.Context + Width, Height int + Styles *styles.Styles + KeyMap *keymap.KeyMap + Copy *osc52.Output + Zone *zone.Manager +} + +// NewCommon returns a new Common struct. +func NewCommon(ctx context.Context, copy *osc52.Output, width, height int) Common { + if ctx == nil { + ctx = context.TODO() + } + return Common{ + ctx: ctx, + Width: width, + Height: height, + Copy: copy, + Styles: styles.DefaultStyles(), + KeyMap: keymap.DefaultKeyMap(), + Zone: zone.New(), + } +} + +// SetValue sets a value in the context. +func (c *Common) SetValue(key, value interface{}) { + c.ctx = context.WithValue(c.ctx, key, value) } // SetSize sets the width and height of the common struct. @@ -22,3 +58,30 @@ func (c *Common) SetSize(width, height int) { c.Width = width c.Height = height } + +// Config returns the server config. +func (c *Common) Config() *config.Config { + v := c.ctx.Value(ConfigKey) + if cfg, ok := v.(*config.Config); ok { + return cfg + } + return nil +} + +// Repo returns the repository. +func (c *Common) Repo() *git.Repository { + v := c.ctx.Value(RepoKey) + if r, ok := v.(*git.Repository); ok { + return r + } + return nil +} + +// PublicKey returns the public key. +func (c *Common) PublicKey() ssh.PublicKey { + v := c.ctx.Value(ssh.ContextKeyPublicKey) + if p, ok := v.(ssh.PublicKey); ok { + return p + } + return nil +} diff --git a/ui/common/error.go b/ui/common/error.go index fe9729805..753f5f26f 100644 --- a/ui/common/error.go +++ b/ui/common/error.go @@ -1,6 +1,13 @@ package common -import tea "github.com/charmbracelet/bubbletea" +import ( + "errors" + + tea "github.com/charmbracelet/bubbletea" +) + +// ErrMissingRepo indicates that the requested repository could not be found. +var ErrMissingRepo = errors.New("missing repo") // ErrorMsg is a Bubble Tea message that represents an error. type ErrorMsg error diff --git a/ui/common/utils.go b/ui/common/utils.go index 7c817a50e..de8fbe8a6 100644 --- a/ui/common/utils.go +++ b/ui/common/utils.go @@ -1,6 +1,10 @@ package common -import "github.com/muesli/reflow/truncate" +import ( + "fmt" + + "github.com/muesli/reflow/truncate" +) // TruncateString is a convenient wrapper around truncate.TruncateString. func TruncateString(s string, max int) string { @@ -9,3 +13,12 @@ func TruncateString(s string, max int) string { } return truncate.StringWithTail(s, uint(max), "…") } + +// RepoURL returns the URL of the repository. +func RepoURL(host string, port string, name string) string { + p := "" + if port != "22" { + p += ":" + port + } + return fmt.Sprintf("git clone ssh://%s/%s", host+p, name) +} diff --git a/ui/components/code/code.go b/ui/components/code/code.go index 9c832bc83..8b442db82 100644 --- a/ui/components/code/code.go +++ b/ui/components/code/code.go @@ -47,7 +47,7 @@ func New(c common.Common, content, extension string) *Code { content: content, extension: extension, Viewport: vp.New(c), - NoContentStyle: c.Styles.CodeNoContent.Copy(), + NoContentStyle: c.Styles.NoContent.Copy(), LineDigitStyle: lineDigitStyle, LineBarStyle: lineBarStyle, } diff --git a/ui/git.go b/ui/git.go deleted file mode 100644 index e4dcf80a2..000000000 --- a/ui/git.go +++ /dev/null @@ -1,25 +0,0 @@ -package ui - -import ( - "github.com/charmbracelet/soft-serve/config" - "github.com/charmbracelet/soft-serve/ui/git" -) - -// source is a wrapper around config.RepoSource that implements git.GitRepoSource. -type source struct { - *config.RepoSource -} - -// GetRepo implements git.GitRepoSource. -func (s *source) GetRepo(name string) (git.GitRepo, error) { - return s.RepoSource.GetRepo(name) -} - -// AllRepos implements git.GitRepoSource. -func (s *source) AllRepos() []git.GitRepo { - rs := make([]git.GitRepo, 0) - for _, r := range s.RepoSource.AllRepos() { - rs = append(rs, r) - } - return rs -} diff --git a/ui/git/git.go b/ui/git/git.go deleted file mode 100644 index c51fee1cd..000000000 --- a/ui/git/git.go +++ /dev/null @@ -1,42 +0,0 @@ -package git - -import ( - "errors" - "fmt" - - "github.com/charmbracelet/soft-serve/git" -) - -// ErrMissingRepo indicates that the requested repository could not be found. -var ErrMissingRepo = errors.New("missing repo") - -// GitRepo is an interface for Git repositories. -type GitRepo interface { - Repo() string - Name() string - Description() string - Readme() (string, string) - HEAD() (*git.Reference, error) - Commit(string) (*git.Commit, error) - CommitsByPage(*git.Reference, int, int) (git.Commits, error) - CountCommits(*git.Reference) (int64, error) - Diff(*git.Commit) (*git.Diff, error) - References() ([]*git.Reference, error) - Tree(*git.Reference, string) (*git.Tree, error) - IsPrivate() bool -} - -// GitRepoSource is an interface for Git repository factory. -type GitRepoSource interface { - GetRepo(string) (GitRepo, error) - AllRepos() []GitRepo -} - -// RepoURL returns the URL of the repository. -func RepoURL(host string, port int, name string) string { - p := "" - if port != 22 { - p += fmt.Sprintf(":%d", port) - } - return fmt.Sprintf("git clone ssh://%s/%s", host+p, name) -} diff --git a/ui/pages/repo/empty.go b/ui/pages/repo/empty.go new file mode 100644 index 000000000..bddab29f1 --- /dev/null +++ b/ui/pages/repo/empty.go @@ -0,0 +1,45 @@ +package repo + +import ( + "fmt" + "strings" + + "github.com/charmbracelet/soft-serve/server/config" +) + +func defaultEmptyRepoMsg(cfg *config.Config, repo string) string { + host := cfg.Backend.ServerHost() + if cfg.Backend.ServerPort() != "22" { + host = fmt.Sprintf("%s:%s", host, cfg.Backend.ServerPort()) + } + repo = strings.TrimSuffix(repo, ".git") + return fmt.Sprintf(`# Quick Start + +Get started by cloning this repository, add your files, commit, and push. + +## Clone this repository. + +`+"```"+`sh +git clone ssh://%[1]s/%[2]s.git +`+"```"+` + +## Creating a new repository on the command line + +`+"```"+`sh +touch README.md +git init +git add README.md +git branch -M main +git commit -m "first commit" +git remote add origin ssh://%[1]s/%[2]s.git +git push -u origin main +`+"```"+` + +## Pushing an existing repository from the command line + +`+"```"+`sh +git remote add origin ssh://%[1]s/%[2]s.git +git push -u origin main +`+"```"+` +`, host, repo) +} diff --git a/ui/pages/repo/files.go b/ui/pages/repo/files.go index 745016102..83f01d6dd 100644 --- a/ui/pages/repo/files.go +++ b/ui/pages/repo/files.go @@ -3,16 +3,17 @@ package repo import ( "errors" "fmt" + "log" "path/filepath" "github.com/alecthomas/chroma/lexers" "github.com/charmbracelet/bubbles/key" tea "github.com/charmbracelet/bubbletea" - ggit "github.com/charmbracelet/soft-serve/git" + "github.com/charmbracelet/soft-serve/git" + "github.com/charmbracelet/soft-serve/server/backend" "github.com/charmbracelet/soft-serve/ui/common" "github.com/charmbracelet/soft-serve/ui/components/code" "github.com/charmbracelet/soft-serve/ui/components/selector" - "github.com/charmbracelet/soft-serve/ui/git" ) type filesView int @@ -49,9 +50,9 @@ type FileContentMsg struct { type Files struct { common common.Common selector *selector.Selector - ref *ggit.Reference + ref *git.Reference activeView filesView - repo git.GitRepo + repo backend.Repository code *code.Code path string currentItem *FileItem @@ -200,8 +201,7 @@ func (f *Files) Update(msg tea.Msg) (tea.Model, tea.Cmd) { cmds := make([]tea.Cmd, 0) switch msg := msg.(type) { case RepoMsg: - f.repo = git.GitRepo(msg) - cmds = append(cmds, f.Init()) + f.repo = msg case RefMsg: f.ref = msg cmds = append(cmds, f.Init()) @@ -265,6 +265,14 @@ func (f *Files) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } } } + case EmptyRepoMsg: + f.ref = nil + f.path = "" + f.currentItem = nil + f.activeView = filesViewFiles + f.lastSelected = make([]int, 0) + f.selector.Select(0) + cmds = append(cmds, f.setItems([]selector.IdentifiableItem{})) } switch f.activeView { case filesViewFiles: @@ -320,14 +328,21 @@ func (f *Files) updateFilesCmd() tea.Msg { files := make([]selector.IdentifiableItem, 0) dirs := make([]selector.IdentifiableItem, 0) if f.ref == nil { + log.Printf("ui: files: ref is nil") return common.ErrorMsg(errNoRef) } - t, err := f.repo.Tree(f.ref, f.path) + r, err := f.repo.Repository() + if err != nil { + return common.ErrorMsg(err) + } + t, err := r.TreePath(f.ref, f.path) if err != nil { + log.Printf("ui: files: error getting tree %v", err) return common.ErrorMsg(err) } ents, err := t.Entries() if err != nil { + log.Printf("ui: files: error listing files %v", err) return common.ErrorMsg(err) } ents.Sort() @@ -347,6 +362,7 @@ func (f *Files) selectTreeCmd() tea.Msg { f.selector.Select(0) return f.updateFilesCmd() } + log.Printf("ui: files: current item is not a tree") return common.ErrorMsg(errNoFileSelected) } @@ -355,25 +371,30 @@ func (f *Files) selectFileCmd() tea.Msg { if i != nil && !i.entry.IsTree() { fi := i.entry.File() if i.Mode().IsDir() || f == nil { + log.Printf("ui: files: current item is not a file") return common.ErrorMsg(errInvalidFile) } bin, err := fi.IsBinary() if err != nil { f.path = filepath.Dir(f.path) + log.Printf("ui: files: error checking if file is binary %v", err) return common.ErrorMsg(err) } if bin { f.path = filepath.Dir(f.path) + log.Printf("ui: files: file is binary") return common.ErrorMsg(errBinaryFile) } c, err := fi.Bytes() if err != nil { f.path = filepath.Dir(f.path) + log.Printf("ui: files: error reading file %v", err) return common.ErrorMsg(err) } f.lastSelected = append(f.lastSelected, f.selector.Index()) return FileContentMsg{string(c), i.entry.Name()} } + log.Printf("ui: files: current item is not a file") return common.ErrorMsg(errNoFileSelected) } @@ -389,3 +410,9 @@ func (f *Files) deselectItemCmd() tea.Msg { f.selector.Select(index) return msg } + +func (f *Files) setItems(items []selector.IdentifiableItem) tea.Cmd { + return func() tea.Msg { + return FileItemsMsg(items) + } +} diff --git a/ui/pages/repo/log.go b/ui/pages/repo/log.go index a1511a5ff..23bc23880 100644 --- a/ui/pages/repo/log.go +++ b/ui/pages/repo/log.go @@ -2,6 +2,7 @@ package repo import ( "fmt" + "log" "strings" "time" @@ -10,12 +11,12 @@ import ( tea "github.com/charmbracelet/bubbletea" gansi "github.com/charmbracelet/glamour/ansi" "github.com/charmbracelet/lipgloss" - ggit "github.com/charmbracelet/soft-serve/git" + "github.com/charmbracelet/soft-serve/git" + "github.com/charmbracelet/soft-serve/server/backend" "github.com/charmbracelet/soft-serve/ui/common" "github.com/charmbracelet/soft-serve/ui/components/footer" "github.com/charmbracelet/soft-serve/ui/components/selector" "github.com/charmbracelet/soft-serve/ui/components/viewport" - "github.com/charmbracelet/soft-serve/ui/git" "github.com/muesli/reflow/wrap" "github.com/muesli/termenv" ) @@ -36,10 +37,10 @@ type LogCountMsg int64 type LogItemsMsg []selector.IdentifiableItem // LogCommitMsg is a message that contains a git commit. -type LogCommitMsg *ggit.Commit +type LogCommitMsg *git.Commit // LogDiffMsg is a message that contains a git diff. -type LogDiffMsg *ggit.Diff +type LogDiffMsg *git.Diff // Log is a model that displays a list of commits and their diffs. type Log struct { @@ -47,13 +48,13 @@ type Log struct { selector *selector.Selector vp *viewport.Viewport activeView logView - repo git.GitRepo - ref *ggit.Reference + repo backend.Repository + ref *git.Reference count int64 nextPage int - activeCommit *ggit.Commit - selectedCommit *ggit.Commit - currentDiff *ggit.Diff + activeCommit *git.Commit + selectedCommit *git.Commit + currentDiff *git.Diff loadingTime time.Time loading bool spinner spinner.Model @@ -77,9 +78,8 @@ func NewLog(common common.Common) *Log { selector.KeyMap.NextPage = common.KeyMap.NextPage selector.KeyMap.PrevPage = common.KeyMap.PrevPage l.selector = selector - s := spinner.New() - s.Spinner = spinner.Dot - s.Style = common.Styles.Spinner + s := spinner.New(spinner.WithSpinner(spinner.Dot), + spinner.WithStyle(common.Styles.Spinner)) l.spinner = s return l } @@ -189,8 +189,7 @@ func (l *Log) Update(msg tea.Msg) (tea.Model, tea.Cmd) { cmds := make([]tea.Cmd, 0) switch msg := msg.(type) { case RepoMsg: - l.repo = git.GitRepo(msg) - cmds = append(cmds, l.Init()) + l.repo = msg case RefMsg: l.ref = msg cmds = append(cmds, l.Init()) @@ -245,6 +244,7 @@ func (l *Log) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if l.activeView == logViewDiff { l.activeView = logViewCommits l.selectedCommit = nil + cmds = append(cmds, updateStatusBarCmd) } case selector.ActiveMsg: switch sel := msg.IdentifiableItem.(type) { @@ -299,6 +299,16 @@ func (l *Log) Update(msg tea.Msg) (tea.Model, tea.Cmd) { l.startLoading(), ) } + case EmptyRepoMsg: + l.ref = nil + l.loading = false + l.activeView = logViewCommits + l.nextPage = 0 + l.count = 0 + l.activeCommit = nil + l.selectedCommit = nil + l.selector.Select(0) + cmds = append(cmds, l.setItems([]selector.IdentifiableItem{})) } if l.loading { s, cmd := l.spinner.Update(msg) @@ -326,7 +336,9 @@ func (l *Log) View() string { msg += "s" } msg += "…" - return msg + return l.common.Styles.SpinnerContainer.Copy(). + Height(l.common.Height). + Render(msg) } switch l.activeView { case logViewCommits: @@ -374,10 +386,16 @@ func (l *Log) StatusBarInfo() string { func (l *Log) countCommitsCmd() tea.Msg { if l.ref == nil { + log.Printf("ui: log: ref is nil") return common.ErrorMsg(errNoRef) } - count, err := l.repo.CountCommits(l.ref) + r, err := l.repo.Repository() + if err != nil { + return common.ErrorMsg(err) + } + count, err := r.CountCommits(l.ref) if err != nil { + log.Printf("ui: error counting commits: %v", err) return common.ErrorMsg(err) } return LogCountMsg(count) @@ -394,15 +412,21 @@ func (l *Log) updateCommitsCmd() tea.Msg { } } if l.ref == nil { + log.Printf("ui: log: ref is nil") return common.ErrorMsg(errNoRef) } items := make([]selector.IdentifiableItem, count) page := l.nextPage limit := l.selector.PerPage() skip := page * limit + r, err := l.repo.Repository() + if err != nil { + return common.ErrorMsg(err) + } // CommitsByPage pages start at 1 - cc, err := l.repo.CommitsByPage(l.ref, page+1, limit) + cc, err := r.CommitsByPage(l.ref, page+1, limit) if err != nil { + log.Printf("ui: error loading commits: %v", err) return common.ErrorMsg(err) } for i, c := range cc { @@ -415,15 +439,21 @@ func (l *Log) updateCommitsCmd() tea.Msg { return LogItemsMsg(items) } -func (l *Log) selectCommitCmd(commit *ggit.Commit) tea.Cmd { +func (l *Log) selectCommitCmd(commit *git.Commit) tea.Cmd { return func() tea.Msg { return LogCommitMsg(commit) } } func (l *Log) loadDiffCmd() tea.Msg { - diff, err := l.repo.Diff(l.selectedCommit) + r, err := l.repo.Repository() if err != nil { + log.Printf("ui: error loading diff repository: %v", err) + return common.ErrorMsg(err) + } + diff, err := r.Diff(l.selectedCommit) + if err != nil { + log.Printf("ui: error loading diff: %v", err) return common.ErrorMsg(err) } return LogDiffMsg(diff) @@ -436,7 +466,7 @@ func renderCtx() gansi.RenderContext { }) } -func (l *Log) renderCommit(c *ggit.Commit) string { +func (l *Log) renderCommit(c *git.Commit) string { s := strings.Builder{} // FIXME: lipgloss prints empty lines when CRLF is used // sanitize commit message from CRLF @@ -450,7 +480,7 @@ func (l *Log) renderCommit(c *ggit.Commit) string { return wrap.String(s.String(), l.common.Width-2) } -func (l *Log) renderSummary(diff *ggit.Diff) string { +func (l *Log) renderSummary(diff *git.Diff) string { stats := strings.Split(diff.Stats().String(), "\n") for i, line := range stats { ch := strings.Split(line, "|") @@ -464,7 +494,7 @@ func (l *Log) renderSummary(diff *ggit.Diff) string { return wrap.String(strings.Join(stats, "\n"), l.common.Width-2) } -func (l *Log) renderDiff(diff *ggit.Diff) string { +func (l *Log) renderDiff(diff *git.Diff) string { var s strings.Builder var pr strings.Builder diffChroma := &gansi.CodeBlockElement{ @@ -479,3 +509,9 @@ func (l *Log) renderDiff(diff *ggit.Diff) string { } return wrap.String(s.String(), l.common.Width) } + +func (l *Log) setItems(items []selector.IdentifiableItem) tea.Cmd { + return func() tea.Msg { + return LogItemsMsg(items) + } +} diff --git a/ui/pages/repo/readme.go b/ui/pages/repo/readme.go index 8605d3205..5374779b9 100644 --- a/ui/pages/repo/readme.go +++ b/ui/pages/repo/readme.go @@ -2,22 +2,27 @@ package repo import ( "fmt" + "path/filepath" "github.com/charmbracelet/bubbles/key" tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/soft-serve/server/backend" "github.com/charmbracelet/soft-serve/ui/common" "github.com/charmbracelet/soft-serve/ui/components/code" - "github.com/charmbracelet/soft-serve/ui/git" ) -type ReadmeMsg struct{} +// ReadmeMsg is a message sent when the readme is loaded. +type ReadmeMsg struct { + Msg tea.Msg +} // Readme is the readme component page. type Readme struct { - common common.Common - code *code.Code - ref RefMsg - repo git.GitRepo + common common.Common + code *code.Code + ref RefMsg + repo backend.Repository + readmePath string } // NewReadme creates a new readme model. @@ -64,15 +69,7 @@ func (r *Readme) FullHelp() [][]key.Binding { // Init implements tea.Model. func (r *Readme) Init() tea.Cmd { - if r.repo == nil { - return common.ErrorCmd(git.ErrMissingRepo) - } - rm, rp := r.repo.Readme() - r.code.GotoTop() - return tea.Batch( - r.code.SetContent(rm, rp), - r.updateReadmeCmd, - ) + return r.updateReadmeCmd } // Update implements tea.Model. @@ -80,11 +77,13 @@ func (r *Readme) Update(msg tea.Msg) (tea.Model, tea.Cmd) { cmds := make([]tea.Cmd, 0) switch msg := msg.(type) { case RepoMsg: - r.repo = git.GitRepo(msg) - cmds = append(cmds, r.Init()) + r.repo = msg case RefMsg: r.ref = msg cmds = append(cmds, r.Init()) + case EmptyRepoMsg: + r.code.SetContent(defaultEmptyRepoMsg(r.common.Config(), + r.repo.Name()), ".md") } c, cmd := r.code.Update(msg) r.code = c.(*code.Code) @@ -101,7 +100,11 @@ func (r *Readme) View() string { // StatusBarValue implements statusbar.StatusBar. func (r *Readme) StatusBarValue() string { - return "" + dir := filepath.Dir(r.readmePath) + if dir == "." { + return "" + } + return dir } // StatusBarInfo implements statusbar.StatusBar. @@ -110,5 +113,19 @@ func (r *Readme) StatusBarInfo() string { } func (r *Readme) updateReadmeCmd() tea.Msg { - return ReadmeMsg{} + m := ReadmeMsg{} + if r.repo == nil { + return common.ErrorCmd(common.ErrMissingRepo) + } + rm, rp, err := backend.Readme(r.repo) + if err != nil { + return common.ErrorCmd(err) + } + r.readmePath = rp + r.code.GotoTop() + cmd := r.code.SetContent(rm, rp) + if cmd != nil { + m.Msg = cmd() + } + return m } diff --git a/ui/pages/repo/refs.go b/ui/pages/repo/refs.go index 308a26288..b0af0defe 100644 --- a/ui/pages/repo/refs.go +++ b/ui/pages/repo/refs.go @@ -3,22 +3,27 @@ package repo import ( "errors" "fmt" + "log" "sort" "strings" "github.com/charmbracelet/bubbles/key" tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/soft-serve/git" ggit "github.com/charmbracelet/soft-serve/git" + "github.com/charmbracelet/soft-serve/server/backend" "github.com/charmbracelet/soft-serve/ui/common" "github.com/charmbracelet/soft-serve/ui/components/selector" "github.com/charmbracelet/soft-serve/ui/components/tabs" - "github.com/charmbracelet/soft-serve/ui/git" ) var ( errNoRef = errors.New("no reference specified") ) +// RefMsg is a message that contains a git.Reference. +type RefMsg *ggit.Reference + // RefItemsMsg is a message that contains a list of RefItem. type RefItemsMsg struct { prefix string @@ -29,9 +34,9 @@ type RefItemsMsg struct { type Refs struct { common common.Common selector *selector.Selector - repo git.GitRepo - ref *ggit.Reference - activeRef *ggit.Reference + repo backend.Repository + ref *git.Reference + activeRef *git.Reference refPrefix string } @@ -104,8 +109,7 @@ func (r *Refs) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case RepoMsg: r.selector.Select(0) - r.repo = git.GitRepo(msg) - cmds = append(cmds, r.Init()) + r.repo = msg case RefMsg: r.ref = msg cmds = append(cmds, r.Init()) @@ -136,6 +140,9 @@ func (r *Refs) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case key.Matches(msg, r.common.KeyMap.SelectItem): cmds = append(cmds, r.selector.SelectItem) } + case EmptyRepoMsg: + r.ref = nil + cmds = append(cmds, r.setItems([]selector.IdentifiableItem{})) } m, cmd := r.selector.Update(msg) r.selector = m.(*selector.Selector) @@ -169,8 +176,13 @@ func (r *Refs) StatusBarInfo() string { func (r *Refs) updateItemsCmd() tea.Msg { its := make(RefItems, 0) - refs, err := r.repo.References() + rr, err := r.repo.Repository() + if err != nil { + return common.ErrorMsg(err) + } + refs, err := rr.References() if err != nil { + log.Printf("ui: error getting references: %v", err) return common.ErrorMsg(err) } for _, ref := range refs { @@ -189,8 +201,37 @@ func (r *Refs) updateItemsCmd() tea.Msg { } } +func (r *Refs) setItems(items []selector.IdentifiableItem) tea.Cmd { + return func() tea.Msg { + return RefItemsMsg{ + items: items, + prefix: r.refPrefix, + } + } +} + func switchRefCmd(ref *ggit.Reference) tea.Cmd { return func() tea.Msg { return RefMsg(ref) } } + +// UpdateRefCmd gets the repository's HEAD reference and sends a RefMsg. +func UpdateRefCmd(repo backend.Repository) tea.Cmd { + return func() tea.Msg { + r, err := repo.Repository() + if err != nil { + return common.ErrorMsg(err) + } + ref, err := r.HEAD() + if err != nil { + if bs, err := r.Branches(); err != nil && len(bs) == 0 { + return EmptyRepoMsg{} + } + log.Printf("ui: error getting HEAD reference: %v", err) + return common.ErrorMsg(err) + } + log.Printf("HEAD: %s", ref.Name()) + return RefMsg(ref) + } +} diff --git a/ui/pages/repo/repo.go b/ui/pages/repo/repo.go index c6e5be91c..5d498b154 100644 --- a/ui/pages/repo/repo.go +++ b/ui/pages/repo/repo.go @@ -9,20 +9,19 @@ import ( "github.com/charmbracelet/bubbles/spinner" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" - "github.com/charmbracelet/soft-serve/config" - ggit "github.com/charmbracelet/soft-serve/git" + "github.com/charmbracelet/soft-serve/git" + "github.com/charmbracelet/soft-serve/server/backend" "github.com/charmbracelet/soft-serve/ui/common" "github.com/charmbracelet/soft-serve/ui/components/footer" "github.com/charmbracelet/soft-serve/ui/components/statusbar" "github.com/charmbracelet/soft-serve/ui/components/tabs" - "github.com/charmbracelet/soft-serve/ui/git" ) type state int const ( loadingState state = iota - loadedState + readyState ) type tab int @@ -46,6 +45,9 @@ func (t tab) String() string { }[t] } +// EmptyRepoMsg is a message to indicate that the repository is empty. +type EmptyRepoMsg struct{} + // CopyURLMsg is a message to copy the URL of the current repository. type CopyURLMsg struct{} @@ -56,10 +58,7 @@ type ResetURLMsg struct{} type UpdateStatusBarMsg struct{} // RepoMsg is a message that contains a git.Repository. -type RepoMsg git.GitRepo - -// RefMsg is a message that contains a git.Reference. -type RefMsg *ggit.Reference +type RepoMsg backend.Repository // BackMsg is a message to go back to the previous view. type BackMsg struct{} @@ -67,18 +66,20 @@ type BackMsg struct{} // Repo is a view for a git repository. type Repo struct { common common.Common - cfg *config.Config - selectedRepo git.GitRepo + selectedRepo backend.Repository activeTab tab tabs *tabs.Tabs statusbar *statusbar.StatusBar panes []common.Component - ref *ggit.Reference + ref *git.Reference copyURL time.Time + state state + spinner spinner.Model + panesReady [lastTab]bool } // New returns a new Repo. -func New(cfg *config.Config, c common.Common) *Repo { +func New(c common.Common) *Repo { sb := statusbar.New(c) ts := make([]string, lastTab) // Tabs must match the order of tab constants above. @@ -89,8 +90,8 @@ func New(cfg *config.Config, c common.Common) *Repo { readme := NewReadme(c) log := NewLog(c) files := NewFiles(c) - branches := NewRefs(c, ggit.RefsHeads) - tags := NewRefs(c, ggit.RefsTags) + branches := NewRefs(c, git.RefsHeads) + tags := NewRefs(c, git.RefsTags) // Make sure the order matches the order of tab constants above. panes := []common.Component{ readme, @@ -99,12 +100,15 @@ func New(cfg *config.Config, c common.Common) *Repo { branches, tags, } + s := spinner.New(spinner.WithSpinner(spinner.Dot), + spinner.WithStyle(c.Styles.Spinner)) r := &Repo{ - cfg: cfg, common: c, tabs: tb, statusbar: sb, panes: panes, + state: loadingState, + spinner: s, } return r } @@ -162,16 +166,20 @@ func (r *Repo) Update(msg tea.Msg) (tea.Model, tea.Cmd) { cmds := make([]tea.Cmd, 0) switch msg := msg.(type) { case RepoMsg: + // Set the state to loading when we get a new repository. + r.state = loadingState + r.panesReady = [lastTab]bool{} r.activeTab = 0 - r.selectedRepo = git.GitRepo(msg) + r.selectedRepo = msg cmds = append(cmds, r.tabs.Init(), - r.updateRefCmd, + // This will set the selected repo in each pane's model. r.updateModels(msg), ) case RefMsg: r.ref = msg for _, p := range r.panes { + // Init will initiate each pane's model with its contents. cmds = append(cmds, p.Init()) } cmds = append(cmds, @@ -200,7 +208,7 @@ func (r *Repo) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } if r.selectedRepo != nil { cmds = append(cmds, r.updateStatusBarCmd) - urlID := fmt.Sprintf("%s-url", r.selectedRepo.Repo()) + urlID := fmt.Sprintf("%s-url", r.selectedRepo.Name()) if msg, ok := msg.(tea.MouseMsg); ok && r.common.Zone.Get(urlID).InBounds(msg) { cmds = append(cmds, r.copyURLCmd()) } @@ -221,46 +229,46 @@ func (r *Repo) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } } case CopyURLMsg: - r.common.Copy.Copy( - git.RepoURL(r.cfg.Host, r.cfg.Port, r.selectedRepo.Repo()), - ) + if cfg := r.common.Config(); cfg != nil { + host := cfg.Backend.ServerHost() + port := cfg.Backend.ServerPort() + r.common.Copy.Copy( + common.RepoURL(host, port, r.selectedRepo.Name()), + ) + } case ResetURLMsg: r.copyURL = time.Time{} - case ReadmeMsg: - case FileItemsMsg: - f, cmd := r.panes[filesTab].Update(msg) - r.panes[filesTab] = f.(*Files) - if cmd != nil { - cmds = append(cmds, cmd) - } - // The Log bubble is the only bubble that uses a spinner, so this is fine - // for now. We need to pass the TickMsg to the Log bubble when the Log is - // loading but not the current selected tab so that the spinner works. - case LogCountMsg, LogItemsMsg, spinner.TickMsg: - l, cmd := r.panes[commitsTab].Update(msg) - r.panes[commitsTab] = l.(*Log) - if cmd != nil { - cmds = append(cmds, cmd) - } - case RefItemsMsg: - switch msg.prefix { - case ggit.RefsHeads: - b, cmd := r.panes[branchesTab].Update(msg) - r.panes[branchesTab] = b.(*Refs) - if cmd != nil { - cmds = append(cmds, cmd) - } - case ggit.RefsTags: - t, cmd := r.panes[tagsTab].Update(msg) - r.panes[tagsTab] = t.(*Refs) - if cmd != nil { - cmds = append(cmds, cmd) + case ReadmeMsg, FileItemsMsg, LogCountMsg, LogItemsMsg, RefItemsMsg: + cmds = append(cmds, r.updateRepo(msg)) + // We have two spinners, one is used to when loading the repository and the + // other is used when loading the log. + // Check if the spinner ID matches the spinner model. + case spinner.TickMsg: + switch msg.ID { + case r.spinner.ID(): + if r.state == loadingState { + s, cmd := r.spinner.Update(msg) + r.spinner = s + if cmd != nil { + cmds = append(cmds, cmd) + } } + default: + cmds = append(cmds, r.updateRepo(msg)) } case UpdateStatusBarMsg: cmds = append(cmds, r.updateStatusBarCmd) case tea.WindowSizeMsg: cmds = append(cmds, r.updateModels(msg)) + case EmptyRepoMsg: + r.ref = nil + r.state = readyState + cmds = append(cmds, + r.updateModels(msg), + r.updateStatusBarCmd, + ) + case common.ErrorMsg: + r.state = readyState } s, cmd := r.statusbar.Update(msg) r.statusbar = s.(*statusbar.StatusBar) @@ -289,15 +297,24 @@ func (r *Repo) View() string { r.common.Styles.Tabs.GetVerticalFrameSize() mainStyle := repoBodyStyle. Height(r.common.Height - hm) - main := r.common.Zone.Mark( + var main string + var statusbar string + switch r.state { + case loadingState: + main = fmt.Sprintf("%s loading…", r.spinner.View()) + case readyState: + main = r.panes[r.activeTab].View() + statusbar = r.statusbar.View() + } + main = r.common.Zone.Mark( "repo-main", - mainStyle.Render(r.panes[r.activeTab].View()), + mainStyle.Render(main), ) view := lipgloss.JoinVertical(lipgloss.Top, r.headerView(), r.tabs.View(), main, - r.statusbar.View(), + statusbar, ) return s.Render(view) } @@ -306,7 +323,6 @@ func (r *Repo) headerView() string { if r.selectedRepo == nil { return "" } - cfg := r.cfg truncate := lipgloss.NewStyle().MaxWidth(r.common.Width) name := r.common.Styles.Repo.HeaderName.Render(r.selectedRepo.Name()) desc := r.selectedRepo.Description() @@ -319,13 +335,16 @@ func (r *Repo) headerView() string { urlStyle := r.common.Styles.URLStyle.Copy(). Width(r.common.Width - lipgloss.Width(desc) - 1). Align(lipgloss.Right) - url := git.RepoURL(cfg.Host, cfg.Port, r.selectedRepo.Repo()) + var url string + if cfg := r.common.Config(); cfg != nil { + url = common.RepoURL(cfg.Backend.ServerHost(), cfg.Backend.ServerPort(), r.selectedRepo.Name()) + } if !r.copyURL.IsZero() && r.copyURL.Add(time.Second).After(time.Now()) { url = "copied!" } url = common.TruncateString(url, r.common.Width-lipgloss.Width(desc)-1) url = r.common.Zone.Mark( - fmt.Sprintf("%s-url", r.selectedRepo.Repo()), + fmt.Sprintf("%s-url", r.selectedRepo.Name()), urlStyle.Render(url), ) style := r.common.Styles.Repo.Header.Copy().Width(r.common.Width) @@ -346,27 +365,16 @@ func (r *Repo) updateStatusBarCmd() tea.Msg { } value := r.panes[r.activeTab].(statusbar.Model).StatusBarValue() info := r.panes[r.activeTab].(statusbar.Model).StatusBarInfo() - ref := "" + branch := "*" if r.ref != nil { - ref = r.ref.Name().Short() + branch += " " + r.ref.Name().Short() } return statusbar.StatusBarMsg{ - Key: r.selectedRepo.Repo(), + Key: r.selectedRepo.Name(), Value: value, Info: info, - Branch: fmt.Sprintf("* %s", ref), - } -} - -func (r *Repo) updateRefCmd() tea.Msg { - if r.selectedRepo == nil { - return nil - } - head, err := r.selectedRepo.HEAD() - if err != nil { - return common.ErrorMsg(err) + Branch: branch, } - return RefMsg(head) } func (r *Repo) updateModels(msg tea.Msg) tea.Cmd { @@ -381,6 +389,67 @@ func (r *Repo) updateModels(msg tea.Msg) tea.Cmd { return tea.Batch(cmds...) } +func (r *Repo) updateRepo(msg tea.Msg) tea.Cmd { + cmds := make([]tea.Cmd, 0) + switch msg := msg.(type) { + case LogCountMsg, LogItemsMsg, spinner.TickMsg: + switch msg.(type) { + case LogItemsMsg: + r.panesReady[commitsTab] = true + } + l, cmd := r.panes[commitsTab].Update(msg) + r.panes[commitsTab] = l.(*Log) + if cmd != nil { + cmds = append(cmds, cmd) + } + case FileItemsMsg: + r.panesReady[filesTab] = true + f, cmd := r.panes[filesTab].Update(msg) + r.panes[filesTab] = f.(*Files) + if cmd != nil { + cmds = append(cmds, cmd) + } + case RefItemsMsg: + switch msg.prefix { + case git.RefsHeads: + r.panesReady[branchesTab] = true + b, cmd := r.panes[branchesTab].Update(msg) + r.panes[branchesTab] = b.(*Refs) + if cmd != nil { + cmds = append(cmds, cmd) + } + case git.RefsTags: + r.panesReady[tagsTab] = true + t, cmd := r.panes[tagsTab].Update(msg) + r.panes[tagsTab] = t.(*Refs) + if cmd != nil { + cmds = append(cmds, cmd) + } + } + case ReadmeMsg: + r.panesReady[readmeTab] = true + } + if r.isReady() { + r.state = readyState + } + return tea.Batch(cmds...) +} + +func (r *Repo) isReady() bool { + ready := true + // We purposely ignore the log pane here because it has its own spinner. + for _, b := range []bool{ + r.panesReady[filesTab], r.panesReady[branchesTab], + r.panesReady[tagsTab], r.panesReady[readmeTab], + } { + if !b { + ready = false + break + } + } + return ready +} + func (r *Repo) copyURLCmd() tea.Cmd { r.copyURL = time.Now() return tea.Batch( diff --git a/ui/pages/selection/item.go b/ui/pages/selection/item.go index 97abe72c1..8e9abd69c 100644 --- a/ui/pages/selection/item.go +++ b/ui/pages/selection/item.go @@ -3,6 +3,7 @@ package selection import ( "fmt" "io" + "sort" "strings" "time" @@ -10,26 +11,76 @@ import ( "github.com/charmbracelet/bubbles/list" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" + "github.com/charmbracelet/soft-serve/server/backend" + "github.com/charmbracelet/soft-serve/server/config" "github.com/charmbracelet/soft-serve/ui/common" - "github.com/charmbracelet/soft-serve/ui/git" "github.com/dustin/go-humanize" ) +var _ sort.Interface = Items{} + +// Items is a list of Item. +type Items []Item + +// Len implements sort.Interface. +func (it Items) Len() int { + return len(it) +} + +// Less implements sort.Interface. +func (it Items) Less(i int, j int) bool { + if it[i].lastUpdate == nil && it[j].lastUpdate != nil { + return false + } + if it[i].lastUpdate != nil && it[j].lastUpdate == nil { + return true + } + if it[i].lastUpdate == nil && it[j].lastUpdate == nil { + return it[i].repo.Name() < it[j].repo.Name() + } + return it[i].lastUpdate.After(*it[j].lastUpdate) +} + +// Swap implements sort.Interface. +func (it Items) Swap(i int, j int) { + it[i], it[j] = it[j], it[i] +} + // Item represents a single item in the selector. type Item struct { - repo git.GitRepo - lastUpdate time.Time + repo backend.Repository + lastUpdate *time.Time cmd string copied time.Time } +// New creates a new Item. +func NewItem(repo backend.Repository, cfg *config.Config) (Item, error) { + r, err := repo.Repository() + if err != nil { + return Item{}, err + } + var lastUpdate *time.Time + lu, err := r.LatestCommitTime() + if err == nil { + lastUpdate = &lu + } + return Item{ + repo: repo, + lastUpdate: lastUpdate, + cmd: common.RepoURL(cfg.Backend.ServerHost(), cfg.Backend.ServerPort(), repo.Name()), + }, nil +} + // ID implements selector.IdentifiableItem. func (i Item) ID() string { - return i.repo.Repo() + return i.repo.Name() } // Title returns the item title. Implements list.DefaultItem. -func (i Item) Title() string { return i.repo.Name() } +func (i Item) Title() string { + return i.repo.Name() +} // Description returns the item description. Implements list.DefaultItem. func (i Item) Description() string { return i.repo.Description() } @@ -107,7 +158,10 @@ func (d ItemDelegate) Render(w io.Writer, m list.Model, index int, listItem list if isSelected { title += " " } - updatedStr := fmt.Sprintf(" Updated %s", humanize.Time(i.lastUpdate)) + var updatedStr string + if i.lastUpdate != nil { + updatedStr = fmt.Sprintf(" Updated %s", humanize.Time(*i.lastUpdate)) + } if m.Width()-styles.Base.GetHorizontalFrameSize()-lipgloss.Width(updatedStr)-lipgloss.Width(title) <= 0 { updatedStr = "" } diff --git a/ui/pages/selection/selection.go b/ui/pages/selection/selection.go index d2c46b1bd..5638a743e 100644 --- a/ui/pages/selection/selection.go +++ b/ui/pages/selection/selection.go @@ -2,20 +2,17 @@ package selection import ( "fmt" - "strings" + "log" + "sort" "github.com/charmbracelet/bubbles/key" "github.com/charmbracelet/bubbles/list" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" - "github.com/charmbracelet/soft-serve/config" "github.com/charmbracelet/soft-serve/ui/common" "github.com/charmbracelet/soft-serve/ui/components/code" "github.com/charmbracelet/soft-serve/ui/components/selector" "github.com/charmbracelet/soft-serve/ui/components/tabs" - "github.com/charmbracelet/soft-serve/ui/git" - wgit "github.com/charmbracelet/wish/git" - "github.com/gliderlabs/ssh" ) type pane int @@ -35,8 +32,6 @@ func (p pane) String() string { // Selection is the model for the selection screen/page. type Selection struct { - cfg *config.Config - pk ssh.PublicKey common common.Common readme *code.Code readmeHeight int @@ -46,29 +41,27 @@ type Selection struct { } // New creates a new selection model. -func New(cfg *config.Config, pk ssh.PublicKey, common common.Common) *Selection { +func New(c common.Common) *Selection { ts := make([]string, lastPane) for i, b := range []pane{selectorPane, readmePane} { ts[i] = b.String() } - t := tabs.New(common, ts) + t := tabs.New(c, ts) t.TabSeparator = lipgloss.NewStyle() - t.TabInactive = common.Styles.TopLevelNormalTab.Copy() - t.TabActive = common.Styles.TopLevelActiveTab.Copy() - t.TabDot = common.Styles.TopLevelActiveTabDot.Copy() + t.TabInactive = c.Styles.TopLevelNormalTab.Copy() + t.TabActive = c.Styles.TopLevelActiveTab.Copy() + t.TabDot = c.Styles.TopLevelActiveTabDot.Copy() t.UseDot = true sel := &Selection{ - cfg: cfg, - pk: pk, - common: common, + common: c, activePane: selectorPane, // start with the selector focused tabs: t, } - readme := code.New(common, "", "") - readme.NoContentStyle = readme.NoContentStyle.SetString("No readme found.") - selector := selector.New(common, + readme := code.New(c, "", "") + readme.NoContentStyle = c.Styles.NoContent.Copy().SetString("No readme found.") + selector := selector.New(c, []selector.IdentifiableItem{}, - ItemDelegate{&common, &sel.activePane}) + ItemDelegate{&c, &sel.activePane}) selector.SetShowTitle(false) selector.SetShowHelp(false) selector.SetShowStatusBar(false) @@ -184,59 +177,29 @@ func (s *Selection) FullHelp() [][]key.Binding { // Init implements tea.Model. func (s *Selection) Init() tea.Cmd { var readmeCmd tea.Cmd - items := make([]selector.IdentifiableItem, 0) - cfg := s.cfg - pk := s.pk + cfg := s.common.Config() + pk := s.common.PublicKey() + if cfg == nil || pk == nil { + return nil + } + repos, err := cfg.Backend.Repositories() + if err != nil { + return common.ErrorCmd(err) + } + sortedItems := make(Items, 0) // Put configured repos first - for _, r := range cfg.Repos { - acc := cfg.AuthRepo(r.Repo, pk) - if r.Private && acc < wgit.ReadOnlyAccess { - continue - } - repo, err := cfg.Source.GetRepo(r.Repo) + for _, r := range repos { + item, err := NewItem(r, cfg) if err != nil { + log.Printf("ui: failed to create item for %s: %v", r.Name(), err) continue } - items = append(items, Item{ - repo: repo, - cmd: git.RepoURL(cfg.Host, cfg.Port, r.Repo), - }) + sortedItems = append(sortedItems, item) } - for _, r := range cfg.Source.AllRepos() { - if r.Repo() == "config" { - rm, rp := r.Readme() - s.readmeHeight = strings.Count(rm, "\n") - readmeCmd = s.readme.SetContent(rm, rp) - } - acc := cfg.AuthRepo(r.Repo(), pk) - if r.IsPrivate() && acc < wgit.ReadOnlyAccess { - continue - } - exists := false - lc, err := r.Commit("HEAD") - if err != nil { - return common.ErrorCmd(err) - } - lastUpdate := lc.Committer.When - if lastUpdate.IsZero() { - lastUpdate = lc.Author.When - } - for i, item := range items { - item := item.(Item) - if item.repo.Repo() == r.Repo() { - exists = true - item.lastUpdate = lastUpdate - items[i] = item - break - } - } - if !exists { - items = append(items, Item{ - repo: r, - lastUpdate: lastUpdate, - cmd: git.RepoURL(cfg.Host, cfg.Port, r.Name()), - }) - } + sort.Sort(sortedItems) + items := make([]selector.IdentifiableItem, len(sortedItems)) + for i, it := range sortedItems { + items[i] = it } return tea.Batch( s.selector.Init(), diff --git a/ui/styles/styles.go b/ui/styles/styles.go index f596a09a1..cd50439cf 100644 --- a/ui/styles/styles.go +++ b/ui/styles/styles.go @@ -119,12 +119,14 @@ type Styles struct { Selector lipgloss.Style FileContent lipgloss.Style Paginator lipgloss.Style - NoItems lipgloss.Style } - Spinner lipgloss.Style + Spinner lipgloss.Style + SpinnerContainer lipgloss.Style - CodeNoContent lipgloss.Style + NoContent lipgloss.Style + + NoItems lipgloss.Style StatusBar lipgloss.Style StatusBarKey lipgloss.Style @@ -402,19 +404,21 @@ func DefaultStyles() *Styles { s.Tree.Paginator = s.Log.Paginator.Copy() - s.Tree.NoItems = s.AboutNoReadme.Copy() - s.Spinner = lipgloss.NewStyle(). MarginTop(1). MarginLeft(2). Foreground(lipgloss.Color("205")) - s.CodeNoContent = lipgloss.NewStyle(). + s.SpinnerContainer = lipgloss.NewStyle() + + s.NoContent = lipgloss.NewStyle(). SetString("No Content."). - MarginTop(1). + MarginTop(2). MarginLeft(2). Foreground(lipgloss.Color("242")) + s.NoItems = s.AboutNoReadme.Copy() + s.StatusBar = lipgloss.NewStyle(). Height(1) diff --git a/ui/ui.go b/ui/ui.go index dd482ac68..e81de69c3 100644 --- a/ui/ui.go +++ b/ui/ui.go @@ -1,19 +1,20 @@ package ui import ( + "errors" + "log" + "github.com/charmbracelet/bubbles/key" "github.com/charmbracelet/bubbles/list" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" - "github.com/charmbracelet/soft-serve/config" + "github.com/charmbracelet/soft-serve/server/backend" "github.com/charmbracelet/soft-serve/ui/common" "github.com/charmbracelet/soft-serve/ui/components/footer" "github.com/charmbracelet/soft-serve/ui/components/header" "github.com/charmbracelet/soft-serve/ui/components/selector" - "github.com/charmbracelet/soft-serve/ui/git" "github.com/charmbracelet/soft-serve/ui/pages/repo" "github.com/charmbracelet/soft-serve/ui/pages/selection" - "github.com/gliderlabs/ssh" ) type page int @@ -26,16 +27,14 @@ const ( type sessionState int const ( - startState sessionState = iota + loadingState sessionState = iota errorState - loadedState + readyState ) // UI is the main UI model. type UI struct { - cfg *config.Config - session ssh.Session - rs git.GitRepoSource + serverName string initialRepo string common common.Common pages []common.Component @@ -48,17 +47,18 @@ type UI struct { } // New returns a new UI model. -func New(cfg *config.Config, s ssh.Session, c common.Common, initialRepo string) *UI { - src := &source{cfg.Source} - h := header.New(c, cfg.Name) +func New(c common.Common, initialRepo string) *UI { + var serverName string + if cfg := c.Config(); cfg != nil { + serverName = cfg.Backend.ServerName() + } + h := header.New(c, serverName) ui := &UI{ - cfg: cfg, - session: s, - rs: src, + serverName: serverName, common: c, pages: make([]common.Component, 2), // selection & repo activePage: selectionPage, - state: startState, + state: loadingState, header: h, initialRepo: initialRepo, showFooter: true, @@ -92,7 +92,7 @@ func (ui *UI) ShortHelp() []key.Binding { switch ui.state { case errorState: b = append(b, ui.common.KeyMap.Back) - case loadedState: + case readyState: b = append(b, ui.pages[ui.activePage].ShortHelp()...) } if !ui.IsFiltering() { @@ -108,7 +108,7 @@ func (ui *UI) FullHelp() [][]key.Binding { switch ui.state { case errorState: b = append(b, []key.Binding{ui.common.KeyMap.Back}) - case loadedState: + case readyState: b = append(b, ui.pages[ui.activePage].FullHelp()...) } h := []key.Binding{ @@ -136,15 +136,8 @@ func (ui *UI) SetSize(width, height int) { // Init implements tea.Model. func (ui *UI) Init() tea.Cmd { - ui.pages[selectionPage] = selection.New( - ui.cfg, - ui.session.PublicKey(), - ui.common, - ) - ui.pages[repoPage] = repo.New( - ui.cfg, - ui.common, - ) + ui.pages[selectionPage] = selection.New(ui.common) + ui.pages[repoPage] = repo.New(ui.common) ui.SetSize(ui.common.Width, ui.common.Height) cmds := make([]tea.Cmd, 0) cmds = append(cmds, @@ -154,7 +147,7 @@ func (ui *UI) Init() tea.Cmd { if ui.initialRepo != "" { cmds = append(cmds, ui.initialRepoCmd(ui.initialRepo)) } - ui.state = loadedState + ui.state = readyState ui.SetSize(ui.common.Width, ui.common.Height) return tea.Batch(cmds...) } @@ -171,6 +164,7 @@ func (ui *UI) IsFiltering() bool { // Update implements tea.Model. func (ui *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + log.Printf("msg received: %T", msg) cmds := make([]tea.Cmd, 0) switch msg := msg.(type) { case tea.WindowSizeMsg: @@ -188,7 +182,7 @@ func (ui *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch { case key.Matches(msg, ui.common.KeyMap.Back) && ui.error != nil: ui.error = nil - ui.state = loadedState + ui.state = readyState // Always show the footer on error. ui.showFooter = ui.footer.ShowAll() case key.Matches(msg, ui.common.KeyMap.Help): @@ -220,14 +214,15 @@ func (ui *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) { ui.showFooter = !ui.showFooter } case repo.RepoMsg: + ui.common.SetValue(common.RepoKey, msg) ui.activePage = repoPage // Show the footer on repo page if show all is set. ui.showFooter = ui.footer.ShowAll() + cmds = append(cmds, repo.UpdateRefCmd(msg)) case common.ErrorMsg: ui.error = msg ui.state = errorState ui.showFooter = true - return ui, nil case selector.SelectMsg: switch msg.IdentifiableItem.(type) { case selection.Item: @@ -246,7 +241,7 @@ func (ui *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if cmd != nil { cmds = append(cmds, cmd) } - if ui.state == loadedState { + if ui.state != loadingState { m, cmd := ui.pages[ui.activePage].Update(msg) ui.pages[ui.activePage] = m.(common.Component) if cmd != nil { @@ -263,7 +258,7 @@ func (ui *UI) View() string { var view string wm, hm := ui.getMargins() switch ui.state { - case startState: + case loadingState: view = "Loading..." case errorState: err := ui.common.Styles.ErrorTitle.Render("Bummer") @@ -276,7 +271,7 @@ func (ui *UI) View() string { hm - ui.common.Styles.Error.GetVerticalFrameSize()). Render(err) - case loadedState: + case readyState: view = ui.pages[ui.activePage].View() default: view = "Unknown state :/ this is a bug!" @@ -292,24 +287,40 @@ func (ui *UI) View() string { ) } +func (ui *UI) openRepo(rn string) (backend.Repository, error) { + cfg := ui.common.Config() + if cfg == nil { + return nil, errors.New("config is nil") + } + repos, err := cfg.Backend.Repositories() + if err != nil { + log.Printf("ui: failed to list repos: %v", err) + return nil, err + } + for _, r := range repos { + if r.Name() == rn { + return r, nil + } + } + return nil, common.ErrMissingRepo +} + func (ui *UI) setRepoCmd(rn string) tea.Cmd { return func() tea.Msg { - for _, r := range ui.rs.AllRepos() { - if r.Repo() == rn { - return repo.RepoMsg(r) - } + r, err := ui.openRepo(rn) + if err != nil { + return common.ErrorMsg(err) } - return common.ErrorMsg(git.ErrMissingRepo) + return repo.RepoMsg(r) } } func (ui *UI) initialRepoCmd(rn string) tea.Cmd { return func() tea.Msg { - for _, r := range ui.rs.AllRepos() { - if r.Repo() == rn { - return repo.RepoMsg(r) - } + r, err := ui.openRepo(rn) + if err != nil { + return nil } - return nil + return repo.RepoMsg(r) } }