package server import ( "crypto/rand" "encoding/hex" "encoding/json" "fmt" "net/http" "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(`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), "\"") } func (s *Server) handleAdminStats(w http.ResponseWriter, r *http.Request) { if !s.requireAdminCookie(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.requireAdminCookie(w, r) { return } if r.Method != "POST" { jsonErr(w, 405, "POST required") 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) handleAdminAPIDevices(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, 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) } func (s *Server) handleAdminAPIKeys(w http.ResponseWriter, r *http.Request) { if !s.requireAdminCookie(w, r) { return } switch r.Method { case "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) default: jsonErr(w, 405, "method not allowed") } } func (s *Server) handleAdminAPISmtp(w http.ResponseWriter, r *http.Request) { if !s.requireAdminCookie(w, r) { return } if r.Method != "POST" { jsonErr(w, 405, "POST required") return } 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) } func (s *Server) handleAdminAPIKeysDelete(w http.ResponseWriter, r *http.Request) { if !s.requireAdminCookie(w, r) { return } if r.Method != "DELETE" { jsonErr(w, 405, "DELETE required") return } id := strings.TrimPrefix(r.URL.Path, "/admin/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"}) } func (s *Server) handleAdminAPIUsers(w http.ResponseWriter, r *http.Request) { if !s.requireAdminCookie(w, r) { return } filter := r.URL.Query().Get("filter") sort := r.URL.Query().Get("sort") order := r.URL.Query().Get("order") page := 1 perPage := 20 if v := r.URL.Query().Get("page"); v != "" { fmt.Sscanf(v, "%d", &page) } if v := r.URL.Query().Get("per_page"); v != "" { fmt.Sscanf(v, "%d", &perPage) } 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 } var total int countSQL := "SELECT COUNT(*) FROM server_users u" + where s.db.QueryRow(countSQL, args...).Scan(&total) 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, }) } func (s *Server) handleAdminAPIUserActions(w http.ResponseWriter, r *http.Request) { if !s.requireAdminCookie(w, r) { return } path := strings.TrimPrefix(r.URL.Path, "/admin/api/users/") if strings.HasSuffix(path, "/block") && r.Method == "POST" { id := strings.TrimSuffix(path, "/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(path, "/reset-password") && r.Method == "POST" { id := strings.TrimSuffix(path, "/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(path, "/edit") && r.Method == "POST" { id := strings.TrimSuffix(path, "/edit") id = strings.TrimSuffix(id, "/") var editReq struct { Username string `json:"username"` Email string `json:"email"` } if err := json.NewDecoder(r.Body).Decode(&editReq); err != nil { jsonErr(w, 400, "bad json") return } if editReq.Username == "" || editReq.Email == "" { jsonErr(w, 400, "username and email required") return } _, err := s.db.Exec("UPDATE server_users SET username=?, email=? WHERE id=?", editReq.Username, strings.ToLower(editReq.Email), id) if err != nil { jsonErr(w, 500, err.Error()) return } jsonOK(w, map[string]interface{}{"status": "ok"}) return } if r.Method == "DELETE" { id := strings.TrimSuffix(path, "/") 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"}) return } jsonErr(w, 404, "unknown action") } func (s *Server) handleAdminCreateUser(w http.ResponseWriter, r *http.Request) { if !s.requireAdminCookie(w, r) { return } locale := s.locale() switch r.Method { case "GET": w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Write([]byte(adminCreateUserHTML(locale))) case "POST": if err := r.ParseForm(); err != nil { http.Error(w, "bad form", 400) 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(errorPageHTML(locale, t(locale, "common.error"), t(locale, "server.allFieldsRequired"), "/admin/create-user"))) return } if err := validatePassword(password); err != "" { w.Header().Set("Content-Type", "text/html; charset=utf-8") w.WriteHeader(400) w.Write([]byte(errorPageHTML(locale, t(locale, "common.error"), string(err), "/admin/create-user"))) return } hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) if err != nil { w.Header().Set("Content-Type", "text/html; charset=utf-8") w.WriteHeader(500) w.Write([]byte(errorPageHTML(locale, t(locale, "common.error"), "internal error", "/admin/create-user"))) 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 (?, ?, ?, ?, 1, ?)", 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(errorPageHTML(locale, t(locale, "common.error"), "Username or email already taken", "/admin/create-user"))) } else { w.WriteHeader(500) w.Write([]byte(errorPageHTML(locale, t(locale, "common.error"), err.Error(), "/admin/create-user"))) } return } http.Redirect(w, r, "/admin/users", http.StatusFound) default: http.Error(w, "method not allowed", 405) } } func (s *Server) handleAdminAPICreateUser(w http.ResponseWriter, r *http.Request) { if !s.requireAdminCookie(w, r) { return } 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, "bad 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, string(err)) 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 (?, ?, ?, ?, 1, ?)", 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") } else { jsonErr(w, 500, err.Error()) } return } jsonOK(w, map[string]string{"status": "ok", "user_id": userID}) } var _ = time.Now var _ = rand.Read var _ = hex.EncodeToString