Eric Bower
·
2025-04-25
pubkeys.go
1package tui
2
3import (
4 "fmt"
5 "time"
6
7 "git.sr.ht/~rockorager/vaxis"
8 "git.sr.ht/~rockorager/vaxis/vxfw"
9 "git.sr.ht/~rockorager/vaxis/vxfw/button"
10 "git.sr.ht/~rockorager/vaxis/vxfw/list"
11 "git.sr.ht/~rockorager/vaxis/vxfw/richtext"
12 "git.sr.ht/~rockorager/vaxis/vxfw/text"
13 "github.com/picosh/pico/pkg/db"
14 "github.com/picosh/utils"
15 "golang.org/x/crypto/ssh"
16)
17
18type PubkeysPage struct {
19 shared *SharedModel
20 list list.Dynamic
21
22 keys []*db.PublicKey
23 err error
24 confirm bool
25}
26
27func NewPubkeysPage(shrd *SharedModel) *PubkeysPage {
28 m := &PubkeysPage{
29 shared: shrd,
30 }
31 m.list = list.Dynamic{DrawCursor: true, Builder: m.getWidget, Gap: 1}
32 return m
33}
34
35type FetchPubkeys struct{}
36
37func (m *PubkeysPage) Footer() []Shortcut {
38 return []Shortcut{
39 {Shortcut: "j/k", Text: "choose"},
40 {Shortcut: "x", Text: "delete"},
41 {Shortcut: "c", Text: "create"},
42 }
43}
44
45func (m *PubkeysPage) fetchKeys() error {
46 keys, err := m.shared.Dbpool.FindKeysForUser(m.shared.User)
47 if err != nil {
48 return err
49
50 }
51 m.keys = keys
52 return nil
53}
54
55func (m *PubkeysPage) HandleEvent(ev vaxis.Event, phase vxfw.EventPhase) (vxfw.Command, error) {
56 switch msg := ev.(type) {
57 case PageIn:
58 m.err = m.fetchKeys()
59 return vxfw.FocusWidgetCmd(&m.list), nil
60 case vaxis.Key:
61 if msg.Matches('c') {
62 m.shared.App.PostEvent(Navigate{To: "add-pubkey"})
63 }
64 if msg.Matches('x') {
65 if len(m.keys) < 2 {
66 m.err = fmt.Errorf("cannot delete last key")
67 } else {
68 m.confirm = true
69 }
70 return vxfw.RedrawCmd{}, nil
71 }
72 if msg.Matches('y') {
73 if m.confirm {
74 m.confirm = false
75 err := m.shared.Dbpool.RemoveKeys([]string{m.keys[m.list.Cursor()].ID})
76 if err != nil {
77 m.err = err
78 return nil, nil
79 }
80 m.err = m.fetchKeys()
81 return vxfw.RedrawCmd{}, nil
82 }
83 }
84 if msg.Matches('n') {
85 m.confirm = false
86 return vxfw.RedrawCmd{}, nil
87 }
88 }
89
90 return nil, nil
91}
92
93func (m *PubkeysPage) getWidget(i uint, cursor uint) vxfw.Widget {
94 if int(i) >= len(m.keys) {
95 return nil
96 }
97
98 style := vaxis.Style{Foreground: grey}
99 isSelected := i == cursor
100 if isSelected {
101 style = vaxis.Style{Foreground: fuschia}
102 }
103
104 pubkey := m.keys[i]
105 key, _, _, _, err := ssh.ParseAuthorizedKey([]byte(pubkey.Key))
106 if err != nil {
107 m.shared.Logger.Error("parse pubkey", "err", err)
108 return nil
109 }
110
111 keyStr := pubkey.Key[0:25] + "..." + pubkey.Key[len(pubkey.Key)-15:]
112
113 txt := richtext.New([]vaxis.Segment{
114 {Text: "Name: ", Style: style},
115 {Text: pubkey.Name + "\n"},
116
117 {Text: "Key: ", Style: style},
118 {Text: keyStr + "\n"},
119
120 {Text: "Sha: ", Style: style},
121 {Text: ssh.FingerprintSHA256(key) + "\n"},
122
123 {Text: "Created: ", Style: style},
124 {Text: pubkey.CreatedAt.Format(time.DateOnly)},
125 })
126
127 return txt
128}
129
130func (m *PubkeysPage) Draw(ctx vxfw.DrawContext) (vxfw.Surface, error) {
131 w := ctx.Max.Width
132 h := ctx.Max.Height
133 root := vxfw.NewSurface(w, h, m)
134 ah := 0
135
136 info := text.New("Pubkeys are SSH public keys which grant access to your pico account. You can have many pubkeys associated with your account and they all have the same level of access. You cannot delete all pubkeys since it will revoke all access to the account.")
137 brd := NewBorder(info)
138 brd.Label = "desc"
139 brdSurf, _ := brd.Draw(ctx)
140 root.AddChild(0, ah, brdSurf)
141 ah += int(brdSurf.Size.Height)
142
143 header := text.New(fmt.Sprintf("%d pubkeys\n\n", len(m.keys)))
144 headerSurf, _ := header.Draw(ctx)
145 root.AddChild(0, ah, headerSurf)
146 ah += int(headerSurf.Size.Height)
147
148 listSurf, _ := m.list.Draw(ctx)
149 root.AddChild(0, ah, listSurf)
150
151 segs := []vaxis.Segment{}
152 if m.confirm {
153 segs = append(segs, vaxis.Segment{
154 Text: "are you sure? y/n\n",
155 Style: vaxis.Style{Foreground: red},
156 })
157 }
158 if m.err != nil {
159 segs = append(segs, vaxis.Segment{
160 Text: m.err.Error() + "\n",
161 Style: vaxis.Style{Foreground: red},
162 })
163 }
164 segs = append(segs, vaxis.Segment{Text: "\n"})
165
166 footer := richtext.New(segs)
167 footerSurf, _ := footer.Draw(ctx)
168 root.AddChild(0, int(h)-3, footerSurf)
169
170 return root, nil
171}
172
173type AddKeyPage struct {
174 shared *SharedModel
175
176 err error
177 focus string
178 input *TextInput
179 btn *button.Button
180}
181
182func NewAddPubkeyPage(shrd *SharedModel) *AddKeyPage {
183 btn := button.New("ADD", func() (vxfw.Command, error) { return nil, nil })
184 btn.Style = button.StyleSet{
185 Default: vaxis.Style{Background: grey},
186 Focus: vaxis.Style{Background: oj, Foreground: black},
187 }
188 return &AddKeyPage{
189 shared: shrd,
190
191 input: NewTextInput("add pubkey"),
192 btn: btn,
193 }
194}
195
196func (m *AddKeyPage) Footer() []Shortcut {
197 return []Shortcut{
198 {Shortcut: "tab", Text: "focus"},
199 {Shortcut: "shift+click", Text: "select text"},
200 {Shortcut: "enter", Text: "add public key"},
201 }
202}
203
204func (m *AddKeyPage) CaptureEvent(ev vaxis.Event) (vxfw.Command, error) {
205 switch msg := ev.(type) {
206 case vaxis.Key:
207 if msg.Matches(vaxis.KeyEnter) {
208 err := m.addPubkey(m.input.GetValue())
209 m.err = err
210 if err == nil {
211 m.input.Reset()
212 m.shared.App.PostEvent(Navigate{To: "pubkeys"})
213 return nil, nil
214 }
215 return vxfw.RedrawCmd{}, nil
216 }
217 }
218 return nil, nil
219}
220
221func (m *AddKeyPage) HandleEvent(ev vaxis.Event, phase vxfw.EventPhase) (vxfw.Command, error) {
222 switch msg := ev.(type) {
223 case PageIn:
224 m.focus = "input"
225 m.input.Reset()
226 return m.input.FocusIn()
227 case vaxis.Key:
228 if msg.Matches(vaxis.KeyTab) {
229 if m.focus == "input" {
230 m.focus = "button"
231 cmd, _ := m.input.FocusOut()
232 return vxfw.BatchCmd([]vxfw.Command{
233 vxfw.FocusWidgetCmd(m.btn),
234 cmd,
235 }), nil
236 }
237 m.focus = "input"
238 return m.input.FocusIn()
239 }
240 }
241
242 return nil, nil
243}
244
245func (m *AddKeyPage) addPubkey(pubkey string) error {
246 pk, comment, _, _, err := ssh.ParseAuthorizedKey([]byte(pubkey))
247 if err != nil {
248 return err
249 }
250
251 key := utils.KeyForKeyText(pk)
252
253 return m.shared.Dbpool.InsertPublicKey(
254 m.shared.User.ID, key, comment, nil,
255 )
256}
257
258func (m *AddKeyPage) Draw(ctx vxfw.DrawContext) (vxfw.Surface, error) {
259 w := ctx.Max.Width
260 h := ctx.Max.Height
261 root := vxfw.NewSurface(w, h, m)
262 ah := 0
263
264 header := text.New("Enter a new public key. You can typically grab an SSH pubkey in the `.ssh` folder: cat ~/.ssh/id_ed25519.pub. You can include the comment as well.")
265 headerSurf, _ := header.Draw(ctx)
266 root.AddChild(0, ah, headerSurf)
267 ah += int(headerSurf.Size.Height) + 1
268
269 inputSurf, _ := m.input.Draw(ctx)
270 root.AddChild(0, ah, inputSurf)
271 ah += int(headerSurf.Size.Height) + 1
272
273 btnSurf, _ := m.btn.Draw(vxfw.DrawContext{
274 Characters: ctx.Characters,
275 Max: vxfw.Size{Width: 5, Height: 1},
276 })
277 root.AddChild(0, ah, btnSurf)
278
279 if m.err != nil {
280 e := richtext.New([]vaxis.Segment{
281 {
282 Text: m.err.Error(),
283 Style: vaxis.Style{Foreground: red},
284 },
285 })
286 errSurf, _ := e.Draw(createDrawCtx(ctx, 1))
287 root.AddChild(0, ah, errSurf)
288 }
289
290 return root, nil
291}