1package cmd
2
3import (
4 "fmt"
5 "net/url"
6 "strings"
7 "text/template"
8 "unicode"
9
10 "github.com/charmbracelet/soft-serve/pkg/access"
11 "github.com/charmbracelet/soft-serve/pkg/backend"
12 "github.com/charmbracelet/soft-serve/pkg/config"
13 "github.com/charmbracelet/soft-serve/pkg/proto"
14 "github.com/charmbracelet/soft-serve/pkg/sshutils"
15 "github.com/charmbracelet/soft-serve/pkg/utils"
16 "github.com/charmbracelet/ssh"
17 "github.com/spf13/cobra"
18)
19
20var templateFuncs = template.FuncMap{
21 "trim": strings.TrimSpace,
22 "trimRightSpace": trimRightSpace,
23 "trimTrailingWhitespaces": trimRightSpace,
24 "rpad": rpad,
25 "gt": cobra.Gt,
26 "eq": cobra.Eq,
27}
28
29const (
30 // UsageTemplate is the template used for the help output.
31 UsageTemplate = `Usage:{{if .Runnable}}
32 {{.UseLine}}{{end}}{{if .HasAvailableSubCommands}}
33 {{.SSHCommand}}{{.CommandPath}} [command]{{end}}{{if gt (len .Aliases) 0}}
34
35Aliases:
36 {{.NameAndAliases}}{{end}}{{if .HasExample}}
37
38Examples:
39{{.Example}}{{end}}{{if .HasAvailableSubCommands}}{{$cmds := .Commands}}{{if eq (len .Groups) 0}}
40
41Available Commands:{{range $cmds}}{{if (or .IsAvailableCommand (eq .Name "help"))}}
42 {{rpad .Name .NamePadding }} {{.Short}}{{end}}{{end}}{{else}}{{range $group := .Groups}}
43
44{{.Title}}{{range $cmds}}{{if (and (eq .GroupID $group.ID) (or .IsAvailableCommand (eq .Name "help")))}}
45 {{rpad .Name .NamePadding }} {{.Short}}{{end}}{{end}}{{end}}{{if not .AllChildCommandsHaveGroup}}
46
47Additional Commands:{{range $cmds}}{{if (and (eq .GroupID "") (or .IsAvailableCommand (eq .Name "help")))}}
48 {{rpad .Name .NamePadding }} {{.Short}}{{end}}{{end}}{{end}}{{end}}{{end}}{{if .HasAvailableLocalFlags}}
49
50Flags:
51{{.LocalFlags.FlagUsages | trimTrailingWhitespaces}}{{end}}{{if .HasAvailableInheritedFlags}}
52
53Global Flags:
54{{.InheritedFlags.FlagUsages | trimTrailingWhitespaces}}{{end}}{{if .HasHelpSubCommands}}
55
56Additional help topics:{{range .Commands}}{{if .IsAdditionalHelpTopicCommand}}
57 {{rpad .CommandPath .CommandPathPadding}} {{.Short}}{{end}}{{end}}{{end}}{{if .HasAvailableSubCommands}}
58
59Use "{{.SSHCommand}}{{.CommandPath}} [command] --help" for more information about a command.{{end}}
60`
61)
62
63// UsageFunc is a function that can be used as a cobra.Command's
64// UsageFunc to render the help output.
65func UsageFunc(c *cobra.Command) error {
66 ctx := c.Context()
67 cfg := config.FromContext(ctx)
68 hostname := "localhost"
69 port := "23231"
70 url, err := url.Parse(cfg.SSH.PublicURL)
71 if err == nil {
72 hostname = url.Hostname()
73 port = url.Port()
74 }
75
76 sshCmd := "ssh"
77 if port != "" && port != "22" {
78 sshCmd += " -p " + port
79 }
80
81 sshCmd += " " + hostname
82 t := template.New("usage")
83 t.Funcs(templateFuncs)
84 template.Must(t.Parse(c.UsageTemplate()))
85 return t.Execute(c.OutOrStderr(), struct {
86 *cobra.Command
87 SSHCommand string
88 }{
89 Command: c,
90 SSHCommand: sshCmd,
91 })
92}
93
94func trimRightSpace(s string) string {
95 return strings.TrimRightFunc(s, unicode.IsSpace)
96}
97
98// rpad adds padding to the right of a string.
99func rpad(s string, padding int) string {
100 template := fmt.Sprintf("%%-%ds", padding)
101 return fmt.Sprintf(template, s)
102}
103
104// CommandName returns the name of the command from the args.
105func CommandName(args []string) string {
106 if len(args) == 0 {
107 return ""
108 }
109 return args[0]
110}
111
112func checkIfReadable(cmd *cobra.Command, args []string) error {
113 var repo string
114 if len(args) > 0 {
115 repo = args[0]
116 }
117
118 ctx := cmd.Context()
119 be := backend.FromContext(ctx)
120 rn := utils.SanitizeRepo(repo)
121 user := proto.UserFromContext(ctx)
122 auth := be.AccessLevelForUser(cmd.Context(), rn, user)
123 if auth < access.ReadOnlyAccess {
124 return proto.ErrRepoNotFound
125 }
126 return nil
127}
128
129// IsPublicKeyAdmin returns true if the given public key is an admin key from
130// the initial_admin_keys config or environment field.
131func IsPublicKeyAdmin(cfg *config.Config, pk ssh.PublicKey) bool {
132 for _, k := range cfg.AdminKeys() {
133 if sshutils.KeysEqual(pk, k) {
134 return true
135 }
136 }
137 return false
138}
139
140func checkIfAdmin(cmd *cobra.Command, args []string) error {
141 var repo string
142 if len(args) > 0 {
143 repo = args[0]
144 }
145
146 ctx := cmd.Context()
147 cfg := config.FromContext(ctx)
148 be := backend.FromContext(ctx)
149 rn := utils.SanitizeRepo(repo)
150 pk := sshutils.PublicKeyFromContext(ctx)
151 if IsPublicKeyAdmin(cfg, pk) {
152 return nil
153 }
154
155 user := proto.UserFromContext(ctx)
156 if user == nil {
157 return proto.ErrUnauthorized
158 }
159
160 if user.IsAdmin() {
161 return nil
162 }
163
164 auth := be.AccessLevelForUser(cmd.Context(), rn, user)
165 if auth >= access.AdminAccess {
166 return nil
167 }
168
169 return proto.ErrUnauthorized
170}
171
172func checkIfCollab(cmd *cobra.Command, args []string) error {
173 var repo string
174 if len(args) > 0 {
175 repo = utils.SanitizeRepo(args[0])
176 }
177
178 ctx := cmd.Context()
179 be := backend.FromContext(ctx)
180 rn := utils.SanitizeRepo(repo)
181 user := proto.UserFromContext(ctx)
182 auth := be.AccessLevelForUser(cmd.Context(), rn, user)
183 if auth < access.ReadWriteAccess {
184 return proto.ErrUnauthorized
185 }
186 return nil
187}
188
189func checkIfReadableAndCollab(cmd *cobra.Command, args []string) error {
190 if err := checkIfReadable(cmd, args); err != nil {
191 return err
192 }
193 if err := checkIfCollab(cmd, args); err != nil {
194 return err
195 }
196 return nil
197}