Files
keeporg/internal/client/api.go
T
root 4d0d53ff04 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.
2026-10-29 16:01:35 +01:00

331 lines
9.1 KiB
Go

// Package client is the shared client engine: one API wrapper and one
// synchronization agent, used by the desktop daemon and the admin CLI.
package client
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"time"
)
// API talks to one KeepOrg server as one user.
type API struct {
BaseURL string
Token string
HTTP *http.Client
}
// NewAPI returns a client for a server base URL.
func NewAPI(baseURL, token string) *API {
return &API{
BaseURL: strings.TrimRight(baseURL, "/"),
Token: token,
HTTP: &http.Client{Timeout: 60 * time.Second},
}
}
// Error is a structured failure returned by the server.
type Error struct {
Status int `json:"-"`
Code string `json:"code"`
Message string `json:"error"`
// Conflict detail, present when Code is "conflict".
CurrentVersion int64 `json:"current_version"`
MergeStrategy string `json:"merge_strategy"`
}
func (e *Error) Error() string {
if e.Message == "" {
return fmt.Sprintf("server returned %d", e.Status)
}
return e.Message
}
// IsConflict reports whether err is a rejected push.
func IsConflict(err error) bool {
var e *Error
return errors.As(err, &e) && (e.Code == "conflict" || e.Code == "force_not_allowed")
}
// Code returns the server's error code for err, or "".
func Code(err error) string {
var e *Error
if errors.As(err, &e) {
return e.Code
}
return ""
}
// Database is one database as the client sees it.
type Database struct {
ID string `json:"id"`
Name string `json:"name"`
OwnerGroupID string `json:"owner_group_id"`
Perm string `json:"perm"`
MergeStrategy string `json:"merge_strategy"`
Version int64 `json:"version"`
SHA256 string `json:"sha256"`
Size int64 `json:"size"`
UpdatedAt time.Time `json:"updated_at"`
UpdatedBy string `json:"updated_by"`
}
// CanWrite reports whether the user may push to this database.
func (d Database) CanWrite() bool { return d.Perm == "write" || d.Perm == "admin" }
// Session is the result of a successful login.
type Session struct {
Token string `json:"token"`
ExpiresAt time.Time `json:"expires_at"`
User struct {
ID string `json:"id"`
Username string `json:"username"`
ServerAdmin bool `json:"server_admin"`
TOTPEnabled bool `json:"totp_enabled"`
} `json:"user"`
}
// DeviceStart is a pending browser-approved login.
type DeviceStart struct {
DeviceCode string `json:"device_code"`
UserCode string `json:"user_code"`
VerificationURL string `json:"verification_url"`
Interval int `json:"interval"`
ExpiresIn int `json:"expires_in"`
}
// PushResult reports what the server did with an accepted push.
type PushResult struct {
Version int64 `json:"version"`
SHA256 string `json:"sha256"`
Unchanged bool `json:"unchanged"`
}
// Login exchanges a password and optional second factor for a session.
func (a *API) Login(ctx context.Context, user, password, totp, label string) (*Session, error) {
var out Session
err := a.do(ctx, http.MethodPost, "/api/v1/auth/login", map[string]string{
"username": user, "password": password, "totp": totp, "label": label}, &out)
if err != nil {
return nil, err
}
return &out, nil
}
// DeviceStart opens a browser-approved login, used for OIDC and for devices
// without a comfortable keyboard.
func (a *API) DeviceStart(ctx context.Context, label string) (*DeviceStart, error) {
var out DeviceStart
if err := a.do(ctx, http.MethodPost, "/api/v1/auth/device/start",
map[string]string{"label": label}, &out); err != nil {
return nil, err
}
return &out, nil
}
// DevicePoll checks whether a browser has approved the login yet. It returns
// a nil session while the request is still pending.
func (a *API) DevicePoll(ctx context.Context, deviceCode string) (*Session, error) {
var out Session
err := a.do(ctx, http.MethodPost, "/api/v1/auth/device/poll",
map[string]string{"device_code": deviceCode}, &out)
var e *Error
switch {
case errors.As(err, &e) && e.Status == http.StatusAccepted:
return nil, nil
case err != nil:
return nil, err
}
return &out, nil
}
// Logout revokes this client's session.
func (a *API) Logout(ctx context.Context) error {
return a.do(ctx, http.MethodPost, "/api/v1/auth/logout", nil, nil)
}
// Databases lists everything assigned to the user.
func (a *API) Databases(ctx context.Context) ([]Database, error) {
var out struct {
Databases []Database `json:"databases"`
}
if err := a.do(ctx, http.MethodGet, "/api/v1/databases", nil, &out); err != nil {
return nil, err
}
return out.Databases, nil
}
// Pull downloads the current bytes of a database along with their version.
// A database that has never been written returns no bytes and version 0.
func (a *API) Pull(ctx context.Context, dbID string) ([]byte, int64, error) {
req, err := a.request(ctx, http.MethodGet, "/api/v1/databases/"+url.PathEscape(dbID)+"/blob", nil)
if err != nil {
return nil, 0, err
}
resp, err := a.HTTP.Do(req)
if err != nil {
return nil, 0, err
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusNoContent {
return nil, 0, nil
}
if resp.StatusCode != http.StatusOK {
return nil, 0, decodeError(resp)
}
data, err := io.ReadAll(resp.Body)
if err != nil {
return nil, 0, err
}
v, _ := strconv.ParseInt(strings.Trim(resp.Header.Get("ETag"), `"`), 10, 64)
return data, v, nil
}
// Push uploads new bytes. baseVersion is the version the edit was based on;
// force is only accepted by a client-wins database.
func (a *API) Push(ctx context.Context, dbID string, baseVersion int64, data []byte, force bool) (*PushResult, error) {
req, err := a.request(ctx, http.MethodPut,
"/api/v1/databases/"+url.PathEscape(dbID)+"/blob", bytes.NewReader(data))
if err != nil {
return nil, err
}
if force {
req.Header.Set("If-Match", "*")
} else {
req.Header.Set("If-Match", strconv.FormatInt(baseVersion, 10))
}
req.Header.Set("Content-Type", "application/octet-stream")
resp, err := a.HTTP.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, decodeError(resp)
}
var out PushResult
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
return nil, err
}
return &out, nil
}
// Event is a push notification from the server.
type Event struct {
Type string `json:"type"`
DatabaseID string `json:"database_id"`
Name string `json:"name"`
Version int64 `json:"version"`
SHA256 string `json:"sha256"`
By string `json:"by"`
}
// Events streams server notifications until ctx is done or the stream breaks.
// The caller reconnects; losing events is safe because every notification
// only ever triggers a full reconcile.
func (a *API) Events(ctx context.Context, onEvent func(Event)) error {
// The token travels as a query parameter because an event stream has no
// place to put a header on reconnect in every client runtime.
u := a.BaseURL + "/api/v1/events?access_token=" + url.QueryEscape(a.Token)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
if err != nil {
return err
}
req.Header.Set("Accept", "text/event-stream")
// A streaming response must not be cut off by the client timeout.
stream := &http.Client{Transport: a.transport()}
resp, err := stream.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return decodeError(resp)
}
dec := newSSEDecoder(resp.Body)
for {
ev, err := dec.next()
if err != nil {
return err
}
if ev == nil {
continue
}
onEvent(*ev)
}
}
func (a *API) transport() http.RoundTripper {
if a.HTTP != nil && a.HTTP.Transport != nil {
return a.HTTP.Transport
}
return http.DefaultTransport
}
func (a *API) request(ctx context.Context, method, path string, body io.Reader) (*http.Request, error) {
req, err := http.NewRequestWithContext(ctx, method, a.BaseURL+path, body)
if err != nil {
return nil, err
}
if a.Token != "" {
req.Header.Set("Authorization", "Bearer "+a.Token)
}
return req, nil
}
// do sends a JSON request and decodes a JSON response. A non-2xx status
// always becomes an *Error.
func (a *API) do(ctx context.Context, method, path string, in, out any) error {
var body io.Reader
if in != nil {
b, err := json.Marshal(in)
if err != nil {
return err
}
body = bytes.NewReader(b)
}
req, err := a.request(ctx, method, path, body)
if err != nil {
return err
}
if in != nil {
req.Header.Set("Content-Type", "application/json")
}
resp, err := a.HTTP.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode > 299 {
return decodeError(resp)
}
if resp.StatusCode == http.StatusAccepted {
return &Error{Status: resp.StatusCode, Code: "pending"}
}
if out == nil {
return nil
}
return json.NewDecoder(io.LimitReader(resp.Body, 8<<20)).Decode(out)
}
func decodeError(resp *http.Response) error {
e := &Error{Status: resp.StatusCode}
b, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<16))
if len(b) > 0 && json.Unmarshal(b, e) == nil && e.Message != "" {
return e
}
e.Message = strings.TrimSpace(string(b))
if e.Message == "" {
e.Message = resp.Status
}
return e
}