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}