cmd.go

  1package cmd
  2
  3import (
  4	"fmt"
  5	"net/url"
  6	"strings"
  7	"text/template"
  8	"unicode"
  9
 10	"github.com/charmbracelet/soft-serve/server/access"
 11	"github.com/charmbracelet/soft-serve/server/backend"
 12	"github.com/charmbracelet/soft-serve/server/config"
 13	"github.com/charmbracelet/soft-serve/server/proto"
 14	"github.com/charmbracelet/soft-serve/server/sshutils"
 15	"github.com/charmbracelet/soft-serve/server/utils"
 16	"github.com/charmbracelet/ssh"
 17	"github.com/prometheus/client_golang/prometheus"
 18	"github.com/prometheus/client_golang/prometheus/promauto"
 19	"github.com/spf13/cobra"
 20)
 21
 22var cliCommandCounter = promauto.NewCounterVec(prometheus.CounterOpts{
 23	Namespace: "soft_serve",
 24	Subsystem: "cli",
 25	Name:      "commands_total",
 26	Help:      "Total times each command was called",
 27}, []string{"command"})
 28
 29var templateFuncs = template.FuncMap{
 30	"trim":                    strings.TrimSpace,
 31	"trimRightSpace":          trimRightSpace,
 32	"trimTrailingWhitespaces": trimRightSpace,
 33	"rpad":                    rpad,
 34	"gt":                      cobra.Gt,
 35	"eq":                      cobra.Eq,
 36}
 37
 38const (
 39	usageTmpl = `Usage:{{if .Runnable}}
 40  {{.UseLine}}{{end}}{{if .HasAvailableSubCommands}}
 41  {{.SSHCommand}}{{.CommandPath}} [command]{{end}}{{if gt (len .Aliases) 0}}
 42
 43Aliases:
 44  {{.NameAndAliases}}{{end}}{{if .HasExample}}
 45
 46Examples:
 47{{.Example}}{{end}}{{if .HasAvailableSubCommands}}{{$cmds := .Commands}}{{if eq (len .Groups) 0}}
 48
 49Available Commands:{{range $cmds}}{{if (or .IsAvailableCommand (eq .Name "help"))}}
 50  {{rpad .Name .NamePadding }} {{.Short}}{{end}}{{end}}{{else}}{{range $group := .Groups}}
 51
 52{{.Title}}{{range $cmds}}{{if (and (eq .GroupID $group.ID) (or .IsAvailableCommand (eq .Name "help")))}}
 53  {{rpad .Name .NamePadding }} {{.Short}}{{end}}{{end}}{{end}}{{if not .AllChildCommandsHaveGroup}}
 54
 55Additional Commands:{{range $cmds}}{{if (and (eq .GroupID "") (or .IsAvailableCommand (eq .Name "help")))}}
 56  {{rpad .Name .NamePadding }} {{.Short}}{{end}}{{end}}{{end}}{{end}}{{end}}{{if .HasAvailableLocalFlags}}
 57
 58Flags:
 59{{.LocalFlags.FlagUsages | trimTrailingWhitespaces}}{{end}}{{if .HasAvailableInheritedFlags}}
 60
 61Global Flags:
 62{{.InheritedFlags.FlagUsages | trimTrailingWhitespaces}}{{end}}{{if .HasHelpSubCommands}}
 63
 64Additional help topics:{{range .Commands}}{{if .IsAdditionalHelpTopicCommand}}
 65  {{rpad .CommandPath .CommandPathPadding}} {{.Short}}{{end}}{{end}}{{end}}{{if .HasAvailableSubCommands}}
 66
 67Use "{{.SSHCommand}}{{.CommandPath}} [command] --help" for more information about a command.{{end}}
 68`
 69)
 70
 71func trimRightSpace(s string) string {
 72	return strings.TrimRightFunc(s, unicode.IsSpace)
 73}
 74
 75// rpad adds padding to the right of a string.
 76func rpad(s string, padding int) string {
 77	template := fmt.Sprintf("%%-%ds", padding)
 78	return fmt.Sprintf(template, s)
 79}
 80
 81func cmdName(args []string) string {
 82	if len(args) == 0 {
 83		return ""
 84	}
 85	return args[0]
 86}
 87
 88// RootCommand returns a new cli root command.
 89func RootCommand(s ssh.Session) *cobra.Command {
 90	ctx := s.Context()
 91	cfg := config.FromContext(ctx)
 92
 93	args := s.Command()
 94	cliCommandCounter.WithLabelValues(cmdName(args)).Inc()
 95	rootCmd := &cobra.Command{
 96		Short:        "Soft Serve is a self-hostable Git server for the command line.",
 97		SilenceUsage: true,
 98	}
 99
100	hostname := "localhost"
101	port := "23231"
102	url, err := url.Parse(cfg.SSH.PublicURL)
103	if err == nil {
104		hostname = url.Hostname()
105		port = url.Port()
106	}
107
108	sshCmd := "ssh"
109	if port != "" && port != "22" {
110		sshCmd += " -p " + port
111	}
112
113	sshCmd += " " + hostname
114	rootCmd.SetUsageTemplate(usageTmpl)
115	rootCmd.SetUsageFunc(func(c *cobra.Command) error {
116		t := template.New("usage")
117		t.Funcs(templateFuncs)
118		template.Must(t.Parse(c.UsageTemplate()))
119		return t.Execute(c.OutOrStderr(), struct {
120			*cobra.Command
121			SSHCommand string
122		}{
123			Command:    c,
124			SSHCommand: sshCmd,
125		})
126	})
127	rootCmd.CompletionOptions.DisableDefaultCmd = true
128	rootCmd.AddCommand(
129		repoCommand(),
130	)
131
132	rootCmd.SetArgs(args)
133	if len(args) == 0 {
134		// otherwise it'll default to os.Args, which is not what we want.
135		rootCmd.SetArgs([]string{"--help"})
136	}
137	rootCmd.SetIn(s)
138	rootCmd.SetOut(s)
139	rootCmd.CompletionOptions.DisableDefaultCmd = true
140	rootCmd.SetErr(s.Stderr())
141
142	user := proto.UserFromContext(ctx)
143	isAdmin := isPublicKeyAdmin(cfg, s.PublicKey()) || (user != nil && user.IsAdmin())
144	if user != nil || isAdmin {
145		if isAdmin {
146			rootCmd.AddCommand(
147				settingsCommand(),
148				userCommand(),
149			)
150		}
151
152		rootCmd.AddCommand(
153			infoCommand(),
154			pubkeyCommand(),
155			setUsernameCommand(),
156			jwtCommand(),
157		)
158	}
159
160	return rootCmd
161}
162
163func checkIfReadable(cmd *cobra.Command, args []string) error {
164	var repo string
165	if len(args) > 0 {
166		repo = args[0]
167	}
168
169	ctx := cmd.Context()
170	be := backend.FromContext(ctx)
171	rn := utils.SanitizeRepo(repo)
172	user := proto.UserFromContext(ctx)
173	auth := be.AccessLevelForUser(cmd.Context(), rn, user)
174	if auth < access.ReadOnlyAccess {
175		return proto.ErrUnauthorized
176	}
177	return nil
178}
179
180func isPublicKeyAdmin(cfg *config.Config, pk ssh.PublicKey) bool {
181	for _, k := range cfg.AdminKeys() {
182		if sshutils.KeysEqual(pk, k) {
183			return true
184		}
185	}
186	return false
187}
188
189func checkIfAdmin(cmd *cobra.Command, _ []string) error {
190	ctx := cmd.Context()
191	cfg := config.FromContext(ctx)
192	pk := sshutils.PublicKeyFromContext(ctx)
193	if isPublicKeyAdmin(cfg, pk) {
194		return nil
195	}
196
197	user := proto.UserFromContext(ctx)
198	if user == nil {
199		return proto.ErrUnauthorized
200	}
201
202	if !user.IsAdmin() {
203		return proto.ErrUnauthorized
204	}
205
206	return nil
207}
208
209func checkIfCollab(cmd *cobra.Command, args []string) error {
210	var repo string
211	if len(args) > 0 {
212		repo = args[0]
213	}
214
215	ctx := cmd.Context()
216	be := backend.FromContext(ctx)
217	rn := utils.SanitizeRepo(repo)
218	user := proto.UserFromContext(ctx)
219	auth := be.AccessLevelForUser(cmd.Context(), rn, user)
220	if auth < access.ReadWriteAccess {
221		return proto.ErrUnauthorized
222	}
223	return nil
224}