package main import ( "crypto/rand" "crypto/sha256" "crypto/tls" "database/sql" "encoding/hex" "encoding/json" "fmt" "io" "log" "net" "net/http" "strconv" "net/smtp" "os" "path/filepath" "regexp" "strings" "sync" "time" "golang.org/x/crypto/bcrypt" "gopkg.in/yaml.v3" _ "github.com/mattn/go-sqlite3" ) var passwordRE = regexp.MustCompile(`^[A-Za-z0-9]+$`) // ============================================================ // Config // ============================================================ type AdminUser struct { Username string `yaml:"username"` PasswordHash string `yaml:"password_hash"` } type Config struct { Port int `yaml:"port"` Admin []AdminUser `yaml:"admin"` mu sync.Mutex path string } func LoadConfig(dataDir string) (*Config, error) { path := filepath.Join(dataDir, "config.yml") cfg := &Config{ Port: 47732, Admin: nil, path: path, } data, err := os.ReadFile(path) if err == nil { if err := yaml.Unmarshal(data, cfg); err != nil { return nil, fmt.Errorf("parse config: %w", err) } } return cfg, nil } func (c *Config) Save() error { c.mu.Lock() defer c.mu.Unlock() data, err := yaml.Marshal(c) if err != nil { return err } return os.WriteFile(c.path, data, 0640) } func (c *Config) SetAdmin(username, password string) error { c.mu.Lock() defer c.mu.Unlock() hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) if err != nil { return err } user := AdminUser{Username: username, PasswordHash: string(hash)} // Replace existing or append. for i, u := range c.Admin { if u.Username == username { c.Admin[i] = user return c.saveLocked() } } c.Admin = append(c.Admin, user) return c.saveLocked() } func (c *Config) CheckAdmin(username, password string) bool { c.mu.Lock() defer c.mu.Unlock() for _, u := range c.Admin { if u.Username == username { if bcrypt.CompareHashAndPassword([]byte(u.PasswordHash), []byte(password)) == nil { return true } } } return false } func (c *Config) saveLocked() error { data, err := yaml.Marshal(c) if err != nil { return err } return os.WriteFile(c.path, data, 0640) } // ============================================================ // Token // ============================================================ type tokenStore struct { mu sync.Mutex tokens map[string]time.Time } func newTokenStore() *tokenStore { return &tokenStore{tokens: make(map[string]time.Time)} } func (ts *tokenStore) Create() string { ts.mu.Lock() defer ts.mu.Unlock() b := make([]byte, 16) rand.Read(b) tok := hex.EncodeToString(b) ts.tokens[tok] = time.Now().Add(24 * time.Hour) return tok } func (ts *tokenStore) Check(tok string) bool { ts.mu.Lock() defer ts.mu.Unlock() exp, ok := ts.tokens[tok] if !ok { return false } if time.Now().After(exp) { delete(ts.tokens, tok) return false } return true } // userTokenStore embeds tokenStore but also tracks the user_id per token. type userTokenStore struct { mu sync.Mutex tokens map[string]userTokenEntry } type userTokenEntry struct { UserID string ExpiresAt time.Time } func newUserTokenStore() *userTokenStore { return &userTokenStore{tokens: make(map[string]userTokenEntry)} } func (uts *userTokenStore) Create(userID string) string { uts.mu.Lock() defer uts.mu.Unlock() b := make([]byte, 16) rand.Read(b) tok := hex.EncodeToString(b) uts.tokens[tok] = userTokenEntry{UserID: userID, ExpiresAt: time.Now().Add(24 * time.Hour)} return tok } func (uts *userTokenStore) Check(tok string) (string, bool) { uts.mu.Lock() defer uts.mu.Unlock() entry, ok := uts.tokens[tok] if !ok { return "", false } if time.Now().After(entry.ExpiresAt) { delete(uts.tokens, tok) return "", false } return entry.UserID, true } // ============================================================ // Server DB schema // ============================================================ const serverSchema = ` 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 ); CREATE TABLE IF NOT EXISTS server_revisions ( rev INTEGER PRIMARY KEY AUTOINCREMENT, op_id TEXT NOT NULL, device_id TEXT NOT NULL, created_at TEXT NOT NULL DEFAULT (datetime('now')) ); 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, created_at TEXT NOT NULL ); CREATE TABLE IF NOT EXISTS server_smtp_config ( key TEXT PRIMARY KEY, value TEXT NOT NULL ); CREATE TABLE IF NOT EXISTS server_users ( id TEXT PRIMARY KEY, username TEXT NOT NULL UNIQUE, email TEXT NOT NULL UNIQUE, password_hash TEXT NOT NULL, confirmed INTEGER NOT NULL DEFAULT 0, blocked INTEGER NOT NULL DEFAULT 0, last_seen TEXT, created_at TEXT NOT NULL ); CREATE TABLE IF NOT EXISTS server_email_tokens ( token TEXT PRIMARY KEY, user_id TEXT NOT NULL, purpose TEXT NOT NULL, expires_at TEXT NOT NULL, created_at TEXT NOT NULL ); CREATE TABLE IF NOT EXISTS server_user_devices ( user_id TEXT NOT NULL, 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 tokens *tokenStore 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) { db, err := sql.Open("sqlite3", fmt.Sprintf("file:%s?mode=rwc", dbPath)) if err != nil { return nil, fmt.Errorf("open db: %w", err) } db.SetMaxOpenConns(1) // Run schema. for _, stmt := range strings.Split(serverSchema, ";") { stmt = strings.TrimSpace(stmt) if stmt == "" { continue } if _, err := db.Exec(stmt); err != nil { db.Close() return nil, fmt.Errorf("schema: %w", err) } } // 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 { db.Close() return nil, err } s := &Server{ db: db, cfg: cfg, tokens: newTokenStore(), userTokens: newUserTokenStore(), blobsDir: blobsDir, pairLimit: &pairRateLimit{}, } s.mux = s.routes() return s, nil } func (s *Server) Close() error { return s.db.Close() } func (s *Server) ListenAndServe(addr string) error { return http.ListenAndServe(addr, s.mux) } // ============================================================ // Routes // ============================================================ func (s *Server) routes() *http.ServeMux { mux := http.NewServeMux() mux.HandleFunc("/api/v1/health", s.handleHealth) mux.HandleFunc("/api/v1/device/register", s.handleDeviceRegister) 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/auth/test", s.handleAuthTest) 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) mux.HandleFunc("/api/v1/auth/forgot", s.handleForgot) mux.HandleFunc("/api/v1/auth/reset", s.handleReset) mux.HandleFunc("/forgot", s.handleUserWebForgot) mux.HandleFunc("/reset", s.handleUserWebReset) mux.HandleFunc("/api/v1/user/devices", s.handleUserDevices) mux.HandleFunc("/register", s.handleUserWebRegister) mux.HandleFunc("/login", s.handleUserWebLogin) mux.HandleFunc("/dashboard", s.handleUserDashboard) mux.HandleFunc("/logout", s.handleUserWebLogout) mux.HandleFunc("/admin/login", s.handleAdminLogin) mux.HandleFunc("/admin/dashboard", s.handleAdminDashboard) mux.HandleFunc("/admin/users", s.handleAdminUsers) mux.HandleFunc("/admin/api/stats", s.handleAdminStats) mux.HandleFunc("/admin/api/smtp/test", s.handleAdminSMTPTest) mux.HandleFunc("/admin/", s.handleAdminAPI) mux.HandleFunc("/", s.handleNotFound) return mux } // ============================================================ // Helpers // ============================================================ func jsonOK(w http.ResponseWriter, v interface{}) { w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(v) } func jsonErr(w http.ResponseWriter, code int, msg string) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(code) json.NewEncoder(w).Encode(map[string]string{"error": msg}) } func (s *Server) requireAPIKey(w http.ResponseWriter, r *http.Request) bool { key := r.Header.Get("Authorization") key = strings.TrimPrefix(key, "Bearer ") if key == "" { key = r.URL.Query().Get("api_key") } if key == "" { 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) if err != nil || count == 0 { jsonErr(w, 401, "invalid API key") return false } return true } func (s *Server) requireAdmin(w http.ResponseWriter, r *http.Request) bool { cookie, err := r.Cookie("session") if err != nil || !s.tokens.Check(cookie.Value) { http.Redirect(w, r, "/admin/login", http.StatusFound) return false } return true } // ============================================================ // SMTP Config // ============================================================ func (s *Server) smtpGet(key string) string { var val string s.db.QueryRow("SELECT value FROM server_smtp_config WHERE key=?", key).Scan(&val) return val } func (s *Server) smtpSet(key, val string) error { _, err := s.db.Exec("INSERT OR REPLACE INTO server_smtp_config (key, value) VALUES (?, ?)", key, val) return err } func errorPageHTML(title, msg, backURL string) string { return fmt.Sprintf(` Verstak Sync — %s

