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:
+138
-16
@@ -3,6 +3,7 @@ package main
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
@@ -51,6 +52,49 @@ func (a *App) startup(ctx context.Context) {
|
||||
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"`
|
||||
ServerURL string `json:"serverUrl"`
|
||||
DeviceID string `json:"deviceId"`
|
||||
DeviceName string `json:"deviceName"`
|
||||
Connected bool `json:"connected"`
|
||||
Revoked bool `json:"revoked"`
|
||||
TokenStored bool `json:"tokenStored"`
|
||||
UnpushedOps int `json:"unpushedOps"`
|
||||
LastSyncAt string `json:"lastSyncAt"`
|
||||
SyncInterval int `json:"syncInterval"`
|
||||
@@ -854,65 +902,120 @@ func (a *App) SyncStatus() (*SyncStatusDTO, error) {
|
||||
if err != nil {
|
||||
return &SyncStatusDTO{}, nil
|
||||
}
|
||||
unpushed, _ := a.sync.GetUnpushedOps()
|
||||
cfg, _ := config.Load(a.vault)
|
||||
deviceToken := config.LoadDeviceToken(a.vault)
|
||||
dto := &SyncStatusDTO{
|
||||
Configured: serverURL != "" && apiKey != "",
|
||||
Configured: serverURL != "" && (apiKey != "" || deviceToken != ""),
|
||||
ServerURL: serverURL,
|
||||
UnpushedOps: len(unpushed),
|
||||
LastSyncAt: lastSyncAt,
|
||||
UnpushedOps: 0,
|
||||
TokenStored: deviceToken != "",
|
||||
}
|
||||
if cfg != nil {
|
||||
dto.DeviceID = cfg.Sync.DeviceID
|
||||
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
|
||||
}
|
||||
|
||||
func (a *App) SyncConfigure(serverURL, username, password string) error {
|
||||
// Register device on server with user credentials.
|
||||
hostname, _ := os.Hostname()
|
||||
if hostname == "" {
|
||||
hostname = "unknown"
|
||||
}
|
||||
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 {
|
||||
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
|
||||
}
|
||||
// Persist to vault config.
|
||||
cfg, err := config.Load(a.vault)
|
||||
if err != nil {
|
||||
return err
|
||||
cfg = &config.Config{}
|
||||
}
|
||||
cfg.Sync.ServerURL = serverURL
|
||||
cfg.Sync.APIKey = apiKey
|
||||
cfg.Sync.DeviceID = deviceID
|
||||
cfg.Sync.APIKey = ""
|
||||
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 {
|
||||
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
|
||||
}
|
||||
|
||||
func (a *App) SyncSetInterval(minutes int) error {
|
||||
cfg, err := config.Load(a.vault)
|
||||
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
|
||||
return config.Save(a.vault, cfg)
|
||||
}
|
||||
|
||||
func (a *App) SyncNow() (map[string]interface{}, error) {
|
||||
serverURL, apiKey, lastRev, _, err := a.sync.GetState()
|
||||
if err != nil || serverURL == "" || apiKey == "" {
|
||||
serverURL, apiKey, lastPullSeq, _, err := a.sync.GetState()
|
||||
deviceToken := config.LoadDeviceToken(a.vault)
|
||||
if err != nil || serverURL == "" || (apiKey == "" && deviceToken == "") {
|
||||
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.DeviceToken = deviceToken
|
||||
|
||||
// Push unpushed ops.
|
||||
unpushed, err := a.sync.GetUnpushedOps()
|
||||
@@ -940,15 +1044,33 @@ func (a *App) SyncNow() (map[string]interface{}, error) {
|
||||
}
|
||||
|
||||
// Pull remote ops.
|
||||
pullResult, err := client.Pull(lastRev)
|
||||
pullResult, err := client.Pull(lastPullSeq)
|
||||
if err != nil {
|
||||
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{}{
|
||||
"pushed": len(pushResult.Accepted),
|
||||
"pulled": len(pullResult.Ops),
|
||||
"serverRevision": pullResult.ServerRevision,
|
||||
"serverSequence": pullResult.ServerSequence,
|
||||
}, 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
@@ -16,8 +16,8 @@
|
||||
background: #13131f;
|
||||
}
|
||||
</style>
|
||||
<script type="module" crossorigin src="/assets/main-Dk1pVsWM.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/main-DVpSwKcZ.css">
|
||||
<script type="module" crossorigin src="/assets/main-CvznySlT.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/main-Bkv7FuGB.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
+520
-123
@@ -195,7 +195,14 @@ CREATE TABLE IF NOT EXISTS server_devices (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
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,
|
||||
revoked_at TEXT,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
@@ -208,15 +215,33 @@ CREATE TABLE IF NOT EXISTS server_revisions (
|
||||
|
||||
CREATE TABLE IF NOT EXISTS server_ops (
|
||||
op_id TEXT PRIMARY KEY,
|
||||
server_sequence INTEGER,
|
||||
device_id TEXT NOT NULL,
|
||||
entity_type TEXT NOT NULL,
|
||||
entity_id TEXT NOT NULL,
|
||||
op_type 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,
|
||||
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 (
|
||||
sha256 TEXT PRIMARY KEY,
|
||||
size INTEGER NOT NULL,
|
||||
@@ -252,12 +277,43 @@ CREATE TABLE IF NOT EXISTS server_user_devices (
|
||||
device_id TEXT NOT NULL,
|
||||
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
|
||||
// ============================================================
|
||||
|
||||
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 {
|
||||
db *sql.DB
|
||||
cfg *Config
|
||||
@@ -265,6 +321,12 @@ type Server struct {
|
||||
userTokens *userTokenStore
|
||||
blobsDir string
|
||||
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) {
|
||||
@@ -288,6 +350,35 @@ func NewServer(dbPath, dataDir string, cfg *Config) (*Server, error) {
|
||||
// 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 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")
|
||||
if err := os.MkdirAll(blobsDir, 0750); err != nil {
|
||||
@@ -301,6 +392,7 @@ func NewServer(dbPath, dataDir string, cfg *Config) (*Server, error) {
|
||||
tokens: newTokenStore(),
|
||||
userTokens: newUserTokenStore(),
|
||||
blobsDir: blobsDir,
|
||||
pairLimit: &pairRateLimit{},
|
||||
}
|
||||
s.mux = s.routes()
|
||||
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/pull", s.handleSyncPull)
|
||||
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/confirm", s.handleConfirm)
|
||||
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")
|
||||
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
|
||||
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 {
|
||||
jsonErr(w, 401, "invalid API key")
|
||||
return false
|
||||
@@ -425,6 +545,20 @@ a:hover{text-decoration:underline}</style>
|
||||
</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 {
|
||||
if v == want {
|
||||
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) {
|
||||
if r.Method != "POST" {
|
||||
jsonErr(w, 405, "POST required")
|
||||
@@ -959,14 +1281,17 @@ func (s *Server) handleSyncPush(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
DeviceID string `json:"device_id"`
|
||||
Ops []struct {
|
||||
OpID string `json:"op_id"`
|
||||
EntityType string `json:"entity_type"`
|
||||
EntityID string `json:"entity_id"`
|
||||
OpType string `json:"op_type"`
|
||||
PayloadJSON string `json:"payload_json"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
DeviceID string `json:"device_id"`
|
||||
IdempotencyKey string `json:"idempotency_key"`
|
||||
Ops []struct {
|
||||
OpID string `json:"op_id"`
|
||||
EntityType string `json:"entity_type"`
|
||||
EntityID string `json:"entity_id"`
|
||||
OpType string `json:"op_type"`
|
||||
PayloadJSON string `json:"payload_json"`
|
||||
ClientSequence int `json:"client_sequence"`
|
||||
LastSeenServerSeq int `json:"last_seen_server_seq"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
} `json:"ops"`
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
// 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 conflicts []map[string]interface{}
|
||||
|
||||
for _, op := range req.Ops {
|
||||
if op.OpID == "" || op.EntityType == "" || op.EntityID == "" || op.OpType == "" {
|
||||
continue
|
||||
}
|
||||
_, err := s.db.Exec(
|
||||
`INSERT OR IGNORE INTO server_ops (op_id, device_id, entity_type, entity_id, op_type, payload_json, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||
op.OpID, req.DeviceID, op.EntityType, op.EntityID, op.OpType, op.PayloadJSON, op.CreatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
continue
|
||||
// Conflict detection: check if another device already created ops for this entity
|
||||
// with a server_sequence higher than what this client last saw.
|
||||
if op.LastSeenServerSeq > 0 {
|
||||
conflictRows, err := s.db.Query(`
|
||||
SELECT op_id, device_id, op_type, server_sequence FROM server_ops
|
||||
WHERE entity_type=? AND entity_id=? AND device_id!=?
|
||||
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(
|
||||
"INSERT INTO server_revisions (op_id, device_id) VALUES (?, ?)",
|
||||
op.OpID, req.DeviceID,
|
||||
`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)
|
||||
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 {
|
||||
continue
|
||||
}
|
||||
rev, _ := res.LastInsertId()
|
||||
_ = rev
|
||||
n, _ := res.RowsAffected()
|
||||
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)
|
||||
}
|
||||
|
||||
jsonOK(w, map[string]interface{}{
|
||||
"accepted": accepted,
|
||||
"count": len(accepted),
|
||||
})
|
||||
resp := map[string]interface{}{
|
||||
"accepted": 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) {
|
||||
@@ -1015,24 +1398,21 @@ func (s *Server) handleSyncPull(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
SinceRevision int `json:"since_revision"`
|
||||
SinceSequence int `json:"since_sequence"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
jsonErr(w, 400, "invalid JSON")
|
||||
return
|
||||
}
|
||||
|
||||
// Get current server revision.
|
||||
var serverRev int
|
||||
s.db.QueryRow("SELECT COALESCE(MAX(rev), 0) FROM server_revisions").Scan(&serverRev)
|
||||
var serverSeq int
|
||||
s.db.QueryRow("SELECT COALESCE(MAX(server_sequence), 0) FROM server_ops").Scan(&serverSeq)
|
||||
|
||||
// Get ops since the requested revision.
|
||||
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
|
||||
FROM server_ops so
|
||||
JOIN server_revisions sr ON sr.op_id = so.op_id
|
||||
WHERE sr.rev > ?
|
||||
ORDER BY sr.rev`, req.SinceRevision)
|
||||
SELECT op_id, server_sequence, device_id, entity_type, entity_id, op_type, payload_json, created_at
|
||||
FROM server_ops
|
||||
WHERE server_sequence > ? AND server_sequence IS NOT NULL
|
||||
ORDER BY server_sequence`, req.SinceSequence)
|
||||
if err != nil {
|
||||
jsonErr(w, 500, err.Error())
|
||||
return
|
||||
@@ -1040,25 +1420,26 @@ func (s *Server) handleSyncPull(w http.ResponseWriter, r *http.Request) {
|
||||
defer rows.Close()
|
||||
|
||||
type opDTO struct {
|
||||
OpID string `json:"op_id"`
|
||||
DeviceID string `json:"device_id"`
|
||||
EntityType string `json:"entity_type"`
|
||||
EntityID string `json:"entity_id"`
|
||||
OpType string `json:"op_type"`
|
||||
PayloadJSON string `json:"payload_json"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
OpID string `json:"op_id"`
|
||||
ServerSequence int `json:"server_sequence"`
|
||||
DeviceID string `json:"device_id"`
|
||||
EntityType string `json:"entity_type"`
|
||||
EntityID string `json:"entity_id"`
|
||||
OpType string `json:"op_type"`
|
||||
PayloadJSON string `json:"payload_json"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
var ops []opDTO
|
||||
for rows.Next() {
|
||||
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
|
||||
}
|
||||
ops = append(ops, o)
|
||||
}
|
||||
|
||||
jsonOK(w, map[string]interface{}{
|
||||
"server_revision": serverRev,
|
||||
"server_sequence": serverSeq,
|
||||
"ops": ops,
|
||||
})
|
||||
}
|
||||
@@ -1398,58 +1779,57 @@ func (s *Server) handleUserDashboard(w http.ResponseWriter, r *http.Request) {
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
// Get username.
|
||||
var username string
|
||||
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(`
|
||||
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
|
||||
JOIN server_user_devices ud ON ud.device_id = d.id
|
||||
WHERE ud.user_id = ?
|
||||
ORDER BY d.created_at`, userID)
|
||||
if err != nil {
|
||||
jsonErr(w, 500, err.Error())
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
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
|
||||
ORDER BY d.created_at DESC`, userID)
|
||||
if err == nil {
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var d dev
|
||||
rows.Scan(&d.ID, &d.Name, &d.LastSeen, &d.CreatedAt, &d.ClientVer, &d.RevokedAt)
|
||||
devices = append(devices, d)
|
||||
}
|
||||
d.LastSeen = lastSeen.String
|
||||
devices = append(devices, d)
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
// Build device rows HTML.
|
||||
deviceRows := ""
|
||||
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 {
|
||||
for _, d := range devices {
|
||||
lastSeen := d.LastSeen
|
||||
if lastSeen == "" {
|
||||
lastSeen = "—"
|
||||
ls := d.LastSeen
|
||||
if ls == "" {
|
||||
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>
|
||||
<td>%s</td>
|
||||
<td class="key-cell" title="%s">%s</td>
|
||||
<td>%s</td>
|
||||
<td>
|
||||
<button class="btn copy-btn" onclick="copyKey('%s',this)">Копировать</button>
|
||||
<button class="btn btn-danger" onclick="delDevice('%s')">Отключить</button>
|
||||
</td>
|
||||
</tr>`, d.Name, d.APIKey, d.APIKey, lastSeen, escKey, d.ID)
|
||||
<td>%s</td>
|
||||
<td>%s</td>
|
||||
<td>%s %s</td>
|
||||
</tr>`, d.Name, status, created, ls, d.ClientVer, revokeBtn)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
h1{border-bottom:1px solid #2a2a3c;padding-bottom:12px}
|
||||
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{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:hover{background:#222233}
|
||||
.btn-primary{background:#6366f1;border-color:#6366f1;color:#fff}
|
||||
.btn-primary:hover{background:#4f46e5}
|
||||
.btn-danger{color:#ff6b6b;border-color:#4a2222}
|
||||
.btn-danger:hover{background:#3a2222}
|
||||
.copy-btn{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}
|
||||
.btn-sm{padding:2px 8px;font-size:11px}
|
||||
.top{display:flex;justify-content:space-between;align-items:center}
|
||||
a{color:#6366f1}
|
||||
</style>
|
||||
@@ -1483,28 +1860,24 @@ a{color:#6366f1}
|
||||
<span>%s · <a href="/logout">Выйти</a></span>
|
||||
</div>
|
||||
<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>
|
||||
function copyKey(key,btn){
|
||||
navigator.clipboard.writeText(key).then(()=>{
|
||||
var old=btn.textContent;btn.textContent='Скопировано';btn.style.color='#4ade80'
|
||||
setTimeout(function(){btn.textContent=old;btn.style.color=''},1500)
|
||||
function revokeDevice(id){
|
||||
if(!confirm('Отозвать устройство? Оно перестанет синхронизироваться.'))return
|
||||
var pw=prompt('Введите ваш пароль для подтверждения:')
|
||||
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>
|
||||
</body></html>`, username, username, deviceRows, username)
|
||||
</body></html>`, username, username, deviceRows)
|
||||
w.Write([]byte(html))
|
||||
}
|
||||
|
||||
@@ -1578,7 +1951,7 @@ a{color:#6366f1}
|
||||
h1{border-bottom:1px solid #2a2a3c;padding-bottom:12px}
|
||||
h2{margin-top:24px;font-size:16px}
|
||||
.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{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}
|
||||
@@ -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>
|
||||
</div>
|
||||
|
||||
<h2>API-ключи</h2>
|
||||
<div id="keys"></div>
|
||||
<h2>Устройства</h2>
|
||||
<div id="devices"></div>
|
||||
<script>
|
||||
fetch('/admin/api/keys').then(r=>r.json()).then(keys=>{
|
||||
const div=document.getElementById('keys')
|
||||
if(!keys.length){div.innerHTML='<p>Нет ключей</p>';return}
|
||||
div.innerHTML='<table><tr><th>Устройство</th><th>API-ключ</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>'+
|
||||
'<td><button class="btn copy-btn" onclick="copyKey(\''+k.api_key+'\',this)">Копировать</button></td>'+
|
||||
'<td><button class="btn btn-danger" onclick="delKey(\''+k.id+'\')">Удалить</button></td></tr>').join('')+'</table>'
|
||||
document.getElementById('dev-count').textContent=keys.length
|
||||
fetch('/admin/api/devices').then(r=>r.json()).then(devices=>{
|
||||
const div=document.getElementById('devices')
|
||||
if(!devices.length){div.innerHTML='<p>Нет устройств</p>';return}
|
||||
div.innerHTML='<table><tr><th>Устройство</th><th>Пользователь</th><th>Версия</th><th>Статус</th><th>Активность</th><th></th></tr>'+
|
||||
devices.map(d=>{
|
||||
var status=d.revoked_at?'<span style="color:#ff6b6b">Отозвано</span>':'<span style="color:#34d399">Активно</span>'
|
||||
var ls=d.last_seen||'—'
|
||||
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=>{
|
||||
document.getElementById('op-count').textContent=stats.ops||'0'
|
||||
})
|
||||
function copyKey(key,btn){
|
||||
navigator.clipboard.writeText(key).then(()=>{
|
||||
var old=btn.textContent;btn.textContent='Скопировано';btn.style.color='#4ade80'
|
||||
setTimeout(function(){btn.textContent=old;btn.style.color=''},1500)
|
||||
})
|
||||
function revokeDevice(id){
|
||||
if(!confirm('Отозвать устройство?'))return
|
||||
fetch('/admin/api/keys/'+id,{method:'DELETE'}).then(()=>location.reload())
|
||||
}
|
||||
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 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})}
|
||||
@@ -1654,12 +2028,6 @@ function testSMTP(){
|
||||
}
|
||||
</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 class="modal">
|
||||
<button class="modal-close" onclick="closeSMTP()">×</button>
|
||||
@@ -1759,6 +2127,35 @@ func (s *Server) handleAdminAPI(w http.ResponseWriter, r *http.Request) {
|
||||
path := strings.TrimPrefix(r.URL.Path, "/admin")
|
||||
|
||||
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":
|
||||
rows, err := s.db.Query("SELECT id, name, api_key FROM server_devices ORDER BY created_at")
|
||||
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}
|
||||
a{color:#6366f1}
|
||||
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{font-size:12px;color:#888;text-transform:uppercase;cursor:pointer;user-select:none}
|
||||
th:hover{color:#b0b0c0}
|
||||
|
||||
+4
-4
@@ -704,12 +704,12 @@ func runSyncPull(args []string) {
|
||||
syncSvc := syncsvc.NewService(db, deviceID)
|
||||
client := syncsvc.NewClient(cfg.Sync.ServerURL, cfg.Sync.APIKey, deviceID, abs)
|
||||
|
||||
_, _, lastRev, _, err := syncSvc.GetState()
|
||||
_, _, lastSeq, _, err := syncSvc.GetState()
|
||||
if err != nil {
|
||||
lastRev = 0
|
||||
lastSeq = 0
|
||||
}
|
||||
|
||||
result, err := client.Pull(lastRev)
|
||||
result, err := client.Pull(lastSeq)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Pull failed: %v\n", err)
|
||||
os.Exit(1)
|
||||
@@ -725,7 +725,7 @@ func runSyncPull(args []string) {
|
||||
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) {
|
||||
|
||||
Reference in New Issue
Block a user