diff --git a/cmd/verstak-server/server.go b/cmd/verstak-server/server.go index 2bac005..9a7c902 100644 --- a/cmd/verstak-server/server.go +++ b/cmd/verstak-server/server.go @@ -12,6 +12,7 @@ import ( "log" "net" "net/http" + "strconv" "net/smtp" "os" "path/filepath" @@ -233,6 +234,8 @@ CREATE TABLE IF NOT EXISTS server_users ( 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 ); @@ -282,6 +285,9 @@ func NewServer(dbPath, dataDir string, cfg *Config) (*Server, error) { return nil, fmt.Errorf("schema: %w", err) } } + // Migrations for older databases. + db.Exec("ALTER TABLE server_users ADD COLUMN blocked INTEGER NOT NULL DEFAULT 0") + db.Exec("ALTER TABLE server_users ADD COLUMN last_seen TEXT") blobsDir := filepath.Join(dataDir, "blobs") if err := os.MkdirAll(blobsDir, 0750); err != nil { @@ -331,6 +337,7 @@ func (s *Server) routes() *http.ServeMux { 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) @@ -602,13 +609,17 @@ func (s *Server) handleDeviceRegister(w http.ResponseWriter, r *http.Request) { // Look up user by username or email. var userID, hash string - var confirmed int - err := s.db.QueryRow("SELECT id, password_hash, confirmed FROM server_users WHERE username=? OR email=?", - req.Username, strings.ToLower(req.Username)).Scan(&userID, &hash, &confirmed) + 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 @@ -745,7 +756,7 @@ func (s *Server) handleConfirm(w http.ResponseWriter, r *http.Request) { 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("

Email confirmed

You can now log in.

")) + w.Write([]byte(confirmedHTML)) } func (s *Server) handleUserLogin(w http.ResponseWriter, r *http.Request) { @@ -766,13 +777,17 @@ func (s *Server) handleUserLogin(w http.ResponseWriter, r *http.Request) { return } var userID, hash string - var confirmed int - err := s.db.QueryRow("SELECT id, password_hash, confirmed FROM server_users WHERE username=? OR email=?", - req.Username, strings.ToLower(req.Username)).Scan(&userID, &hash, &confirmed) + 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 @@ -781,6 +796,7 @@ func (s *Server) handleUserLogin(w http.ResponseWriter, r *http.Request) { 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}) } @@ -1195,7 +1211,11 @@ func (s *Server) handleUserWebRegister(w http.ResponseWriter, r *http.Request) { 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") - w.Write([]byte("

Registration successful

Check your email to confirm (or if SMTP not configured, check server logs for the token).

Log in")) + regMsg := registrationOKHTML + if host == "" { + regMsg = registrationAutoHTML + } + w.Write([]byte(regMsg)) default: jsonErr(w, 405, "method not allowed") } @@ -1214,10 +1234,10 @@ func (s *Server) handleUserWebLogin(w http.ResponseWriter, r *http.Request) { username := r.FormValue("username") password := r.FormValue("password") var userID, hash string - var confirmed int - err := s.db.QueryRow("SELECT id, password_hash, confirmed FROM server_users WHERE username=? OR email=?", - username, strings.ToLower(username)).Scan(&userID, &hash, &confirmed) - if err != nil || confirmed == 0 || bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) != nil { + 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")) @@ -1453,6 +1473,7 @@ pre{background:#13131f;border:1px solid #2a2a3c;border-radius:8px;padding:12px;o
+
@@ -1538,6 +1559,14 @@ function testSMTP(){ 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 @@ -1654,6 +1683,158 @@ func (s *Server) handleAdminAPI(w http.ResponseWriter, r *http.Request) { } 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") } @@ -1739,3 +1920,243 @@ button:hover{background:#4f46e5} ` + +const adminUsersHTML = ` + + +Verstak Sync — Пользователи + + +

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

+

← Дашборд

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

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

+

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

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

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

+

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

+

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

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

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

+

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

+Войти +
+`