From 2fa583d157273aaabe4b5bb136b0b5cee1ab12be Mon Sep 17 00:00:00 2001 From: mirivlad Date: Tue, 2 Jun 2026 11:08:29 +0800 Subject: [PATCH] stabilization: server.go split + i18n templates + frontend localization MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit cmd/verstak-server/server.go (2838→127 строк): разделён на 12 файлов - config.go, tokens.go, schema.go - server.go (только struct + NewServer + ListenAndServe) - routes.go, middleware.go, smtp.go - handlers_api.go, handlers_user.go, handlers_web_user.go, handlers_admin.go - templates.go (конвертирован в функции с i18n.T()) frontend: все русские строки заменены на t() вызовы - App.svelte, FileTreeRow.svelte, ConfirmModal.svelte - FilePreviewModal.svelte, fileUtils.js core: gofmt по всему проекту Все сборки (CLI, server, gui, frontend), go vet, go test проходят. check-i18n.sh: frontend чист, Go-файлы с кириллицей — только тесты/легаси. --- cmd/verstak-server/config.go | 92 + cmd/verstak-server/handlers_admin.go | 501 ++++ cmd/verstak-server/handlers_api.go | 325 +++ cmd/verstak-server/handlers_user.go | 539 +++++ cmd/verstak-server/handlers_web_user.go | 380 +++ cmd/verstak-server/middleware.go | 110 + cmd/verstak-server/routes.go | 37 + cmd/verstak-server/schema.go | 100 + cmd/verstak-server/server.go | 2711 ---------------------- cmd/verstak-server/smtp.go | 156 ++ cmd/verstak-server/templates.go | 576 +++++ cmd/verstak-server/tokens.go | 80 + frontend/src/App.svelte | 356 +-- frontend/src/FileTreeRow.svelte | 33 +- frontend/src/lib/ConfirmModal.svelte | 13 +- frontend/src/lib/FilePreviewModal.svelte | 15 +- frontend/src/lib/fileUtils.js | 68 +- frontend/src/lib/i18n/locales/ru.js | 8 + internal/core/actions/action.go | 36 +- internal/core/actions/action_test.go | 8 +- internal/core/activity/activity.go | 40 +- internal/core/files/file.go | 2 +- internal/core/files/file_test.go | 10 +- internal/core/nodes/node.go | 30 +- internal/core/plugins/manager.go | 18 +- internal/core/smoke_test.go | 2 +- internal/core/storage/storage.go | 12 +- internal/core/sync/client.go | 12 +- internal/core/sync/sync.go | 24 +- internal/core/sync/sync_e2e_test.go | 14 +- internal/gui/index.html.go | 7 +- internal/gui/server.go | 24 +- internal/i18n/catalog.go | 4 +- 33 files changed, 3276 insertions(+), 3067 deletions(-) create mode 100644 cmd/verstak-server/config.go create mode 100644 cmd/verstak-server/handlers_admin.go create mode 100644 cmd/verstak-server/handlers_api.go create mode 100644 cmd/verstak-server/handlers_user.go create mode 100644 cmd/verstak-server/handlers_web_user.go create mode 100644 cmd/verstak-server/middleware.go create mode 100644 cmd/verstak-server/routes.go create mode 100644 cmd/verstak-server/schema.go create mode 100644 cmd/verstak-server/smtp.go create mode 100644 cmd/verstak-server/templates.go create mode 100644 cmd/verstak-server/tokens.go 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
+
+ +
+ + + +
+ +

Устройства

+
+ + + + + + _ = 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 - - -
-

%s

-

%s

