cmd.go

  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/prometheus/client_golang/prometheus"
 19	"github.com/prometheus/client_golang/prometheus/promauto"
 20	"github.com/spf13/cobra"
 21)
 22
 23// sessionCtxKey is the key for the session in the context.
 24var sessionCtxKey = &struct{ string }{"session"}
 25
 26var cliCommandCounter = promauto.NewCounterVec(prometheus.CounterOpts{
 27	Namespace: "soft_serve",
 28	Subsystem: "cli",
 29	Name:      "commands_total",
 30	Help:      "Total times each command was called",
 31}, []string{"command"})
 32
 33var templateFuncs = template.FuncMap{
 34	"trim":                    strings.TrimSpace,
 35	"trimRightSpace":          trimRightSpace,
 36	"trimTrailingWhitespaces": trimRightSpace,
 37	"rpad":                    rpad,
 38	"gt":                      cobra.Gt,
 39	"eq":                      cobra.Eq,
 40}
 41
 42const (
 43	usageTmpl = `Usage:{{if .Runnable}}
 44  {{.UseLine}}{{end}}{{if .HasAvailableSubCommands}}
 45  {{.SSHCommand}}{{.CommandPath}} [command]{{end}}{{if gt (len .Aliases) 0}}
 46
 47Aliases:
 48  {{.NameAndAliases}}{{end}}{{if .HasExample}}
 49
 50Examples:
 51{{.Example}}{{end}}{{if .HasAvailableSubCommands}}{{$cmds := .Commands}}{{if eq (len .Groups) 0}}
 52
 53Available Commands:{{range $cmds}}{{if (or .IsAvailableCommand (eq .Name "help"))}}
 54  {{rpad .Name .NamePadding }} {{.Short}}{{end}}{{end}}{{else}}{{range $group := .Groups}}
 55
 56{{.Title}}{{range $cmds}}{{if (and (eq .GroupID $group.ID) (or .IsAvailableCommand (eq .Name "help")))}}
 57  {{rpad .Name .NamePadding }} {{.Short}}{{end}}{{end}}{{end}}{{if not .AllChildCommandsHaveGroup}}
 58
 59Additional Commands:{{range $cmds}}{{if (and (eq .GroupID "") (or .IsAvailableCommand (eq .Name "help")))}}
 60  {{rpad .Name .NamePadding }} {{.Short}}{{end}}{{end}}{{end}}{{end}}{{end}}{{if .HasAvailableLocalFlags}}
 61
 62Flags:
 63{{.LocalFlags.FlagUsages | trimTrailingWhitespaces}}{{end}}{{if .HasAvailableInheritedFlags}}
 64
 65Global Flags:
 66{{.InheritedFlags.FlagUsages | trimTrailingWhitespaces}}{{end}}{{if .HasHelpSubCommands}}
 67
 68Additional help topics:{{range .Commands}}{{if .IsAdditionalHelpTopicCommand}}
 69  {{rpad .CommandPath .CommandPathPadding}} {{.Short}}{{end}}{{end}}{{end}}{{if .HasAvailableSubCommands}}
 70
 71Use "{{.SSHCommand}}{{.CommandPath}} [command] --help" for more information about a command.{{end}}
 72`
 73)
 74
 75func trimRightSpace(s string) string {
 76	return strings.TrimRightFunc(s, unicode.IsSpace)
 77}
 78
 79// rpad adds padding to the right of a string.
 80func rpad(s string, padding int) string {
 81	template := fmt.Sprintf("%%-%ds", padding)
 82	return fmt.Sprintf(template, s)
 83}
 84
 85func cmdName(args []string) string {
 86	if len(args) == 0 {
 87		return ""
 88	}
 89	return args[0]
 90}
 91
 92// rootCommand is the root command for the server.
 93func rootCommand(cfg *config.Config, s ssh.Session) *cobra.Command {
 94	cliCommandCounter.WithLabelValues(cmdName(s.Command())).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	user, _ := cfg.Backend.UserByPublicKey(s.PublicKey())
133	isAdmin := isPublicKeyAdmin(cfg, s.PublicKey()) || (user != nil && user.IsAdmin())
134	if user != nil || isAdmin {
135		if isAdmin {
136			rootCmd.AddCommand(
137				settingsCommand(),
138				userCommand(),
139			)
140		}
141
142		rootCmd.AddCommand(
143			infoCommand(),
144			pubkeyCommand(),
145			setUsernameCommand(),
146		)
147	}
148
149	return rootCmd
150}
151
152func fromContext(cmd *cobra.Command) (*config.Config, ssh.Session) {
153	ctx := cmd.Context()
154	cfg := config.FromContext(ctx)
155	s := ctx.Value(sessionCtxKey).(ssh.Session)
156	return cfg, s
157}
158
159func checkIfReadable(cmd *cobra.Command, args []string) error {
160	var repo string
161	if len(args) > 0 {
162		repo = args[0]
163	}
164	cfg, s := fromContext(cmd)
165	rn := utils.SanitizeRepo(repo)
166	auth := cfg.Backend.AccessLevelByPublicKey(rn, s.PublicKey())
167	if auth < backend.ReadOnlyAccess {
168		return errors.ErrUnauthorized
169	}
170	return nil
171}
172
173func isPublicKeyAdmin(cfg *config.Config, pk ssh.PublicKey) bool {
174	for _, k := range cfg.AdminKeys() {
175		if backend.KeysEqual(pk, k) {
176			return true
177		}
178	}
179	return false
180}
181
182func checkIfAdmin(cmd *cobra.Command, _ []string) error {
183	cfg, s := fromContext(cmd)
184	if isPublicKeyAdmin(cfg, s.PublicKey()) {
185		return nil
186	}
187
188	user, _ := cfg.Backend.UserByPublicKey(s.PublicKey())
189	if user == nil {
190		return errors.ErrUnauthorized
191	}
192
193	if !user.IsAdmin() {
194		return errors.ErrUnauthorized
195	}
196
197	return nil
198}
199
200func checkIfCollab(cmd *cobra.Command, args []string) error {
201	var repo string
202	if len(args) > 0 {
203		repo = args[0]
204	}
205	cfg, s := fromContext(cmd)
206	rn := utils.SanitizeRepo(repo)
207	auth := cfg.Backend.AccessLevelByPublicKey(rn, s.PublicKey())
208	if auth < backend.ReadWriteAccess {
209		return errors.ErrUnauthorized
210	}
211	return nil
212}
213
214// Middleware is the Soft Serve middleware that handles SSH commands.
215func Middleware(cfg *config.Config, logger *log.Logger) wish.Middleware {
216	return func(sh ssh.Handler) ssh.Handler {
217		return func(s ssh.Session) {
218			func() {
219				_, _, active := s.Pty()
220				if active {
221					return
222				}
223
224				// Ignore git server commands.
225				args := s.Command()
226				if len(args) > 0 {
227					if args[0] == "git-receive-pack" ||
228						args[0] == "git-upload-pack" ||
229						args[0] == "git-upload-archive" {
230						return
231					}
232				}
233
234				// Here we copy the server's config and replace the backend
235				// with a new one that uses the session's context.
236				var ctx context.Context = s.Context()
237				scfg := *cfg
238				cfg = &scfg
239				be := cfg.Backend.WithContext(ctx)
240				cfg.Backend = be
241				ctx = config.WithContext(ctx, cfg)
242				ctx = backend.WithContext(ctx, be)
243				ctx = context.WithValue(ctx, sessionCtxKey, s)
244
245				rootCmd := rootCommand(cfg, s)
246				rootCmd.SetArgs(args)
247				if len(args) == 0 {
248					// otherwise it'll default to os.Args, which is not what we want.
249					rootCmd.SetArgs([]string{"--help"})
250				}
251				rootCmd.SetIn(s)
252				rootCmd.SetOut(s)
253				rootCmd.CompletionOptions.DisableDefaultCmd = true
254				rootCmd.SetErr(s.Stderr())
255				if err := rootCmd.ExecuteContext(ctx); err != nil {
256					_ = s.Exit(1)
257				}
258			}()
259			sh(s)
260		}
261	}
262}