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