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