1package cmd
  2
  3import (
  4	"context"
  5	"fmt"
  6	"net/url"
  7	"strings"
  8	"text/template"
  9	"unicode"
 10
 11	"github.com/charmbracelet/log"
 12	"github.com/charmbracelet/soft-serve/server/backend"
 13	"github.com/charmbracelet/soft-serve/server/config"
 14	"github.com/charmbracelet/soft-serve/server/hooks"
 15	"github.com/charmbracelet/soft-serve/server/utils"
 16	"github.com/charmbracelet/ssh"
 17	"github.com/charmbracelet/wish"
 18	"github.com/spf13/cobra"
 19)
 20
 21// ContextKey is a type that can be used as a key in a context.
 22type ContextKey string
 23
 24// String returns the string representation of the ContextKey.
 25func (c ContextKey) String() string {
 26	return string(c) + "ContextKey"
 27}
 28
 29var (
 30	// ConfigCtxKey is the key for the config in the context.
 31	ConfigCtxKey = ContextKey("config")
 32	// SessionCtxKey is the key for the session in the context.
 33	SessionCtxKey = ContextKey("session")
 34	// HooksCtxKey is the key for the git hooks in the context.
 35	HooksCtxKey = ContextKey("hooks")
 36)
 37
 38var (
 39	// ErrUnauthorized is returned when the user is not authorized to perform action.
 40	ErrUnauthorized = fmt.Errorf("Unauthorized")
 41	// ErrRepoNotFound is returned when the repo is not found.
 42	ErrRepoNotFound = fmt.Errorf("Repository not found")
 43	// ErrFileNotFound is returned when the file is not found.
 44	ErrFileNotFound = fmt.Errorf("File not found")
 45)
 46
 47var (
 48	logger = log.WithPrefix("server.cmd")
 49)
 50
 51var templateFuncs = template.FuncMap{
 52	"trim":                    strings.TrimSpace,
 53	"trimRightSpace":          trimRightSpace,
 54	"trimTrailingWhitespaces": trimRightSpace,
 55	"rpad":                    rpad,
 56	"gt":                      cobra.Gt,
 57	"eq":                      cobra.Eq,
 58}
 59
 60const (
 61	usageTmpl = `Usage:{{if .Runnable}}
 62  {{.UseLine}}{{end}}{{if .HasAvailableSubCommands}}
 63  {{.SSHCommand}}{{.CommandPath}} [command]{{end}}{{if gt (len .Aliases) 0}}
 64
 65Aliases:
 66  {{.NameAndAliases}}{{end}}{{if .HasExample}}
 67
 68Examples:
 69{{.Example}}{{end}}{{if .HasAvailableSubCommands}}{{$cmds := .Commands}}{{if eq (len .Groups) 0}}
 70
 71Available Commands:{{range $cmds}}{{if (or .IsAvailableCommand (eq .Name "help"))}}
 72  {{rpad .Name .NamePadding }} {{.Short}}{{end}}{{end}}{{else}}{{range $group := .Groups}}
 73
 74{{.Title}}{{range $cmds}}{{if (and (eq .GroupID $group.ID) (or .IsAvailableCommand (eq .Name "help")))}}
 75  {{rpad .Name .NamePadding }} {{.Short}}{{end}}{{end}}{{end}}{{if not .AllChildCommandsHaveGroup}}
 76
 77Additional Commands:{{range $cmds}}{{if (and (eq .GroupID "") (or .IsAvailableCommand (eq .Name "help")))}}
 78  {{rpad .Name .NamePadding }} {{.Short}}{{end}}{{end}}{{end}}{{end}}{{end}}{{if .HasAvailableLocalFlags}}
 79
 80Flags:
 81{{.LocalFlags.FlagUsages | trimTrailingWhitespaces}}{{end}}{{if .HasAvailableInheritedFlags}}
 82
 83Global Flags:
 84{{.InheritedFlags.FlagUsages | trimTrailingWhitespaces}}{{end}}{{if .HasHelpSubCommands}}
 85
 86Additional help topics:{{range .Commands}}{{if .IsAdditionalHelpTopicCommand}}
 87  {{rpad .CommandPath .CommandPathPadding}} {{.Short}}{{end}}{{end}}{{end}}{{if .HasAvailableSubCommands}}
 88
 89Use "{{.SSHCommand}}{{.CommandPath}} [command] --help" for more information about a command.{{end}}
 90`
 91)
 92
 93func trimRightSpace(s string) string {
 94	return strings.TrimRightFunc(s, unicode.IsSpace)
 95}
 96
 97// rpad adds padding to the right of a string.
 98func rpad(s string, padding int) string {
 99	template := fmt.Sprintf("%%-%ds", padding)
100	return fmt.Sprintf(template, s)
101}
102
103// rootCommand is the root command for the server.
104func rootCommand(cfg *config.Config, s ssh.Session) *cobra.Command {
105	rootCmd := &cobra.Command{
106		Short:        "Soft Serve is a self-hostable Git server for the command line.",
107		SilenceUsage: true,
108	}
109
110	hostname := "localhost"
111	port := "23231"
112	url, err := url.Parse(cfg.SSH.PublicURL)
113	if err == nil {
114		hostname = url.Hostname()
115		port = url.Port()
116	}
117
118	sshCmd := "ssh"
119	if port != "" && port != "22" {
120		sshCmd += " -p " + port
121	}
122
123	sshCmd += " " + hostname
124	rootCmd.SetUsageTemplate(usageTmpl)
125	rootCmd.SetUsageFunc(func(c *cobra.Command) error {
126		t := template.New("usage")
127		t.Funcs(templateFuncs)
128		template.Must(t.Parse(c.UsageTemplate()))
129		return t.Execute(c.OutOrStderr(), struct {
130			*cobra.Command
131			SSHCommand string
132		}{
133			Command:    c,
134			SSHCommand: sshCmd,
135		})
136	})
137	rootCmd.CompletionOptions.DisableDefaultCmd = true
138	rootCmd.AddCommand(
139		hookCommand(),
140		repoCommand(),
141	)
142
143	user, _ := cfg.Backend.UserByPublicKey(s.PublicKey())
144	isAdmin := isPublicKeyAdmin(cfg, s.PublicKey()) || (user != nil && user.IsAdmin())
145	if user != nil || isAdmin {
146		if isAdmin {
147			rootCmd.AddCommand(
148				settingsCommand(),
149				userCommand(),
150			)
151		}
152
153		rootCmd.AddCommand(
154			infoCommand(),
155			pubkeyCommand(),
156			setUsernameCommand(),
157		)
158	}
159
160	return rootCmd
161}
162
163func fromContext(cmd *cobra.Command) (*config.Config, ssh.Session) {
164	ctx := cmd.Context()
165	cfg := ctx.Value(ConfigCtxKey).(*config.Config)
166	s := ctx.Value(SessionCtxKey).(ssh.Session)
167	return cfg, s
168}
169
170func checkIfReadable(cmd *cobra.Command, args []string) error {
171	var repo string
172	if len(args) > 0 {
173		repo = args[0]
174	}
175	cfg, s := fromContext(cmd)
176	rn := utils.SanitizeRepo(repo)
177	auth := cfg.Backend.AccessLevelByPublicKey(rn, s.PublicKey())
178	if auth < backend.ReadOnlyAccess {
179		return ErrUnauthorized
180	}
181	return nil
182}
183
184func isPublicKeyAdmin(cfg *config.Config, pk ssh.PublicKey) bool {
185	for _, k := range cfg.InitialAdminKeys {
186		pk2, _, err := backend.ParseAuthorizedKey(k)
187		if err == nil && backend.KeysEqual(pk, pk2) {
188			return true
189		}
190	}
191	return false
192}
193
194func checkIfAdmin(cmd *cobra.Command, _ []string) error {
195	cfg, s := fromContext(cmd)
196	if isPublicKeyAdmin(cfg, s.PublicKey()) {
197		return nil
198	}
199
200	user, _ := cfg.Backend.UserByPublicKey(s.PublicKey())
201	if user == nil {
202		return ErrUnauthorized
203	}
204
205	if !user.IsAdmin() {
206		return ErrUnauthorized
207	}
208
209	return nil
210}
211
212func checkIfCollab(cmd *cobra.Command, args []string) error {
213	var repo string
214	if len(args) > 0 {
215		repo = args[0]
216	}
217	cfg, s := fromContext(cmd)
218	rn := utils.SanitizeRepo(repo)
219	auth := cfg.Backend.AccessLevelByPublicKey(rn, s.PublicKey())
220	if auth < backend.ReadWriteAccess {
221		return ErrUnauthorized
222	}
223	return nil
224}
225
226// Middleware is the Soft Serve middleware that handles SSH commands.
227func Middleware(cfg *config.Config, hooks hooks.Hooks) wish.Middleware {
228	return func(sh ssh.Handler) ssh.Handler {
229		return func(s ssh.Session) {
230			func() {
231				_, _, active := s.Pty()
232				if active {
233					return
234				}
235
236				// Ignore git server commands.
237				args := s.Command()
238				if len(args) > 0 {
239					if args[0] == "git-receive-pack" ||
240						args[0] == "git-upload-pack" ||
241						args[0] == "git-upload-archive" {
242						return
243					}
244				}
245
246				ctx := context.WithValue(s.Context(), ConfigCtxKey, cfg)
247				ctx = context.WithValue(ctx, SessionCtxKey, s)
248				ctx = context.WithValue(ctx, HooksCtxKey, hooks)
249
250				rootCmd := rootCommand(cfg, s)
251				rootCmd.SetArgs(args)
252				if len(args) == 0 {
253					// otherwise it'll default to os.Args, which is not what we want.
254					rootCmd.SetArgs([]string{"--help"})
255				}
256				rootCmd.SetIn(s)
257				rootCmd.SetOut(s)
258				rootCmd.CompletionOptions.DisableDefaultCmd = true
259				rootCmd.SetErr(s.Stderr())
260				if err := rootCmd.ExecuteContext(ctx); err != nil {
261					_ = s.Exit(1)
262				}
263			}()
264			sh(s)
265		}
266	}
267}