Share `.env` files as an encrypted `lazysecrets://share/…` link. The files are encrypted with a secret's value, so anyone who can read that secret can open the link. **Sharing:** `S` → `f` on a repo (all its plain env files), an env file, or a key in one (its file). - **The key** is `share.key` from the repo's `.lazysecrets.yaml`, else from the config. It can be a `lazysecrets://` link, a Key Vault URL or an `@Microsoft.KeyVault(...)` reference. - **Without `share.key`,** a picker built on Go to opens, titled "Share .env", with "Pick a secret to encrypt with" in its own border. It lists only secrets, leaving out values from plain `.env` files. Pasting a reference picks it. Picking shares at once, and the picker stays open until the link is copied. If the secret can't be used, the reason shows in red in the picker: it can't be read, or it doesn't meet `share.keyRules` (which rule failed is named). `esc` cancels. - **`share.keyRules`** (defaults `minLength: 32`, `minClasses: 2`; never under 16) applies when sharing, not when opening. - **On Azure,** the link pins the key's version, so rotating the key doesn't break old links, and disabling a version revokes them. - **The command line:** `lazysecrets share [--key <secret>] <env files>`. **Opening** works by paste, `ctrl+o`, the clipboard offer, the link handler or `lazysecrets <link>`. 1. It first says which repo the link is from, how many files it holds and which key it uses, and decrypts only after `o`. 2. It saves into the repo's clone, found by its remote, showing which files are new, which would replace one there, and which git doesn't ignore. 3. Without a clone, it asks for a folder and saves straight away. The prompt stays open, with the reason in red, until the files are written. 4. If decryption fails, a popup says why: the first line of the reason, `r` for the whole message, `g` to go to the key's secret. A key that has changed is named by where it lives: a Databricks or SOPS value changed in place, an Azure current version that isn't the one the link was made with, or a pinned version, meaning the link may be damaged. **Format** (`internal/share`): a version byte, then a JSON header with the key, the repo and the file count, then an age file (scrypt 2^15, with the key's value as passphrase) holding the files as deflated JSON, all as unpadded base64url. Links are capped at 16 KB. A future format gets a new version, and old ones stay readable. **Safety:** - A link can only write env files, inside the folder: never in `.git`, never through a symlink. - New files are created with mode 0600. - Opening accepts an scrypt work factor of at most 2^20, and unpacks at most 1 MB. **Along the way:** - Opening a link to a secret ends on its details, as `enter` would. - `o` on an Azure secret opens its portal page, and in the versions view, that version's page. - The details pane follows its secret's list when it reloads in the background. Before, it kept what it showed first, including an old portal address. - `ref.Read` is shared by `get` and sharing, with Azure `VersionedValue` for pinning. - `provider.Contents` lets the demo repos share their env files. `--demo` has a share key (`kv-shared-prod / env-share`), so the whole flow works there. - New dependency: `filippo.io/age`. Tested end to end in the TUI and the demo, the CLI, and the `share` package. In a real vault: version pinning, opening into a clone, and revocation by disabling the version. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Reviewed-on: #54
425 lines
12 KiB
Go
425 lines
12 KiB
Go
package app
|
|
|
|
import (
|
|
"cmp"
|
|
"fmt"
|
|
"slices"
|
|
"strings"
|
|
"unicode"
|
|
"unicode/utf8"
|
|
|
|
"charm.land/bubbles/v2/key"
|
|
"charm.land/bubbles/v2/textinput"
|
|
tea "charm.land/bubbletea/v2"
|
|
"charm.land/lipgloss/v2"
|
|
"github.com/charmbracelet/x/ansi"
|
|
|
|
"git.kebcloud.no/keb/lazysecrets/internal/match"
|
|
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
|
"git.kebcloud.no/keb/lazysecrets/internal/ref"
|
|
"git.kebcloud.no/keb/lazysecrets/internal/ui/keys"
|
|
"git.kebcloud.no/keb/lazysecrets/internal/ui/panes"
|
|
)
|
|
|
|
// A modal is a centred popup that takes the keyboard, like lazygit's menus:
|
|
// a list of options, each with a shortcut key, or a text input.
|
|
|
|
type option struct {
|
|
key string
|
|
label string
|
|
tip string // shown under the list while the option is selected
|
|
note string // dimmed on the right of the row; the filter matches it
|
|
// star is the store key ctrl+f stars or unstars (favourites, search);
|
|
// starred shows a ★ before the label.
|
|
star string
|
|
starAccount string // the account the star is for
|
|
starred bool
|
|
// mark, if set, goes first: how much is loaded (search). reload,
|
|
// if set, runs on ctrl+r.
|
|
mark string
|
|
reload func(m *Model) tea.Cmd
|
|
id string // identifies the entry across rebuilds (search)
|
|
group string // the source row it's in (search)
|
|
kind provider.Kind // the kind of provider it's from (search)
|
|
hits []int // characters of the label the filter matched (Go to)
|
|
plain bool // a key in a plain env file: no key to encrypt with (Go to)
|
|
run func(m *Model) tea.Cmd
|
|
// header makes this a section title: not selectable, and hidden
|
|
// while a filter narrows the list.
|
|
header bool
|
|
}
|
|
|
|
type modal struct {
|
|
title string
|
|
lines []string // explanation above the options
|
|
about *ref.Ref // drawn above lines, redrawn as lists load (refLabel)
|
|
options []option
|
|
cursor int
|
|
|
|
// A text input instead of options; submit runs on enter (named
|
|
// submitLabel in the footer; "open" if empty). err says why the last
|
|
// one wasn't taken, under the input.
|
|
input *textinput.Model
|
|
submit func(m *Model, text string) tea.Cmd
|
|
submitLabel string
|
|
err string
|
|
|
|
// A filter over the options: typing narrows them (all holds them
|
|
// all), so moving uses arrows or ctrl+j/k.
|
|
filter *textinput.Model
|
|
all []option
|
|
|
|
footer string // overrides the default footer
|
|
onClose func(m *Model) // runs when it closes, picked or not
|
|
}
|
|
|
|
// showFilterMenu opens a menu with a filter input above the options.
|
|
func (m *Model) showFilterMenu(title string, options []option) tea.Cmd {
|
|
in := textinput.New()
|
|
in.Prompt = "/"
|
|
in.Placeholder = "type to filter"
|
|
in.SetWidth(40)
|
|
m.prompt = &modal{title: title, options: options, all: options, filter: &in}
|
|
m.prompt.move(0)
|
|
m.flash = flash{}
|
|
return m.prompt.filter.Focus()
|
|
}
|
|
|
|
// move moves the cursor by delta, skipping section headers; with nothing
|
|
// selectable that way it stays put. move(0) settles on a selectable option.
|
|
func (p *modal) move(delta int) {
|
|
ok := func(i int) bool { return i >= 0 && i < len(p.options) && !p.options[i].header }
|
|
step := 1
|
|
if delta < 0 {
|
|
step = -1
|
|
}
|
|
i := max(0, min(p.cursor+delta, len(p.options)-1))
|
|
for i >= 0 && i < len(p.options) && !ok(i) {
|
|
i += step
|
|
}
|
|
switch {
|
|
case ok(i):
|
|
p.cursor = i
|
|
case !ok(p.cursor): // e.g. after filtering: first selectable
|
|
for p.cursor = 0; p.cursor < len(p.options) && !ok(p.cursor); p.cursor++ {
|
|
}
|
|
}
|
|
}
|
|
|
|
// counter is "3 of 12" over the selectable options.
|
|
func (p *modal) counter() string {
|
|
n, at := 0, 0
|
|
for i, o := range p.options {
|
|
if o.header {
|
|
continue
|
|
}
|
|
n++
|
|
if i == p.cursor {
|
|
at = n
|
|
}
|
|
}
|
|
if n == 0 {
|
|
return ""
|
|
}
|
|
return fmt.Sprintf("%d of %d", at, n)
|
|
}
|
|
|
|
// refilter narrows the options to fuzzy matches of the filter. Matches
|
|
// stay under their section headers, best first within each section, and
|
|
// sections without matches drop out.
|
|
func (p *modal) refilter() {
|
|
q := p.filter.Value()
|
|
if q == "" {
|
|
p.options = p.all
|
|
p.cursor = max(0, min(p.cursor, len(p.options)-1))
|
|
p.move(0)
|
|
return
|
|
}
|
|
items := make([]match.Item, len(p.all))
|
|
for i, o := range p.all {
|
|
label := ansi.Strip(o.label)
|
|
items[i] = match.Item{Text: strings.TrimSpace(label + " " + o.note), NameEnd: utf8.RuneCountInString(label)}
|
|
}
|
|
rank := map[int]int{} // matching options: their place in the ranking
|
|
for n, h := range match.Rank(match.Parse(q), items) {
|
|
if !p.all[h.Index].header {
|
|
rank[h.Index] = n
|
|
}
|
|
}
|
|
|
|
p.options = nil
|
|
var section []int
|
|
header := -1
|
|
flush := func() {
|
|
if len(section) == 0 {
|
|
return
|
|
}
|
|
slices.SortStableFunc(section, func(a, b int) int { return rank[a] - rank[b] })
|
|
if header >= 0 {
|
|
p.options = append(p.options, p.all[header])
|
|
}
|
|
for _, i := range section {
|
|
p.options = append(p.options, p.all[i])
|
|
}
|
|
}
|
|
for i, o := range p.all {
|
|
if o.header {
|
|
flush()
|
|
section, header = nil, i
|
|
continue
|
|
}
|
|
if _, ok := rank[i]; ok {
|
|
section = append(section, i)
|
|
}
|
|
}
|
|
flush()
|
|
p.cursor = max(0, min(p.cursor, len(p.options)-1))
|
|
p.move(0)
|
|
}
|
|
|
|
// showMenu opens a menu. Every menu ends with an x Dismiss row.
|
|
func (m *Model) showMenu(title string, lines []string, options []option) {
|
|
options = append(options, option{key: "x", label: "Dismiss", run: func(*Model) tea.Cmd { return nil }})
|
|
m.prompt = &modal{title: title, lines: lines, options: options}
|
|
m.flash = flash{}
|
|
}
|
|
|
|
// handleModal routes keys while a modal is shown.
|
|
func (m *Model) handleModal(msg tea.KeyPressMsg) tea.Cmd {
|
|
p := m.prompt
|
|
km := m.keys
|
|
textField := p.input != nil || p.filter != nil
|
|
closeIt := func() {
|
|
m.prompt = nil
|
|
if p.onClose != nil {
|
|
p.onClose(m)
|
|
}
|
|
}
|
|
if (textField && key.Matches(msg, km.PopupClose)) || (!textField && key.Matches(msg, km.MenuClose)) {
|
|
closeIt()
|
|
return nil
|
|
}
|
|
if p.input != nil {
|
|
if key.Matches(msg, km.PopupSelect) {
|
|
m.prompt = nil
|
|
return p.submit(m, p.input.Value())
|
|
}
|
|
in, cmd := p.input.Update(msg)
|
|
p.input = &in
|
|
return cmd
|
|
}
|
|
if p.filter != nil {
|
|
switch {
|
|
case key.Matches(msg, km.PopupDown):
|
|
p.move(1)
|
|
case key.Matches(msg, km.PopupUp):
|
|
p.move(-1)
|
|
case key.Matches(msg, km.PopupSelect):
|
|
if len(p.options) > 0 && !p.options[p.cursor].header {
|
|
closeIt()
|
|
return p.options[p.cursor].run(m)
|
|
}
|
|
default:
|
|
in, cmd := p.filter.Update(msg)
|
|
p.filter = &in
|
|
p.refilter()
|
|
return cmd
|
|
}
|
|
return nil
|
|
}
|
|
// A menu: each entry's letter picks it (fixed, part of its label),
|
|
// before the moving keys.
|
|
for _, o := range p.options {
|
|
if o.key != "" && o.key == msg.String() {
|
|
m.prompt = nil
|
|
return o.run(m)
|
|
}
|
|
}
|
|
switch {
|
|
case key.Matches(msg, km.Help):
|
|
return m.showKeybindingsFor(keys.Menu)
|
|
case key.Matches(msg, km.MenuDown):
|
|
p.cursor = min(p.cursor+1, len(p.options)-1)
|
|
case key.Matches(msg, km.MenuUp):
|
|
p.cursor = max(p.cursor-1, 0)
|
|
case key.Matches(msg, km.MenuSelect):
|
|
m.prompt = nil
|
|
return p.options[p.cursor].run(m)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// modalView draws the modal as a box sized to its content.
|
|
func (m Model) modalView() string {
|
|
p := m.prompt
|
|
var body []string
|
|
if p.about != nil {
|
|
body = append(body, " "+m.refLabel(*p.about))
|
|
}
|
|
for _, l := range p.lines {
|
|
body = append(body, " "+l)
|
|
}
|
|
if (len(p.lines) > 0 || p.about != nil) && (len(p.options) > 0 || p.input != nil) {
|
|
body = append(body, "")
|
|
}
|
|
km := m.keys
|
|
foot := footer(move(km.MenuUp, km.MenuDown), hint(km.MenuSelect, "select"), hint(km.MenuClose, "dismiss"))
|
|
if p.input != nil {
|
|
body = append(body, " "+p.input.View())
|
|
if p.err != "" {
|
|
body = append(body, "", " "+m.st.Error.Render("✗ "+capitalize(p.err)))
|
|
}
|
|
foot = footer(hint(km.PopupSelect, cmp.Or(p.submitLabel, "open")), hint(km.PopupClose, "cancel"))
|
|
}
|
|
|
|
// Size from all options, not the ones shown or the cursor's, so the
|
|
// popup doesn't change shape while moving or filtering.
|
|
all := p.all
|
|
if all == nil {
|
|
all = p.options
|
|
}
|
|
optText := func(o option) string {
|
|
pre := ""
|
|
if o.mark != "" {
|
|
pre = o.mark + " "
|
|
}
|
|
if o.star != "" {
|
|
mark := " "
|
|
if o.starred {
|
|
mark = m.st.Favourite.Render(star)
|
|
}
|
|
return pre + mark + o.label
|
|
}
|
|
if o.key == "" {
|
|
return o.label
|
|
}
|
|
return m.st.Label.Render(o.key) + " " + o.label
|
|
}
|
|
optW := 0
|
|
for _, o := range all {
|
|
if !o.header {
|
|
w := ansi.StringWidth(optText(o)) + 2 // "▸ " or " "
|
|
if o.note != "" {
|
|
w += 2 + ansi.StringWidth(o.note)
|
|
}
|
|
optW = max(optW, w)
|
|
}
|
|
}
|
|
optW = min(optW, max(0, m.w-8)) // notes give way to the screen
|
|
row := func(o option) string {
|
|
return withNote(m.st, optText(o), o.note, optW-2)
|
|
}
|
|
|
|
const window = 15 // rows of a filtered list
|
|
options, first, rows := p.options, 0, len(p.options)
|
|
if p.filter != nil {
|
|
body = append(body, " "+p.filter.View(), "")
|
|
foot = footer(move(km.PopupUp, km.PopupDown), hint(km.PopupSelect, "jump"), hint(km.PopupClose, "close"))
|
|
rows = min(len(all), window)
|
|
first = max(0, min(p.cursor-window/2, len(options)-window))
|
|
options = options[first:min(len(options), first+window)]
|
|
if len(options) == 0 {
|
|
body = append(body, " "+m.st.Muted.Render("no matches"))
|
|
rows--
|
|
}
|
|
}
|
|
var headers []int // body lines that are section headers, drawn once w is known
|
|
for j, o := range options {
|
|
if o.header {
|
|
if j > 0 {
|
|
body = append(body, "")
|
|
}
|
|
headers = append(headers, len(body))
|
|
body = append(body, o.label)
|
|
continue
|
|
}
|
|
if i := first + j; i == p.cursor {
|
|
sel := m.st.Selected
|
|
if m.termBlurred {
|
|
sel = m.st.SelectedBlurred
|
|
}
|
|
body = append(body, " "+panes.Highlight("▸ "+row(o), sel, optW))
|
|
} else {
|
|
body = append(body, " "+" "+row(o))
|
|
}
|
|
}
|
|
for range rows - len(options) {
|
|
body = append(body, "") // keep the height while filtering
|
|
}
|
|
|
|
if p.footer != "" {
|
|
foot = p.footer
|
|
}
|
|
w := max(ansi.StringWidth(p.title)+6, optW+4, ansi.StringWidth(foot)+6)
|
|
for _, l := range body {
|
|
w = max(w, ansi.StringWidth(l)+3)
|
|
}
|
|
if p.input != nil || p.filter != nil {
|
|
w = max(w, min(m.w-4, 72))
|
|
}
|
|
w = min(w, m.w-2)
|
|
|
|
// Section headers as rules: "─── Secrets pane ─────────".
|
|
for _, i := range headers {
|
|
title := body[i]
|
|
fill := max(1, w-2-2-3-2-ansi.StringWidth(title)) // borders, margins, "───", spaces
|
|
body[i] = " " + m.st.Muted.Render("─── ") + panes.TitleStyle(m.st, !m.termBlurred).Render(title) +
|
|
m.st.Muted.Render(" "+strings.Repeat("─", fill))
|
|
}
|
|
|
|
// The selected option's tip, wrapped; room for the longest one is kept
|
|
// so the popup doesn't change height while moving.
|
|
wrapTip := func(tip string) []string {
|
|
if tip == "" {
|
|
return nil
|
|
}
|
|
return strings.Split(ansi.Wordwrap(tip, w-4, " "), "\n")
|
|
}
|
|
tipRows := 0
|
|
for _, o := range all {
|
|
tipRows = max(tipRows, len(wrapTip(o.tip)))
|
|
}
|
|
if tipRows > 0 {
|
|
var tip []string
|
|
if p.cursor < len(p.options) {
|
|
tip = wrapTip(p.options[p.cursor].tip)
|
|
}
|
|
body = append(body, "")
|
|
for i := range tipRows {
|
|
line := ""
|
|
if i < len(tip) {
|
|
line = " " + m.st.Muted.Render(tip[i])
|
|
}
|
|
body = append(body, line)
|
|
}
|
|
}
|
|
counter := ""
|
|
if p.filter != nil {
|
|
counter = p.counter()
|
|
}
|
|
focused := !m.termBlurred // like the panes, no highlight in an unfocused window
|
|
return panes.BoxCounted(m.st, panes.TitleStyle(m.st, focused).Render(p.title), foot, counter, body, w, len(body)+2, focused)
|
|
}
|
|
|
|
// overlay draws the modal centred over the screen.
|
|
func (m Model) overlay(base string) string {
|
|
var box string
|
|
if m.search != nil {
|
|
box = m.searchViewBox()
|
|
} else {
|
|
box = m.modalView()
|
|
}
|
|
x := max(0, (m.w-lipgloss.Width(box))/2)
|
|
y := max(0, (strings.Count(base, "\n")+1-lipgloss.Height(box))/3)
|
|
return lipgloss.NewCompositor(
|
|
lipgloss.NewLayer(base),
|
|
lipgloss.NewLayer(box).X(x).Y(y).Z(1),
|
|
).Render()
|
|
}
|
|
|
|
// capitalize upper-cases the first letter of a message.
|
|
func capitalize(s string) string {
|
|
r, n := utf8.DecodeRuneInString(s)
|
|
return string(unicode.ToUpper(r)) + s[n:]
|
|
}
|