944 lines
27 KiB
Go
944 lines
27 KiB
Go
package app
|
|
|
|
import (
|
|
"cmp"
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
"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/ui/panes"
|
|
)
|
|
|
|
// Opening references: a reference (lazysecrets:// link, Key Vault URL, …)
|
|
// is first resolved to a source, then the panes are stepped through to it
|
|
// as each list loads. Copy and reveal actions ask before reading the value.
|
|
|
|
// linksRowID is the Sources row listing vaults opened from links that no
|
|
// account could locate (e.g. no read access to the vault resource).
|
|
const linksRowID = "links"
|
|
|
|
type navStage int
|
|
|
|
const (
|
|
navSource navStage = iota
|
|
navContainer
|
|
navObject
|
|
navVersion
|
|
navAction
|
|
)
|
|
|
|
// nav is an open in progress.
|
|
type nav struct {
|
|
ref ref.Ref
|
|
act linkAction
|
|
// sourceOnly stops at the source (jumping to a starred subscription).
|
|
sourceOnly bool
|
|
|
|
sourceRow string // Sources row to select
|
|
stage navStage
|
|
// quiet gives up without a word: going back to where lazysecrets
|
|
// was left, which may be gone.
|
|
quiet bool
|
|
// link ends on the object's details, as enter on it would: opening
|
|
// a link to it opens it, rather than just finding it in its list.
|
|
link bool
|
|
}
|
|
|
|
type resolvedMsg struct {
|
|
ref ref.Ref
|
|
act linkAction
|
|
sourceRow string
|
|
others []string // other accounts' rows that also found the vault
|
|
choices []choice // several candidates: ask
|
|
linked *provider.Container
|
|
err error
|
|
}
|
|
|
|
type choice struct {
|
|
label string
|
|
sourceRow string
|
|
}
|
|
|
|
// linkAction is what to do with an object once it's opened. Links don't
|
|
// carry it: whoever opens one chooses.
|
|
type linkAction int
|
|
|
|
const (
|
|
actGo linkAction = iota // just go to it
|
|
actCopy // and copy its value
|
|
actShow // and show its value
|
|
)
|
|
|
|
// offerRef asks what to do with a reference: for an object, go to it, or
|
|
// go to it and copy or show its value; for a subscription, vault or scope,
|
|
// just open it.
|
|
// With ask false, vault and scope links open straight away.
|
|
func (m *Model) offerRef(title string, r ref.Ref, ask bool) tea.Cmd {
|
|
if r.IsShare() {
|
|
return m.offerShare(r)
|
|
}
|
|
if r.IsContainer() || r.IsSource() {
|
|
if !ask {
|
|
return m.openRef(r, actGo)
|
|
}
|
|
m.showMenu(title, nil, []option{
|
|
{key: "o", label: "Open it", run: func(m *Model) tea.Cmd { return m.openRef(r, actGo) }},
|
|
})
|
|
m.prompt.about = &r
|
|
return nil
|
|
}
|
|
do := func(a linkAction) func(*Model) tea.Cmd {
|
|
return func(m *Model) tea.Cmd { return m.openRef(r, a) }
|
|
}
|
|
opts := []option{
|
|
{key: "o", label: "Go to it", run: do(actGo)},
|
|
{key: "c", label: "Go to it and copy its value", run: do(actCopy)},
|
|
{key: "r", label: "Go to it and show its value", run: do(actShow)},
|
|
}
|
|
if r.Kind == provider.KindAzure || r.Host != "" { // {{secrets/s/k}} names no workspace
|
|
opts = append(opts, option{key: "y", label: "Copy its value, staying here", run: func(m *Model) tea.Cmd { return m.copyFromRef(r) }})
|
|
}
|
|
m.showMenu(title, nil, opts)
|
|
m.prompt.about = &r
|
|
return nil
|
|
}
|
|
|
|
// copyFromRef copies the value a link points at without going there. The
|
|
// link says enough to read it: the vault and name (Azure, tried with each
|
|
// account, the one that last read it first) or the workspace, scope and
|
|
// key (Databricks).
|
|
func (m *Model) copyFromRef(r ref.Ref) tea.Cmd {
|
|
type try struct {
|
|
p provider.Provider
|
|
o provider.Object
|
|
}
|
|
var tries []try
|
|
key := r
|
|
key.Version = ""
|
|
node := m.store.Node(key.String())
|
|
for _, p := range m.providers {
|
|
if p.Kind() != r.Kind {
|
|
continue
|
|
}
|
|
o := provider.Object{Name: r.Name, Type: cmp.Or(r.Type, provider.TypeSecret), Version: r.Version}
|
|
src := provider.Source{Kind: r.Kind, Provider: p.ID(), ID: node.Source}
|
|
if len(node.Path) > 0 {
|
|
src.Name = node.Path[0]
|
|
}
|
|
if r.Kind != provider.KindAzure { // by host: Databricks workspaces, repos
|
|
o.Type = provider.TypeSecret
|
|
src.ID = ""
|
|
for _, s := range m.sourceResults[p.ID()].sources {
|
|
if sourceMatches(r, s) {
|
|
src = s
|
|
}
|
|
}
|
|
if src.ID == "" {
|
|
continue
|
|
}
|
|
o.Container = provider.Container{Source: src, Name: r.Scope}
|
|
} else {
|
|
o.Container = provider.Container{Source: src, Name: r.ContainerName(), Detail: r.VaultURL()}
|
|
}
|
|
t := try{p, o}
|
|
if p.ID() == node.Account {
|
|
tries = append([]try{t}, tries...)
|
|
} else {
|
|
tries = append(tries, t)
|
|
}
|
|
}
|
|
if len(tries) == 0 {
|
|
m.flash = flash{text: "copy: no account or profile for " + describeRef(r), isErr: true}
|
|
return nil
|
|
}
|
|
ctx, req := m.begin(slotCopy, r.Kind)
|
|
m.flash = flash{text: "copying " + r.Name + "…"}
|
|
return func() tea.Msg {
|
|
var errs []error
|
|
for _, t := range tries {
|
|
v, err := t.p.Value(ctx, t.o)
|
|
if err == nil {
|
|
msg := copyMsg{req: req, name: r.Name, value: v}
|
|
if t.o.Container.Source.ID != "" {
|
|
msg.obj = t.o // where it is is known: count the use
|
|
}
|
|
return msg
|
|
}
|
|
if ctx.Err() != nil {
|
|
return copyMsg{req: req, name: r.Name, err: err}
|
|
}
|
|
errs = append(errs, fmt.Errorf("%s: %w", t.p.Label(), err))
|
|
}
|
|
if len(errs) == 1 {
|
|
return copyMsg{req: req, name: r.Name, err: errors.Unwrap(errs[0])} // one account: no need to name it
|
|
}
|
|
return copyMsg{req: req, name: r.Name, err: errors.Join(errs...)}
|
|
}
|
|
}
|
|
|
|
// openRef starts opening a reference, doing act once there.
|
|
func (m *Model) openRef(r ref.Ref, act linkAction) tea.Cmd {
|
|
m.nav = nil
|
|
if r.IsSource() {
|
|
m.openSource(r)
|
|
return nil
|
|
}
|
|
m.flash = flash{text: "opening " + describeRef(r) + "…"}
|
|
cands := m.providers
|
|
return func() tea.Msg {
|
|
ctx, cancel := context.WithTimeout(context.Background(), defaultResolveTimeout)
|
|
defer cancel()
|
|
msg := resolve(ctx, cands, r)
|
|
msg.act = act
|
|
return msg
|
|
}
|
|
}
|
|
|
|
const defaultResolveTimeout = 30 * time.Second
|
|
|
|
// openSource goes to a subscription or workspace, asking which account
|
|
// when several list it. Sources are loaded already, so it needs no lookup.
|
|
func (m *Model) openSource(r ref.Ref) {
|
|
var choices []choice
|
|
for _, p := range m.providers {
|
|
if p.Kind() != r.Kind {
|
|
continue
|
|
}
|
|
for _, src := range m.sourceResults[p.ID()].sources {
|
|
if sourceMatches(r, src) {
|
|
row := sourceRowKey(p.ID(), src.ID)
|
|
choices = append(choices, choice{label: "Open with " + m.sourceLabel(row), sourceRow: row})
|
|
}
|
|
}
|
|
}
|
|
if row, ok := preferredRow(r, choices); ok {
|
|
choices = []choice{{sourceRow: row}}
|
|
}
|
|
switch len(choices) {
|
|
case 0:
|
|
m.flash = flash{text: "open: " + describeRef(r) + " isn't in your sources", isErr: true}
|
|
case 1:
|
|
m.nav = &nav{ref: r, sourceRow: choices[0].sourceRow, sourceOnly: true}
|
|
default:
|
|
var opts []option
|
|
for i, c := range choices {
|
|
row := c.sourceRow
|
|
opts = append(opts, option{key: fmt.Sprint(i + 1), label: c.label, run: func(m *Model) tea.Cmd {
|
|
m.nav = &nav{ref: r, sourceRow: row, sourceOnly: true}
|
|
return nil
|
|
}})
|
|
}
|
|
m.showMenu("Several accounts can see "+describeRef(r), nil, opts)
|
|
}
|
|
}
|
|
|
|
// refLabel draws what a link points at like the favourites list: the
|
|
// provider's icon and the path in its colour, " platform-prod /
|
|
// kv-app-prod / api-key", with the type and version after it. The
|
|
// subscription or workspace isn't in the link: it comes from what's
|
|
// remembered about the item, or from lists already loaded.
|
|
func (m Model) refLabel(r ref.Ref) string {
|
|
var path []string
|
|
if src := m.refSource(r); src != "" {
|
|
path = append(path, src)
|
|
}
|
|
switch {
|
|
case r.IsSource():
|
|
if len(path) == 0 {
|
|
path = append(path, cmp.Or(r.Subscription, r.Host))
|
|
}
|
|
case r.IsContainer():
|
|
path = append(path, r.ContainerName())
|
|
default:
|
|
path = append(path, r.ContainerName(), r.Name)
|
|
}
|
|
label := m.pathLabel(r.Kind, path, "")
|
|
var extra []string
|
|
if t := strings.ToLower(pluralLabels[r.Type]); t != "" && r.Type != provider.TypeSecret {
|
|
if r.IsContainer() {
|
|
extra = append(extra, t+" tab")
|
|
} else {
|
|
extra = append(extra, strings.TrimSuffix(t, "s")) // "key", "certificate"
|
|
}
|
|
}
|
|
if r.Version != "" {
|
|
extra = append(extra, "version "+r.Version)
|
|
}
|
|
if len(extra) > 0 {
|
|
label += m.st.Muted.Render(" · " + strings.Join(extra, " · "))
|
|
}
|
|
return label
|
|
}
|
|
|
|
// refSource names the subscription or workspace r is in, if known.
|
|
func (m Model) refSource(r ref.Ref) string {
|
|
key := r
|
|
key.Version = ""
|
|
if n := m.store.Node(key.String()); len(n.Path) > 0 {
|
|
return n.Path[0]
|
|
}
|
|
for _, res := range m.sourceResults {
|
|
for _, src := range res.sources {
|
|
if (r.Kind != provider.KindAzure || r.IsSource()) && src.Kind == r.Kind && sourceMatches(r, src) {
|
|
return src.Name
|
|
}
|
|
}
|
|
}
|
|
if r.Kind == provider.KindAzure && !r.IsSource() {
|
|
for _, e := range m.cache.containers {
|
|
for _, c := range e.value {
|
|
if matchesContainer(c, r) {
|
|
return c.Source.Name
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func describeRef(r ref.Ref) string {
|
|
if r.IsSource() {
|
|
switch r.Kind {
|
|
case provider.KindDatabricks:
|
|
return "workspace " + r.Host
|
|
case provider.KindRepo:
|
|
return "repo " + r.Host
|
|
}
|
|
return "subscription " + r.Subscription
|
|
}
|
|
if r.IsContainer() {
|
|
return r.ContainerName()
|
|
}
|
|
return r.Name + " in " + r.ContainerName()
|
|
}
|
|
|
|
// resolve finds the source holding the reference's container.
|
|
func resolve(ctx context.Context, providers []provider.Provider, r ref.Ref) resolvedMsg {
|
|
msg := resolvedMsg{ref: r}
|
|
var errs []error
|
|
switch {
|
|
case r.Kind == provider.KindDatabricks && r.Host == "":
|
|
// {{secrets/scope/key}}: search every workspace for the scope.
|
|
for _, p := range providers {
|
|
if p.Kind() != provider.KindDatabricks {
|
|
continue
|
|
}
|
|
srcs, err := p.Sources(ctx)
|
|
if err != nil {
|
|
errs = append(errs, err)
|
|
continue
|
|
}
|
|
for _, src := range srcs {
|
|
cs, err := p.Containers(ctx, src)
|
|
if err != nil {
|
|
errs = append(errs, fmt.Errorf("%s: %w", src.Name, err))
|
|
continue
|
|
}
|
|
for _, c := range cs {
|
|
if c.Name == r.Scope {
|
|
msg.choices = append(msg.choices, choice{label: src.Name, sourceRow: sourceRowKey(p.ID(), src.ID)})
|
|
}
|
|
}
|
|
}
|
|
}
|
|
switch len(msg.choices) {
|
|
case 0:
|
|
msg.err = errors.Join(append([]error{fmt.Errorf("scope %q isn't in any workspace", r.Scope)}, errs...)...)
|
|
case 1:
|
|
msg.sourceRow, msg.choices = msg.choices[0].sourceRow, nil
|
|
}
|
|
return msg
|
|
}
|
|
|
|
for _, p := range providers {
|
|
loc, ok := p.(provider.Locator)
|
|
if !ok || p.Kind() != r.Kind {
|
|
continue
|
|
}
|
|
id, found, err := loc.Locate(ctx, r.Host)
|
|
if err != nil {
|
|
errs = append(errs, fmt.Errorf("%s: %w", p.Label(), err))
|
|
continue
|
|
}
|
|
if found {
|
|
// Several accounts may see it, with different access: the
|
|
// user picks.
|
|
row := sourceRowKey(p.ID(), id)
|
|
if msg.sourceRow == "" {
|
|
msg.sourceRow = row
|
|
} else {
|
|
msg.others = append(msg.others, row)
|
|
}
|
|
}
|
|
}
|
|
if msg.sourceRow != "" {
|
|
return msg
|
|
}
|
|
if r.Kind == provider.KindAzure {
|
|
// No account can find the vault resource, but one may still read
|
|
// it: open it under "Opened links", where each account is tried.
|
|
msg.linked = &provider.Container{
|
|
Source: provider.Source{Kind: provider.KindAzure, Name: r.Host},
|
|
ID: r.VaultURL(), Name: r.ContainerName(), Detail: r.VaultURL(),
|
|
Types: []provider.ObjectType{provider.TypeSecret, provider.TypeKey, provider.TypeCertificate},
|
|
}
|
|
return msg
|
|
}
|
|
missing := fmt.Errorf("no Databricks profile for workspace %s", r.Host)
|
|
if r.Kind == provider.KindRepo {
|
|
missing = fmt.Errorf("repo %s isn't here: start lazysecrets in it, or list it under providers.repos.paths", r.Host)
|
|
}
|
|
msg.err = errors.Join(append([]error{missing}, errs...)...)
|
|
return msg
|
|
}
|
|
|
|
func (m *Model) handleResolved(msg resolvedMsg) {
|
|
switch {
|
|
case msg.err != nil:
|
|
m.flash = flash{text: "open: " + msg.err.Error(), isErr: true}
|
|
case len(msg.choices) > 1:
|
|
for i := range msg.choices {
|
|
msg.choices[i].label = "Open in " + msg.choices[i].label
|
|
}
|
|
m.chooseSource("Scope "+msg.ref.Scope+" is in several workspaces", msg.ref, msg.act, msg.choices, true)
|
|
case len(msg.others) > 0:
|
|
var choices []choice
|
|
for _, row := range append([]string{msg.sourceRow}, msg.others...) {
|
|
choices = append(choices, choice{label: "Open with " + m.sourceLabel(row), sourceRow: row})
|
|
}
|
|
if row, ok := preferredRow(msg.ref, choices); ok {
|
|
m.startLinkNav(msg.ref, msg.act, row)
|
|
return
|
|
}
|
|
m.chooseSource("Several accounts can see "+msg.ref.ContainerName(), msg.ref, msg.act, choices, true)
|
|
case msg.linked != nil:
|
|
m.addLinked(*msg.linked)
|
|
m.startLinkNav(msg.ref, msg.act, linksRowID)
|
|
default:
|
|
m.startLinkNav(msg.ref, msg.act, msg.sourceRow)
|
|
}
|
|
}
|
|
|
|
// preferredRow is the choice under the account the reference asks for
|
|
// (ref.Ref.Account), if it's one of them.
|
|
func preferredRow(r ref.Ref, choices []choice) (string, bool) {
|
|
for _, c := range choices {
|
|
if r.Account != "" && providerOf(c.sourceRow) == r.Account {
|
|
return c.sourceRow, true
|
|
}
|
|
}
|
|
return "", false
|
|
}
|
|
|
|
// startLinkNav opens a link: like startNav, ending on its object's
|
|
// details.
|
|
func (m *Model) startLinkNav(r ref.Ref, act linkAction, sourceRow string) {
|
|
m.startNav(r, act, sourceRow)
|
|
m.nav.link = true
|
|
}
|
|
|
|
// chooseSource asks where to open r.
|
|
func (m *Model) chooseSource(title string, r ref.Ref, act linkAction, choices []choice, link bool) {
|
|
var opts []option
|
|
for i, c := range choices {
|
|
key := ""
|
|
if i < 9 {
|
|
key = fmt.Sprint(i + 1)
|
|
}
|
|
row := c.sourceRow
|
|
opts = append(opts, option{key: key, label: c.label, run: func(m *Model) tea.Cmd {
|
|
m.startNav(r, act, row)
|
|
m.nav.link = link
|
|
return nil
|
|
}})
|
|
}
|
|
m.showMenu(title, nil, opts)
|
|
}
|
|
|
|
// sourceLabel names a Sources row as "Azure · admin / app-dev".
|
|
func (m Model) sourceLabel(row string) string {
|
|
providerID, sourceID := splitSourceRow(row)
|
|
label := providerID
|
|
if p, ok := m.byID[providerID]; ok {
|
|
label = p.Label()
|
|
}
|
|
for _, s := range m.sourceResults[providerID].sources {
|
|
if s.ID == sourceID {
|
|
return label + " / " + s.Name
|
|
}
|
|
}
|
|
return label
|
|
}
|
|
|
|
func (m *Model) startNav(r ref.Ref, act linkAction, sourceRow string) {
|
|
m.nav = &nav{ref: r, act: act, sourceRow: sourceRow}
|
|
m.versionsOf = nil
|
|
m.flash = flash{}
|
|
}
|
|
|
|
// addLinked lists a vault under "Opened links" for this session.
|
|
func (m *Model) addLinked(c provider.Container) {
|
|
for _, l := range m.linked {
|
|
if l.ID == c.ID {
|
|
return
|
|
}
|
|
}
|
|
m.linked = append(m.linked, c)
|
|
m.rebuildSources()
|
|
}
|
|
|
|
func (m *Model) navFail(format string, a ...any) {
|
|
if !m.nav.quiet {
|
|
m.flash = flash{text: "open: " + fmt.Sprintf(format, a...), isErr: true}
|
|
}
|
|
m.nav = nil
|
|
}
|
|
|
|
// advanceNav moves an open along as far as the loaded lists allow. It
|
|
// reports whether it got further, so the caller can sync and try again.
|
|
func (m *Model) advanceNav() bool {
|
|
n := m.nav
|
|
if n == nil {
|
|
return false
|
|
}
|
|
r := n.ref
|
|
switch n.stage {
|
|
case navSource:
|
|
if !m.sources.Select(n.sourceRow) {
|
|
group, _ := splitSourceRow(n.sourceRow)
|
|
if m.collapsed[group] {
|
|
m.collapsed[group] = false
|
|
m.rebuildSources()
|
|
return true
|
|
}
|
|
if m.sourcesPending > 0 {
|
|
return false // still loading
|
|
}
|
|
m.navFail("%s isn't in the Sources list (hidden in the config?)", describeRef(r))
|
|
return false
|
|
}
|
|
m.sourcesTouched = true
|
|
m.focus = paneContainers
|
|
if n.sourceOnly {
|
|
m.nav = nil
|
|
return true
|
|
}
|
|
n.stage = navContainer
|
|
return true
|
|
|
|
case navContainer:
|
|
if m.selSource != n.sourceRow || m.containers.Loading {
|
|
return false
|
|
}
|
|
if !m.containers.SelectWhere(func(c provider.Container) bool { return matchesContainer(c, r) }) {
|
|
m.navFail("%s not found", r.ContainerName())
|
|
return false
|
|
}
|
|
if r.Type != "" {
|
|
m.wantType = r.Type
|
|
}
|
|
if !r.IsContainer() {
|
|
m.focus = paneSecrets
|
|
}
|
|
n.stage = navObject
|
|
return true
|
|
|
|
case navObject:
|
|
c, _ := m.containers.Selected()
|
|
if (r.Type != "" && m.objType != r.Type) || m.secrets.Loading ||
|
|
!strings.HasPrefix(m.selContainer, containerKey(c)+"|") {
|
|
return false
|
|
}
|
|
if m.secrets.Err != nil {
|
|
m.nav = nil // the pane shows the error
|
|
return false
|
|
}
|
|
// Reached it, and its account can read it: remember that.
|
|
m.refreshContext(storeKey(c), c.Name, []string{c.Source.Name}, c.Source)
|
|
if r.IsContainer() {
|
|
m.nav = nil
|
|
return true
|
|
}
|
|
if !m.secrets.SelectWhere(func(o provider.Object) bool { return o.Name == r.Name }) {
|
|
m.navFail("%s not found in %s", r.Name, r.ContainerName())
|
|
return false
|
|
}
|
|
if o, ok := m.secrets.Selected(); ok {
|
|
m.refreshContext(objectKey(o), o.Name, []string{c.Source.Name, c.Name}, c.Source)
|
|
}
|
|
if r.Version != "" {
|
|
o, _ := m.secrets.Selected()
|
|
m.versionsOf = &o
|
|
n.stage = navVersion
|
|
return true
|
|
}
|
|
n.stage = navAction
|
|
return true
|
|
|
|
case navVersion:
|
|
if m.versionsOf == nil || m.secrets.Loading || m.secrets.Err != nil {
|
|
if m.secrets.Err != nil {
|
|
m.nav = nil
|
|
}
|
|
return false
|
|
}
|
|
if !m.secrets.SelectWhere(func(o provider.Object) bool { return o.Version == r.Version }) {
|
|
m.flash = flash{text: "version " + r.Version + " not found; showing all versions", isErr: true}
|
|
}
|
|
n.stage = navAction
|
|
return true
|
|
|
|
case navAction:
|
|
// The user chose this when opening the link; no need to ask again.
|
|
m.nav = nil
|
|
switch n.act {
|
|
case actCopy:
|
|
m.pendingCmd = m.copyValue()
|
|
case actShow:
|
|
if m.detail.State() != panes.ValueShown {
|
|
m.pendingCmd = m.toggleReveal()
|
|
}
|
|
}
|
|
if n.link {
|
|
m.focus = paneDetail // after: copying acts on the focused pane
|
|
}
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
// matchesContainer compares by vault URL (Azure), or scope or file name.
|
|
func matchesContainer(c provider.Container, r ref.Ref) bool {
|
|
if r.Kind != provider.KindAzure {
|
|
return c.Name == r.Scope
|
|
}
|
|
return strings.EqualFold(strings.TrimSuffix(c.Detail, "/")+"/", r.VaultURL())
|
|
}
|
|
|
|
// share copies a link to the focused item. With menu, it offers the
|
|
// other things worth sharing about it.
|
|
func (m *Model) share(menu bool) tea.Cmd {
|
|
switch m.focus {
|
|
case paneSources:
|
|
s, ok := m.selectedSource()
|
|
if !ok {
|
|
m.flash = warning("select a subscription, workspace, vault or secret to share")
|
|
return nil
|
|
}
|
|
if !menu {
|
|
return m.shareSource(s, "o")
|
|
}
|
|
share := func(k string) func(*Model) tea.Cmd {
|
|
return func(m *Model) tea.Cmd { return m.shareSource(s, k) }
|
|
}
|
|
opts := []option{{key: "o", label: "Lazysecrets link", run: share("o")}}
|
|
if s.URL != "" {
|
|
opts = append(opts, option{key: "b", label: webLabel(s.Kind), run: share("b")})
|
|
}
|
|
if m.canShareFiles(s) {
|
|
opts = append(opts, option{key: "f", label: "Encrypted link to its env files", run: func(m *Model) tea.Cmd { return m.shareFiles(s, nil) }})
|
|
}
|
|
m.showMenu("Share "+s.Name, nil, opts)
|
|
case paneContainers:
|
|
c, ok := m.containers.Selected()
|
|
if !ok {
|
|
return nil
|
|
}
|
|
if !menu {
|
|
return m.shareContainer(c, "o")
|
|
}
|
|
share := func(k string) func(*Model) tea.Cmd {
|
|
return func(m *Model) tea.Cmd { return m.shareContainer(c, k) }
|
|
}
|
|
opts := []option{option{key: "o", label: "Lazysecrets link", run: share("o")}}
|
|
if c.Source.Kind == provider.KindAzure && m.objType != provider.TypeSecret {
|
|
opts = append(opts, option{key: "t", label: "Lazysecrets link to the " + tabLabels[m.objType] + " tab", run: share("t")})
|
|
}
|
|
if c.Source.Kind == provider.KindAzure {
|
|
opts = append(opts, option{key: "u", label: "Vault URL", run: share("u")})
|
|
}
|
|
if c.URL != "" {
|
|
opts = append(opts, option{key: "b", label: webLabel(c.Source.Kind), run: share("b")})
|
|
}
|
|
if c.Detail == provider.DetailPlainEnv && m.canShareFiles(c.Source) {
|
|
opts = append(opts, option{key: "f", label: "Encrypted link to the file", run: func(m *Model) tea.Cmd {
|
|
return m.shareFiles(c.Source, []provider.Container{c})
|
|
}})
|
|
}
|
|
m.showMenu("Share "+c.Name, nil, opts)
|
|
default:
|
|
o, ok := m.detail.Secret()
|
|
if !ok {
|
|
return nil
|
|
}
|
|
if !menu {
|
|
return m.shareObject(o, "o")
|
|
}
|
|
share := func(k string) func(*Model) tea.Cmd {
|
|
return func(m *Model) tea.Cmd { return m.shareObject(o, k) }
|
|
}
|
|
opts := []option{
|
|
option{key: "o", label: "Lazysecrets link", run: share("o")},
|
|
}
|
|
if o.Container.Source.Kind == provider.KindAzure {
|
|
opts = append(opts, option{key: "u", label: "Key Vault URL", run: share("u")})
|
|
}
|
|
if o.Reference != "" {
|
|
opts = append(opts, option{key: "y", label: "Config reference", run: share("y")})
|
|
}
|
|
if o.URL != "" {
|
|
opts = append(opts, option{key: "b", label: webLabel(o.Container.Source.Kind), run: share("b")})
|
|
}
|
|
if c := o.Container; c.Detail == provider.DetailPlainEnv && m.canShareFiles(c.Source) {
|
|
opts = append(opts, option{key: "f", label: "Encrypted link to its file", run: func(m *Model) tea.Cmd {
|
|
return m.shareFiles(c.Source, []provider.Container{c})
|
|
}})
|
|
}
|
|
m.showMenu("Share "+o.Name, nil, opts)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// webLabel names the web page o (open in browser) opens.
|
|
func webLabel(kind provider.Kind) string {
|
|
switch kind {
|
|
case provider.KindDatabricks:
|
|
return "Web page (Databricks workspace)"
|
|
case provider.KindRepo:
|
|
return "Web page (repository)"
|
|
}
|
|
return "Web page (Azure portal)"
|
|
}
|
|
|
|
// shareSource copies what the share menu's key k stands for, for a
|
|
// subscription or workspace.
|
|
func (m *Model) shareSource(s provider.Source, k string) tea.Cmd {
|
|
m.usedSource(s)
|
|
if k == "b" {
|
|
if s.URL == "" {
|
|
return nil
|
|
}
|
|
return m.copyText(s.Name+" web page", s.URL, false)
|
|
}
|
|
r, err := ref.ForSource(s)
|
|
if err != nil {
|
|
m.flash = flash{text: "share: " + err.Error(), isErr: true}
|
|
return nil
|
|
}
|
|
return m.copyText("link to "+s.Name, r.String(), false)
|
|
}
|
|
|
|
// shareContainer copies what the share menu's key k stands for, for a
|
|
// vault or scope.
|
|
func (m *Model) shareContainer(c provider.Container, k string) tea.Cmd {
|
|
m.usedContainer(c)
|
|
tab := provider.TypeSecret
|
|
switch k {
|
|
case "b":
|
|
if c.URL == "" {
|
|
return nil
|
|
}
|
|
return m.copyText(c.Name+" web page", c.URL, false)
|
|
case "t":
|
|
tab = m.objType
|
|
case "o", "u":
|
|
default:
|
|
return nil
|
|
}
|
|
r, err := ref.ForContainer(c, tab)
|
|
if err != nil {
|
|
m.flash = flash{text: "share: " + err.Error(), isErr: true}
|
|
return nil
|
|
}
|
|
if k == "u" {
|
|
if u := r.IdentifierURL(); u != "" {
|
|
return m.copyText(c.Name+" URL", u, false)
|
|
}
|
|
return nil
|
|
}
|
|
return m.copyText("link to "+c.Name, r.String(), false)
|
|
}
|
|
|
|
// shareObject copies what the share menu's key k stands for.
|
|
func (m *Model) shareObject(o provider.Object, k string) tea.Cmd {
|
|
m.usedObject(o)
|
|
if k == "b" {
|
|
if o.URL == "" {
|
|
return nil
|
|
}
|
|
return m.copyText(o.Name+" web page", o.URL, false)
|
|
}
|
|
if k == "y" {
|
|
if o.Reference == "" {
|
|
return nil
|
|
}
|
|
return m.copyText(o.Name+" reference", o.Reference, false)
|
|
}
|
|
version := ""
|
|
if m.versionsOf != nil {
|
|
version = o.Version // shared from the versions view: that version
|
|
}
|
|
r, err := ref.ForObject(o, version)
|
|
if err != nil {
|
|
m.flash = flash{text: "share: " + err.Error(), isErr: true}
|
|
return nil
|
|
}
|
|
switch k {
|
|
case "u":
|
|
if u := r.IdentifierURL(); u != "" {
|
|
return m.copyText(o.Name+" URL", u, false)
|
|
}
|
|
return nil
|
|
}
|
|
return m.copyText("link to "+o.Name, r.String(), false)
|
|
}
|
|
|
|
func (m *Model) startOpenPrompt() tea.Cmd {
|
|
in := textinput.New()
|
|
in.Prompt = "open: "
|
|
in.Placeholder = "lazysecrets:// link, Key Vault URL or reference"
|
|
in.SetWidth(max(20, min(m.w-12, 64)))
|
|
m.prompt = &modal{
|
|
title: "Open a link", input: &in,
|
|
lines: []string{m.st.Muted.Render("lazysecrets:// link, Key Vault URL or reference")},
|
|
submit: func(m *Model, text string) tea.Cmd { return m.openText(text) },
|
|
}
|
|
// Pre-fill from the clipboard, read now: it may hold any reference,
|
|
// including ones not offered in the footer (our own, dismissed ones).
|
|
clip := m.clip
|
|
readClip := func() tea.Msg {
|
|
text, err := clip.Read()
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
text = strings.TrimSpace(text)
|
|
if _, err := ref.Parse(text); err != nil || strings.ContainsAny(text, "\n") {
|
|
return nil // not a reference: don't keep it
|
|
}
|
|
return openPromptFillMsg(text)
|
|
}
|
|
return tea.Batch(m.prompt.input.Focus(), readClip)
|
|
}
|
|
|
|
type openPromptFillMsg string
|
|
|
|
// fillOpenPrompt puts a reference from the clipboard in the open prompt,
|
|
// unless the user has started typing.
|
|
func (m *Model) fillOpenPrompt(text string) {
|
|
if p := m.prompt; p != nil && p.input != nil && p.input.Value() == "" {
|
|
p.input.SetValue(text)
|
|
p.input.CursorEnd()
|
|
}
|
|
}
|
|
|
|
// openText parses a pasted or typed reference and asks what to do with it.
|
|
func (m *Model) openText(s string) tea.Cmd {
|
|
r, err := ref.Parse(s)
|
|
if err != nil {
|
|
m.flash = flash{text: "open: " + err.Error(), isErr: true}
|
|
return nil
|
|
}
|
|
return m.offerRef("Open link", r, false)
|
|
}
|
|
|
|
// handlePaste opens a pasted reference, unless a text input has focus.
|
|
func (m *Model) handlePaste(msg tea.PasteMsg) tea.Cmd {
|
|
if m.search != nil && m.search.picker != nil {
|
|
return m.pickPasted(msg.Content) // a reference to the secret
|
|
}
|
|
if p := m.prompt; p != nil && p.input != nil {
|
|
in, cmd := p.input.Update(msg)
|
|
p.input = &in
|
|
return cmd
|
|
}
|
|
if l := m.focusedFilter(); l != nil && l.Filtering() {
|
|
return l.UpdateFilter(msg)
|
|
}
|
|
if m.prompt != nil {
|
|
return nil
|
|
}
|
|
return m.openText(msg.Content)
|
|
}
|
|
|
|
type clipboardTextMsg string
|
|
|
|
// clipCheckEvery throttles the clipboard check on key presses, which
|
|
// covers terminals that don't report focus.
|
|
const clipCheckEvery = 3 * time.Second
|
|
|
|
// maybeCheckClipboard checks the clipboard if it hasn't been for a while.
|
|
func (m *Model) maybeCheckClipboard() tea.Cmd {
|
|
if now := m.now(); now.Sub(m.clipChecked) >= clipCheckEvery {
|
|
m.clipChecked = now
|
|
return m.checkClipboard()
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// checkClipboard reads the clipboard in the background to look for a
|
|
// lazysecrets:// link. Only such links are kept; anything else (it may well
|
|
// be a password) is dropped as soon as it has been looked at.
|
|
func (m Model) checkClipboard() tea.Cmd {
|
|
clip := m.clip
|
|
return func() tea.Msg {
|
|
text, err := clip.Read()
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
text = strings.TrimSpace(text)
|
|
if !isLink(text) {
|
|
return clipboardTextMsg("")
|
|
}
|
|
return clipboardTextMsg(text)
|
|
}
|
|
}
|
|
|
|
// isLink reports whether s looks like a lazysecrets:// link, valid or not.
|
|
func isLink(s string) bool {
|
|
return strings.HasPrefix(strings.ToLower(s), ref.Scheme+"://")
|
|
}
|
|
|
|
// offerClipboardLink pops up a link found on the clipboard, once: whatever
|
|
// the user picks, that link isn't offered again. It isn't offered while
|
|
// another popup is open, or if it points at what's selected already (e.g.
|
|
// just shared with s). A lazysecrets:// link that doesn't parse is
|
|
// reported, so a typo doesn't fail silently.
|
|
func (m *Model) offerClipboardLink(text string) {
|
|
if text == "" || text == m.dismissedLink || m.prompt != nil || m.search != nil {
|
|
return
|
|
}
|
|
r, err := ref.Parse(text)
|
|
if err != nil {
|
|
m.dismissedLink = text
|
|
m.showMenu("Link on clipboard can't be read", []string{m.st.Error.Render(err.Error())}, nil)
|
|
return
|
|
}
|
|
if m.isSelected(r) {
|
|
return
|
|
}
|
|
m.dismissedLink = text
|
|
m.offerRef("Link on clipboard", r, true)
|
|
}
|
|
|
|
// isSelected reports whether r points at the selected container, or the
|
|
// selected object in it.
|
|
func (m Model) isSelected(r ref.Ref) bool {
|
|
c, ok := m.containers.Selected()
|
|
if !ok || c.Source.Kind != r.Kind || !matchesContainer(c, r) {
|
|
return false
|
|
}
|
|
if r.IsContainer() {
|
|
return true
|
|
}
|
|
o, ok := m.detail.Secret()
|
|
return ok && o.Name == r.Name && (r.Type == "" || o.Type == r.Type) &&
|
|
(r.Version == "" || o.Version == r.Version)
|
|
}
|