The demo provider finds repos by their remote, as the repo provider does, so a key in a demo repo's SOPS file can be read. Opening a share link uses the repo's clone only when it's on disk (the demo's aren't), else asks where to save. The demo GitHub client secret is 40 characters, like real ones, so it passes share.keyRules. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
381 lines
11 KiB
Go
381 lines
11 KiB
Go
package app
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"path"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"charm.land/bubbles/v2/textinput"
|
|
tea "charm.land/bubbletea/v2"
|
|
|
|
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
|
"git.kebcloud.no/keb/lazysecrets/internal/ref"
|
|
"git.kebcloud.no/keb/lazysecrets/internal/share"
|
|
)
|
|
|
|
// Sharing env files as an encrypted lazysecrets://share link (package
|
|
// share), and opening one: the files are encrypted with a secret's value,
|
|
// so whoever can read that secret can open the link.
|
|
|
|
// sharing is a share being made: the files read, and the key to use.
|
|
type sharing struct {
|
|
repo provider.Source
|
|
files []share.File
|
|
key ref.Ref // zero until chosen
|
|
}
|
|
|
|
type shareFilesMsg struct {
|
|
sharing
|
|
err error
|
|
}
|
|
|
|
type sealedMsg struct {
|
|
link ref.Ref
|
|
n int
|
|
err error
|
|
}
|
|
|
|
// canShareFiles reports whether src's env files can be shared: it's a
|
|
// repo whose files can be read.
|
|
func (m Model) canShareFiles(src provider.Source) bool {
|
|
_, ok := m.providerFor(src).(provider.Contents)
|
|
return src.Kind == provider.KindRepo && ok
|
|
}
|
|
|
|
// shareFiles shares env files of a repo: cs, or with cs nil all its
|
|
// plain ones.
|
|
func (m *Model) shareFiles(src provider.Source, cs []provider.Container) tea.Cmd {
|
|
p := m.providerFor(src)
|
|
files, ok := p.(provider.Contents)
|
|
if !ok {
|
|
m.flash = warning("these files can't be shared")
|
|
return nil
|
|
}
|
|
general, timeout := m.shareKey, m.timeout
|
|
m.flash = flash{text: "reading the files…"}
|
|
return func() tea.Msg {
|
|
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
|
defer cancel()
|
|
msg := shareFilesMsg{sharing: sharing{repo: src}}
|
|
if cs == nil {
|
|
all, err := p.Containers(ctx, src)
|
|
if err != nil {
|
|
msg.err = err
|
|
return msg
|
|
}
|
|
for _, c := range all {
|
|
if c.Detail == provider.DetailPlainEnv {
|
|
cs = append(cs, c)
|
|
}
|
|
}
|
|
if len(cs) == 0 {
|
|
msg.err = errors.New(src.Name + " has no plain env files")
|
|
return msg
|
|
}
|
|
}
|
|
for _, c := range cs {
|
|
b, err := files.Contents(ctx, c)
|
|
if err != nil {
|
|
msg.err = err
|
|
return msg
|
|
}
|
|
msg.files = append(msg.files, share.File{Path: c.Name, Data: b})
|
|
}
|
|
key, ok, err := share.DefaultKey(src.ID, general)
|
|
if ok {
|
|
msg.key = key
|
|
}
|
|
msg.err = err
|
|
return msg
|
|
}
|
|
}
|
|
|
|
func (m *Model) handleShareFiles(msg shareFilesMsg) tea.Cmd {
|
|
switch {
|
|
case msg.err != nil:
|
|
m.flash = flash{text: "share: " + msg.err.Error(), isErr: true}
|
|
case msg.key == (ref.Ref{}):
|
|
return m.pickShareKey(msg.sharing)
|
|
default:
|
|
m.confirmShare(msg.sharing)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// shareTitle names the action: "Share .env", "Share 2 env files".
|
|
func shareTitle(s sharing) string {
|
|
if len(s.files) == 1 {
|
|
return "Share " + path.Base(s.files[0].Path)
|
|
}
|
|
return "Share " + plural(len(s.files), "env file")
|
|
}
|
|
|
|
// sharedFiles lists the files being shared, under what's happening.
|
|
func (m *Model) sharedFiles(s sharing) []string {
|
|
lines := []string{"Sharing " + plural(len(s.files), "env file") + " as an encrypted link:"}
|
|
for _, f := range s.files {
|
|
lines = append(lines, " "+m.repoLabel(s.repo.Name, f.Path))
|
|
}
|
|
return lines
|
|
}
|
|
|
|
// confirmShare says what the link will hold before making it.
|
|
func (m *Model) confirmShare(s sharing) {
|
|
lines := append(m.sharedFiles(s), "", m.st.Muted.Render("encrypted with ")+m.refLabel(s.key))
|
|
m.showMenu(shareTitle(s), lines, []option{
|
|
{key: "c", label: "Copy the link", run: func(m *Model) tea.Cmd { return m.seal(s) }},
|
|
{key: "k", label: "Encrypt with another secret…", run: func(m *Model) tea.Cmd { return m.pickShareKey(s) }},
|
|
})
|
|
}
|
|
|
|
// pickShareKey asks which secret to encrypt with, in Go to, then shares
|
|
// with it.
|
|
func (m *Model) pickShareKey(s sharing) tea.Cmd {
|
|
cmd := m.openGoTo(tabSearch, true)
|
|
m.search.pick = func(m *Model, key ref.Ref) tea.Cmd {
|
|
s.key = key
|
|
return m.seal(s) // the picker showed what's shared
|
|
}
|
|
m.search.pickTitle = shareTitle(s)
|
|
m.search.pickLines = m.sharedFiles(s)
|
|
m.search.pickHeading = "Pick a secret to encrypt with"
|
|
m.search.pickHelp = []string{
|
|
m.st.Muted.Render("The recipient needs read access to it to decrypt the files."),
|
|
m.st.Muted.Render("Its value isn't shown or put in the link."),
|
|
}
|
|
m.search.input.SetWidth(searchWidth(m.w) - 10) // inside the picker's border
|
|
m.fillSearch(false)
|
|
return tea.Batch(cmd, m.searchLoads())
|
|
}
|
|
|
|
// pickPasted picks a pasted reference to a secret, while picking one.
|
|
func (m *Model) pickPasted(text string) tea.Cmd {
|
|
key, err := share.ParseKey(strings.TrimSpace(text))
|
|
if err != nil {
|
|
m.flash = flash{text: "share: " + err.Error(), isErr: true}
|
|
return nil
|
|
}
|
|
pick := m.search.pick
|
|
m.search = nil
|
|
return pick(m, key)
|
|
}
|
|
|
|
// seal reads the key and encrypts the files with it.
|
|
func (m *Model) seal(s sharing) tea.Cmd {
|
|
read, rules := m.keyReader(), m.shareRules
|
|
m.flash = flash{text: "encrypting with " + s.key.Name + "…"}
|
|
return func() tea.Msg {
|
|
v, version, err := read(s.key)
|
|
if err != nil {
|
|
return sealedMsg{err: fmt.Errorf("reading the key %s: %w", s.key.Name, err)}
|
|
}
|
|
link, err := share.Seal(share.Header{Key: share.Pin(s.key, version), Repo: s.repo.Detail}, []byte(v.Reveal()), s.files, rules)
|
|
return sealedMsg{link: link, n: len(s.files), err: err}
|
|
}
|
|
}
|
|
|
|
func (m *Model) handleSealed(msg sealedMsg) tea.Cmd {
|
|
if msg.err != nil {
|
|
m.noteErr(msg.err)
|
|
m.flash = flash{text: "share: " + msg.err.Error(), isErr: true}
|
|
return nil
|
|
}
|
|
m.dismissedLink = msg.link.String() // not offered back on focus
|
|
return m.copyText("encrypted link to "+plural(msg.n, "file"), msg.link.String(), false)
|
|
}
|
|
|
|
// keyReader reads a key's value with the providers, the account that
|
|
// last read it first, off the UI goroutine.
|
|
func (m *Model) keyReader() func(ref.Ref) (provider.SecretValue, string, error) {
|
|
providers, st, timeout := m.providers, m.store, m.timeout
|
|
return func(key ref.Ref) (provider.SecretValue, string, error) {
|
|
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
|
defer cancel()
|
|
k := key
|
|
k.Version = ""
|
|
node := st.Node(k.String())
|
|
return ref.Read(ctx, providers, key, node.Account, node.Source)
|
|
}
|
|
}
|
|
|
|
// --- opening ---
|
|
|
|
type shareOpenedMsg struct {
|
|
sealed share.Sealed
|
|
files []share.File
|
|
dir string // the repo's clone; "" when it isn't here
|
|
err error
|
|
}
|
|
|
|
// offerShare says what a share link holds before decrypting it: links
|
|
// can come from anywhere, so nothing is read or written unasked.
|
|
func (m *Model) offerShare(r ref.Ref) tea.Cmd {
|
|
s, err := share.Read(r)
|
|
if err != nil {
|
|
m.showMenu("Share link can't be read", []string{m.st.Error.Render(err.Error())}, nil)
|
|
return nil
|
|
}
|
|
from := ""
|
|
if s.Repo != "" {
|
|
from = m.st.Muted.Render(" from ") + m.repoLabel(s.Repo)
|
|
}
|
|
m.showMenu("Shared env files", []string{
|
|
m.st.Muted.Render(plural(s.Files, "env file")) + from,
|
|
m.st.Muted.Render("encrypted with ") + m.refLabel(s.Key),
|
|
}, []option{
|
|
{key: "o", label: "Decrypt…", tip: "reads the secret, then asks before saving", run: func(m *Model) tea.Cmd { return m.openShare(s) }},
|
|
})
|
|
return nil
|
|
}
|
|
|
|
// openShare reads the key, decrypts the files and finds the repo's clone.
|
|
func (m *Model) openShare(s share.Sealed) tea.Cmd {
|
|
read := m.keyReader()
|
|
providers, timeout := m.providers, m.timeout
|
|
m.flash = flash{text: "decrypting with " + s.Key.Name + "…"}
|
|
return func() tea.Msg {
|
|
msg := shareOpenedMsg{sealed: s}
|
|
v, _, err := read(s.Key)
|
|
if err != nil {
|
|
msg.err = fmt.Errorf("reading the key %s: %w", s.Key.Name, err)
|
|
return msg
|
|
}
|
|
if msg.files, msg.err = s.Open([]byte(v.Reveal())); msg.err != nil {
|
|
return msg
|
|
}
|
|
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
|
defer cancel()
|
|
for _, p := range providers {
|
|
if loc, ok := p.(provider.Locator); ok && p.Kind() == provider.KindRepo && s.Repo != "" {
|
|
// Its clone, if it's on disk (the demo's aren't).
|
|
if root, found, _ := loc.Locate(ctx, s.Repo); found && isDir(root) {
|
|
msg.dir = root
|
|
break
|
|
}
|
|
}
|
|
}
|
|
return msg
|
|
}
|
|
}
|
|
|
|
func (m *Model) handleShareOpened(msg shareOpenedMsg) tea.Cmd {
|
|
switch {
|
|
case msg.err != nil:
|
|
m.noteErr(msg.err)
|
|
m.flash = flash{text: "open: " + msg.err.Error(), isErr: true}
|
|
case msg.dir == "":
|
|
return m.askShareDir(msg)
|
|
default:
|
|
m.offerWrite(msg.files, msg.dir)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// askShareDir asks where to save files from a repo that isn't here.
|
|
func (m *Model) askShareDir(msg shareOpenedMsg) tea.Cmd {
|
|
in := textinput.New()
|
|
in.Prompt = "folder: "
|
|
in.SetWidth(max(20, min(m.w-12, 64)))
|
|
dir := m.placeDir
|
|
if dir == "" {
|
|
dir, _ = os.Getwd()
|
|
}
|
|
in.SetValue(dir)
|
|
in.CursorEnd()
|
|
lines := []string{m.st.Muted.Render("Paths are from the repo's root.")}
|
|
if msg.sealed.Repo != "" {
|
|
lines = append([]string{m.repoLabel(msg.sealed.Repo) + m.st.Muted.Render(" isn't among your repos. Save where?")}, lines...)
|
|
}
|
|
m.prompt = &modal{
|
|
title: "Save the files", input: &in, lines: lines,
|
|
submit: func(m *Model, text string) tea.Cmd {
|
|
m.offerWrite(msg.files, expandHome(strings.TrimSpace(text)))
|
|
return nil
|
|
},
|
|
}
|
|
return m.prompt.input.Focus()
|
|
}
|
|
|
|
// offerWrite shows where each file goes, then writes them as chosen:
|
|
// files there already are replaced only when asked.
|
|
func (m *Model) offerWrite(files []share.File, dir string) {
|
|
targets, err := share.Plan(dir, files)
|
|
if err != nil {
|
|
m.flash = flash{text: "save: " + err.Error(), isErr: true}
|
|
return
|
|
}
|
|
var lines []string
|
|
exist := 0
|
|
for _, t := range targets {
|
|
file := " " + m.repoLabel(filepath.Base(dir), t.Path)
|
|
line := file + m.st.Muted.Render(" · new")
|
|
if t.Exists {
|
|
exist++
|
|
line = file + m.st.Muted.Render(" · replaces the one there")
|
|
}
|
|
if t.Exposed {
|
|
line += m.st.Error.Render(" · not ignored by git")
|
|
}
|
|
lines = append(lines, line)
|
|
}
|
|
lines = append([]string{m.st.Muted.Render("In " + dir + ":")}, lines...)
|
|
write := func(onlyNew bool) func(*Model) tea.Cmd {
|
|
return func(m *Model) tea.Cmd {
|
|
n := 0
|
|
for _, t := range targets {
|
|
if onlyNew && t.Exists {
|
|
continue
|
|
}
|
|
if err := t.Write(); err != nil {
|
|
m.flash = flash{text: "save: " + err.Error(), isErr: true}
|
|
return nil
|
|
}
|
|
n++
|
|
}
|
|
m.flash = done("saved " + plural(n, "file") + " in " + dir)
|
|
return nil
|
|
}
|
|
}
|
|
var opts []option
|
|
switch {
|
|
case exist == 0:
|
|
opts = append(opts, option{key: "w", label: "Save", run: write(false)})
|
|
case exist == len(targets):
|
|
opts = append(opts, option{key: "w", label: "Replace", run: write(false)})
|
|
default:
|
|
opts = append(opts,
|
|
option{key: "w", label: fmt.Sprintf("Save all, replacing %d", exist), run: write(false)},
|
|
option{key: "n", label: onlyNew(len(targets) - exist), run: write(true)},
|
|
)
|
|
}
|
|
m.showMenu("Save the shared files", lines, opts)
|
|
}
|
|
|
|
// isDir reports whether dir is a folder on disk.
|
|
func isDir(dir string) bool {
|
|
info, err := os.Stat(dir)
|
|
return err == nil && info.IsDir()
|
|
}
|
|
|
|
// expandHome expands a leading ~/ to the home folder.
|
|
func expandHome(dir string) string {
|
|
if rest, ok := strings.CutPrefix(dir, "~/"); ok {
|
|
if home, err := os.UserHomeDir(); err == nil {
|
|
return filepath.Join(home, rest)
|
|
}
|
|
}
|
|
return dir
|
|
}
|
|
|
|
// onlyNew labels saving only the n files not there yet.
|
|
func onlyNew(n int) string {
|
|
if n == 1 {
|
|
return "Save only the new one"
|
|
}
|
|
return fmt.Sprintf("Save only the %d new ones", n)
|
|
}
|