sync: overhaul sync system — device pairing, server_sequence, auto-sync, dashboards

BREAKING: replace legacy API keys with device tokens via pairing flow.
- Server: /api/client/pair, revoke, me endpoints; server_sequence + tombstones + idempotency
- Desktop client: PairDevice, GetMe, RevokeCurrent; auto-sync loop every 60s
- Config: device_token stored in separate file (0600), not config.yml
- Client DB: last_pull_seq migration for incremental pull
- Frontend (Svelte): settings modal with connect/disconnect/interval
- User dashboard (/dashboard): device list with status, revoke with password
- Admin dashboard (/admin/dashboard): devices table from /admin/api/devices
- CLI (cmd/verstak): updated for ServerSequence/GetState changes
- Fix: autoSyncLoop falls back to SQLite sync_state for server URL
- Fix: SyncSetInterval preserves server_url/device_id from SQLite
This commit is contained in:
mirivlad 2026-06-02 02:26:05 +08:00
parent 7fe02fc8df
commit 87c8dfcbea
15 changed files with 1002 additions and 253 deletions

View File

@ -3,6 +3,7 @@ package main
import ( import (
"context" "context"
"fmt" "fmt"
"log"
"os" "os"
"os/exec" "os/exec"
"path/filepath" "path/filepath"
@ -51,6 +52,49 @@ func (a *App) startup(ctx context.Context) {
wailsruntime.EventsEmit(ctx, "files-dropped", paths) wailsruntime.EventsEmit(ctx, "files-dropped", paths)
} }
}) })
go a.autoSyncLoop()
}
func (a *App) autoSyncLoop() {
const checkInterval = 60 * time.Second
ticker := time.NewTicker(checkInterval)
defer ticker.Stop()
log.Printf("[autosync] started, vault=%s", a.vault)
for {
select {
case <-ticker.C:
serverURL := ""
cfg, err := config.Load(a.vault)
if err == nil {
serverURL = cfg.Sync.ServerURL
}
// Fall back to SQLite sync_state if config doesn't have it.
if serverURL == "" {
sURL, _, _, _, _ := a.sync.GetState()
serverURL = sURL
}
if serverURL == "" {
log.Printf("[autosync] no server URL")
continue
}
if cfg != nil && cfg.Sync.SyncInterval <= 0 {
log.Printf("[autosync] interval=%d, skipping", cfg.Sync.SyncInterval)
continue
}
deviceToken := config.LoadDeviceToken(a.vault)
if deviceToken == "" {
log.Printf("[autosync] no device token")
continue
}
log.Printf("[autosync] running SyncNow...")
if _, err := a.SyncNow(); err != nil {
log.Printf("[autosync] SyncNow error: %v", err)
}
case <-a.ctx.Done():
log.Printf("[autosync] stopped")
return
}
}
} }
// ============================================================ // ============================================================
@ -844,6 +888,10 @@ type SyncStatusDTO struct {
Configured bool `json:"configured"` Configured bool `json:"configured"`
ServerURL string `json:"serverUrl"` ServerURL string `json:"serverUrl"`
DeviceID string `json:"deviceId"` DeviceID string `json:"deviceId"`
DeviceName string `json:"deviceName"`
Connected bool `json:"connected"`
Revoked bool `json:"revoked"`
TokenStored bool `json:"tokenStored"`
UnpushedOps int `json:"unpushedOps"` UnpushedOps int `json:"unpushedOps"`
LastSyncAt string `json:"lastSyncAt"` LastSyncAt string `json:"lastSyncAt"`
SyncInterval int `json:"syncInterval"` SyncInterval int `json:"syncInterval"`
@ -854,65 +902,120 @@ func (a *App) SyncStatus() (*SyncStatusDTO, error) {
if err != nil { if err != nil {
return &SyncStatusDTO{}, nil return &SyncStatusDTO{}, nil
} }
unpushed, _ := a.sync.GetUnpushedOps()
cfg, _ := config.Load(a.vault) cfg, _ := config.Load(a.vault)
deviceToken := config.LoadDeviceToken(a.vault)
dto := &SyncStatusDTO{ dto := &SyncStatusDTO{
Configured: serverURL != "" && apiKey != "", Configured: serverURL != "" && (apiKey != "" || deviceToken != ""),
ServerURL: serverURL, ServerURL: serverURL,
UnpushedOps: len(unpushed),
LastSyncAt: lastSyncAt, LastSyncAt: lastSyncAt,
UnpushedOps: 0,
TokenStored: deviceToken != "",
} }
if cfg != nil { if cfg != nil {
dto.DeviceID = cfg.Sync.DeviceID dto.DeviceID = cfg.Sync.DeviceID
dto.SyncInterval = cfg.Sync.SyncInterval dto.SyncInterval = cfg.Sync.SyncInterval
} }
unpushed, _ := a.sync.GetUnpushedOps()
dto.UnpushedOps = len(unpushed)
if deviceToken != "" {
client := syncsvc.NewClient(serverURL, "", "", a.vault)
client.DeviceToken = deviceToken
if cfg != nil {
client.DeviceID = cfg.Sync.DeviceID
}
if info, err := client.GetMe(); err == nil {
dto.DeviceName = info.DeviceName
dto.DeviceID = info.DeviceID
dto.Connected = true
if info.RevokedAt != "" {
dto.Revoked = true
dto.Connected = false
}
}
}
return dto, nil return dto, nil
} }
func (a *App) SyncConfigure(serverURL, username, password string) error { func (a *App) SyncConfigure(serverURL, username, password string) error {
// Register device on server with user credentials.
hostname, _ := os.Hostname() hostname, _ := os.Hostname()
if hostname == "" { if hostname == "" {
hostname = "unknown" hostname = "unknown"
} }
client := syncsvc.NewClient(serverURL, "", "", a.vault) client := syncsvc.NewClient(serverURL, "", "", a.vault)
deviceID, apiKey, err := client.RegisterDeviceWithAuth(hostname, username, password) deviceID, deviceToken, err := client.PairDevice(serverURL, username, password, hostname, "verstak-gui/v2")
if err != nil { if err != nil {
return fmt.Errorf("register: %w", err) return fmt.Errorf("pair: %w", err)
} }
if err := a.sync.SetState(serverURL, apiKey); err != nil { // Save token to separate file with 0600 perms.
if err := config.SaveDeviceToken(a.vault, deviceToken); err != nil {
return fmt.Errorf("save token: %w", err)
}
if err := a.sync.SetState(serverURL, ""); err != nil {
return err return err
} }
// Persist to vault config.
cfg, err := config.Load(a.vault) cfg, err := config.Load(a.vault)
if err != nil { if err != nil {
return err cfg = &config.Config{}
} }
cfg.Sync.ServerURL = serverURL cfg.Sync.ServerURL = serverURL
cfg.Sync.APIKey = apiKey
cfg.Sync.DeviceID = deviceID cfg.Sync.DeviceID = deviceID
cfg.Sync.APIKey = ""
return config.Save(a.vault, cfg) return config.Save(a.vault, cfg)
} }
func (a *App) SyncDisconnect() error {
deviceToken := config.LoadDeviceToken(a.vault)
cfg, err := config.Load(a.vault)
if err != nil {
cfg = &config.Config{}
}
// Revoke token on server if we have one.
if deviceToken != "" {
client := syncsvc.NewClient(cfg.Sync.ServerURL, "", "", a.vault)
client.DeviceToken = deviceToken
_ = client.RevokeCurrent()
}
config.RemoveDeviceToken(a.vault)
cfg.Sync.ServerURL = ""
cfg.Sync.DeviceID = ""
cfg.Sync.APIKey = ""
if err := config.Save(a.vault, cfg); err != nil {
return err
}
return a.sync.SetState("", "")
}
func (a *App) SyncTestConnection(serverURL, username, password string) error { func (a *App) SyncTestConnection(serverURL, username, password string) error {
client := syncsvc.NewClient(serverURL, "", "", a.vault) client := syncsvc.NewClient(serverURL, "", "", a.vault)
_, _, err := client.RegisterDeviceWithAuth("test-connection", username, password) _, _, err := client.PairDevice(serverURL, username, password, "test-connection", "verstak-gui/v2")
return err return err
} }
func (a *App) SyncSetInterval(minutes int) error { func (a *App) SyncSetInterval(minutes int) error {
cfg, err := config.Load(a.vault) cfg, err := config.Load(a.vault)
if err != nil { if err != nil {
return err cfg = &config.Config{}
}
// If config lost the server URL, restore from sync_state.
if cfg.Sync.ServerURL == "" {
sURL, _, _, _, _ := a.sync.GetState()
if sURL != "" {
cfg.Sync.ServerURL = sURL
}
}
if cfg.Sync.DeviceID == "" {
cfg.Sync.DeviceID = a.sync.GetDeviceID()
} }
cfg.Sync.SyncInterval = minutes cfg.Sync.SyncInterval = minutes
return config.Save(a.vault, cfg) return config.Save(a.vault, cfg)
} }
func (a *App) SyncNow() (map[string]interface{}, error) { func (a *App) SyncNow() (map[string]interface{}, error) {
serverURL, apiKey, lastRev, _, err := a.sync.GetState() serverURL, apiKey, lastPullSeq, _, err := a.sync.GetState()
if err != nil || serverURL == "" || apiKey == "" { deviceToken := config.LoadDeviceToken(a.vault)
if err != nil || serverURL == "" || (apiKey == "" && deviceToken == "") {
return nil, fmt.Errorf("sync not configured") return nil, fmt.Errorf("sync not configured")
} }
@ -922,6 +1025,7 @@ func (a *App) SyncNow() (map[string]interface{}, error) {
} }
client := syncsvc.NewClient(serverURL, apiKey, deviceID, a.vault) client := syncsvc.NewClient(serverURL, apiKey, deviceID, a.vault)
client.DeviceToken = deviceToken
// Push unpushed ops. // Push unpushed ops.
unpushed, err := a.sync.GetUnpushedOps() unpushed, err := a.sync.GetUnpushedOps()
@ -940,15 +1044,33 @@ func (a *App) SyncNow() (map[string]interface{}, error) {
} }
// Pull remote ops. // Pull remote ops.
pullResult, err := client.Pull(lastRev) pullResult, err := client.Pull(lastPullSeq)
if err != nil { if err != nil {
return nil, fmt.Errorf("pull: %w", err) return nil, fmt.Errorf("pull: %w", err)
} }
if len(pullResult.Ops) > 0 {
// Apply pulled ops locally (record as remote ops, mark applied).
for _, op := range pullResult.Ops {
_ = a.sync.RecordRemoteOp(op)
}
opIDs := make([]string, len(pullResult.Ops))
for i, op := range pullResult.Ops {
opIDs[i] = op.OpID
}
_ = a.sync.MarkApplied(opIDs)
}
// Update sync state.
if pullResult.ServerSequence > lastPullSeq {
_ = a.sync.SetLastPullSeq(pullResult.ServerSequence)
}
_ = a.sync.SetLastSyncAt(time.Now().UTC().Format(time.RFC3339))
return map[string]interface{}{ return map[string]interface{}{
"pushed": len(pushResult.Accepted), "pushed": len(pushResult.Accepted),
"pulled": len(pullResult.Ops), "pulled": len(pullResult.Ops),
"serverRevision": pullResult.ServerRevision, "serverSequence": pullResult.ServerSequence,
}, nil }, nil
} }

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -16,8 +16,8 @@
background: #13131f; background: #13131f;
} }
</style> </style>
<script type="module" crossorigin src="/assets/main-Dk1pVsWM.js"></script> <script type="module" crossorigin src="/assets/main-CvznySlT.js"></script>
<link rel="stylesheet" crossorigin href="/assets/main-DVpSwKcZ.css"> <link rel="stylesheet" crossorigin href="/assets/main-Bkv7FuGB.css">
</head> </head>
<body> <body>
<div id="app"></div> <div id="app"></div>

