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	be := backend.FromContext(ctx)
 93
 94	args := s.Command()
 95	cliCommandCounter.WithLabelValues(cmdName(args)).Inc()
 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	rootCmd.SetArgs(args)
134	if len(args) == 0 {
135		// otherwise it'll default to os.Args, which is not what we want.
136		rootCmd.SetArgs([]string{"--help"})
137	}
138	rootCmd.SetIn(s)
139	rootCmd.SetOut(s)
140	rootCmd.CompletionOptions.DisableDefaultCmd = true
141	rootCmd.SetErr(s.Stderr())
142
143	user, _ := be.UserByPublicKey(s.Context(), 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 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	pk := sshutils.PublicKeyFromContext(ctx)
173	auth := be.AccessLevelByPublicKey(cmd.Context(), rn, pk)
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	be := backend.FromContext(ctx)
192	cfg := config.FromContext(ctx)
193	pk := sshutils.PublicKeyFromContext(ctx)
194	if isPublicKeyAdmin(cfg, pk) {
195		return nil
196	}
197
198	user, _ := be.UserByPublicKey(ctx, pk)
199	if user == nil {
200		return proto.ErrUnauthorized
201	}
202
203	if !user.IsAdmin() {
204		return proto.ErrUnauthorized
205	}
206
207	return nil
208}
209
210func checkIfCollab(cmd *cobra.Command, args []string) error {
211	var repo string
212	if len(args) > 0 {
213		repo = args[0]
214	}
215
216	ctx := cmd.Context()
217	be := backend.FromContext(ctx)
218	pk := sshutils.PublicKeyFromContext(ctx)
219	rn := utils.SanitizeRepo(repo)
220	auth := be.AccessLevelByPublicKey(ctx, rn, pk)
221	if auth < access.ReadWriteAccess {
222		return proto.ErrUnauthorized
223	}
224	return nil
225}