Files
lazysecrets/internal/ui/app/sharefiles.go
T
kebandClaude Opus 5.5 0bfa2a3141
CI / Checks (pull_request) Successful in 39s
PR title / Conventional title (pull_request) Successful in 2s
feat: share.keyRules, how long and varied a share key must be
Sharing refuses a key shorter than minLength characters (default 32,
never under 16) or of fewer than minClasses kinds of lowercase,
uppercase, digits and symbols (default 2, so random hex passes),
saying so without showing it. Opening doesn't check.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-13 12:16:12 +02:00

357 lines
10 KiB
Go

package app
import (
"context"
"errors"
"fmt"
"os"
"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 are on disk.
func (m Model) canShareFiles(src provider.Source) bool {
_, ok := m.providerFor(src).(provider.Files)
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.Files)
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 {
f, err := files.File(c, nil)
if err == nil {
var b []byte
b, err = os.ReadFile(f.Path)
msg.files = append(msg.files, share.File{Path: c.Name, Data: b})
}
if err != nil {
msg.err = err
return msg
}
}
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
}
// confirmShare says what the link will hold before making it.
func (m *Model) confirmShare(s sharing) {
lines := []string{m.st.Muted.Render(plural(len(s.files), "env file") + " from " + s.repo.Name + ":")}
for _, f := range s.files {
lines = append(lines, " "+f.Path)
}
lines = append(lines, "", m.st.Muted.Render("encrypted with ")+m.refLabel(s.key))
m.showMenu("Share as an encrypted link", 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.
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
m.confirmShare(s)
return nil
}
m.search.pickTitle = "Encrypt with which secret?"
what := s.files[0].Path
if len(s.files) > 1 {
what = plural(len(s.files), "env file")
}
m.search.pickLines = []string{
"Sharing " + what + " from " + s.repo.Name + " as an encrypted link.",
"Pick the secret to encrypt with: whoever can read it can open the link.",
m.st.Muted.Render("Its value isn't shown or put in the link. share.key sets a default."),
}
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 = " from " + 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 them…", 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("Their paths are from the repo's root.")}
if msg.sealed.Repo != "" {
lines = append([]string{m.st.Muted.Render(msg.sealed.Repo + " isn't among your repos: save them 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 {
line := " " + t.Path + m.st.Muted.Render(" · new")
if t.Exists {
exist++
line = " " + t.Path + 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 them", run: write(false)})
case exist == len(targets):
opts = append(opts, option{key: "w", label: "Replace " + map[bool]string{true: "it", false: "them"}[exist == 1], 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: "Save only the new " + plural(len(targets)-exist, "file"), 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
}