From cec2305b15cbffa70f9ff039d57b9f0f90d6c1a8 Mon Sep 17 00:00:00 2001 From: mirivlad Date: Sat, 20 Jun 2026 11:21:29 +0800 Subject: [PATCH] feat: add admin panel (login, dashboard, users, devices) --- internal/server/handlers_admin.go | 174 ++++++++++++++++++++++++++++++ internal/server/routes.go | 4 + 2 files changed, 178 insertions(+) create mode 100644 internal/server/handlers_admin.go diff --git a/internal/server/handlers_admin.go b/internal/server/handlers_admin.go new file mode 100644 index 0000000..43fb3b4 --- /dev/null +++ b/internal/server/handlers_admin.go @@ -0,0 +1,174 @@ +package server + +import ( + "crypto/rand" + "encoding/hex" + "encoding/json" + "net/http" + "strings" + "time" +) + +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(`Admin Login + +

Admin Login

+ + +
`)) + case "POST": + if err := r.ParseForm(); err != nil { + http.Error(w, "bad form", 400) + return + } + user := r.FormValue("username") + pass := r.FormValue("password") + if !s.cfg.CheckAdmin(user, pass) { + http.Error(w, "401 Unauthorized", 401) + return + } + tok := s.tokens.Create() + http.SetCookie(w, &http.Cookie{ + Name: "admin_session", Value: tok, Path: "/admin", + HttpOnly: true, SameSite: http.SameSiteLaxMode, MaxAge: 86400, + }) + http.Redirect(w, r, "/admin/dashboard", http.StatusFound) + default: + http.Error(w, "method not allowed", 405) + } +} + +func (s *Server) handleAdminDashboard(w http.ResponseWriter, r *http.Request) { + if !s.requireAdminCookie(w, r) { + return + } + + var userCount, deviceCount, opsCount int + s.db.QueryRow("SELECT COUNT(*) FROM server_users").Scan(&userCount) + s.db.QueryRow("SELECT COUNT(*) FROM server_devices").Scan(&deviceCount) + s.db.QueryRow("SELECT COUNT(*) FROM server_ops").Scan(&opsCount) + + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.Write([]byte(`Admin Dashboard + +

Verstak Sync Server — Admin

+
` + intToStr(userCount) + `
Users
+
` + intToStr(deviceCount) + `
Devices
+
` + intToStr(opsCount) + `
Sync Ops
+

Users | Devices | Health

+`)) +} + +func (s *Server) handleAdminUsers(w http.ResponseWriter, r *http.Request) { + if !s.requireAdminCookie(w, r) { + return + } + rows, err := s.db.Query("SELECT id, username, email, confirmed, blocked, created_at FROM server_users ORDER BY created_at DESC") + if err != nil { + http.Error(w, err.Error(), 500) + return + } + defer rows.Close() + + var users []map[string]interface{} + for rows.Next() { + var id, username, email, createdAt string + var confirmed, blocked int + rows.Scan(&id, &username, &email, &confirmed, &blocked, &createdAt) + users = append(users, map[string]interface{}{ + "id": id, "username": username, "email": email, + "confirmed": confirmed, "blocked": blocked, "created_at": createdAt, + }) + } + + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.Write([]byte(`Users + +

Users ← Dashboard

+`)) + + for _, u := range users { + confirmed := "✅" + if u["confirmed"].(int) == 0 { + confirmed = "❌" + } + blocked := "" + if u["blocked"].(int) != 0 { + blocked = "🚫" + } + w.Write([]byte(``)) + } + w.Write([]byte(`
UsernameEmailConfirmedBlockedCreated
` + u["username"].(string) + `` + u["email"].(string) + + `` + confirmed + `` + blocked + `` + u["created_at"].(string) + `
`)) +} + +func (s *Server) handleAdminDevices(w http.ResponseWriter, r *http.Request) { + if !s.requireAdminCookie(w, r) { + return + } + rows, err := s.db.Query(`SELECT d.id, d.name, d.client_version, COALESCE(d.last_seen,''), COALESCE(d.revoked_at,''), d.created_at + FROM server_devices d ORDER BY d.created_at DESC`) + if err != nil { + http.Error(w, err.Error(), 500) + return + } + defer rows.Close() + + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.Write([]byte(`Devices + +

Devices ← Dashboard

+`)) + + for rows.Next() { + var id, name, clientVer, lastSeen, revokedAt, createdAt string + rows.Scan(&id, &name, &clientVer, &lastSeen, &revokedAt, &createdAt) + if lastSeen == "" { + lastSeen = "never" + } + if revokedAt == "" { + revokedAt = "-" + } + w.Write([]byte(``)) + } + w.Write([]byte(`
NameIDVersionLast SeenRevokedCreated
` + name + `` + id + + `` + clientVer + `` + lastSeen + `` + revokedAt + `` + createdAt + `
`)) +} + +func (s *Server) requireAdminCookie(w http.ResponseWriter, r *http.Request) bool { + cookie, err := r.Cookie("admin_session") + if err != nil || cookie.Value == "" { + http.Redirect(w, r, "/admin/login", http.StatusFound) + return false + } + if !s.tokens.Check(cookie.Value) { + http.Redirect(w, r, "/admin/login", http.StatusFound) + return false + } + return true +} + +func intToStr(n int) string { + b, _ := json.Marshal(n) + return strings.Trim(string(b), "\"") +} + +var _ = time.Now +var _ = rand.Read +var _ = hex.EncodeToString diff --git a/internal/server/routes.go b/internal/server/routes.go index 8307a40..33063d8 100644 --- a/internal/server/routes.go +++ b/internal/server/routes.go @@ -16,5 +16,9 @@ func (s *Server) routes() { s.mux.HandleFunc("/api/v1/auth/login", s.handleUserLogin) s.mux.HandleFunc("/api/v1/auth/forgot", s.handleForgot) s.mux.HandleFunc("/api/v1/auth/reset", s.handleReset) + s.mux.HandleFunc("/admin/login", s.handleAdminLogin) + s.mux.HandleFunc("/admin/dashboard", s.handleAdminDashboard) + s.mux.HandleFunc("/admin/users", s.handleAdminUsers) + s.mux.HandleFunc("/admin/devices", s.handleAdminDevices) s.mux.HandleFunc("/", s.handleNotFound) }