repos / pico

pico services mono repo
git clone https://github.com/picosh/pico.git

pico / pkg / tui
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}