Providers whose containers are files give their contents through provider.Contents, which sharing reads instead of the disk. The demo repos build their env files from their keys, and the second demo account has a key to share with (kv-shared-prod / env-share), so S → f and opening the link work in --demo; the demo repos aren't on disk, so opening asks where to save. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
374 lines
11 KiB
Go
374 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 != "" {
|
|
if root, found, _ := loc.Locate(ctx, s.Repo); found {
|
|
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)
|
|
}
|
|
|
|
// 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)
|
|
}
|