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