Files
lazysecrets/internal/ui/app/sharefiles.go
T
kebandClaude Opus 5.5 166790d825
CI / Checks (pull_request) Successful in 41s
PR title / Conventional title (pull_request) Successful in 2s
fix: a demo repo's SOPS value can be the share key
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>
2026-10-13 13:17:04 +02:00

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)
}