%s

%s

← Назад
`, 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" } return "" } func (s *Server) smtpConnect(host, port, user, pass, security string) (*smtp.Client, error) { addr := net.JoinHostPort(host, port) switch security { case "tls": tlsCfg := &tls.Config{ServerName: host} conn, err := tls.Dial("tcp", addr, tlsCfg) if err != nil { return nil, fmt.Errorf("tls dial: %w", err) } cl, err := smtp.NewClient(conn, host) if err != nil { conn.Close() return nil, fmt.Errorf("smtp client: %w", err) } return cl, nil default: conn, err := net.DialTimeout("tcp", addr, 10*time.Second) if err != nil { return nil, fmt.Errorf("connect: %w", err) } cl, err := smtp.NewClient(conn, host) if err != nil { conn.Close() return nil, fmt.Errorf("smtp client: %w", err) } if security != "none" { if ok, _ := cl.Extension("STARTTLS"); ok { tlsCfg := &tls.Config{ServerName: host} if err := cl.StartTLS(tlsCfg); err != nil { cl.Close() return nil, fmt.Errorf("starttls: %w", err) } } } return cl, nil } } func (s *Server) smtpSendMsg(cl *smtp.Client, user, pass, host, from, to string, msg []byte) error { if user != "" { auth := smtp.PlainAuth("", user, pass, host) if err := cl.Auth(auth); err != nil { return fmt.Errorf("auth: %w", err) } } if err := cl.Mail(from); err != nil { return fmt.Errorf("mail from: %w", err) } if err := cl.Rcpt(to); err != nil { return fmt.Errorf("rcpt: %w", err) } w, err := cl.Data() if err != nil { return fmt.Errorf("data: %w", err) } if _, err := w.Write(msg); err != nil { w.Close() return fmt.Errorf("write: %w", err) } if err := w.Close(); err != nil { return fmt.Errorf("send: %w", err) } return nil } func (s *Server) smtpSend(to, subject, body string) error { host := s.smtpGet("smtp_host") port := s.smtpGet("smtp_port") user := s.smtpGet("smtp_user") pass := s.smtpGet("smtp_pass") from := s.smtpGet("smtp_from") security := s.smtpGet("smtp_security") if host == "" || port == "" || from == "" { err := fmt.Errorf("SMTP not configured") log.Printf("smtp: %v (to=%s)", err, to) return err } log.Printf("smtp: sending to %s via %s:%s (security=%s)", to, host, port, security) msg := []byte("From: " + from + "\r\n" + "To: " + to + "\r\n" + "Subject: " + subject + "\r\n" + "MIME-Version: 1.0\r\n" + "Content-Type: text/plain; charset=UTF-8\r\n" + "\r\n" + body + "\r\n") cl, err := s.smtpConnect(host, port, user, pass, security) if err != nil { log.Printf("smtp: connect error: %v", err) return err } defer cl.Close() if err := s.smtpSendMsg(cl, user, pass, host, from, to, msg); err != nil { log.Printf("smtp: send error: %v", err) return err } log.Printf("smtp: sent OK to %s", to) return nil } func (s *Server) smtpTest(host, port, user, pass, security, from, to string) error { if host == "" || port == "" || from == "" { return fmt.Errorf("SMTP not configured") } msg := []byte("From: " + from + "\r\nTo: " + to + "\r\nSubject: Test from Verstak Sync\r\n\r\nThis is a test email from Verstak Sync Server.\r\n") cl, err := s.smtpConnect(host, port, user, pass, security) if err != nil { return err } defer cl.Close() return s.smtpSendMsg(cl, user, pass, host, from, to, msg) } // ============================================================ // User helpers // ============================================================ func validatePassword(password string) string { if len(password) < 8 { return "Password must be at least 8 characters" } if !passwordRE.MatchString(password) { return "Password must contain only Latin letters and digits" } hasLetter := false hasDigit := false for _, ch := range password { if ch >= 'A' && ch <= 'Z' || ch >= 'a' && ch <= 'z' { hasLetter = true } if ch >= '0' && ch <= '9' { hasDigit = true } } if !hasLetter || !hasDigit { return "Password must contain both letters and digits" } return "" } func (s *Server) requireUser(w http.ResponseWriter, r *http.Request) (string, bool) { key := r.Header.Get("Authorization") key = strings.TrimPrefix(key, "Bearer ") if key == "" { jsonErr(w, 401, "authorization required") return "", false } userID, ok := s.userTokens.Check(key) if !ok { jsonErr(w, 401, "invalid or expired token") return "", false } return userID, true } // ============================================================ // Handlers // ============================================================ func (s *Server) handleNotFound(w http.ResponseWriter, r *http.Request) { if r.URL.Path == "/" { w.Header().Set("Content-Type", "text/plain; charset=utf-8") w.Write([]byte("Verstak Sync Server\n")) return } jsonErr(w, 404, "not found") } func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) { jsonOK(w, map[string]interface{}{ "status": "ok", "version": "verstak-server/v1", "time": time.Now().UTC().Format(time.RFC3339), }) } 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) handleAuthTest(w http.ResponseWriter, r *http.Request) { if r.Method != "POST" { jsonErr(w, 405, "POST required") return } var req struct { Username string `json:"username"` Password string `json:"password"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { jsonErr(w, 400, "bad json") return } if req.Username == "" || req.Password == "" { jsonErr(w, 400, "username and password required") return } var hash string var confirmed, blocked int err := s.db.QueryRow("SELECT password_hash, confirmed, blocked FROM server_users WHERE username=? OR email=?", req.Username, strings.ToLower(req.Username)).Scan(&hash, &confirmed, &blocked) if err != nil { jsonErr(w, 401, "invalid credentials") return } if blocked != 0 { jsonErr(w, 403, "account blocked") return } if confirmed == 0 { jsonErr(w, 403, "email not confirmed") return } if bcrypt.CompareHashAndPassword([]byte(hash), []byte(req.Password)) != nil { jsonErr(w, 401, "invalid credentials") return } jsonOK(w, map[string]string{"status": "ok"}) } 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") return } var req struct { Name string `json:"name"` Username string `json:"username"` Password string `json:"password"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { jsonErr(w, 400, "invalid JSON") return } if req.Name == "" { jsonErr(w, 400, "name required") return } if req.Username == "" || req.Password == "" { jsonErr(w, 401, "username and password required") return } // Look up user by username or email. 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.Username, strings.ToLower(req.Username)).Scan(&userID, &hash, &confirmed, &blocked) if err != nil { jsonErr(w, 401, "invalid credentials") return } if blocked != 0 { jsonErr(w, 403, "account blocked") return } if confirmed == 0 { jsonErr(w, 403, "email not confirmed") return } if bcrypt.CompareHashAndPassword([]byte(hash), []byte(req.Password)) != nil { jsonErr(w, 401, "invalid credentials") return } b := make([]byte, 20) rand.Read(b) apiKey := hex.EncodeToString(b) deviceID := apiKey[:12] now := time.Now().UTC().Format(time.RFC3339) _, err = s.db.Exec( "INSERT INTO server_devices (id, name, api_key, last_seen, created_at) VALUES (?, ?, ?, ?, ?)", deviceID, req.Name, apiKey, now, now, ) if err != nil { jsonErr(w, 500, err.Error()) return } // Link device to user. s.db.Exec("INSERT OR IGNORE INTO server_user_devices (user_id, device_id) VALUES (?, ?)", userID, deviceID) jsonOK(w, map[string]interface{}{ "device_id": deviceID, "api_key": apiKey, }) } // ============================================================ // Auth / User handlers // ============================================================ func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) { if r.Method != "POST" { jsonErr(w, 405, "POST required") return } var req struct { Username string `json:"username"` Email string `json:"email"` Password string `json:"password"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { jsonErr(w, 400, "invalid JSON") return } if req.Username == "" || req.Email == "" || req.Password == "" { jsonErr(w, 400, "username, email and password required") return } if err := validatePassword(req.Password); err != "" { jsonErr(w, 400, err) return } if !strings.Contains(req.Email, "@") || !strings.Contains(req.Email, ".") { jsonErr(w, 400, "invalid email") return } hash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost) if err != nil { jsonErr(w, 500, "internal error") return } now := time.Now().UTC().Format(time.RFC3339) id := make([]byte, 12) rand.Read(id) userID := hex.EncodeToString(id) _, err = s.db.Exec( "INSERT INTO server_users (id, username, email, password_hash, confirmed, created_at) VALUES (?, ?, ?, ?, 0, ?)", userID, req.Username, strings.ToLower(req.Email), string(hash), now, ) if err != nil { if strings.Contains(err.Error(), "UNIQUE") { jsonErr(w, 409, "username or email already taken") return } jsonErr(w, 500, err.Error()) return } // Confirmation token. tok := make([]byte, 24) rand.Read(tok) tokenStr := hex.EncodeToString(tok) exp := time.Now().Add(48 * time.Hour).UTC().Format(time.RFC3339) s.db.Exec("INSERT INTO server_email_tokens (token, user_id, purpose, expires_at, created_at) VALUES (?, ?, 'confirm', ?, ?)", tokenStr, userID, exp, now) // Try to send email. host := s.smtpGet("smtp_host") if host != "" { srvURL := s.smtpGet("server_url") var confirmURL string if srvURL != "" { confirmURL = fmt.Sprintf("%s/confirm?token=%s", srvURL, tokenStr) } else { confirmURL = fmt.Sprintf("/api/v1/auth/confirm?token=%s", tokenStr) } body := fmt.Sprintf("Welcome to Verstak Sync!\n\nPlease confirm your email by clicking:\n%s\n\nIf you did not register, ignore this message.", confirmURL) if err := s.smtpSend(req.Email, "Confirm your Verstak Sync account", body); err != nil { log.Printf("register: failed to send confirm email: %v", err) } } else { log.Printf("register: SMTP not configured, confirmation token=%s for user %s", tokenStr, req.Username) } jsonOK(w, map[string]string{"status": "confirmation_sent"}) } func (s *Server) handleConfirm(w http.ResponseWriter, r *http.Request) { if r.Method != "GET" { jsonErr(w, 405, "GET required") return } tokenStr := r.URL.Query().Get("token") if tokenStr == "" { jsonErr(w, 400, "token required") return } var userID, expiresAt string err := s.db.QueryRow("SELECT user_id, expires_at FROM server_email_tokens WHERE token=? AND purpose='confirm'", tokenStr).Scan(&userID, &expiresAt) if err != nil { jsonErr(w, 400, "invalid or expired token") return } exp, err := time.Parse(time.RFC3339, expiresAt) if err != nil || time.Now().After(exp) { jsonErr(w, 400, "token expired") return } s.db.Exec("UPDATE server_users SET confirmed=1 WHERE id=?", userID) log.Printf("confirm: user %s confirmed email", userID) s.db.Exec("DELETE FROM server_email_tokens WHERE token=?", tokenStr) w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Write([]byte(confirmedHTML)) } func (s *Server) handleUserLogin(w http.ResponseWriter, r *http.Request) { if r.Method != "POST" { jsonErr(w, 405, "POST required") return } var req struct { Username string `json:"username"` Password string `json:"password"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { jsonErr(w, 400, "invalid JSON") return } if req.Username == "" || req.Password == "" { jsonErr(w, 400, "username and password required") return } 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.Username, strings.ToLower(req.Username)).Scan(&userID, &hash, &confirmed, &blocked) if err != nil { jsonErr(w, 401, "invalid credentials") return } if blocked != 0 { jsonErr(w, 403, "account blocked") return } if confirmed == 0 { jsonErr(w, 403, "email not confirmed") return } if bcrypt.CompareHashAndPassword([]byte(hash), []byte(req.Password)) != nil { jsonErr(w, 401, "invalid credentials") return } s.db.Exec("UPDATE server_users SET last_seen=? WHERE id=?", time.Now().UTC().Format(time.RFC3339), userID) tok := s.userTokens.Create(userID) jsonOK(w, map[string]string{"token": tok, "user_id": userID}) } func (s *Server) handleForgot(w http.ResponseWriter, r *http.Request) { if r.Method != "POST" { jsonErr(w, 405, "POST required") return } var req struct { Email string `json:"email"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { jsonErr(w, 400, "invalid JSON") return } if req.Email == "" { jsonErr(w, 400, "email required") return } var userID string err := s.db.QueryRow("SELECT id FROM server_users WHERE email=?", strings.ToLower(req.Email)).Scan(&userID) if err != nil { jsonOK(w, map[string]string{"status": "if email exists, reset link sent"}) return } tok := make([]byte, 24) rand.Read(tok) tokenStr := hex.EncodeToString(tok) exp := time.Now().Add(1 * time.Hour).UTC().Format(time.RFC3339) now := time.Now().UTC().Format(time.RFC3339) s.db.Exec("INSERT INTO server_email_tokens (token, user_id, purpose, expires_at, created_at) VALUES (?, ?, 'reset', ?, ?)", tokenStr, userID, exp, now) host := s.smtpGet("smtp_host") if host != "" { srvURL := s.smtpGet("server_url") resetURL := fmt.Sprintf("/api/v1/auth/reset?token=%s", tokenStr) if srvURL != "" { resetURL = fmt.Sprintf("%s/api/v1/auth/reset?token=%s", srvURL, tokenStr) } body := fmt.Sprintf("Reset your Verstak Sync password:\n\n%s\n\nThis link expires in 1 hour.", resetURL) s.smtpSend(req.Email, "Verstak Sync password reset", body) } jsonOK(w, map[string]string{"status": "if email exists, reset link sent"}) } func (s *Server) handleReset(w http.ResponseWriter, r *http.Request) { if r.Method != "POST" { jsonErr(w, 405, "POST required") return } var req struct { Token string `json:"token"` NewPassword string `json:"new_password"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { jsonErr(w, 400, "invalid JSON") return } if req.Token == "" || req.NewPassword == "" { jsonErr(w, 400, "token and new_password required") return } if err := validatePassword(req.NewPassword); err != "" { jsonErr(w, 400, err) return } var userID, expiresAt string err := s.db.QueryRow("SELECT user_id, expires_at FROM server_email_tokens WHERE token=? AND purpose='reset'", req.Token).Scan(&userID, &expiresAt) if err != nil { jsonErr(w, 400, "invalid or expired token") return } exp, err := time.Parse(time.RFC3339, expiresAt) if err != nil || time.Now().After(exp) { jsonErr(w, 400, "token expired") return } hash, err := bcrypt.GenerateFromPassword([]byte(req.NewPassword), bcrypt.DefaultCost) if err != nil { jsonErr(w, 500, "internal error") return } s.db.Exec("UPDATE server_users SET password_hash=? WHERE id=?", string(hash), userID) s.db.Exec("DELETE FROM server_email_tokens WHERE token=?", req.Token) jsonOK(w, map[string]string{"status": "password reset"}) } func (s *Server) handleUserDevices(w http.ResponseWriter, r *http.Request) { userID, ok := s.requireUser(w, r) if !ok { return } if r.Method != "GET" { jsonErr(w, 405, "GET required") return } rows, err := s.db.Query(` SELECT d.id, d.name, d.last_seen, d.created_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 deviceDTO struct { ID string `json:"id"` Name string `json:"name"` LastSeen string `json:"last_seen"` CreatedAt string `json:"created_at"` } var devices []deviceDTO for rows.Next() { var d deviceDTO var lastSeen sql.NullString if err := rows.Scan(&d.ID, &d.Name, &lastSeen, &d.CreatedAt); err != nil { continue } d.LastSeen = lastSeen.String devices = append(devices, d) } if devices == nil { devices = []deviceDTO{} } jsonOK(w, map[string]interface{}{"devices": devices}) } func (s *Server) handleSyncPush(w http.ResponseWriter, r *http.Request) { if !s.requireAPIKey(w, r) { return } if r.Method != "POST" { jsonErr(w, 405, "POST required") return } var req struct { 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 { jsonErr(w, 400, "invalid JSON: "+err.Error()) 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 } // 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() } } res, err := s.db.Exec( `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 } 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) } 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) { if !s.requireAPIKey(w, r) { return } if r.Method != "POST" { jsonErr(w, 405, "POST required") return } var req struct { SinceSequence int `json:"since_sequence"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { jsonErr(w, 400, "invalid JSON") return } var serverSeq int s.db.QueryRow("SELECT COALESCE(MAX(server_sequence), 0) FROM server_ops").Scan(&serverSeq) rows, err := s.db.Query(` 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 } defer rows.Close() type opDTO struct { 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.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_sequence": serverSeq, "ops": ops, }) } func (s *Server) handleBlobs(w http.ResponseWriter, r *http.Request) { if !s.requireAPIKey(w, r) { return } switch r.Method { case "POST": // Upload: accept multipart file, store by SHA-256. if err := r.ParseMultipartForm(200 << 20); err != nil { jsonErr(w, 400, "multipart error: "+err.Error()) return } file, header, err := r.FormFile("file") if err != nil { jsonErr(w, 400, "file field required") return } defer file.Close() // Read content and compute SHA-256. data, err := io.ReadAll(file) if err != nil { jsonErr(w, 500, "read error") return } hash := sha256.Sum256(data) shaHex := hex.EncodeToString(hash[:]) // Store at blobs/ab/cd/sha256. blobDir := filepath.Join(s.blobsDir, shaHex[:2], shaHex[2:4]) if err := os.MkdirAll(blobDir, 0750); err != nil { jsonErr(w, 500, "mkdir error") return } blobPath := filepath.Join(blobDir, shaHex) if err := os.WriteFile(blobPath, data, 0640); err != nil { jsonErr(w, 500, "write error") return } _ = header // Record in blobs table. now := time.Now().UTC().Format(time.RFC3339) s.db.Exec("INSERT OR IGNORE INTO server_blobs (sha256, size, created_at) VALUES (?, ?, ?)", shaHex, len(data), now) jsonOK(w, map[string]interface{}{ "sha256": shaHex, "size": len(data), }) case "GET": // Download: GET /api/v1/blobs/{sha256} shaHex := strings.TrimPrefix(r.URL.Path, "/api/v1/blobs/") if len(shaHex) != 64 { jsonErr(w, 400, "invalid SHA-256") return } blobPath := filepath.Join(s.blobsDir, shaHex[:2], shaHex[2:4], shaHex) if _, err := os.Stat(blobPath); os.IsNotExist(err) { jsonErr(w, 404, "blob not found") return } data, err := os.ReadFile(blobPath) if err != nil { jsonErr(w, 500, "read error") return } w.Header().Set("Content-Type", "application/octet-stream") w.Header().Set("Content-Disposition", "attachment; filename=\""+shaHex+"\"") w.Write(data) default: jsonErr(w, 405, "method not allowed") } } // ============================================================ // User web GUI // ============================================================ func (s *Server) requireUserWeb(w http.ResponseWriter, r *http.Request) (string, bool) { cookie, err := r.Cookie("user_session") if err != nil { http.Redirect(w, r, "/login", http.StatusFound) return "", false } userID, ok := s.userTokens.Check(cookie.Value) if !ok { http.Redirect(w, r, "/login", http.StatusFound) return "", false } return userID, true } func (s *Server) handleUserWebRegister(w http.ResponseWriter, r *http.Request) { switch r.Method { case "GET": w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Write([]byte(userRegisterHTML)) case "POST": if err := r.ParseForm(); err != nil { w.Header().Set("Content-Type", "text/html; charset=utf-8") w.WriteHeader(400) w.Write([]byte("

400 Bad request

Back")) return } username := r.FormValue("username") email := r.FormValue("email") password := r.FormValue("password") if username == "" || email == "" || password == "" { w.Header().Set("Content-Type", "text/html; charset=utf-8") w.WriteHeader(400) w.Write([]byte("

All fields required

Back")) return } if err := validatePassword(password); err != "" { w.Header().Set("Content-Type", "text/html; charset=utf-8") w.WriteHeader(400) w.Write([]byte("

" + err + "

Back")) return } hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) if err != nil { w.WriteHeader(500) w.Write([]byte("

Internal error

Back")) return } now := time.Now().UTC().Format(time.RFC3339) id := make([]byte, 12) rand.Read(id) userID := hex.EncodeToString(id) _, err = s.db.Exec( "INSERT INTO server_users (id, username, email, password_hash, confirmed, created_at) VALUES (?, ?, ?, ?, 0, ?)", userID, username, strings.ToLower(email), string(hash), now, ) if err != nil { w.Header().Set("Content-Type", "text/html; charset=utf-8") if strings.Contains(err.Error(), "UNIQUE") { w.WriteHeader(409) w.Write([]byte("

Username or email already taken

Back")) } else { w.WriteHeader(500) w.Write([]byte("

"+err.Error()+"

Back")) } return } // Confirmation token. tok := make([]byte, 24) rand.Read(tok) tokenStr := hex.EncodeToString(tok) exp := time.Now().Add(48 * time.Hour).UTC().Format(time.RFC3339) s.db.Exec("INSERT INTO server_email_tokens (token, user_id, purpose, expires_at, created_at) VALUES (?, ?, 'confirm', ?, ?)", tokenStr, userID, exp, now) // Try to send email. host := s.smtpGet("smtp_host") if host != "" { srvURL := s.smtpGet("server_url") var confirmURL string if srvURL != "" { confirmURL = fmt.Sprintf("%s/api/v1/auth/confirm?token=%s", srvURL, tokenStr) } else { confirmURL = fmt.Sprintf("http://%s/api/v1/auth/confirm?token=%s", r.Host, tokenStr) } body := fmt.Sprintf("Welcome to Verstak Sync!\n\nPlease confirm your email by clicking:\n%s\n\nIf you did not register, ignore this message.", confirmURL) if err := s.smtpSend(email, "Confirm your Verstak Sync account", body); err != nil { log.Printf("register web: failed to send confirm email: %v", err) } } else { log.Printf("register web: SMTP not configured, confirmation token=%s for user %s", tokenStr, username) } w.Header().Set("Content-Type", "text/html; charset=utf-8") regMsg := registrationOKHTML if host == "" { regMsg = registrationAutoHTML } w.Write([]byte(regMsg)) default: jsonErr(w, 405, "method not allowed") } } func (s *Server) handleUserWebForgot(w http.ResponseWriter, r *http.Request) { switch r.Method { case "GET": w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Write([]byte(forgotPasswordHTML)) case "POST": if err := r.ParseForm(); err != nil { jsonErr(w, 400, "bad form") return } email := strings.ToLower(r.FormValue("email")) if email == "" { w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Write([]byte(errorPageHTML("Ошибка", "Email обязателен", "/forgot"))) return } var userID string err := s.db.QueryRow("SELECT id FROM server_users WHERE email=?", email).Scan(&userID) if err != nil { w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Write([]byte(forgotSentHTML)) return } tok := make([]byte, 24) rand.Read(tok) tokenStr := hex.EncodeToString(tok) exp := time.Now().Add(1 * time.Hour).UTC().Format(time.RFC3339) now := time.Now().UTC().Format(time.RFC3339) s.db.Exec("INSERT INTO server_email_tokens (token, user_id, purpose, expires_at, created_at) VALUES (?, ?, 'reset', ?, ?)", tokenStr, userID, exp, now) host := s.smtpGet("smtp_host") if host != "" { srvURL := s.smtpGet("server_url") resetURL := fmt.Sprintf("/reset?token=%s", tokenStr) if srvURL != "" { resetURL = fmt.Sprintf("%s/reset?token=%s", srvURL, tokenStr) } body := fmt.Sprintf("Reset your Verstak Sync password:\n\n%s\n\nThis link expires in 1 hour.", resetURL) if err := s.smtpSend(email, "Verstak Sync password reset", body); err != nil { log.Printf("forgot web: failed to send reset email: %v", err) } } else { log.Printf("forgot web: SMTP not configured, reset token=%s for email %s", tokenStr, email) } w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Write([]byte(forgotSentHTML)) default: jsonErr(w, 405, "method not allowed") } } func (s *Server) handleUserWebReset(w http.ResponseWriter, r *http.Request) { switch r.Method { case "GET": token := r.URL.Query().Get("token") if token == "" { http.Redirect(w, r, "/forgot", http.StatusFound) return } // Validate token exists and not expired before showing form. var userID, expiresAt string err := s.db.QueryRow("SELECT user_id, expires_at FROM server_email_tokens WHERE token=? AND purpose='reset'", token).Scan(&userID, &expiresAt) if err != nil { http.Redirect(w, r, "/forgot", http.StatusFound) return } exp, err := time.Parse(time.RFC3339, expiresAt) if err != nil || time.Now().After(exp) { http.Redirect(w, r, "/forgot", http.StatusFound) return } w.Header().Set("Content-Type", "text/html; charset=utf-8") html := strings.ReplaceAll(resetPasswordHTML, "{TOKEN}", token) w.Write([]byte(html)) case "POST": if err := r.ParseForm(); err != nil { jsonErr(w, 400, "bad form") return } token := r.FormValue("token") newPass := r.FormValue("password") confirm := r.FormValue("confirm") if token == "" || newPass == "" || confirm == "" { w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Write([]byte(errorPageHTML("Ошибка", "Все поля обязательны", "/forgot"))) return } if err := validatePassword(newPass); err != "" { w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Write([]byte(errorPageHTML("Ошибка", err, "/reset?token="+token))) return } if newPass != confirm { w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Write([]byte(errorPageHTML("Ошибка", "Пароли не совпадают", "/reset?token="+token))) return } var userID string err := s.db.QueryRow("SELECT user_id FROM server_email_tokens WHERE token=? AND purpose='reset'", token).Scan(&userID) if err != nil { http.Redirect(w, r, "/forgot", http.StatusFound) return } hash, _ := bcrypt.GenerateFromPassword([]byte(newPass), bcrypt.DefaultCost) s.db.Exec("UPDATE server_users SET password_hash=? WHERE id=?", string(hash), userID) s.db.Exec("DELETE FROM server_email_tokens WHERE token=?", token) log.Printf("reset: user %s reset password", userID) w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Write([]byte(resetDoneHTML)) default: jsonErr(w, 405, "method not allowed") } } func (s *Server) handleUserWebLogin(w http.ResponseWriter, r *http.Request) { switch r.Method { case "GET": w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Write([]byte(userLoginHTML)) case "POST": if err := r.ParseForm(); err != nil { jsonErr(w, 400, "bad form") return } username := r.FormValue("username") password := r.FormValue("password") 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=?", username, strings.ToLower(username)).Scan(&userID, &hash, &confirmed, &blocked) if err != nil || blocked != 0 || confirmed == 0 || bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) != nil { w.Header().Set("Content-Type", "text/html; charset=utf-8") w.WriteHeader(401) w.Write([]byte("

401 Unauthorized

Try again")) return } tok := s.userTokens.Create(userID) http.SetCookie(w, &http.Cookie{ Name: "user_session", Value: tok, Path: "/", HttpOnly: true, SameSite: http.SameSiteLaxMode, MaxAge: 86400, }) http.Redirect(w, r, "/dashboard", http.StatusFound) default: jsonErr(w, 405, "method not allowed") } } func (s *Server) handleUserDashboard(w http.ResponseWriter, r *http.Request) { userID, ok := s.requireUserWeb(w, r) if !ok { return } var username string s.db.QueryRow("SELECT username FROM server_users WHERE id=?", userID).Scan(&username) // 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, 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 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) } } w.Header().Set("Content-Type", "text/html; charset=utf-8") deviceRows := "" if len(devices) == 0 { deviceRows = "Нет подключённых устройств.
Подключите устройство из desktop-клиента Verstak." } else { for _, d := range devices { ls := d.LastSeen if ls == "" { ls = "—" } created := d.CreatedAt if len(created) > 10 { created = created[:10] } status := "Активно" revokeBtn := fmt.Sprintf(``, d.ID) if d.RevokedAt != "" { status = "Отозвано" revokeBtn = "" } deviceRows += fmt.Sprintf(` %s %s %s %s %s %s `, d.Name, status, created, ls, d.ClientVer, revokeBtn) } } html := fmt.Sprintf(` Verstak Sync — %s

Verstak Sync

%s · Выйти

Устройства

%s
УстройствоСтатусПодключеноАктивностьВерсия

Подключить новое устройство

Откройте desktop-клиент Verstak, перейдите в настройки синхронизации и введите URL сервера, логин и пароль.

`, username, username, deviceRows) w.Write([]byte(html)) } func (s *Server) handleUserWebLogout(w http.ResponseWriter, r *http.Request) { http.SetCookie(w, &http.Cookie{ Name: "user_session", Value: "", Path: "/", HttpOnly: true, MaxAge: -1, }) http.Redirect(w, r, "/login", http.StatusFound) } // ============================================================ // Admin handlers // ============================================================ func (s *Server) handleAdminLogin(w http.ResponseWriter, r *http.Request) { switch r.Method { case "GET": w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Write([]byte(adminLoginHTML)) case "POST": if err := r.ParseForm(); err != nil { jsonErr(w, 400, "bad form") return } user := r.FormValue("username") pass := r.FormValue("password") if !s.cfg.CheckAdmin(user, pass) { w.Header().Set("Content-Type", "text/html; charset=utf-8") w.WriteHeader(401) w.Write([]byte("

401 Unauthorized

Try again")) return } tok := s.tokens.Create() http.SetCookie(w, &http.Cookie{ Name: "session", Value: tok, Path: "/admin", HttpOnly: true, SameSite: http.SameSiteLaxMode, MaxAge: 86400, }) http.Redirect(w, r, "/admin/dashboard", http.StatusFound) default: jsonErr(w, 405, "method not allowed") } } func (s *Server) handleAdminDashboard(w http.ResponseWriter, r *http.Request) { if !s.requireAdmin(w, r) { return } w.Header().Set("Content-Type", "text/html; charset=utf-8") // Fetch data for dashboard. var deviceCount, opsCount int s.db.QueryRow("SELECT COUNT(*) FROM server_devices").Scan(&deviceCount) s.db.QueryRow("SELECT COUNT(*) FROM server_ops").Scan(&opsCount) // Load SMTP config for display. smtpHost := s.smtpGet("smtp_host") smtpPort := s.smtpGet("smtp_port") smtpUser := s.smtpGet("smtp_user") smtpFrom := s.smtpGet("smtp_from") smtpSecurity := s.smtpGet("smtp_security") srvURL := s.smtpGet("server_url") html := ` Verstak Sync — Admin

Verstak Sync Server

Устройств: 0
Операций: 0

Устройства

_ = smtpURL _ = smtpUser _ = smtpFrom _ = smtpSecurity _ = smtpHost _ = smtpPort ` w.Write([]byte(html)) } func (s *Server) handleAdminUsers(w http.ResponseWriter, r *http.Request) { if !s.requireAdmin(w, r) { return } w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Write([]byte(adminUsersHTML)) } func (s *Server) handleAdminStats(w http.ResponseWriter, r *http.Request) { if !s.requireAdmin(w, r) { return } var opsCount int s.db.QueryRow("SELECT COUNT(*) FROM server_ops").Scan(&opsCount) jsonOK(w, map[string]int{"ops": opsCount}) } func (s *Server) handleAdminSMTPTest(w http.ResponseWriter, r *http.Request) { if !s.requireAdmin(w, r) { return } var req struct { Host string `json:"smtp_host"` Port string `json:"smtp_port"` User string `json:"smtp_user"` Pass string `json:"smtp_pass"` Security string `json:"smtp_security"` From string `json:"smtp_from"` To string `json:"test_to"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { jsonErr(w, 400, "bad json") return } host := req.Host port := req.Port user := req.User pass := req.Pass security := req.Security from := req.From to := req.To if to == "" { to = from } if host == "" || port == "" || from == "" { jsonOK(w, map[string]interface{}{"ok": false, "error": "host, port and from required"}) return } if err := s.smtpTest(host, port, user, pass, security, from, to); err != nil { jsonOK(w, map[string]interface{}{"ok": false, "error": err.Error()}) return } jsonOK(w, map[string]interface{}{"ok": true}) } func (s *Server) handleAdminAPI(w http.ResponseWriter, r *http.Request) { if !s.requireAdmin(w, r) { return } 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 { jsonErr(w, 500, err.Error()) return } defer rows.Close() var out []map[string]string for rows.Next() { var id, name, key string rows.Scan(&id, &name, &key) out = append(out, map[string]string{"id": id, "name": name, "api_key": key}) } jsonOK(w, out) case path == "/api/keys" && r.Method == "POST": if err := r.ParseForm(); err != nil { jsonErr(w, 400, "bad form") return } name := r.FormValue("name") if name == "" { jsonErr(w, 400, "name required") return } b := make([]byte, 20) rand.Read(b) apiKey := hex.EncodeToString(b) now := time.Now().UTC().Format(time.RFC3339) _, err := s.db.Exec( "INSERT INTO server_devices (id, name, api_key, last_seen, created_at) VALUES (?, ?, ?, ?, ?)", apiKey[:12], name, apiKey, now, now, ) if err != nil { jsonErr(w, 500, err.Error()) return } http.Redirect(w, r, "/admin/dashboard", http.StatusFound) case strings.HasPrefix(path, "/api/keys/") && r.Method == "DELETE": id := strings.TrimPrefix(path, "/api/keys/") _, err := s.db.Exec("DELETE FROM server_devices WHERE id=?", id) if err != nil { jsonErr(w, 500, err.Error()) return } s.db.Exec("DELETE FROM server_user_devices WHERE device_id=?", id) jsonOK(w, map[string]string{"status": "deleted"}) case path == "/api/smtp" && r.Method == "POST": if err := r.ParseForm(); err != nil { jsonErr(w, 400, "bad form") return } for _, key := range []string{"smtp_host", "smtp_port", "smtp_user", "smtp_pass", "smtp_security", "smtp_from", "server_url"} { val := r.FormValue(key) if val != "" { s.smtpSet(key, val) } } http.Redirect(w, r, "/admin/dashboard", http.StatusFound) case path == "/api/users" && r.Method == "GET": filter := r.URL.Query().Get("filter") sort := r.URL.Query().Get("sort") order := r.URL.Query().Get("order") page, _ := strconv.Atoi(r.URL.Query().Get("page")) perPage, _ := strconv.Atoi(r.URL.Query().Get("per_page")) if page < 1 { page = 1 } if perPage < 1 || perPage > 100 { perPage = 20 } where := "" var args []interface{} if filter != "" { where = " WHERE u.username LIKE ?" args = append(args, "%"+filter+"%") } validSorts := map[string]string{ "username": "u.username", "email": "u.email", "confirmed": "u.confirmed", "blocked": "u.blocked", "created_at": "u.created_at", "last_seen": "u.last_seen", "devices": "devices", } orderClause := "u.created_at DESC" if col, ok := validSorts[sort]; ok { if order != "asc" { order = "desc" } orderClause = col + " " + order } // Count total. var total int countSQL := "SELECT COUNT(*) FROM server_users u" + where s.db.QueryRow(countSQL, args...).Scan(&total) // Fetch page. offset := (page - 1) * perPage sql := `SELECT u.id, u.username, u.email, u.confirmed, u.blocked, u.last_seen, u.created_at, COALESCE((SELECT COUNT(*) FROM server_user_devices ud JOIN server_devices d ON d.id=ud.device_id WHERE ud.user_id=u.id),0) AS devices FROM server_users u` + where + ` ORDER BY ` + orderClause + ` LIMIT ? OFFSET ?` args = append(args, perPage, offset) rows, err := s.db.Query(sql, args...) if err != nil { jsonErr(w, 500, err.Error()) return } defer rows.Close() type userRow struct { ID string `json:"id"` Username string `json:"username"` Email string `json:"email"` Confirmed int `json:"confirmed"` Blocked int `json:"blocked"` LastSeen string `json:"last_seen"` CreatedAt string `json:"created_at"` Devices int `json:"devices"` } var users []userRow for rows.Next() { var u userRow var lastSeen *string rows.Scan(&u.ID, &u.Username, &u.Email, &u.Confirmed, &u.Blocked, &lastSeen, &u.CreatedAt, &u.Devices) if lastSeen != nil { u.LastSeen = *lastSeen } users = append(users, u) } jsonOK(w, map[string]interface{}{ "users": users, "total": total, "page": page, "per_page": perPage, }) case strings.HasPrefix(path, "/api/users/") && r.Method == "POST": sub := strings.TrimPrefix(path, "/api/users/") if strings.HasSuffix(sub, "/block") { id := strings.TrimSuffix(sub, "/block") id = strings.TrimSuffix(id, "/") var blocked int s.db.QueryRow("SELECT blocked FROM server_users WHERE id=?", id).Scan(&blocked) newVal := 1 if blocked != 0 { newVal = 0 } s.db.Exec("UPDATE server_users SET blocked=? WHERE id=?", newVal, id) jsonOK(w, map[string]interface{}{"status": "ok", "blocked": newVal}) return } if strings.HasSuffix(sub, "/reset-password") { id := strings.TrimSuffix(sub, "/reset-password") id = strings.TrimSuffix(id, "/") b := make([]byte, 12) rand.Read(b) newPass := hex.EncodeToString(b) hash, _ := bcrypt.GenerateFromPassword([]byte(newPass), bcrypt.DefaultCost) _, err := s.db.Exec("UPDATE server_users SET password_hash=? WHERE id=?", string(hash), id) if err != nil { jsonErr(w, 500, err.Error()) return } jsonOK(w, map[string]interface{}{"status": "ok", "new_password": newPass}) return } if strings.HasSuffix(sub, "/edit") { id := strings.TrimSuffix(sub, "/edit") id = strings.TrimSuffix(id, "/") var req struct { Username string `json:"username"` Email string `json:"email"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { jsonErr(w, 400, "bad json") return } if req.Username == "" || req.Email == "" { jsonErr(w, 400, "username and email required") return } _, err := s.db.Exec("UPDATE server_users SET username=?, email=? WHERE id=?", req.Username, strings.ToLower(req.Email), id) if err != nil { jsonErr(w, 500, err.Error()) return } jsonOK(w, map[string]interface{}{"status": "ok"}) return } jsonErr(w, 404, "unknown action") case strings.HasPrefix(path, "/api/users/") && r.Method == "DELETE": id := strings.TrimPrefix(path, "/api/users/") id = strings.TrimSuffix(id, "/") // Get user devices to delete. rows, _ := s.db.Query("SELECT device_id FROM server_user_devices WHERE user_id=?", id) var deviceIDs []string for rows.Next() { var did string rows.Scan(&did) deviceIDs = append(deviceIDs, did) } rows.Close() for _, did := range deviceIDs { s.db.Exec("DELETE FROM server_devices WHERE id=?", did) } s.db.Exec("DELETE FROM server_user_devices WHERE user_id=?", id) s.db.Exec("DELETE FROM server_email_tokens WHERE user_id=?", id) s.db.Exec("DELETE FROM server_users WHERE id=?", id) jsonOK(w, map[string]interface{}{"status": "deleted"}) default: jsonErr(w, 404, "not found") } } // ============================================================ // Embedded admin login HTML // ============================================================ const userRegisterHTML = ` Verstak Sync — Регистрация

Регистрация

Минимум 8 символов: латинские буквы + цифры

Уже есть аккаунт? Войти

` const userLoginHTML = ` Verstak Sync — Вход

Verstak Sync

` const adminLoginHTML = ` Verstak Sync — Admin Login

Verstak Sync

` const adminUsersHTML = ` Verstak Sync — Пользователи

Пользователи

← Дашборд

Логин Email Статус Устройств Активность Действия
` const confirmedHTML = ` Verstak Sync — Email подтверждён

✓ Email подтверждён

Ваш email успешно подтверждён. Теперь вы можете войти в систему.

Войти
` const registrationOKHTML = ` Verstak Sync — Регистрация

✓ Регистрация успешна

На вашу почту отправлено письмо с подтверждением.

Перейдите по ссылке в письме, чтобы активировать аккаунт.

Войти
` const registrationAutoHTML = ` Verstak Sync — Регистрация

✓ Регистрация успешна

Вы можете войти — подтверждение email не требуется.

Войти
` const forgotPasswordHTML = ` Verstak Sync — Восстановление пароля

Восстановление пароля

Введите email, указанный при регистрации

` const forgotSentHTML = ` Verstak Sync — Письмо отправлено

✓ Письмо отправлено

Если указанный email зарегистрирован, на него придёт ссылка для сброса пароля.

На главную
` const resetPasswordHTML = ` Verstak Sync — Новый пароль

Новый пароль

Минимум 8 символов, латинские буквы и цифры
` const resetDoneHTML = ` Verstak Sync — Пароль изменён

✓ Пароль изменён

Теперь вы можете войти с новым паролем.

Войти
`