mirror of
https://github.com/J3vb/OwnCord.git
synced 2026-09-03 03:50:00 +03:00
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:
@@ -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/…",
|
||||
|
||||
@@ -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
@@ -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))
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
+751
-923
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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() {
|
||||
|
||||
Reference in New Issue
Block a user