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 != "" && 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 isAdmin := isPublicKeyAdmin(cfg, s.PublicKey()) || (user != nil && user.IsAdmin())
145 if user != nil || isAdmin {
146 if isAdmin {
147 rootCmd.AddCommand(
148 settingsCommand(),
149 userCommand(),
150 )
151 }
152
153 rootCmd.AddCommand(
154 infoCommand(),
155 pubkeyCommand(),
156 setUsernameCommand(),
157 )
158 }
159
160 return rootCmd
161}
162
163func fromContext(cmd *cobra.Command) (*config.Config, ssh.Session) {
164 ctx := cmd.Context()
165 cfg := ctx.Value(ConfigCtxKey).(*config.Config)
166 s := ctx.Value(SessionCtxKey).(ssh.Session)
167 return cfg, s
168}
169
170func checkIfReadable(cmd *cobra.Command, args []string) error {
171 var repo string
172 if len(args) > 0 {
173 repo = args[0]
174 }
175 cfg, s := fromContext(cmd)
176 rn := utils.SanitizeRepo(repo)
177 auth := cfg.Backend.AccessLevelByPublicKey(rn, s.PublicKey())
178 if auth < backend.ReadOnlyAccess {
179 return ErrUnauthorized
180 }
181 return nil
182}
183
184func isPublicKeyAdmin(cfg *config.Config, pk ssh.PublicKey) bool {
185 for _, k := range cfg.InitialAdminKeys {
186 pk2, _, err := backend.ParseAuthorizedKey(k)
187 if err == nil && backend.KeysEqual(pk, pk2) {
188 return true
189 }
190 }
191 return false
192}
193
194func checkIfAdmin(cmd *cobra.Command, _ []string) error {
195 cfg, s := fromContext(cmd)
196 if isPublicKeyAdmin(cfg, s.PublicKey()) {
197 return nil
198 }
199
200 user, _ := cfg.Backend.UserByPublicKey(s.PublicKey())
201 if user == nil {
202 return ErrUnauthorized
203 }
204
205 if !user.IsAdmin() {
206 return ErrUnauthorized
207 }
208
209 return nil
210}
211
212func checkIfCollab(cmd *cobra.Command, args []string) error {
213 var repo string
214 if len(args) > 0 {
215 repo = args[0]
216 }
217 cfg, s := fromContext(cmd)
218 rn := utils.SanitizeRepo(repo)
219 auth := cfg.Backend.AccessLevelByPublicKey(rn, s.PublicKey())
220 if auth < backend.ReadWriteAccess {
221 return ErrUnauthorized
222 }
223 return nil
224}
225
226// Middleware is the Soft Serve middleware that handles SSH commands.
227func Middleware(cfg *config.Config, hooks hooks.Hooks) wish.Middleware {
228 return func(sh ssh.Handler) ssh.Handler {
229 return func(s ssh.Session) {
230 func() {
231 _, _, active := s.Pty()
232 if active {
233 return
234 }
235
236 // Ignore git server commands.
237 args := s.Command()
238 if len(args) > 0 {
239 if args[0] == "git-receive-pack" ||
240 args[0] == "git-upload-pack" ||
241 args[0] == "git-upload-archive" {
242 return
243 }
244 }
245
246 ctx := context.WithValue(s.Context(), ConfigCtxKey, cfg)
247 ctx = context.WithValue(ctx, SessionCtxKey, s)
248 ctx = context.WithValue(ctx, HooksCtxKey, hooks)
249
250 rootCmd := rootCommand(cfg, s)
251 rootCmd.SetArgs(args)
252 if len(args) == 0 {
253 // otherwise it'll default to os.Args, which is not what we want.
254 rootCmd.SetArgs([]string{"--help"})
255 }
256 rootCmd.SetIn(s)
257 rootCmd.SetOut(s)
258 rootCmd.CompletionOptions.DisableDefaultCmd = true
259 rootCmd.SetErr(s.Stderr())
260 if err := rootCmd.ExecuteContext(ctx); err != nil {
261 _ = s.Exit(1)
262 }
263 }()
264 sh(s)
265 }
266 }
267}