535 lines
12 KiB
Go
535 lines
12 KiB
Go
package vault
|
|
|
|
import (
|
|
"errors"
|
|
"slices"
|
|
"strings"
|
|
)
|
|
|
|
var ErrEntryNotFound = errors.New("entry not found")
|
|
var ErrGroupNotEmpty = errors.New("group is not empty")
|
|
|
|
type Entry struct {
|
|
ID string
|
|
Title string
|
|
Username string
|
|
Password string
|
|
URL string
|
|
Notes string
|
|
Tags []string
|
|
Fields map[string]string
|
|
Attachments map[string][]byte
|
|
History []Entry
|
|
Path []string
|
|
}
|
|
|
|
type SearchResult struct {
|
|
Entry Entry
|
|
Path string
|
|
}
|
|
|
|
type Model struct {
|
|
Entries []Entry
|
|
Templates []Entry
|
|
RecycleBin []Entry
|
|
Groups [][]string
|
|
}
|
|
|
|
func (m Model) ChildGroups(path []string) []string {
|
|
seen := map[string]bool{}
|
|
var groups []string
|
|
for _, entry := range m.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)
|
|
}
|
|
for _, groupPath := range m.Groups {
|
|
if len(path) > len(groupPath) {
|
|
continue
|
|
}
|
|
if !slices.Equal(groupPath[:len(path)], path) {
|
|
continue
|
|
}
|
|
if len(groupPath) == len(path) {
|
|
continue
|
|
}
|
|
group := groupPath[len(path)]
|
|
if seen[group] {
|
|
continue
|
|
}
|
|
seen[group] = true
|
|
groups = append(groups, group)
|
|
}
|
|
slices.Sort(groups)
|
|
return groups
|
|
}
|
|
|
|
func (m Model) EntriesInPath(path []string) []Entry {
|
|
var entries []Entry
|
|
for _, entry := range m.Entries {
|
|
if slices.Equal(entry.Path, path) {
|
|
entries = append(entries, entry)
|
|
}
|
|
}
|
|
slices.SortFunc(entries, func(a, b Entry) int {
|
|
switch {
|
|
case a.Title < b.Title:
|
|
return -1
|
|
case a.Title > b.Title:
|
|
return 1
|
|
default:
|
|
return 0
|
|
}
|
|
})
|
|
return entries
|
|
}
|
|
|
|
func (m Model) EntriesUnderPath(path []string) []Entry {
|
|
var entries []Entry
|
|
for _, entry := range m.Entries {
|
|
if !hasPathPrefix(entry.Path, path) {
|
|
continue
|
|
}
|
|
entries = append(entries, entry)
|
|
}
|
|
slices.SortFunc(entries, func(a, b Entry) int {
|
|
switch {
|
|
case a.Title < b.Title:
|
|
return -1
|
|
case a.Title > b.Title:
|
|
return 1
|
|
default:
|
|
return 0
|
|
}
|
|
})
|
|
return entries
|
|
}
|
|
|
|
func (m Model) Search(query string) []SearchResult {
|
|
query = strings.TrimSpace(strings.ToLower(query))
|
|
if query == "" {
|
|
return nil
|
|
}
|
|
|
|
var results []SearchResult
|
|
for _, entry := range m.Entries {
|
|
haystack := strings.ToLower(
|
|
entry.Title + " " +
|
|
entry.Username + " " +
|
|
entry.URL + " " +
|
|
strings.Join(entry.Path, " "),
|
|
)
|
|
if !strings.Contains(haystack, query) {
|
|
continue
|
|
}
|
|
results = append(results, SearchResult{
|
|
Entry: entry,
|
|
Path: strings.Join(entry.Path, " / "),
|
|
})
|
|
}
|
|
|
|
slices.SortFunc(results, func(a, b SearchResult) int {
|
|
switch {
|
|
case a.Entry.Title < b.Entry.Title:
|
|
return -1
|
|
case a.Entry.Title > b.Entry.Title:
|
|
return 1
|
|
default:
|
|
return 0
|
|
}
|
|
})
|
|
return results
|
|
}
|
|
|
|
func (m *Model) UpsertEntry(entry Entry) {
|
|
for i := range m.Entries {
|
|
if m.Entries[i].ID != entry.ID {
|
|
continue
|
|
}
|
|
|
|
previous := cloneEntry(m.Entries[i])
|
|
entry.History = append([]Entry{previous}, cloneHistory(m.Entries[i].History)...)
|
|
m.Entries[i] = cloneEntry(entry)
|
|
return
|
|
}
|
|
|
|
m.Entries = append(m.Entries, cloneEntry(entry))
|
|
}
|
|
|
|
func (m *Model) UpsertTemplate(entry Entry) {
|
|
for i := range m.Templates {
|
|
if m.Templates[i].ID != entry.ID {
|
|
continue
|
|
}
|
|
|
|
m.Templates[i] = cloneEntry(entry)
|
|
return
|
|
}
|
|
|
|
m.Templates = append(m.Templates, cloneEntry(entry))
|
|
}
|
|
|
|
func (m *Model) DeleteTemplate(id string) error {
|
|
for i := range m.Templates {
|
|
if m.Templates[i].ID != id {
|
|
continue
|
|
}
|
|
|
|
m.Templates = append(m.Templates[:i], m.Templates[i+1:]...)
|
|
return nil
|
|
}
|
|
|
|
return ErrEntryNotFound
|
|
}
|
|
|
|
func (m *Model) DeleteEntry(id string) error {
|
|
for i := range m.Entries {
|
|
if m.Entries[i].ID != id {
|
|
continue
|
|
}
|
|
|
|
m.RecycleBin = append(m.RecycleBin, cloneEntry(m.Entries[i]))
|
|
m.Entries = append(m.Entries[:i], m.Entries[i+1:]...)
|
|
return nil
|
|
}
|
|
|
|
return ErrEntryNotFound
|
|
}
|
|
|
|
func (m *Model) RestoreEntry(id string) error {
|
|
for i := range m.RecycleBin {
|
|
if m.RecycleBin[i].ID != id {
|
|
continue
|
|
}
|
|
|
|
m.Entries = append(m.Entries, cloneEntry(m.RecycleBin[i]))
|
|
m.RecycleBin = append(m.RecycleBin[:i], m.RecycleBin[i+1:]...)
|
|
return nil
|
|
}
|
|
|
|
return ErrEntryNotFound
|
|
}
|
|
|
|
func (m *Model) InstantiateTemplate(templateID string, overrides Entry) (Entry, error) {
|
|
for i := range m.Templates {
|
|
if m.Templates[i].ID != templateID {
|
|
continue
|
|
}
|
|
|
|
entry := mergeEntryTemplate(m.Templates[i], overrides)
|
|
m.UpsertEntry(entry)
|
|
return cloneEntry(entry), nil
|
|
}
|
|
|
|
return Entry{}, ErrEntryNotFound
|
|
}
|
|
|
|
func (m *Model) DuplicateEntry(id, duplicateID string) (Entry, error) {
|
|
for i := range m.Entries {
|
|
if m.Entries[i].ID != id {
|
|
continue
|
|
}
|
|
|
|
duplicate := cloneEntry(m.Entries[i])
|
|
duplicate.ID = duplicateID
|
|
duplicate.Title = duplicate.Title + " (Copy)"
|
|
duplicate.History = nil
|
|
m.Entries = append(m.Entries, duplicate)
|
|
return cloneEntry(duplicate), nil
|
|
}
|
|
|
|
return Entry{}, ErrEntryNotFound
|
|
}
|
|
|
|
func (m *Model) RestoreEntryVersion(id string, historyIndex int) error {
|
|
for i := range m.Entries {
|
|
if m.Entries[i].ID != id {
|
|
continue
|
|
}
|
|
if historyIndex < 0 || historyIndex >= len(m.Entries[i].History) {
|
|
return ErrEntryNotFound
|
|
}
|
|
|
|
current := cloneEntry(m.Entries[i])
|
|
restored := cloneEntry(m.Entries[i].History[historyIndex])
|
|
restored.ID = current.ID
|
|
restored.History = append([]Entry{current}, append(
|
|
cloneHistory(m.Entries[i].History[:historyIndex]),
|
|
cloneHistory(m.Entries[i].History[historyIndex+1:])...,
|
|
)...)
|
|
m.Entries[i] = restored
|
|
return nil
|
|
}
|
|
|
|
return ErrEntryNotFound
|
|
}
|
|
|
|
func (m *Model) CreateGroup(parent []string, name string) {
|
|
groupPath := append([]string(nil), parent...)
|
|
for _, part := range splitGroupPath(name) {
|
|
groupPath = append(groupPath, part)
|
|
if groupPathExists(m.Groups, groupPath) {
|
|
continue
|
|
}
|
|
m.Groups = append(m.Groups, append([]string(nil), groupPath...))
|
|
}
|
|
}
|
|
|
|
func (m *Model) RenameGroup(path []string, newName string) error {
|
|
if len(path) == 0 {
|
|
return ErrEntryNotFound
|
|
}
|
|
|
|
renamed := false
|
|
newPath := append(append([]string(nil), path[:len(path)-1]...), newName)
|
|
for i := range m.Entries {
|
|
if !hasPathPrefix(m.Entries[i].Path, path) {
|
|
continue
|
|
}
|
|
m.Entries[i].Path = append(append([]string(nil), newPath...), m.Entries[i].Path[len(path):]...)
|
|
renamed = true
|
|
}
|
|
for i := range m.Templates {
|
|
if !hasPathPrefix(m.Templates[i].Path, path) {
|
|
continue
|
|
}
|
|
m.Templates[i].Path = append(append([]string(nil), newPath...), m.Templates[i].Path[len(path):]...)
|
|
renamed = true
|
|
}
|
|
for i := range m.Groups {
|
|
if !hasPathPrefix(m.Groups[i], path) {
|
|
continue
|
|
}
|
|
m.Groups[i] = append(append([]string(nil), newPath...), m.Groups[i][len(path):]...)
|
|
renamed = true
|
|
}
|
|
if !renamed {
|
|
return ErrEntryNotFound
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (m *Model) MoveEntry(id string, path []string) error {
|
|
for i := range m.Entries {
|
|
if m.Entries[i].ID != id {
|
|
continue
|
|
}
|
|
m.Entries[i].Path = append([]string(nil), path...)
|
|
return nil
|
|
}
|
|
return ErrEntryNotFound
|
|
}
|
|
|
|
func (m *Model) MoveGroup(path, parent []string) error {
|
|
if len(path) == 0 {
|
|
return ErrEntryNotFound
|
|
}
|
|
if hasPathPrefix(parent, path) {
|
|
return ErrEntryNotFound
|
|
}
|
|
|
|
groupName := path[len(path)-1]
|
|
newPath := append(append([]string(nil), parent...), groupName)
|
|
moved := false
|
|
for i := range m.Entries {
|
|
if !hasPathPrefix(m.Entries[i].Path, path) {
|
|
continue
|
|
}
|
|
m.Entries[i].Path = append(append([]string(nil), newPath...), m.Entries[i].Path[len(path):]...)
|
|
moved = true
|
|
}
|
|
for i := range m.Templates {
|
|
if !hasPathPrefix(m.Templates[i].Path, path) {
|
|
continue
|
|
}
|
|
m.Templates[i].Path = append(append([]string(nil), newPath...), m.Templates[i].Path[len(path):]...)
|
|
moved = true
|
|
}
|
|
for i := range m.Groups {
|
|
if !hasPathPrefix(m.Groups[i], path) {
|
|
continue
|
|
}
|
|
m.Groups[i] = append(append([]string(nil), newPath...), m.Groups[i][len(path):]...)
|
|
moved = true
|
|
}
|
|
if !moved {
|
|
return ErrEntryNotFound
|
|
}
|
|
if !groupPathExists(m.Groups, newPath) {
|
|
m.Groups = append(m.Groups, append([]string(nil), newPath...))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (m *Model) MoveTemplate(id string, path []string) error {
|
|
for i := range m.Templates {
|
|
if m.Templates[i].ID != id {
|
|
continue
|
|
}
|
|
m.Templates[i].Path = append([]string(nil), path...)
|
|
return nil
|
|
}
|
|
return ErrEntryNotFound
|
|
}
|
|
|
|
func (m *Model) DeleteGroup(path []string) error {
|
|
for _, entry := range m.Entries {
|
|
if slices.Equal(entry.Path, path) || hasPathPrefix(entry.Path, path) {
|
|
return ErrGroupNotEmpty
|
|
}
|
|
}
|
|
for _, entry := range m.Templates {
|
|
if slices.Equal(entry.Path, path) || hasPathPrefix(entry.Path, path) {
|
|
return ErrGroupNotEmpty
|
|
}
|
|
}
|
|
|
|
for i := range m.Groups {
|
|
if slices.Equal(m.Groups[i], path) {
|
|
m.Groups = append(m.Groups[:i], m.Groups[i+1:]...)
|
|
return nil
|
|
}
|
|
}
|
|
return ErrEntryNotFound
|
|
}
|
|
|
|
func hasPathPrefix(path, prefix []string) bool {
|
|
if len(prefix) > len(path) {
|
|
return false
|
|
}
|
|
return slices.Equal(path[:len(prefix)], prefix)
|
|
}
|
|
|
|
func splitGroupPath(name string) []string {
|
|
var parts []string
|
|
for _, part := range strings.Split(name, "/") {
|
|
part = strings.TrimSpace(part)
|
|
if part == "" {
|
|
continue
|
|
}
|
|
parts = append(parts, part)
|
|
}
|
|
return parts
|
|
}
|
|
|
|
func groupPathExists(groups [][]string, path []string) bool {
|
|
for _, existing := range groups {
|
|
if slices.Equal(existing, path) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func mergeEntryTemplate(template, overrides Entry) Entry {
|
|
entry := cloneEntry(template)
|
|
|
|
if overrides.ID != "" {
|
|
entry.ID = overrides.ID
|
|
}
|
|
if overrides.Title != "" {
|
|
entry.Title = overrides.Title
|
|
}
|
|
if overrides.Username != "" {
|
|
entry.Username = overrides.Username
|
|
}
|
|
if overrides.Password != "" {
|
|
entry.Password = overrides.Password
|
|
}
|
|
if overrides.URL != "" {
|
|
entry.URL = overrides.URL
|
|
}
|
|
if overrides.Notes != "" {
|
|
entry.Notes = overrides.Notes
|
|
}
|
|
if len(overrides.Tags) > 0 {
|
|
entry.Tags = slices.Clone(overrides.Tags)
|
|
}
|
|
if len(overrides.Path) > 0 {
|
|
entry.Path = slices.Clone(overrides.Path)
|
|
}
|
|
|
|
entry.Fields = mergeStringMaps(template.Fields, overrides.Fields)
|
|
entry.Attachments = mergeBinaryMaps(template.Attachments, overrides.Attachments)
|
|
entry.History = nil
|
|
|
|
return entry
|
|
}
|
|
|
|
func mergeStringMaps(base, overrides map[string]string) map[string]string {
|
|
if len(base) == 0 && len(overrides) == 0 {
|
|
return nil
|
|
}
|
|
|
|
out := make(map[string]string, len(base)+len(overrides))
|
|
for key, value := range base {
|
|
out[key] = value
|
|
}
|
|
for key, value := range overrides {
|
|
out[key] = value
|
|
}
|
|
|
|
return out
|
|
}
|
|
|
|
func mergeBinaryMaps(base, overrides map[string][]byte) map[string][]byte {
|
|
if len(base) == 0 && len(overrides) == 0 {
|
|
return nil
|
|
}
|
|
|
|
out := make(map[string][]byte, len(base)+len(overrides))
|
|
for key, value := range base {
|
|
out[key] = slices.Clone(value)
|
|
}
|
|
for key, value := range overrides {
|
|
out[key] = slices.Clone(value)
|
|
}
|
|
|
|
return out
|
|
}
|
|
|
|
func cloneEntry(entry Entry) Entry {
|
|
entry.Tags = slices.Clone(entry.Tags)
|
|
entry.Path = slices.Clone(entry.Path)
|
|
entry.History = cloneHistory(entry.History)
|
|
if entry.Fields != nil {
|
|
fields := make(map[string]string, len(entry.Fields))
|
|
for key, value := range entry.Fields {
|
|
fields[key] = value
|
|
}
|
|
entry.Fields = fields
|
|
}
|
|
if entry.Attachments != nil {
|
|
attachments := make(map[string][]byte, len(entry.Attachments))
|
|
for key, value := range entry.Attachments {
|
|
attachments[key] = slices.Clone(value)
|
|
}
|
|
entry.Attachments = attachments
|
|
}
|
|
return entry
|
|
}
|
|
|
|
func cloneHistory(history []Entry) []Entry {
|
|
if len(history) == 0 {
|
|
return nil
|
|
}
|
|
|
|
out := make([]Entry, len(history))
|
|
for i := range history {
|
|
out[i] = cloneEntry(history[i])
|
|
}
|
|
return out
|
|
}
|