-← Назад -
-`, title, title, msg, backURL) -} - -func sha256Hex(s string) string { - h := sha256.Sum256([]byte(s)) - return hex.EncodeToString(h[:]) -} - -func genDeviceToken() (token, prefix, suffix string) { - b := make([]byte, 32) - rand.Read(b) - token = "vs_dev_" + hex.EncodeToString(b) - prefix = token[:16] - suffix = token[len(token)-8:] - return -} - -func sel(v, want string) string { - if v == want { - return " selected" - } - return "" -} - -func (s *Server) smtpConnect(host, port, user, pass, security string) (*smtp.Client, error) { - addr := net.JoinHostPort(host, port) - switch security { - case "tls": - tlsCfg := &tls.Config{ServerName: host} - conn, err := tls.Dial("tcp", addr, tlsCfg) - if err != nil { - return nil, fmt.Errorf("tls dial: %w", err) - } - cl, err := smtp.NewClient(conn, host) - if err != nil { - conn.Close() - return nil, fmt.Errorf("smtp client: %w", err) - } - return cl, nil - default: - conn, err := net.DialTimeout("tcp", addr, 10*time.Second) - if err != nil { - return nil, fmt.Errorf("connect: %w", err) - } - cl, err := smtp.NewClient(conn, host) - if err != nil { - conn.Close() - return nil, fmt.Errorf("smtp client: %w", err) - } - if security != "none" { - if ok, _ := cl.Extension("STARTTLS"); ok { - tlsCfg := &tls.Config{ServerName: host} - if err := cl.StartTLS(tlsCfg); err != nil { - cl.Close() - return nil, fmt.Errorf("starttls: %w", err) - } - } - } - return cl, nil - } -} - -func (s *Server) smtpSendMsg(cl *smtp.Client, user, pass, host, from, to string, msg []byte) error { - if user != "" { - auth := smtp.PlainAuth("", user, pass, host) - if err := cl.Auth(auth); err != nil { - return fmt.Errorf("auth: %w", err) - } - } - if err := cl.Mail(from); err != nil { - return fmt.Errorf("mail from: %w", err) - } - if err := cl.Rcpt(to); err != nil { - return fmt.Errorf("rcpt: %w", err) - } - w, err := cl.Data() - if err != nil { - return fmt.Errorf("data: %w", err) - } - if _, err := w.Write(msg); err != nil { - w.Close() - return fmt.Errorf("write: %w", err) - } - if err := w.Close(); err != nil { - return fmt.Errorf("send: %w", err) - } - return nil -} - -func (s *Server) smtpSend(to, subject, body string) error { - host := s.smtpGet("smtp_host") - port := s.smtpGet("smtp_port") - user := s.smtpGet("smtp_user") - pass := s.smtpGet("smtp_pass") - from := s.smtpGet("smtp_from") - security := s.smtpGet("smtp_security") - if host == "" || port == "" || from == "" { - err := fmt.Errorf("SMTP not configured") - log.Printf("smtp: %v (to=%s)", err, to) - return err - } - log.Printf("smtp: sending to %s via %s:%s (security=%s)", to, host, port, security) - msg := []byte("From: " + from + "\r\n" + - "To: " + to + "\r\n" + - "Subject: " + subject + "\r\n" + - "MIME-Version: 1.0\r\n" + - "Content-Type: text/plain; charset=UTF-8\r\n" + - "\r\n" + body + "\r\n") - cl, err := s.smtpConnect(host, port, user, pass, security) - if err != nil { - log.Printf("smtp: connect error: %v", err) - return err - } - defer cl.Close() - if err := s.smtpSendMsg(cl, user, pass, host, from, to, msg); err != nil { - log.Printf("smtp: send error: %v", err) - return err - } - log.Printf("smtp: sent OK to %s", to) - return nil -} - -func (s *Server) smtpTest(host, port, user, pass, security, from, to string) error { - if host == "" || port == "" || from == "" { - return fmt.Errorf("SMTP not configured") - } - msg := []byte("From: " + from + "\r\nTo: " + to + "\r\nSubject: Test from Verstak Sync\r\n\r\nThis is a test email from Verstak Sync Server.\r\n") - cl, err := s.smtpConnect(host, port, user, pass, security) - if err != nil { - return err - } - defer cl.Close() - return s.smtpSendMsg(cl, user, pass, host, from, to, msg) -} - -// ============================================================ -// User helpers -// ============================================================ - -func validatePassword(password string) string { - if len(password) < 8 { - return "Password must be at least 8 characters" - } - if !passwordRE.MatchString(password) { - return "Password must contain only Latin letters and digits" - } - hasLetter := false - hasDigit := false - for _, ch := range password { - if ch >= 'A' && ch <= 'Z' || ch >= 'a' && ch <= 'z' { - hasLetter = true - } - if ch >= '0' && ch <= '9' { - hasDigit = true - } - } - if !hasLetter || !hasDigit { - return "Password must contain both letters and digits" - } - return "" -} - -func (s *Server) requireUser(w http.ResponseWriter, r *http.Request) (string, bool) { - key := r.Header.Get("Authorization") - key = strings.TrimPrefix(key, "Bearer ") - if key == "" { - jsonErr(w, 401, "authorization required") - return "", false - } - userID, ok := s.userTokens.Check(key) - if !ok { - jsonErr(w, 401, "invalid or expired token") - return "", false - } - return userID, true -} - -// ============================================================ -// Handlers -// ============================================================ - -func (s *Server) handleNotFound(w http.ResponseWriter, r *http.Request) { - if r.URL.Path == "/" { - w.Header().Set("Content-Type", "text/plain; charset=utf-8") - w.Write([]byte("Verstak Sync Server\n")) - return - } - jsonErr(w, 404, "not found") -} - -func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) { - jsonOK(w, map[string]interface{}{ - "status": "ok", - "version": "verstak-server/v1", - "time": time.Now().UTC().Format(time.RFC3339), - }) -} - -func (s *Server) handleClientPair(w http.ResponseWriter, r *http.Request) { - if r.Method != "POST" { - jsonErr(w, 405, "POST required") - return - } - ip := r.RemoteAddr - if idx := strings.LastIndex(ip, ":"); idx >= 0 { - ip = ip[:idx] - } - if !s.pairLimit.allow(ip) { - s.auditLog("rate_limit_exceeded", "", "", ip, "pair rate limit exceeded") - jsonErr(w, 429, "too many attempts") - return - } - var req struct { - Login string `json:"login"` - Password string `json:"password"` - DeviceName string `json:"device_name"` - ClientVersion string `json:"client_version"` - } - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - jsonErr(w, 400, "bad json") - return - } - if req.Login == "" || req.Password == "" { - jsonErr(w, 400, "login and password required") - return - } - if req.DeviceName == "" { - req.DeviceName = "unknown" - } - // Look up user. - var userID, hash string - var confirmed, blocked int - err := s.db.QueryRow("SELECT id, password_hash, confirmed, blocked FROM server_users WHERE username=? OR email=?", - req.Login, strings.ToLower(req.Login)).Scan(&userID, &hash, &confirmed, &blocked) - if err != nil { - s.auditLog("device_auth_failed", "", "", ip, "pair: user not found: "+req.Login) - jsonErr(w, 401, "invalid credentials") - return - } - if blocked != 0 { - s.auditLog("device_auth_failed", userID, "", ip, "pair: user blocked") - jsonErr(w, 403, "account blocked") - return - } - if confirmed == 0 { - s.auditLog("device_auth_failed", userID, "", ip, "pair: email not confirmed") - jsonErr(w, 403, "email not confirmed") - return - } - if bcrypt.CompareHashAndPassword([]byte(hash), []byte(req.Password)) != nil { - s.auditLog("device_auth_failed", userID, "", ip, "pair: wrong password") - jsonErr(w, 401, "invalid credentials") - return - } - // Generate device. - devID := make([]byte, 12) - rand.Read(devID) - deviceID := "dev_" + hex.EncodeToString(devID) - token, prefix, suffix := genDeviceToken() - tokenHash := sha256Hex(token) - now := time.Now().UTC().Format(time.RFC3339) - apiKey := make([]byte, 20) - rand.Read(apiKey) - _, err = s.db.Exec(`INSERT INTO server_devices - (id, name, api_key, token_hash, token_prefix, token_suffix, user_id, client_version, last_ip, last_seen, created_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - deviceID, req.DeviceName, hex.EncodeToString(apiKey), tokenHash, prefix, suffix, - userID, req.ClientVersion, ip, now, now) - if err != nil { - jsonErr(w, 500, err.Error()) - return - } - s.db.Exec("INSERT OR IGNORE INTO server_user_devices (user_id, device_id) VALUES (?, ?)", userID, deviceID) - s.db.Exec("UPDATE server_users SET last_seen=? WHERE id=?", now, userID) - s.pairLimit.reset(ip) - s.auditLog("device_paired", userID, deviceID, ip, "device paired: "+req.DeviceName) - jsonOK(w, map[string]interface{}{ - "user_id": userID, - "device_id": deviceID, - "device_token": token, - "server_time": now, - "initial_sync_cursor": 0, - }) -} - -func (s *Server) handleAuthTest(w http.ResponseWriter, r *http.Request) { - if r.Method != "POST" { - jsonErr(w, 405, "POST required") - return - } - var req struct { - Username string `json:"username"` - Password string `json:"password"` - } - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - jsonErr(w, 400, "bad json") - return - } - if req.Username == "" || req.Password == "" { - jsonErr(w, 400, "username and password required") - return - } - var hash string - var confirmed, blocked int - err := s.db.QueryRow("SELECT password_hash, confirmed, blocked FROM server_users WHERE username=? OR email=?", - req.Username, strings.ToLower(req.Username)).Scan(&hash, &confirmed, &blocked) - if err != nil { - jsonErr(w, 401, "invalid credentials") - return - } - if blocked != 0 { - jsonErr(w, 403, "account blocked") - return - } - if confirmed == 0 { - jsonErr(w, 403, "email not confirmed") - return - } - if bcrypt.CompareHashAndPassword([]byte(hash), []byte(req.Password)) != nil { - jsonErr(w, 401, "invalid credentials") - return - } - jsonOK(w, map[string]string{"status": "ok"}) -} - -func (s *Server) handleClientRevoke(w http.ResponseWriter, r *http.Request) { - if r.Method != "POST" { - jsonErr(w, 405, "POST required") - return - } - tok := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ") - if tok == "" { - jsonErr(w, 401, "token required") - return - } - hash := sha256Hex(tok) - var deviceID, userID string - err := s.db.QueryRow("SELECT id, user_id FROM server_devices WHERE token_hash=?", hash).Scan(&deviceID, &userID) - if err != nil { - jsonErr(w, 401, "invalid token") - return - } - now := time.Now().UTC().Format(time.RFC3339) - s.db.Exec("UPDATE server_devices SET revoked_at=? WHERE id=?", now, deviceID) - s.auditLog("device_revoked", userID, deviceID, r.RemoteAddr, "device revoked by user") - jsonOK(w, map[string]string{"status": "revoked"}) -} - -func (s *Server) handleClientRevokeDevice(w http.ResponseWriter, r *http.Request) { - if r.Method != "POST" { - jsonErr(w, 405, "POST required") - return - } - userID, ok := s.requireUserWeb(w, r) - if !ok { - return - } - var req struct { - DeviceID string `json:"device_id"` - Password string `json:"password"` - } - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - jsonErr(w, 400, "invalid JSON") - return - } - if req.DeviceID == "" || req.Password == "" { - jsonErr(w, 400, "device_id and password required") - return - } - // Verify password. - var pwHash string - err := s.db.QueryRow("SELECT password_hash FROM server_users WHERE id=?", userID).Scan(&pwHash) - if err != nil { - jsonErr(w, 403, "access denied") - return - } - if bcrypt.CompareHashAndPassword([]byte(pwHash), []byte(req.Password)) != nil { - jsonErr(w, 403, "wrong password") - return - } - // Verify device belongs to user. - var devUserID string - err = s.db.QueryRow("SELECT user_id FROM server_devices WHERE id=?", req.DeviceID).Scan(&devUserID) - if err != nil { - jsonErr(w, 404, "device not found") - return - } - if devUserID != userID { - jsonErr(w, 403, "device does not belong to you") - return - } - now := time.Now().UTC().Format(time.RFC3339) - s.db.Exec("UPDATE server_devices SET revoked_at=? WHERE id=?", now, req.DeviceID) - s.auditLog("device_revoked", userID, req.DeviceID, r.RemoteAddr, "device revoked via web dashboard") - jsonOK(w, map[string]string{"status": "revoked"}) -} - -func (s *Server) handleClientMe(w http.ResponseWriter, r *http.Request) { - tok := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ") - if tok == "" { - jsonErr(w, 401, "token required") - return - } - hash := sha256Hex(tok) - var deviceID, userID, name, clientVer, lastSeen, revokedAt, createdAt string - err := s.db.QueryRow(`SELECT d.id, d.user_id, d.name, COALESCE(d.client_version,''), COALESCE(d.last_seen,''), COALESCE(d.revoked_at,''), d.created_at - FROM server_devices d WHERE d.token_hash=?`, hash). - Scan(&deviceID, &userID, &name, &clientVer, &lastSeen, &revokedAt, &createdAt) - if err != nil { - jsonErr(w, 401, "invalid token") - return - } - var username string - s.db.QueryRow("SELECT username FROM server_users WHERE id=?", userID).Scan(&username) - jsonOK(w, map[string]interface{}{ - "device_id": deviceID, - "user_id": userID, - "username": username, - "device_name": name, - "client_version": clientVer, - "last_seen": lastSeen, - "revoked_at": revokedAt, - "created_at": createdAt, - }) -} - -func (s *Server) handleDeviceRegister(w http.ResponseWriter, r *http.Request) { - if r.Method != "POST" { - jsonErr(w, 405, "POST required") - return - } - var req struct { - Name string `json:"name"` - Username string `json:"username"` - Password string `json:"password"` - } - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - jsonErr(w, 400, "invalid JSON") - return - } - if req.Name == "" { - jsonErr(w, 400, "name required") - return - } - if req.Username == "" || req.Password == "" { - jsonErr(w, 401, "username and password required") - return - } - - // Look up user by username or email. - var userID, hash string - var confirmed, blocked int - err := s.db.QueryRow("SELECT id, password_hash, confirmed, blocked FROM server_users WHERE username=? OR email=?", - req.Username, strings.ToLower(req.Username)).Scan(&userID, &hash, &confirmed, &blocked) - if err != nil { - jsonErr(w, 401, "invalid credentials") - return - } - if blocked != 0 { - jsonErr(w, 403, "account blocked") - return - } - if confirmed == 0 { - jsonErr(w, 403, "email not confirmed") - return - } - if bcrypt.CompareHashAndPassword([]byte(hash), []byte(req.Password)) != nil { - jsonErr(w, 401, "invalid credentials") - return - } - - b := make([]byte, 20) - rand.Read(b) - apiKey := hex.EncodeToString(b) - deviceID := apiKey[:12] - now := time.Now().UTC().Format(time.RFC3339) - - _, err = s.db.Exec( - "INSERT INTO server_devices (id, name, api_key, last_seen, created_at) VALUES (?, ?, ?, ?, ?)", - deviceID, req.Name, apiKey, now, now, - ) - if err != nil { - jsonErr(w, 500, err.Error()) - return - } - // Link device to user. - s.db.Exec("INSERT OR IGNORE INTO server_user_devices (user_id, device_id) VALUES (?, ?)", userID, deviceID) - - jsonOK(w, map[string]interface{}{ - "device_id": deviceID, - "api_key": apiKey, - }) -} - -// ============================================================ -// Auth / User handlers -// ============================================================ - -func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) { - if r.Method != "POST" { - jsonErr(w, 405, "POST required") - return - } - var req struct { - Username string `json:"username"` - Email string `json:"email"` - Password string `json:"password"` - } - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - jsonErr(w, 400, "invalid JSON") - return - } - if req.Username == "" || req.Email == "" || req.Password == "" { - jsonErr(w, 400, "username, email and password required") - return - } - if err := validatePassword(req.Password); err != "" { - jsonErr(w, 400, err) - return - } - if !strings.Contains(req.Email, "@") || !strings.Contains(req.Email, ".") { - jsonErr(w, 400, "invalid email") - return - } - hash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost) - if err != nil { - jsonErr(w, 500, "internal error") - return - } - now := time.Now().UTC().Format(time.RFC3339) - id := make([]byte, 12) - rand.Read(id) - userID := hex.EncodeToString(id) - _, err = s.db.Exec( - "INSERT INTO server_users (id, username, email, password_hash, confirmed, created_at) VALUES (?, ?, ?, ?, 0, ?)", - userID, req.Username, strings.ToLower(req.Email), string(hash), now, - ) - if err != nil { - if strings.Contains(err.Error(), "UNIQUE") { - jsonErr(w, 409, "username or email already taken") - return - } - jsonErr(w, 500, err.Error()) - return - } - // Confirmation token. - tok := make([]byte, 24) - rand.Read(tok) - tokenStr := hex.EncodeToString(tok) - exp := time.Now().Add(48 * time.Hour).UTC().Format(time.RFC3339) - s.db.Exec("INSERT INTO server_email_tokens (token, user_id, purpose, expires_at, created_at) VALUES (?, ?, 'confirm', ?, ?)", - tokenStr, userID, exp, now) - // Try to send email. - host := s.smtpGet("smtp_host") - if host != "" { - srvURL := s.smtpGet("server_url") - var confirmURL string - if srvURL != "" { - confirmURL = fmt.Sprintf("%s/confirm?token=%s", srvURL, tokenStr) - } else { - confirmURL = fmt.Sprintf("/api/v1/auth/confirm?token=%s", tokenStr) - } - body := fmt.Sprintf("Welcome to Verstak Sync!\n\nPlease confirm your email by clicking:\n%s\n\nIf you did not register, ignore this message.", confirmURL) - if err := s.smtpSend(req.Email, "Confirm your Verstak Sync account", body); err != nil { - log.Printf("register: failed to send confirm email: %v", err) - } - } else { - log.Printf("register: SMTP not configured, confirmation token=%s for user %s", tokenStr, req.Username) - } - jsonOK(w, map[string]string{"status": "confirmation_sent"}) -} - -func (s *Server) handleConfirm(w http.ResponseWriter, r *http.Request) { - if r.Method != "GET" { - jsonErr(w, 405, "GET required") - return - } - tokenStr := r.URL.Query().Get("token") - if tokenStr == "" { - jsonErr(w, 400, "token required") - return - } - var userID, expiresAt string - err := s.db.QueryRow("SELECT user_id, expires_at FROM server_email_tokens WHERE token=? AND purpose='confirm'", - tokenStr).Scan(&userID, &expiresAt) - if err != nil { - jsonErr(w, 400, "invalid or expired token") - return - } - exp, err := time.Parse(time.RFC3339, expiresAt) - if err != nil || time.Now().After(exp) { - jsonErr(w, 400, "token expired") - return - } - s.db.Exec("UPDATE server_users SET confirmed=1 WHERE id=?", userID) - log.Printf("confirm: user %s confirmed email", userID) - s.db.Exec("DELETE FROM server_email_tokens WHERE token=?", tokenStr) - w.Header().Set("Content-Type", "text/html; charset=utf-8") - w.Write([]byte(confirmedHTML)) -} - -func (s *Server) handleUserLogin(w http.ResponseWriter, r *http.Request) { - if r.Method != "POST" { - jsonErr(w, 405, "POST required") - return - } - var req struct { - Username string `json:"username"` - Password string `json:"password"` - } - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - jsonErr(w, 400, "invalid JSON") - return - } - if req.Username == "" || req.Password == "" { - jsonErr(w, 400, "username and password required") - return - } - var userID, hash string - var confirmed, blocked int - err := s.db.QueryRow("SELECT id, password_hash, confirmed, blocked FROM server_users WHERE username=? OR email=?", - req.Username, strings.ToLower(req.Username)).Scan(&userID, &hash, &confirmed, &blocked) - if err != nil { - jsonErr(w, 401, "invalid credentials") - return - } - if blocked != 0 { - jsonErr(w, 403, "account blocked") - return - } - if confirmed == 0 { - jsonErr(w, 403, "email not confirmed") - return - } - if bcrypt.CompareHashAndPassword([]byte(hash), []byte(req.Password)) != nil { - jsonErr(w, 401, "invalid credentials") - return - } - s.db.Exec("UPDATE server_users SET last_seen=? WHERE id=?", time.Now().UTC().Format(time.RFC3339), userID) - tok := s.userTokens.Create(userID) - jsonOK(w, map[string]string{"token": tok, "user_id": userID}) -} - -func (s *Server) handleForgot(w http.ResponseWriter, r *http.Request) { - if r.Method != "POST" { - jsonErr(w, 405, "POST required") - return - } - var req struct { - Email string `json:"email"` - } - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - jsonErr(w, 400, "invalid JSON") - return - } - if req.Email == "" { - jsonErr(w, 400, "email required") - return - } - var userID string - err := s.db.QueryRow("SELECT id FROM server_users WHERE email=?", strings.ToLower(req.Email)).Scan(&userID) - if err != nil { - jsonOK(w, map[string]string{"status": "if email exists, reset link sent"}) - return - } - tok := make([]byte, 24) - rand.Read(tok) - tokenStr := hex.EncodeToString(tok) - exp := time.Now().Add(1 * time.Hour).UTC().Format(time.RFC3339) - now := time.Now().UTC().Format(time.RFC3339) - s.db.Exec("INSERT INTO server_email_tokens (token, user_id, purpose, expires_at, created_at) VALUES (?, ?, 'reset', ?, ?)", - tokenStr, userID, exp, now) - host := s.smtpGet("smtp_host") - if host != "" { - srvURL := s.smtpGet("server_url") - resetURL := fmt.Sprintf("/api/v1/auth/reset?token=%s", tokenStr) - if srvURL != "" { - resetURL = fmt.Sprintf("%s/api/v1/auth/reset?token=%s", srvURL, tokenStr) - } - body := fmt.Sprintf("Reset your Verstak Sync password:\n\n%s\n\nThis link expires in 1 hour.", resetURL) - s.smtpSend(req.Email, "Verstak Sync password reset", body) - } - jsonOK(w, map[string]string{"status": "if email exists, reset link sent"}) -} - -func (s *Server) handleReset(w http.ResponseWriter, r *http.Request) { - if r.Method != "POST" { - jsonErr(w, 405, "POST required") - return - } - var req struct { - Token string `json:"token"` - NewPassword string `json:"new_password"` - } - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - jsonErr(w, 400, "invalid JSON") - return - } - if req.Token == "" || req.NewPassword == "" { - jsonErr(w, 400, "token and new_password required") - return - } - if err := validatePassword(req.NewPassword); err != "" { - jsonErr(w, 400, err) - return - } - var userID, expiresAt string - err := s.db.QueryRow("SELECT user_id, expires_at FROM server_email_tokens WHERE token=? AND purpose='reset'", - req.Token).Scan(&userID, &expiresAt) - if err != nil { - jsonErr(w, 400, "invalid or expired token") - return - } - exp, err := time.Parse(time.RFC3339, expiresAt) - if err != nil || time.Now().After(exp) { - jsonErr(w, 400, "token expired") - return - } - hash, err := bcrypt.GenerateFromPassword([]byte(req.NewPassword), bcrypt.DefaultCost) - if err != nil { - jsonErr(w, 500, "internal error") - return - } - s.db.Exec("UPDATE server_users SET password_hash=? WHERE id=?", string(hash), userID) - s.db.Exec("DELETE FROM server_email_tokens WHERE token=?", req.Token) - jsonOK(w, map[string]string{"status": "password reset"}) -} - -func (s *Server) handleUserDevices(w http.ResponseWriter, r *http.Request) { - userID, ok := s.requireUser(w, r) - if !ok { - return - } - if r.Method != "GET" { - jsonErr(w, 405, "GET required") - return - } - rows, err := s.db.Query(` - SELECT d.id, d.name, d.last_seen, d.created_at - FROM server_devices d - JOIN server_user_devices ud ON ud.device_id = d.id - WHERE ud.user_id = ? - ORDER BY d.created_at`, userID) - if err != nil { - jsonErr(w, 500, err.Error()) - return - } - defer rows.Close() - type deviceDTO struct { - ID string `json:"id"` - Name string `json:"name"` - LastSeen string `json:"last_seen"` - CreatedAt string `json:"created_at"` - } - var devices []deviceDTO - for rows.Next() { - var d deviceDTO - var lastSeen sql.NullString - if err := rows.Scan(&d.ID, &d.Name, &lastSeen, &d.CreatedAt); err != nil { - continue - } - d.LastSeen = lastSeen.String - devices = append(devices, d) - } - if devices == nil { - devices = []deviceDTO{} - } - jsonOK(w, map[string]interface{}{"devices": devices}) -} - -func (s *Server) handleSyncPush(w http.ResponseWriter, r *http.Request) { - if !s.requireAPIKey(w, r) { - return - } - if r.Method != "POST" { - jsonErr(w, 405, "POST required") - return - } - var req struct { - DeviceID string `json:"device_id"` - IdempotencyKey string `json:"idempotency_key"` - Ops []struct { - OpID string `json:"op_id"` - EntityType string `json:"entity_type"` - EntityID string `json:"entity_id"` - OpType string `json:"op_type"` - PayloadJSON string `json:"payload_json"` - ClientSequence int `json:"client_sequence"` - LastSeenServerSeq int `json:"last_seen_server_seq"` - CreatedAt string `json:"created_at"` - } `json:"ops"` - } - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - jsonErr(w, 400, "invalid JSON: "+err.Error()) - return - } - - // Idempotency: if request-level key provided, check for cached response. - if req.IdempotencyKey != "" { - var cachedJSON string - err := s.db.QueryRow("SELECT response_json FROM server_idempotency_keys WHERE idempotency_key=?", req.IdempotencyKey).Scan(&cachedJSON) - if err == nil { - w.Header().Set("Content-Type", "application/json") - w.Write([]byte(cachedJSON)) - return - } - } - - now := time.Now().UTC().Format(time.RFC3339) - var accepted []string - var conflicts []map[string]interface{} - - for _, op := range req.Ops { - if op.OpID == "" || op.EntityType == "" || op.EntityID == "" || op.OpType == "" { - continue - } - // Conflict detection: check if another device already created ops for this entity - // with a server_sequence higher than what this client last saw. - if op.LastSeenServerSeq > 0 { - conflictRows, err := s.db.Query(` - SELECT op_id, device_id, op_type, server_sequence FROM server_ops - WHERE entity_type=? AND entity_id=? AND device_id!=? - AND server_sequence > ? AND op_type != 'delete' - ORDER BY server_sequence`, op.EntityType, op.EntityID, req.DeviceID, op.LastSeenServerSeq) - if err == nil { - for conflictRows.Next() { - var cOpID, cDevID, cOpType string - var cSeq int - conflictRows.Scan(&cOpID, &cDevID, &cOpType, &cSeq) - conflicts = append(conflicts, map[string]interface{}{ - "op_id": cOpID, - "device_id": cDevID, - "op_type": cOpType, - "server_sequence": cSeq, - "entity_type": op.EntityType, - "entity_id": op.EntityID, - }) - } - conflictRows.Close() - } - } - - res, err := s.db.Exec( - `INSERT OR IGNORE INTO server_ops (op_id, server_sequence, device_id, entity_type, entity_id, op_type, payload_json, idempotency_key, client_sequence, last_seen_server_seq, created_at, pushed_at) - VALUES (?, NULL, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - op.OpID, req.DeviceID, op.EntityType, op.EntityID, op.OpType, op.PayloadJSON, - req.IdempotencyKey, op.ClientSequence, op.LastSeenServerSeq, op.CreatedAt, now, - ) - if err != nil { - continue - } - n, _ := res.RowsAffected() - if n == 0 { - continue // duplicate op_id - } - seqRes, err := s.db.Exec("INSERT INTO server_revisions (op_id, device_id) VALUES (?, ?)", op.OpID, req.DeviceID) - if err != nil { - continue - } - seq, _ := seqRes.LastInsertId() - s.db.Exec("UPDATE server_ops SET server_sequence=? WHERE op_id=?", seq, op.OpID) - - if op.OpType == "delete" { - s.db.Exec(`INSERT OR REPLACE INTO server_tombstones (entity_type, entity_id, op_id, deleted_at) VALUES (?, ?, ?, ?)`, - op.EntityType, op.EntityID, op.OpID, now) - } - - accepted = append(accepted, op.OpID) - } - - resp := map[string]interface{}{ - "accepted": accepted, - "count": len(accepted), - "conflicts": conflicts, - } - - // Cache response for idempotency. - if req.IdempotencyKey != "" { - if respJSON, err := json.Marshal(resp); err == nil { - s.db.Exec("INSERT OR IGNORE INTO server_idempotency_keys (idempotency_key, response_json, created_at) VALUES (?, ?, ?)", - req.IdempotencyKey, string(respJSON), now) - } - } - - jsonOK(w, resp) -} - -func (s *Server) handleSyncPull(w http.ResponseWriter, r *http.Request) { - if !s.requireAPIKey(w, r) { - return - } - if r.Method != "POST" { - jsonErr(w, 405, "POST required") - return - } - var req struct { - SinceSequence int `json:"since_sequence"` - } - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - jsonErr(w, 400, "invalid JSON") - return - } - - var serverSeq int - s.db.QueryRow("SELECT COALESCE(MAX(server_sequence), 0) FROM server_ops").Scan(&serverSeq) - - rows, err := s.db.Query(` - SELECT op_id, server_sequence, device_id, entity_type, entity_id, op_type, payload_json, created_at - FROM server_ops - WHERE server_sequence > ? AND server_sequence IS NOT NULL - ORDER BY server_sequence`, req.SinceSequence) - if err != nil { - jsonErr(w, 500, err.Error()) - return - } - defer rows.Close() - - type opDTO struct { - OpID string `json:"op_id"` - ServerSequence int `json:"server_sequence"` - DeviceID string `json:"device_id"` - EntityType string `json:"entity_type"` - EntityID string `json:"entity_id"` - OpType string `json:"op_type"` - PayloadJSON string `json:"payload_json"` - CreatedAt string `json:"created_at"` - } - var ops []opDTO - for rows.Next() { - var o opDTO - if err := rows.Scan(&o.OpID, &o.ServerSequence, &o.DeviceID, &o.EntityType, &o.EntityID, &o.OpType, &o.PayloadJSON, &o.CreatedAt); err != nil { - continue - } - ops = append(ops, o) - } - - jsonOK(w, map[string]interface{}{ - "server_sequence": serverSeq, - "ops": ops, - }) -} - -func (s *Server) handleBlobs(w http.ResponseWriter, r *http.Request) { - if !s.requireAPIKey(w, r) { - return - } - switch r.Method { - case "POST": - // Upload: accept multipart file, store by SHA-256. - if err := r.ParseMultipartForm(200 << 20); err != nil { - jsonErr(w, 400, "multipart error: "+err.Error()) - return - } - file, header, err := r.FormFile("file") - if err != nil { - jsonErr(w, 400, "file field required") - return - } - defer file.Close() - - // Read content and compute SHA-256. - data, err := io.ReadAll(file) - if err != nil { - jsonErr(w, 500, "read error") - return - } - hash := sha256.Sum256(data) - shaHex := hex.EncodeToString(hash[:]) - - // Store at blobs/ab/cd/sha256. - blobDir := filepath.Join(s.blobsDir, shaHex[:2], shaHex[2:4]) - if err := os.MkdirAll(blobDir, 0750); err != nil { - jsonErr(w, 500, "mkdir error") - return - } - blobPath := filepath.Join(blobDir, shaHex) - if err := os.WriteFile(blobPath, data, 0640); err != nil { - jsonErr(w, 500, "write error") - return - } - _ = header - - // Record in blobs table. - now := time.Now().UTC().Format(time.RFC3339) - s.db.Exec("INSERT OR IGNORE INTO server_blobs (sha256, size, created_at) VALUES (?, ?, ?)", - shaHex, len(data), now) - - jsonOK(w, map[string]interface{}{ - "sha256": shaHex, - "size": len(data), - }) - - case "GET": - // Download: GET /api/v1/blobs/{sha256} - shaHex := strings.TrimPrefix(r.URL.Path, "/api/v1/blobs/") - if len(shaHex) != 64 { - jsonErr(w, 400, "invalid SHA-256") - return - } - blobPath := filepath.Join(s.blobsDir, shaHex[:2], shaHex[2:4], shaHex) - if _, err := os.Stat(blobPath); os.IsNotExist(err) { - jsonErr(w, 404, "blob not found") - return - } - data, err := os.ReadFile(blobPath) - if err != nil { - jsonErr(w, 500, "read error") - return - } - w.Header().Set("Content-Type", "application/octet-stream") - w.Header().Set("Content-Disposition", "attachment; filename=\""+shaHex+"\"") - w.Write(data) - - default: - jsonErr(w, 405, "method not allowed") - } -} - -// ============================================================ -// User web GUI -// ============================================================ - -func (s *Server) requireUserWeb(w http.ResponseWriter, r *http.Request) (string, bool) { - cookie, err := r.Cookie("user_session") - if err != nil { - http.Redirect(w, r, "/login", http.StatusFound) - return "", false - } - userID, ok := s.userTokens.Check(cookie.Value) - if !ok { - http.Redirect(w, r, "/login", http.StatusFound) - return "", false - } - return userID, true -} - -func (s *Server) handleUserWebRegister(w http.ResponseWriter, r *http.Request) { - switch r.Method { - case "GET": - w.Header().Set("Content-Type", "text/html; charset=utf-8") - w.Write([]byte(userRegisterHTML)) - case "POST": - if err := r.ParseForm(); err != nil { - w.Header().Set("Content-Type", "text/html; charset=utf-8") - w.WriteHeader(400) - w.Write([]byte("

