repos / pico

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

pico / pkg / tui
Antonio Mika  ·  2025-04-02

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