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}