Why: collaborative KeePass use needs one arbiter of "who wrote last" that never sees a master password, plus access control that follows an org chart. What: stdlib-only Go server. Metadata is a single JSON document with one writer and atomic commit; KDBX payloads are versioned blobs on disk. Access comes from a group tree where a grant on a group reaches every member below it. Login is password+TOTP, invite links, or OIDC through a browser-approved device flow. Pushes carry If-Match and are arbitrated by a per-database merge strategy (server-wins by default). Updates are pushed to clients over SSE. Proved: go test ./internal/... covers RFC 6238 vectors, ACL inheritance and isolation, store rollback, blob pruning, every push verdict, and an end-to-end HTTP pass over login, conflict, force, read-only grants, invite redemption, TOTP enrolment, device login and the event stream.
137 lines
2.9 KiB
Go
137 lines
2.9 KiB
Go
package store
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
// Store is the single writer of the metadata document. Every mutation runs on
|
|
// a private copy of the state and is swapped in only once it has reached the
|
|
// disk, so a reader never observes a half-applied update and a crash never
|
|
// leaves a partial file.
|
|
type Store struct {
|
|
path string
|
|
mu sync.RWMutex
|
|
s *State
|
|
}
|
|
|
|
// Open loads the state document at path, creating an empty one if absent.
|
|
func Open(path string) (*Store, error) {
|
|
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
|
return nil, err
|
|
}
|
|
st := &Store{path: path, s: newState()}
|
|
b, err := os.ReadFile(path)
|
|
switch {
|
|
case os.IsNotExist(err):
|
|
return st, st.persist(st.s)
|
|
case err != nil:
|
|
return nil, err
|
|
}
|
|
s := newState()
|
|
if err := json.Unmarshal(b, s); err != nil {
|
|
return nil, fmt.Errorf("store: parse %s: %w", path, err)
|
|
}
|
|
s.init()
|
|
st.s = s
|
|
return st, nil
|
|
}
|
|
|
|
// Read runs fn against a consistent view of the state. fn must not retain or
|
|
// mutate anything it is given.
|
|
func (st *Store) Read(fn func(*State)) {
|
|
st.mu.RLock()
|
|
defer st.mu.RUnlock()
|
|
fn(st.s)
|
|
}
|
|
|
|
// Update applies fn to a copy of the state and commits it. If fn or the write
|
|
// fails, the stored state is left untouched.
|
|
func (st *Store) Update(fn func(*State) error) error {
|
|
st.mu.Lock()
|
|
defer st.mu.Unlock()
|
|
next, err := clone(st.s)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := fn(next); err != nil {
|
|
return err
|
|
}
|
|
if err := st.persist(next); err != nil {
|
|
return err
|
|
}
|
|
st.s = next
|
|
return nil
|
|
}
|
|
|
|
// GC drops expired sessions, device logins and invites.
|
|
func (st *Store) GC(now time.Time) error {
|
|
return st.Update(func(s *State) error {
|
|
for k, v := range s.Sessions {
|
|
if now.After(v.ExpiresAt) {
|
|
delete(s.Sessions, k)
|
|
}
|
|
}
|
|
for k, v := range s.Devices {
|
|
if now.After(v.ExpiresAt) {
|
|
delete(s.Devices, k)
|
|
}
|
|
}
|
|
for k, v := range s.Invites {
|
|
if !v.Usable(now) {
|
|
delete(s.Invites, k)
|
|
}
|
|
}
|
|
return nil
|
|
})
|
|
}
|
|
|
|
func (st *Store) persist(s *State) error {
|
|
b, err := json.MarshalIndent(s, "", " ")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return writeFileAtomic(st.path, b, 0o600)
|
|
}
|
|
|
|
func clone(s *State) (*State, error) {
|
|
b, err := json.Marshal(s)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out := newState()
|
|
if err := json.Unmarshal(b, out); err != nil {
|
|
return nil, err
|
|
}
|
|
out.init()
|
|
return out, nil
|
|
}
|
|
|
|
// writeFileAtomic replaces path with data, surviving a crash mid-write.
|
|
func writeFileAtomic(path string, data []byte, mode os.FileMode) error {
|
|
tmp := path + ".tmp"
|
|
f, err := os.OpenFile(tmp, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, mode)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if _, err := f.Write(data); err != nil {
|
|
f.Close()
|
|
os.Remove(tmp)
|
|
return err
|
|
}
|
|
if err := f.Sync(); err != nil {
|
|
f.Close()
|
|
os.Remove(tmp)
|
|
return err
|
|
}
|
|
if err := f.Close(); err != nil {
|
|
os.Remove(tmp)
|
|
return err
|
|
}
|
|
return os.Rename(tmp, path)
|
|
}
|