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}}
+
-
-
-
+
+
+
Welcome to OwnCord
-
No accounts exist yet. Create the owner account to get started.
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
No accounts exist yet. Create the owner account to get started.
+
+
+
+
+
-
-
-
+
+
+
Setup Complete!
-
Your owner account has been created. Here's your invite code for adding other users:
-
-
Save this code! Share it with people you want to invite. It has unlimited uses.
-
+
Your owner account has been created. Here's your invite code:
+
+
Save this code! Share it with people you want to invite.
+
-
-
-
+
+
+
OwnCord Admin
-
-
-
-
-
-
-
-
-
-
+
+
+
+
-
-
-
-
-
-
-
- Dashboard
-
-
- Update Available!
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- | ID | Username | Role | Status | Banned | Actions |
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- | ID | Name | Type | Category | Archived | Actions |
-
-
-
-
-
-
-
-
- Audit Log
-
-
-
- | Time | Actor | Action | Target | Detail |
-
-
-
-
-
-
-
-
-
- Settings
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Backups
-
-
-
-
-
-
Backup History
-
- | Name | Size | Date | Actions |
-
-
-
-
+
+
+
+
+
+
+
+
diff --git a/Server/admin/update_handlers_test.go b/Server/admin/update_handlers_test.go
index b5f0dfb8..a31402ad 100644
--- a/Server/admin/update_handlers_test.go
+++ b/Server/admin/update_handlers_test.go
@@ -30,7 +30,7 @@ func TestAdminAPI_CheckUpdate_OK(t *testing.T) {
u.SetBaseURL(mockGH.URL)
database := openAdminTestDB(t)
- handler := admin.NewAdminAPI(database, "1.0.0", nil, u)
+ handler := admin.NewAdminAPI(database, "1.0.0", nil, u, nil)
token := createAdminUser(t, database)
w := doRequest(t, handler, http.MethodGet, "/updates", token, nil)
@@ -63,7 +63,7 @@ func TestAdminAPI_CheckUpdate_UpToDate(t *testing.T) {
u.SetBaseURL(mockGH.URL)
database := openAdminTestDB(t)
- handler := admin.NewAdminAPI(database, "1.0.0", nil, u)
+ handler := admin.NewAdminAPI(database, "1.0.0", nil, u, nil)
token := createAdminUser(t, database)
w := doRequest(t, handler, http.MethodGet, "/updates", token, nil)
@@ -80,7 +80,7 @@ func TestAdminAPI_CheckUpdate_UpToDate(t *testing.T) {
func TestAdminAPI_CheckUpdate_Unauthenticated(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, "/updates", "", nil)
if w.Code != http.StatusUnauthorized {
@@ -90,7 +90,7 @@ func TestAdminAPI_CheckUpdate_Unauthenticated(t *testing.T) {
func TestAdminAPI_ApplyUpdate_RequiresOwner(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 admin user (not owner - role 2)
adminUID, _ := database.CreateUser("adminonly2", "hash", 2)
@@ -110,7 +110,7 @@ func TestAdminAPI_ApplyUpdate_RequiresOwner(t *testing.T) {
func TestAdminAPI_ApplyUpdate_NilUpdater(t *testing.T) {
database := openAdminTestDB(t)
// nil updater — the endpoint should return 503
- 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.MethodPost, "/updates/apply", token, nil)
@@ -123,7 +123,7 @@ func TestAdminAPI_ApplyUpdate_NilUpdater(t *testing.T) {
// in the 503 response.
func TestAdminAPI_ApplyUpdate_NilUpdater_ErrorCode(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.MethodPost, "/updates/apply", token, nil)
@@ -155,7 +155,7 @@ func TestAdminAPI_ApplyUpdate_NoUpdateAvailable(t *testing.T) {
u.SetBaseURL(mockGH.URL)
database := openAdminTestDB(t)
- handler := admin.NewAdminAPI(database, "1.0.0", nil, u)
+ handler := admin.NewAdminAPI(database, "1.0.0", nil, u, nil)
token := createAdminUser(t, database)
w := doRequest(t, handler, http.MethodPost, "/updates/apply", token, nil)
@@ -186,7 +186,7 @@ func TestAdminAPI_ApplyUpdate_CheckFails(t *testing.T) {
u.SetBaseURL(mockGH.URL)
database := openAdminTestDB(t)
- handler := admin.NewAdminAPI(database, "1.0.0", nil, u)
+ handler := admin.NewAdminAPI(database, "1.0.0", nil, u, nil)
token := createAdminUser(t, database)
w := doRequest(t, handler, http.MethodPost, "/updates/apply", token, nil)
@@ -214,7 +214,7 @@ func TestAdminAPI_ApplyUpdate_MissingAssets(t *testing.T) {
u.SetBaseURL(mockGH.URL)
database := openAdminTestDB(t)
- handler := admin.NewAdminAPI(database, "1.0.0", nil, u)
+ handler := admin.NewAdminAPI(database, "1.0.0", nil, u, nil)
token := createAdminUser(t, database)
w := doRequest(t, handler, http.MethodPost, "/updates/apply", token, nil)
@@ -235,7 +235,7 @@ func TestAdminAPI_ApplyUpdate_MissingAssets(t *testing.T) {
// unauthenticated requests to POST /updates/apply.
func TestAdminAPI_ApplyUpdate_Unauthenticated(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.MethodPost, "/updates/apply", "", nil)
if w.Code != http.StatusUnauthorized {
@@ -286,7 +286,7 @@ func TestAdminAPI_ApplyUpdate_DownloadFails(t *testing.T) {
// the important thing is that the code path is executed.
database := openAdminTestDB(t)
- handler := admin.NewAdminAPI(database, "1.0.0", nil, u)
+ handler := admin.NewAdminAPI(database, "1.0.0", nil, u, nil)
token := createAdminUser(t, database)
w := doRequest(t, handler, http.MethodPost, "/updates/apply", token, nil)
diff --git a/Server/api/router.go b/Server/api/router.go
index b287b82b..ace2057e 100644
--- a/Server/api/router.go
+++ b/Server/api/router.go
@@ -20,7 +20,7 @@ import (
// NewRouter builds and returns the fully configured HTTP handler and the
// WebSocket hub (so the caller can call hub.GracefulStop on shutdown).
-func NewRouter(cfg *config.Config, database *db.DB, ver string) (http.Handler, *ws.Hub) {
+func NewRouter(cfg *config.Config, database *db.DB, ver string, logBuf *admin.RingBuffer) (http.Handler, *ws.Hub) {
r := chi.NewRouter()
// Middleware stack.
@@ -82,7 +82,7 @@ func NewRouter(cfg *config.Config, database *db.DB, ver string) (http.Handler, *
// Admin panel: static files + REST API (Phase 6).
u := updater.NewUpdater(ver, cfg.GitHub.Token, "J3vb", "OwnCord")
- r.Mount("/admin", admin.NewHandler(database, ver, hub, u))
+ r.Mount("/admin", admin.NewHandler(database, ver, hub, u, logBuf))
// Client auto-update endpoint (unauthenticated).
MountClientUpdateRoute(r, u)
diff --git a/Server/api/router_test.go b/Server/api/router_test.go
index e7537fbe..0fa99358 100644
--- a/Server/api/router_test.go
+++ b/Server/api/router_test.go
@@ -32,7 +32,7 @@ func setupRouter(t *testing.T) http.Handler {
},
}
- handler, _ := api.NewRouter(cfg, database, "test")
+ handler, _ := api.NewRouter(cfg, database, "test", nil)
return handler
}
diff --git a/Server/main.go b/Server/main.go
index d7b8955d..8d038238 100644
--- a/Server/main.go
+++ b/Server/main.go
@@ -18,6 +18,7 @@ import (
"syscall"
"time"
+ "github.com/owncord/server/admin"
"github.com/owncord/server/api"
"github.com/owncord/server/auth"
"github.com/owncord/server/config"
@@ -28,11 +29,15 @@ import (
var version = "dev"
func main() {
- log := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
- Level: slog.LevelInfo,
- }))
+ // Create ring buffer for admin log viewer, then build a multi-handler
+ // that tees log records to both stdout (INFO+) and the ring buffer (DEBUG+).
+ logBuf := admin.NewRingBuffer(2000)
+ stdoutHandler := slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo})
+ multiHandler := admin.NewMultiHandler(stdoutHandler, logBuf, slog.LevelDebug)
+ log := slog.New(multiHandler)
+ slog.SetDefault(log)
- if err := run(log); err != nil {
+ if err := run(log, logBuf); err != nil {
_, _ = fmt.Fprintf(os.Stderr, "\n [ERROR] %v\n\n", err)
log.Error("server exited with error", "error", err)
os.Exit(1)
@@ -40,7 +45,7 @@ func main() {
}
// run is the real entrypoint — separated for testability.
-func run(log *slog.Logger) error {
+func run(log *slog.Logger, logBuf *admin.RingBuffer) error {
// Clean up old binary from a previous update.
if exePath, err := os.Executable(); err == nil {
oldPath := exePath + ".old"
@@ -64,7 +69,17 @@ func run(log *slog.Logger) error {
return fmt.Errorf("creating data dir %s: %w", cfg.Server.DataDir, mkdirErr)
}
- // ── 3. Open database + run migrations ─────────────────────────────────
+ // ── 3. TLS ────────────────────────────────────────────────────────────
+ tlsResult, err := auth.LoadOrGenerate(cfg.TLS)
+ if err != nil {
+ return fmt.Errorf("configuring TLS: %w", err)
+ }
+ tlsCfg := tlsResult.TLSConfig
+
+ // Print startup banner first so it appears above all init logs.
+ printBanner(cfg, version, tlsCfg != nil)
+
+ // ── 4. Open database + run migrations ─────────────────────────────────
database, err := db.Open(cfg.Database.Path)
if err != nil {
return fmt.Errorf("opening database: %w", err)
@@ -87,15 +102,8 @@ func run(log *slog.Logger) error {
log.Info("cleared stale voice states")
}
- // ── 4. TLS ─────────────────────────────────────────────────────────────
- tlsResult, err := auth.LoadOrGenerate(cfg.TLS)
- if err != nil {
- return fmt.Errorf("configuring TLS: %w", err)
- }
- tlsCfg := tlsResult.TLSConfig
-
// ── 5. Build HTTP router ───────────────────────────────────────────────
- router, hub := api.NewRouter(cfg, database, version)
+ router, hub := api.NewRouter(cfg, database, version, logBuf)
// ── 6. Start server ────────────────────────────────────────────────────
addr := fmt.Sprintf(":%d", cfg.Server.Port)
@@ -150,9 +158,6 @@ func run(log *slog.Logger) error {
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
- // Print startup banner.
- printBanner(cfg, version, tlsCfg != nil)
-
// Start serving in a goroutine.
serveErr := make(chan error, 1)
go func() {