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
This commit is contained in:
jevb
2026-03-19 05:32:40 +01:00
parent aa713390e4
commit b15738c4d8
16 changed files with 1234 additions and 1066 deletions
+2 -2
View File
@@ -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/…",
+10 -10
View File
@@ -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)
+7 -1
View File
@@ -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))
+26 -26
View File
@@ -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()
+43 -43
View File
@@ -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)
+13 -13
View File
@@ -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"
+7 -7
View File
@@ -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
+329
View File
@@ -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
}
}
}
}
+1 -1
View File
@@ -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 {
+3 -3
View File
@@ -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)
+6 -6
View File
@@ -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": "",
File diff suppressed because it is too large Load Diff
+11 -11
View File
@@ -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)
+2 -2
View File
@@ -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)
+1 -1
View File
@@ -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
}
+22 -17
View File
@@ -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() {