From b15738c4d80f4983f31d1076e8aaff07171a0a7c Mon Sep 17 00:00:00 2001 From: jevb Date: Thu, 19 Mar 2026 05:32:40 +0100 Subject: [PATCH] feat: redesign admin panel, add live server logs and audit log filters Admin panel redesign: - Rebuild frontend from mockup with Discord-style dark theme - Stat cards, section cards, role badges, modal system, toast notifications - All 7 sections: Dashboard, Users, Channels, Audit Log, Settings, Backups, Updates - Modals replace confirm()/prompt() for all destructive actions Live server logs (new): - RingBuffer + MultiHandler tees slog to stdout AND in-memory buffer - SSE endpoint at /admin/api/logs/stream streams logs in real-time - Log viewer with level filters (DEBUG/INFO/WARN/ERROR), search, auto-scroll, pause/resume, copy all, clear - Color-coded lines by level, source categorization from file paths Audit log improvements: - Search filter (actor, action, target, detail) - Action type dropdown filter - Copy All and Export CSV buttons - Instant client-side re-filtering Console output: - Switch from JSON to human-readable text format (slog.TextHandler) - Move startup banner before init logs so it appears first --- Server/admin/admin.go | 4 +- Server/admin/admin_handler_test.go | 20 +- Server/admin/api.go | 8 +- Server/admin/api_edge_cases_test.go | 52 +- Server/admin/api_test.go | 86 +- Server/admin/handlers_backup_test.go | 26 +- Server/admin/handlers_channels_test.go | 14 +- Server/admin/logstream.go | 329 ++++ Server/admin/middleware_and_spawn_test.go | 2 +- Server/admin/middleware_coverage_test.go | 6 +- Server/admin/setup_handler_test.go | 12 +- Server/admin/static/index.html | 1674 +++++++++------------ Server/admin/update_handlers_test.go | 22 +- Server/api/router.go | 4 +- Server/api/router_test.go | 2 +- Server/main.go | 39 +- 16 files changed, 1234 insertions(+), 1066 deletions(-) create mode 100644 Server/admin/logstream.go diff --git a/Server/admin/admin.go b/Server/admin/admin.go index 0dad90b6..4dd49e5d 100644 --- a/Server/admin/admin.go +++ b/Server/admin/admin.go @@ -22,11 +22,11 @@ var staticFiles embed.FS // // /api/* — admin REST API (all require ADMINISTRATOR permission) // /* — embedded static files (SPA; index.html for unknown paths) -func NewHandler(database *db.DB, version string, hub HubBroadcaster, u *updater.Updater) http.Handler { +func NewHandler(database *db.DB, version string, hub HubBroadcaster, u *updater.Updater, logBuf *RingBuffer) http.Handler { r := chi.NewRouter() // Admin REST API mounted at /api - r.Mount("/api", NewAdminAPI(database, version, hub, u)) + r.Mount("/api", NewAdminAPI(database, version, hub, u, logBuf)) // Static files — serve from the "static" sub-tree of the embedded FS. // The //go:embed static directive in this package embeds as "static/…", diff --git a/Server/admin/admin_handler_test.go b/Server/admin/admin_handler_test.go index 68c46d5d..6e0edaeb 100644 --- a/Server/admin/admin_handler_test.go +++ b/Server/admin/admin_handler_test.go @@ -17,7 +17,7 @@ import ( // http.Handler with all dependencies wired. func TestNewHandler_ReturnsNonNilHandler(t *testing.T) { database := openAdminTestDB(t) - h := admin.NewHandler(database, "1.0.0", &mockHub{}, nil) + h := admin.NewHandler(database, "1.0.0", &mockHub{}, nil, nil) if h == nil { t.Fatal("NewHandler returned nil handler") } @@ -27,7 +27,7 @@ func TestNewHandler_ReturnsNonNilHandler(t *testing.T) { // responds with 200 and HTML content (the embedded admin SPA). func TestNewHandler_ServesStaticRoot(t *testing.T) { database := openAdminTestDB(t) - h := admin.NewHandler(database, "1.0.0", &mockHub{}, nil) + h := admin.NewHandler(database, "1.0.0", &mockHub{}, nil, nil) req := httptest.NewRequest(http.MethodGet, "/", nil) w := httptest.NewRecorder() @@ -47,7 +47,7 @@ func TestNewHandler_ServesStaticRoot(t *testing.T) { // Content-Security-Policy header allowing inline scripts and styles. func TestNewHandler_SetsCSPOnRoot(t *testing.T) { database := openAdminTestDB(t) - h := admin.NewHandler(database, "1.0.0", nil, nil) + h := admin.NewHandler(database, "1.0.0", nil, nil, nil) req := httptest.NewRequest(http.MethodGet, "/", nil) w := httptest.NewRecorder() @@ -63,7 +63,7 @@ func TestNewHandler_SetsCSPOnRoot(t *testing.T) { // through the NewHandler-returned handler (setup/status endpoint is unauthenticated). func TestNewHandler_APIRoutesMounted(t *testing.T) { database := openAdminTestDB(t) - h := admin.NewHandler(database, "1.0.0", &mockHub{}, nil) + h := admin.NewHandler(database, "1.0.0", &mockHub{}, nil, nil) req := httptest.NewRequest(http.MethodGet, "/api/setup/status", nil) w := httptest.NewRecorder() @@ -79,7 +79,7 @@ func TestNewHandler_APIRoutesMounted(t *testing.T) { // /api require a valid token. func TestNewHandler_AuthProtectedRoute(t *testing.T) { database := openAdminTestDB(t) - h := admin.NewHandler(database, "1.0.0", &mockHub{}, nil) + h := admin.NewHandler(database, "1.0.0", &mockHub{}, nil, nil) // /api/stats requires authentication req := httptest.NewRequest(http.MethodGet, "/api/stats", nil) @@ -96,7 +96,7 @@ func TestNewHandler_AuthProtectedRoute(t *testing.T) { func TestNewHandler_WithUpdater(t *testing.T) { database := openAdminTestDB(t) u := updater.NewUpdater("1.0.0", "", "J3vb", "OwnCord") - h := admin.NewHandler(database, "1.0.0", &mockHub{}, u) + h := admin.NewHandler(database, "1.0.0", &mockHub{}, u, nil) if h == nil { t.Fatal("NewHandler with updater returned nil handler") } @@ -135,7 +135,7 @@ func TestHandler_ServesEmbeddedFiles(t *testing.T) { // (position == 100) can reach backup endpoints. func TestOwnerOnlyMiddleware_OwnerAllowed(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil) // createAdminUser creates an Owner-role user (role_id=1, position=100) ownerToken := createAdminUser(t, database) @@ -166,7 +166,7 @@ func TestOwnerOnlyMiddleware_OwnerAllowed(t *testing.T) { // (position < 100) cannot reach owner-only endpoints. func TestOwnerOnlyMiddleware_AdminDenied(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil) // Create admin user (role_id=2, position=80) adminUID, _ := database.CreateUser("middlewareadmin", "hash", 2) @@ -184,7 +184,7 @@ func TestOwnerOnlyMiddleware_AdminDenied(t *testing.T) { // reach owner-only endpoints. func TestOwnerOnlyMiddleware_MemberDenied(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil) memberToken := createMemberUser(t, database) @@ -201,7 +201,7 @@ func TestOwnerOnlyMiddleware_MemberDenied(t *testing.T) { // rejected before reaching ownerOnlyMiddleware. func TestOwnerOnlyMiddleware_Unauthenticated(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil) w := doRequest(t, handler, http.MethodPost, "/backup", "", nil) diff --git a/Server/admin/api.go b/Server/admin/api.go index 70dfa0e9..d5c252a0 100644 --- a/Server/admin/api.go +++ b/Server/admin/api.go @@ -114,13 +114,19 @@ func toAdminUserResponseFromUser(database *db.DB, u *db.User) adminUserResponse // NewAdminAPI returns a chi router with all /admin/api/* routes. All routes // 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 { +func NewAdminAPI(database *db.DB, version string, hub HubBroadcaster, u *updater.Updater, logBuf *RingBuffer) http.Handler { r := chi.NewRouter() // Setup endpoints — unauthenticated, only functional when no users exist. r.Get("/setup/status", handleSetupStatus(database)) r.Post("/setup", handleSetup(database)) + // SSE log stream — does its own auth via query param token because + // EventSource cannot send Authorization headers. + if logBuf != nil { + r.Get("/logs/stream", handleLogStream(logBuf, database)) + } + // All remaining routes require authentication and ADMINISTRATOR permission. r.Group(func(r chi.Router) { r.Use(adminAuthMiddleware(database)) diff --git a/Server/admin/api_edge_cases_test.go b/Server/admin/api_edge_cases_test.go index 2c2e0390..5879eec3 100644 --- a/Server/admin/api_edge_cases_test.go +++ b/Server/admin/api_edge_cases_test.go @@ -18,7 +18,7 @@ import ( // their own account via the admin panel. func TestAdminAPI_PatchUser_CannotModifySelf(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil) token := createAdminUser(t, database) // The admin user created by createAdminUser has id=1. We try to patch id=1. @@ -34,7 +34,7 @@ func TestAdminAPI_PatchUser_CannotModifySelf(t *testing.T) { // banned user unbans them and returns 200. func TestAdminAPI_PatchUser_UnbanUser(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil) token := createAdminUser(t, database) // Create and ban a target user first. @@ -58,7 +58,7 @@ func TestAdminAPI_PatchUser_UnbanUser(t *testing.T) { // TestAdminAPI_PatchUser_InvalidBody verifies that a non-JSON body returns 400. func TestAdminAPI_PatchUser_InvalidBody(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", nil, nil) + handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil) token := createAdminUser(t, database) targetUID, _ := database.CreateUser("invalidbody", "hash", 3) @@ -80,7 +80,7 @@ func TestAdminAPI_PatchUser_InvalidBody(t *testing.T) { // "type" field causes the channel to be created with type "text". func TestAdminAPI_CreateChannel_DefaultsTypeToText(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", nil, nil) + handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil) token := createAdminUser(t, database) body := map[string]any{ @@ -105,7 +105,7 @@ func TestAdminAPI_CreateChannel_DefaultsTypeToText(t *testing.T) { // TestAdminAPI_CreateChannel_InvalidBody verifies that a malformed body returns 400. func TestAdminAPI_CreateChannel_InvalidBody(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", nil, nil) + handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil) token := createAdminUser(t, database) req := httptest.NewRequest(http.MethodPost, "/channels", bytes.NewReader([]byte("not-json"))) @@ -125,7 +125,7 @@ func TestAdminAPI_CreateChannel_InvalidBody(t *testing.T) { // the URL returns 400. func TestAdminAPI_ForceLogout_InvalidID(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", nil, nil) + handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil) token := createAdminUser(t, database) w := doRequest(t, handler, http.MethodDelete, "/users/notanumber/sessions", token, nil) @@ -141,7 +141,7 @@ func TestAdminAPI_ForceLogout_InvalidID(t *testing.T) { // returns 400. func TestAdminAPI_PatchChannel_InvalidBody(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", nil, nil) + handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil) token := createAdminUser(t, database) chID, _ := database.AdminCreateChannel("malformed", "text", "", "", 0) @@ -163,7 +163,7 @@ func TestAdminAPI_PatchChannel_InvalidBody(t *testing.T) { // to 500 (testing the queryInt cap branch). func TestAdminAPI_ListUsers_CapLargeLimit(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", nil, nil) + handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil) token := createAdminUser(t, database) // Passing limit=9999 should be silently capped to 500. @@ -180,7 +180,7 @@ func TestAdminAPI_ListUsers_CapLargeLimit(t *testing.T) { // when no updater is configured. func TestAdminAPI_CheckUpdate_NilUpdater(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", nil, nil) + handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil) token := createAdminUser(t, database) w := doRequest(t, handler, http.MethodGet, "/updates", token, nil) @@ -196,7 +196,7 @@ func TestAdminAPI_CheckUpdate_NilUpdater(t *testing.T) { // returns 400. func TestAdminAPI_DeleteChannel_InvalidID(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", nil, nil) + handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil) token := createAdminUser(t, database) w := doRequest(t, handler, http.MethodDelete, "/channels/notanumber", token, nil) @@ -212,7 +212,7 @@ func TestAdminAPI_DeleteChannel_InvalidID(t *testing.T) { // returns 400. func TestAdminAPI_PatchChannel_InvalidID(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", nil, nil) + handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil) token := createAdminUser(t, database) body := map[string]any{"name": "x"} @@ -228,7 +228,7 @@ func TestAdminAPI_PatchChannel_InvalidID(t *testing.T) { // TestAdminAPI_AuditLog_Pagination verifies that limit and offset params work. func TestAdminAPI_AuditLog_Pagination(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", nil, nil) + handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil) token := createAdminUser(t, database) // Create several audit entries. @@ -259,7 +259,7 @@ func TestAdminAPI_AuditLog_Pagination(t *testing.T) { // hub is nil (the OnlineCount field defaults to 0). func TestAdminAPI_Stats_NilHub(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", nil, nil) + handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil) token := createAdminUser(t, database) w := doRequest(t, handler, http.MethodGet, "/stats", token, nil) @@ -286,7 +286,7 @@ func TestAdminAPI_Stats_NilHub(t *testing.T) { // falls back to the default (testing the queryInt error-fallback branch). func TestAdminAPI_AuditLog_InvalidLimitParam(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", nil, nil) + handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil) token := createAdminUser(t, database) w := doRequest(t, handler, http.MethodGet, "/audit-log?limit=notanumber", token, nil) @@ -300,7 +300,7 @@ func TestAdminAPI_AuditLog_InvalidLimitParam(t *testing.T) { // the default (testing the n < 1 branch of queryInt). func TestAdminAPI_ListUsers_InvalidLimitParam(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", nil, nil) + handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil) token := createAdminUser(t, database) // limit=0 triggers the n < 1 fallback in queryInt @@ -318,7 +318,7 @@ func TestAdminAPI_ListUsers_InvalidLimitParam(t *testing.T) { // BroadcastMemberBan). func TestAdminAPI_PatchUser_BanNilHubDoesNotPanic(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", nil, nil) + handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil) token := createAdminUser(t, database) targetUID, _ := database.CreateUser("ban-nohub", "hash", 3) @@ -342,7 +342,7 @@ func TestAdminAPI_PatchUser_BanNilHubDoesNotPanic(t *testing.T) { // around BroadcastMemberUpdate). func TestAdminAPI_PatchUser_RoleChangeNilHubDoesNotPanic(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", nil, nil) + handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil) token := createAdminUser(t, database) targetUID, _ := database.CreateUser("role-nohub", "hash", 3) @@ -367,7 +367,7 @@ func TestAdminAPI_PatchUser_RoleChangeNilHubDoesNotPanic(t *testing.T) { // providing ban_reason is accepted (reason defaults to empty string). func TestAdminAPI_PatchUser_BanWithoutReason(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil) token := createAdminUser(t, database) targetUID, _ := database.CreateUser("banwithout", "hash", 3) @@ -388,7 +388,7 @@ func TestAdminAPI_PatchUser_BanWithoutReason(t *testing.T) { func TestAdminAPI_PatchUser_RoleChangeBroadcast(t *testing.T) { database := openAdminTestDB(t) hub := &mockHub{} - handler := admin.NewAdminAPI(database, "1.0.0", hub, nil) + handler := admin.NewAdminAPI(database, "1.0.0", hub, nil, nil) token := createAdminUser(t, database) targetUID, _ := database.CreateUser("rolebroadcast", "hash", 3) @@ -410,7 +410,7 @@ func TestAdminAPI_PatchUser_RoleChangeBroadcast(t *testing.T) { // needs_setup=true when the database has no users. func TestAdminAPI_SetupStatus_NeedsSetup(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", nil, nil) + handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil) w := doRequest(t, handler, http.MethodGet, "/setup/status", "", nil) @@ -430,7 +430,7 @@ func TestAdminAPI_SetupStatus_NeedsSetup(t *testing.T) { // TestAdminAPI_SetupStatus_AlreadySetup verifies needs_setup=false when users exist. func TestAdminAPI_SetupStatus_AlreadySetup(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", nil, nil) + handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil) _, _ = database.CreateUser("existing", "hash", 1) @@ -451,7 +451,7 @@ func TestAdminAPI_SetupStatus_AlreadySetup(t *testing.T) { // session, channel, and invite. func TestAdminAPI_Setup_Success(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", nil, nil) + handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil) body := map[string]string{ "username": "owner", @@ -482,7 +482,7 @@ func TestAdminAPI_Setup_Success(t *testing.T) { // when users already exist. func TestAdminAPI_Setup_AlreadyCompleted(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", nil, nil) + handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil) _, _ = database.CreateUser("existing", "hash", 1) @@ -501,7 +501,7 @@ func TestAdminAPI_Setup_AlreadyCompleted(t *testing.T) { // username or password returns 400. func TestAdminAPI_Setup_MissingFields(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", nil, nil) + handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil) body := map[string]string{ "username": "", @@ -517,7 +517,7 @@ func TestAdminAPI_Setup_MissingFields(t *testing.T) { // TestAdminAPI_Setup_WeakPassword verifies that a weak password is rejected. func TestAdminAPI_Setup_WeakPassword(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", nil, nil) + handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil) body := map[string]string{ "username": "owner", @@ -533,7 +533,7 @@ func TestAdminAPI_Setup_WeakPassword(t *testing.T) { // TestAdminAPI_Setup_InvalidBody verifies that a non-JSON body returns 400. func TestAdminAPI_Setup_InvalidBody(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", nil, nil) + handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil) req := httptest.NewRequest(http.MethodPost, "/setup", bytes.NewReader([]byte("not-json"))) w := httptest.NewRecorder() diff --git a/Server/admin/api_test.go b/Server/admin/api_test.go index a07e0535..e1485419 100644 --- a/Server/admin/api_test.go +++ b/Server/admin/api_test.go @@ -198,7 +198,7 @@ func doRequest(t *testing.T, handler http.Handler, method, path, token string, b func TestAdminAPI_Stats_OK(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil) token := createAdminUser(t, database) w := doRequest(t, handler, http.MethodGet, "/stats", token, nil) @@ -221,7 +221,7 @@ func TestAdminAPI_Stats_OK(t *testing.T) { func TestAdminAPI_Stats_Unauthenticated(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil) w := doRequest(t, handler, http.MethodGet, "/stats", "", nil) @@ -232,7 +232,7 @@ func TestAdminAPI_Stats_Unauthenticated(t *testing.T) { func TestAdminAPI_Stats_Forbidden(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil) token := createMemberUser(t, database) w := doRequest(t, handler, http.MethodGet, "/stats", token, nil) @@ -246,7 +246,7 @@ func TestAdminAPI_Stats_Forbidden(t *testing.T) { func TestAdminAPI_ListUsers_OK(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil) token := createAdminUser(t, database) w := doRequest(t, handler, http.MethodGet, "/users?limit=50&offset=0", token, nil) @@ -267,7 +267,7 @@ func TestAdminAPI_ListUsers_OK(t *testing.T) { func TestAdminAPI_ListUsers_DefaultPagination(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil) token := createAdminUser(t, database) // No query params — should use defaults @@ -280,7 +280,7 @@ func TestAdminAPI_ListUsers_DefaultPagination(t *testing.T) { func TestAdminAPI_ListUsers_Unauthenticated(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil) w := doRequest(t, handler, http.MethodGet, "/users", "", nil) @@ -293,7 +293,7 @@ func TestAdminAPI_ListUsers_Unauthenticated(t *testing.T) { func TestAdminAPI_PatchUser_BanUser(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil) token := createAdminUser(t, database) // Create a target user @@ -321,7 +321,7 @@ func TestAdminAPI_PatchUser_BanUser(t *testing.T) { func TestAdminAPI_PatchUser_ChangeRole(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil) token := createAdminUser(t, database) targetUID, _ := database.CreateUser("rolechange", "hash", 3) @@ -343,7 +343,7 @@ func TestAdminAPI_PatchUser_ChangeRole(t *testing.T) { func TestAdminAPI_PatchUser_NotFound(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil) token := createAdminUser(t, database) body := map[string]any{"banned": true} @@ -356,7 +356,7 @@ func TestAdminAPI_PatchUser_NotFound(t *testing.T) { func TestAdminAPI_PatchUser_InvalidID(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil) token := createAdminUser(t, database) w := doRequest(t, handler, http.MethodPatch, "/users/abc", token, nil) @@ -370,7 +370,7 @@ func TestAdminAPI_PatchUser_InvalidID(t *testing.T) { func TestAdminAPI_ForceLogout_OK(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil) token := createAdminUser(t, database) targetUID, _ := database.CreateUser("logoutme", "hash", 3) @@ -390,7 +390,7 @@ func TestAdminAPI_ForceLogout_OK(t *testing.T) { func TestAdminAPI_ForceLogout_Unauthenticated(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil) w := doRequest(t, handler, http.MethodDelete, "/users/1/sessions", "", nil) @@ -403,7 +403,7 @@ func TestAdminAPI_ForceLogout_Unauthenticated(t *testing.T) { func TestAdminAPI_ListChannels_OK(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil) token := createAdminUser(t, database) _, _ = database.AdminCreateChannel("general", "text", "", "", 0) @@ -427,7 +427,7 @@ func TestAdminAPI_ListChannels_OK(t *testing.T) { func TestAdminAPI_CreateChannel_OK(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil) token := createAdminUser(t, database) body := map[string]any{ @@ -454,7 +454,7 @@ func TestAdminAPI_CreateChannel_OK(t *testing.T) { func TestAdminAPI_CreateChannel_MissingName(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil) token := createAdminUser(t, database) body := map[string]any{ @@ -471,7 +471,7 @@ func TestAdminAPI_CreateChannel_MissingName(t *testing.T) { func TestAdminAPI_UpdateChannel_OK(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil) token := createAdminUser(t, database) chID, _ := database.AdminCreateChannel("old", "text", "", "", 0) @@ -492,7 +492,7 @@ func TestAdminAPI_UpdateChannel_OK(t *testing.T) { func TestAdminAPI_UpdateChannel_NotFound(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil) token := createAdminUser(t, database) body := map[string]any{"name": "x"} @@ -507,7 +507,7 @@ func TestAdminAPI_UpdateChannel_NotFound(t *testing.T) { func TestAdminAPI_DeleteChannel_OK(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil) token := createAdminUser(t, database) chID, _ := database.AdminCreateChannel("del-me", "text", "", "", 0) @@ -521,7 +521,7 @@ func TestAdminAPI_DeleteChannel_OK(t *testing.T) { func TestAdminAPI_DeleteChannel_NotFound(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil) token := createAdminUser(t, database) w := doRequest(t, handler, http.MethodDelete, "/channels/99999", token, nil) @@ -535,7 +535,7 @@ func TestAdminAPI_DeleteChannel_NotFound(t *testing.T) { func TestAdminAPI_AuditLog_OK(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil) token := createAdminUser(t, database) uid, _ := database.CreateUser("actor", "hash", 1) @@ -558,7 +558,7 @@ func TestAdminAPI_AuditLog_OK(t *testing.T) { func TestAdminAPI_AuditLog_Empty(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil) token := createAdminUser(t, database) w := doRequest(t, handler, http.MethodGet, "/audit-log", token, nil) @@ -578,7 +578,7 @@ func TestAdminAPI_AuditLog_Empty(t *testing.T) { func TestAdminAPI_GetSettings_OK(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil) token := createAdminUser(t, database) w := doRequest(t, handler, http.MethodGet, "/settings", token, nil) @@ -600,7 +600,7 @@ func TestAdminAPI_GetSettings_OK(t *testing.T) { func TestAdminAPI_PatchSettings_OK(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil) token := createAdminUser(t, database) body := map[string]string{ @@ -625,7 +625,7 @@ func TestAdminAPI_PatchSettings_OK(t *testing.T) { func TestAdminAPI_PatchSettings_InvalidBody(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil) token := createAdminUser(t, database) req := httptest.NewRequest(http.MethodPatch, "/settings", bytes.NewReader([]byte("not-json"))) @@ -642,7 +642,7 @@ func TestAdminAPI_PatchSettings_InvalidBody(t *testing.T) { func TestAdminAPI_Backup_RequiresOwner(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil) // Admin (role 2) can authenticate but is not Owner (role 1, position 100) adminUID, _ := database.CreateUser("adminonly", "hash", 2) @@ -659,7 +659,7 @@ func TestAdminAPI_Backup_RequiresOwner(t *testing.T) { func TestAdminAPI_Backup_Unauthenticated(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil) w := doRequest(t, handler, http.MethodPost, "/backup", "", nil) @@ -676,7 +676,7 @@ func TestAdminAPI_Backup_Unauthenticated(t *testing.T) { // which logs an audit entry containing the actor_id. func TestAdminAPI_ActorFromContext_AuditEntry(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil) token := createAdminUser(t, database) // Create a target user to act on. @@ -710,7 +710,7 @@ func TestAdminAPI_ActorFromContext_AuditEntry(t *testing.T) { // DELETE /users/{id}/sessions path. func TestAdminAPI_ActorFromContext_ForceLogout(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil) token := createAdminUser(t, database) targetUID, _ := database.CreateUser("logoutctx", "hash", 3) @@ -742,7 +742,7 @@ func TestAdminAPI_ActorFromContext_ForceLogout(t *testing.T) { // returns 400 without writing anything to the database. func TestAdminAPI_PatchSettings_RejectsUnknownKey(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil) token := createAdminUser(t, database) body := map[string]string{ @@ -768,7 +768,7 @@ func TestAdminAPI_PatchSettings_RejectsUnknownKey(t *testing.T) { // containing both valid and invalid keys is rejected entirely (no partial write). func TestAdminAPI_PatchSettings_RejectsMixedKeys(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil) token := createAdminUser(t, database) body := map[string]string{ @@ -809,7 +809,7 @@ func TestAdminAPI_PatchSettings_AcceptsAllWhitelistedKeys(t *testing.T) { for _, key := range whitelistedKeys { t.Run(key, func(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil) token := createAdminUser(t, database) body := map[string]string{key: "testvalue"} @@ -826,7 +826,7 @@ func TestAdminAPI_PatchSettings_AcceptsAllWhitelistedKeys(t *testing.T) { // (no-op update) is accepted and returns the current settings. func TestAdminAPI_PatchSettings_EmptyPayloadIsOK(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil) token := createAdminUser(t, database) body := map[string]string{} @@ -843,7 +843,7 @@ func TestAdminAPI_PatchSettings_EmptyPayloadIsOK(t *testing.T) { // expose the PasswordHash field in any returned user object. func TestAdminAPI_ListUsers_NoPasswordHash(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil) token := createAdminUser(t, database) // Create a second user so the list is non-trivial. @@ -870,7 +870,7 @@ func TestAdminAPI_ListUsers_NoPasswordHash(t *testing.T) { // expose the TOTPSecret field. func TestAdminAPI_ListUsers_NoTOTPSecret(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil) token := createAdminUser(t, database) w := doRequest(t, handler, http.MethodGet, "/users", token, nil) @@ -889,7 +889,7 @@ func TestAdminAPI_ListUsers_NoTOTPSecret(t *testing.T) { // are still present after the sensitive-field removal. func TestAdminAPI_ListUsers_PublicFieldsPresent(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil) token := createAdminUser(t, database) w := doRequest(t, handler, http.MethodGet, "/users", token, nil) @@ -918,7 +918,7 @@ func TestAdminAPI_ListUsers_PublicFieldsPresent(t *testing.T) { // not expose PasswordHash in the returned user object. func TestAdminAPI_PatchUser_NoPasswordHash(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil) token := createAdminUser(t, database) targetUID, _ := database.CreateUser("patchvictim", "topsecretbcrypt", 3) @@ -946,7 +946,7 @@ func TestAdminAPI_PatchUser_NoPasswordHash(t *testing.T) { // not expose TOTPSecret in the returned user object. func TestAdminAPI_PatchUser_NoTOTPSecret(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil) token := createAdminUser(t, database) targetUID, _ := database.CreateUser("patchtotp", "hash", 3) @@ -1019,7 +1019,7 @@ func (m *mockHub) ClientCount() int { func TestAdminAPI_CreateChannel_BroadcastsChannelCreate(t *testing.T) { database := openAdminTestDB(t) hub := &mockHub{} - handler := admin.NewAdminAPI(database, "1.0.0", hub, nil) + handler := admin.NewAdminAPI(database, "1.0.0", hub, nil, nil) token := createAdminUser(t, database) body := map[string]any{ @@ -1042,7 +1042,7 @@ func TestAdminAPI_CreateChannel_BroadcastsChannelCreate(t *testing.T) { func TestAdminAPI_CreateChannel_NilHubDoesNotPanic(t *testing.T) { database := openAdminTestDB(t) // nil hub: handler must not panic - handler := admin.NewAdminAPI(database, "1.0.0", nil, nil) + handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil) token := createAdminUser(t, database) body := map[string]any{"name": "safe-channel", "type": "text"} @@ -1056,7 +1056,7 @@ func TestAdminAPI_CreateChannel_NilHubDoesNotPanic(t *testing.T) { func TestAdminAPI_UpdateChannel_BroadcastsChannelUpdate(t *testing.T) { database := openAdminTestDB(t) hub := &mockHub{} - handler := admin.NewAdminAPI(database, "1.0.0", hub, nil) + handler := admin.NewAdminAPI(database, "1.0.0", hub, nil, nil) token := createAdminUser(t, database) chID, _ := database.AdminCreateChannel("before", "text", "", "", 0) @@ -1077,7 +1077,7 @@ func TestAdminAPI_UpdateChannel_BroadcastsChannelUpdate(t *testing.T) { func TestAdminAPI_UpdateChannel_NilHubDoesNotPanic(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", nil, nil) + handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil) token := createAdminUser(t, database) chID, _ := database.AdminCreateChannel("patchme", "text", "", "", 0) @@ -1092,7 +1092,7 @@ func TestAdminAPI_UpdateChannel_NilHubDoesNotPanic(t *testing.T) { func TestAdminAPI_DeleteChannel_BroadcastsChannelDelete(t *testing.T) { database := openAdminTestDB(t) hub := &mockHub{} - handler := admin.NewAdminAPI(database, "1.0.0", hub, nil) + handler := admin.NewAdminAPI(database, "1.0.0", hub, nil, nil) token := createAdminUser(t, database) chID, _ := database.AdminCreateChannel("delete-me", "text", "", "", 0) @@ -1112,7 +1112,7 @@ func TestAdminAPI_DeleteChannel_BroadcastsChannelDelete(t *testing.T) { func TestAdminAPI_DeleteChannel_NilHubDoesNotPanic(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", nil, nil) + handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil) token := createAdminUser(t, database) chID, _ := database.AdminCreateChannel("del-no-hub", "text", "", "", 0) diff --git a/Server/admin/handlers_backup_test.go b/Server/admin/handlers_backup_test.go index 579e042a..6e982bfa 100644 --- a/Server/admin/handlers_backup_test.go +++ b/Server/admin/handlers_backup_test.go @@ -35,7 +35,7 @@ func chdirTemp(t *testing.T) string { func TestHandleBackup_Success(t *testing.T) { tmpDir := chdirTemp(t) database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil) token := createAdminUser(t, database) w := doRequest(t, handler, http.MethodPost, "/backup", token, nil) @@ -70,7 +70,7 @@ func TestHandleBackup_Success(t *testing.T) { func TestHandleBackup_RequiresOwner(t *testing.T) { _ = chdirTemp(t) database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil) adminUID, _ := database.CreateUser("backupadmin", "hash", 2) token := "backup-admin-token" @@ -90,7 +90,7 @@ func TestHandleBackup_RequiresOwner(t *testing.T) { func TestHandleListBackups_EmptyWhenNoDirExists(t *testing.T) { _ = chdirTemp(t) database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil) token := createAdminUser(t, database) w := doRequest(t, handler, http.MethodGet, "/backups", token, nil) @@ -113,7 +113,7 @@ func TestHandleListBackups_EmptyWhenNoDirExists(t *testing.T) { func TestHandleListBackups_ReturnsCreatedBackup(t *testing.T) { _ = chdirTemp(t) database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil) token := createAdminUser(t, database) // Create a backup first. @@ -155,7 +155,7 @@ func TestHandleListBackups_ReturnsCreatedBackup(t *testing.T) { func TestHandleDeleteBackup_Success(t *testing.T) { tmpDir := chdirTemp(t) database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil) token := createAdminUser(t, database) // Create a real backup file to delete. @@ -186,7 +186,7 @@ func TestHandleDeleteBackup_Success(t *testing.T) { func TestHandleDeleteBackup_NotFound(t *testing.T) { _ = chdirTemp(t) database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil) token := createAdminUser(t, database) w := doRequest(t, handler, http.MethodDelete, "/backups/nonexistent.db", token, nil) @@ -201,7 +201,7 @@ func TestHandleDeleteBackup_NotFound(t *testing.T) { func TestHandleDeleteBackup_InvalidNameTraversal(t *testing.T) { _ = chdirTemp(t) database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil) token := createAdminUser(t, database) // The chi router URL-decodes the path parameter, so ".." arrives decoded. @@ -219,7 +219,7 @@ func TestHandleDeleteBackup_InvalidNameTraversal(t *testing.T) { func TestHandleDeleteBackup_RequiresOwner(t *testing.T) { tmpDir := chdirTemp(t) database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil) adminUID, _ := database.CreateUser("deladmin", "hash", 2) token := "del-admin-token" @@ -244,7 +244,7 @@ func TestHandleDeleteBackup_RequiresOwner(t *testing.T) { func TestHandleRestoreBackup_Success(t *testing.T) { tmpDir := chdirTemp(t) database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil) token := createAdminUser(t, database) // Set up backup and data directories. @@ -288,7 +288,7 @@ func TestHandleRestoreBackup_Success(t *testing.T) { func TestHandleRestoreBackup_NotFound(t *testing.T) { _ = chdirTemp(t) database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil) token := createAdminUser(t, database) w := doRequest(t, handler, http.MethodPost, "/backups/missing.db/restore", token, nil) @@ -303,7 +303,7 @@ func TestHandleRestoreBackup_NotFound(t *testing.T) { func TestHandleRestoreBackup_InvalidName(t *testing.T) { _ = chdirTemp(t) database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil) token := createAdminUser(t, database) w := doRequest(t, handler, http.MethodPost, "/backups/..evil.db/restore", token, nil) @@ -319,7 +319,7 @@ func TestHandleRestoreBackup_InvalidName(t *testing.T) { func TestHandleListBackups_ErrorReadingDir(t *testing.T) { tmpDir := chdirTemp(t) database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil) token := createAdminUser(t, database) // Create data/ directory but make "backups" a file instead of a directory. @@ -347,7 +347,7 @@ func TestHandleListBackups_ErrorReadingDir(t *testing.T) { func TestHandleRestoreBackup_RequiresOwner(t *testing.T) { tmpDir := chdirTemp(t) database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil) adminUID, _ := database.CreateUser("restoreadmin", "hash", 2) token := "restore-admin-token" diff --git a/Server/admin/handlers_channels_test.go b/Server/admin/handlers_channels_test.go index e435740a..d5b872f0 100644 --- a/Server/admin/handlers_channels_test.go +++ b/Server/admin/handlers_channels_test.go @@ -12,7 +12,7 @@ import ( func TestCreateChannel_TextUnderTextCategory_OK(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil) token := createAdminUser(t, database) body := map[string]any{ @@ -28,7 +28,7 @@ func TestCreateChannel_TextUnderTextCategory_OK(t *testing.T) { func TestCreateChannel_AnnouncementUnderTextCategory_OK(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil) token := createAdminUser(t, database) body := map[string]any{ @@ -44,7 +44,7 @@ func TestCreateChannel_AnnouncementUnderTextCategory_OK(t *testing.T) { func TestCreateChannel_VoiceUnderVoiceCategory_OK(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil) token := createAdminUser(t, database) body := map[string]any{ @@ -60,7 +60,7 @@ func TestCreateChannel_VoiceUnderVoiceCategory_OK(t *testing.T) { func TestCreateChannel_VoiceUnderTextCategory_Rejected(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil) token := createAdminUser(t, database) body := map[string]any{ @@ -83,7 +83,7 @@ func TestCreateChannel_VoiceUnderTextCategory_Rejected(t *testing.T) { func TestCreateChannel_TextUnderVoiceCategory_Rejected(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil) token := createAdminUser(t, database) body := map[string]any{ @@ -99,7 +99,7 @@ func TestCreateChannel_TextUnderVoiceCategory_Rejected(t *testing.T) { func TestCreateChannel_EmptyCategory_Allowed(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil) token := createAdminUser(t, database) body := map[string]any{ @@ -115,7 +115,7 @@ func TestCreateChannel_EmptyCategory_Allowed(t *testing.T) { func TestCreateChannel_CaseInsensitiveVoiceCategory(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil) token := createAdminUser(t, database) // "VOICE" in uppercase should still be treated as a voice category diff --git a/Server/admin/logstream.go b/Server/admin/logstream.go new file mode 100644 index 00000000..97e31e89 --- /dev/null +++ b/Server/admin/logstream.go @@ -0,0 +1,329 @@ +package admin + +import ( + "context" + "encoding/json" + "fmt" + "log/slog" + "net/http" + "runtime" + "strings" + "sync" + "time" + + "github.com/owncord/server/auth" + "github.com/owncord/server/db" + "github.com/owncord/server/permissions" +) + +// LogEntry holds a single structured log record for the ring buffer. +type LogEntry struct { + Timestamp string `json:"ts"` + Level string `json:"level"` + Message string `json:"msg"` + Source string `json:"source"` + Attrs string `json:"attrs,omitempty"` +} + +// RingBuffer is a bounded, thread-safe circular buffer of log entries +// with fan-out to SSE subscriber channels. +type RingBuffer struct { + mu sync.Mutex + entries []LogEntry + capacity int + subscribers map[*chan LogEntry]struct{} +} + +// NewRingBuffer creates a ring buffer with the given capacity. +func NewRingBuffer(capacity int) *RingBuffer { + return &RingBuffer{ + entries: make([]LogEntry, 0, capacity), + capacity: capacity, + subscribers: make(map[*chan LogEntry]struct{}), + } +} + +// Write appends an entry, drops the oldest if full, and fans out +// to all subscribers (non-blocking to avoid slow clients blocking logging). +func (rb *RingBuffer) Write(entry LogEntry) { + rb.mu.Lock() + defer rb.mu.Unlock() + + if len(rb.entries) >= rb.capacity { + rb.entries = rb.entries[1:] + } + rb.entries = append(rb.entries, entry) + + for chp := range rb.subscribers { + select { + case *chp <- entry: + default: + // Slow subscriber — drop to avoid blocking. + } + } +} + +// Snapshot returns a copy of all current entries for backfill. +func (rb *RingBuffer) Snapshot() []LogEntry { + rb.mu.Lock() + defer rb.mu.Unlock() + out := make([]LogEntry, len(rb.entries)) + copy(out, rb.entries) + return out +} + +// Subscribe creates a buffered channel for a new SSE client. +// Returns the channel and an unsubscribe function. +func (rb *RingBuffer) Subscribe() (<-chan LogEntry, func()) { + ch := make(chan LogEntry, 64) + chp := &ch + rb.mu.Lock() + rb.subscribers[chp] = struct{}{} + rb.mu.Unlock() + + return ch, func() { + rb.mu.Lock() + delete(rb.subscribers, chp) + rb.mu.Unlock() + } +} + +// multiHandler is an slog.Handler that tees records to two handlers: +// the original stdout handler and a ring buffer handler. +type multiHandler struct { + stdout slog.Handler + ring *ringHandler +} + +// ringHandler converts slog.Records into LogEntries and writes them +// to the RingBuffer. +type ringHandler struct { + buf *RingBuffer + level slog.Leveler + attrs []slog.Attr + groups []string +} + +// NewMultiHandler creates a handler that sends records to both stdout +// and the ring buffer. The ring buffer captures all levels from minLevel. +func NewMultiHandler(stdout slog.Handler, buf *RingBuffer, minLevel slog.Leveler) slog.Handler { + return &multiHandler{ + stdout: stdout, + ring: &ringHandler{ + buf: buf, + level: minLevel, + }, + } +} + +func (h *multiHandler) Enabled(_ context.Context, level slog.Level) bool { + return h.stdout.Enabled(context.Background(), level) || h.ring.Enabled(level) +} + +func (h *multiHandler) Handle(ctx context.Context, r slog.Record) error { + if h.stdout.Enabled(ctx, r.Level) { + _ = h.stdout.Handle(ctx, r) + } + if h.ring.Enabled(r.Level) { + h.ring.Handle(r) + } + return nil +} + +func (h *multiHandler) WithAttrs(attrs []slog.Attr) slog.Handler { + return &multiHandler{ + stdout: h.stdout.WithAttrs(attrs), + ring: h.ring.withAttrs(attrs), + } +} + +func (h *multiHandler) WithGroup(name string) slog.Handler { + return &multiHandler{ + stdout: h.stdout.WithGroup(name), + ring: h.ring.withGroup(name), + } +} + +func (rh *ringHandler) Enabled(level slog.Level) bool { + return level >= rh.level.Level() +} + +func (rh *ringHandler) Handle(r slog.Record) { + // Build source from file path. + source := categorizeSource(r) + + // Collect attributes as a JSON object. + attrs := make(map[string]any) + // Add pre-set attrs from WithAttrs. + for _, a := range rh.attrs { + attrs[a.Key] = a.Value.Any() + } + // Add record attrs. + r.Attrs(func(a slog.Attr) bool { + key := a.Key + if len(rh.groups) > 0 { + key = strings.Join(rh.groups, ".") + "." + key + } + attrs[key] = a.Value.Any() + return true + }) + + var attrsJSON string + if len(attrs) > 0 { + if b, err := json.Marshal(attrs); err == nil { + attrsJSON = string(b) + } + } + + rh.buf.Write(LogEntry{ + Timestamp: r.Time.Format(time.RFC3339Nano), + Level: r.Level.String(), + Message: r.Message, + Source: source, + Attrs: attrsJSON, + }) +} + +func (rh *ringHandler) withAttrs(attrs []slog.Attr) *ringHandler { + combined := make([]slog.Attr, len(rh.attrs)+len(attrs)) + copy(combined, rh.attrs) + copy(combined[len(rh.attrs):], attrs) + return &ringHandler{ + buf: rh.buf, + level: rh.level, + attrs: combined, + groups: rh.groups, + } +} + +func (rh *ringHandler) withGroup(name string) *ringHandler { + groups := make([]string, len(rh.groups)+1) + copy(groups, rh.groups) + groups[len(rh.groups)] = name + return &ringHandler{ + buf: rh.buf, + level: rh.level, + attrs: rh.attrs, + groups: groups, + } +} + +// categorizeSource extracts a human-readable source category from the log record. +func categorizeSource(r slog.Record) string { + if r.PC == 0 { + return "server" + } + // Use runtime frame to get the source file path. + frames := runtime.CallersFrames([]uintptr{r.PC}) + frame, _ := frames.Next() + file := frame.File + switch { + case strings.Contains(file, "/ws/"): + return "websocket" + case strings.Contains(file, "/api/"): + return "http" + case strings.Contains(file, "/admin/"): + return "admin" + case strings.Contains(file, "/auth/"): + return "auth" + case strings.Contains(file, "/db/"): + return "database" + case strings.Contains(file, "/storage/"): + return "storage" + case strings.Contains(file, "/updater/"): + return "updater" + case strings.Contains(file, "/config/"): + return "config" + default: + return "server" + } +} + +// authenticateAdmin validates a raw token string and returns the user +// if they have ADMINISTRATOR permission. Used by both adminAuthMiddleware +// and the SSE log stream endpoint. +func authenticateAdmin(database *db.DB, rawToken string) (*db.User, error) { + if rawToken == "" { + return nil, fmt.Errorf("missing token") + } + hash := auth.HashToken(rawToken) + sess, err := database.GetSessionByTokenHash(hash) + if err != nil || sess == nil { + return nil, fmt.Errorf("invalid session") + } + if auth.IsSessionExpired(sess.ExpiresAt) { + return nil, fmt.Errorf("session expired") + } + user, err := database.GetUserByID(sess.UserID) + if err != nil || user == nil { + return nil, fmt.Errorf("user not found") + } + role, err := database.GetRoleByID(user.RoleID) + if err != nil || role == nil { + return nil, fmt.Errorf("role not found") + } + if !permissions.HasAdmin(role.Permissions) { + return nil, fmt.Errorf("administrator permission required") + } + return user, nil +} + +// handleLogStream serves an SSE endpoint that streams log entries in real-time. +// Auth is via query param ?token= since EventSource cannot send headers. +func handleLogStream(ringBuf *RingBuffer, database *db.DB) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + // Authenticate via query param. + rawToken := r.URL.Query().Get("token") + if _, err := authenticateAdmin(database, rawToken); err != nil { + http.Error(w, `{"error":"UNAUTHORIZED","message":"`+err.Error()+`"}`, http.StatusUnauthorized) + return + } + + // Check that we can flush (required for SSE). + flusher, ok := w.(http.Flusher) + if !ok { + http.Error(w, "streaming not supported", http.StatusInternalServerError) + return + } + + // Set SSE headers. + w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("Connection", "keep-alive") + w.Header().Set("X-Accel-Buffering", "no") + w.WriteHeader(http.StatusOK) + flusher.Flush() + + // Send backfill. + for _, entry := range ringBuf.Snapshot() { + if data, err := json.Marshal(entry); err == nil { + _, _ = fmt.Fprintf(w, "data: %s\n\n", data) + } + } + flusher.Flush() + + // Subscribe for new entries. + ch, unsub := ringBuf.Subscribe() + defer unsub() + + // Keepalive ticker to avoid WriteTimeout (30s). + keepalive := time.NewTicker(15 * time.Second) + defer keepalive.Stop() + + ctx := r.Context() + for { + select { + case entry := <-ch: + if data, err := json.Marshal(entry); err == nil { + _, _ = fmt.Fprintf(w, "data: %s\n\n", data) + flusher.Flush() + } + case <-keepalive.C: + _, _ = fmt.Fprint(w, ": keepalive\n\n") + flusher.Flush() + case <-ctx.Done(): + return + } + } + } +} diff --git a/Server/admin/middleware_and_spawn_test.go b/Server/admin/middleware_and_spawn_test.go index 5b08f466..2f75a0bd 100644 --- a/Server/admin/middleware_and_spawn_test.go +++ b/Server/admin/middleware_and_spawn_test.go @@ -248,7 +248,7 @@ func TestOwnerOnlyMiddleware_OwnerPassesThrough(t *testing.T) { // role_id has been set to a nonexistent value returns 401. func TestAdminAuthMiddleware_RoleNotFound(t *testing.T) { database := openWhiteboxTestDB(t) - handler := NewAdminAPI(database, "1.0.0", nil, nil) + handler := NewAdminAPI(database, "1.0.0", nil, nil, nil) uid, err := database.CreateUser("noroleuser", "$2a$12$x", 1) if err != nil { diff --git a/Server/admin/middleware_coverage_test.go b/Server/admin/middleware_coverage_test.go index f9d98b0b..62745800 100644 --- a/Server/admin/middleware_coverage_test.go +++ b/Server/admin/middleware_coverage_test.go @@ -18,7 +18,7 @@ import ( // session has expired is rejected with 401. func TestAdminAuthMiddleware_ExpiredSession(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", nil, nil) + handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil) // Create a user and session, then manually expire the session by setting // expires_at to a past timestamp via the exported Exec helper. @@ -52,7 +52,7 @@ func TestAdminAuthMiddleware_ExpiredSession(t *testing.T) { // Authorization header returns 401. func TestAdminAuthMiddleware_MissingBearer(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", nil, nil) + handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil) w := doRequest(t, handler, http.MethodGet, "/stats", "", nil) @@ -65,7 +65,7 @@ func TestAdminAuthMiddleware_MissingBearer(t *testing.T) { // sessions table returns 401. func TestAdminAuthMiddleware_InvalidToken(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", nil, nil) + handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil) w := doRequest(t, handler, http.MethodGet, "/stats", "completely-invalid-token", nil) diff --git a/Server/admin/setup_handler_test.go b/Server/admin/setup_handler_test.go index 05212bdb..e51cf709 100644 --- a/Server/admin/setup_handler_test.go +++ b/Server/admin/setup_handler_test.go @@ -10,7 +10,7 @@ import ( func TestSetupStatus_NeedsSetup(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", nil, nil) + handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil) rr := doRequest(t, handler, "GET", "/setup/status", "", nil) if rr.Code != http.StatusOK { @@ -31,7 +31,7 @@ func TestSetupStatus_NeedsSetup(t *testing.T) { 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) + handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil) rr := doRequest(t, handler, "GET", "/setup/status", "", nil) if rr.Code != http.StatusOK { @@ -51,7 +51,7 @@ func TestSetupStatus_NoSetupNeeded(t *testing.T) { func TestSetup_CreatesOwner(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", nil, nil) + handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil) rr := doRequest(t, handler, "POST", "/setup", "", map[string]string{ "username": "myadmin", @@ -96,7 +96,7 @@ func TestSetup_CreatesOwner(t *testing.T) { func TestSetup_BlockedAfterFirstUser(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", nil, nil) + handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil) // First setup succeeds. rr := doRequest(t, handler, "POST", "/setup", "", map[string]string{ @@ -119,7 +119,7 @@ func TestSetup_BlockedAfterFirstUser(t *testing.T) { func TestSetup_WeakPassword(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", nil, nil) + handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil) rr := doRequest(t, handler, "POST", "/setup", "", map[string]string{ "username": "admin", @@ -132,7 +132,7 @@ func TestSetup_WeakPassword(t *testing.T) { func TestSetup_MissingFields(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", nil, nil) + handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil) rr := doRequest(t, handler, "POST", "/setup", "", map[string]string{ "username": "", diff --git a/Server/admin/static/index.html b/Server/admin/static/index.html index 361517c2..dc7c7c54 100644 --- a/Server/admin/static/index.html +++ b/Server/admin/static/index.html @@ -1,1005 +1,833 @@ - - - OwnCord Admin - + @media(max-width:900px){.sidebar{width:200px}.content{padding:24px 20px}.stat-grid{grid-template-columns:repeat(auto-fill,minmax(160px,1fr))}} + @media(max-width:600px){.admin{flex-direction:column}.sidebar{width:100%;height:auto;max-height:56px;overflow:hidden}.content{padding:16px 12px}.stat-grid{grid-template-columns:1fr 1fr}.filter-bar{flex-direction:column}.modal{width:95vw;max-height:90vh}} + - -