1package cmd
2
3import (
4 "fmt"
5 "net/url"
6 "strings"
7 "text/template"
8 "unicode"
9
10 "github.com/charmbracelet/soft-serve/server/access"
11 "github.com/charmbracelet/soft-serve/server/backend"
12 "github.com/charmbracelet/soft-serve/server/config"
13 "github.com/charmbracelet/soft-serve/server/proto"
14 "github.com/charmbracelet/soft-serve/server/sshutils"
15 "github.com/charmbracelet/soft-serve/server/utils"
16 "github.com/charmbracelet/ssh"
17 "github.com/prometheus/client_golang/prometheus"
18 "github.com/prometheus/client_golang/prometheus/promauto"
19 "github.com/spf13/cobra"
20)
21
22var cliCommandCounter = promauto.NewCounterVec(prometheus.CounterOpts{
23 Namespace: "soft_serve",
24 Subsystem: "cli",
25 Name: "commands_total",
26 Help: "Total times each command was called",
27}, []string{"command"})
28
29var templateFuncs = template.FuncMap{
30 "trim": strings.TrimSpace,
31 "trimRightSpace": trimRightSpace,
32 "trimTrailingWhitespaces": trimRightSpace,
33 "rpad": rpad,
34 "gt": cobra.Gt,
35 "eq": cobra.Eq,
36}
37
38const (
39 usageTmpl = `Usage:{{if .Runnable}}
40 {{.UseLine}}{{end}}{{if .HasAvailableSubCommands}}
41 {{.SSHCommand}}{{.CommandPath}} [command]{{end}}{{if gt (len .Aliases) 0}}
42
43Aliases:
44 {{.NameAndAliases}}{{end}}{{if .HasExample}}
45
46Examples:
47{{.Example}}{{end}}{{if .HasAvailableSubCommands}}{{$cmds := .Commands}}{{if eq (len .Groups) 0}}
48
49Available Commands:{{range $cmds}}{{if (or .IsAvailableCommand (eq .Name "help"))}}
50 {{rpad .Name .NamePadding }} {{.Short}}{{end}}{{end}}{{else}}{{range $group := .Groups}}
51
52{{.Title}}{{range $cmds}}{{if (and (eq .GroupID $group.ID) (or .IsAvailableCommand (eq .Name "help")))}}
53 {{rpad .Name .NamePadding }} {{.Short}}{{end}}{{end}}{{end}}{{if not .AllChildCommandsHaveGroup}}
54
55Additional Commands:{{range $cmds}}{{if (and (eq .GroupID "") (or .IsAvailableCommand (eq .Name "help")))}}
56 {{rpad .Name .NamePadding }} {{.Short}}{{end}}{{end}}{{end}}{{end}}{{end}}{{if .HasAvailableLocalFlags}}
57
58Flags:
59{{.LocalFlags.FlagUsages | trimTrailingWhitespaces}}{{end}}{{if .HasAvailableInheritedFlags}}
60
61Global Flags:
62{{.InheritedFlags.FlagUsages | trimTrailingWhitespaces}}{{end}}{{if .HasHelpSubCommands}}
63
64Additional help topics:{{range .Commands}}{{if .IsAdditionalHelpTopicCommand}}
65 {{rpad .CommandPath .CommandPathPadding}} {{.Short}}{{end}}{{end}}{{end}}{{if .HasAvailableSubCommands}}
66
67Use "{{.SSHCommand}}{{.CommandPath}} [command] --help" for more information about a command.{{end}}
68`
69)
70
71func trimRightSpace(s string) string {
72 return strings.TrimRightFunc(s, unicode.IsSpace)
73}
74
75// rpad adds padding to the right of a string.
76func rpad(s string, padding int) string {
77 template := fmt.Sprintf("%%-%ds", padding)
78 return fmt.Sprintf(template, s)
79}
80
81func cmdName(args []string) string {
82 if len(args) == 0 {
83 return ""
84 }
85 return args[0]
86}
87
88// RootCommand returns a new cli root command.
89func RootCommand(s ssh.Session) *cobra.Command {
90 ctx := s.Context()
91 cfg := config.FromContext(ctx)
92 be := backend.FromContext(ctx)
93
94 args := s.Command()
95 cliCommandCounter.WithLabelValues(cmdName(args)).Inc()
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 rootCmd.SetArgs(args)
134 if len(args) == 0 {
135 // otherwise it'll default to os.Args, which is not what we want.
136 rootCmd.SetArgs([]string{"--help"})
137 }
138 rootCmd.SetIn(s)
139 rootCmd.SetOut(s)
140 rootCmd.CompletionOptions.DisableDefaultCmd = true
141 rootCmd.SetErr(s.Stderr())
142
143 user, _ := be.UserByPublicKey(s.Context(), 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 checkIfReadable(cmd *cobra.Command, args []string) error {
164 var repo string
165 if len(args) > 0 {
166 repo = args[0]
167 }
168
169 ctx := cmd.Context()
170 be := backend.FromContext(ctx)
171 rn := utils.SanitizeRepo(repo)
172 pk := sshutils.PublicKeyFromContext(ctx)
173 auth := be.AccessLevelByPublicKey(cmd.Context(), rn, pk)
174 if auth < access.ReadOnlyAccess {
175 return proto.ErrUnauthorized
176 }
177 return nil
178}
179
180func isPublicKeyAdmin(cfg *config.Config, pk ssh.PublicKey) bool {
181 for _, k := range cfg.AdminKeys() {
182 if sshutils.KeysEqual(pk, k) {
183 return true
184 }
185 }
186 return false
187}
188
189func checkIfAdmin(cmd *cobra.Command, _ []string) error {
190 ctx := cmd.Context()
191 be := backend.FromContext(ctx)
192 cfg := config.FromContext(ctx)
193 pk := sshutils.PublicKeyFromContext(ctx)
194 if isPublicKeyAdmin(cfg, pk) {
195 return nil
196 }
197
198 user, _ := be.UserByPublicKey(ctx, pk)
199 if user == nil {
200 return proto.ErrUnauthorized
201 }
202
203 if !user.IsAdmin() {
204 return proto.ErrUnauthorized
205 }
206
207 return nil
208}
209
210func checkIfCollab(cmd *cobra.Command, args []string) error {
211 var repo string
212 if len(args) > 0 {
213 repo = args[0]
214 }
215
216 ctx := cmd.Context()
217 be := backend.FromContext(ctx)
218 pk := sshutils.PublicKeyFromContext(ctx)
219 rn := utils.SanitizeRepo(repo)
220 auth := be.AccessLevelByPublicKey(ctx, rn, pk)
221 if auth < access.ReadWriteAccess {
222 return proto.ErrUnauthorized
223 }
224 return nil
225}