repos / pico

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

pico / pkg / tui
Eric Bower  ·  2025-05-05

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				cursor := int(m.list.Cursor())
 75				if cursor >= len(m.keys) {
 76					return nil, nil
 77				}
 78				m.confirm = false
 79				err := m.shared.Dbpool.RemoveKeys([]string{m.keys[m.list.Cursor()].ID})
 80				if err != nil {
 81					m.err = err
 82					return nil, nil
 83				}
 84				m.err = m.fetchKeys()
 85				return vxfw.RedrawCmd{}, nil
 86			}
 87		}
 88		if msg.Matches('n') {
 89			m.confirm = false
 90			return vxfw.RedrawCmd{}, nil
 91		}
 92	}
 93
 94	return nil, nil
 95}
 96
 97func (m *PubkeysPage) getWidget(i uint, cursor uint) vxfw.Widget {
 98	if int(i) >= len(m.keys) {
 99		return nil
100	}
101
102	style := vaxis.Style{Foreground: grey}
103	isSelected := i == cursor
104	if isSelected {
105		style = vaxis.Style{Foreground: fuschia}
106	}
107
108	pubkey := m.keys[i]
109	key, _, _, _, err := ssh.ParseAuthorizedKey([]byte(pubkey.Key))
110	if err != nil {
111		m.shared.Logger.Error("parse pubkey", "err", err)
112		return nil
113	}
114
115	keyStr := pubkey.Key[0:25] + "..." + pubkey.Key[len(pubkey.Key)-15:]
116
117	txt := richtext.New([]vaxis.Segment{
118		{Text: "Name: ", Style: style},
119		{Text: pubkey.Name + "\n"},
120
121		{Text: "Key: ", Style: style},
122		{Text: keyStr + "\n"},
123
124		{Text: "Sha: ", Style: style},
125		{Text: ssh.FingerprintSHA256(key) + "\n"},
126
127		{Text: "Created: ", Style: style},
128		{Text: pubkey.CreatedAt.Format(time.DateOnly)},
129	})
130
131	return txt
132}
133
134func (m *PubkeysPage) Draw(ctx vxfw.DrawContext) (vxfw.Surface, error) {
135	w := ctx.Max.Width
136	h := ctx.Max.Height
137	root := vxfw.NewSurface(w, h, m)
138	ah := 0
139
140	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.")
141	brd := NewBorder(info)
142	brd.Label = "desc"
143	brdSurf, _ := brd.Draw(ctx)
144	root.AddChild(0, ah, brdSurf)
145	ah += int(brdSurf.Size.Height)
146
147	header := text.New(fmt.Sprintf("%d pubkeys\n\n", len(m.keys)))
148	headerSurf, _ := header.Draw(ctx)
149	root.AddChild(0, ah, headerSurf)
150	ah += int(headerSurf.Size.Height)
151
152	footerHeight := 3
153	listSurf, _ := m.list.Draw(createDrawCtx(ctx, ctx.Max.Height-uint16(ah)-uint16(footerHeight)))
154	root.AddChild(0, ah, listSurf)
155
156	segs := []vaxis.Segment{}
157	if m.confirm {
158		segs = append(segs, vaxis.Segment{
159			Text:  "are you sure? y/n\n",
160			Style: vaxis.Style{Foreground: red},
161		})
162	}
163	if m.err != nil {
164		segs = append(segs, vaxis.Segment{
165			Text:  m.err.Error() + "\n",
166			Style: vaxis.Style{Foreground: red},
167		})
168	}
169	segs = append(segs, vaxis.Segment{Text: "\n"})
170
171	footer := richtext.New(segs)
172	footerSurf, _ := footer.Draw(ctx)
173	root.AddChild(0, int(h)-footerHeight, footerSurf)
174
175	return root, nil
176}
177
178type AddKeyPage struct {
179	shared *SharedModel
180
181	err   error
182	focus string
183	input *TextInput
184	btn   *button.Button
185}
186
187func NewAddPubkeyPage(shrd *SharedModel) *AddKeyPage {
188	btn := button.New("ADD", func() (vxfw.Command, error) { return nil, nil })
189	btn.Style = button.StyleSet{
190		Default: vaxis.Style{Background: grey},
191		Focus:   vaxis.Style{Background: oj, Foreground: black},
192	}
193	return &AddKeyPage{
194		shared: shrd,
195
196		input: NewTextInput("add pubkey"),
197		btn:   btn,
198	}
199}
200
201func (m *AddKeyPage) Footer() []Shortcut {
202	return []Shortcut{
203		{Shortcut: "tab", Text: "focus"},
204		{Shortcut: "shift+click", Text: "select text"},
205		{Shortcut: "enter", Text: "add public key"},
206	}
207}
208
209func (m *AddKeyPage) CaptureEvent(ev vaxis.Event) (vxfw.Command, error) {
210	switch msg := ev.(type) {
211	case vaxis.Key:
212		if msg.Matches(vaxis.KeyEnter) {
213			err := m.addPubkey(m.input.GetValue())
214			m.err = err
215			if err == nil {
216				m.input.Reset()
217				m.shared.App.PostEvent(Navigate{To: "pubkeys"})
218				return nil, nil
219			}
220			return vxfw.RedrawCmd{}, nil
221		}
222	}
223	return nil, nil
224}
225
226func (m *AddKeyPage) HandleEvent(ev vaxis.Event, phase vxfw.EventPhase) (vxfw.Command, error) {
227	switch msg := ev.(type) {
228	case PageIn:
229		m.focus = "input"
230		m.input.Reset()
231		return m.input.FocusIn()
232	case vaxis.Key:
233		if msg.Matches(vaxis.KeyTab) {
234			if m.focus == "input" {
235				m.focus = "button"
236				cmd, _ := m.input.FocusOut()
237				return vxfw.BatchCmd([]vxfw.Command{
238					vxfw.FocusWidgetCmd(m.btn),
239					cmd,
240				}), nil
241			}
242			m.focus = "input"
243			return m.input.FocusIn()
244		}
245	}
246
247	return nil, nil
248}
249
250func (m *AddKeyPage) addPubkey(pubkey string) error {
251	pk, comment, _, _, err := ssh.ParseAuthorizedKey([]byte(pubkey))
252	if err != nil {
253		return err
254	}
255
256	key := utils.KeyForKeyText(pk)
257
258	return m.shared.Dbpool.InsertPublicKey(
259		m.shared.User.ID, key, comment, nil,
260	)
261}
262
263func (m *AddKeyPage) Draw(ctx vxfw.DrawContext) (vxfw.Surface, error) {
264	w := ctx.Max.Width
265	h := ctx.Max.Height
266	root := vxfw.NewSurface(w, h, m)
267	ah := 0
268
269	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.")
270	headerSurf, _ := header.Draw(ctx)
271	root.AddChild(0, ah, headerSurf)
272	ah += int(headerSurf.Size.Height) + 1
273
274	inputSurf, _ := m.input.Draw(ctx)
275	root.AddChild(0, ah, inputSurf)
276	ah += int(headerSurf.Size.Height) + 1
277
278	btnSurf, _ := m.btn.Draw(vxfw.DrawContext{
279		Characters: ctx.Characters,
280		Max:        vxfw.Size{Width: 5, Height: 1},
281	})
282	root.AddChild(0, ah, btnSurf)
283
284	if m.err != nil {
285		e := richtext.New([]vaxis.Segment{
286			{
287				Text:  m.err.Error(),
288				Style: vaxis.Style{Foreground: red},
289			},
290		})
291		errSurf, _ := e.Draw(createDrawCtx(ctx, 1))
292		root.AddChild(0, ah, errSurf)
293	}
294
295	return root, nil
296}