diff --git a/server/backend/backend.go b/server/backend/backend.go index e43b83941..5d771e357 100644 --- a/server/backend/backend.go +++ b/server/backend/backend.go @@ -9,56 +9,10 @@ import ( // Backend is an interface that handles repositories management and any // non-Git related operations. type Backend interface { - // ServerName returns the server's name. - ServerName() string - // SetServerName sets the server's name. - SetServerName(name string) error - // ServerHost returns the server's host. - ServerHost() string - // SetServerHost sets the server's host. - SetServerHost(host string) error - // ServerPort returns the server's port. - ServerPort() string - // SetServerPort sets the server's port. - SetServerPort(port string) error - - // AnonAccess returns the access level for anonymous users. - AnonAccess() AccessLevel - // SetAnonAccess sets the access level for anonymous users. - SetAnonAccess(level AccessLevel) error - // AllowKeyless returns true if keyless access is allowed. - AllowKeyless() bool - // SetAllowKeyless sets whether or not keyless access is allowed. - SetAllowKeyless(allow bool) error - - // Repository finds the given repository. - Repository(repo string) (Repository, error) - // Repositories returns a list of all repositories. - Repositories() ([]Repository, error) - // CreateRepository creates a new repository. - CreateRepository(name string, private bool) (Repository, error) - // DeleteRepository deletes a repository. - DeleteRepository(name string) error - // RenameRepository renames a repository. - RenameRepository(oldName, newName string) error - - // Description returns the repo's description. - Description(repo string) string - // SetDescription sets the repo's description. - SetDescription(repo, desc string) error - // IsPrivate returns true if the repository is private. - IsPrivate(repo string) bool - // SetPrivate sets the repository's private status. - SetPrivate(repo string, priv bool) error - - // IsCollaborator returns true if the authorized key is a collaborator on the repository. - IsCollaborator(pk ssh.PublicKey, repo string) bool - // AddCollaborator adds the authorized key as a collaborator on the repository. - AddCollaborator(pk ssh.PublicKey, repo string) error - // IsAdmin returns true if the authorized key is an admin. - IsAdmin(pk ssh.PublicKey) bool - // AddAdmin adds the authorized key as an admin. - AddAdmin(pk ssh.PublicKey) error + ServerBackend + RepositoryStore + RepositoryMetadata + RepositoryAccess } // ParseAuthorizedKey parses an authorized key string into a public key. diff --git a/server/backend/file/file.go b/server/backend/file/file.go index da8196d63..05949556b 100644 --- a/server/backend/file/file.go +++ b/server/backend/file/file.go @@ -89,7 +89,7 @@ func (fb *FileBackend) adminsPath() string { } func (fb *FileBackend) collabsPath(repo string) string { - return filepath.Join(fb.reposPath(), repo, collabs) + return filepath.Join(fb.path, collabs, repo) } func sanatizeRepo(repo string) string { @@ -117,10 +117,16 @@ func readAll(path string) (string, error) { return string(bts), err } +// exists returns true if the given path exists. +func exists(path string) bool { + _, err := os.Stat(path) + return err == nil +} + // NewFileBackend creates a new FileBackend. func NewFileBackend(path string) (*FileBackend, error) { fb := &FileBackend{path: path} - for _, dir := range []string{repos, settings} { + for _, dir := range []string{repos, settings, collabs} { if err := os.MkdirAll(filepath.Join(path, dir), 0755); err != nil { return nil, err } @@ -181,10 +187,10 @@ func (fb *FileBackend) AccessLevel(repo string, pk gossh.PublicKey) backend.Acce // AddAdmin adds a public key to the list of server admins. // // It implements backend.Backend. -func (fb *FileBackend) AddAdmin(pk gossh.PublicKey) error { +func (fb *FileBackend) AddAdmin(pk gossh.PublicKey, memo string) error { // Skip if the key already exists. if fb.IsAdmin(pk) { - return nil + return fmt.Errorf("key already exists") } ak := backend.MarshalAuthorizedKey(pk) @@ -195,32 +201,206 @@ func (fb *FileBackend) AddAdmin(pk gossh.PublicKey) error { } defer f.Close() //nolint:errcheck - _, err = fmt.Fprintln(f, ak) + if memo != "" { + memo = " " + memo + } + _, err = fmt.Fprintf(f, "%s%s\n", ak, memo) return err } // AddCollaborator adds a public key to the list of collaborators for the given repo. // // It implements backend.Backend. -func (fb *FileBackend) AddCollaborator(pk gossh.PublicKey, repo string) error { +func (fb *FileBackend) AddCollaborator(pk gossh.PublicKey, memo string, name string) error { + // Check if repo exists + if !exists(filepath.Join(fb.reposPath(), sanatizeRepo(name)+".git")) { + return fmt.Errorf("repository %s does not exist", name) + } + // Skip if the key already exists. - if fb.IsCollaborator(pk, repo) { - return nil + if fb.IsCollaborator(pk, name) { + return fmt.Errorf("key already exists") } ak := backend.MarshalAuthorizedKey(pk) - repo = sanatizeRepo(repo) + ".git" - f, err := os.OpenFile(fb.collabsPath(repo), os.O_APPEND|os.O_WRONLY|os.O_CREATE, 0644) + name = sanatizeRepo(name) + if err := os.MkdirAll(filepath.Dir(fb.collabsPath(name)), 0755); err != nil { + logger.Debug("failed to create collaborators directory", + "err", err, "path", filepath.Dir(fb.collabsPath(name))) + return err + } + + f, err := os.OpenFile(fb.collabsPath(name), os.O_APPEND|os.O_WRONLY|os.O_CREATE, 0644) if err != nil { - logger.Debug("failed to open collaborators file", "err", err, "path", fb.collabsPath(repo)) + logger.Debug("failed to open collaborators file", "err", err, "path", fb.collabsPath(name)) return err } defer f.Close() //nolint:errcheck - _, err = fmt.Fprintln(f, ak) + if memo != "" { + memo = " " + memo + } + _, err = fmt.Fprintf(f, "%s%s\n", ak, memo) return err } +// Admins returns a list of public keys that are admins. +// +// It implements backend.Backend. +func (fb *FileBackend) Admins() ([]string, error) { + admins := make([]string, 0) + f, err := os.Open(fb.adminsPath()) + if err != nil { + logger.Debug("failed to open admin keys file", "err", err, "path", fb.adminsPath()) + return nil, err + } + + defer f.Close() //nolint:errcheck + s := bufio.NewScanner(f) + for s.Scan() { + admins = append(admins, s.Text()) + } + + return admins, s.Err() +} + +// Collaborators returns a list of public keys that are collaborators for the given repo. +// +// It implements backend.Backend. +func (fb *FileBackend) Collaborators(repo string) ([]string, error) { + // Check if repo exists + if !exists(filepath.Join(fb.reposPath(), sanatizeRepo(repo)+".git")) { + return nil, fmt.Errorf("repository %s does not exist", repo) + } + + collabs := make([]string, 0) + f, err := os.Open(fb.collabsPath(repo)) + if err != nil && errors.Is(err, os.ErrNotExist) { + return collabs, nil + } + if err != nil { + logger.Debug("failed to open collaborators file", "err", err, "path", fb.collabsPath(repo)) + return nil, err + } + + defer f.Close() //nolint:errcheck + s := bufio.NewScanner(f) + for s.Scan() { + collabs = append(collabs, s.Text()) + } + + return collabs, s.Err() +} + +// RemoveAdmin implements backend.Backend +func (fb *FileBackend) RemoveAdmin(pk gossh.PublicKey) error { + f, err := os.OpenFile(fb.adminsPath(), os.O_RDWR, 0644) + if err != nil { + logger.Debug("failed to open admin keys file", "err", err, "path", fb.adminsPath()) + return err + } + + defer f.Close() //nolint:errcheck + s := bufio.NewScanner(f) + lines := make([]string, 0) + for s.Scan() { + apk, _, err := backend.ParseAuthorizedKey(s.Text()) + if err != nil { + logger.Debug("failed to parse admin key", "err", err, "path", fb.adminsPath()) + continue + } + + if !ssh.KeysEqual(apk, pk) { + lines = append(lines, s.Text()) + } + } + + if err := s.Err(); err != nil { + logger.Debug("failed to scan admin keys file", "err", err, "path", fb.adminsPath()) + return err + } + + if err := f.Truncate(0); err != nil { + logger.Debug("failed to truncate admin keys file", "err", err, "path", fb.adminsPath()) + return err + } + + if _, err := f.Seek(0, 0); err != nil { + logger.Debug("failed to seek admin keys file", "err", err, "path", fb.adminsPath()) + return err + } + + w := bufio.NewWriter(f) + for _, line := range lines { + if _, err := fmt.Fprintln(w, line); err != nil { + logger.Debug("failed to write admin keys file", "err", err, "path", fb.adminsPath()) + return err + } + } + + return w.Flush() +} + +// RemoveCollaborator removes a public key from the list of collaborators for the given repo. +// +// It implements backend.Backend. +func (fb *FileBackend) RemoveCollaborator(pk gossh.PublicKey, repo string) error { + // Check if repo exists + if !exists(filepath.Join(fb.reposPath(), sanatizeRepo(repo)+".git")) { + return fmt.Errorf("repository %s does not exist", repo) + } + + f, err := os.OpenFile(fb.collabsPath(repo), os.O_RDWR, 0644) + if err != nil && errors.Is(err, os.ErrNotExist) { + return nil + } + + if err != nil { + logger.Debug("failed to open collaborators file", "err", err, "path", fb.collabsPath(repo)) + return err + } + + defer f.Close() //nolint:errcheck + s := bufio.NewScanner(f) + lines := make([]string, 0) + for s.Scan() { + apk, _, err := backend.ParseAuthorizedKey(s.Text()) + if err != nil { + logger.Debug("failed to parse collaborator key", "err", err, "path", fb.collabsPath(repo)) + continue + } + + if !ssh.KeysEqual(apk, pk) { + lines = append(lines, s.Text()) + } + } + + if err := s.Err(); err != nil { + logger.Debug("failed to scan collaborators file", "err", err, "path", fb.collabsPath(repo)) + return err + } + + if err := f.Truncate(0); err != nil { + logger.Debug("failed to truncate collaborators file", "err", err, "path", fb.collabsPath(repo)) + return err + } + + if _, err := f.Seek(0, 0); err != nil { + logger.Debug("failed to seek collaborators file", "err", err, "path", fb.collabsPath(repo)) + return err + } + + w := bufio.NewWriter(f) + for _, line := range lines { + if _, err := fmt.Fprintln(w, line); err != nil { + logger.Debug("failed to write collaborators file", "err", err, "path", fb.collabsPath(repo)) + return err + } + } + + return w.Flush() +} + // AllowKeyless returns true if keyless access is allowed. // // It implements backend.Backend. @@ -304,19 +484,16 @@ func (fb *FileBackend) IsAdmin(pk gossh.PublicKey) bool { // given repo. // // It implements backend.Backend. -func (fb *FileBackend) IsCollaborator(pk gossh.PublicKey, repo string) bool { - repo = sanatizeRepo(repo) + ".git" - _, err := os.Stat(filepath.Join(fb.reposPath(), repo)) - if errors.Is(err, os.ErrNotExist) { +func (fb *FileBackend) IsCollaborator(pk gossh.PublicKey, name string) bool { + name = sanatizeRepo(name) + _, err := os.Stat(fb.collabsPath(name)) + if err != nil { return false } - f, err := os.Open(fb.collabsPath(repo)) - if err != nil && errors.Is(err, os.ErrNotExist) { - return false - } + f, err := os.Open(fb.collabsPath(name)) if err != nil { - logger.Debug("failed to open collaborators file", "err", err, "path", fb.collabsPath(repo)) + logger.Debug("failed to open collaborators file", "err", err, "path", fb.collabsPath(name)) return false } diff --git a/server/backend/noop/noop.go b/server/backend/noop/noop.go index 72c863b95..45cf14396 100644 --- a/server/backend/noop/noop.go +++ b/server/backend/noop/noop.go @@ -21,18 +21,38 @@ type Noop struct { Port string } +// Admins implements backend.Backend +func (*Noop) Admins() ([]string, error) { + return nil, nil +} + +// Collaborators implements backend.Backend +func (*Noop) Collaborators(repo string) ([]string, error) { + return nil, nil +} + +// RemoveAdmin implements backend.Backend +func (*Noop) RemoveAdmin(pk ssh.PublicKey) error { + return nil +} + +// RemoveCollaborator implements backend.Backend +func (*Noop) RemoveCollaborator(pk ssh.PublicKey, repo string) error { + return nil +} + // 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 { +func (*Noop) AddAdmin(pk ssh.PublicKey, memo string) error { return ErrNotImpl } // AddCollaborator implements backend.Backend -func (*Noop) AddCollaborator(pk ssh.PublicKey, repo string) error { +func (*Noop) AddCollaborator(pk ssh.PublicKey, memo string, repo string) error { return ErrNotImpl } diff --git a/server/backend/repo.go b/server/backend/repo.go index 61bd6c1ea..d9238ae76 100644 --- a/server/backend/repo.go +++ b/server/backend/repo.go @@ -1,6 +1,55 @@ package backend -import "github.com/charmbracelet/soft-serve/git" +import ( + "github.com/charmbracelet/soft-serve/git" + "golang.org/x/crypto/ssh" +) + +// RepositoryStore is an interface for managing repositories. +type RepositoryStore interface { + // Repository finds the given repository. + Repository(repo string) (Repository, error) + // Repositories returns a list of all repositories. + Repositories() ([]Repository, error) + // CreateRepository creates a new repository. + CreateRepository(name string, private bool) (Repository, error) + // DeleteRepository deletes a repository. + DeleteRepository(name string) error + // RenameRepository renames a repository. + RenameRepository(oldName, newName string) error +} + +// RepositoryMetadata is an interface for managing repository metadata. +type RepositoryMetadata interface { + // Description returns the repository's description. + Description(repo string) string + // SetDescription sets the repository's description. + SetDescription(repo, desc string) error + // IsPrivate returns whether the repository is private. + IsPrivate(repo string) bool + // SetPrivate sets whether the repository is private. + SetPrivate(repo string, private bool) error +} + +// RepositoryAccess is an interface for managing repository access. +type RepositoryAccess interface { + // IsCollaborator returns true if the authorized key is a collaborator on the repository. + IsCollaborator(pk ssh.PublicKey, repo string) bool + // AddCollaborator adds the authorized key as a collaborator on the repository. + AddCollaborator(pk ssh.PublicKey, memo string, repo string) error + // RemoveCollaborator removes the authorized key as a collaborator on the repository. + RemoveCollaborator(pk ssh.PublicKey, repo string) error + // Collaborators returns a list of all collaborators on the repository. + Collaborators(repo string) ([]string, error) + // IsAdmin returns true if the authorized key is an admin. + IsAdmin(pk ssh.PublicKey) bool + // AddAdmin adds the authorized key as an admin. + AddAdmin(pk ssh.PublicKey, memo string) error + // RemoveAdmin removes the authorized key as an admin. + RemoveAdmin(pk ssh.PublicKey) error + // Admins returns a list of all admins. + Admins() ([]string, error) +} // Repository is a Git repository interface. type Repository interface { diff --git a/server/backend/server.go b/server/backend/server.go new file mode 100644 index 000000000..97c02b096 --- /dev/null +++ b/server/backend/server.go @@ -0,0 +1,26 @@ +package backend + +// ServerBackend is an interface that handles server configuration. +type ServerBackend interface { + // ServerName returns the server's name. + ServerName() string + // SetServerName sets the server's name. + SetServerName(name string) error + // ServerHost returns the server's host. + ServerHost() string + // SetServerHost sets the server's host. + SetServerHost(host string) error + // ServerPort returns the server's port. + ServerPort() string + // SetServerPort sets the server's port. + SetServerPort(port string) error + + // AnonAccess returns the access level for anonymous users. + AnonAccess() AccessLevel + // SetAnonAccess sets the access level for anonymous users. + SetAnonAccess(level AccessLevel) error + // AllowKeyless returns true if keyless access is allowed. + AllowKeyless() bool + // SetAllowKeyless sets whether or not keyless access is allowed. + SetAllowKeyless(allow bool) error +}