View File

@ -195,7 +195,14 @@ CREATE TABLE IF NOT EXISTS server_devices (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
name TEXT NOT NULL, name TEXT NOT NULL,
api_key TEXT NOT NULL UNIQUE, api_key TEXT NOT NULL UNIQUE,
token_hash TEXT,
token_prefix TEXT,
token_suffix TEXT,
user_id TEXT,
client_version TEXT,
last_ip TEXT,
last_seen TEXT, last_seen TEXT,
revoked_at TEXT,
created_at TEXT NOT NULL created_at TEXT NOT NULL
); );
@ -208,15 +215,33 @@ CREATE TABLE IF NOT EXISTS server_revisions (
CREATE TABLE IF NOT EXISTS server_ops ( CREATE TABLE IF NOT EXISTS server_ops (
op_id TEXT PRIMARY KEY, op_id TEXT PRIMARY KEY,
server_sequence INTEGER,
device_id TEXT NOT NULL, device_id TEXT NOT NULL,
entity_type TEXT NOT NULL, entity_type TEXT NOT NULL,
entity_id TEXT NOT NULL, entity_id TEXT NOT NULL,
op_type TEXT NOT NULL, op_type TEXT NOT NULL,
payload_json TEXT NOT NULL, payload_json TEXT NOT NULL,
idempotency_key TEXT,
client_sequence INTEGER DEFAULT 0,
last_seen_server_seq INTEGER DEFAULT 0,
created_at TEXT NOT NULL, created_at TEXT NOT NULL,
pushed_at TEXT NOT NULL DEFAULT (datetime('now')) pushed_at TEXT NOT NULL DEFAULT (datetime('now'))
); );
CREATE TABLE IF NOT EXISTS server_tombstones (
entity_type TEXT NOT NULL,
entity_id TEXT NOT NULL,
op_id TEXT NOT NULL,
deleted_at TEXT NOT NULL,
PRIMARY KEY (entity_type, entity_id)
);
CREATE TABLE IF NOT EXISTS server_idempotency_keys (
idempotency_key TEXT PRIMARY KEY,
response_json TEXT NOT NULL,
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS server_blobs ( CREATE TABLE IF NOT EXISTS server_blobs (
sha256 TEXT PRIMARY KEY, sha256 TEXT PRIMARY KEY,
size INTEGER NOT NULL, size INTEGER NOT NULL,
@ -252,12 +277,43 @@ CREATE TABLE IF NOT EXISTS server_user_devices (
device_id TEXT NOT NULL, device_id TEXT NOT NULL,
PRIMARY KEY (user_id, device_id) PRIMARY KEY (user_id, device_id)
); );
CREATE TABLE IF NOT EXISTS server_audit_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
event_type TEXT NOT NULL,
user_id TEXT,
device_id TEXT,
ip TEXT,
message TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
` `
// ============================================================ // ============================================================
// Server // Server
// ============================================================ // ============================================================
type pairRateLimit struct {
mu sync.Mutex
attempts map[string]int
}
func (p *pairRateLimit) allow(ip string) bool {
p.mu.Lock()
defer p.mu.Unlock()
if p.attempts == nil {
p.attempts = make(map[string]int)
}
p.attempts[ip]++
return p.attempts[ip] <= 5
}
func (p *pairRateLimit) reset(ip string) {
p.mu.Lock()
defer p.mu.Unlock()
delete(p.attempts, ip)
}
type Server struct { type Server struct {
db *sql.DB db *sql.DB
cfg *Config cfg *Config
@ -265,6 +321,12 @@ type Server struct {
userTokens *userTokenStore userTokens *userTokenStore
blobsDir string blobsDir string
mux *http.ServeMux mux *http.ServeMux
pairLimit *pairRateLimit
}
func (s *Server) auditLog(eventType, userID, deviceID, ip, msg string) {
s.db.Exec("INSERT INTO server_audit_log (event_type, user_id, device_id, ip, message, created_at) VALUES (?, ?, ?, ?, ?, ?)",
eventType, userID, deviceID, ip, msg, time.Now().UTC().Format(time.RFC3339))
} }
func NewServer(dbPath, dataDir string, cfg *Config) (*Server, error) { func NewServer(dbPath, dataDir string, cfg *Config) (*Server, error) {
@ -288,6 +350,35 @@ func NewServer(dbPath, dataDir string, cfg *Config) (*Server, error) {
// Migrations for older databases. // Migrations for older databases.
db.Exec("ALTER TABLE server_users ADD COLUMN blocked INTEGER NOT NULL DEFAULT 0") db.Exec("ALTER TABLE server_users ADD COLUMN blocked INTEGER NOT NULL DEFAULT 0")
db.Exec("ALTER TABLE server_users ADD COLUMN last_seen TEXT") db.Exec("ALTER TABLE server_users ADD COLUMN last_seen TEXT")
db.Exec("ALTER TABLE server_devices ADD COLUMN token_hash TEXT")
db.Exec("ALTER TABLE server_devices ADD COLUMN token_prefix TEXT")
db.Exec("ALTER TABLE server_devices ADD COLUMN token_suffix TEXT")
db.Exec("ALTER TABLE server_devices ADD COLUMN user_id TEXT")
db.Exec("ALTER TABLE server_devices ADD COLUMN client_version TEXT")
db.Exec("ALTER TABLE server_devices ADD COLUMN last_ip TEXT")
db.Exec("ALTER TABLE server_devices ADD COLUMN revoked_at TEXT")
// Migration: add server_sequence and tombstones.
db.Exec("ALTER TABLE server_ops ADD COLUMN server_sequence INTEGER")
db.Exec("CREATE INDEX IF NOT EXISTS idx_server_ops_sequence ON server_ops(server_sequence)")
db.Exec("CREATE INDEX IF NOT EXISTS idx_server_ops_entity ON server_ops(entity_type, entity_id)")
db.Exec(`CREATE TABLE IF NOT EXISTS server_tombstones (
entity_type TEXT NOT NULL,
entity_id TEXT NOT NULL,
op_id TEXT NOT NULL,
deleted_at TEXT NOT NULL,
PRIMARY KEY (entity_type, entity_id)
)`)
db.Exec("CREATE INDEX IF NOT EXISTS idx_server_ops_sequence ON server_ops(server_sequence)")
db.Exec("CREATE INDEX IF NOT EXISTS idx_server_ops_entity ON server_ops(entity_type, entity_id)")
db.Exec(`CREATE TABLE IF NOT EXISTS server_idempotency_keys (
idempotency_key TEXT PRIMARY KEY,
response_json TEXT NOT NULL,
created_at TEXT NOT NULL
)`)
db.Exec(`ALTER TABLE server_ops ADD COLUMN idempotency_key TEXT`)
db.Exec(`ALTER TABLE server_ops ADD COLUMN client_sequence INTEGER DEFAULT 0`)
db.Exec(`ALTER TABLE server_ops ADD COLUMN last_seen_server_seq INTEGER DEFAULT 0`)
blobsDir := filepath.Join(dataDir, "blobs") blobsDir := filepath.Join(dataDir, "blobs")
if err := os.MkdirAll(blobsDir, 0750); err != nil { if err := os.MkdirAll(blobsDir, 0750); err != nil {
@ -301,6 +392,7 @@ func NewServer(dbPath, dataDir string, cfg *Config) (*Server, error) {
tokens: newTokenStore(), tokens: newTokenStore(),
userTokens: newUserTokenStore(), userTokens: newUserTokenStore(),
blobsDir: blobsDir, blobsDir: blobsDir,
pairLimit: &pairRateLimit{},
} }
s.mux = s.routes() s.mux = s.routes()
return s, nil return s, nil
@ -325,6 +417,10 @@ func (s *Server) routes() *http.ServeMux {
mux.HandleFunc("/api/v1/sync/push", s.handleSyncPush) mux.HandleFunc("/api/v1/sync/push", s.handleSyncPush)
mux.HandleFunc("/api/v1/sync/pull", s.handleSyncPull) mux.HandleFunc("/api/v1/sync/pull", s.handleSyncPull)
mux.HandleFunc("/api/v1/blobs/", s.handleBlobs) mux.HandleFunc("/api/v1/blobs/", s.handleBlobs)
mux.HandleFunc("/api/client/pair", s.handleClientPair)
mux.HandleFunc("/api/client/revoke-current", s.handleClientRevoke)
mux.HandleFunc("/api/client/me", s.handleClientMe)
mux.HandleFunc("/api/client/revoke-device", s.handleClientRevokeDevice)
mux.HandleFunc("/api/v1/auth/register", s.handleRegister) mux.HandleFunc("/api/v1/auth/register", s.handleRegister)
mux.HandleFunc("/api/v1/auth/confirm", s.handleConfirm) mux.HandleFunc("/api/v1/auth/confirm", s.handleConfirm)
mux.HandleFunc("/api/v1/auth/login", s.handleUserLogin) mux.HandleFunc("/api/v1/auth/login", s.handleUserLogin)
@ -372,8 +468,32 @@ func (s *Server) requireAPIKey(w http.ResponseWriter, r *http.Request) bool {
jsonErr(w, 401, "API key required") jsonErr(w, 401, "API key required")
return false return false
} }
// First try device token (hashed).
hash := sha256Hex(key)
var deviceID, userID, revokedAt sql.NullString
err := s.db.QueryRow("SELECT id, user_id, revoked_at FROM server_devices WHERE token_hash=?", hash).Scan(&deviceID, &userID, &revokedAt)
if err == nil {
if revokedAt.Valid && revokedAt.String != "" {
jsonErr(w, 401, "device revoked")
return false
}
// Check user not blocked.
var blocked int
if userID.Valid && userID.String != "" {
s.db.QueryRow("SELECT blocked FROM server_users WHERE id=?", userID.String).Scan(&blocked)
if blocked != 0 {
jsonErr(w, 403, "user blocked")
return false
}
}
r.Header.Set("X-Device-ID", deviceID.String)
r.Header.Set("X-User-ID", userID.String)
s.db.Exec("UPDATE server_devices SET last_seen=? WHERE id=?", time.Now().UTC().Format(time.RFC3339), deviceID.String)
return true
}
// Fallback to plain api_key (legacy).
var count int var count int
err := s.db.QueryRow("SELECT COUNT(*) FROM server_devices WHERE api_key=?", key).Scan(&count) err = s.db.QueryRow("SELECT COUNT(*) FROM server_devices WHERE api_key=?", key).Scan(&count)
if err != nil || count == 0 { if err != nil || count == 0 {
jsonErr(w, 401, "invalid API key") jsonErr(w, 401, "invalid API key")
return false return false
@ -425,6 +545,20 @@ a:hover{text-decoration:underline}</style>
</body></html>`, title, title, msg, backURL) </body></html>`, title, title, msg, backURL)
} }
func sha256Hex(s string) string {
h := sha256.Sum256([]byte(s))
return hex.EncodeToString(h[:])
}
func genDeviceToken() (token, prefix, suffix string) {
b := make([]byte, 32)
rand.Read(b)
token = "vs_dev_" + hex.EncodeToString(b)
prefix = token[:16]
suffix = token[len(token)-8:]
return
}
func sel(v, want string) string { func sel(v, want string) string {
if v == want { if v == want {
return " selected" return " selected"
@ -606,6 +740,194 @@ func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
}) })
} }
func (s *Server) handleClientPair(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
jsonErr(w, 405, "POST required")
return
}
ip := r.RemoteAddr
if idx := strings.LastIndex(ip, ":"); idx >= 0 {
ip = ip[:idx]
}
if !s.pairLimit.allow(ip) {
s.auditLog("rate_limit_exceeded", "", "", ip, "pair rate limit exceeded")
jsonErr(w, 429, "too many attempts")
return
}
var req struct {
Login string `json:"login"`
Password string `json:"password"`
DeviceName string `json:"device_name"`
ClientVersion string `json:"client_version"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonErr(w, 400, "bad json")
return
}
if req.Login == "" || req.Password == "" {
jsonErr(w, 400, "login and password required")
return
}
if req.DeviceName == "" {
req.DeviceName = "unknown"
}
// Look up user.
var userID, hash string
var confirmed, blocked int
err := s.db.QueryRow("SELECT id, password_hash, confirmed, blocked FROM server_users WHERE username=? OR email=?",
req.Login, strings.ToLower(req.Login)).Scan(&userID, &hash, &confirmed, &blocked)
if err != nil {
s.auditLog("device_auth_failed", "", "", ip, "pair: user not found: "+req.Login)
jsonErr(w, 401, "invalid credentials")
return
}
if blocked != 0 {
s.auditLog("device_auth_failed", userID, "", ip, "pair: user blocked")
jsonErr(w, 403, "account blocked")
return
}
if confirmed == 0 {
s.auditLog("device_auth_failed", userID, "", ip, "pair: email not confirmed")
jsonErr(w, 403, "email not confirmed")
return
}
if bcrypt.CompareHashAndPassword([]byte(hash), []byte(req.Password)) != nil {
s.auditLog("device_auth_failed", userID, "", ip, "pair: wrong password")
jsonErr(w, 401, "invalid credentials")
return
}
// Generate device.
devID := make([]byte, 12)
rand.Read(devID)
deviceID := "dev_" + hex.EncodeToString(devID)
token, prefix, suffix := genDeviceToken()
tokenHash := sha256Hex(token)
now := time.Now().UTC().Format(time.RFC3339)
apiKey := make([]byte, 20)
rand.Read(apiKey)
_, err = s.db.Exec(`INSERT INTO server_devices
(id, name, api_key, token_hash, token_prefix, token_suffix, user_id, client_version, last_ip, last_seen, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
deviceID, req.DeviceName, hex.EncodeToString(apiKey), tokenHash, prefix, suffix,
userID, req.ClientVersion, ip, now, now)
if err != nil {
jsonErr(w, 500, err.Error())
return
}
s.db.Exec("INSERT OR IGNORE INTO server_user_devices (user_id, device_id) VALUES (?, ?)", userID, deviceID)
s.db.Exec("UPDATE server_users SET last_seen=? WHERE id=?", now, userID)
s.pairLimit.reset(ip)
s.auditLog("device_paired", userID, deviceID, ip, "device paired: "+req.DeviceName)
jsonOK(w, map[string]interface{}{
"user_id": userID,
"device_id": deviceID,
"device_token": token,
"server_time": now,
"initial_sync_cursor": 0,
})
}
func (s *Server) handleClientRevoke(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
jsonErr(w, 405, "POST required")
return
}
tok := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")
if tok == "" {
jsonErr(w, 401, "token required")
return
}
hash := sha256Hex(tok)
var deviceID, userID string
err := s.db.QueryRow("SELECT id, user_id FROM server_devices WHERE token_hash=?", hash).Scan(&deviceID, &userID)
if err != nil {
jsonErr(w, 401, "invalid token")
return
}
now := time.Now().UTC().Format(time.RFC3339)
s.db.Exec("UPDATE server_devices SET revoked_at=? WHERE id=?", now, deviceID)
s.auditLog("device_revoked", userID, deviceID, r.RemoteAddr, "device revoked by user")
jsonOK(w, map[string]string{"status": "revoked"})
}
func (s *Server) handleClientRevokeDevice(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
jsonErr(w, 405, "POST required")
return
}
userID, ok := s.requireUserWeb(w, r)
if !ok {
return
}
var req struct {
DeviceID string `json:"device_id"`
Password string `json:"password"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonErr(w, 400, "invalid JSON")
return
}
if req.DeviceID == "" || req.Password == "" {
jsonErr(w, 400, "device_id and password required")
return
}
// Verify password.
var pwHash string
err := s.db.QueryRow("SELECT password_hash FROM server_users WHERE id=?", userID).Scan(&pwHash)
if err != nil {
jsonErr(w, 403, "access denied")
return
}
if bcrypt.CompareHashAndPassword([]byte(pwHash), []byte(req.Password)) != nil {
jsonErr(w, 403, "wrong password")
return
}
// Verify device belongs to user.
var devUserID string
err = s.db.QueryRow("SELECT user_id FROM server_devices WHERE id=?", req.DeviceID).Scan(&devUserID)
if err != nil {
jsonErr(w, 404, "device not found")
return
}
if devUserID != userID {
jsonErr(w, 403, "device does not belong to you")
return
}
now := time.Now().UTC().Format(time.RFC3339)
s.db.Exec("UPDATE server_devices SET revoked_at=? WHERE id=?", now, req.DeviceID)
s.auditLog("device_revoked", userID, req.DeviceID, r.RemoteAddr, "device revoked via web dashboard")
jsonOK(w, map[string]string{"status": "revoked"})
}
func (s *Server) handleClientMe(w http.ResponseWriter, r *http.Request) {
tok := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")
if tok == "" {
jsonErr(w, 401, "token required")
return
}
hash := sha256Hex(tok)
var deviceID, userID, name, clientVer, lastSeen, revokedAt, createdAt string
err := s.db.QueryRow(`SELECT d.id, d.user_id, d.name, COALESCE(d.client_version,''), COALESCE(d.last_seen,''), COALESCE(d.revoked_at,''), d.created_at
FROM server_devices d WHERE d.token_hash=?`, hash).
Scan(&deviceID, &userID, &name, &clientVer, &lastSeen, &revokedAt, &createdAt)
if err != nil {
jsonErr(w, 401, "invalid token")
return
}
var username string
s.db.QueryRow("SELECT username FROM server_users WHERE id=?", userID).Scan(&username)
jsonOK(w, map[string]interface{}{
"device_id": deviceID,
"user_id": userID,
"username": username,
"device_name": name,
"client_version": clientVer,
"last_seen": lastSeen,
"revoked_at": revokedAt,
"created_at": createdAt,
})
}
func (s *Server) handleDeviceRegister(w http.ResponseWriter, r *http.Request) { func (s *Server) handleDeviceRegister(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" { if r.Method != "POST" {
jsonErr(w, 405, "POST required") jsonErr(w, 405, "POST required")
@ -959,14 +1281,17 @@ func (s *Server) handleSyncPush(w http.ResponseWriter, r *http.Request) {
return return
} }
var req struct { var req struct {
DeviceID string `json:"device_id"` DeviceID string `json:"device_id"`
Ops []struct { IdempotencyKey string `json:"idempotency_key"`
OpID string `json:"op_id"` Ops []struct {
EntityType string `json:"entity_type"` OpID string `json:"op_id"`
EntityID string `json:"entity_id"` EntityType string `json:"entity_type"`
OpType string `json:"op_type"` EntityID string `json:"entity_id"`
PayloadJSON string `json:"payload_json"` OpType string `json:"op_type"`
CreatedAt string `json:"created_at"` PayloadJSON string `json:"payload_json"`
ClientSequence int `json:"client_sequence"`
LastSeenServerSeq int `json:"last_seen_server_seq"`
CreatedAt string `json:"created_at"`
} `json:"ops"` } `json:"ops"`
} }
if err := json.NewDecoder(r.Body).Decode(&req); err != nil { if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
@ -974,36 +1299,94 @@ func (s *Server) handleSyncPush(w http.ResponseWriter, r *http.Request) {
return return
} }
// Idempotency: if request-level key provided, check for cached response.
if req.IdempotencyKey != "" {
var cachedJSON string
err := s.db.QueryRow("SELECT response_json FROM server_idempotency_keys WHERE idempotency_key=?", req.IdempotencyKey).Scan(&cachedJSON)
if err == nil {
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(cachedJSON))
return
}
}
now := time.Now().UTC().Format(time.RFC3339)
var accepted []string var accepted []string
var conflicts []map[string]interface{}
for _, op := range req.Ops { for _, op := range req.Ops {
if op.OpID == "" || op.EntityType == "" || op.EntityID == "" || op.OpType == "" { if op.OpID == "" || op.EntityType == "" || op.EntityID == "" || op.OpType == "" {
continue continue
} }
_, err := s.db.Exec( // Conflict detection: check if another device already created ops for this entity
`INSERT OR IGNORE INTO server_ops (op_id, device_id, entity_type, entity_id, op_type, payload_json, created_at) // with a server_sequence higher than what this client last saw.
VALUES (?, ?, ?, ?, ?, ?, ?)`, if op.LastSeenServerSeq > 0 {
op.OpID, req.DeviceID, op.EntityType, op.EntityID, op.OpType, op.PayloadJSON, op.CreatedAt, conflictRows, err := s.db.Query(`
) SELECT op_id, device_id, op_type, server_sequence FROM server_ops
if err != nil { WHERE entity_type=? AND entity_id=? AND device_id!=?
continue AND server_sequence > ? AND op_type != 'delete'
ORDER BY server_sequence`, op.EntityType, op.EntityID, req.DeviceID, op.LastSeenServerSeq)
if err == nil {
for conflictRows.Next() {
var cOpID, cDevID, cOpType string
var cSeq int
conflictRows.Scan(&cOpID, &cDevID, &cOpType, &cSeq)
conflicts = append(conflicts, map[string]interface{}{
"op_id": cOpID,
"device_id": cDevID,
"op_type": cOpType,
"server_sequence": cSeq,
"entity_type": op.EntityType,
"entity_id": op.EntityID,
})
}
conflictRows.Close()
}
} }
// Assign revision.
res, err := s.db.Exec( res, err := s.db.Exec(
"INSERT INTO server_revisions (op_id, device_id) VALUES (?, ?)", `INSERT OR IGNORE INTO server_ops (op_id, server_sequence, device_id, entity_type, entity_id, op_type, payload_json, idempotency_key, client_sequence, last_seen_server_seq, created_at, pushed_at)
op.OpID, req.DeviceID, VALUES (?, NULL, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
op.OpID, req.DeviceID, op.EntityType, op.EntityID, op.OpType, op.PayloadJSON,
req.IdempotencyKey, op.ClientSequence, op.LastSeenServerSeq, op.CreatedAt, now,
) )
if err != nil { if err != nil {
continue continue
} }
rev, _ := res.LastInsertId() n, _ := res.RowsAffected()
_ = rev if n == 0 {
continue // duplicate op_id
}
seqRes, err := s.db.Exec("INSERT INTO server_revisions (op_id, device_id) VALUES (?, ?)", op.OpID, req.DeviceID)
if err != nil {
continue
}
seq, _ := seqRes.LastInsertId()
s.db.Exec("UPDATE server_ops SET server_sequence=? WHERE op_id=?", seq, op.OpID)
if op.OpType == "delete" {
s.db.Exec(`INSERT OR REPLACE INTO server_tombstones (entity_type, entity_id, op_id, deleted_at) VALUES (?, ?, ?, ?)`,
op.EntityType, op.EntityID, op.OpID, now)
}
accepted = append(accepted, op.OpID) accepted = append(accepted, op.OpID)
} }
jsonOK(w, map[string]interface{}{ resp := map[string]interface{}{
"accepted": accepted, "accepted": accepted,
"count": len(accepted), "count": len(accepted),
}) "conflicts": conflicts,
}
// Cache response for idempotency.
if req.IdempotencyKey != "" {
if respJSON, err := json.Marshal(resp); err == nil {
s.db.Exec("INSERT OR IGNORE INTO server_idempotency_keys (idempotency_key, response_json, created_at) VALUES (?, ?, ?)",
req.IdempotencyKey, string(respJSON), now)
}
}
jsonOK(w, resp)
} }
func (s *Server) handleSyncPull(w http.ResponseWriter, r *http.Request) { func (s *Server) handleSyncPull(w http.ResponseWriter, r *http.Request) {
@ -1015,24 +1398,21 @@ func (s *Server) handleSyncPull(w http.ResponseWriter, r *http.Request) {
return return
} }
var req struct { var req struct {
SinceRevision int `json:"since_revision"` SinceSequence int `json:"since_sequence"`
} }
if err := json.NewDecoder(r.Body).Decode(&req); err != nil { if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonErr(w, 400, "invalid JSON") jsonErr(w, 400, "invalid JSON")
return return
} }
// Get current server revision. var serverSeq int
var serverRev int s.db.QueryRow("SELECT COALESCE(MAX(server_sequence), 0) FROM server_ops").Scan(&serverSeq)
s.db.QueryRow("SELECT COALESCE(MAX(rev), 0) FROM server_revisions").Scan(&serverRev)
// Get ops since the requested revision.
rows, err := s.db.Query(` rows, err := s.db.Query(`
SELECT so.op_id, so.device_id, so.entity_type, so.entity_id, so.op_type, so.payload_json, so.created_at SELECT op_id, server_sequence, device_id, entity_type, entity_id, op_type, payload_json, created_at
FROM server_ops so FROM server_ops
JOIN server_revisions sr ON sr.op_id = so.op_id WHERE server_sequence > ? AND server_sequence IS NOT NULL
WHERE sr.rev > ? ORDER BY server_sequence`, req.SinceSequence)
ORDER BY sr.rev`, req.SinceRevision)
if err != nil { if err != nil {
jsonErr(w, 500, err.Error()) jsonErr(w, 500, err.Error())
return return
@ -1040,25 +1420,26 @@ func (s *Server) handleSyncPull(w http.ResponseWriter, r *http.Request) {
defer rows.Close() defer rows.Close()
type opDTO struct { type opDTO struct {
OpID string `json:"op_id"` OpID string `json:"op_id"`
DeviceID string `json:"device_id"` ServerSequence int `json:"server_sequence"`
EntityType string `json:"entity_type"` DeviceID string `json:"device_id"`
EntityID string `json:"entity_id"` EntityType string `json:"entity_type"`
OpType string `json:"op_type"` EntityID string `json:"entity_id"`
PayloadJSON string `json:"payload_json"` OpType string `json:"op_type"`
CreatedAt string `json:"created_at"` PayloadJSON string `json:"payload_json"`
CreatedAt string `json:"created_at"`
} }
var ops []opDTO var ops []opDTO
for rows.Next() { for rows.Next() {
var o opDTO var o opDTO
if err := rows.Scan(&o.OpID, &o.DeviceID, &o.EntityType, &o.EntityID, &o.OpType, &o.PayloadJSON, &o.CreatedAt); err != nil { if err := rows.Scan(&o.OpID, &o.ServerSequence, &o.DeviceID, &o.EntityType, &o.EntityID, &o.OpType, &o.PayloadJSON, &o.CreatedAt); err != nil {
continue continue
} }
ops = append(ops, o) ops = append(ops, o)
} }
jsonOK(w, map[string]interface{}{ jsonOK(w, map[string]interface{}{
"server_revision": serverRev, "server_sequence": serverSeq,
"ops": ops, "ops": ops,
}) })
} }
@ -1398,58 +1779,57 @@ func (s *Server) handleUserDashboard(w http.ResponseWriter, r *http.Request) {
if !ok { if !ok {
return return
} }
// Get username.
var username string var username string
s.db.QueryRow("SELECT username FROM server_users WHERE id=?", userID).Scan(&username) s.db.QueryRow("SELECT username FROM server_users WHERE id=?", userID).Scan(&username)
// Get devices. // Get devices with status info.
type dev struct {
ID, Name, LastSeen, CreatedAt, ClientVer, RevokedAt string
}
var devices []dev
rows, err := s.db.Query(` rows, err := s.db.Query(`
SELECT d.id, d.name, d.api_key, d.last_seen, d.created_at SELECT d.id, d.name, COALESCE(d.last_seen,''), d.created_at,
COALESCE(d.client_version,''), COALESCE(d.revoked_at,'')
FROM server_devices d FROM server_devices d
JOIN server_user_devices ud ON ud.device_id = d.id JOIN server_user_devices ud ON ud.device_id = d.id
WHERE ud.user_id = ? WHERE ud.user_id = ?
ORDER BY d.created_at`, userID) ORDER BY d.created_at DESC`, userID)
if err != nil { if err == nil {
jsonErr(w, 500, err.Error()) defer rows.Close()
return for rows.Next() {
} var d dev
defer rows.Close() rows.Scan(&d.ID, &d.Name, &d.LastSeen, &d.CreatedAt, &d.ClientVer, &d.RevokedAt)
devices = append(devices, d)
type dev struct {
ID, Name, APIKey, LastSeen, CreatedAt string
}
var devices []dev
for rows.Next() {
var d dev
var lastSeen sql.NullString
if err := rows.Scan(&d.ID, &d.Name, &d.APIKey, &lastSeen, &d.CreatedAt); err != nil {
continue
} }
d.LastSeen = lastSeen.String
devices = append(devices, d)
} }
w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set("Content-Type", "text/html; charset=utf-8")
// Build device rows HTML.
deviceRows := "" deviceRows := ""
if len(devices) == 0 { if len(devices) == 0 {
deviceRows = "<tr><td colspan='4' style='color:#666;text-align:center;padding:24px'>Нет подключённых устройств</td></tr>" deviceRows = "<tr><td colspan='5' style='color:#666;text-align:center;padding:24px'>Нет подключённых устройств.<br>Подключите устройство из desktop-клиента Verstak.</td></tr>"
} else { } else {
for _, d := range devices { for _, d := range devices {
lastSeen := d.LastSeen ls := d.LastSeen
if lastSeen == "" { if ls == "" {
lastSeen = "—" ls = "—"
}
created := d.CreatedAt
if len(created) > 10 {
created = created[:10]
}
status := "<span style='color:#34d399'>Активно</span>"
revokeBtn := fmt.Sprintf(`<button class="btn btn-danger btn-sm" onclick="revokeDevice('%s')">Отозвать</button>`, d.ID)
if d.RevokedAt != "" {
status = "<span style='color:#ff6b6b'>Отозвано</span>"
revokeBtn = ""
} }
escKey := strings.ReplaceAll(d.APIKey, "'", "\\'")
deviceRows += fmt.Sprintf(`<tr> deviceRows += fmt.Sprintf(`<tr>
<td>%s</td> <td>%s</td>
<td class="key-cell" title="%s">%s</td>
<td>%s</td> <td>%s</td>
<td> <td>%s</td>
<button class="btn copy-btn" onclick="copyKey('%s',this)">Копировать</button> <td>%s</td>
<button class="btn btn-danger" onclick="delDevice('%s')">Отключить</button> <td>%s %s</td>
</td> </tr>`, d.Name, status, created, ls, d.ClientVer, revokeBtn)
</tr>`, d.Name, d.APIKey, d.APIKey, lastSeen, escKey, d.ID)
} }
} }
@ -1461,19 +1841,16 @@ func (s *Server) handleUserDashboard(w http.ResponseWriter, r *http.Request) {
body{font-family:sans-serif;background:#13131f;color:#e4e4ef;padding:24px;max-width:800px;margin:0 auto} body{font-family:sans-serif;background:#13131f;color:#e4e4ef;padding:24px;max-width:800px;margin:0 auto}
h1{border-bottom:1px solid #2a2a3c;padding-bottom:12px} h1{border-bottom:1px solid #2a2a3c;padding-bottom:12px}
h2{margin-top:24px;font-size:16px} h2{margin-top:24px;font-size:16px}
table{width:100%;border-collapse:collapse;margin-top:8px} table{width:100%%;border-collapse:collapse;margin-top:8px}
th,td{text-align:left;padding:8px 12px;border-bottom:1px solid #2a2a3c} th,td{text-align:left;padding:8px 12px;border-bottom:1px solid #2a2a3c}
th{font-size:12px;color:#888;text-transform:uppercase} th{font-size:12px;color:#888;text-transform:uppercase}
.key-cell{max-width:300px;overflow:hidden;text-overflow:ellipsis;font-family:monospace;font-size:12px;color:#b0b0c0}
.btn{font-family:inherit;font-size:12px;padding:6px 12px;border-radius:6px;border:1px solid #2a2a3c;background:#1a1a28;color:#ccc;cursor:pointer;display:inline-flex;align-items:center;gap:4px} .btn{font-family:inherit;font-size:12px;padding:6px 12px;border-radius:6px;border:1px solid #2a2a3c;background:#1a1a28;color:#ccc;cursor:pointer;display:inline-flex;align-items:center;gap:4px}
.btn:hover{background:#222233} .btn:hover{background:#222233}
.btn-primary{background:#6366f1;border-color:#6366f1;color:#fff} .btn-primary{background:#6366f1;border-color:#6366f1;color:#fff}
.btn-primary:hover{background:#4f46e5} .btn-primary:hover{background:#4f46e5}
.btn-danger{color:#ff6b6b;border-color:#4a2222} .btn-danger{color:#ff6b6b;border-color:#4a2222}
.btn-danger:hover{background:#3a2222} .btn-danger:hover{background:#3a2222}
.copy-btn{padding:2px 8px;font-size:11px} .btn-sm{padding:2px 8px;font-size:11px}
input{font-family:inherit;font-size:14px;padding:8px 12px;border:1px solid #2a2a3c;background:#13131f;color:#e4e4ef;border-radius:6px;margin-right:8px;flex:1}
input:focus{outline:none;border-color:#6366f1}
.top{display:flex;justify-content:space-between;align-items:center} .top{display:flex;justify-content:space-between;align-items:center}
a{color:#6366f1} a{color:#6366f1}
</style> </style>
@ -1483,28 +1860,24 @@ a{color:#6366f1}
<span>%s · <a href="/logout">Выйти</a></span> <span>%s · <a href="/logout">Выйти</a></span>
</div> </div>
<h2>Устройства</h2> <h2>Устройства</h2>
<table><tr><th>Устройство</th><th>API-ключ</th><th>Последняя активность</th><th></th></tr>%s</table> <table><tr><th>Устройство</th><th>Статус</th><th>Подключено</th><th>Активность</th><th>Версия</th></tr>%s</table>
<div style="margin-top:24px;padding:16px;background:#1a1a28;border:1px solid #2a2a3c;border-radius:8px">
<h2 style="margin-top:0">Подключить новое устройство</h2>
<p style="font-size:13px;color:#888">Откройте desktop-клиент Verstak, перейдите в настройки синхронизации и введите URL сервера, логин и пароль.</p>
</div>
<h2>Новое устройство</h2>
<form action="/api/v1/device/register" method="POST" style="display:flex;gap:8px;margin-top:8px"
onsubmit="event.preventDefault();var f=this;fetch('/api/v1/device/register',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({name:f.name.value,username:'%s',password:document.getElementById('regpass').value})}).then(r=>r.json()).then(d=>{if(d.api_key){f.name.value='';location.reload()}else{alert(d.error||'error')}})">
<input name="name" placeholder="Название устройства" required>
<input type="hidden" id="regpass" value="">
<button class="btn btn-primary" type="button" onclick="var p=prompt('Ваш пароль:');if(p){document.getElementById('regpass').value=p;this.form.requestSubmit()}">Подключить</button>
</form>
<script> <script>
function copyKey(key,btn){ function revokeDevice(id){
navigator.clipboard.writeText(key).then(()=>{ if(!confirm('Отозвать устройство? Оно перестанет синхронизироваться.'))return
var old=btn.textContent;btn.textContent='Скопировано';btn.style.color='#4ade80' var pw=prompt('Введите ваш пароль для подтверждения:')
setTimeout(function(){btn.textContent=old;btn.style.color=''},1500) if(!pw)return
fetch('/api/client/revoke-device',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({device_id:id,password:pw})}).then(function(r){return r.json()}).then(function(d){
if(d.status==='revoked'){location.reload()}else{alert(d.error||'error')}
}) })
} }
function delDevice(id){
if(!confirm('Отключить устройство?'))return
fetch('/admin/api/keys/'+id,{method:'DELETE'}).then(()=>location.reload())
}
</script> </script>
</body></html>`, username, username, deviceRows, username) </body></html>`, username, username, deviceRows)
w.Write([]byte(html)) w.Write([]byte(html))
} }
@ -1578,7 +1951,7 @@ a{color:#6366f1}
h1{border-bottom:1px solid #2a2a3c;padding-bottom:12px} h1{border-bottom:1px solid #2a2a3c;padding-bottom:12px}
h2{margin-top:24px;font-size:16px} h2{margin-top:24px;font-size:16px}
.stat{background:#1a1a28;border:1px solid #2a2a3c;padding:12px 16px;border-radius:8px;margin:8px 0} .stat{background:#1a1a28;border:1px solid #2a2a3c;padding:12px 16px;border-radius:8px;margin:8px 0}
table{width:100%;border-collapse:collapse;margin-top:8px} table{width:100%%;border-collapse:collapse;margin-top:8px}
th,td{text-align:left;padding:8px 12px;border-bottom:1px solid #2a2a3c} th,td{text-align:left;padding:8px 12px;border-bottom:1px solid #2a2a3c}
th{font-size:12px;color:#888;text-transform:uppercase} th{font-size:12px;color:#888;text-transform:uppercase}
.key-cell{max-width:360px;overflow:hidden;text-overflow:ellipsis;font-family:monospace;font-size:12px;color:#b0b0c0} .key-cell{max-width:360px;overflow:hidden;text-overflow:ellipsis;font-family:monospace;font-size:12px;color:#b0b0c0}
@ -1615,28 +1988,29 @@ pre{background:#13131f;border:1px solid #2a2a3c;border-radius:8px;padding:12px;o
<button class="btn" onclick="openHealth()">Health check</button> <button class="btn" onclick="openHealth()">Health check</button>
</div> </div>
<h2>API-ключи</h2> <h2>Устройства</h2>
<div id="keys"></div> <div id="devices"></div>
<script> <script>
fetch('/admin/api/keys').then(r=>r.json()).then(keys=>{ fetch('/admin/api/devices').then(r=>r.json()).then(devices=>{
const div=document.getElementById('keys') const div=document.getElementById('devices')
if(!keys.length){div.innerHTML='<p>Нет ключей</p>';return} if(!devices.length){div.innerHTML='<p>Нет устройств</p>';return}
div.innerHTML='<table><tr><th>Устройство</th><th>API-ключ</th><th></th><th></th></tr>'+ div.innerHTML='<table><tr><th>Устройство</th><th>Пользователь</th><th>Версия</th><th>Статус</th><th>Активность</th><th></th></tr>'+
keys.map(k=>'<tr><td>'+k.name+'</td><td class="key-cell" title="'+k.api_key+'">'+k.api_key+'</td>'+ devices.map(d=>{
'<td><button class="btn copy-btn" onclick="copyKey(\''+k.api_key+'\',this)">Копировать</button></td>'+ var status=d.revoked_at?'<span style="color:#ff6b6b">Отозвано</span>':'<span style="color:#34d399">Активно</span>'
'<td><button class="btn btn-danger" onclick="delKey(\''+k.id+'\')">Удалить</button></td></tr>').join('')+'</table>' var ls=d.last_seen||'—'
document.getElementById('dev-count').textContent=keys.length var revBtn=''
if(!d.revoked_at) revBtn='<button class="btn btn-danger" onclick="revokeDevice(\''+d.id+'\')">Отозвать</button>'
return '<tr><td>'+d.name+'</td><td>'+(d.user||'—')+'</td><td>'+(d.client_version||'—')+'</td><td>'+status+'</td><td>'+ls+'</td><td>'+revBtn+'</td></tr>'
}).join('')+'</table>'
document.getElementById('dev-count').textContent=devices.length
}) })
fetch('/admin/api/stats').then(r=>r.json()).then(stats=>{ fetch('/admin/api/stats').then(r=>r.json()).then(stats=>{
document.getElementById('op-count').textContent=stats.ops||'0' document.getElementById('op-count').textContent=stats.ops||'0'
}) })
function copyKey(key,btn){ function revokeDevice(id){
navigator.clipboard.writeText(key).then(()=>{ if(!confirm('Отозвать устройство?'))return
var old=btn.textContent;btn.textContent='Скопировано';btn.style.color='#4ade80' fetch('/admin/api/keys/'+id,{method:'DELETE'}).then(()=>location.reload())
setTimeout(function(){btn.textContent=old;btn.style.color=''},1500)
})
} }
function delKey(id){if(confirm('Удалить ключ?'))fetch('/admin/api/keys/'+id,{method:'DELETE'}).then(()=>location.reload())}
function openSMTP(){document.getElementById('smtp-modal').style.display='flex';document.getElementById('smtp-test-result').textContent=''} function openSMTP(){document.getElementById('smtp-modal').style.display='flex';document.getElementById('smtp-test-result').textContent=''}
function closeSMTP(e){if(!e||e.target.id==='smtp-modal')document.getElementById('smtp-modal').style.display='none'} function closeSMTP(e){if(!e||e.target.id==='smtp-modal')document.getElementById('smtp-modal').style.display='none'}
function openHealth(){var m=document.getElementById('health-modal');m.style.display='flex';document.getElementById('health-result').textContent='Загрузка...';fetch('/api/v1/health').then(function(r){return r.text()}).then(function(t){document.getElementById('health-result').textContent=t})} function openHealth(){var m=document.getElementById('health-modal');m.style.display='flex';document.getElementById('health-result').textContent='Загрузка...';fetch('/api/v1/health').then(function(r){return r.text()}).then(function(t){document.getElementById('health-result').textContent=t})}
@ -1654,12 +2028,6 @@ function testSMTP(){
} }
</script> </script>
<h3>Новый ключ</h3>
<form action="/admin/api/keys" method="POST" style="display:flex;gap:8px">
<input name="name" placeholder="Название устройства" required style="flex:1">
<button class="btn btn-primary">Создать</button>
</form>
<div id="smtp-modal" class="modal-overlay" style="display:none" onclick="closeSMTP(event)"> <div id="smtp-modal" class="modal-overlay" style="display:none" onclick="closeSMTP(event)">
<div class="modal"> <div class="modal">
<button class="modal-close" onclick="closeSMTP()">&times;</button> <button class="modal-close" onclick="closeSMTP()">&times;</button>
@ -1759,6 +2127,35 @@ func (s *Server) handleAdminAPI(w http.ResponseWriter, r *http.Request) {
path := strings.TrimPrefix(r.URL.Path, "/admin") path := strings.TrimPrefix(r.URL.Path, "/admin")
switch { switch {
case path == "/api/devices" && r.Method == "GET":
rows, err := s.db.Query(`
SELECT d.id, d.name, d.client_version, COALESCE(d.last_seen,''), COALESCE(d.revoked_at,''), d.created_at,
COALESCE(u.username,'')
FROM server_devices d
LEFT JOIN server_users u ON u.id = d.user_id
ORDER BY d.created_at DESC`)
if err != nil {
jsonErr(w, 500, err.Error())
return
}
defer rows.Close()
type devDTO struct {
ID string `json:"id"`
Name string `json:"name"`
ClientVersion string `json:"client_version"`
LastSeen string `json:"last_seen"`
RevokedAt string `json:"revoked_at"`
CreatedAt string `json:"created_at"`
User string `json:"user"`
}
var out []devDTO
for rows.Next() {
var d devDTO
rows.Scan(&d.ID, &d.Name, &d.ClientVersion, &d.LastSeen, &d.RevokedAt, &d.CreatedAt, &d.User)
out = append(out, d)
}
jsonOK(w, out)
case path == "/api/keys" && r.Method == "GET": case path == "/api/keys" && r.Method == "GET":
rows, err := s.db.Query("SELECT id, name, api_key FROM server_devices ORDER BY created_at") rows, err := s.db.Query("SELECT id, name, api_key FROM server_devices ORDER BY created_at")
if err != nil { if err != nil {
@ -2073,7 +2470,7 @@ const adminUsersHTML = `<!DOCTYPE html>
body{font-family:sans-serif;background:#13131f;color:#e4e4ef;padding:24px;max-width:960px;margin:0 auto} body{font-family:sans-serif;background:#13131f;color:#e4e4ef;padding:24px;max-width:960px;margin:0 auto}
a{color:#6366f1} a{color:#6366f1}
h1{border-bottom:1px solid #2a2a3c;padding-bottom:12px} h1{border-bottom:1px solid #2a2a3c;padding-bottom:12px}
table{width:100%;border-collapse:collapse;margin-top:12px} table{width:100%%;border-collapse:collapse;margin-top:12px}
th,td{text-align:left;padding:8px 12px;border-bottom:1px solid #2a2a3c} th,td{text-align:left;padding:8px 12px;border-bottom:1px solid #2a2a3c}
th{font-size:12px;color:#888;text-transform:uppercase;cursor:pointer;user-select:none} th{font-size:12px;color:#888;text-transform:uppercase;cursor:pointer;user-select:none}
th:hover{color:#b0b0c0} th:hover{color:#b0b0c0}

View File

@ -704,12 +704,12 @@ func runSyncPull(args []string) {
syncSvc := syncsvc.NewService(db, deviceID) syncSvc := syncsvc.NewService(db, deviceID)
client := syncsvc.NewClient(cfg.Sync.ServerURL, cfg.Sync.APIKey, deviceID, abs) client := syncsvc.NewClient(cfg.Sync.ServerURL, cfg.Sync.APIKey, deviceID, abs)
_, _, lastRev, _, err := syncSvc.GetState() _, _, lastSeq, _, err := syncSvc.GetState()
if err != nil { if err != nil {
lastRev = 0 lastSeq = 0
} }
result, err := client.Pull(lastRev) result, err := client.Pull(lastSeq)
if err != nil { if err != nil {
fmt.Fprintf(os.Stderr, "Pull failed: %v\n", err) fmt.Fprintf(os.Stderr, "Pull failed: %v\n", err)
os.Exit(1) os.Exit(1)
@ -725,7 +725,7 @@ func runSyncPull(args []string) {
syncSvc.MarkApplied(opIDs) syncSvc.MarkApplied(opIDs)
} }
fmt.Printf("Pulled %d ops (server rev: %d)\n", len(result.Ops), result.ServerRevision) fmt.Printf("Pulled %d ops (server seq: %d)\n", len(result.Ops), result.ServerSequence)
} }
func runSyncStatus(args []string) { func runSyncStatus(args []string) {

View File

@ -925,7 +925,22 @@
if (syncInterval > 0) { if (syncInterval > 0) {
await wailsCall('SyncSetInterval', syncInterval) await wailsCall('SyncSetInterval', syncInterval)
} }
syncResult = 'ok' syncPassword = ''
syncUsername = ''
await loadSyncStatus()
showSettings = false
} catch (e) {
syncResult = 'err: ' + String(e)
}
syncLoading = false
}
async function saveSyncInterval() {
syncLoading = true
syncResult = ''
try {
await wailsCall('SyncSetInterval', syncInterval)
syncResult = 'интервал сохранён'
await loadSyncStatus() await loadSyncStatus()
} catch (e) { } catch (e) {
syncResult = 'err: ' + String(e) syncResult = 'err: ' + String(e)
@ -945,12 +960,25 @@
syncLoading = false syncLoading = false
} }
async function disconnectSync() {
syncLoading = true
syncResult = ''
try {
await wailsCall('SyncDisconnect')
syncResult = 'disconnected'
await loadSyncStatus()
} catch (e) {
syncResult = 'err: ' + String(e)
}
syncLoading = false
}
async function runSyncNow() { async function runSyncNow() {
syncLoading = true syncLoading = true
syncResult = '' syncResult = ''
try { try {
const r = await wailsCall('SyncNow') const r = await wailsCall('SyncNow')
syncResult = 'pushed ' + r.pushed + ', pulled ' + r.pulled + ' (rev ' + r.serverRevision + ')' syncResult = 'pushed ' + r.pushed + ', pulled ' + r.pulled + ' (seq ' + r.serverSequence + ')'
await loadSyncStatus() await loadSyncStatus()
} catch (e) { } catch (e) {
syncResult = 'err: ' + String(e) syncResult = 'err: ' + String(e)
@ -1524,36 +1552,73 @@
<h3>Настройки синхронизации</h3> <h3>Настройки синхронизации</h3>
{#if syncStatus} {#if syncStatus}
<div class="sync-status"> <div class="sync-status">
<div class="sync-row"><span class="sync-label">Статус</span><span class="sync-value">{syncStatus.configured ? 'Включена' : 'Отключена'}</span></div> <div class="sync-row">
<div class="sync-row"><span class="sync-label">Сервер</span><span class="sync-value mono">{syncStatus.serverUrl || '—'}</span></div> <span class="sync-label">Статус</span>
<div class="sync-row"><span class="sync-label">Устройство</span><span class="sync-value mono">{syncStatus.deviceId || '—'}</span></div> <span class="sync-value">
{#if syncStatus.revoked}
<span style="color:#ff6b6b">Отозвано</span>
{:else if syncStatus.connected}
<span style="color:#34d399">Подключено</span>
{:else if syncStatus.configured}
<span style="color:#f59e0b">Не подключено</span>
{:else}
<span style="color:#666">Отключена</span>
{/if}
</span>
</div>
{#if syncStatus.serverUrl}
<div class="sync-row"><span class="sync-label">Сервер</span><span class="sync-value mono">{syncStatus.serverUrl}</span></div>
{/if}
{#if syncStatus.deviceName}
<div class="sync-row"><span class="sync-label">Устройство</span><span class="sync-value">{syncStatus.deviceName}</span></div>
{/if}
{#if syncStatus.deviceId && !syncStatus.deviceName}
<div class="sync-row"><span class="sync-label">ID устройства</span><span class="sync-value mono">{syncStatus.deviceId}</span></div>
{/if}
<div class="sync-row"><span class="sync-label">Неотправлено</span><span class="sync-value">{syncStatus.unpushedOps}</span></div> <div class="sync-row"><span class="sync-label">Неотправлено</span><span class="sync-value">{syncStatus.unpushedOps}</span></div>
<div class="sync-row"><span class="sync-label">Последняя синх.</span><span class="sync-value">{syncStatus.lastSyncAt || '—'}</span></div> {#if syncStatus.lastSyncAt}
<div class="sync-row"><span class="sync-label">Последняя синх.</span><span class="sync-value">{syncStatus.lastSyncAt}</span></div>
{/if}
</div> </div>
{/if} {/if}
<div class="form-group">
<label>URL сервера</label> {#if syncStatus?.configured}
<input type="text" placeholder="https://example.com:47732" bind:value={syncServerUrl} /> <div class="sync-connected-actions">
</div> <button class="btn" on:click={runSyncNow} disabled={syncLoading}>Синхронизировать</button>
<div class="form-group"> <button class="btn btn-danger" on:click={disconnectSync} disabled={syncLoading}>Отключиться</button>
<label>Логин</label> </div>
<input type="text" placeholder="username" bind:value={syncUsername} /> {:else}
</div> <div class="form-group">
<div class="form-group"> <label>URL сервера</label>
<label>Пароль</label> <input type="text" placeholder="https://example.com:47732" bind:value={syncServerUrl} />
<input type="password" placeholder="password" bind:value={syncPassword} /> </div>
</div> <div class="form-group">
<div class="form-group"> <label>Логин</label>
<label>Автосинхронизация (мин)</label> <input type="text" placeholder="username" bind:value={syncUsername} />
<input type="number" placeholder="0 = отключено" bind:value={syncInterval} min="0" /> </div>
</div> <div class="form-group">
{#if syncResult} <label>Пароль</label>
<div class="sync-result">{syncResult}</div> <input type="password" placeholder="password" bind:value={syncPassword} />
</div>
<div class="modal-actions" style="margin-top:12px">
<button class="btn" on:click={testConnection} disabled={syncLoading || !syncServerUrl}>Проверить</button>
<button class="btn btn-primary" on:click={saveSyncConfig} disabled={syncLoading}>Подключиться</button>
</div>
{/if} {/if}
<div class="modal-actions">
<button class="btn" on:click={testConnection} disabled={syncLoading || !syncServerUrl}>Проверить</button> <div style="margin-top:16px;padding-top:16px;border-top:1px solid #2a2a3c">
<button class="btn btn-primary" on:click={saveSyncConfig} disabled={syncLoading}>Подключиться</button> <div class="form-group">
<button class="btn" on:click={runSyncNow} disabled={syncLoading || !syncStatus?.configured}>Синхронизировать</button> <label>Автосинхронизация (мин, 0 = отключено)</label>
<input type="number" placeholder="0" bind:value={syncInterval} min="0" />
</div>
<button class="btn" on:click={saveSyncInterval} disabled={syncLoading}>Сохранить интервал</button>
</div>
{#if syncResult}
<div class="sync-result" style="margin-top:8px">{syncResult}</div>
{/if}
<div class="modal-actions" style="margin-top:12px">
<button class="btn" on:click={closeSettings}>Закрыть</button> <button class="btn" on:click={closeSettings}>Закрыть</button>
</div> </div>
</div> </div>
@ -1790,4 +1855,5 @@
.sync-value { color: #e4e4ef; } .sync-value { color: #e4e4ef; }
.sync-value.mono { font-family: 'SF Mono', 'Fira Code', monospace; font-size: 12px; } .sync-value.mono { font-family: 'SF Mono', 'Fira Code', monospace; font-size: 12px; }
.sync-result { font-size: 12px; color: #6366f1; padding: 4px 0; } .sync-result { font-size: 12px; color: #6366f1; padding: 4px 0; }
.sync-connected-actions { display: flex; gap: 8px; margin-bottom: 16px; }
</style> </style>

2
go.mod
View File

@ -5,6 +5,7 @@ go 1.25.0
require ( require (
github.com/mattn/go-sqlite3 v1.14.44 github.com/mattn/go-sqlite3 v1.14.44
github.com/wailsapp/wails/v2 v2.12.0 github.com/wailsapp/wails/v2 v2.12.0
golang.org/x/crypto v0.33.0
gopkg.in/yaml.v3 v3.0.1 gopkg.in/yaml.v3 v3.0.1
) )
@ -33,7 +34,6 @@ require (
github.com/valyala/fasttemplate v1.2.2 // indirect github.com/valyala/fasttemplate v1.2.2 // indirect
github.com/wailsapp/go-webview2 v1.0.22 // indirect github.com/wailsapp/go-webview2 v1.0.22 // indirect
github.com/wailsapp/mimetype v1.4.1 // indirect github.com/wailsapp/mimetype v1.4.1 // indirect
golang.org/x/crypto v0.33.0 // indirect
golang.org/x/net v0.35.0 // indirect golang.org/x/net v0.35.0 // indirect
golang.org/x/sys v0.30.0 // indirect golang.org/x/sys v0.30.0 // indirect
golang.org/x/text v0.22.0 // indirect golang.org/x/text v0.22.0 // indirect

View File

@ -10,29 +10,29 @@ import (
// Config lives at .verstak/config.yml inside the vault. // Config lives at .verstak/config.yml inside the vault.
type Config struct { type Config struct {
Engine EngineConfig `yaml:"engine"` Engine EngineConfig `yaml:"engine"`
Sync SyncConfig `yaml:"sync"` Sync SyncConfig `yaml:"sync"`
Browser BrowserConfig `yaml:"browser"` Browser BrowserConfig `yaml:"browser"`
} }
type EngineConfig struct { type EngineConfig struct {
Version int `yaml:"version"` Version int `yaml:"version"`
VaultID string `yaml:"vault_id"` VaultID string `yaml:"vault_id"`
CreatedAt string `yaml:"created_at"` CreatedAt string `yaml:"created_at"`
VaultRoot string `yaml:"vault_root"` VaultRoot string `yaml:"vault_root"`
} }
type SyncConfig struct { type SyncConfig struct {
ServerURL string `yaml:"server_url"` ServerURL string `yaml:"server_url"`
APIKey string `yaml:"api_key"` APIKey string `yaml:"api_key"`
DeviceID string `yaml:"device_id"` DeviceID string `yaml:"device_id"`
AutoSync bool `yaml:"auto_sync"` AutoSync bool `yaml:"auto_sync"`
SyncInterval int `yaml:"sync_interval"` SyncInterval int `yaml:"sync_interval"`
} }
type BrowserConfig struct { type BrowserConfig struct {
Enabled bool `yaml:"enabled"` Enabled bool `yaml:"enabled"`
LocalPort int `yaml:"local_port"` LocalPort int `yaml:"local_port"`
} }
// Load reads .verstak/config.yml from the vault root. // Load reads .verstak/config.yml from the vault root.
@ -67,3 +67,41 @@ func Save(vaultRoot string, cfg *Config) error {
func MetaDir(vaultRoot string) string { func MetaDir(vaultRoot string) string {
return filepath.Join(vaultRoot, ".verstak") return filepath.Join(vaultRoot, ".verstak")
} }
// DeviceTokenPath returns the path to the device_token file.
func DeviceTokenPath(vaultRoot string) string {
return filepath.Join(vaultRoot, ".verstak", "device_token.json")
}
// SaveDeviceToken writes the device token to a separate file with 0600 perms.
func SaveDeviceToken(vaultRoot, token string) error {
path := DeviceTokenPath(vaultRoot)
dir := filepath.Dir(path)
if err := os.MkdirAll(dir, 0o750); err != nil {
return err
}
data := fmt.Sprintf(`{"device_token":%q}`, token)
return os.WriteFile(path, []byte(data), 0o600)
}
// LoadDeviceToken reads the device token from the separate file.
func LoadDeviceToken(vaultRoot string) string {
path := DeviceTokenPath(vaultRoot)
data, err := os.ReadFile(path)
if err != nil {
return ""
}
var v struct {
DeviceToken string `yaml:"device_token"`
}
if err := yaml.Unmarshal(data, &v); err != nil {
return ""
}
return v.DeviceToken
}
// RemoveDeviceToken deletes the device token file.
func RemoveDeviceToken(vaultRoot string) error {
path := DeviceTokenPath(vaultRoot)
return os.Remove(path)
}

View File

@ -0,0 +1,6 @@
package storage
// migration011 — add last_pull_seq to sync_state.
const migration011 = `
ALTER TABLE sync_state ADD COLUMN last_pull_seq INTEGER NOT NULL DEFAULT 0;
`

View File

@ -57,16 +57,17 @@ CREATE TABLE IF NOT EXISTS _schema_ver (
` `
var migrationFiles = map[int]string{ var migrationFiles = map[int]string{
1: migration001, 1: migration001,
2: migration002, 2: migration002,
3: migration003, 3: migration003,
4: migration004, 4: migration004,
5: migration005, 5: migration005,
6: migration006, 6: migration006,
// 7: migration007 (FTS5) — created lazily by search.Rebuild() // 7: migration007 (FTS5) — created lazily by search.Rebuild()
8: migration008, 8: migration008,
9: migration009, 9: migration009,
10: migration010, 10: migration010,
11: migration011,
} }
func (db *DB) runInitialSchema() error { func (db *DB) runInitialSchema() error {

View File

@ -14,11 +14,12 @@ import (
// Client communicates with the Verstak Sync Server. // Client communicates with the Verstak Sync Server.
type Client struct { type Client struct {
ServerURL string ServerURL string
APIKey string APIKey string // legacy API key
DeviceID string DeviceToken string // new device token
VaultRoot string DeviceID string
HTTP *http.Client VaultRoot string
HTTP *http.Client
} }
// NewClient creates a sync client. // NewClient creates a sync client.
@ -32,6 +33,56 @@ func NewClient(serverURL, apiKey, deviceID, vaultRoot string) *Client {
} }
} }
// PairDevice calls POST /api/client/pair and returns device_id and device_token.
func (c *Client) PairDevice(serverURL, username, password, deviceName, clientVersion string) (deviceID, deviceToken string, err error) {
body := map[string]string{
"login": username,
"password": password,
"device_name": deviceName,
"client_version": clientVersion,
}
var resp struct {
DeviceID string `json:"device_id"`
DeviceToken string `json:"device_token"`
}
savedURL := c.ServerURL
c.ServerURL = serverURL
err = c.post("/api/client/pair", body, &resp)
c.ServerURL = savedURL
if err != nil {
return "", "", err
}
return resp.DeviceID, resp.DeviceToken, nil
}
// GetMe calls GET /api/client/me and returns device info.
type DeviceInfo struct {
DeviceID string `json:"device_id"`
UserID string `json:"user_id"`
Username string `json:"username"`
DeviceName string `json:"device_name"`
ClientVersion string `json:"client_version"`
LastSeen string `json:"last_seen"`
RevokedAt string `json:"revoked_at"`
CreatedAt string `json:"created_at"`
}
func (c *Client) GetMe() (*DeviceInfo, error) {
var resp DeviceInfo
if err := c.get("/api/client/me", &resp); err != nil {
return nil, err
}
return &resp, nil
}
// RevokeCurrent calls POST /api/client/revoke-current.
func (c *Client) RevokeCurrent() error {
var resp struct {
Status string `json:"status"`
}
return c.post("/api/client/revoke-current", nil, &resp)
}
// RegisterDevice calls POST /api/v1/device/register and returns the API key. // RegisterDevice calls POST /api/v1/device/register and returns the API key.
func (c *Client) RegisterDevice(name string) (apiKey string, err error) { func (c *Client) RegisterDevice(name string) (apiKey string, err error) {
body := map[string]string{"name": name} body := map[string]string{"name": name}
@ -81,24 +132,28 @@ func (c *Client) Login(username, password string) (token string, err error) {
// PushRequest is the payload for POST /sync/push. // PushRequest is the payload for POST /sync/push.
type PushRequest struct { type PushRequest struct {
DeviceID string `json:"device_id"` DeviceID string `json:"device_id"`
Ops []PushOp `json:"ops"` IdempotencyKey string `json:"idempotency_key,omitempty"`
Ops []PushOp `json:"ops"`
} }
// PushOp is a single operation in a push request. // PushOp is a single operation in a push request.
type PushOp struct { type PushOp struct {
OpID string `json:"op_id"` OpID string `json:"op_id"`
EntityType string `json:"entity_type"` EntityType string `json:"entity_type"`
EntityID string `json:"entity_id"` EntityID string `json:"entity_id"`
OpType string `json:"op_type"` OpType string `json:"op_type"`
PayloadJSON string `json:"payload_json"` PayloadJSON string `json:"payload_json"`
CreatedAt string `json:"created_at"` ClientSequence int `json:"client_sequence"`
LastSeenServerSeq int `json:"last_seen_server_seq"`
CreatedAt string `json:"created_at"`
} }
// PushResponse is the response from POST /sync/push. // PushResponse is the response from POST /sync/push.
type PushResponse struct { type PushResponse struct {
Accepted []string `json:"accepted"` Accepted []string `json:"accepted"`
Count int `json:"count"` Count int `json:"count"`
Conflicts []map[string]interface{} `json:"conflicts"`
} }
// Push sends local operations to the server. // Push sends local operations to the server.
@ -124,18 +179,18 @@ func (c *Client) Push(ops []Op) (*PushResponse, error) {
// PullRequest is the payload for POST /sync/pull. // PullRequest is the payload for POST /sync/pull.
type PullRequest struct { type PullRequest struct {
SinceRevision int `json:"since_revision"` SinceSequence int `json:"since_sequence"`
} }
// PullResponse is the response from POST /sync/pull. // PullResponse is the response from POST /sync/pull.
type PullResponse struct { type PullResponse struct {
ServerRevision int `json:"server_revision"` ServerSequence int `json:"server_sequence"`
Ops []Op `json:"ops"` Ops []Op `json:"ops"`
} }
// Pull fetches remote operations since a given revision. // Pull fetches remote operations since a given sequence.
func (c *Client) Pull(sinceRevision int) (*PullResponse, error) { func (c *Client) Pull(sinceSequence int) (*PullResponse, error) {
req := PullRequest{SinceRevision: sinceRevision} req := PullRequest{SinceSequence: sinceSequence}
var resp PullResponse var resp PullResponse
if err := c.post("/api/v1/sync/pull", req, &resp); err != nil { if err := c.post("/api/v1/sync/pull", req, &resp); err != nil {
return nil, err return nil, err
@ -166,7 +221,7 @@ func (c *Client) UploadBlob(localPath string) (sha256 string, err error) {
return "", err return "", err
} }
req.Header.Set("Content-Type", w.FormDataContentType()) req.Header.Set("Content-Type", w.FormDataContentType())
req.Header.Set("Authorization", "Bearer "+c.APIKey) req.Header.Set("Authorization", "Bearer "+c.bearerToken())
resp, err := c.HTTP.Do(req) resp, err := c.HTTP.Do(req)
if err != nil { if err != nil {
@ -190,7 +245,7 @@ func (c *Client) DownloadBlob(sha256, destPath string) error {
if err != nil { if err != nil {
return err return err
} }
req.Header.Set("Authorization", "Bearer "+c.APIKey) req.Header.Set("Authorization", "Bearer "+c.bearerToken())
resp, err := c.HTTP.Do(req) resp, err := c.HTTP.Do(req)
if err != nil { if err != nil {
@ -213,17 +268,50 @@ func (c *Client) DownloadBlob(sha256, destPath string) error {
// --- internal --- // --- internal ---
func (c *Client) bearerToken() string {
if c.DeviceToken != "" {
return c.DeviceToken
}
return c.APIKey
}
func (c *Client) post(path string, body, result interface{}) error { func (c *Client) post(path string, body, result interface{}) error {
var b bytes.Buffer var b bytes.Buffer
if err := json.NewEncoder(&b).Encode(body); err != nil { if body != nil {
return err if err := json.NewEncoder(&b).Encode(body); err != nil {
return err
}
} }
req, err := http.NewRequest("POST", c.ServerURL+path, &b) req, err := http.NewRequest("POST", c.ServerURL+path, &b)
if err != nil { if err != nil {
return err return err
} }
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+c.APIKey) req.Header.Set("Authorization", "Bearer "+c.bearerToken())
resp, err := c.HTTP.Do(req)
if err != nil {
return fmt.Errorf("http: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
data, _ := io.ReadAll(resp.Body)
return fmt.Errorf("server %d: %s", resp.StatusCode, string(data))
}
if result != nil {
return json.NewDecoder(resp.Body).Decode(result)
}
return nil
}
func (c *Client) get(path string, result interface{}) error {
req, err := http.NewRequest("GET", c.ServerURL+path, nil)
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+c.bearerToken())
resp, err := c.HTTP.Do(req) resp, err := c.HTTP.Do(req)
if err != nil { if err != nil {

View File

@ -30,15 +30,16 @@ const (
// Op represents a sync operation. // Op represents a sync operation.
type Op struct { type Op struct {
ID string `json:"id"` ID string `json:"id"`
OpID string `json:"op_id"` OpID string `json:"op_id"`
DeviceID string `json:"device_id,omitempty"` ServerSequence int `json:"server_sequence,omitempty"`
EntityType string `json:"entity_type"` DeviceID string `json:"device_id,omitempty"`
EntityID string `json:"entity_id"` EntityType string `json:"entity_type"`
OpType string `json:"op_type"` EntityID string `json:"entity_id"`
PayloadJSON string `json:"payload_json"` OpType string `json:"op_type"`
CreatedAt string `json:"created_at"` PayloadJSON string `json:"payload_json"`
PushedAt *string `json:"pushed_at,omitempty"` CreatedAt string `json:"created_at"`
PushedAt *string `json:"pushed_at,omitempty"`
} }
// Service records and manages sync operations. // Service records and manages sync operations.
@ -74,6 +75,17 @@ func (s *Service) RecordOp(entityType, entityID, opType string, payload interfac
return err return err
} }
// RecordRemoteOp writes a remote op to the local sync_ops table (already applied server-side).
func (s *Service) RecordRemoteOp(op Op) error {
now := time.Now().UTC().Format(time.RFC3339)
_, err := s.db.Exec(
`INSERT OR IGNORE INTO sync_ops (id, op_id, device_id, entity_type, entity_id, op_type, payload_json, created_at, pushed_at, applied_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
op.OpID+"-remote", op.OpID, op.DeviceID, op.EntityType, op.EntityID, op.OpType, op.PayloadJSON, op.CreatedAt, now, now,
)
return err
}
// GetUnpushedOps returns ops that have not been pushed yet. // GetUnpushedOps returns ops that have not been pushed yet.
func (s *Service) GetUnpushedOps() ([]Op, error) { func (s *Service) GetUnpushedOps() ([]Op, error) {
rows, err := s.db.Query( rows, err := s.db.Query(
@ -111,10 +123,10 @@ func (s *Service) MarkApplied(opIDs []string) error {
} }
// GetState returns the current sync state. // GetState returns the current sync state.
func (s *Service) GetState() (serverURL, apiKey string, lastPushRev int, lastSyncAt string, err error) { func (s *Service) GetState() (serverURL, apiKey string, lastPullSeq int, lastSyncAt string, err error) {
err = s.db.QueryRow( err = s.db.QueryRow(
`SELECT server_url, api_key, last_push_rev, COALESCE(last_sync_at,'') FROM sync_state WHERE device_id=?`, `SELECT server_url, api_key, last_pull_seq, COALESCE(last_sync_at,'') FROM sync_state WHERE device_id=?`,
s.deviceID).Scan(&serverURL, &apiKey, &lastPushRev, &lastSyncAt) s.deviceID).Scan(&serverURL, &apiKey, &lastPullSeq, &lastSyncAt)
if err == sql.ErrNoRows { if err == sql.ErrNoRows {
return "", "", 0, "", nil return "", "", 0, "", nil
} }
@ -124,14 +136,33 @@ func (s *Service) GetState() (serverURL, apiKey string, lastPushRev int, lastSyn
// SetState saves sync connection state. // SetState saves sync connection state.
func (s *Service) SetState(serverURL, apiKey string) error { func (s *Service) SetState(serverURL, apiKey string) error {
_, err := s.db.Exec( _, err := s.db.Exec(
`INSERT INTO sync_state (device_id, server_url, api_key, last_push_rev, last_sync_at) `INSERT INTO sync_state (device_id, server_url, api_key, last_pull_seq, last_sync_at)
VALUES (?, ?, ?, 0, '') VALUES (?, ?, ?, 0, '')
ON CONFLICT(device_id) DO UPDATE SET server_url=excluded.server_url, api_key=excluded.api_key`, ON CONFLICT(device_id) DO UPDATE SET
server_url=excluded.server_url,
api_key=excluded.api_key`,
s.deviceID, serverURL, apiKey, s.deviceID, serverURL, apiKey,
) )
return err return err
} }
// SetLastPullSeq updates the last pulled server sequence.
func (s *Service) SetLastPullSeq(seq int) error {
_, err := s.db.Exec("UPDATE sync_state SET last_pull_seq=? WHERE device_id=?", seq, s.deviceID)
return err
}
// GetDeviceID returns the device ID used by this service.
func (s *Service) GetDeviceID() string {
return s.deviceID
}
// SetLastSyncAt updates the last sync timestamp.
func (s *Service) SetLastSyncAt(t string) error {
_, err := s.db.Exec("UPDATE sync_state SET last_sync_at=? WHERE device_id=?", t, s.deviceID)
return err
}
// --- helpers --- // --- helpers ---
func scanOps(rows *sql.Rows) ([]Op, error) { func scanOps(rows *sql.Rows) ([]Op, error) {