400 Bad request

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

All fields required

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

" + err + "

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

Internal error

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

Username or email already taken

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

"+err.Error()+"

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

401 Unauthorized

Try again")) - return - } - tok := s.userTokens.Create(userID) - http.SetCookie(w, &http.Cookie{ - Name: "user_session", Value: tok, Path: "/", - HttpOnly: true, SameSite: http.SameSiteLaxMode, - MaxAge: 86400, - }) - http.Redirect(w, r, "/dashboard", http.StatusFound) - default: - jsonErr(w, 405, "method not allowed") - } -} - -func (s *Server) handleUserDashboard(w http.ResponseWriter, r *http.Request) { - userID, ok := s.requireUserWeb(w, r) - if !ok { - return - } - var username string - s.db.QueryRow("SELECT username FROM server_users WHERE id=?", userID).Scan(&username) - - // Get devices with status info. - type dev struct { - ID, Name, LastSeen, CreatedAt, ClientVer, RevokedAt string - } - var devices []dev - rows, err := s.db.Query(` - SELECT d.id, d.name, COALESCE(d.last_seen,''), d.created_at, - COALESCE(d.client_version,''), COALESCE(d.revoked_at,'') - FROM server_devices d - JOIN server_user_devices ud ON ud.device_id = d.id - WHERE ud.user_id = ? - ORDER BY d.created_at DESC`, userID) - if err == nil { - defer rows.Close() - for rows.Next() { - var d dev - rows.Scan(&d.ID, &d.Name, &d.LastSeen, &d.CreatedAt, &d.ClientVer, &d.RevokedAt) - devices = append(devices, d) - } - } - - w.Header().Set("Content-Type", "text/html; charset=utf-8") - deviceRows := "" - if len(devices) == 0 { - deviceRows = "Нет подключённых устройств.
Подключите устройство из desktop-клиента Verstak." - } else { - for _, d := range devices { - ls := d.LastSeen - if ls == "" { - ls = "—" - } - created := d.CreatedAt - if len(created) > 10 { - created = created[:10] - } - status := "Активно" - revokeBtn := fmt.Sprintf(``, d.ID) - if d.RevokedAt != "" { - status = "Отозвано" - revokeBtn = "" - } - deviceRows += fmt.Sprintf(` - %s - %s - %s - %s - %s %s - `, d.Name, status, created, ls, d.ClientVer, revokeBtn) - } - } - - html := fmt.Sprintf(` - - -Verstak Sync — %s - - -
-

Verstak Sync

-%s · Выйти -
-

Устройства

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

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

-

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

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

401 Unauthorized

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

Verstak Sync Server

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

Устройства

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

Регистрация

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

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

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

Verstak Sync

- - - - - - -
-` - -const adminLoginHTML = ` - - -Verstak Sync — Admin Login - - -
-

Verstak Sync

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

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

-

← Дашборд

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

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

-

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

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

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

-

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

-

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

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

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

-

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

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

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

-

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

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

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

-

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

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

Новый пароль

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

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

-

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

-Войти -
-` 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 + + +
+

%s

+ + + + + + +
%s
+ +

%s %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 + + +
+

Verstak Sync

+ + + + + + +
+`, + 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 + + +
+

Verstak Sync

+ + + + + +
+`, + 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
+ + + + + + + + + + +`, + 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 + + +
+

%s

+

%s

+%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 + + +
+

%s

+

%s

+

%s

+%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 + + +
+

%s

+

%s

+%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 + + +
+

%s

+

%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 + + +
+

%s

+

%s

+%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 + + +
+

%s

+ + + + + +
%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 + + +
+

%s

+

%s

+%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 + + +
+

%s

+

%s

+%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 @@