mirror of
https://github.com/J3vb/OwnCord.git
synced 2026-09-03 03:50:00 +03:00
feat: add setup wizard for initial owner account creation
When no users exist, the admin panel shows a setup wizard instead of the login form. Creates the first Owner account with a session token and generates an unlimited invite code for onboarding other users. The setup endpoint is locked out after the first user is created. Also fixes the admin panel 404 by serving index.html directly for the root path instead of delegating to http.FileServer.
This commit is contained in:
@@ -37,6 +37,18 @@ func NewHandler(database *db.DB, version string, hub HubBroadcaster, u *updater.
|
||||
// happen in production. Panic so it surfaces immediately in tests.
|
||||
panic("admin: failed to create static sub-FS: " + err.Error())
|
||||
}
|
||||
|
||||
// Serve index.html directly for the root path. We read it once at
|
||||
// startup instead of using http.FileServer, which has redirect
|
||||
// behaviour that conflicts with chi's Mount prefix stripping.
|
||||
indexHTML, err := fs.ReadFile(staticFS, "index.html")
|
||||
if err != nil {
|
||||
panic("admin: failed to read index.html: " + err.Error())
|
||||
}
|
||||
r.Get("/", func(w http.ResponseWriter, req *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.Write(indexHTML)
|
||||
})
|
||||
r.Handle("/*", http.FileServer(http.FS(staticFS)))
|
||||
|
||||
return r
|
||||
|
||||
+28
-21
@@ -30,31 +30,38 @@ const (
|
||||
// ─── NewAdminAPI ──────────────────────────────────────────────────────────────
|
||||
|
||||
// NewAdminAPI returns a chi router with all /admin/api/* routes. All routes
|
||||
// are protected by adminAuthMiddleware which requires the ADMINISTRATOR bit.
|
||||
// are protected by adminAuthMiddleware which requires the ADMINISTRATOR bit,
|
||||
// except for the setup endpoints which are unauthenticated.
|
||||
func NewAdminAPI(database *db.DB, version string, hub HubBroadcaster, u *updater.Updater) http.Handler {
|
||||
r := chi.NewRouter()
|
||||
|
||||
// All routes require authentication and ADMINISTRATOR permission.
|
||||
r.Use(adminAuthMiddleware(database))
|
||||
// Setup endpoints — unauthenticated, only functional when no users exist.
|
||||
r.Get("/setup/status", handleSetupStatus(database))
|
||||
r.Post("/setup", handleSetup(database))
|
||||
|
||||
r.Get("/stats", handleGetStats(database))
|
||||
r.Get("/users", handleListUsers(database))
|
||||
r.Patch("/users/{id}", handlePatchUser(database))
|
||||
r.Delete("/users/{id}/sessions", handleForceLogout(database))
|
||||
r.Get("/channels", handleListChannels(database))
|
||||
r.Post("/channels", handleCreateChannel(database))
|
||||
r.Patch("/channels/{id}", handlePatchChannel(database))
|
||||
r.Delete("/channels/{id}", handleDeleteChannel(database))
|
||||
r.Get("/audit-log", handleGetAuditLog(database))
|
||||
r.Get("/settings", handleGetSettings(database))
|
||||
r.Patch("/settings", handlePatchSettings(database))
|
||||
r.Post("/backup", http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||
ownerOnlyMiddleware(database, handleBackup(database)).ServeHTTP(w, req)
|
||||
}))
|
||||
r.Get("/updates", handleCheckUpdate(u))
|
||||
r.Post("/updates/apply", http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||
ownerOnlyMiddleware(database, handleApplyUpdate(u, hub, version)).ServeHTTP(w, req)
|
||||
}))
|
||||
// All remaining routes require authentication and ADMINISTRATOR permission.
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(adminAuthMiddleware(database))
|
||||
|
||||
r.Get("/stats", handleGetStats(database))
|
||||
r.Get("/users", handleListUsers(database))
|
||||
r.Patch("/users/{id}", handlePatchUser(database))
|
||||
r.Delete("/users/{id}/sessions", handleForceLogout(database))
|
||||
r.Get("/channels", handleListChannels(database))
|
||||
r.Post("/channels", handleCreateChannel(database))
|
||||
r.Patch("/channels/{id}", handlePatchChannel(database))
|
||||
r.Delete("/channels/{id}", handleDeleteChannel(database))
|
||||
r.Get("/audit-log", handleGetAuditLog(database))
|
||||
r.Get("/settings", handleGetSettings(database))
|
||||
r.Patch("/settings", handlePatchSettings(database))
|
||||
r.Post("/backup", http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||
ownerOnlyMiddleware(database, handleBackup(database)).ServeHTTP(w, req)
|
||||
}))
|
||||
r.Get("/updates", handleCheckUpdate(u))
|
||||
r.Post("/updates/apply", http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||
ownerOnlyMiddleware(database, handleApplyUpdate(u, hub, version)).ServeHTTP(w, req)
|
||||
}))
|
||||
})
|
||||
|
||||
return r
|
||||
}
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/microcosm-cc/bluemonday"
|
||||
"github.com/owncord/server/auth"
|
||||
"github.com/owncord/server/db"
|
||||
)
|
||||
|
||||
// setupSanitizer strips all HTML from user input during setup.
|
||||
var setupSanitizer = bluemonday.StrictPolicy()
|
||||
|
||||
// ownerRoleID is the role ID assigned to the first user (Owner).
|
||||
const ownerRoleID = 1
|
||||
|
||||
// setupStatusResponse is the JSON shape returned by GET /api/setup/status.
|
||||
type setupStatusResponse struct {
|
||||
NeedsSetup bool `json:"needs_setup"`
|
||||
}
|
||||
|
||||
// setupRequest is the JSON body for POST /api/setup.
|
||||
type setupRequest struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
// setupResponse is the JSON shape returned on successful setup.
|
||||
type setupResponse struct {
|
||||
Token string `json:"token"`
|
||||
UserID int64 `json:"user_id"`
|
||||
Username string `json:"username"`
|
||||
InviteCode string `json:"invite_code"`
|
||||
}
|
||||
|
||||
// handleSetupStatus returns whether initial setup is needed (no users exist).
|
||||
func handleSetupStatus(database *db.DB) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
count, err := database.UserCount()
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to check user count")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, setupStatusResponse{NeedsSetup: count == 0})
|
||||
}
|
||||
}
|
||||
|
||||
// handleSetup creates the first owner account. It only works when no users
|
||||
// exist in the database, preventing abuse after initial setup.
|
||||
func handleSetup(database *db.DB) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
// Gate: only allow when no users exist.
|
||||
count, err := database.UserCount()
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to check user count")
|
||||
return
|
||||
}
|
||||
if count > 0 {
|
||||
writeErr(w, http.StatusForbidden, "FORBIDDEN", "setup has already been completed")
|
||||
return
|
||||
}
|
||||
|
||||
var req setupRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeErr(w, http.StatusBadRequest, "BAD_REQUEST", "invalid request body")
|
||||
return
|
||||
}
|
||||
|
||||
req.Username = strings.TrimSpace(setupSanitizer.Sanitize(req.Username))
|
||||
if req.Username == "" || req.Password == "" {
|
||||
writeErr(w, http.StatusBadRequest, "BAD_REQUEST", "username and password are required")
|
||||
return
|
||||
}
|
||||
|
||||
if err := auth.ValidatePasswordStrength(req.Password); err != nil {
|
||||
writeErr(w, http.StatusBadRequest, "BAD_REQUEST", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// Hash the password.
|
||||
hash, err := auth.HashPassword(req.Password)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to hash password")
|
||||
return
|
||||
}
|
||||
|
||||
// Create the owner account (role_id=1 is Owner).
|
||||
uid, err := database.CreateUser(req.Username, hash, ownerRoleID)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to create user")
|
||||
return
|
||||
}
|
||||
|
||||
// Issue a session token so the user is immediately logged in.
|
||||
token, err := auth.GenerateToken()
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to generate session token")
|
||||
return
|
||||
}
|
||||
|
||||
device := r.Header.Get("User-Agent")
|
||||
ip := r.RemoteAddr
|
||||
if _, err := database.CreateSession(uid, auth.HashToken(token), device, ip); err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to create session")
|
||||
return
|
||||
}
|
||||
|
||||
// Generate a bootstrap invite code so the owner can invite others.
|
||||
inviteCode, err := database.CreateInvite(uid, 0, nil) // unlimited uses, no expiry
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to generate invite code")
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusCreated, setupResponse{
|
||||
Token: token,
|
||||
UserID: uid,
|
||||
Username: req.Username,
|
||||
InviteCode: inviteCode,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
package admin_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/owncord/server/admin"
|
||||
)
|
||||
|
||||
func TestSetupStatus_NeedsSetup(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil)
|
||||
|
||||
rr := doRequest(t, handler, "GET", "/setup/status", "", nil)
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("GET /setup/status = %d, want 200", rr.Code)
|
||||
}
|
||||
|
||||
var resp struct {
|
||||
NeedsSetup bool `json:"needs_setup"`
|
||||
}
|
||||
if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if !resp.NeedsSetup {
|
||||
t.Error("needs_setup = false, want true (no users)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetupStatus_NoSetupNeeded(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
createAdminUser(t, database) // Create a user first
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil)
|
||||
|
||||
rr := doRequest(t, handler, "GET", "/setup/status", "", nil)
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("GET /setup/status = %d, want 200", rr.Code)
|
||||
}
|
||||
|
||||
var resp struct {
|
||||
NeedsSetup bool `json:"needs_setup"`
|
||||
}
|
||||
if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if resp.NeedsSetup {
|
||||
t.Error("needs_setup = true, want false (user exists)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetup_CreatesOwner(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil)
|
||||
|
||||
rr := doRequest(t, handler, "POST", "/setup", "", map[string]string{
|
||||
"username": "myadmin",
|
||||
"password": "SecurePass123!",
|
||||
})
|
||||
|
||||
if rr.Code != http.StatusCreated {
|
||||
t.Fatalf("POST /setup = %d, want 201; body=%s", rr.Code, rr.Body.String())
|
||||
}
|
||||
|
||||
var resp struct {
|
||||
Token string `json:"token"`
|
||||
UserID int64 `json:"user_id"`
|
||||
Username string `json:"username"`
|
||||
InviteCode string `json:"invite_code"`
|
||||
}
|
||||
if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if resp.Token == "" {
|
||||
t.Error("token is empty")
|
||||
}
|
||||
if resp.Username != "myadmin" {
|
||||
t.Errorf("username = %q, want %q", resp.Username, "myadmin")
|
||||
}
|
||||
if resp.InviteCode == "" {
|
||||
t.Error("invite_code is empty")
|
||||
}
|
||||
if resp.UserID == 0 {
|
||||
t.Error("user_id is 0")
|
||||
}
|
||||
|
||||
// Verify user was created with Owner role.
|
||||
user, err := database.GetUserByUsername("myadmin")
|
||||
if err != nil || user == nil {
|
||||
t.Fatal("user not found in database after setup")
|
||||
}
|
||||
if user.RoleID != 1 {
|
||||
t.Errorf("role_id = %d, want 1 (Owner)", user.RoleID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetup_BlockedAfterFirstUser(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil)
|
||||
|
||||
// First setup succeeds.
|
||||
rr := doRequest(t, handler, "POST", "/setup", "", map[string]string{
|
||||
"username": "owner1",
|
||||
"password": "SecurePass123!",
|
||||
})
|
||||
if rr.Code != http.StatusCreated {
|
||||
t.Fatalf("first setup = %d, want 201; body=%s", rr.Code, rr.Body.String())
|
||||
}
|
||||
|
||||
// Second setup is blocked.
|
||||
rr2 := doRequest(t, handler, "POST", "/setup", "", map[string]string{
|
||||
"username": "hacker",
|
||||
"password": "EvilPass456!",
|
||||
})
|
||||
if rr2.Code != http.StatusForbidden {
|
||||
t.Errorf("second setup = %d, want 403", rr2.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetup_WeakPassword(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil)
|
||||
|
||||
rr := doRequest(t, handler, "POST", "/setup", "", map[string]string{
|
||||
"username": "admin",
|
||||
"password": "short",
|
||||
})
|
||||
if rr.Code != http.StatusBadRequest {
|
||||
t.Errorf("weak password = %d, want 400; body=%s", rr.Code, rr.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetup_MissingFields(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil)
|
||||
|
||||
rr := doRequest(t, handler, "POST", "/setup", "", map[string]string{
|
||||
"username": "",
|
||||
"password": "",
|
||||
})
|
||||
if rr.Code != http.StatusBadRequest {
|
||||
t.Errorf("missing fields = %d, want 400", rr.Code)
|
||||
}
|
||||
}
|
||||
@@ -203,8 +203,41 @@
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!-- Setup wizard overlay (first-time setup) -->
|
||||
<div id="setup-overlay" class="hidden">
|
||||
<div id="login-box">
|
||||
<h2>Welcome to OwnCord</h2>
|
||||
<p style="color:var(--muted);font-size:14px;margin-bottom:20px">No accounts exist yet. Create the owner account to get started.</p>
|
||||
<div class="form-group">
|
||||
<label for="setup-username">Username</label>
|
||||
<input id="setup-username" type="text" autocomplete="username" placeholder="Choose a username">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="setup-password">Password</label>
|
||||
<input id="setup-password" type="password" autocomplete="new-password" placeholder="Min 8 characters">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="setup-confirm">Confirm Password</label>
|
||||
<input id="setup-confirm" type="password" autocomplete="new-password" placeholder="Re-enter password">
|
||||
</div>
|
||||
<button class="btn" id="setup-btn" style="width:100%">Create Owner Account</button>
|
||||
<div id="setup-error" style="color:var(--danger);font-size:13px;margin-top:10px;min-height:20px"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Setup success overlay (shows invite code) -->
|
||||
<div id="setup-success-overlay" class="hidden">
|
||||
<div id="login-box">
|
||||
<h2>Setup Complete!</h2>
|
||||
<p style="color:var(--muted);font-size:14px;margin-bottom:16px">Your owner account has been created. Here's your invite code for adding other users:</p>
|
||||
<div style="background:var(--bg);border:1px solid var(--border);border-radius:4px;padding:12px;font-family:monospace;font-size:16px;text-align:center;margin-bottom:16px;user-select:all;letter-spacing:.05em" id="setup-invite-code"></div>
|
||||
<p style="color:var(--warning);font-size:13px;margin-bottom:20px">Save this code! Share it with people you want to invite. It has unlimited uses.</p>
|
||||
<button class="btn" id="setup-continue-btn" style="width:100%">Continue to Admin Panel</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Login overlay -->
|
||||
<div id="login-overlay">
|
||||
<div id="login-overlay" class="hidden">
|
||||
<div id="login-box">
|
||||
<h2>OwnCord Admin</h2>
|
||||
<div class="form-group">
|
||||
@@ -370,6 +403,18 @@ async function api(method, path, body) {
|
||||
|
||||
// ─── Auth ─────────────────────────────────────────────────────────────────────
|
||||
async function checkAuth() {
|
||||
// First check if initial setup is needed.
|
||||
try {
|
||||
const res = await fetch('/admin/api/setup/status');
|
||||
const data = await res.json();
|
||||
if (data.needs_setup) {
|
||||
showSetup();
|
||||
return;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('setup status check failed:', e);
|
||||
}
|
||||
|
||||
if (!token) { showLogin(); return; }
|
||||
try {
|
||||
await api('GET', '/stats');
|
||||
@@ -380,17 +425,81 @@ async function checkAuth() {
|
||||
}
|
||||
}
|
||||
|
||||
function showLogin() {
|
||||
document.getElementById('login-overlay').classList.remove('hidden');
|
||||
function hideAllOverlays() {
|
||||
document.getElementById('setup-overlay').classList.add('hidden');
|
||||
document.getElementById('setup-success-overlay').classList.add('hidden');
|
||||
document.getElementById('login-overlay').classList.add('hidden');
|
||||
document.getElementById('app').classList.add('hidden');
|
||||
}
|
||||
|
||||
function showSetup() {
|
||||
hideAllOverlays();
|
||||
document.getElementById('setup-overlay').classList.remove('hidden');
|
||||
}
|
||||
|
||||
function showLogin() {
|
||||
hideAllOverlays();
|
||||
document.getElementById('login-overlay').classList.remove('hidden');
|
||||
}
|
||||
|
||||
function showApp() {
|
||||
document.getElementById('login-overlay').classList.add('hidden');
|
||||
hideAllOverlays();
|
||||
document.getElementById('app').classList.remove('hidden');
|
||||
document.getElementById('app').style.display = 'flex';
|
||||
}
|
||||
|
||||
// ─── Setup wizard ─────────────────────────────────────────────────────────────
|
||||
document.getElementById('setup-btn').onclick = async () => {
|
||||
const username = document.getElementById('setup-username').value.trim();
|
||||
const password = document.getElementById('setup-password').value;
|
||||
const confirm = document.getElementById('setup-confirm').value;
|
||||
const errEl = document.getElementById('setup-error');
|
||||
errEl.textContent = '';
|
||||
|
||||
if (!username || !password) {
|
||||
errEl.textContent = 'Username and password are required.';
|
||||
return;
|
||||
}
|
||||
if (password !== confirm) {
|
||||
errEl.textContent = 'Passwords do not match.';
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch('/admin/api/setup', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username, password }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.message || 'Setup failed');
|
||||
|
||||
// Store the session token.
|
||||
token = data.token;
|
||||
localStorage.setItem('admin_token', token);
|
||||
|
||||
// Show the invite code before continuing.
|
||||
hideAllOverlays();
|
||||
document.getElementById('setup-invite-code').textContent = data.invite_code;
|
||||
document.getElementById('setup-success-overlay').classList.remove('hidden');
|
||||
} catch (e) {
|
||||
errEl.textContent = e.message;
|
||||
}
|
||||
};
|
||||
|
||||
document.getElementById('setup-continue-btn').onclick = () => {
|
||||
showApp();
|
||||
loadSection('dashboard');
|
||||
};
|
||||
|
||||
// Allow Enter key to submit setup form.
|
||||
['setup-username', 'setup-password', 'setup-confirm'].forEach(id => {
|
||||
document.getElementById(id).addEventListener('keydown', e => {
|
||||
if (e.key === 'Enter') document.getElementById('setup-btn').click();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Login ────────────────────────────────────────────────────────────────────
|
||||
document.getElementById('login-btn').onclick = async () => {
|
||||
const username = document.getElementById('login-username').value.trim();
|
||||
const password = document.getElementById('login-password').value;
|
||||
@@ -413,6 +522,13 @@ document.getElementById('login-btn').onclick = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
// Allow Enter key to submit login form.
|
||||
['login-username', 'login-password'].forEach(id => {
|
||||
document.getElementById(id).addEventListener('keydown', e => {
|
||||
if (e.key === 'Enter') document.getElementById('login-btn').click();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Navigation ───────────────────────────────────────────────────────────────
|
||||
document.querySelectorAll('#nav a').forEach(link => {
|
||||
link.onclick = e => {
|
||||
|
||||
@@ -14,6 +14,17 @@ const (
|
||||
permViewAuditLog = int64(0x8000000)
|
||||
)
|
||||
|
||||
// ─── Setup ───────────────────────────────────────────────────────────────────
|
||||
|
||||
// UserCount returns the total number of registered users.
|
||||
func (d *DB) UserCount() (int64, error) {
|
||||
var count int64
|
||||
if err := d.sqlDB.QueryRow(`SELECT COUNT(*) FROM users`).Scan(&count); err != nil {
|
||||
return 0, fmt.Errorf("UserCount: %w", err)
|
||||
}
|
||||
return count, nil
|
||||
}
|
||||
|
||||
// ─── Server Stats ─────────────────────────────────────────────────────────────
|
||||
|
||||
// GetServerStats returns aggregate counts for the admin dashboard.
|
||||
|
||||
Reference in New Issue
Block a user