Add client engine and desktop CLI that mirror KDBX files
Why: a KeePass application must see an ordinary .kdbx file. A mirror directory of real files gives that on every desktop platform without FUSE, WinFsp or macFUSE, and without cgo. What: internal/client holds the API wrapper, an SSE reader, and a mirror agent whose entire policy is one pure decide() table: adopt, pull, push, force-push, server-wins, restore or manual. Saves are detected by rescan, remote changes arrive over the event stream, and every overwrite of local bytes leaves a conflict copy. cmd/keeporg drives it: login (password, TOTP or browser/OIDC device flow), run, sync, status, plus the group, grant, database and invite administration. Proved: go test ./internal/client covers the decision table and runs a real server in-process for adopt, follow, server-wins, client-wins, manual, read-only restore, server-side rename and event-driven update. Also smoke tested live: built both binaries, bootstrapped a server, logged in, created a database and adopted a local file.
This commit is contained in:
@@ -0,0 +1,157 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Group is a node of the server's group tree as the client sees it.
|
||||
type Group struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
ParentID string `json:"parent_id"`
|
||||
Admin bool `json:"admin"`
|
||||
Members int `json:"members"`
|
||||
}
|
||||
|
||||
// Invite is a registration link.
|
||||
type Invite struct {
|
||||
Token string `json:"token"`
|
||||
URL string `json:"url"`
|
||||
GroupID string `json:"group_id"`
|
||||
Role string `json:"role"`
|
||||
Uses int `json:"uses"`
|
||||
MaxUses int `json:"max_uses"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
}
|
||||
|
||||
// User is an account as an administrator sees it.
|
||||
type User struct {
|
||||
ID string `json:"id"`
|
||||
Username string `json:"username"`
|
||||
Email string `json:"email"`
|
||||
ServerAdmin bool `json:"server_admin"`
|
||||
TOTPEnabled bool `json:"totp_enabled"`
|
||||
}
|
||||
|
||||
// Identity is the answer to "who am I and where do I belong".
|
||||
type Identity struct {
|
||||
User
|
||||
Groups []struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Role string `json:"role"`
|
||||
} `json:"groups"`
|
||||
}
|
||||
|
||||
func (a *API) Whoami(ctx context.Context) (*Identity, error) {
|
||||
var out Identity
|
||||
return &out, a.do(ctx, http.MethodGet, "/api/v1/auth/whoami", nil, &out)
|
||||
}
|
||||
|
||||
func (a *API) Groups(ctx context.Context) ([]Group, error) {
|
||||
var out struct {
|
||||
Groups []Group `json:"groups"`
|
||||
}
|
||||
err := a.do(ctx, http.MethodGet, "/api/v1/groups", nil, &out)
|
||||
return out.Groups, err
|
||||
}
|
||||
|
||||
func (a *API) CreateGroup(ctx context.Context, name, parentID string) (*Group, error) {
|
||||
var out Group
|
||||
return &out, a.do(ctx, http.MethodPost, "/api/v1/groups",
|
||||
map[string]string{"name": name, "parent_id": parentID}, &out)
|
||||
}
|
||||
|
||||
func (a *API) DeleteGroup(ctx context.Context, id string) error {
|
||||
return a.do(ctx, http.MethodDelete, "/api/v1/groups/"+url.PathEscape(id), nil, nil)
|
||||
}
|
||||
|
||||
func (a *API) SetMember(ctx context.Context, groupID, username, role string) error {
|
||||
return a.do(ctx, http.MethodPost,
|
||||
"/api/v1/groups/"+url.PathEscape(groupID)+"/members",
|
||||
map[string]string{"username": username, "role": role}, nil)
|
||||
}
|
||||
|
||||
func (a *API) RemoveMember(ctx context.Context, groupID, user string) error {
|
||||
return a.do(ctx, http.MethodDelete,
|
||||
"/api/v1/groups/"+url.PathEscape(groupID)+"/members/"+url.PathEscape(user), nil, nil)
|
||||
}
|
||||
|
||||
func (a *API) CreateDatabase(ctx context.Context, name, groupID, strategy string) (*Database, error) {
|
||||
body := map[string]string{"name": name, "owner_group_id": groupID}
|
||||
if strategy != "" {
|
||||
body["merge_strategy"] = strategy
|
||||
}
|
||||
var out Database
|
||||
return &out, a.do(ctx, http.MethodPost, "/api/v1/databases", body, &out)
|
||||
}
|
||||
|
||||
// SetStrategy changes the merge strategy of a database. Only an
|
||||
// administrator of the owning group may do this.
|
||||
func (a *API) SetStrategy(ctx context.Context, dbID, strategy string) (*Database, error) {
|
||||
var out Database
|
||||
return &out, a.do(ctx, http.MethodPatch, "/api/v1/databases/"+url.PathEscape(dbID),
|
||||
map[string]string{"merge_strategy": strategy}, &out)
|
||||
}
|
||||
|
||||
func (a *API) RenameDatabase(ctx context.Context, dbID, name string) (*Database, error) {
|
||||
var out Database
|
||||
return &out, a.do(ctx, http.MethodPatch, "/api/v1/databases/"+url.PathEscape(dbID),
|
||||
map[string]string{"name": name}, &out)
|
||||
}
|
||||
|
||||
func (a *API) DeleteDatabase(ctx context.Context, dbID string) error {
|
||||
return a.do(ctx, http.MethodDelete, "/api/v1/databases/"+url.PathEscape(dbID), nil, nil)
|
||||
}
|
||||
|
||||
func (a *API) SetGrant(ctx context.Context, groupID, dbID, perm string) error {
|
||||
return a.do(ctx, http.MethodPost, "/api/v1/grants",
|
||||
map[string]string{"group_id": groupID, "database_id": dbID, "perm": perm}, nil)
|
||||
}
|
||||
|
||||
func (a *API) DeleteGrant(ctx context.Context, groupID, dbID string) error {
|
||||
q := url.Values{"group_id": {groupID}, "database_id": {dbID}}
|
||||
return a.do(ctx, http.MethodDelete, "/api/v1/grants?"+q.Encode(), nil, nil)
|
||||
}
|
||||
|
||||
func (a *API) CreateInvite(ctx context.Context, groupID, role string, ttlHours, maxUses int) (*Invite, error) {
|
||||
var out Invite
|
||||
return &out, a.do(ctx, http.MethodPost, "/api/v1/invites", map[string]any{
|
||||
"group_id": groupID, "role": role, "ttl_hours": ttlHours, "max_uses": maxUses}, &out)
|
||||
}
|
||||
|
||||
func (a *API) Invites(ctx context.Context) ([]Invite, error) {
|
||||
var out struct {
|
||||
Invites []Invite `json:"invites"`
|
||||
}
|
||||
err := a.do(ctx, http.MethodGet, "/api/v1/invites", nil, &out)
|
||||
return out.Invites, err
|
||||
}
|
||||
|
||||
func (a *API) Users(ctx context.Context) ([]User, error) {
|
||||
var out struct {
|
||||
Users []User `json:"users"`
|
||||
}
|
||||
err := a.do(ctx, http.MethodGet, "/api/v1/users", nil, &out)
|
||||
return out.Users, err
|
||||
}
|
||||
|
||||
// EnrollTOTP starts second-factor enrolment and returns the shared secret
|
||||
// and its otpauth URI.
|
||||
func (a *API) EnrollTOTP(ctx context.Context) (secret, uri string, err error) {
|
||||
var out struct {
|
||||
Secret string `json:"secret"`
|
||||
URI string `json:"uri"`
|
||||
}
|
||||
err = a.do(ctx, http.MethodPost, "/api/v1/auth/totp/enroll", nil, &out)
|
||||
return out.Secret, out.URI, err
|
||||
}
|
||||
|
||||
// ConfirmTOTP completes enrolment by proving the secret was stored.
|
||||
func (a *API) ConfirmTOTP(ctx context.Context, code string) error {
|
||||
return a.do(ctx, http.MethodPost, "/api/v1/auth/totp/confirm",
|
||||
map[string]string{"code": code}, nil)
|
||||
}
|
||||
Reference in New Issue
Block a user