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(s.locale()))) 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(errorPageHTML(s.locale(), "401 Unauthorized", "401 Unauthorized", "/admin/login"))) 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") var deviceCount, opsCount int s.db.QueryRow("SELECT COUNT(*) FROM server_devices").Scan(&deviceCount) s.db.QueryRow("SELECT COUNT(*) FROM server_ops").Scan(&opsCount) 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") w.Write([]byte(adminDashboardHTML(s.locale(), deviceCount, opsCount, smtpHost, smtpPort, smtpUser, smtpFrom, smtpSecurity, srvURL))) } 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(s.locale()))) } 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") } }