diff --git a/cmd/verstak-server/config.go b/cmd/verstak-server/config.go
new file mode 100644
index 0000000..0a6cb85
--- /dev/null
+++ b/cmd/verstak-server/config.go
@@ -0,0 +1,92 @@
+package main
+
+import (
+ "fmt"
+ "os"
+ "path/filepath"
+ "regexp"
+ "sync"
+
+ "golang.org/x/crypto/bcrypt"
+ "gopkg.in/yaml.v3"
+)
+
+var passwordRE = regexp.MustCompile(`^[A-Za-z0-9]+$`)
+
+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)
+}
diff --git a/cmd/verstak-server/handlers_admin.go b/cmd/verstak-server/handlers_admin.go
new file mode 100644
index 0000000..ee03953
--- /dev/null
+++ b/cmd/verstak-server/handlers_admin.go
@@ -0,0 +1,501 @@
+package main
+
+import (
+ "crypto/rand"
+ "encoding/hex"
+ "encoding/json"
+ "net/http"
+ "strconv"
+ "strings"
+ "time"
+
+ "golang.org/x/crypto/bcrypt"
+)
+
+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("ru")))
+ 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
+
+
+
+
+Устройства
+
+
+
+
+
+
+
+
+
Health check
+
Загрузка...
+
+
+ _ = 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("ru")))
+}
+
+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")
+ }
+}
diff --git a/cmd/verstak-server/handlers_api.go b/cmd/verstak-server/handlers_api.go
new file mode 100644
index 0000000..fd464e5
--- /dev/null
+++ b/cmd/verstak-server/handlers_api.go
@@ -0,0 +1,325 @@
+package main
+
+import (
+ "crypto/rand"
+ "encoding/hex"
+ "encoding/json"
+ "net/http"
+ "strings"
+ "time"
+
+ "golang.org/x/crypto/bcrypt"
+)
+
+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,
+ })
+}
diff --git a/cmd/verstak-server/handlers_user.go b/cmd/verstak-server/handlers_user.go
new file mode 100644
index 0000000..b077328
--- /dev/null
+++ b/cmd/verstak-server/handlers_user.go
@@ -0,0 +1,539 @@
+package main
+
+import (
+ "crypto/rand"
+ "crypto/sha256"
+ "database/sql"
+ "encoding/hex"
+ "encoding/json"
+ "fmt"
+ "io"
+ "log"
+ "net/http"
+ "os"
+ "path/filepath"
+ "strings"
+ "time"
+
+ "golang.org/x/crypto/bcrypt"
+)
+
+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("ru")))
+}
+
+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")
+ }
+}
diff --git a/cmd/verstak-server/handlers_web_user.go b/cmd/verstak-server/handlers_web_user.go
new file mode 100644
index 0000000..865d786
--- /dev/null
+++ b/cmd/verstak-server/handlers_web_user.go
@@ -0,0 +1,380 @@
+package main
+
+import (
+ "crypto/rand"
+ "encoding/hex"
+ "fmt"
+ "log"
+ "net/http"
+ "strings"
+ "time"
+
+ "verstak/internal/i18n"
+
+ "golang.org/x/crypto/bcrypt"
+)
+
+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("ru")))
+ 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("ru")
+ if host == "" {
+ regMsg = registrationAutoHTML("ru")
+ }
+ 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("ru")))
+ 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("ru", i18n.T("ru", "common.error"), i18n.T("ru", "server.needEmail"), "/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("ru")))
+ 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("ru")))
+ 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("ru"), "{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("ru", i18n.T("ru", "common.error"), i18n.T("ru", "server.allFieldsRequired"), "/forgot")))
+ return
+ }
+ if err := validatePassword(newPass); err != "" {
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ w.Write([]byte(errorPageHTML("ru", i18n.T("ru", "common.error"), err, "/reset?token="+token)))
+ return
+ }
+ if newPass != confirm {
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ w.Write([]byte(errorPageHTML("ru", i18n.T("ru", "common.error"), i18n.T("ru", "server.passwordsDoNotMatch"), "/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("ru")))
+ 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("ru")))
+ 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)
+}
diff --git a/cmd/verstak-server/middleware.go b/cmd/verstak-server/middleware.go
new file mode 100644
index 0000000..665db6b
--- /dev/null
+++ b/cmd/verstak-server/middleware.go
@@ -0,0 +1,110 @@
+package main
+
+import (
+ "database/sql"
+ "encoding/json"
+ "net/http"
+ "strings"
+ "time"
+)
+
+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
+}
+
+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
+}
diff --git a/cmd/verstak-server/routes.go b/cmd/verstak-server/routes.go
new file mode 100644
index 0000000..45a1b4e
--- /dev/null
+++ b/cmd/verstak-server/routes.go
@@ -0,0 +1,37 @@
+package main
+
+import "net/http"
+
+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
+}
diff --git a/cmd/verstak-server/schema.go b/cmd/verstak-server/schema.go
new file mode 100644
index 0000000..4942d05
--- /dev/null
+++ b/cmd/verstak-server/schema.go
@@ -0,0 +1,100 @@
+package main
+
+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'))
+);
+`
diff --git a/cmd/verstak-server/server.go b/cmd/verstak-server/server.go
index ac1d208..958a673 100644
--- a/cmd/verstak-server/server.go
+++ b/cmd/verstak-server/server.go
@@ -1,298 +1,18 @@
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
@@ -405,2434 +125,3 @@ func (s *Server) Close() error {
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
-
-
-
-`, 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
-
-
-
-
-Устройства
-
-
-
-
-
-
-
-
-
Health check
-
Загрузка...
-
-
- _ = 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 — Регистрация
-
-
-
-`
-
-const userLoginHTML = `
-
-
-Verstak Sync — Вход
-
-
-
-`
-
-const adminLoginHTML = `
-
-
-Verstak Sync — Admin Login
-
-
-
-`
-
-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 — Восстановление пароля
-
-
-
-`
-
-const forgotSentHTML = `
-
-
-Verstak Sync — Письмо отправлено
-
-
-
-
✓ Письмо отправлено
-
Если указанный email зарегистрирован, на него придёт ссылка для сброса пароля.
-
На главную
-
-`
-
-const resetPasswordHTML = `
-
-
-Verstak Sync — Новый пароль
-
-
-
-`
-
-const resetDoneHTML = `
-
-
-Verstak Sync — Пароль изменён
-
-
-
-
✓ Пароль изменён
-
Теперь вы можете войти с новым паролем.
-
Войти
-
-`
diff --git a/cmd/verstak-server/smtp.go b/cmd/verstak-server/smtp.go
new file mode 100644
index 0000000..f9d1899
--- /dev/null
+++ b/cmd/verstak-server/smtp.go
@@ -0,0 +1,156 @@
+package main
+
+import (
+ "crypto/rand"
+ "crypto/sha256"
+ "crypto/tls"
+ "encoding/hex"
+ "fmt"
+ "log"
+ "net"
+ "net/smtp"
+ "time"
+)
+
+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 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)
+}
diff --git a/cmd/verstak-server/templates.go b/cmd/verstak-server/templates.go
new file mode 100644
index 0000000..467289a
--- /dev/null
+++ b/cmd/verstak-server/templates.go
@@ -0,0 +1,576 @@
+package main
+
+import (
+ "fmt"
+ "strings"
+
+ "verstak/internal/i18n"
+)
+
+func userRegisterHTML(locale string) string {
+ return fmt.Sprintf(`
+
+
+Verstak Sync — %s
+
+
+
+`,
+ i18n.T(locale, "server.registerTitle"),
+ i18n.T(locale, "server.register"),
+ i18n.T(locale, "server.username"),
+ i18n.T(locale, "server.email"),
+ i18n.T(locale, "server.password"),
+ i18n.T(locale, "server.passwordHint"),
+ i18n.T(locale, "server.registerBtn"),
+ i18n.T(locale, "server.alreadyHaveAccount"),
+ i18n.T(locale, "server.loginBtn"),
+ )
+}
+
+func userLoginHTML(locale string) string {
+ return fmt.Sprintf(`
+
+
+Verstak Sync — %s
+
+
+
+`,
+ i18n.T(locale, "server.loginTitle"),
+ i18n.T(locale, "server.usernameOrEmail"),
+ i18n.T(locale, "server.password"),
+ i18n.T(locale, "server.loginBtn"),
+ i18n.T(locale, "server.forgotPassword"),
+ i18n.T(locale, "server.registerBtn"),
+ i18n.T(locale, "server.adminLink"),
+ )
+}
+
+func adminLoginHTML(locale string) string {
+ return fmt.Sprintf(`
+
+
+%s
+
+
+
+`,
+ i18n.T(locale, "admin.login"),
+ i18n.T(locale, "admin.username"),
+ i18n.T(locale, "admin.password"),
+ i18n.T(locale, "admin.loginBtn"),
+ )
+}
+
+func adminUsersHTML(locale string) string {
+ newPassResult := i18n.T(locale, "server.newPasswordResult")
+ newPassParts := strings.SplitN(newPassResult, "%s", 2)
+ newPassPrefix := newPassParts[0]
+ newPassSuffix := strings.ReplaceAll(newPassParts[1], "\n", "\\n")
+
+ deleteMsg := i18n.T(locale, "admin.deleteUserMessage")
+ deleteMsgParts := strings.SplitN(deleteMsg, "%s", 2)
+ delMsgPrefix := deleteMsgParts[0]
+ delMsgSuffix := deleteMsgParts[1]
+
+ return fmt.Sprintf(`
+
+
+%[1]s
+
+
+%[2]s
+%[3]s
+
+
+
+
+
+
+
+| %[5]s |
+%[6]s |
+%[7]s |
+%[8]s |
+%[9]s |
+%[10]s |
+
+
+
+
+
+
+
+
+
+
%[11]s
+
+
+
+
+
+
+
+
+
+
+
+
%[14]s
+
+
+
+
+
+
+
+
+
+
+
+
+`,
+ i18n.T(locale, "admin.users"),
+ i18n.T(locale, "admin.usersHeading"),
+ i18n.T(locale, "server.dashboard"),
+ i18n.T(locale, "admin.filterPlaceholder"),
+ i18n.T(locale, "admin.username"),
+ i18n.T(locale, "admin.email"),
+ i18n.T(locale, "admin.status"),
+ i18n.T(locale, "admin.devices"),
+ i18n.T(locale, "admin.lastSeen"),
+ i18n.T(locale, "admin.actions"),
+ i18n.T(locale, "admin.confirmTitle"),
+ i18n.T(locale, "admin.modalCancel"),
+ i18n.T(locale, "admin.modalConfirm"),
+ i18n.T(locale, "admin.editUser"),
+ i18n.T(locale, "admin.username"),
+ i18n.T(locale, "admin.email"),
+ i18n.T(locale, "admin.modalCancel"),
+ i18n.T(locale, "admin.editBtn"),
+ i18n.T(locale, "admin.resultTitle"),
+ i18n.T(locale, "common.ok"),
+ i18n.T(locale, "admin.confirmed"),
+ i18n.T(locale, "admin.unconfirmed"),
+ i18n.T(locale, "admin.blocked"),
+ i18n.T(locale, "admin.unblock"),
+ i18n.T(locale, "admin.block"),
+ i18n.T(locale, "admin.resetPassword"),
+ i18n.T(locale, "admin.noUsers"),
+ i18n.T(locale, "server.newPassword"),
+ newPassPrefix,
+ newPassSuffix,
+ i18n.T(locale, "admin.resetPasswordConfirm"),
+ i18n.T(locale, "admin.resetPasswordMessage"),
+ i18n.T(locale, "admin.resetBtn"),
+ i18n.T(locale, "admin.deleteUser"),
+ delMsgPrefix,
+ delMsgSuffix,
+ i18n.T(locale, "admin.deleteBtn"),
+ i18n.T(locale, "admin.unblockUserTitle"),
+ i18n.T(locale, "admin.blockUserTitle"),
+ i18n.T(locale, "admin.unblockUserMessage"),
+ i18n.T(locale, "admin.blockUserMessage"),
+ )
+}
+
+func confirmedHTML(locale string) string {
+ return fmt.Sprintf(`
+
+
+Verstak Sync — %s
+
+
+
+`,
+ i18n.T(locale, "server.emailConfirmed"),
+ i18n.T(locale, "server.emailConfirmed"),
+ i18n.T(locale, "server.emailConfirmedMessage"),
+ i18n.T(locale, "server.loginBtn"),
+ )
+}
+
+func registrationOKHTML(locale string) string {
+ return fmt.Sprintf(`
+
+
+Verstak Sync — %s
+
+
+
+`,
+ i18n.T(locale, "server.registerTitle"),
+ i18n.T(locale, "server.registrationSuccess"),
+ i18n.T(locale, "server.registrationEmailSent"),
+ i18n.T(locale, "server.registrationCheckEmail"),
+ i18n.T(locale, "server.loginBtn"),
+ )
+}
+
+func registrationAutoHTML(locale string) string {
+ return fmt.Sprintf(`
+
+
+Verstak Sync — %s
+
+
+
+`,
+ i18n.T(locale, "server.registerTitle"),
+ i18n.T(locale, "server.registrationSuccess"),
+ i18n.T(locale, "server.registrationAutoMessage"),
+ i18n.T(locale, "server.loginBtn"),
+ )
+}
+
+func forgotPasswordHTML(locale string) string {
+ return fmt.Sprintf(`
+
+
+%s
+
+
+
+`,
+ i18n.T(locale, "server.resetPasswordTitle"),
+ i18n.T(locale, "server.resetPassword"),
+ i18n.T(locale, "server.resetInstruction"),
+ i18n.T(locale, "server.email"),
+ i18n.T(locale, "server.sendLink"),
+ i18n.T(locale, "server.backToLogin"),
+ )
+}
+
+func forgotSentHTML(locale string) string {
+ return fmt.Sprintf(`
+
+
+%s
+
+
+
+`,
+ i18n.T(locale, "server.emailSentTitle"),
+ i18n.T(locale, "server.emailSent"),
+ i18n.T(locale, "server.emailSentMessage"),
+ i18n.T(locale, "server.goHome"),
+ )
+}
+
+func resetPasswordHTML(locale string) string {
+ return fmt.Sprintf(`
+
+
+%s
+
+
+
+`,
+ i18n.T(locale, "server.newPasswordTitle"),
+ i18n.T(locale, "server.newPassword"),
+ i18n.T(locale, "server.password"),
+ i18n.T(locale, "server.passwordConfirm"),
+ i18n.T(locale, "server.adminPwdHint"),
+ i18n.T(locale, "server.save"),
+ )
+}
+
+func resetDoneHTML(locale string) string {
+ return fmt.Sprintf(`
+
+
+Verstak Sync — %s
+
+
+
+`,
+ i18n.T(locale, "server.passwordChanged"),
+ i18n.T(locale, "server.passwordChanged"),
+ i18n.T(locale, "server.passwordChangedMessage"),
+ i18n.T(locale, "server.loginBtn"),
+ )
+}
+
+func errorPageHTML(locale, title, msg, backURL string) string {
+ return fmt.Sprintf(`
+
+
+Verstak Sync — %s
+
+
+
+`, title, title, msg, backURL, i18n.T(locale, "server.back"))
+}
diff --git a/cmd/verstak-server/tokens.go b/cmd/verstak-server/tokens.go
new file mode 100644
index 0000000..1ca7a5d
--- /dev/null
+++ b/cmd/verstak-server/tokens.go
@@ -0,0 +1,80 @@
+package main
+
+import (
+ "crypto/rand"
+ "encoding/hex"
+ "sync"
+ "time"
+)
+
+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
+}
diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte
index 52b1317..146e6d2 100644
--- a/frontend/src/App.svelte
+++ b/frontend/src/App.svelte
@@ -5,6 +5,7 @@
import ConfirmModal from './lib/ConfirmModal.svelte'
import { onMount, onDestroy } from 'svelte'
import { canPreviewFile, needsBase64Preview, needsTextPreview } from './lib/fileUtils.js'
+ import { t } from './lib/i18n'
// ===== Wails v2 API call helper =====
// In production: window['go']['main']['App']['MethodName'](...args)
@@ -56,13 +57,13 @@
let newActionKind = 'open_url'
let newActionData = ''
let actionKinds = [
- { id: 'open_url', label: 'Открыть URL' },
- { id: 'open_file', label: 'Открыть файл' },
- { id: 'open_folder', label: 'Открыть папку' },
- { id: 'run_command', label: 'Запустить команду' },
- { id: 'run_script', label: 'Запустить скрипт' },
- { id: 'open_terminal', label: 'Открыть терминал' },
- { id: 'launch_app', label: 'Запустить приложение' },
+ { id: 'open_url', label: t('action.openUrl') },
+ { id: 'open_file', label: t('action.openFile') },
+ { id: 'open_folder', label: t('action.openFolder') },
+ { id: 'run_command', label: t('action.runCommand') },
+ { id: 'run_script', label: t('action.runScript') },
+ { id: 'open_terminal', label: t('action.openTerminal') },
+ { id: 'launch_app', label: t('action.launchApp') },
]
let loading = true
let importing = false
@@ -89,7 +90,7 @@
let confirmTitle = ''
let confirmMessage = ''
let confirmDanger = false
- let confirmText = 'Удалить'
+ let confirmText = t('common.delete')
let confirmAction = null
let cancelAction = null
@@ -109,12 +110,12 @@
let syncResult = ''
const tabs = [
- { id: 'overview', label: 'Обзор' },
- { id: 'notes', label: 'Заметки' },
- { id: 'files', label: 'Файлы' },
- { id: 'actions', label: 'Действия' },
- { id: 'worklog', label: 'Журнал' },
- { id: 'activity', label: 'Активность' },
+ { id: 'overview', label: t('tab.overview') },
+ { id: 'notes', label: t('tab.notes') },
+ { id: 'files', label: t('tab.files') },
+ { id: 'actions', label: t('tab.actions') },
+ { id: 'worklog', label: t('tab.worklog') },
+ { id: 'activity', label: t('tab.activity') },
]
let unlistenDrop = null
@@ -128,14 +129,14 @@
error = String(e)
// Fallback: show sections from known list
sections = [
- { id: 'today', label: 'Сегодня' },
- { id: 'inbox', label: 'Неразобранное' },
- { id: 'activity', label: 'Активность' },
- { id: 'clients', label: 'Клиенты' },
- { id: 'projects', label: 'Проекты' },
- { id: 'recipes', label: 'Рецепты' },
- { id: 'documents', label: 'Документы' },
- { id: 'archive', label: 'Архив' },
+ { id: 'today', label: t('nav.today') },
+ { id: 'inbox', label: t('nav.inbox') },
+ { id: 'activity', label: t('nav.activity') },
+ { id: 'clients', label: t('nav.clients') },
+ { id: 'projects', label: t('nav.projects') },
+ { id: 'recipes', label: t('nav.recipes') },
+ { id: 'documents', label: t('nav.documents') },
+ { id: 'archive', label: t('nav.archive') },
]
}
@@ -315,7 +316,7 @@
// ===== File operations =====
async function createFile() {
- const name = prompt('Введите имя файла:')
+ const name = prompt(t('file.namePrompt'))
if (!name || !name.trim()) return
try {
const parentId = currentFolderId || selectedNode.id
@@ -411,11 +412,19 @@
async function deleteSelected() {
const ids = getTargetIds(selectedIds)
- const label = ids.length === 1 && fileItems.find(x => x.id === ids[0])?.type === 'folder' ? 'папку' : `файлов (${ids.length})`
+ const item = fileItems.find(x => x.id === ids[0])
+ let label
+ if (ids.length === 1 && item?.type === 'folder') {
+ label = t('delete.folder')
+ } else if (ids.length === 1) {
+ label = t('delete.file')
+ } else {
+ label = t('delete.files', { count: ids.length })
+ }
openConfirm({
- title: 'Удаление',
- message: `Удалить ${label}?`,
- confirmText: 'Удалить',
+ title: t('delete.confirmTitle'),
+ message: t('delete.confirmMessage') + ' ' + label + '?',
+ confirmText: t('common.delete'),
danger: true,
onConfirm: async () => {
for (const id of ids) {
@@ -548,12 +557,11 @@
async function submitRename() {
const name = renameValue.trim()
- if (!name) { renameError = 'Имя не может быть пустым'; return }
- // Validate name via backend
+ if (!name) { renameError = t('rename.emptyError'); return }
try {
await wailsCall('ValidateName', name)
} catch (e) {
- renameError = 'Недопустимое имя'
+ renameError = t('rename.invalidError')
return
}
showRename = false
@@ -582,10 +590,10 @@
// ===== Confirm modal =====
function openConfirm(opts) {
- confirmTitle = opts.title || 'Подтверждение'
+ confirmTitle = opts.title || t('common.confirm')
confirmMessage = opts.message || ''
confirmDanger = opts.danger !== undefined ? opts.danger : true
- confirmText = opts.confirmText || 'Удалить'
+ confirmText = opts.confirmText || t('common.delete')
confirmAction = opts.onConfirm || null
cancelAction = opts.onCancel || null
showConfirm = true
@@ -654,9 +662,9 @@
async function openNote(note) {
if (noteEditor && noteEditor.dirty) {
openConfirm({
- title: 'Несохранённые изменения',
- message: 'Закрыть редактор? Все несохранённые изменения будут потеряны.',
- confirmText: 'Закрыть',
+ title: t('note.unsavedTitle'),
+ message: t('note.unsavedMessage'),
+ confirmText: t('note.unsavedClose'),
danger: false,
onConfirm: async () => {
await doOpenNote(note)
@@ -679,9 +687,9 @@
function closeNoteEditor() {
if (noteEditor && noteEditor.dirty) {
openConfirm({
- title: 'Несохранённые изменения',
- message: 'Закрыть редактор? Все несохранённые изменения будут потеряны.',
- confirmText: 'Закрыть',
+ title: t('note.unsavedTitle'),
+ message: t('note.unsavedMessage'),
+ confirmText: t('note.unsavedClose'),
danger: false,
onConfirm: () => { noteEditor = null }
})
@@ -769,11 +777,11 @@
}
async function deleteFile({ id, type }) {
- const label = type === 'folder' ? 'папку' : 'файл'
+ const label = type === 'folder' ? t('delete.folder') : t('delete.file')
openConfirm({
- title: 'Удаление',
- message: `Удалить ${label}?`,
- confirmText: 'Удалить',
+ title: t('delete.confirmTitle'),
+ message: t('delete.confirmMessage') + ' ' + label + '?',
+ confirmText: t('common.delete'),
danger: true,
onConfirm: async () => {
try {
@@ -800,7 +808,7 @@
async function onFilesDropped(paths) {
if (!paths || paths.length === 0) return
if (!selectedNode) {
- error = 'Сначала выберите дело для добавления файлов'
+ error = t('error.selectCaseFirst')
return
}
const path = paths[0]
@@ -811,18 +819,18 @@
function tabClass(id) { return activeTab === id ? 'tab active' : 'tab' }
function eventLabel(type) {
const labels = {
- 'note_created': 'Заметка создана',
- 'note_updated': 'Заметка изменена',
- 'file_added': 'Файл добавлен',
- 'file_deleted': 'Файл удалён',
- 'file_renamed': 'Файл переименован',
- 'file_copied': 'Файл скопирован',
- 'file_moved': 'Файл перемещён',
- 'folder_added': 'Папка добавлена',
- 'folder_deleted': 'Папка удалена',
- 'folder_renamed': 'Папка переименована',
- 'node_created': 'Дело создано',
- 'node_updated': 'Дело изменено',
+ 'note_created': t('event.noteCreated'),
+ 'note_updated': t('event.noteUpdated'),
+ 'file_added': t('event.fileAdded'),
+ 'file_deleted': t('event.fileDeleted'),
+ 'file_renamed': t('event.fileRenamed'),
+ 'file_copied': t('event.fileCopied'),
+ 'file_moved': t('event.fileMoved'),
+ 'folder_added': t('event.folderAdded'),
+ 'folder_deleted': t('event.folderDeleted'),
+ 'folder_renamed': t('event.folderRenamed'),
+ 'node_created': t('event.caseCreated'),
+ 'node_updated': t('event.caseUpdated'),
}
return labels[type] || type
}
@@ -843,8 +851,8 @@
try { return new Date(str).toLocaleDateString('ru-RU', { day: 'numeric', month: 'short' }) } catch (e) { return str }
}
function nodeKindLabel(kind) {
- const labels = { 'project': 'Проект', 'client': 'Клиент', 'document': 'Документ', 'recipe': 'Рецепт', 'archive': 'Архив', 'case': 'Дело' }
- return labels[kind] || kind || 'Дело'
+ const labels = { 'project': t('kind.project'), 'client': t('kind.client'), 'document': t('kind.document'), 'recipe': t('kind.recipe'), 'archive': t('kind.archive'), 'case': t('kind.case') }
+ return labels[kind] || kind || t('kind.case')
}
function pluralize(n, one, few, many) {
n = Math.abs(n) % 100
@@ -940,7 +948,7 @@
syncResult = ''
try {
await wailsCall('SyncSetInterval', syncInterval)
- syncResult = 'интервал сохранён'
+ syncResult = t('sync.settingsSaved')
await loadSyncStatus()
} catch (e) {
syncResult = 'err: ' + String(e)
@@ -992,11 +1000,11 @@