1package web
2
3import (
4 "errors"
5 "log/slog"
6 "net/http"
7 "time"
8
9 "tug/internal/platform/sshkey"
10 "tug/internal/transport/web/text"
11)
12
13const (
14 SSHKeyNewPath = "/ssh-keys/new"
15 sshKeyRemovePath = "/ssh-keys/{key}/remove"
16)
17
18var sshKeyRefusals = []struct {
19 err error
20 key text.Key
21}{
22 {sshkey.ErrNotOneLine, text.SSHKeyProblemNotOneLine},
23 {sshkey.ErrNotAKey, text.SSHKeyProblemNotAKey},
24 {sshkey.ErrOptions, text.SSHKeyProblemOptions},
25 {sshkey.ErrCertificate, text.SSHKeyProblemCertificate},
26 {sshkey.ErrKeyType, text.SSHKeyProblemType},
27 {sshkey.ErrShortRSA, text.SSHKeyProblemShortRSA},
28 {sshkey.ErrLabel, text.SSHKeyProblemLabel},
29 {sshkey.ErrOwned, text.SSHKeyProblemOwned},
30 {sshkey.ErrTaken, text.SSHKeyProblemTaken},
31 {sshkey.ErrLimit, text.SSHKeyProblemLimit},
32}
33
34type SSHKey struct {
35 ID string
36 Fingerprint string
37 Type string
38 Label string
39 AddedAt time.Time
40}
41
42type sshKeyRow struct {
43 ID string
44 Fingerprint Ident
45 Type Ident
46 Label string
47 Added string
48}
49
50type sshKeyList struct {
51 Rows []sshKeyRow
52 Remove string
53 Add string
54 Full bool
55}
56
57type sshKeyForm struct {
58 Action string
59 Cancel string
60 Key string
61 Label string
62 Message text.Key
63}
64
65func sshKeyRefusal(err error) (text.Key, bool) {
66 for _, r := range sshKeyRefusals {
67 if errors.Is(err, r.err) {
68 return r.key, true
69 }
70 }
71 return "", false
72}
73
74func sshKeyID(r *http.Request) (string, bool) {
75 id := r.PathValue("key")
76 return id, reAdminID.MatchString(id)
77}
78
79func newSSHKeyList(keys []SSHKey, remove, add string) sshKeyList {
80 l := sshKeyList{Remove: remove, Add: add, Full: len(keys) >= sshkey.MaxPerAccount}
81 for _, k := range keys {
82 l.Rows = append(l.Rows, sshKeyRow{ID: k.ID, Fingerprint: Ident(k.Fingerprint), Type: Ident(k.Type), Label: k.Label, Added: stamp(k.AddedAt)})
83 }
84 return l
85}
86
87func echoedSSHKeyForm(action, cancel, key, label string, message text.Key) sshKeyForm {
88 return sshKeyForm{Action: action, Cancel: cancel, Key: echoed(key, echoSSHKeyBytes), Label: echoed(label, echoDisplayNameBytes), Message: message}
89}
90
91func findSSHKey(keys []SSHKey, id string) (SSHKey, bool) {
92 for _, k := range keys {
93 if k.ID == id {
94 return k, true
95 }
96 }
97 return SSHKey{}, false
98}
99
100func sshKeyRemoveConfirm(k SSHKey, action, cancel string) Confirm {
101 return Confirm{Title: text.SSHKeyRemoveTitle, Body: text.SSHKeyRemoveBody, Button: text.SSHKeyRemoveButton, Subject: Ident(k.Fingerprint), Action: action, Cancel: cancel}
102}
103
104func (k *Kit) sshKeyDone(w http.ResponseWriter, r *http.Request, key text.Key, fingerprint, path string) {
105 if err := k.SetFlash(w, key, Ident(fingerprint)); err != nil {
106 slog.Error("ssh key flash not set", "err", err)
107 }
108 k.SeeOther(w, r, path)
109}
110
111func (a *portal) sshKeyRoutes() []Route {
112 return []Route{
113 {Method: http.MethodGet, Path: SSHKeyNewPath, Handler: a.kit.RequireSudo(http.HandlerFunc(a.sshKeyNew))},
114 {Method: http.MethodPost, Path: SSHKeyNewPath, Handler: a.kit.RequireSudo(http.HandlerFunc(a.addSSHKey))},
115 {Method: http.MethodGet, Path: sshKeyRemovePath, Handler: http.HandlerFunc(a.sshKeyRemoveShow)},
116 {Method: http.MethodPost, Path: sshKeyRemovePath, Handler: http.HandlerFunc(a.removeSSHKey)},
117 }
118}
119
120func (a *portal) sshKeyNew(w http.ResponseWriter, r *http.Request) {
121 if _, ok := a.kit.principal(w, r); ok {
122 a.render(w, r, http.StatusOK, "account-ssh-key-new", sshKeyForm{Action: SSHKeyNewPath, Cancel: "/"})
123 }
124}
125
126func (a *portal) addSSHKey(w http.ResponseWriter, r *http.Request) {
127 p, ok := a.kit.principal(w, r)
128 if !ok {
129 return
130 }
131 line, label := r.PostFormValue("key"), r.PostFormValue("label")
132 added, err := a.b.AddSSHKey(r.Context(), p, line, label)
133 if key, ok := sshKeyRefusal(err); ok {
134 a.render(w, r, http.StatusOK, "account-ssh-key-new", echoedSSHKeyForm(SSHKeyNewPath, "/", line, label, key))
135 return
136 }
137 if err != nil {
138 a.failed(w, r, "add ssh key", err)
139 return
140 }
141 a.kit.sshKeyDone(w, r, text.FlashSSHKeyAdded, added.Fingerprint, "/")
142}
143
144func (a *portal) sshKeyRemoveShow(w http.ResponseWriter, r *http.Request) {
145 p, ok := a.kit.principal(w, r)
146 if !ok {
147 return
148 }
149 keyID, ok := sshKeyID(r)
150 if !ok {
151 a.kit.ErrorPage(w, r, http.StatusNotFound)
152 return
153 }
154 keys, err := a.b.SSHKeys(r.Context(), p)
155 if err != nil {
156 a.failed(w, r, "ssh keys", err)
157 return
158 }
159 k, ok := findSSHKey(keys, keyID)
160 if !ok {
161 a.kit.ErrorPage(w, r, http.StatusNotFound)
162 return
163 }
164 a.kit.RenderConfirm(w, r, sshKeyRemoveConfirm(k, "/ssh-keys/"+k.ID+"/remove", "/"))
165}
166
167func (a *portal) removeSSHKey(w http.ResponseWriter, r *http.Request) {
168 p, ok := a.kit.principal(w, r)
169 if !ok {
170 return
171 }
172 keyID, ok := sshKeyID(r)
173 if !ok {
174 a.kit.ErrorPage(w, r, http.StatusNotFound)
175 return
176 }
177 removed, err := a.b.RemoveSSHKey(r.Context(), p, keyID)
178 if err != nil {
179 a.failed(w, r, "remove ssh key", err)
180 return
181 }
182 a.kit.sshKeyDone(w, r, text.FlashSSHKeyRemoved, removed.Fingerprint, "/")
183}
184
185func (a *admin) sshKeyRoutes(sudo func(method, path string, h http.HandlerFunc) Route) []Route {
186 return []Route{
187 sudo(http.MethodGet, "/users/{id}"+SSHKeyNewPath, a.sshKeyNew),
188 sudo(http.MethodPost, "/users/{id}"+SSHKeyNewPath, a.addSSHKey),
189 sudo(http.MethodGet, "/users/{id}"+sshKeyRemovePath, a.sshKeyRemoveShow),
190 sudo(http.MethodPost, "/users/{id}"+sshKeyRemovePath, a.removeSSHKey),
191 }
192}
193
194type adminSSHKeyNewData struct {
195 Viewer Ident
196 ID Ident
197 Username Ident
198 sshKeyForm
199}
200
201func (a *admin) renderSSHKeyNew(w http.ResponseWriter, r *http.Request, p Principal, id string, u AdminUser, f sshKeyForm) {
202 a.render(w, r, http.StatusOK, "admin-user-ssh-key-new", adminSSHKeyNewData{Viewer: p.Username, ID: Ident(id), Username: Ident(u.Username), sshKeyForm: f})
203}
204
205func (a *admin) sshKeyNew(w http.ResponseWriter, r *http.Request) {
206 if p, id, u, ok := a.loadUser(w, r); ok {
207 a.renderSSHKeyNew(w, r, p, id, u, sshKeyForm{Action: "/users/" + id + SSHKeyNewPath, Cancel: "/users/" + id})
208 }
209}
210
211func (a *admin) addSSHKey(w http.ResponseWriter, r *http.Request) {
212 p, id, u, ok := a.loadUser(w, r)
213 if !ok {
214 return
215 }
216 line, label := r.PostFormValue("key"), r.PostFormValue("label")
217 added, err := a.b.AddSSHKey(r.Context(), p.UserID, id, line, label)
218 key, refused := sshKeyRefusal(err)
219 if !refused {
220 key, refused = adminProblemKey(err)
221 }
222 if refused {
223 a.renderSSHKeyNew(w, r, p, id, u, echoedSSHKeyForm("/users/"+id+SSHKeyNewPath, "/users/"+id, line, label, key))
224 return
225 }
226 if err != nil {
227 a.failed(w, r, "add ssh key", err)
228 return
229 }
230 a.kit.sshKeyDone(w, r, text.FlashSSHKeyAdded, added.Fingerprint, "/users/"+id)
231}
232
233func (a *admin) sshKeyRemoveShow(w http.ResponseWriter, r *http.Request) {
234 _, id, u, ok := a.loadUser(w, r)
235 if !ok {
236 return
237 }
238 keyID, ok := sshKeyID(r)
239 if !ok {
240 a.kit.ErrorPage(w, r, http.StatusNotFound)
241 return
242 }
243 k, ok := findSSHKey(u.SSHKeys, keyID)
244 if !ok {
245 a.kit.ErrorPage(w, r, http.StatusNotFound)
246 return
247 }
248 a.kit.RenderConfirm(w, r, sshKeyRemoveConfirm(k, "/users/"+id+"/ssh-keys/"+k.ID+"/remove", "/users/"+id))
249}
250
251func (a *admin) removeSSHKey(w http.ResponseWriter, r *http.Request) {
252 p, ok := a.kit.principal(w, r)
253 if !ok {
254 return
255 }
256 id, ok := a.id(w, r)
257 if !ok {
258 return
259 }
260 keyID, ok := sshKeyID(r)
261 if !ok {
262 a.kit.ErrorPage(w, r, http.StatusNotFound)
263 return
264 }
265 removed, err := a.b.RemoveSSHKey(r.Context(), p.UserID, id, keyID)
266 if err != nil {
267 a.failed(w, r, "remove ssh key", err)
268 return
269 }
270 a.kit.sshKeyDone(w, r, text.FlashSSHKeyRemoved, removed.Fingerprint, "/users/"+id)
271}