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