Files
keepassgo/autofillcache/cache.go
T
2026-04-01 05:21:15 -07:00

192 lines
4.0 KiB
Go

package autofillcache
import (
"encoding/json"
"net/url"
"os"
"path/filepath"
"sort"
"strings"
"time"
"git.julianfamily.org/keepassgo/vault"
)
type Entry struct {
ID string `json:"id"`
Title string `json:"title"`
Username string `json:"username"`
Password string `json:"password"`
URL string `json:"url"`
Host string `json:"host"`
Path []string `json:"path,omitempty"`
}
type File struct {
UpdatedAt string `json:"updatedAt"`
Entries []Entry `json:"entries"`
}
func Match(cache File, webURL string) (Entry, bool) {
target := normalizeURL(webURL)
if target.host == "" {
return Entry{}, false
}
exactHost := make([]Entry, 0)
parentHost := make([]Entry, 0)
for _, entry := range cache.Entries {
if entry.Host == target.host {
exactHost = append(exactHost, entry)
continue
}
if entry.Host != "" && strings.HasSuffix(target.host, "."+entry.Host) {
parentHost = append(parentHost, entry)
}
}
if matched, ok := chooseEntry(target, exactHost); ok {
return matched, true
}
return chooseEntry(target, parentHost)
}
func Build(model vault.Model, now time.Time) File {
entries := make([]Entry, 0, len(model.Entries))
for _, item := range model.Entries {
host := normalizeHost(item.URL)
if host == "" {
continue
}
if strings.TrimSpace(item.Username) == "" || strings.TrimSpace(item.Password) == "" {
continue
}
entries = append(entries, Entry{
ID: item.ID,
Title: item.Title,
Username: item.Username,
Password: item.Password,
URL: item.URL,
Host: host,
Path: append([]string(nil), item.Path...),
})
}
return File{
UpdatedAt: now.UTC().Format(time.RFC3339),
Entries: entries,
}
}
func Write(path string, model vault.Model, now time.Time) error {
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return err
}
data, err := json.MarshalIndent(Build(model, now), "", " ")
if err != nil {
return err
}
return os.WriteFile(path, data, 0o600)
}
func Clear(path string) error {
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
return err
}
return nil
}
func normalizeHost(raw string) string {
return normalizeURL(raw).host
}
type normalizedTarget struct {
host string
path string
url string
}
func normalizeURL(raw string) normalizedTarget {
value := strings.TrimSpace(raw)
if value == "" {
return normalizedTarget{}
}
if !strings.Contains(value, "://") {
value = "https://" + value
}
parsed, err := url.Parse(value)
if err != nil {
return normalizedTarget{}
}
host := strings.TrimSpace(parsed.Hostname())
path := cleanPath(parsed.EscapedPath())
return normalizedTarget{
host: strings.ToLower(host),
path: path,
url: strings.ToLower(host) + path,
}
}
func cleanPath(path string) string {
path = strings.TrimSpace(path)
if path == "" || path == "/" {
return "/"
}
path = strings.TrimRight(path, "/")
if path == "" {
return "/"
}
if !strings.HasPrefix(path, "/") {
path = "/" + path
}
return path
}
func chooseEntry(target normalizedTarget, entries []Entry) (Entry, bool) {
switch len(entries) {
case 0:
return Entry{}, false
case 1:
return entries[0], true
}
exact := make([]Entry, 0)
prefix := make([]Entry, 0)
for _, entry := range entries {
entryTarget := normalizeURL(entry.URL)
if entryTarget.host == "" {
continue
}
if entryTarget.url == target.url {
exact = append(exact, entry)
continue
}
if entryTarget.path != "/" && strings.HasPrefix(target.path, entryTarget.path) {
prefix = append(prefix, entry)
}
}
if len(exact) == 1 {
return exact[0], true
}
if len(exact) > 1 {
return Entry{}, false
}
if len(prefix) == 0 {
return Entry{}, false
}
sort.Slice(prefix, func(i, j int) bool {
return len(normalizeURL(prefix[i].URL).path) > len(normalizeURL(prefix[j].URL).path)
})
bestPath := normalizeURL(prefix[0].URL).path
best := make([]Entry, 0, len(prefix))
for _, entry := range prefix {
if normalizeURL(entry.URL).path == bestPath {
best = append(best, entry)
}
}
if len(best) == 1 {
return best[0], true
}
return Entry{}, false
}