package appstate import ( "errors" "fmt" "slices" "strings" "time" "git.julianfamily.org/keepassgo/internal/apiapproval" "git.julianfamily.org/keepassgo/internal/apitokens" "git.julianfamily.org/keepassgo/internal/vault" "git.julianfamily.org/keepassgo/internal/webdav" ) type Section string var ( ErrAttachmentAlreadyExists = errors.New("attachment already exists") ErrAttachmentNotFound = errors.New("attachment not found") ) const ( SectionEntries Section = "" SectionTemplates Section = "templates" SectionRecycleBin Section = "recycle-bin" SectionAPITokens Section = "api-tokens" SectionAPIAudit Section = "api-audit" SectionAbout Section = "about" ) type CurrentSession interface { Current() (vault.Model, error) } type MutableSession interface { CurrentSession Replace(vault.Model) } type LockableSession interface { CurrentSession Lock() error Unlock(vault.MasterKey) error } type MasterKeyChangeableSession interface { CurrentSession ChangeMasterKey(vault.MasterKey) error } type SaveableSession interface { CurrentSession Save() error } type SynchronizableSession interface { CurrentSession Synchronize() error } type AdvancedSynchronizableSession interface { CurrentSession SynchronizeFromLocal(string) error SynchronizeFromLocalBytes(string, []byte) error SynchronizeToLocal(string) error SynchronizeFromRemote(webdav.Client, string) error SynchronizeToRemote(webdav.Client, string) error } type CreateableSession interface { CurrentSession Create(vault.Model, vault.MasterKey) error } type OpenableSession interface { CurrentSession Open(string, vault.MasterKey) error } type SaveAsSession interface { CurrentSession SaveAs(string) error } type RemoteOpenableSession interface { CurrentSession OpenRemote(webdav.Client, string, vault.MasterKey) error } type SecurityConfigurableSession interface { ConfigureSecurity(vault.SecuritySettings) error SecuritySettings() vault.SecuritySettings } type ApprovalManager interface { Pending() []apiapproval.Request Resolve(string, apiapproval.Outcome) (apiapproval.Request, *apitokens.PolicyRule, error) } type State struct { Session CurrentSession Approvals ApprovalManager Section Section CurrentPath []string SearchQuery string SelectedEntryID string Dirty bool StatusMessage string ErrorMessage string } func (s *State) PendingApprovals() []apiapproval.Request { if s.Approvals == nil { return nil } return s.Approvals.Pending() } func (s *State) ResolveApproval(id string, outcome apiapproval.Outcome) error { if s.Approvals == nil { return fmt.Errorf("approval manager is not configured") } _, _, err := s.Approvals.Resolve(id, outcome) return err } func (s *State) APITokens() ([]apitokens.Token, error) { model, err := s.currentModel() if err != nil { return nil, err } return apitokens.Entries(model) } func (s *State) RemoteProfiles() ([]vault.RemoteProfile, error) { model, err := s.currentModel() if err != nil { return nil, err } profiles := slices.Clone(model.RemoteProfiles) slices.SortFunc(profiles, func(a, b vault.RemoteProfile) int { switch { case a.Name < b.Name: return -1 case a.Name > b.Name: return 1 case a.ID < b.ID: return -1 case a.ID > b.ID: return 1 default: return 0 } }) return profiles, nil } func (s *State) RemoteCredentialEntries() ([]vault.Entry, error) { model, err := s.currentModel() if err != nil { return nil, err } entries := slices.Clone(model.Entries) slices.SortFunc(entries, func(a, b vault.Entry) int { switch { case a.Title < b.Title: return -1 case a.Title > b.Title: return 1 case a.ID < b.ID: return -1 case a.ID > b.ID: return 1 default: return 0 } }) return entries, nil } func (s *State) IssueAPIToken(name, clientName string, expiresAt *time.Time, now time.Time) (apitokens.Token, string, error) { session, ok := s.Session.(MutableSession) if !ok { return apitokens.Token{}, "", fmt.Errorf("session is not mutable") } model, err := session.Current() if err != nil { return apitokens.Token{}, "", err } token, secret, err := apitokens.Issue(name, clientName, expiresAt, now) if err != nil { return apitokens.Token{}, "", err } apitokens.Upsert(&model, token) session.Replace(model) s.Dirty = true return token, secret, nil } func (s *State) RotateAPIToken(id string, now time.Time) (apitokens.Token, string, error) { session, ok := s.Session.(MutableSession) if !ok { return apitokens.Token{}, "", fmt.Errorf("session is not mutable") } model, err := session.Current() if err != nil { return apitokens.Token{}, "", err } token, err := apitokens.Find(model, id) if err != nil { return apitokens.Token{}, "", err } token, secret, err := apitokens.Rotate(token, now) if err != nil { return apitokens.Token{}, "", err } apitokens.Upsert(&model, token) session.Replace(model) s.Dirty = true return token, secret, nil } func (s *State) UpsertAPIToken(token apitokens.Token) error { session, ok := s.Session.(MutableSession) if !ok { return fmt.Errorf("session is not mutable") } model, err := session.Current() if err != nil { return err } apitokens.Upsert(&model, token) session.Replace(model) s.Dirty = true return nil } func (s *State) DisableAPIToken(id string) error { session, ok := s.Session.(MutableSession) if !ok { return fmt.Errorf("session is not mutable") } model, err := session.Current() if err != nil { return err } token, err := apitokens.Find(model, id) if err != nil { return err } apitokens.Upsert(&model, apitokens.Disable(token)) session.Replace(model) s.Dirty = true return nil } func (s *State) RevokeAPIToken(id string, when time.Time) error { session, ok := s.Session.(MutableSession) if !ok { return fmt.Errorf("session is not mutable") } model, err := session.Current() if err != nil { return err } token, err := apitokens.Find(model, id) if err != nil { return err } apitokens.Upsert(&model, apitokens.Revoke(token, when)) session.Replace(model) s.Dirty = true return nil } func (s *State) DeleteAPIToken(id string) error { session, ok := s.Session.(MutableSession) if !ok { return fmt.Errorf("session is not mutable") } model, err := session.Current() if err != nil { return err } if err := apitokens.Delete(&model, id); err != nil { return err } session.Replace(model) s.Dirty = true return nil } func (s *State) SecuritySettings() (vault.SecuritySettings, error) { security, ok := s.Session.(SecurityConfigurableSession) if !ok { return vault.SecuritySettings{}, fmt.Errorf("session does not expose security settings") } return security.SecuritySettings(), nil } func (s *State) ConfigureSecurity(settings vault.SecuritySettings) error { security, ok := s.Session.(SecurityConfigurableSession) if !ok { return fmt.Errorf("session does not expose security settings") } if err := security.ConfigureSecurity(settings); err != nil { return err } s.Dirty = true return nil } func (s *State) ShowSection(section Section) { s.Section = section s.CurrentPath = nil s.SelectedEntryID = "" } func (s *State) SetSearchQuery(query string) { s.SearchQuery = query } func (s *State) BeginNewEntry() { s.SelectedEntryID = "" s.StatusMessage = "" s.ErrorMessage = "" } func (s *State) SetActionResult(label string, err error) { if err != nil { s.ErrorMessage = err.Error() s.StatusMessage = "" return } s.ErrorMessage = "" s.StatusMessage = label + " complete" } func (s *State) VisibleEntries() ([]vault.Entry, error) { model, err := s.currentModel() if err != nil { return nil, err } entries := s.entriesForSection(model) if strings.TrimSpace(s.SearchQuery) != "" { return filterEntries(entries, s.SearchQuery), nil } if s.Section == SectionEntries { return entriesInPath(model.Entries, s.CurrentPath), nil } if s.Section == SectionRecycleBin || len(s.CurrentPath) == 0 { return entries, nil } return entriesInPath(entries, s.CurrentPath), nil } func (s *State) ChildGroups() ([]string, error) { if strings.TrimSpace(s.SearchQuery) != "" { return nil, nil } model, err := s.currentModel() if err != nil { return nil, err } if s.Section != SectionEntries { if s.Section == SectionTemplates && len(s.CurrentPath) == 0 { return childGroups(s.entriesForSection(model), []string{"Templates"}), nil } return childGroups(s.entriesForSection(model), s.CurrentPath), nil } return model.ChildGroups(s.CurrentPath), nil } func (s *State) SelectVisibleIndex(index int) error { entries, err := s.VisibleEntries() if err != nil { return err } if index < 0 || index >= len(entries) { return fmt.Errorf("visible index %d out of range", index) } s.SelectedEntryID = entries[index].ID return nil } func (s *State) ToggleVisibleIndex(index int) error { entries, err := s.VisibleEntries() if err != nil { return err } if index < 0 || index >= len(entries) { return fmt.Errorf("visible index %d out of range", index) } if s.SelectedEntryID == entries[index].ID { s.SelectedEntryID = "" return nil } s.SelectedEntryID = entries[index].ID return nil } func (s *State) currentModel() (vault.Model, error) { if s.Session == nil { return vault.Model{}, nil } return s.Session.Current() } func (s *State) entriesForSection(model vault.Model) []vault.Entry { switch s.Section { case SectionTemplates: return slices.Clone(model.Templates) case SectionRecycleBin: return slices.Clone(model.RecycleBin) case SectionAPITokens, SectionAPIAudit, SectionAbout: return nil default: return slices.Clone(model.Entries) } } func (s State) SearchPathContext(entry vault.Entry) string { path := slices.Clone(entry.Path) switch s.Section { case SectionTemplates: if len(path) == 0 || path[0] != "Templates" { path = append([]string{"Templates"}, path...) } case SectionRecycleBin: path = append([]string{"Recycle Bin"}, path...) } return strings.Join(path, " / ") } func entriesInPath(entries []vault.Entry, path []string) []vault.Entry { var out []vault.Entry for _, entry := range entries { if slices.Equal(entry.Path, path) { out = append(out, entry) } } slices.SortFunc(out, func(a, b vault.Entry) int { switch { case a.Title < b.Title: return -1 case a.Title > b.Title: return 1 default: return 0 } }) return out } func filterEntries(entries []vault.Entry, query string) []vault.Entry { query = strings.TrimSpace(strings.ToLower(query)) if query == "" { return nil } var out []vault.Entry for _, entry := range entries { haystack := strings.ToLower( entry.Title + " " + entry.Username + " " + entry.URL + " " + strings.Join(entry.Path, " "), ) if !strings.Contains(haystack, query) { continue } out = append(out, entry) } slices.SortFunc(out, func(a, b vault.Entry) int { switch { case a.Title < b.Title: return -1 case a.Title > b.Title: return 1 default: return 0 } }) return out } func childGroups(entries []vault.Entry, path []string) []string { seen := map[string]bool{} var groups []string for _, entry := range entries { if len(path) > len(entry.Path) { continue } if !slices.Equal(entry.Path[:len(path)], path) { continue } if len(entry.Path) == len(path) { continue } group := entry.Path[len(path)] if seen[group] { continue } seen[group] = true groups = append(groups, group) } slices.Sort(groups) return groups } func (s *State) DeleteSelectedEntry() error { session, ok := s.Session.(MutableSession) if !ok { return fmt.Errorf("session is not mutable") } model, err := session.Current() if err != nil { return err } if err := model.DeleteEntry(s.SelectedEntryID); err != nil { return err } session.Replace(model) s.SelectedEntryID = "" s.Dirty = true return nil } func (s *State) RestoreEntry(id string) error { session, ok := s.Session.(MutableSession) if !ok { return fmt.Errorf("session is not mutable") } model, err := session.Current() if err != nil { return err } if err := model.RestoreEntry(id); err != nil { return err } session.Replace(model) s.Dirty = true return nil } func (s *State) UpsertEntry(entry vault.Entry) error { session, ok := s.Session.(MutableSession) if !ok { return fmt.Errorf("session is not mutable") } model, err := session.Current() if err != nil { return err } model.UpsertEntry(entry) session.Replace(model) s.SelectedEntryID = entry.ID s.Dirty = true return nil } func (s *State) UpsertTemplate(entry vault.Entry) error { session, ok := s.Session.(MutableSession) if !ok { return fmt.Errorf("session is not mutable") } model, err := session.Current() if err != nil { return err } model.UpsertTemplate(entry) session.Replace(model) s.SelectedEntryID = entry.ID s.Dirty = true return nil } func (s *State) InstantiateTemplate(templateID string, overrides vault.Entry) (vault.Entry, error) { session, ok := s.Session.(MutableSession) if !ok { return vault.Entry{}, fmt.Errorf("session is not mutable") } model, err := session.Current() if err != nil { return vault.Entry{}, err } entry, err := model.InstantiateTemplate(templateID, overrides) if err != nil { return vault.Entry{}, err } session.Replace(model) s.SelectedEntryID = entry.ID s.Dirty = true return entry, nil } func (s *State) DeleteTemplate(id string) error { session, ok := s.Session.(MutableSession) if !ok { return fmt.Errorf("session is not mutable") } model, err := session.Current() if err != nil { return err } if err := model.DeleteTemplate(id); err != nil { return err } session.Replace(model) if s.SelectedEntryID == id { s.SelectedEntryID = "" } s.Dirty = true return nil } func (s *State) DuplicateSelectedEntry(duplicateID string) (vault.Entry, error) { session, ok := s.Session.(MutableSession) if !ok { return vault.Entry{}, fmt.Errorf("session is not mutable") } model, err := session.Current() if err != nil { return vault.Entry{}, err } duplicate, err := model.DuplicateEntry(s.SelectedEntryID, duplicateID) if err != nil { return vault.Entry{}, err } session.Replace(model) s.SelectedEntryID = duplicate.ID s.Dirty = true return duplicate, nil } func (s *State) RestoreSelectedEntryVersion(historyIndex int) error { session, ok := s.Session.(MutableSession) if !ok { return fmt.Errorf("session is not mutable") } model, err := session.Current() if err != nil { return err } if err := model.RestoreEntryVersion(s.SelectedEntryID, historyIndex); err != nil { return err } session.Replace(model) s.Dirty = true return nil } func (s *State) Lock() error { session, ok := s.Session.(LockableSession) if !ok { return fmt.Errorf("session is not lockable") } if err := session.Lock(); err != nil { return err } s.SelectedEntryID = "" return nil } func (s *State) Unlock(key vault.MasterKey) error { session, ok := s.Session.(LockableSession) if !ok { return fmt.Errorf("session is not lockable") } return session.Unlock(key) } func (s *State) ChangeMasterKey(key vault.MasterKey) error { session, ok := s.Session.(MasterKeyChangeableSession) if !ok { return fmt.Errorf("session does not support master key changes") } if err := session.ChangeMasterKey(key); err != nil { return err } s.Dirty = true return nil } func (s *State) EnterGroup(name string) { s.CurrentPath = append(append([]string(nil), s.CurrentPath...), name) s.SelectedEntryID = "" } func (s *State) NavigateToPath(path []string) { s.CurrentPath = append([]string(nil), path...) s.SelectedEntryID = "" } func (s *State) Save() error { session, ok := s.Session.(SaveableSession) if !ok { return fmt.Errorf("session is not saveable") } if err := session.Save(); err != nil { return err } s.Dirty = false return nil } func (s *State) Synchronize() error { session, ok := s.Session.(SynchronizableSession) if !ok { return fmt.Errorf("session is not synchronizable") } if err := session.Synchronize(); err != nil { return err } s.Dirty = false return nil } func (s *State) SynchronizeFromLocal(path string) error { session, ok := s.Session.(AdvancedSynchronizableSession) if !ok { return fmt.Errorf("session is not advanced-synchronizable") } if err := session.SynchronizeFromLocal(path); err != nil { return err } s.Dirty = false return nil } func (s *State) SynchronizeFromLocalBytes(name string, content []byte) error { session, ok := s.Session.(AdvancedSynchronizableSession) if !ok { return fmt.Errorf("session is not advanced-synchronizable") } if err := session.SynchronizeFromLocalBytes(name, content); err != nil { return err } s.Dirty = false return nil } func (s *State) SynchronizeToLocal(path string) error { session, ok := s.Session.(AdvancedSynchronizableSession) if !ok { return fmt.Errorf("session is not advanced-synchronizable") } if err := session.SynchronizeToLocal(path); err != nil { return err } s.Dirty = true return nil } func (s *State) SynchronizeFromRemote(client webdav.Client, path string) error { session, ok := s.Session.(AdvancedSynchronizableSession) if !ok { return fmt.Errorf("session is not advanced-synchronizable") } if err := session.SynchronizeFromRemote(client, path); err != nil { return err } s.Dirty = false return nil } func (s *State) SynchronizeToRemote(client webdav.Client, path string) error { session, ok := s.Session.(AdvancedSynchronizableSession) if !ok { return fmt.Errorf("session is not advanced-synchronizable") } if err := session.SynchronizeToRemote(client, path); err != nil { return err } s.Dirty = true return nil } func (s *State) CreateVault(key vault.MasterKey) error { session, ok := s.Session.(CreateableSession) if !ok { return fmt.Errorf("session is not createable") } if err := session.Create(vault.Model{}, key); err != nil { return err } s.CurrentPath = nil s.SelectedEntryID = "" s.Dirty = false return nil } func (s *State) OpenVault(path string, key vault.MasterKey) error { session, ok := s.Session.(OpenableSession) if !ok { return fmt.Errorf("session is not openable") } if err := session.Open(path, key); err != nil { return err } s.CurrentPath = nil s.SelectedEntryID = "" s.Dirty = false return nil } func (s *State) SaveAs(path string) error { session, ok := s.Session.(SaveAsSession) if !ok { return fmt.Errorf("session is not save-as capable") } if err := session.SaveAs(path); err != nil { return err } s.Dirty = false return nil } func (s *State) OpenRemoteVault(client webdav.Client, path string, key vault.MasterKey) error { session, ok := s.Session.(RemoteOpenableSession) if !ok { return fmt.Errorf("session is not remote-openable") } if err := session.OpenRemote(client, path, key); err != nil { return err } s.CurrentPath = nil s.SelectedEntryID = "" s.Dirty = false return nil } func (s *State) OpenBoundRemoteVault(binding RemoteBinding, key vault.MasterKey) error { model, err := s.currentModel() if err != nil { return err } resolved, err := binding.Resolve(model) if err != nil { return err } client := webdav.Client{ BaseURL: resolved.Profile.BaseURL, Username: resolved.Credentials.Username, Password: resolved.Credentials.Password, } return s.OpenRemoteVault(client, resolved.Profile.Path, key) } func (s *State) ConfigureRemoteBinding(input RemoteBindingInput) (RemoteBinding, error) { session, ok := s.Session.(MutableSession) if !ok { return RemoteBinding{}, fmt.Errorf("session is not mutable") } model, err := session.Current() if err != nil { return RemoteBinding{}, err } binding, err := ConfigureRemoteBinding(&model, input) if err != nil { return RemoteBinding{}, err } session.Replace(model) s.Dirty = true return binding, nil } func (s *State) RemoveRemoteBinding(binding RemoteBinding) error { session, ok := s.Session.(MutableSession) if !ok { return fmt.Errorf("session is not mutable") } model, err := session.Current() if err != nil { return err } if err := RemoveRemoteBinding(&model, binding); err != nil { return err } session.Replace(model) s.Dirty = true return nil } func (s *State) CreateGroup(name string) error { session, ok := s.Session.(MutableSession) if !ok { return fmt.Errorf("session is not mutable") } model, err := session.Current() if err != nil { return err } model.CreateGroup(s.CurrentPath, name) session.Replace(model) s.Dirty = true return nil } func (s *State) MoveCurrentGroup(parent []string) error { session, ok := s.Session.(MutableSession) if !ok { return fmt.Errorf("session is not mutable") } model, err := session.Current() if err != nil { return err } current := append([]string(nil), s.CurrentPath...) if err := model.MoveGroup(current, parent); err != nil { return err } session.Replace(model) if len(current) > 0 { s.CurrentPath = append(append([]string(nil), parent...), current[len(current)-1]) } s.Dirty = true return nil } func (s *State) RenameCurrentGroup(newName string) error { session, ok := s.Session.(MutableSession) if !ok { return fmt.Errorf("session is not mutable") } model, err := session.Current() if err != nil { return err } if err := model.RenameGroup(s.CurrentPath, newName); err != nil { return err } session.Replace(model) if len(s.CurrentPath) > 0 { s.CurrentPath = append(append([]string(nil), s.CurrentPath[:len(s.CurrentPath)-1]...), newName) } s.Dirty = true return nil } func (s *State) MoveSelectedEntry(path []string) error { session, ok := s.Session.(MutableSession) if !ok { return fmt.Errorf("session is not mutable") } model, err := session.Current() if err != nil { return err } if err := model.MoveEntry(s.SelectedEntryID, path); err != nil { return err } session.Replace(model) s.Dirty = true return nil } func (s *State) DeleteCurrentGroup() error { session, ok := s.Session.(MutableSession) if !ok { return fmt.Errorf("session is not mutable") } model, err := session.Current() if err != nil { return err } if err := model.DeleteGroup(s.CurrentPath); err != nil { return err } session.Replace(model) if len(s.CurrentPath) > 0 { s.CurrentPath = append([]string(nil), s.CurrentPath[:len(s.CurrentPath)-1]...) } s.SelectedEntryID = "" s.Dirty = true return nil } func (s *State) AddAttachmentToSelectedEntry(name string, content []byte) error { session, ok := s.Session.(MutableSession) if !ok { return fmt.Errorf("session is not mutable") } model, err := session.Current() if err != nil { return err } for i := range model.Entries { if model.Entries[i].ID != s.SelectedEntryID { continue } if model.Entries[i].Attachments == nil { model.Entries[i].Attachments = map[string][]byte{} } if _, exists := model.Entries[i].Attachments[name]; exists { return ErrAttachmentAlreadyExists } model.Entries[i].Attachments[name] = append([]byte(nil), content...) session.Replace(model) s.Dirty = true return nil } return vault.ErrEntryNotFound } func (s *State) ReplaceAttachmentOnSelectedEntry(name string, content []byte) error { session, ok := s.Session.(MutableSession) if !ok { return fmt.Errorf("session is not mutable") } model, err := session.Current() if err != nil { return err } for i := range model.Entries { if model.Entries[i].ID != s.SelectedEntryID { continue } if _, exists := model.Entries[i].Attachments[name]; !exists { return ErrAttachmentNotFound } model.Entries[i].Attachments[name] = append([]byte(nil), content...) session.Replace(model) s.Dirty = true return nil } return vault.ErrEntryNotFound } func (s *State) DeleteAttachmentFromSelectedEntry(name string) error { session, ok := s.Session.(MutableSession) if !ok { return fmt.Errorf("session is not mutable") } model, err := session.Current() if err != nil { return err } for i := range model.Entries { if model.Entries[i].ID != s.SelectedEntryID { continue } if _, exists := model.Entries[i].Attachments[name]; !exists { return ErrAttachmentNotFound } delete(model.Entries[i].Attachments, name) if len(model.Entries[i].Attachments) == 0 { model.Entries[i].Attachments = nil } session.Replace(model) s.Dirty = true return nil } return vault.ErrEntryNotFound }