From 0ef54c31f874d9bbb374f3398629bc31251c0f2b Mon Sep 17 00:00:00 2001 From: mirivlad Date: Mon, 1 Jun 2026 23:40:48 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20user=20web=20GUI=20=E2=80=94=20login,?= =?UTF-8?q?=20dashboard=20with=20devices/keys,=20logout?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cmd/verstak-server/server.go | 207 +++++++++++++++++++++++++++++++++++ 1 file changed, 207 insertions(+) diff --git a/cmd/verstak-server/server.go b/cmd/verstak-server/server.go index 3c61cb6..222ff44 100644 --- a/cmd/verstak-server/server.go +++ b/cmd/verstak-server/server.go @@ -324,6 +324,9 @@ func (s *Server) routes() *http.ServeMux { mux.HandleFunc("/api/v1/auth/forgot", s.handleForgot) mux.HandleFunc("/api/v1/auth/reset", s.handleReset) mux.HandleFunc("/api/v1/user/devices", s.handleUserDevices) + 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/", s.handleAdminAPI) @@ -1020,6 +1023,185 @@ func (s *Server) handleBlobs(w http.ResponseWriter, r *http.Request) { } } +// ============================================================ +// 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) 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 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 { + 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 + } + // Get username. + var username string + s.db.QueryRow("SELECT username FROM server_users WHERE id=?", userID).Scan(&username) + + // Get devices. + rows, err := s.db.Query(` + SELECT d.id, d.name, d.api_key, 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 dev struct { + ID, Name, APIKey, LastSeen, CreatedAt string + } + var devices []dev + for rows.Next() { + var d dev + var lastSeen sql.NullString + if err := rows.Scan(&d.ID, &d.Name, &d.APIKey, &lastSeen, &d.CreatedAt); err != nil { + continue + } + d.LastSeen = lastSeen.String + devices = append(devices, d) + } + + w.Header().Set("Content-Type", "text/html; charset=utf-8") + // Build device rows HTML. + deviceRows := "" + if len(devices) == 0 { + deviceRows = "Нет подключённых устройств" + } else { + for _, d := range devices { + lastSeen := d.LastSeen + if lastSeen == "" { + lastSeen = "—" + } + escKey := strings.ReplaceAll(d.APIKey, "'", "\\'") + deviceRows += fmt.Sprintf(` + %s + %s + %s + + + + + `, d.Name, d.APIKey, d.APIKey, lastSeen, escKey, d.ID) + } + } + + html := fmt.Sprintf(` + + +Verstak Sync — %s + + +
+

Verstak Sync

+%s · Выйти +
+

Устройства

+%s
УстройствоAPI-ключПоследняя активность
+ +

Новое устройство

+
+ + + +
+ +`, username, username, deviceRows, username) + 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": @@ -1232,6 +1414,31 @@ func (s *Server) handleAdminAPI(w http.ResponseWriter, r *http.Request) { // Embedded admin login HTML // ============================================================ +const userLoginHTML = ` + + +Verstak Sync — Вход + + +
+

Verstak Sync

+ + + + + +

Администратор?

+
+` + const adminLoginHTML = `