mirror of
https://github.com/J3vb/OwnCord.git
synced 2026-09-03 03:50:00 +03:00
Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
63c87df487 | ||
|
|
528ae260ee | ||
|
|
df717e431c | ||
|
|
70875a36f6 | ||
|
|
f9258efc6e | ||
|
|
0518e689d0 | ||
|
|
1d1804ce5b | ||
|
|
e13adaf8b1 | ||
|
|
ed69bb56c7 | ||
|
|
bd397d2bbc |
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"nextId": 379,
|
||||
"nextId": 380,
|
||||
"findings": [
|
||||
{
|
||||
"id": "OC-0001",
|
||||
@@ -8565,6 +8565,28 @@
|
||||
"test": "Server/api/auth_characterization_test.go, Server/auth/totp_test.go",
|
||||
"revertProof": "pass"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "OC-0379",
|
||||
"title": "ownerOnlyMiddleware still re-read the role adminAuthMiddleware had already resolved — OC-0345's fix kept the redundant query and the 503 branch that existed only to serve it",
|
||||
"file": "Server/admin/middleware.go",
|
||||
"line": 130,
|
||||
"severity": "low",
|
||||
"why": "OC-0345's title named two defects: the redundant GetRoleByID and the transient-fault-as-403 collapse. Its fix (PR #1454) repaired only the error mapping — deliberately, per its own suggestedFix, to avoid touching the two tests that injected only adminUserKey — so the owner gate still issued a second role read on every owner-only request and carried a private 503 path whose only job was that read's failures. The register's closure evidence for OC-0345 ('Reuse the authenticated context…') was half-met, and the function's doc comment claimed to avoid the redundant query it performed.",
|
||||
"repro": "Verified at dev 7abdd941 by the 2026-08-31 post-merge audit: middleware.go:130 ran database.GetRoleByID inside ownerOnlyMiddleware while adminAuthMiddleware had stored the same principal's *db.Role under adminRoleKey (:90) and requirePerm (:104) already consumed it query-free. The nine owner-only routes in Server/admin/api.go paid the extra read; a roles-table fault on it produced the gate's own 503 although the perimeter had just proven the database healthy on the same request.",
|
||||
"evidence": "RED first: TestOwnerOnlyMiddleware_NoSecondRoleLookup (role in context, roles table renamed away) failed 503 against the old middleware — the second lookup, observed. GREEN after: the gate consumes adminRoleKey (missing role fails closed as 401, exactly requirePerm's contract; position below Owner stays 403), the query and its 503 branch are deleted, and the signature drops *db.DB at all nine call sites, so reintroducing a lookup is a compile-visible change. Revert-proof: restoring the old middleware body fails the build at api.go:150.",
|
||||
"status": "fixed",
|
||||
"found": "2026-08-31",
|
||||
"hunt": "postmerge-audit-2026-08-31",
|
||||
"lens": "audit",
|
||||
"confidence": "high",
|
||||
"finder": "claude",
|
||||
"fixed": "2026-08-31",
|
||||
"fix": {
|
||||
"commit": "bcdc0ef3",
|
||||
"test": "Server/admin/middleware_and_spawn_test.go",
|
||||
"revertProof": "pass"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -23,11 +23,11 @@ var staticFiles embed.FS
|
||||
//
|
||||
// /api/* — admin REST API (all require a moderation permission; see NewAdminAPI)
|
||||
// /* — embedded static files (SPA; index.html for unknown paths)
|
||||
func NewHandler(database *db.DB, version string, hub HubBroadcaster, u *updater.Updater, logBuf *RingBuffer, allowedOrigins []string, permInvalidator PermissionInvalidator, mod *service.ModerationService, roles *service.RoleService, opts ...SetupOptions) http.Handler {
|
||||
func NewHandler(database *db.DB, version string, hub HubBroadcaster, u *updater.Updater, logBuf *RingBuffer, allowedOrigins []string, permInvalidator PermissionInvalidator, mod *service.ModerationService, roles *service.RoleService, settings *service.SettingsService, opts ...SetupOptions) http.Handler {
|
||||
r := chi.NewRouter()
|
||||
|
||||
// Admin REST API mounted at /api
|
||||
r.Mount("/api", NewAdminAPI(database, version, hub, u, logBuf, allowedOrigins, permInvalidator, mod, roles, opts...))
|
||||
r.Mount("/api", NewAdminAPI(database, version, hub, u, logBuf, allowedOrigins, permInvalidator, mod, roles, settings, opts...))
|
||||
|
||||
// Static files — serve from the "static" sub-tree of the embedded FS.
|
||||
// The //go:embed static directive in this package embeds as "static/…",
|
||||
|
||||
@@ -20,7 +20,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, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
h := admin.NewHandler(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
if h == nil {
|
||||
t.Fatal("NewHandler returned nil handler")
|
||||
}
|
||||
@@ -30,7 +30,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, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
h := admin.NewHandler(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
w := httptest.NewRecorder()
|
||||
@@ -61,7 +61,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, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
h := admin.NewHandler(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
w := httptest.NewRecorder()
|
||||
@@ -77,7 +77,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, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
h := admin.NewHandler(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/setup/status", nil)
|
||||
w := httptest.NewRecorder()
|
||||
@@ -93,7 +93,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, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
h := admin.NewHandler(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
|
||||
// /api/stats requires authentication
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/stats", nil)
|
||||
@@ -110,7 +110,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, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
h := admin.NewHandler(database, "1.0.0", &mockHub{}, u, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
if h == nil {
|
||||
t.Fatal("NewHandler with updater returned nil handler")
|
||||
}
|
||||
@@ -122,7 +122,7 @@ func TestNewHandler_WithUpdater(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, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
|
||||
// createAdminUser creates an Owner-role user (role_id=1, position=100)
|
||||
ownerToken := createAdminUser(t, database)
|
||||
@@ -157,7 +157,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, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
|
||||
// Create admin user (role_id=2, position=80)
|
||||
adminUID, _ := database.CreateUser(context.Background(), "middlewareadmin", "hash", 2)
|
||||
@@ -175,7 +175,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, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
|
||||
memberToken := createMemberUser(t, database)
|
||||
|
||||
@@ -192,7 +192,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, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
|
||||
w := doRequest(t, handler, http.MethodPost, "/backup", "", nil)
|
||||
|
||||
|
||||
+12
-12
@@ -65,7 +65,7 @@ func startSetupLimiterReap(rl *auth.RateLimiter) {
|
||||
// The optional trailing SetupOptions enables the first-run wizard's
|
||||
// config.yaml write-back and restart; without it the setup endpoints keep
|
||||
// their legacy account-only behaviour (the case in most tests).
|
||||
func NewAdminAPI(database *db.DB, version string, hub HubBroadcaster, u *updater.Updater, logBuf *RingBuffer, allowedOrigins []string, permInvalidator PermissionInvalidator, mod *service.ModerationService, roles *service.RoleService, opts ...SetupOptions) http.Handler {
|
||||
func NewAdminAPI(database *db.DB, version string, hub HubBroadcaster, u *updater.Updater, logBuf *RingBuffer, allowedOrigins []string, permInvalidator PermissionInvalidator, mod *service.ModerationService, roles *service.RoleService, settings *service.SettingsService, opts ...SetupOptions) http.Handler {
|
||||
r := chi.NewRouter()
|
||||
|
||||
var setupOpts SetupOptions
|
||||
@@ -147,36 +147,36 @@ func NewAdminAPI(database *db.DB, version string, hub HubBroadcaster, u *updater
|
||||
// API tokens — Owner-only. Minting a network-reachable, revocation-
|
||||
// surviving bearer credential is gated like backups/updates.
|
||||
r.Get("/tokens", http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||
ownerOnlyMiddleware(database, handleListAPITokens(database)).ServeHTTP(w, req)
|
||||
ownerOnlyMiddleware(handleListAPITokens(database)).ServeHTTP(w, req)
|
||||
}))
|
||||
r.Post("/tokens", http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||
ownerOnlyMiddleware(database, handleCreateAPIToken(database)).ServeHTTP(w, req)
|
||||
ownerOnlyMiddleware(handleCreateAPIToken(database)).ServeHTTP(w, req)
|
||||
}))
|
||||
r.Delete("/tokens/{id}", http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||
ownerOnlyMiddleware(database, handleRevokeAPIToken(database)).ServeHTTP(w, req)
|
||||
ownerOnlyMiddleware(handleRevokeAPIToken(database)).ServeHTTP(w, req)
|
||||
}))
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(requirePerm(permissions.ManageServer))
|
||||
r.Get("/settings", handleGetSettings(database))
|
||||
r.Patch("/settings", handlePatchSettings(database))
|
||||
r.Get("/settings", handleGetSettings(settings))
|
||||
r.Patch("/settings", handlePatchSettings(settings))
|
||||
})
|
||||
r.Post("/backup", http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||
ownerOnlyMiddleware(database, handleBackup(database)).ServeHTTP(w, req)
|
||||
ownerOnlyMiddleware(handleBackup(database)).ServeHTTP(w, req)
|
||||
}))
|
||||
r.Get("/backups", http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||
ownerOnlyMiddleware(database, handleListBackups()).ServeHTTP(w, req)
|
||||
ownerOnlyMiddleware(handleListBackups()).ServeHTTP(w, req)
|
||||
}))
|
||||
r.Delete("/backups/{name}", http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||
ownerOnlyMiddleware(database, handleDeleteBackup(database)).ServeHTTP(w, req)
|
||||
ownerOnlyMiddleware(handleDeleteBackup(database)).ServeHTTP(w, req)
|
||||
}))
|
||||
r.Post("/backups/{name}/restore", http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||
ownerOnlyMiddleware(database, handleRestoreBackup(database, hub)).ServeHTTP(w, req)
|
||||
ownerOnlyMiddleware(handleRestoreBackup(database, hub)).ServeHTTP(w, req)
|
||||
}))
|
||||
r.Get("/updates", http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||
ownerOnlyMiddleware(database, handleCheckUpdate(u)).ServeHTTP(w, req)
|
||||
ownerOnlyMiddleware(handleCheckUpdate(u)).ServeHTTP(w, req)
|
||||
}))
|
||||
r.Post("/updates/apply", http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||
ownerOnlyMiddleware(database, handleApplyUpdate(u, hub, version)).ServeHTTP(w, req)
|
||||
ownerOnlyMiddleware(handleApplyUpdate(u, hub, version)).ServeHTTP(w, req)
|
||||
}))
|
||||
})
|
||||
|
||||
|
||||
@@ -21,7 +21,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, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
// The admin user created by createAdminUser has id=1. We try to patch id=1.
|
||||
@@ -37,7 +37,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, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
// Create and ban a target user first.
|
||||
@@ -62,7 +62,7 @@ func TestAdminAPI_PatchUser_UnbanUser(t *testing.T) {
|
||||
// expiry so the ban lapses on its own.
|
||||
func TestAdminAPI_PatchUser_TempBan(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
targetUID, _ := database.CreateUser(context.Background(), "tempbanme", "hash", 3)
|
||||
@@ -86,7 +86,7 @@ func TestAdminAPI_PatchUser_TempBan(t *testing.T) {
|
||||
// TestAdminAPI_PatchUser_TempBanOutOfRange verifies duration bounds are enforced.
|
||||
func TestAdminAPI_PatchUser_TempBanOutOfRange(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
targetUID, _ := database.CreateUser(context.Background(), "toolongban", "hash", 3)
|
||||
@@ -103,7 +103,7 @@ func TestAdminAPI_PatchUser_TempBanOutOfRange(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, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
targetUID, _ := database.CreateUser(context.Background(), "invalidbody", "hash", 3)
|
||||
@@ -125,7 +125,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, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
body := map[string]any{
|
||||
@@ -150,7 +150,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, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/channels", bytes.NewReader([]byte("not-json")))
|
||||
@@ -170,7 +170,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, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
w := doRequest(t, handler, http.MethodDelete, "/users/notanumber/sessions", token, nil)
|
||||
@@ -186,7 +186,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, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
chID, _ := database.AdminCreateChannel(context.Background(), "malformed", "text", "", "", 0)
|
||||
@@ -208,7 +208,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, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
// Passing limit=9999 should be silently capped to 500.
|
||||
@@ -225,7 +225,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, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
w := doRequest(t, handler, http.MethodGet, "/updates", token, nil)
|
||||
@@ -241,7 +241,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, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
w := doRequest(t, handler, http.MethodDelete, "/channels/notanumber", token, nil)
|
||||
@@ -257,7 +257,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, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
body := map[string]any{"name": "x"}
|
||||
@@ -273,7 +273,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, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
// Create several audit entries.
|
||||
@@ -304,7 +304,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, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
w := doRequest(t, handler, http.MethodGet, "/stats", token, nil)
|
||||
@@ -331,7 +331,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, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
w := doRequest(t, handler, http.MethodGet, "/audit-log?limit=notanumber", token, nil)
|
||||
@@ -345,7 +345,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, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
// limit=0 triggers the n < 1 fallback in queryInt
|
||||
@@ -363,7 +363,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, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
targetUID, _ := database.CreateUser(context.Background(), "ban-nohub", "hash", 3)
|
||||
@@ -388,7 +388,7 @@ func TestAdminAPI_PatchUser_BanNilHubDoesNotPanic(t *testing.T) {
|
||||
func TestAdminAPI_LogStreamTicketFlow(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
logBuf := admin.NewRingBuffer(8)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, logBuf, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, logBuf, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
ticketResp := doRequest(t, handler, http.MethodPost, "/logs/ticket", token, nil)
|
||||
@@ -483,7 +483,7 @@ func TestAdminAPI_LogStreamTicketFlow(t *testing.T) {
|
||||
// around BroadcastMemberUpdate).
|
||||
func TestAdminAPI_PatchUser_RoleChangeNilHubDoesNotPanic(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
targetUID, _ := database.CreateUser(context.Background(), "role-nohub", "hash", 3)
|
||||
@@ -508,7 +508,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, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
targetUID, _ := database.CreateUser(context.Background(), "banwithout", "hash", 3)
|
||||
@@ -529,7 +529,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, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", hub, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
targetUID, _ := database.CreateUser(context.Background(), "rolebroadcast", "hash", 3)
|
||||
@@ -551,7 +551,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, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
|
||||
w := doRequest(t, handler, http.MethodGet, "/setup/status", "", nil)
|
||||
|
||||
@@ -571,7 +571,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, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
|
||||
_, _ = database.CreateUser(context.Background(), "existing", "hash", 1)
|
||||
|
||||
@@ -592,7 +592,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, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
|
||||
body := map[string]string{
|
||||
"username": "owner",
|
||||
@@ -623,7 +623,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, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
|
||||
_, _ = database.CreateUser(context.Background(), "existing", "hash", 1)
|
||||
|
||||
@@ -642,7 +642,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, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
|
||||
body := map[string]string{
|
||||
"username": "",
|
||||
@@ -658,7 +658,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, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
|
||||
body := map[string]string{
|
||||
"username": "owner",
|
||||
@@ -674,7 +674,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, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/setup", bytes.NewReader([]byte("not-json")))
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
+66
-60
@@ -27,6 +27,12 @@ func newTestModService(database *db.DB) *service.ModerationService {
|
||||
return service.NewModerationService(st, service.NewPermissionService(st, checker))
|
||||
}
|
||||
|
||||
// newTestSettingsService builds a real SettingsService over the test
|
||||
// database so the settings routes exercise the same policy production runs.
|
||||
func newTestSettingsService(database *db.DB) *service.SettingsService {
|
||||
return service.NewSettingsService(database)
|
||||
}
|
||||
|
||||
// newTestRoleService builds a real RoleService over the test database so the
|
||||
// role routes exercise the production authorization (MANAGE_ROLES + hierarchy)
|
||||
// instead of a stub.
|
||||
@@ -262,7 +268,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, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
w := doRequest(t, handler, http.MethodGet, "/stats", token, nil)
|
||||
@@ -285,7 +291,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, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
|
||||
w := doRequest(t, handler, http.MethodGet, "/stats", "", nil)
|
||||
|
||||
@@ -296,7 +302,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, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
token := createMemberUser(t, database)
|
||||
|
||||
w := doRequest(t, handler, http.MethodGet, "/stats", token, nil)
|
||||
@@ -310,7 +316,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, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
w := doRequest(t, handler, http.MethodGet, "/users?limit=50&offset=0", token, nil)
|
||||
@@ -331,7 +337,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, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
// No query params — should use defaults
|
||||
@@ -344,7 +350,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, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
|
||||
w := doRequest(t, handler, http.MethodGet, "/users", "", nil)
|
||||
|
||||
@@ -362,7 +368,7 @@ func TestAdminAPI_ListUsers_Unauthenticated(t *testing.T) {
|
||||
// owner via the raw UPDATE).
|
||||
func TestAdminAPI_PatchUser_BanHierarchy(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
ownerToken := createAdminUser(t, database) // Owner role (pos 100)
|
||||
|
||||
// A second owner-rank user: equal position, cannot be banned.
|
||||
@@ -425,7 +431,7 @@ func TestAdminAPI_PatchUser_BanHierarchy(t *testing.T) {
|
||||
|
||||
func TestAdminAPI_PatchUser_BanUser(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
// Create a target user
|
||||
@@ -453,7 +459,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, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
targetUID, _ := database.CreateUser(context.Background(), "rolechange", "hash", 3)
|
||||
@@ -475,7 +481,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, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
body := map[string]any{"banned": true}
|
||||
@@ -488,7 +494,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, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
w := doRequest(t, handler, http.MethodPatch, "/users/abc", token, nil)
|
||||
@@ -502,7 +508,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, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
targetUID, _ := database.CreateUser(context.Background(), "logoutme", "hash", 3)
|
||||
@@ -522,7 +528,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, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
|
||||
w := doRequest(t, handler, http.MethodDelete, "/users/1/sessions", "", nil)
|
||||
|
||||
@@ -535,7 +541,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, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
_, _ = database.AdminCreateChannel(context.Background(), "general", "text", "", "", 0)
|
||||
@@ -559,7 +565,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, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
body := map[string]any{
|
||||
@@ -586,7 +592,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, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
body := map[string]any{
|
||||
@@ -603,7 +609,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, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
chID, _ := database.AdminCreateChannel(context.Background(), "old", "text", "", "", 0)
|
||||
@@ -624,7 +630,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, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
body := map[string]any{"name": "x"}
|
||||
@@ -639,7 +645,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, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
chID, _ := database.AdminCreateChannel(context.Background(), "del-me", "text", "", "", 0)
|
||||
@@ -659,7 +665,7 @@ func TestAdminAPI_DeleteChannel_OK(t *testing.T) {
|
||||
func TestAdminAPI_DeleteChannel_CleansVoiceBeforeDBDelete(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
hub := &mockHub{}
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", hub, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", hub, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
// The minimal admin schema has no voice_states; create it with the real
|
||||
@@ -722,7 +728,7 @@ func TestAdminAPI_DeleteChannel_CleansVoiceBeforeDBDelete(t *testing.T) {
|
||||
func TestAdminAPI_DeleteChannel_ArchivesBeforeVoiceCleanup(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
hub := &mockHub{}
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", hub, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", hub, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
chID, _ := database.AdminCreateChannel(context.Background(), "del-race", "voice", "", "", 0)
|
||||
@@ -748,7 +754,7 @@ func TestAdminAPI_DeleteChannel_ArchivesBeforeVoiceCleanup(t *testing.T) {
|
||||
|
||||
func TestAdminAPI_DeleteChannel_NotFound(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
w := doRequest(t, handler, http.MethodDelete, "/channels/99999", token, nil)
|
||||
@@ -762,7 +768,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, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
uid, _ := database.CreateUser(context.Background(), "actor", "hash", 1)
|
||||
@@ -785,7 +791,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, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
w := doRequest(t, handler, http.MethodGet, "/audit-log", token, nil)
|
||||
@@ -805,7 +811,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, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
w := doRequest(t, handler, http.MethodGet, "/settings", token, nil)
|
||||
@@ -827,7 +833,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, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
body := map[string]string{
|
||||
@@ -852,7 +858,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, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPatch, "/settings", bytes.NewReader([]byte("not-json")))
|
||||
@@ -869,7 +875,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, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
|
||||
// Admin (role 2) can authenticate but is not Owner (role 1, position 100)
|
||||
adminUID, _ := database.CreateUser(context.Background(), "adminonly", "hash", 2)
|
||||
@@ -886,7 +892,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, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
|
||||
w := doRequest(t, handler, http.MethodPost, "/backup", "", nil)
|
||||
|
||||
@@ -903,7 +909,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, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
// Create a target user to act on.
|
||||
@@ -937,7 +943,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, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
targetUID, _ := database.CreateUser(context.Background(), "logoutctx", "hash", 3)
|
||||
@@ -969,7 +975,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, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
body := map[string]string{
|
||||
@@ -995,7 +1001,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, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
body := map[string]string{
|
||||
@@ -1039,7 +1045,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, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
value := "testvalue"
|
||||
@@ -1060,7 +1066,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, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
body := map[string]string{}
|
||||
@@ -1073,7 +1079,7 @@ func TestAdminAPI_PatchSettings_EmptyPayloadIsOK(t *testing.T) {
|
||||
|
||||
func TestAdminAPI_PatchSettings_RejectsRequire2FAWhenUsersNotEnrolled(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
body := map[string]string{
|
||||
@@ -1089,7 +1095,7 @@ func TestAdminAPI_PatchSettings_RejectsRequire2FAWhenUsersNotEnrolled(t *testing
|
||||
|
||||
func TestAdminAPI_PatchSettings_AllowsRequire2FAWhenAllUsersEnrolledAndRegistrationClosed(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
if _, err := database.ExecContext(context.Background(), `UPDATE users SET totp_secret = ? WHERE id = 1`, "JBSWY3DPEHPK3PXP"); err != nil {
|
||||
@@ -1109,7 +1115,7 @@ func TestAdminAPI_PatchSettings_AllowsRequire2FAWhenAllUsersEnrolledAndRegistrat
|
||||
|
||||
func TestAdminAPI_PatchSettings_RejectsInvalidBooleanValue(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
body := map[string]string{
|
||||
@@ -1131,7 +1137,7 @@ func TestAdminAPI_PatchSettings_RejectsInvalidBooleanValue(t *testing.T) {
|
||||
// re-run the enrollment count for a key nobody asked to change).
|
||||
func TestAdminAPI_PatchSettings_UnrelatedKeyNotBlockedByRequire2FAGate(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
// Enroll the admin so the initial require_2fa enable succeeds.
|
||||
@@ -1177,7 +1183,7 @@ func TestAdminAPI_PatchSettings_UnrelatedKeyNotBlockedByRequire2FAGate(t *testin
|
||||
// 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, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
// Create a second user so the list is non-trivial.
|
||||
@@ -1204,7 +1210,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, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
w := doRequest(t, handler, http.MethodGet, "/users", token, nil)
|
||||
@@ -1223,7 +1229,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, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
w := doRequest(t, handler, http.MethodGet, "/users", token, nil)
|
||||
@@ -1252,7 +1258,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, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
targetUID, _ := database.CreateUser(context.Background(), "patchvictim", "topsecretbcrypt", 3)
|
||||
@@ -1280,7 +1286,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, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
targetUID, _ := database.CreateUser(context.Background(), "patchtotp", "hash", 3)
|
||||
@@ -1381,7 +1387,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, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", hub, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
body := map[string]any{
|
||||
@@ -1404,7 +1410,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, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
body := map[string]any{"name": "safe-channel", "type": "text"}
|
||||
@@ -1418,7 +1424,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, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", hub, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
chID, _ := database.AdminCreateChannel(context.Background(), "before", "text", "", "", 0)
|
||||
@@ -1439,7 +1445,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, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
chID, _ := database.AdminCreateChannel(context.Background(), "patchme", "text", "", "", 0)
|
||||
@@ -1454,7 +1460,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, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", hub, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
chID, _ := database.AdminCreateChannel(context.Background(), "delete-me", "text", "", "", 0)
|
||||
@@ -1474,7 +1480,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, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
chID, _ := database.AdminCreateChannel(context.Background(), "del-no-hub", "text", "", "", 0)
|
||||
@@ -1503,7 +1509,7 @@ func TestAdminAPI_DeleteChannel_NilHubDoesNotPanic(t *testing.T) {
|
||||
func TestAdminAPI_DeleteChannel_SurvivesContextCancelAfterArchiveCommits(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
hub := &mockHub{}
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", hub, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", hub, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
chID, _ := database.AdminCreateChannel(context.Background(), "del-cancel-race", "text", "", "", 0)
|
||||
@@ -1543,7 +1549,7 @@ func TestAdminAPI_DeleteChannel_SurvivesContextCancelAfterArchiveCommits(t *test
|
||||
|
||||
func TestAdminAPI_CreateAPIToken_OK(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
token := createAdminUser(t, database) // Owner role
|
||||
|
||||
w := doRequest(t, handler, http.MethodPost, "/tokens", token, map[string]any{"label": "ci-bot"})
|
||||
@@ -1573,7 +1579,7 @@ func TestAdminAPI_CreateAPIToken_OK(t *testing.T) {
|
||||
|
||||
func TestAdminAPI_CreateAPIToken_MissingLabel(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
w := doRequest(t, handler, http.MethodPost, "/tokens", token, map[string]any{"label": " "})
|
||||
@@ -1588,7 +1594,7 @@ func TestAdminAPI_CreateAPIToken_MissingLabel(t *testing.T) {
|
||||
// negative value down the nil-expiresAt ("never expires") branch.
|
||||
func TestAdminAPI_CreateAPIToken_NegativeExpiresHours(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
w := doRequest(t, handler, http.MethodPost, "/tokens", token, map[string]any{"label": "neg-hours", "expires_hours": -1})
|
||||
@@ -1609,7 +1615,7 @@ func TestAdminAPI_CreateAPIToken_NegativeExpiresHours(t *testing.T) {
|
||||
// timestamp and hand back a token that 401s on first use.
|
||||
func TestAdminAPI_CreateAPIToken_HugeExpiresHours(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
w := doRequest(t, handler, http.MethodPost, "/tokens", token, map[string]any{"label": "huge-hours", "expires_hours": 3000000})
|
||||
@@ -1627,7 +1633,7 @@ func TestAdminAPI_CreateAPIToken_HugeExpiresHours(t *testing.T) {
|
||||
|
||||
func TestAdminAPI_ListAPITokens_OK(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
hash := auth.HashToken("raw-secret-value")
|
||||
@@ -1656,7 +1662,7 @@ func TestAdminAPI_ListAPITokens_OK(t *testing.T) {
|
||||
|
||||
func TestAdminAPI_RevokeAPIToken_OK(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
hash := auth.HashToken("revoke-me")
|
||||
@@ -1678,7 +1684,7 @@ func TestAdminAPI_RevokeAPIToken_OK(t *testing.T) {
|
||||
|
||||
func TestAdminAPI_RevokeAPIToken_NotFound(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
w := doRequest(t, handler, http.MethodDelete, "/tokens/99999", token, nil)
|
||||
@@ -1692,7 +1698,7 @@ func TestAdminAPI_RevokeAPIToken_NotFound(t *testing.T) {
|
||||
// survives password change + bulk logout).
|
||||
func TestAdminAPI_Tokens_RequiresOwner(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
|
||||
adminUID, _ := database.CreateUser(context.Background(), "adminonly", "hash", 2) // Admin, not Owner
|
||||
token := "admin-only-token"
|
||||
@@ -1706,7 +1712,7 @@ func TestAdminAPI_Tokens_RequiresOwner(t *testing.T) {
|
||||
|
||||
func TestAdminAPI_Tokens_Unauthenticated(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
|
||||
w := doRequest(t, handler, http.MethodGet, "/tokens", "", nil)
|
||||
if w.Code != http.StatusUnauthorized {
|
||||
|
||||
@@ -27,7 +27,7 @@ func TestAuditCoverage_AdminMutations(t *testing.T) {
|
||||
t.Helper()
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, &mockPermInvalidator{},
|
||||
newTestModService(database), newTestRoleService(database))
|
||||
newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
token := createAdminUser(t, database)
|
||||
chID, err := database.CreateChannel(context.Background(), "secret", "text", "", "", 0)
|
||||
if err != nil {
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/J3vb/OwnCord/Server/db"
|
||||
"github.com/J3vb/OwnCord/Server/service"
|
||||
)
|
||||
|
||||
// Scheduled-backup intervals for the backup_schedule setting values the admin
|
||||
@@ -35,8 +36,8 @@ const (
|
||||
//
|
||||
// The returned error feeds the maintenance loop's circuit breaker; settings
|
||||
// simply not existing (fresh DB mid-migration) is not an error.
|
||||
func MaintainBackups(ctx context.Context, database *db.DB) error {
|
||||
schedule, err := database.GetSetting(ctx, "backup_schedule")
|
||||
func MaintainBackups(ctx context.Context, database *db.DB, settings *service.SettingsService) error {
|
||||
schedule, err := settings.Setting(ctx, "backup_schedule")
|
||||
if err != nil {
|
||||
if errors.Is(err, db.ErrNotFound) {
|
||||
return nil
|
||||
@@ -60,7 +61,7 @@ func MaintainBackups(ctx context.Context, database *db.DB) error {
|
||||
}
|
||||
}
|
||||
|
||||
if err := pruneExpiredBackups(ctx, database); err != nil {
|
||||
if err := pruneExpiredBackups(ctx, database, settings); err != nil {
|
||||
slog.Warn("backup retention pruning failed", "error", err)
|
||||
if firstErr == nil {
|
||||
firstErr = err
|
||||
@@ -118,8 +119,8 @@ func runScheduledBackup(ctx context.Context, database *db.DB, interval time.Dura
|
||||
|
||||
// pruneExpiredBackups deletes *.db backups whose mtime is older than the
|
||||
// backup_retention window (in days), always keeping the newest one.
|
||||
func pruneExpiredBackups(ctx context.Context, database *db.DB) error {
|
||||
retStr, err := database.GetSetting(ctx, "backup_retention")
|
||||
func pruneExpiredBackups(ctx context.Context, database *db.DB, settings *service.SettingsService) error {
|
||||
retStr, err := settings.Setting(ctx, "backup_retention")
|
||||
if err != nil {
|
||||
if errors.Is(err, db.ErrNotFound) {
|
||||
return nil
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/J3vb/OwnCord/Server/admin"
|
||||
"github.com/J3vb/OwnCord/Server/service"
|
||||
)
|
||||
|
||||
func listBackupFiles(t *testing.T, dir string) []string {
|
||||
@@ -49,7 +50,7 @@ func TestMaintainBackups_ScheduleAndRetention(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
// Settings absent → no-op, no error.
|
||||
if err := admin.MaintainBackups(ctx, database); err != nil {
|
||||
if err := admin.MaintainBackups(ctx, database, service.NewSettingsService(database)); err != nil {
|
||||
t.Fatalf("MaintainBackups with no settings: %v", err)
|
||||
}
|
||||
if got := listBackupFiles(t, dir); len(got) != 0 {
|
||||
@@ -59,7 +60,7 @@ func TestMaintainBackups_ScheduleAndRetention(t *testing.T) {
|
||||
// Schedule off → still a no-op.
|
||||
mustSetSetting(t, database, "backup_schedule", "off")
|
||||
mustSetSetting(t, database, "backup_retention", "7")
|
||||
if err := admin.MaintainBackups(ctx, database); err != nil {
|
||||
if err := admin.MaintainBackups(ctx, database, service.NewSettingsService(database)); err != nil {
|
||||
t.Fatalf("MaintainBackups with schedule=off: %v", err)
|
||||
}
|
||||
if got := listBackupFiles(t, dir); len(got) != 0 {
|
||||
@@ -68,7 +69,7 @@ func TestMaintainBackups_ScheduleAndRetention(t *testing.T) {
|
||||
|
||||
// Daily → first tick creates exactly one scheduled backup.
|
||||
mustSetSetting(t, database, "backup_schedule", "daily")
|
||||
if err := admin.MaintainBackups(ctx, database); err != nil {
|
||||
if err := admin.MaintainBackups(ctx, database, service.NewSettingsService(database)); err != nil {
|
||||
t.Fatalf("MaintainBackups daily #1: %v", err)
|
||||
}
|
||||
files := listBackupFiles(t, dir)
|
||||
@@ -78,7 +79,7 @@ func TestMaintainBackups_ScheduleAndRetention(t *testing.T) {
|
||||
first := filepath.Join(dir, files[0])
|
||||
|
||||
// Fresh backup on disk → next tick is a no-op.
|
||||
if err := admin.MaintainBackups(ctx, database); err != nil {
|
||||
if err := admin.MaintainBackups(ctx, database, service.NewSettingsService(database)); err != nil {
|
||||
t.Fatalf("MaintainBackups daily #2: %v", err)
|
||||
}
|
||||
if got := listBackupFiles(t, dir); len(got) != 1 {
|
||||
@@ -88,7 +89,7 @@ func TestMaintainBackups_ScheduleAndRetention(t *testing.T) {
|
||||
// Backup older than a day (but inside retention) → a new one is taken and
|
||||
// the old one is kept.
|
||||
backdate(t, first, 25*time.Hour)
|
||||
if err := admin.MaintainBackups(ctx, database); err != nil {
|
||||
if err := admin.MaintainBackups(ctx, database, service.NewSettingsService(database)); err != nil {
|
||||
t.Fatalf("MaintainBackups daily #3: %v", err)
|
||||
}
|
||||
if got := listBackupFiles(t, dir); len(got) != 2 {
|
||||
@@ -97,7 +98,7 @@ func TestMaintainBackups_ScheduleAndRetention(t *testing.T) {
|
||||
|
||||
// Old backup past the 7-day retention window → pruned; the fresh one stays.
|
||||
backdate(t, first, 8*24*time.Hour)
|
||||
if err := admin.MaintainBackups(ctx, database); err != nil {
|
||||
if err := admin.MaintainBackups(ctx, database, service.NewSettingsService(database)); err != nil {
|
||||
t.Fatalf("MaintainBackups daily #4: %v", err)
|
||||
}
|
||||
got := listBackupFiles(t, dir)
|
||||
@@ -132,7 +133,7 @@ func TestMaintainBackups_RetentionNeverDeletesNewest(t *testing.T) {
|
||||
backdate(t, older, 30*24*time.Hour)
|
||||
backdate(t, newer, 20*24*time.Hour)
|
||||
|
||||
if err := admin.MaintainBackups(ctx, database); err != nil {
|
||||
if err := admin.MaintainBackups(ctx, database, service.NewSettingsService(database)); err != nil {
|
||||
t.Fatalf("MaintainBackups: %v", err)
|
||||
}
|
||||
got := listBackupFiles(t, dir)
|
||||
|
||||
@@ -20,7 +20,7 @@ import (
|
||||
func TestAdminAPI_PatchChannel_ArchiveCleansVoice(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
hub := &mockHub{}
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", hub, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", hub, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
chID, _ := database.AdminCreateChannel(context.Background(), "archive-voice", "voice", "", "", 0)
|
||||
@@ -47,7 +47,7 @@ func TestAdminAPI_PatchChannel_ArchiveCleansVoice(t *testing.T) {
|
||||
func TestAdminAPI_PatchChannel_UnarchiveDoesNotCleanVoice(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
hub := &mockHub{}
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", hub, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", hub, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
chID, _ := database.AdminCreateChannel(context.Background(), "unarchive-voice", "voice", "", "", 0)
|
||||
@@ -89,7 +89,7 @@ func TestAdminAPI_PatchChannel_UnarchiveDoesNotCleanVoice(t *testing.T) {
|
||||
func TestAdminAPI_PatchChannel_ArchiveSurvivesContextCancelAfterCommit(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
hub := &mockHub{}
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", hub, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", hub, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
chID, _ := database.AdminCreateChannel(context.Background(), "archive-cancel-race", "voice", "", "", 0)
|
||||
@@ -138,7 +138,7 @@ func TestAdminAPI_PatchChannel_ArchiveSurvivesContextCancelAfterCommit(t *testin
|
||||
func TestAdminAPI_CreateChannel_SurvivesContextCancelAfterCommit(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
hub := &mockHub{}
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", hub, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", hub, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
|
||||
@@ -46,7 +46,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, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
w := doRequest(t, handler, http.MethodPost, "/backup", token, nil)
|
||||
@@ -81,7 +81,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, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
|
||||
adminUID, _ := database.CreateUser(context.Background(), "backupadmin", "hash", 2)
|
||||
token := "backup-admin-token"
|
||||
@@ -101,7 +101,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, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
w := doRequest(t, handler, http.MethodGet, "/backups", token, nil)
|
||||
@@ -124,7 +124,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, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
// Create a backup first.
|
||||
@@ -166,7 +166,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, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
// Create a real backup file to delete.
|
||||
@@ -197,7 +197,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, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
w := doRequest(t, handler, http.MethodDelete, "/backups/nonexistent.db", token, nil)
|
||||
@@ -212,7 +212,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, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
// The chi router URL-decodes the path parameter, so ".." arrives decoded.
|
||||
@@ -230,7 +230,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, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
|
||||
adminUID, _ := database.CreateUser(context.Background(), "deladmin", "hash", 2)
|
||||
token := "del-admin-token"
|
||||
@@ -255,7 +255,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, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
// Set up backup and data directories.
|
||||
@@ -359,7 +359,7 @@ func TestHandleRestoreBackup_Success(t *testing.T) {
|
||||
func TestHandleRestoreBackup_RollsBackWhenCopyFails(t *testing.T) {
|
||||
tmpDir := chdirTemp(t)
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
backupDir := filepath.Join(tmpDir, "data", "backups")
|
||||
@@ -453,7 +453,7 @@ func TestHandleRestoreBackup_RollsBackWhenCopyFails(t *testing.T) {
|
||||
func TestHandleRestoreBackup_RestartsWhenCloseFails(t *testing.T) {
|
||||
tmpDir := chdirTemp(t)
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
backupDir := filepath.Join(tmpDir, "data", "backups")
|
||||
@@ -500,7 +500,7 @@ func TestHandleRestoreBackup_RestartsWhenCloseFails(t *testing.T) {
|
||||
func TestHandleRestoreBackup_AbortsWithoutSafetyBackup(t *testing.T) {
|
||||
tmpDir := chdirTemp(t)
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
backupDir := filepath.Join(tmpDir, "data", "backups")
|
||||
@@ -556,7 +556,7 @@ func TestHandleRestoreBackup_AbortsWithoutSafetyBackup(t *testing.T) {
|
||||
func TestHandleRestoreBackup_UsesConfiguredDatabasePath(t *testing.T) {
|
||||
tmpDir := chdirTemp(t)
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
backupDir := filepath.Join(tmpDir, "data", "backups")
|
||||
@@ -622,7 +622,7 @@ func TestHandleRestoreBackup_UsesConfiguredDatabasePath(t *testing.T) {
|
||||
func TestHandleRestoreBackup_NotFound(t *testing.T) {
|
||||
_ = chdirTemp(t)
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
w := doRequest(t, handler, http.MethodPost, "/backups/missing.db/restore", token, nil)
|
||||
@@ -637,7 +637,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, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
w := doRequest(t, handler, http.MethodPost, "/backups/..evil.db/restore", token, nil)
|
||||
@@ -653,7 +653,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, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
// Create data/ directory but make "backups" a file instead of a directory.
|
||||
@@ -681,7 +681,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, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
|
||||
adminUID, _ := database.CreateUser(context.Background(), "restoreadmin", "hash", 2)
|
||||
token := "restore-admin-token"
|
||||
|
||||
@@ -29,7 +29,7 @@ func (m *mockPermInvalidator) InvalidateAll() {
|
||||
|
||||
func TestGetChannelPermissions_ReturnsAllRoles(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
chID, err := database.CreateChannel(context.Background(), "secret", "text", "", "", 0)
|
||||
@@ -67,7 +67,7 @@ func TestGetChannelPermissions_ReturnsAllRoles(t *testing.T) {
|
||||
|
||||
func TestGetChannelPermissions_NotFound(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
w := doRequest(t, handler, http.MethodGet, "/channels/9999/permissions", token, nil)
|
||||
@@ -78,7 +78,7 @@ func TestGetChannelPermissions_NotFound(t *testing.T) {
|
||||
|
||||
func TestGetChannelPermissions_DMRejected(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
chID, err := database.CreateChannel(context.Background(), "dm-chan", "dm", "", "", 0)
|
||||
@@ -99,7 +99,7 @@ func TestPutChannelPermission_PersistsAndPropagates(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
hub := &mockHub{}
|
||||
inv := &mockPermInvalidator{}
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", hub, nil, nil, nil, inv, newTestModService(database), newTestRoleService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", hub, nil, nil, nil, inv, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
chID, err := database.CreateChannel(context.Background(), "secret", "text", "", "", 0)
|
||||
@@ -160,7 +160,7 @@ func TestPutChannelPermission_PersistsAndPropagates(t *testing.T) {
|
||||
|
||||
func TestPutChannelPermission_MasksUnknownBits(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
chID, err := database.CreateChannel(context.Background(), "secret2", "text", "", "", 0)
|
||||
@@ -190,7 +190,7 @@ func TestPutChannelPermission_MasksUnknownBits(t *testing.T) {
|
||||
|
||||
func TestPutChannelPermission_UnknownRole(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
chID, err := database.CreateChannel(context.Background(), "secret3", "text", "", "", 0)
|
||||
@@ -207,7 +207,7 @@ func TestPutChannelPermission_UnknownRole(t *testing.T) {
|
||||
|
||||
func TestPutChannelPermission_NonAdminForbidden(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
_ = createAdminUser(t, database)
|
||||
memberToken := createMemberUser(t, database)
|
||||
|
||||
@@ -228,7 +228,7 @@ func TestPutChannelPermission_NonAdminForbidden(t *testing.T) {
|
||||
// a channel override — the escalation this override endpoint must refuse.
|
||||
func TestPutChannelPermission_ModeratorCannotEscalate(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
_, modToken := createRoleUser(t, database, 10, "Moderator", moderatorMask, 60, "moduser")
|
||||
|
||||
chID, err := database.CreateChannel(context.Background(), "escalate", "text", "", "", 0)
|
||||
@@ -258,7 +258,7 @@ func TestPutChannelPermission_ModeratorCannotEscalate(t *testing.T) {
|
||||
// a channel override, since ADMINISTRATOR bypasses the escalation guard.
|
||||
func TestPutChannelPermission_AdministratorCanGrantAnyBit(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
chID, err := database.CreateChannel(context.Background(), "admin-grant", "text", "", "", 0)
|
||||
@@ -287,7 +287,7 @@ func TestPutChannelPermission_AdministratorCanGrantAnyBit(t *testing.T) {
|
||||
// the actor's own mask — mirroring service.requireBelowActor.
|
||||
func TestPutChannelPermission_RefusesEqualOrHigherRole(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
_, modToken := createRoleUser(t, database, 10, "Moderator", moderatorMask, 60, "moduser")
|
||||
|
||||
chID, err := database.CreateChannel(context.Background(), "hierarchy", "text", "", "", 0)
|
||||
@@ -320,7 +320,7 @@ func TestDeleteChannelPermission_ClearsOverride(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
hub := &mockHub{}
|
||||
inv := &mockPermInvalidator{}
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", hub, nil, nil, nil, inv, newTestModService(database), newTestRoleService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", hub, nil, nil, nil, inv, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
chID, err := database.CreateChannel(context.Background(), "secret5", "text", "", "", 0)
|
||||
@@ -373,7 +373,7 @@ func TestDeleteChannelPermission_ClearsOverride(t *testing.T) {
|
||||
// TestPutChannelPermission_RefusesEqualOrHigherRole (A-2026-08-01).
|
||||
func TestDeleteChannelPermission_RefusesEqualOrHigherRole(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
_, modToken := createRoleUser(t, database, 10, "Moderator", moderatorMask, 60, "moduser")
|
||||
|
||||
chID, err := database.CreateChannel(context.Background(), "hierarchy-del", "text", "", "", 0)
|
||||
@@ -417,7 +417,7 @@ func TestDeleteChannelPermission_RefusesEqualOrHigherRole(t *testing.T) {
|
||||
// (TestPutChannelPermission_UnknownRole).
|
||||
func TestDeleteChannelPermission_UnknownRole(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
chID, err := database.CreateChannel(context.Background(), "hierarchy-del-404", "text", "", "", 0)
|
||||
@@ -440,7 +440,7 @@ func TestDeleteChannelPermission_UnknownRole(t *testing.T) {
|
||||
// not skip it just because the hierarchy guard alone passes.
|
||||
func TestDeleteChannelPermission_EscalationGuard(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
|
||||
// Helper role: low position, base permissions include MANAGE_MESSAGES.
|
||||
if _, err := database.ExecContext(context.Background(),
|
||||
@@ -485,7 +485,7 @@ func TestDeleteChannelPermission_EscalationGuard(t *testing.T) {
|
||||
// by this write, not just the (trivially empty) bits being written.
|
||||
func TestPutChannelPermission_ClearByZeroMaskEscalationGuard(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
|
||||
if _, err := database.ExecContext(context.Background(),
|
||||
`INSERT INTO roles (id, name, color, permissions, position, is_default)
|
||||
|
||||
@@ -30,7 +30,7 @@ func TestPutChannelUserPermission_PersistsInvalidatesAndAudits(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
hub := &mockHub{}
|
||||
inv := &mockPermInvalidator{}
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", hub, nil, nil, nil, inv, newTestModService(database), newTestRoleService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", hub, nil, nil, nil, inv, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
token := createAdminUser(t, database)
|
||||
target := seedOverrideTarget(t, database, "override-target")
|
||||
|
||||
@@ -95,7 +95,7 @@ func TestPutChannelUserPermission_PersistsInvalidatesAndAudits(t *testing.T) {
|
||||
// editor writes — one bit per row, in both directions at once.
|
||||
func TestPutChannelUserPermission_MaskRoundTrip(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
token := createAdminUser(t, database)
|
||||
target := seedOverrideTarget(t, database, "matrix-target")
|
||||
|
||||
@@ -135,7 +135,7 @@ func TestPutChannelUserPermission_MaskRoundTrip(t *testing.T) {
|
||||
|
||||
func TestPutChannelUserPermission_MasksUnknownBits(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
token := createAdminUser(t, database)
|
||||
target := seedOverrideTarget(t, database, "junk-target")
|
||||
|
||||
@@ -162,7 +162,7 @@ func TestPutChannelUserPermission_MasksUnknownBits(t *testing.T) {
|
||||
|
||||
func TestPutChannelUserPermission_UnknownUser(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
chID, err := database.CreateChannel(context.Background(), "nope", "text", "", "", 0)
|
||||
@@ -179,7 +179,7 @@ func TestPutChannelUserPermission_UnknownUser(t *testing.T) {
|
||||
|
||||
func TestPutChannelUserPermission_DMRejected(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
token := createAdminUser(t, database)
|
||||
target := seedOverrideTarget(t, database, "dm-target")
|
||||
|
||||
@@ -197,7 +197,7 @@ func TestPutChannelUserPermission_DMRejected(t *testing.T) {
|
||||
|
||||
func TestChannelUserPermission_NonAdminForbidden(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
_ = createAdminUser(t, database)
|
||||
memberToken := createMemberUser(t, database)
|
||||
target := seedOverrideTarget(t, database, "forbidden-target")
|
||||
@@ -231,7 +231,7 @@ func TestChannelUserPermission_NonAdminForbidden(t *testing.T) {
|
||||
// writing it into a per-user channel override.
|
||||
func TestPutChannelUserPermission_ModeratorCannotEscalate(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
_, modToken := createRoleUser(t, database, 10, "Moderator", moderatorMask, 60, "moduser")
|
||||
target := seedOverrideTarget(t, database, "escalate-target")
|
||||
|
||||
@@ -263,7 +263,7 @@ func TestPutChannelUserPermission_ModeratorCannotEscalate(t *testing.T) {
|
||||
// access their role grants.
|
||||
func TestPutChannelUserPermission_CannotTargetHigherRankedUser(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
// Actor: Moderator at position 60 holding MANAGE_CHANNELS + READ_MESSAGES.
|
||||
_, modToken := createRoleUser(t, database, 10, "Moderator", moderatorMask, 60, "mod-hier")
|
||||
// Target holds a role ranked ABOVE the actor.
|
||||
@@ -300,7 +300,7 @@ func TestPutChannelUserPermission_CannotTargetHigherRankedUser(t *testing.T) {
|
||||
// override, since ADMINISTRATOR bypasses the escalation guard.
|
||||
func TestPutChannelUserPermission_AdministratorCanGrantAnyBit(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
token := createAdminUser(t, database)
|
||||
target := seedOverrideTarget(t, database, "admin-grant-target")
|
||||
|
||||
@@ -329,7 +329,7 @@ func TestDeleteChannelUserPermission_ClearsOverride(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
hub := &mockHub{}
|
||||
inv := &mockPermInvalidator{}
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", hub, nil, nil, nil, inv, newTestModService(database), newTestRoleService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", hub, nil, nil, nil, inv, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
token := createAdminUser(t, database)
|
||||
target := seedOverrideTarget(t, database, "clear-target")
|
||||
|
||||
@@ -391,7 +391,7 @@ func TestDeleteChannelUserPermission_ClearsOverride(t *testing.T) {
|
||||
// passes.
|
||||
func TestDeleteChannelUserPermission_EscalationGuard(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
// Actor: MANAGE_CHANNELS holder without MANAGE_MESSAGES or ADMINISTRATOR.
|
||||
_, modToken := createRoleUser(t, database, 10, "Moderator", permissions.ManageChannels, 70, "moduser")
|
||||
target := seedOverrideTarget(t, database, "escalate-del-target")
|
||||
@@ -424,7 +424,7 @@ func TestDeleteChannelUserPermission_EscalationGuard(t *testing.T) {
|
||||
// (TestPutChannelPermission_ClearByZeroMaskEscalationGuard's per-user twin).
|
||||
func TestPutChannelUserPermission_ClearByZeroMaskEscalationGuard(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
_, modToken := createRoleUser(t, database, 10, "Moderator", permissions.ManageChannels, 70, "moduser")
|
||||
target := seedOverrideTarget(t, database, "escalate-zero-target")
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ import (
|
||||
func newChannelTestAPI(t *testing.T) (http.Handler, string, *db.DB) {
|
||||
t.Helper()
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
return handler, createAdminUser(t, database), database
|
||||
}
|
||||
|
||||
@@ -352,7 +352,7 @@ func TestPatchChannel_RejectedPatchWritesNothing(t *testing.T) {
|
||||
func TestPatchChannel_BroadcastCarriesFeatureFlags(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
hub := &mockHub{}
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", hub, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", hub, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
id := newChannel(t, handler, token, "lounge", "voice")
|
||||
|
||||
@@ -25,7 +25,7 @@ func newRolesHandler(t *testing.T, database *db.DB) (http.Handler, *mockHub, *mo
|
||||
hub := &mockHub{}
|
||||
inv := &mockPermInvalidator{}
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", hub, nil, nil, nil, inv,
|
||||
newTestModService(database), newTestRoleService(database))
|
||||
newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
return handler, hub, inv, createAdminUser(t, database)
|
||||
}
|
||||
|
||||
@@ -155,7 +155,7 @@ func TestAdminAPI_Roles_ServiceUnavailableFailsClosed(t *testing.T) {
|
||||
// nil RoleService: the routes must refuse rather than fall through to an
|
||||
// unchecked write.
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil,
|
||||
newTestModService(database), nil)
|
||||
newTestModService(database), nil, newTestSettingsService(database))
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
for _, tc := range []struct {
|
||||
|
||||
@@ -1,31 +1,31 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/J3vb/OwnCord/Server/db"
|
||||
"github.com/J3vb/OwnCord/Server/service"
|
||||
)
|
||||
|
||||
// ─── Settings Handlers ──────────────────────────────────────────────────────
|
||||
//
|
||||
// Thin adapters over service.SettingsService (B3-8 settings/audit family):
|
||||
// the whitelist, boolean normalization, require_2fa preconditions, atomic
|
||||
// apply and audit rows all live in the service.
|
||||
|
||||
func handleGetSettings(database *db.DB) http.HandlerFunc {
|
||||
func handleGetSettings(settings *service.SettingsService) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
settings, err := database.GetAllSettings(r.Context())
|
||||
all, err := settings.List(r.Context())
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to get settings")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, settings)
|
||||
writeJSON(w, http.StatusOK, all)
|
||||
}
|
||||
}
|
||||
|
||||
func handlePatchSettings(database *db.DB) http.HandlerFunc {
|
||||
func handlePatchSettings(settings *service.SettingsService) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
var updates map[string]string
|
||||
if err := json.NewDecoder(r.Body).Decode(&updates); err != nil {
|
||||
@@ -33,144 +33,15 @@ func handlePatchSettings(database *db.DB) http.HandlerFunc {
|
||||
return
|
||||
}
|
||||
|
||||
// Validate all keys against the whitelist before writing anything so
|
||||
// the operation is atomic from the caller's perspective.
|
||||
for key := range updates {
|
||||
if _, ok := allowedSettingKeys[key]; !ok {
|
||||
writeErr(w, http.StatusBadRequest, "BAD_REQUEST",
|
||||
fmt.Sprintf("unknown setting key: %q", key))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
normalizedUpdates, err := normalizeSettingUpdates(updates)
|
||||
if err != nil {
|
||||
all, err := settings.Patch(r.Context(), actorFromContext(r), updates)
|
||||
if errors.Is(err, service.ErrBadRequest) {
|
||||
writeErr(w, http.StatusBadRequest, "BAD_REQUEST", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if err := validateRequire2FAUpdate(r.Context(), database, normalizedUpdates); err != nil {
|
||||
writeErr(w, http.StatusBadRequest, "BAD_REQUEST", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
actor := actorFromContext(r)
|
||||
|
||||
// Apply all settings atomically so a mid-loop failure doesn't leave
|
||||
// partial updates.
|
||||
tx, err := database.BeginTx(r.Context(), nil)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to start transaction")
|
||||
writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to update settings")
|
||||
return
|
||||
}
|
||||
for key, value := range normalizedUpdates {
|
||||
if _, txErr := tx.ExecContext(r.Context(),
|
||||
`INSERT INTO settings (key, value) VALUES (?, ?)
|
||||
ON CONFLICT(key) DO UPDATE SET value = excluded.value`,
|
||||
key, value,
|
||||
); txErr != nil {
|
||||
_ = tx.Rollback()
|
||||
writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to update setting: "+key)
|
||||
return
|
||||
}
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to commit settings")
|
||||
return
|
||||
}
|
||||
for key := range normalizedUpdates {
|
||||
slog.Info("setting changed", "actor_id", actor, "key", key)
|
||||
db.WriteAudit(context.WithoutCancel(r.Context()), database, actor, "setting_change", "setting", 0,
|
||||
fmt.Sprintf("%s updated", key))
|
||||
}
|
||||
|
||||
settings, err := database.GetAllSettings(r.Context())
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to fetch settings")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, settings)
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeSettingUpdates(updates map[string]string) (map[string]string, error) {
|
||||
normalized := make(map[string]string, len(updates))
|
||||
for key, value := range updates {
|
||||
normalized[key] = value
|
||||
switch key {
|
||||
case "require_2fa", "registration_open":
|
||||
parsed, err := parseBooleanSettingValue(value)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", key, err)
|
||||
}
|
||||
if parsed {
|
||||
normalized[key] = "1"
|
||||
} else {
|
||||
normalized[key] = "0"
|
||||
}
|
||||
}
|
||||
}
|
||||
return normalized, nil
|
||||
}
|
||||
|
||||
func validateRequire2FAUpdate(ctx context.Context, database *db.DB, updates map[string]string) error {
|
||||
targetRequire2FA, err := targetBoolSetting(ctx, database, updates, "require_2fa")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !targetRequire2FA {
|
||||
return nil
|
||||
}
|
||||
|
||||
registrationOpen, err := targetBoolSetting(ctx, database, updates, "registration_open")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if registrationOpen {
|
||||
return fmt.Errorf("require_2fa cannot be enabled while registration is open")
|
||||
}
|
||||
|
||||
// The enrollment count only matters when this request is actually turning
|
||||
// require_2fa on. Without this guard, an unrelated PATCH (motd, server
|
||||
// name, backup settings, ...) inherits require_2fa's *current* value via
|
||||
// targetBoolSetting's DB fallback and gets rejected by a precondition
|
||||
// about a value it never touches — wedging the whole settings page once
|
||||
// any non-banned user without TOTP exists.
|
||||
if _, changingRequire2FA := updates["require_2fa"]; !changingRequire2FA {
|
||||
return nil
|
||||
}
|
||||
|
||||
count, err := database.CountUsersWithoutTOTP(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to validate 2FA enrollment")
|
||||
}
|
||||
if count > 0 {
|
||||
return fmt.Errorf("require_2fa cannot be enabled until all users have 2FA enabled")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func targetBoolSetting(ctx context.Context, database *db.DB, updates map[string]string, key string) (bool, error) {
|
||||
if value, ok := updates[key]; ok {
|
||||
return parseBooleanSettingValue(value)
|
||||
}
|
||||
value, err := database.GetSetting(ctx, key)
|
||||
if errors.Is(err, db.ErrNotFound) {
|
||||
return false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return parseBooleanSettingValue(value)
|
||||
}
|
||||
|
||||
func parseBooleanSettingValue(value string) (bool, error) {
|
||||
switch strings.ToLower(strings.TrimSpace(value)) {
|
||||
case "1", "true":
|
||||
return true, nil
|
||||
case "0", "false":
|
||||
return false, nil
|
||||
default:
|
||||
return false, fmt.Errorf("invalid boolean value %q", value)
|
||||
writeJSON(w, http.StatusOK, all)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ import (
|
||||
func TestAdminAPI_PatchUser_RefusedRoleChangeDoesNotLeaveBanCommitted(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
hub := &mockHub{}
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", hub, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", hub, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
|
||||
// Moderator: BAN_MEMBERS (and everything below bit 20), but not
|
||||
// MANAGE_ROLES (bit 24) — moderatorMask is perm_gates_test.go's constant
|
||||
|
||||
@@ -29,7 +29,7 @@ func (m *unbanMockHub) BroadcastMemberUnban(userID int64) {
|
||||
func TestAdminAPI_PatchUser_UnbanBroadcastsMemberUnban(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
hub := &unbanMockHub{mockHub: &mockHub{}}
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", hub, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", hub, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
targetUID, _ := database.CreateUser(context.Background(), "unbanbroadcast", "hash", 3)
|
||||
@@ -63,7 +63,7 @@ func TestAdminAPI_PatchUser_UnbanBroadcastsMemberUnban(t *testing.T) {
|
||||
func TestAdminAPI_PatchUser_RoleChangeRefreshesVisibility(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
hub := &mockHub{}
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", hub, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", hub, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
targetUID, _ := database.CreateUser(context.Background(), "rolerefresh", "hash", 3)
|
||||
@@ -120,7 +120,7 @@ func TestAdminAPI_PatchUser_RoleChangeBroadcastsEvenIfRoleReReadFails(t *testing
|
||||
database := openAdminTestDB(t)
|
||||
hub := &mockHub{}
|
||||
invalidator := &roleDeletingInvalidator{database: database, deleteRoleID: 2, fallbackRoleID: 3}
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", hub, nil, nil, nil, invalidator, newTestModService(database), newTestRoleService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", hub, nil, nil, nil, invalidator, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
targetUID, _ := database.CreateUser(context.Background(), "roleracetarget", "hash", 3)
|
||||
|
||||
@@ -34,7 +34,7 @@ func TestAdminAPI_PatchRole_MemberLookupFailureBlanketInvalidates(t *testing.T)
|
||||
service.NewPermissionService(database, permissions.NewChecker(database)),
|
||||
)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", hub, nil, nil, nil, inv,
|
||||
newTestModService(database), roleSvc)
|
||||
newTestModService(database), roleSvc, newTestSettingsService(database))
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
// Strip the seeded Member role down to READ_MESSAGES — a permissions
|
||||
|
||||
@@ -22,7 +22,7 @@ func TestAdminAPI_LogStreamTicketFlow_APIToken(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
logBuf := admin.NewRingBuffer(8)
|
||||
logBuf.Write(admin.LogEntry{Timestamp: "2026-07-31T10:00:00Z", Level: "INFO", Message: "hello from ring", Source: "server"})
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, logBuf, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, logBuf, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
|
||||
// An admin user authenticated only by an API token — no session row exists.
|
||||
uid, err := database.CreateUser(context.Background(), "apitokenadmin", "$2a$12$placeholder", 1)
|
||||
|
||||
+11
-20
@@ -116,31 +116,22 @@ func requirePerm(perm int64) func(http.Handler) http.Handler {
|
||||
}
|
||||
}
|
||||
|
||||
// ownerOnlyMiddleware wraps a handler to require Owner role (position == 100).
|
||||
// It reads the user from context (set by adminAuthMiddleware) rather than
|
||||
// re-authenticating, avoiding redundant DB queries and session-expiry gaps.
|
||||
func ownerOnlyMiddleware(database *db.DB, next http.Handler) http.Handler {
|
||||
// ownerOnlyMiddleware wraps a handler to require the Owner role
|
||||
// (position == permissions.OwnerRolePosition). It consumes the *db.Role that
|
||||
// adminAuthMiddleware resolved and stored in the request context — the same
|
||||
// contract as requirePerm, so no second role read runs and no read-fault
|
||||
// error mapping exists here at all: OC-0345's 503 branch died with the query
|
||||
// it served, and OC-0379 pins the absence (a role read fault now surfaces
|
||||
// once, at the perimeter, as its 503). A request that somehow arrives without
|
||||
// the context role fails closed as unauthenticated.
|
||||
func ownerOnlyMiddleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
user, ok := r.Context().Value(adminUserKey).(*db.User)
|
||||
if !ok || user == nil {
|
||||
role, ok := r.Context().Value(adminRoleKey).(*db.Role)
|
||||
if !ok || role == nil {
|
||||
writeErr(w, http.StatusUnauthorized, "UNAUTHORIZED", "not authenticated")
|
||||
return
|
||||
}
|
||||
|
||||
role, err := database.GetRoleByID(r.Context(), user.RoleID)
|
||||
if err != nil {
|
||||
// A read fault is an outage, not a missing role: answering 403
|
||||
// would tell the Owner they lack the Owner role. Mirror the
|
||||
// perimeter's contract above — log it, report 503 (OC-0345).
|
||||
slog.ErrorContext(r.Context(), "admin: owner role lookup failed", "error", err)
|
||||
writeErr(w, http.StatusServiceUnavailable, "SERVICE_UNAVAILABLE", "authorization service temporarily unavailable")
|
||||
return
|
||||
}
|
||||
if role == nil {
|
||||
writeErr(w, http.StatusForbidden, "FORBIDDEN", "role not found")
|
||||
return
|
||||
}
|
||||
|
||||
if role.Position < permissions.OwnerRolePosition {
|
||||
writeErr(w, http.StatusForbidden, "FORBIDDEN", "owner role required")
|
||||
return
|
||||
|
||||
@@ -12,6 +12,8 @@ import (
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"testing"
|
||||
|
||||
"github.com/J3vb/OwnCord/Server/service"
|
||||
"testing/fstest"
|
||||
|
||||
"github.com/J3vb/OwnCord/Server/auth"
|
||||
@@ -138,38 +140,42 @@ CREATE TABLE IF NOT EXISTS invites (
|
||||
// ─── ownerOnlyMiddleware whitebox tests ──────────────────────────────────────
|
||||
|
||||
// TestOwnerOnlyMiddleware_NoUserInContext verifies that ownerOnlyMiddleware
|
||||
// returns 401 when there is no user stored in the request context.
|
||||
// returns 401 when the request context carries no authenticated principal —
|
||||
// simulates a call bypassing adminAuthMiddleware, which is what stores the
|
||||
// role the gate consumes.
|
||||
func TestOwnerOnlyMiddleware_NoUserInContext(t *testing.T) {
|
||||
database := openWhiteboxTestDB(t)
|
||||
|
||||
reached := false
|
||||
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
reached = true
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
|
||||
handler := ownerOnlyMiddleware(database, next)
|
||||
handler := ownerOnlyMiddleware(next)
|
||||
|
||||
// Request with NO user in context — simulates a call bypassing adminAuthMiddleware.
|
||||
req := httptest.NewRequest(http.MethodPost, "/backup", nil)
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, req)
|
||||
|
||||
if reached {
|
||||
t.Error("next handler was reached despite missing user in context")
|
||||
t.Error("next handler was reached despite an unauthenticated context")
|
||||
}
|
||||
if w.Code != http.StatusUnauthorized {
|
||||
t.Errorf("status = %d, want 401", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestOwnerOnlyMiddleware_RoleNotFound verifies that ownerOnlyMiddleware
|
||||
// returns 403 when the user's role_id does not exist in the database.
|
||||
func TestOwnerOnlyMiddleware_RoleNotFound(t *testing.T) {
|
||||
// TestOwnerOnlyMiddleware_UserWithoutRoleInContext verifies the gate fails
|
||||
// closed as 401 when the context carries a user but no role. Through the full
|
||||
// stack this cannot happen — adminAuthMiddleware stores both or refuses the
|
||||
// request (a genuinely missing role is its 401, a role read fault its 503) —
|
||||
// so a half-populated context means the perimeter did not run, and the gate
|
||||
// must treat that as unauthenticated rather than consult the database itself.
|
||||
// (The pre-OC-0379 middleware answered this shape by re-reading the role; the
|
||||
// old TestOwnerOnlyMiddleware_RoleNotFound covered that lookup's miss, a
|
||||
// branch that no longer exists.)
|
||||
func TestOwnerOnlyMiddleware_UserWithoutRoleInContext(t *testing.T) {
|
||||
database := openWhiteboxTestDB(t)
|
||||
|
||||
// Create a user initially with a valid role, then mutate role_id to a
|
||||
// nonexistent value (disabling FK checks temporarily so SQLite allows it).
|
||||
uid, err := database.CreateUser(context.Background(), "orphanuser", "$2a$12$x", 1)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateUser: %v", err)
|
||||
@@ -179,45 +185,60 @@ func TestOwnerOnlyMiddleware_RoleNotFound(t *testing.T) {
|
||||
t.Fatalf("GetUserByID: %v", err)
|
||||
}
|
||||
|
||||
// Disable FK enforcement, update role_id, re-enable.
|
||||
if _, err := database.ExecContext(context.Background(), `PRAGMA foreign_keys=OFF`); err != nil {
|
||||
t.Fatalf("disable FK: %v", err)
|
||||
}
|
||||
if _, err := database.ExecContext(context.Background(), `UPDATE users SET role_id = 9999 WHERE id = ?`, uid); err != nil {
|
||||
t.Fatalf("UPDATE role_id: %v", err)
|
||||
}
|
||||
if _, err := database.ExecContext(context.Background(), `PRAGMA foreign_keys=ON`); err != nil {
|
||||
t.Fatalf("re-enable FK: %v", err)
|
||||
}
|
||||
user.RoleID = 9999 // mirror the DB value in our in-memory struct
|
||||
|
||||
reached := false
|
||||
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
reached = true
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
|
||||
handler := ownerOnlyMiddleware(database, next)
|
||||
handler := ownerOnlyMiddleware(next)
|
||||
|
||||
// Inject user into context as adminAuthMiddleware would.
|
||||
// User injected, role deliberately absent.
|
||||
ctx := context.WithValue(context.Background(), adminUserKey, user)
|
||||
req := httptest.NewRequest(http.MethodPost, "/backup", nil).WithContext(ctx)
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, req)
|
||||
|
||||
if reached {
|
||||
t.Error("next handler was reached despite missing role")
|
||||
t.Error("next handler was reached despite no role in context")
|
||||
}
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Errorf("status = %d, want 403 (role not found)", w.Code)
|
||||
if w.Code != http.StatusUnauthorized {
|
||||
t.Errorf("status = %d, want 401 (no role in context is unauthenticated)", w.Code)
|
||||
}
|
||||
|
||||
var resp map[string]string
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if resp["error"] != "FORBIDDEN" {
|
||||
t.Errorf("error = %q, want FORBIDDEN", resp["error"])
|
||||
if resp["error"] != "UNAUTHORIZED" {
|
||||
t.Errorf("error = %q, want UNAUTHORIZED", resp["error"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestOwnerOnlyMiddleware_BelowOwnerForbidden verifies a role below the Owner
|
||||
// position is refused with 403 "owner role required". The role comes straight
|
||||
// from the context — the middleware reads nothing else, so a plain struct is
|
||||
// the whole setup.
|
||||
func TestOwnerOnlyMiddleware_BelowOwnerForbidden(t *testing.T) {
|
||||
reached := false
|
||||
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
reached = true
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
|
||||
handler := ownerOnlyMiddleware(next)
|
||||
|
||||
mod := &db.Role{ID: 2, Name: "Moderator", Position: 50}
|
||||
ctx := context.WithValue(context.Background(), adminRoleKey, mod)
|
||||
req := httptest.NewRequest(http.MethodPost, "/backup", nil).WithContext(ctx)
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, req)
|
||||
|
||||
if reached {
|
||||
t.Error("next handler was reached for a below-owner role")
|
||||
}
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Errorf("status = %d, want 403 (owner role required)", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -234,6 +255,10 @@ func TestOwnerOnlyMiddleware_OwnerPassesThrough(t *testing.T) {
|
||||
if err != nil || user == nil {
|
||||
t.Fatalf("GetUserByID: %v", err)
|
||||
}
|
||||
role, err := database.GetRoleByID(context.Background(), user.RoleID)
|
||||
if err != nil || role == nil {
|
||||
t.Fatalf("GetRoleByID: %v", err)
|
||||
}
|
||||
|
||||
reached := false
|
||||
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -241,9 +266,11 @@ func TestOwnerOnlyMiddleware_OwnerPassesThrough(t *testing.T) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
|
||||
handler := ownerOnlyMiddleware(database, next)
|
||||
handler := ownerOnlyMiddleware(next)
|
||||
|
||||
// Both keys, as adminAuthMiddleware stores them.
|
||||
ctx := context.WithValue(context.Background(), adminUserKey, user)
|
||||
ctx = context.WithValue(ctx, adminRoleKey, role)
|
||||
req := httptest.NewRequest(http.MethodPost, "/backup", nil).WithContext(ctx)
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, req)
|
||||
@@ -262,7 +289,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, nil, nil, nil, nil, nil)
|
||||
handler := NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, nil, nil, nil)
|
||||
|
||||
uid, err := database.CreateUser(context.Background(), "noroleuser", "$2a$12$x", 1)
|
||||
if err != nil {
|
||||
@@ -341,7 +368,7 @@ func TestHandleListChannels_DBError(t *testing.T) {
|
||||
// when the database query fails.
|
||||
func TestHandleGetSettings_DBError(t *testing.T) {
|
||||
database := openWhiteboxTestDB(t)
|
||||
handler := handleGetSettings(database)
|
||||
handler := handleGetSettings(service.NewSettingsService(database))
|
||||
|
||||
_ = database.Close()
|
||||
|
||||
@@ -523,16 +550,26 @@ func TestSpawnDetached_CommandConstruction(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestOwnerOnlyMiddleware_RoleLookupFailureIs503 pins OC-0345: a database
|
||||
// fault on the owner gate's role read is an outage, not a missing role, so the
|
||||
// Owner must get 503 SERVICE_UNAVAILABLE — never the 403 "role not found" a
|
||||
// genuinely absent role earns. Whitebox on purpose: through the full stack
|
||||
// adminAuthMiddleware reads the role first and would answer its own 503, so
|
||||
// the branch under test would never run.
|
||||
func TestOwnerOnlyMiddleware_RoleLookupFailureIs503(t *testing.T) {
|
||||
// TestOwnerOnlyMiddleware_RoleLookupFailureIs503 pinned OC-0345's 503 mapping
|
||||
// on the owner gate's own role read. OC-0379 removed that read entirely — the
|
||||
// gate consumes adminRoleKey and issues no query, so the branch this test
|
||||
// exercised no longer exists in any form. The 503-on-read-fault contract it
|
||||
// protected still holds where the one remaining role read lives: the
|
||||
// perimeter's default branch in adminAuthMiddleware (middleware.go), covered
|
||||
// by its own tests. TestOwnerOnlyMiddleware_NoSecondRoleLookup below is the
|
||||
// replacement pin: it renames the roles table away and requires the owner
|
||||
// path to succeed anyway.
|
||||
|
||||
// TestOwnerOnlyMiddleware_NoSecondRoleLookup pins OC-0379 (OC-0345's residue):
|
||||
// the owner gate consumes the role adminAuthMiddleware already resolved into
|
||||
// the request context and performs no role read of its own. The roles table is
|
||||
// renamed away exactly as the OC-0345 fault test did — if the middleware still
|
||||
// issues a role query, that query fails and the request cannot reach 200, so
|
||||
// this test is red for as long as the second lookup exists.
|
||||
func TestOwnerOnlyMiddleware_NoSecondRoleLookup(t *testing.T) {
|
||||
database := openWhiteboxTestDB(t)
|
||||
|
||||
uid, err := database.CreateUser(context.Background(), "ownerfault", "$2a$12$x", 1)
|
||||
uid, err := database.CreateUser(context.Background(), "ownerctx", "$2a$12$x", 1)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateUser: %v", err)
|
||||
}
|
||||
@@ -540,7 +577,11 @@ func TestOwnerOnlyMiddleware_RoleLookupFailureIs503(t *testing.T) {
|
||||
if err != nil || user == nil {
|
||||
t.Fatalf("GetUserByID: %v", err)
|
||||
}
|
||||
// Every query against roles now fails with a non-sentinel error.
|
||||
role, err := database.GetRoleByID(context.Background(), user.RoleID)
|
||||
if err != nil || role == nil {
|
||||
t.Fatalf("GetRoleByID: %v", err)
|
||||
}
|
||||
// After this, any role read fails: the only way to 200 is the context role.
|
||||
if _, err := database.ExecContext(context.Background(), `ALTER TABLE roles RENAME TO roles_gone`); err != nil {
|
||||
t.Fatalf("hide roles: %v", err)
|
||||
}
|
||||
@@ -550,24 +591,18 @@ func TestOwnerOnlyMiddleware_RoleLookupFailureIs503(t *testing.T) {
|
||||
reached = true
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
handler := ownerOnlyMiddleware(database, next)
|
||||
handler := ownerOnlyMiddleware(next)
|
||||
|
||||
ctx := context.WithValue(context.Background(), adminUserKey, user)
|
||||
ctx = context.WithValue(ctx, adminRoleKey, role)
|
||||
req := httptest.NewRequest(http.MethodPost, "/backup", nil).WithContext(ctx)
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, req)
|
||||
|
||||
if reached {
|
||||
t.Error("next handler was reached although the role could not be read")
|
||||
if !reached {
|
||||
t.Error("next handler was not reached: the owner gate performed a role lookup instead of consuming the context role")
|
||||
}
|
||||
if w.Code != http.StatusServiceUnavailable {
|
||||
t.Errorf("status = %d, want 503 (a role read fault is not a missing role)", w.Code)
|
||||
}
|
||||
var resp map[string]string
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if resp["error"] != "SERVICE_UNAVAILABLE" {
|
||||
t.Errorf("error = %q, want SERVICE_UNAVAILABLE", resp["error"])
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("status = %d, want 200 with no role read", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,7 +19,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, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
|
||||
// Create a user and session, then manually expire the session by setting
|
||||
// expires_at to a past timestamp via the exported Exec helper.
|
||||
@@ -54,7 +54,7 @@ func TestAdminAuthMiddleware_ExpiredSession(t *testing.T) {
|
||||
// access immediately, not only when the session expires.
|
||||
func TestAdminAuthMiddleware_BannedAdmin(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
if w := doRequest(t, handler, http.MethodGet, "/stats", token, nil); w.Code != http.StatusOK {
|
||||
@@ -78,7 +78,7 @@ func TestAdminAuthMiddleware_BannedAdmin(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, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
|
||||
w := doRequest(t, handler, http.MethodGet, "/stats", "", nil)
|
||||
|
||||
@@ -91,7 +91,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, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
|
||||
w := doRequest(t, handler, http.MethodGet, "/stats", "completely-invalid-token", nil)
|
||||
|
||||
|
||||
@@ -42,7 +42,7 @@ func TestAdminAuthMiddleware_DBErrorIsNotUnauthorized(t *testing.T) {
|
||||
t.Fatalf("Close: %v", err)
|
||||
}
|
||||
|
||||
handler := NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, nil, nil)
|
||||
handler := NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, nil, nil, nil)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/stats", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
|
||||
@@ -49,7 +49,7 @@ func createRoleUser(t *testing.T, database *db.DB, roleID int64, name string, pe
|
||||
func newModeratorHandler(t *testing.T) (http.Handler, *db.DB, string) {
|
||||
t.Helper()
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
_, token := createRoleUser(t, database, 10, "Moderator", moderatorMask, 60, "moduser")
|
||||
return handler, database, token
|
||||
}
|
||||
@@ -69,7 +69,7 @@ func TestPerimeter_ModeratorAdmitted(t *testing.T) {
|
||||
|
||||
func TestPerimeter_NoModerationBitsRejected(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
// MANAGE_MESSAGES alone is not a perimeter bit — it has no admin route.
|
||||
_, token := createRoleUser(t, database, 11, "Helper", permissions.ManageMessages, 50, "helperuser")
|
||||
|
||||
@@ -129,7 +129,7 @@ func TestPatchUserBan_ModeratorAllowed(t *testing.T) {
|
||||
|
||||
func TestChannelRoutes_WithoutManageChannelsForbidden(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
_, token := createRoleUser(t, database, 12, "Auditor", permissions.ViewAuditLog, 50, "auditoruser")
|
||||
|
||||
for _, tc := range []struct {
|
||||
@@ -170,7 +170,7 @@ func TestAuditAndSettings_ModeratorForbidden(t *testing.T) {
|
||||
|
||||
func TestAuditAndSettings_BitHoldersAllowed(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
_, auditToken := createRoleUser(t, database, 12, "Auditor", permissions.ViewAuditLog, 50, "auditoruser")
|
||||
_, cfgToken := createRoleUser(t, database, 13, "Configurator", permissions.ManageServer, 50, "cfguser")
|
||||
|
||||
@@ -215,7 +215,7 @@ func TestOwnerOnlyRoutes_ModeratorForbidden(t *testing.T) {
|
||||
|
||||
func TestForceLogout_RequiresKickMembers(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
_, token := createRoleUser(t, database, 14, "ChannelMod", permissions.ManageChannels, 60, "chanmoduser")
|
||||
|
||||
targetUID, _ := database.CreateUser(context.Background(), "victim", "hash", 3)
|
||||
@@ -235,7 +235,7 @@ func TestForceLogout_RequiresKickMembers(t *testing.T) {
|
||||
|
||||
func TestForceLogout_HierarchyEnforced(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
_, token := createRoleUser(t, database, 10, "Moderator", moderatorMask, 60, "moduser")
|
||||
|
||||
// Owner (role 1, position 100) outranks the moderator.
|
||||
@@ -269,7 +269,7 @@ func TestForceLogout_HierarchyEnforced(t *testing.T) {
|
||||
|
||||
func TestPatchUserRole_RequiresManageRoles(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
// The seeded Moderator mask stops at bit 19 — no MANAGE_ROLES (bit 24).
|
||||
_, token := createRoleUser(t, database, 10, "Moderator", moderatorMask, 60, "moduser")
|
||||
targetUID, _ := database.CreateUser(context.Background(), "promoteme", "hash", 3)
|
||||
@@ -286,7 +286,7 @@ func TestPatchUserRole_RequiresManageRoles(t *testing.T) {
|
||||
|
||||
func TestPatchUserRole_CannotPromoteToOwner(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
// Role 2 "Admin" (position 80) holds MANAGE_ROLES but is below Owner.
|
||||
_, token := createRoleUser(t, database, 2, "Admin", 0x3FFFFFFF, 80, "adminuser2")
|
||||
targetUID, _ := database.CreateUser(context.Background(), "wannabeowner", "hash", 3)
|
||||
@@ -304,7 +304,7 @@ func TestPatchUserRole_CannotPromoteToOwner(t *testing.T) {
|
||||
|
||||
func TestPatchUserRole_ModeratorCannotDemoteAdmin(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
// A moderator that does hold MANAGE_ROLES still cannot touch a higher rank.
|
||||
_, token := createRoleUser(t, database, 10, "Moderator", moderatorMask|permissions.ManageRoles, 60, "moduser")
|
||||
adminUID, err := database.CreateUser(context.Background(), "sitting-admin", "hash", 2)
|
||||
@@ -383,7 +383,7 @@ func TestGetMe_ReportsCallerPermissions(t *testing.T) {
|
||||
|
||||
func TestGetMe_OwnerFlagged(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
w := doRequest(t, handler, http.MethodGet, "/me", token, nil)
|
||||
|
||||
@@ -131,7 +131,7 @@ func TestApplyUpdate_Conflict409(t *testing.T) {
|
||||
t.Setenv("OWNCORD_CONTAINER", "0")
|
||||
database := openAdminTestDB(t)
|
||||
u := updater.NewUpdater("1.0.0", "", "J3vb", "OwnCord")
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", nil, u, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", nil, u, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
cases := []struct {
|
||||
@@ -165,7 +165,7 @@ func TestApplyUpdate_Conflict409(t *testing.T) {
|
||||
// POST /backups/{name}/restore refuses the same way — before any disk I/O.
|
||||
func TestRestoreBackup_Conflict409(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
admin.ForceRestartState(false) // restart pending
|
||||
@@ -190,7 +190,7 @@ func TestRestoreBackup_Conflict409(t *testing.T) {
|
||||
func TestRestore_CloseFailure_StillMarksPendingAndRequestsRestart(t *testing.T) {
|
||||
tmpDir := chdirTemp(t)
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
backupDir := filepath.Join(tmpDir, "data", "backups")
|
||||
|
||||
@@ -34,7 +34,7 @@ func TestSetup_HonoursTrustedProxies(t *testing.T) {
|
||||
cfg.Server.TrustedProxies = []string{trustedProxyAddr + "/32"}
|
||||
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil,
|
||||
newTestModService(database), newTestRoleService(database),
|
||||
newTestModService(database), newTestRoleService(database), newTestSettingsService(database),
|
||||
admin.SetupOptions{RunningCfg: cfg})
|
||||
|
||||
body, err := json.Marshal(map[string]string{
|
||||
|
||||
@@ -14,7 +14,7 @@ import (
|
||||
|
||||
func TestSetupStatus_NeedsSetup(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
|
||||
rr := doRequest(t, handler, "GET", "/setup/status", "", nil)
|
||||
if rr.Code != http.StatusOK {
|
||||
@@ -35,7 +35,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, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
|
||||
rr := doRequest(t, handler, "GET", "/setup/status", "", nil)
|
||||
if rr.Code != http.StatusOK {
|
||||
@@ -55,7 +55,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, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
|
||||
rr := doRequest(t, handler, "POST", "/setup", "", map[string]string{
|
||||
"username": "myadmin",
|
||||
@@ -108,7 +108,7 @@ func TestSetup_CreatesOwner(t *testing.T) {
|
||||
// Server/api/auth_handler_test.go (TestRegister_UsernameNotHTMLEscaped).
|
||||
func TestSetup_UsernameNotHTMLEscaped(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
|
||||
rr := doRequest(t, handler, "POST", "/setup", "", map[string]string{
|
||||
"username": "O'Brien",
|
||||
@@ -138,7 +138,7 @@ func TestSetup_UsernameNotHTMLEscaped(t *testing.T) {
|
||||
|
||||
func TestSetup_BlockedAfterFirstUser(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
|
||||
// First setup succeeds.
|
||||
rr := doRequest(t, handler, "POST", "/setup", "", map[string]string{
|
||||
@@ -161,7 +161,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, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
|
||||
rr := doRequest(t, handler, "POST", "/setup", "", map[string]string{
|
||||
"username": "admin",
|
||||
@@ -174,7 +174,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, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
|
||||
rr := doRequest(t, handler, "POST", "/setup", "", map[string]string{
|
||||
"username": "",
|
||||
@@ -189,7 +189,7 @@ func TestSetup_MissingFields(t *testing.T) {
|
||||
// server and asserts that exactly one owner is created (BUG-119).
|
||||
func TestSetup_ConcurrentRace(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
|
||||
const goroutines = 20
|
||||
results := make(chan int, goroutines)
|
||||
@@ -242,7 +242,7 @@ func TestSetup_ConcurrentRace(t *testing.T) {
|
||||
// on an empty allowlist, and a foreign origin still does not.
|
||||
func TestSetup_SameOriginAllowedWithEmptyAllowlist(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
|
||||
body, err := json.Marshal(map[string]string{"username": "owner", "password": "correct-horse"})
|
||||
if err != nil {
|
||||
@@ -264,7 +264,7 @@ func TestSetup_SameOriginAllowedWithEmptyAllowlist(t *testing.T) {
|
||||
|
||||
func TestSetup_ForeignOriginStillBlocked(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
|
||||
body, err := json.Marshal(map[string]string{"username": "owner", "password": "correct-horse"})
|
||||
if err != nil {
|
||||
|
||||
@@ -29,7 +29,7 @@ func TestSetupLimiter_ReapsStaleEntries(t *testing.T) {
|
||||
defer restoreHook()
|
||||
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
|
||||
if limiter == nil {
|
||||
t.Fatal("setup limiter was not captured — CaptureSetupLimiter hook not wired into NewAdminAPI")
|
||||
|
||||
@@ -32,7 +32,7 @@ func TestSetup_SessionCreationFailureDoesNotOrphanOwner(t *testing.T) {
|
||||
t.Fatalf("DROP TABLE sessions: %v", err)
|
||||
}
|
||||
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
|
||||
rr := doRequest(t, handler, "POST", "/setup", "", map[string]string{
|
||||
"username": "owner1",
|
||||
@@ -82,7 +82,7 @@ func TestSetup_InviteCreationFailureDoesNotOrphanOwner(t *testing.T) {
|
||||
t.Fatalf("DROP TABLE invites: %v", err)
|
||||
}
|
||||
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
|
||||
rr := doRequest(t, handler, "POST", "/setup", "", map[string]string{
|
||||
"username": "owner2",
|
||||
|
||||
@@ -40,7 +40,7 @@ func wizardRunningCfg() *config.Config {
|
||||
func wizardHandler(t *testing.T, database *db.DB, cfgPath string, restarted chan string) http.Handler {
|
||||
t.Helper()
|
||||
t.Cleanup(admin.ResetRestartState)
|
||||
return admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database),
|
||||
return admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database),
|
||||
admin.SetupOptions{
|
||||
ConfigPath: cfgPath,
|
||||
RunningCfg: wizardRunningCfg(),
|
||||
|
||||
@@ -26,22 +26,6 @@ const (
|
||||
adminTokenHashKey
|
||||
)
|
||||
|
||||
// ─── Allowed settings keys ────────────────────────────────────────────────────
|
||||
|
||||
// allowedSettingKeys is the whitelist of keys that may be written via
|
||||
// PATCH /admin/api/settings. Derived from the settings table in SCHEMA.md.
|
||||
var allowedSettingKeys = map[string]struct{}{
|
||||
"server_name": {},
|
||||
"server_icon": {},
|
||||
"motd": {},
|
||||
"max_upload_bytes": {},
|
||||
"voice_quality": {},
|
||||
"require_2fa": {},
|
||||
"registration_open": {},
|
||||
"backup_schedule": {},
|
||||
"backup_retention": {},
|
||||
}
|
||||
|
||||
// ─── HubBroadcaster ──────────────────────────────────────────────────────────
|
||||
|
||||
// HubBroadcaster is the subset of ws.Hub needed by the admin package.
|
||||
|
||||
@@ -39,7 +39,7 @@ func TestAdminAPI_CheckUpdate_OK(t *testing.T) {
|
||||
u.SetBaseURL(mockGH.URL)
|
||||
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", nil, u, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", nil, u, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
w := doRequest(t, handler, http.MethodGet, "/updates", token, nil)
|
||||
@@ -78,7 +78,7 @@ func TestAdminAPI_CheckUpdate_IncompleteReleaseNotInstallable(t *testing.T) {
|
||||
u.SetBaseURL(mockGH.URL)
|
||||
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", nil, u, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", nil, u, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
w := doRequest(t, handler, http.MethodGet, "/updates", token, nil)
|
||||
@@ -111,7 +111,7 @@ func TestAdminAPI_CheckUpdate_UpToDate(t *testing.T) {
|
||||
u.SetBaseURL(mockGH.URL)
|
||||
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", nil, u, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", nil, u, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
w := doRequest(t, handler, http.MethodGet, "/updates", token, nil)
|
||||
@@ -128,7 +128,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, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
|
||||
w := doRequest(t, handler, http.MethodGet, "/updates", "", nil)
|
||||
if w.Code != http.StatusUnauthorized {
|
||||
@@ -138,7 +138,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, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
|
||||
// Create admin user (not owner - role 2)
|
||||
adminUID, _ := database.CreateUser(context.Background(), "adminonly2", "hash", 2)
|
||||
@@ -158,7 +158,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, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
w := doRequest(t, handler, http.MethodPost, "/updates/apply", token, nil)
|
||||
@@ -171,7 +171,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, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
w := doRequest(t, handler, http.MethodPost, "/updates/apply", token, nil)
|
||||
@@ -203,7 +203,7 @@ func TestAdminAPI_ApplyUpdate_NoUpdateAvailable(t *testing.T) {
|
||||
u.SetBaseURL(mockGH.URL)
|
||||
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", nil, u, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", nil, u, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
w := doRequest(t, handler, http.MethodPost, "/updates/apply", token, nil)
|
||||
@@ -234,7 +234,7 @@ func TestAdminAPI_ApplyUpdate_CheckFails(t *testing.T) {
|
||||
u.SetBaseURL(mockGH.URL)
|
||||
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", nil, u, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", nil, u, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
w := doRequest(t, handler, http.MethodPost, "/updates/apply", token, nil)
|
||||
@@ -262,7 +262,7 @@ func TestAdminAPI_ApplyUpdate_MissingAssets(t *testing.T) {
|
||||
u.SetBaseURL(mockGH.URL)
|
||||
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", nil, u, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", nil, u, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
w := doRequest(t, handler, http.MethodPost, "/updates/apply", token, nil)
|
||||
@@ -283,7 +283,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, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
|
||||
w := doRequest(t, handler, http.MethodPost, "/updates/apply", "", nil)
|
||||
if w.Code != http.StatusUnauthorized {
|
||||
@@ -350,7 +350,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, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", nil, u, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
w := doRequest(t, handler, http.MethodPost, "/updates/apply", token, nil)
|
||||
@@ -371,7 +371,7 @@ func TestAdminAPI_ApplyUpdate_DownloadFails(t *testing.T) {
|
||||
func TestAdminAPI_ApplyUpdate_RefusedInContainer(t *testing.T) {
|
||||
t.Setenv("OWNCORD_CONTAINER", "1")
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
w := doRequest(t, handler, http.MethodPost, "/updates/apply", token, nil)
|
||||
@@ -393,7 +393,7 @@ func TestAdminAPI_ApplyUpdate_RefusedInContainer(t *testing.T) {
|
||||
func TestAdminAPI_ApplyUpdate_ContainerOptOut(t *testing.T) {
|
||||
t.Setenv("OWNCORD_CONTAINER", "0")
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database), newTestSettingsService(database))
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
w := doRequest(t, handler, http.MethodPost, "/updates/apply", token, nil)
|
||||
|
||||
@@ -56,7 +56,10 @@ func fullRouter(t *testing.T) http.Handler {
|
||||
GIF: config.GIFConfig{APIKey: "absence-test"},
|
||||
}
|
||||
|
||||
rt := app.StartRuntime(cfg, database, nil)
|
||||
rt, rtErr := app.StartRuntime(cfg, database, nil)
|
||||
if rtErr != nil {
|
||||
t.Fatalf("app.StartRuntime: %v", rtErr)
|
||||
}
|
||||
handler, cleanup := api.NewRouter(cfg, database, "test", nil, nil, rt)
|
||||
t.Cleanup(cleanup)
|
||||
return handler
|
||||
|
||||
@@ -36,7 +36,10 @@ func setupDiagnosticsRouter(t *testing.T) (http.Handler, string, *db.DB) {
|
||||
},
|
||||
}
|
||||
|
||||
rt := app.StartRuntime(cfg, database, nil)
|
||||
rt, rtErr := app.StartRuntime(cfg, database, nil)
|
||||
if rtErr != nil {
|
||||
t.Fatalf("app.StartRuntime: %v", rtErr)
|
||||
}
|
||||
handler, cleanup := api.NewRouter(cfg, database, "1.0.0-test", nil, nil, rt)
|
||||
t.Cleanup(cleanup)
|
||||
|
||||
@@ -108,7 +111,10 @@ func TestDiagnosticsConnectivity_HonoursTrustedProxies(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
rt := app.StartRuntime(cfg, database, nil)
|
||||
rt, rtErr := app.StartRuntime(cfg, database, nil)
|
||||
if rtErr != nil {
|
||||
t.Fatalf("app.StartRuntime: %v", rtErr)
|
||||
}
|
||||
handler, cleanup := api.NewRouter(cfg, database, "1.0.0-test", nil, nil, rt)
|
||||
t.Cleanup(cleanup)
|
||||
|
||||
|
||||
@@ -15,7 +15,10 @@ import (
|
||||
"google.golang.org/protobuf/proto"
|
||||
|
||||
"github.com/J3vb/OwnCord/Server/api"
|
||||
"github.com/J3vb/OwnCord/Server/auth"
|
||||
"github.com/J3vb/OwnCord/Server/config"
|
||||
"github.com/J3vb/OwnCord/Server/db"
|
||||
"github.com/J3vb/OwnCord/Server/service"
|
||||
"github.com/J3vb/OwnCord/Server/ws"
|
||||
)
|
||||
|
||||
@@ -238,7 +241,6 @@ func hubWithLiveKit(t *testing.T, status int) *ws.Hub {
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
|
||||
hub := ws.NewHub(nil, nil, nil)
|
||||
lk, err := ws.NewLiveKitClient(&config.VoiceConfig{
|
||||
LiveKitAPIKey: "testkeytestkeytest",
|
||||
LiveKitAPISecret: "testsecrettestsecrettestsecret",
|
||||
@@ -247,7 +249,26 @@ func hubWithLiveKit(t *testing.T, status int) *ws.Hub {
|
||||
if err != nil {
|
||||
t.Fatalf("NewLiveKitClient: %v", err)
|
||||
}
|
||||
hub.SetLiveKit(lk)
|
||||
hub := newBareHub(t, lk)
|
||||
return hub
|
||||
}
|
||||
|
||||
// newBareHub builds the smallest hub NewHub now accepts: B3-4 made DB and
|
||||
// Limiter required, so the pre-B3-4 ws.NewHub(nil, nil, nil) fixture — the
|
||||
// poster child of construction succeeding with nothing wired — is illegal by
|
||||
// design. An unmigrated in-memory database is enough: construction only
|
||||
// best-effort-reads the settings cache.
|
||||
func newBareHub(t *testing.T, lk *ws.LiveKitClient) *ws.Hub {
|
||||
t.Helper()
|
||||
database, err := db.Open(":memory:")
|
||||
if err != nil {
|
||||
t.Fatalf("db.Open: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = database.Close() })
|
||||
hub, err := ws.NewHub(ws.HubOptions{DB: database, Limiter: auth.NewRateLimiter(), LiveKit: lk, Settings: service.NewSettingsService(database)})
|
||||
if err != nil {
|
||||
t.Fatalf("ws.NewHub: %v", err)
|
||||
}
|
||||
return hub
|
||||
}
|
||||
|
||||
@@ -303,7 +324,7 @@ func TestHandleLiveKitHealth_Degraded(t *testing.T) {
|
||||
|
||||
func TestHandleLiveKitHealth_NotConfigured(t *testing.T) {
|
||||
// A hub with no LiveKit client at all — the common case when voice is off.
|
||||
handler := api.LiveKitHealthHandlerForTest(ws.NewHub(nil, nil, nil))
|
||||
handler := api.LiveKitHealthHandlerForTest(newBareHub(t, nil))
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
handler(rr, httptest.NewRequest(http.MethodGet, "/api/v1/livekit/health", nil))
|
||||
|
||||
@@ -187,7 +187,7 @@ func NewRouter(cfg *config.Config, database *db.DB, ver string, logBuf *admin.Ri
|
||||
// Admin panel: static files + REST API (Phase 6).
|
||||
// Restrict /admin to configured CIDRs (default: private networks only).
|
||||
u := updater.NewUpdater(ver, cfg.GitHub.Token, cfg.GitHub.Owner, cfg.GitHub.Repo)
|
||||
adminHandler := admin.NewHandler(database, ver, hub, u, logBuf, cfg.Server.AllowedOrigins, svc.Permissions, svc.Moderation, svc.Roles,
|
||||
adminHandler := admin.NewHandler(database, ver, hub, u, logBuf, cfg.Server.AllowedOrigins, svc.Permissions, svc.Moderation, svc.Roles, svc.Settings,
|
||||
admin.SetupOptions{ConfigPath: config.DefaultPath, RunningCfg: cfg})
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(AdminIPRestrict(cfg.Server.AdminAllowedCIDRs, cfg.Server.TrustedProxies))
|
||||
|
||||
@@ -98,7 +98,10 @@ func TestNewRouter_DeleteAccount_BroadcastsMemberBanOverWS(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
rt := app.StartRuntime(cfg, database, nil)
|
||||
rt, rtErr := app.StartRuntime(cfg, database, nil)
|
||||
if rtErr != nil {
|
||||
t.Fatalf("app.StartRuntime: %v", rtErr)
|
||||
}
|
||||
handler, cleanup := api.NewRouter(cfg, database, "test", nil, nil, rt)
|
||||
t.Cleanup(cleanup)
|
||||
|
||||
|
||||
@@ -73,7 +73,10 @@ func TestNewRouter_LiveKitProcessStartFailure_VoiceJoinFailsClosed(t *testing.T)
|
||||
},
|
||||
}
|
||||
|
||||
rt := app.StartRuntime(cfg, database, nil)
|
||||
rt, rtErr := app.StartRuntime(cfg, database, nil)
|
||||
if rtErr != nil {
|
||||
t.Fatalf("app.StartRuntime: %v", rtErr)
|
||||
}
|
||||
handler, cleanup := api.NewRouter(cfg, database, "test", nil, nil, rt)
|
||||
t.Cleanup(cleanup)
|
||||
|
||||
|
||||
@@ -33,7 +33,10 @@ func setupRouter(t *testing.T) http.Handler {
|
||||
},
|
||||
}
|
||||
|
||||
rt := app.StartRuntime(cfg, database, nil)
|
||||
rt, rtErr := app.StartRuntime(cfg, database, nil)
|
||||
if rtErr != nil {
|
||||
t.Fatalf("app.StartRuntime: %v", rtErr)
|
||||
}
|
||||
handler, cleanup := api.NewRouter(cfg, database, "test", nil, nil, rt)
|
||||
t.Cleanup(cleanup)
|
||||
return handler
|
||||
|
||||
@@ -43,6 +43,12 @@ func TestNewRouterRefusesToStartWithMalformedTOTPKey(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
rt, rtErr := app.StartRuntime(cfg, database, nil)
|
||||
if rtErr != nil {
|
||||
t.Fatalf("app.StartRuntime: %v", rtErr)
|
||||
}
|
||||
defer rt.Hub.GracefulStop()
|
||||
|
||||
panicked := false
|
||||
func() {
|
||||
defer func() {
|
||||
@@ -50,7 +56,7 @@ func TestNewRouterRefusesToStartWithMalformedTOTPKey(t *testing.T) {
|
||||
panicked = true
|
||||
}
|
||||
}()
|
||||
api.NewRouter(cfg, database, "test", nil, nil, app.StartRuntime(cfg, database, nil))
|
||||
api.NewRouter(cfg, database, "test", nil, nil, rt)
|
||||
}()
|
||||
|
||||
if !panicked {
|
||||
|
||||
@@ -256,7 +256,10 @@ func genRoutes(w io.Writer) error {
|
||||
|
||||
// internal/app owns hub construction since B3-3, so the route index is
|
||||
// generated over the same collaborators the server runs with.
|
||||
rt := app.StartRuntime(cfg, database, nil)
|
||||
rt, err := app.StartRuntime(cfg, database, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("building runtime for the route walk: %w", err)
|
||||
}
|
||||
defer rt.Hub.GracefulStop()
|
||||
handler, cleanup := api.NewRouter(cfg, database, "gendocs", nil, nil, rt)
|
||||
defer cleanup()
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"auth": 90.8,
|
||||
"db": 79.3,
|
||||
"permissions": 100.0,
|
||||
"service": 67.8,
|
||||
"service": 69.2,
|
||||
"ws": 86.7
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,8 +5,10 @@ import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"maps"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/J3vb/OwnCord/Server/db/dbgen"
|
||||
@@ -323,6 +325,30 @@ func (d *DB) SetSetting(ctx context.Context, key, value string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// ApplySettings upserts every key→value pair in one transaction, so a
|
||||
// mid-loop failure leaves no partial update behind. Keys are applied in
|
||||
// sorted order for deterministic behaviour under test.
|
||||
func (d *DB) ApplySettings(ctx context.Context, updates map[string]string) error {
|
||||
if len(updates) == 0 {
|
||||
return nil
|
||||
}
|
||||
tx, err := d.writer.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("ApplySettings: begin: %w", err)
|
||||
}
|
||||
q := d.q.WithTx(tx)
|
||||
for _, key := range slices.Sorted(maps.Keys(updates)) {
|
||||
if err := q.SetSetting(ctx, dbgen.SetSettingParams{Key: key, Value: updates[key]}); err != nil {
|
||||
_ = tx.Rollback()
|
||||
return fmt.Errorf("ApplySettings: %s: %w", key, err)
|
||||
}
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return fmt.Errorf("ApplySettings: commit: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetAllSettings returns all settings as a key→value map.
|
||||
func (d *DB) GetAllSettings(ctx context.Context) (map[string]string, error) {
|
||||
rows, err := d.q.GetAllSettings(ctx)
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
package db_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// ApplySettings targets the real settings table, so these tests use the
|
||||
// package's full-migration opener (migrated_db_test.go), not the minimal
|
||||
// shared testSchema fixture.
|
||||
|
||||
func TestApplySettings_AppliesEveryKey(t *testing.T) {
|
||||
database := newMigratedTestDB(t)
|
||||
if err := database.ApplySettings(context.Background(), map[string]string{
|
||||
"server_name": "Applied",
|
||||
"motd": "Hello",
|
||||
}); err != nil {
|
||||
t.Fatalf("ApplySettings: %v", err)
|
||||
}
|
||||
for key, want := range map[string]string{"server_name": "Applied", "motd": "Hello"} {
|
||||
got, err := database.GetSetting(context.Background(), key)
|
||||
if err != nil || got != want {
|
||||
t.Fatalf("GetSetting(%s) = %q, %v; want %q", key, got, err, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplySettings_EmptyMapIsNoOp(t *testing.T) {
|
||||
database := newMigratedTestDB(t)
|
||||
if err := database.ApplySettings(context.Background(), nil); err != nil {
|
||||
t.Fatalf("ApplySettings(nil): %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplySettings_RollsBackOnFailure(t *testing.T) {
|
||||
database := newMigratedTestDB(t)
|
||||
// Sabotage the table so the in-transaction upsert fails after Begin
|
||||
// succeeded — the rollback path must surface the error, and a later
|
||||
// repair must find no half-applied state (the transaction is the unit).
|
||||
if _, err := database.ExecContext(context.Background(), "DROP TABLE settings"); err != nil {
|
||||
t.Fatalf("DROP TABLE settings: %v", err)
|
||||
}
|
||||
if err := database.ApplySettings(context.Background(), map[string]string{
|
||||
"server_name": "never",
|
||||
}); err == nil {
|
||||
t.Fatal("ApplySettings against a dropped table must error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplySettings_BeginFailsOnClosedDB(t *testing.T) {
|
||||
database := newMigratedTestDB(t)
|
||||
_ = database.Close()
|
||||
if err := database.ApplySettings(context.Background(), map[string]string{
|
||||
"server_name": "never",
|
||||
}); err == nil {
|
||||
t.Fatal("ApplySettings on a closed database must error")
|
||||
}
|
||||
}
|
||||
+64
-54
@@ -1,6 +1,7 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/url"
|
||||
|
||||
@@ -13,16 +14,18 @@ import (
|
||||
"github.com/J3vb/OwnCord/Server/ws"
|
||||
)
|
||||
|
||||
// StartRuntime builds the collaborators the hub and the router share, applies
|
||||
// every pre-Run hub setter, and starts the hub's dispatch goroutine.
|
||||
// StartRuntime builds the collaborators the hub and the router share,
|
||||
// constructs the hub with everything it must hold before Run (B3-4:
|
||||
// HubOptions replaced the pre-Run setters), and starts the dispatch
|
||||
// goroutine.
|
||||
//
|
||||
// Before B3-3 this lived inside api.NewRouter (the ws.NewHub call at
|
||||
// router.go:106 and the plugin and LiveKit setters at :325-360), while
|
||||
// main.go set the event persister and the event store after NewRouter
|
||||
// returned — two owners of one hub, with nothing checking that the required
|
||||
// collaborators were present before Run started. There is one owner now, and
|
||||
// one place B3-4 has to change when the required setters become validated
|
||||
// constructor options.
|
||||
// Before B3-3 this lived inside api.NewRouter, while main.go set the event
|
||||
// persister and store after NewRouter returned — two owners of one hub, with
|
||||
// nothing checking that the required collaborators were present before Run
|
||||
// started. B3-3 collapsed the owners to one; B3-4 moved the pre-Run wiring
|
||||
// into ws.NewHub itself, which now refuses to construct without its required
|
||||
// collaborators, so an incomplete hub is a startup error here rather than a
|
||||
// later panic.
|
||||
//
|
||||
// The limiter and the service layer are built here rather than in the router
|
||||
// because the hub needs the SAME instances: the limiter persists auth
|
||||
@@ -32,75 +35,82 @@ import (
|
||||
// It starts the hub, so every caller must stop it — App.Close does, through
|
||||
// the "hub" close step; api's tests rely on the goleak ignore for
|
||||
// ws.(*Hub).Run.func1 exactly as they did when NewRouter started it.
|
||||
func StartRuntime(cfg *config.Config, database *db.DB, pluginRegistry *plugin.Registry) api.Runtime {
|
||||
func StartRuntime(cfg *config.Config, database *db.DB, pluginRegistry *plugin.Registry) (api.Runtime, error) {
|
||||
// Lockouts are persisted to the database so they survive restarts (M2).
|
||||
limiter := auth.NewPersistentRateLimiter(database)
|
||||
// Service layer — centralises business logic for REST and WS handlers.
|
||||
// *db.DB satisfies service.Store directly.
|
||||
svc := service.New(database, limiter)
|
||||
|
||||
lk, proc, voiceEnabled := buildVoice(cfg)
|
||||
|
||||
// WebSocket hub — WS does its own in-band auth, so no AuthMiddleware.
|
||||
hub := ws.NewHub(database, limiter, svc)
|
||||
// Replay budget knobs must land before hub.Run starts (below).
|
||||
hub.ConfigureReplay(cfg.EventPersistence.ReplayRingSize, cfg.EventPersistence.ReplayColdLimit)
|
||||
hub, err := ws.NewHub(ws.HubOptions{
|
||||
DB: database,
|
||||
Limiter: limiter,
|
||||
Services: svc,
|
||||
Settings: svc.Settings,
|
||||
// nil pluginRegistry means plugins are disabled; the hub no-ops.
|
||||
PluginRegistry: pluginRegistry,
|
||||
LiveKit: lk,
|
||||
LiveKitProcess: proc,
|
||||
// Replay budget knobs land at construction — the dispatch loop
|
||||
// reads the ring unlocked.
|
||||
ReplayRingSize: cfg.EventPersistence.ReplayRingSize,
|
||||
ReplayColdLimit: cfg.EventPersistence.ReplayColdLimit,
|
||||
})
|
||||
if err != nil {
|
||||
return api.Runtime{}, fmt.Errorf("app: building hub: %w", err)
|
||||
}
|
||||
|
||||
wirePlugins(hub, pluginRegistry)
|
||||
voiceEnabled := startVoice(cfg, hub)
|
||||
|
||||
go hub.Run()
|
||||
|
||||
return api.Runtime{Hub: hub, Limiter: limiter, Services: svc, VoiceEnabled: voiceEnabled}
|
||||
}
|
||||
|
||||
// wirePlugins wires the plugin registry and its event sink into the hub.
|
||||
// Moved from api.routerPluginWiring.
|
||||
func wirePlugins(hub *ws.Hub, pluginRegistry *plugin.Registry) {
|
||||
// Phase C Step 9 — wire plugin registry and event sink into the hub.
|
||||
// nil pluginRegistry means plugins are disabled; the hub no-ops cleanly.
|
||||
// The plugin event sink consumes the built hub's broadcaster, so it is
|
||||
// the surviving two-phase wire (moved from api.routerPluginWiring).
|
||||
if pluginRegistry != nil {
|
||||
hub.SetPluginRegistry(pluginRegistry)
|
||||
sink := pluginRegistry.Sink()
|
||||
sink.SetBroadcaster(hub.BroadcastToChannel)
|
||||
hub.SetPluginEventSink(sink)
|
||||
}
|
||||
|
||||
// Start the supervised LiveKit process only once the hub holds it
|
||||
// (OC-0019): the voice_join guard must be able to fail closed via
|
||||
// IsRunning() == false the moment Start fails, never see a half-wired
|
||||
// hub with a running process it does not know about.
|
||||
if proc != nil {
|
||||
if startErr := proc.Start(); startErr != nil {
|
||||
slog.Error("failed to start LiveKit process", "error", startErr)
|
||||
}
|
||||
}
|
||||
|
||||
go hub.Run()
|
||||
|
||||
return api.Runtime{Hub: hub, Limiter: limiter, Services: svc, VoiceEnabled: voiceEnabled}, nil
|
||||
}
|
||||
|
||||
// startVoice creates the LiveKit client and, when OwnCord manages the
|
||||
// companion process, starts it — the construction half of what
|
||||
// api.routerVoiceRoutes did before B3-3. It reports whether voice is
|
||||
// buildVoice creates the LiveKit client and, when OwnCord manages the
|
||||
// companion process, the process manager — construction only; StartRuntime
|
||||
// starts the process after the hub holds it. It reports whether voice is
|
||||
// configured; the webhook, LiveKit health and signalling-proxy routes are
|
||||
// still mounted by the router, on exactly that condition (the `lkErr == nil`
|
||||
// still mounted by the router on exactly that condition (the `lkErr == nil`
|
||||
// guard, now api.Runtime.VoiceEnabled).
|
||||
func startVoice(cfg *config.Config, hub *ws.Hub) bool {
|
||||
func buildVoice(cfg *config.Config) (*ws.LiveKitClient, *ws.LiveKitProcess, bool) {
|
||||
// Create LiveKit client if voice config is present; voice is disabled on failure.
|
||||
lk, lkErr := ws.NewLiveKitClient(&cfg.Voice)
|
||||
if lkErr != nil {
|
||||
slog.Warn("failed to create LiveKit client, voice disabled", "error", lkErr)
|
||||
return false
|
||||
return nil, nil, false
|
||||
}
|
||||
hub.SetLiveKit(lk)
|
||||
|
||||
// Optionally start a companion LiveKit process — either from a
|
||||
// Optionally build a companion LiveKit process — either from a
|
||||
// configured binary or via checksum-verified auto-download (the
|
||||
// download happens in the background inside Start).
|
||||
// download happens in the background inside Start). The hub keeps the
|
||||
// process even if Start() later fails (OC-0019): its only hub consumer
|
||||
// is the voice_join guard (`h.lkProcess != nil && !h.lkProcess.IsRunning()`),
|
||||
// which reads a nil process as "LiveKit is externally managed, don't
|
||||
// check". OwnCord being told to manage LiveKit and failing to launch it
|
||||
// must fail joins closed via IsRunning() == false, not wave them
|
||||
// through with no SFU running.
|
||||
if cfg.Voice.LiveKitBinaryPath != "" || cfg.Voice.AutoDownloadLiveKit {
|
||||
proc := ws.NewLiveKitProcess(&cfg.Voice, &cfg.TLS, cfg.Server.DataDir)
|
||||
// Register the process with the hub BEFORE calling Start(), and
|
||||
// keep it registered even if Start() fails (OC-0019). The only
|
||||
// consumer of h.lkProcess is the voice_join guard
|
||||
// (`h.lkProcess != nil && !h.lkProcess.IsRunning()`), which reads
|
||||
// a nil process as "LiveKit is externally managed, don't check".
|
||||
// That is the wrong reading here: OwnCord was told to manage
|
||||
// LiveKit and failed to launch it, so joins must fail closed via
|
||||
// IsRunning() == false, not be waved through with no SFU
|
||||
// running. IsRunning() is false for a proc whose Start() never
|
||||
// got as far as spawning cmd, and Hub.Stop's lkProcess.Stop() is
|
||||
// safe to call on a never-started proc.
|
||||
hub.SetLiveKitProcess(proc)
|
||||
if startErr := proc.Start(); startErr != nil {
|
||||
slog.Error("failed to start LiveKit process", "error", startErr)
|
||||
}
|
||||
return true
|
||||
return lk, ws.NewLiveKitProcess(&cfg.Voice, &cfg.TLS, cfg.Server.DataDir), true
|
||||
}
|
||||
|
||||
// Warn if LiveKit is externally managed and webhook may be blocked by admin CIDRs.
|
||||
@@ -113,5 +123,5 @@ func startVoice(cfg *config.Config, hub *ws.Hub) bool {
|
||||
"add the LiveKit server's IP to livekit_webhook_allowed_cidrs or webhooks will be silently dropped",
|
||||
"livekit_host", lkHost)
|
||||
}
|
||||
return true
|
||||
return lk, nil, true
|
||||
}
|
||||
|
||||
@@ -198,9 +198,11 @@ func (a *App) startPlugins() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// startHub builds the hub and the collaborators it shares with the router,
|
||||
// applies every pre-Run setter and starts the dispatch goroutine — B3-3 moved
|
||||
// all of that out of api.NewRouter so the hub has exactly one owner.
|
||||
// startHub builds the hub and the collaborators it shares with the router
|
||||
// and starts the dispatch goroutine — B3-3 moved all of that out of
|
||||
// api.NewRouter so the hub has exactly one owner, and B3-4 moved the pre-Run
|
||||
// wiring into ws.HubOptions, so an incomplete hub fails this start step
|
||||
// instead of panicking later.
|
||||
//
|
||||
// Its close step is GracefulStopContext, the only caller of
|
||||
// LiveKitProcess.Stop and what closes the dispatch goroutine. gracefulOnce
|
||||
@@ -208,7 +210,11 @@ func (a *App) startPlugins() error {
|
||||
// path, so it is reached on every return from Run and a supervised
|
||||
// livekit-server process is never orphaned (OC-0027).
|
||||
func (a *App) startHub() error {
|
||||
a.runtime = StartRuntime(a.cfg, a.database, a.plugins)
|
||||
rt, err := StartRuntime(a.cfg, a.database, a.plugins)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
a.runtime = rt
|
||||
a.hub = a.runtime.Hub
|
||||
a.onClose("hub", func(ctx context.Context) error {
|
||||
a.runtime.Hub.GracefulStopContext(ctx)
|
||||
@@ -260,7 +266,7 @@ func (a *App) startAuditWriter() error {
|
||||
// in-flight tick (which can hold the writer — scheduled backups run VACUUM
|
||||
// INTO) is not still using the database when the handle closes.
|
||||
func (a *App) startMaintenance() error {
|
||||
stop := startMaintenanceLoop(a.bgCtx, a.log, a.cfg, a.database)
|
||||
stop := startMaintenanceLoop(a.bgCtx, a.log, a.cfg, a.database, a.runtime.Services.Settings)
|
||||
a.onClose("maintenance", func(context.Context) error {
|
||||
stop()
|
||||
return nil
|
||||
|
||||
@@ -18,6 +18,7 @@ import (
|
||||
"github.com/J3vb/OwnCord/Server/auth"
|
||||
"github.com/J3vb/OwnCord/Server/config"
|
||||
"github.com/J3vb/OwnCord/Server/db"
|
||||
"github.com/J3vb/OwnCord/Server/service"
|
||||
"github.com/J3vb/OwnCord/Server/ws"
|
||||
)
|
||||
|
||||
@@ -115,7 +116,10 @@ func TestSeedHubReplayState_ForcesFullResyncForOfflineClient(t *testing.T) {
|
||||
}
|
||||
|
||||
limiter := auth.NewRateLimiter()
|
||||
hub := ws.NewHub(database, limiter, nil)
|
||||
hub, hubErr := ws.NewHub(ws.HubOptions{DB: database, Limiter: limiter, Settings: service.NewSettingsService(database)})
|
||||
if hubErr != nil {
|
||||
t.Fatalf("ws.NewHub: %v", hubErr)
|
||||
}
|
||||
go hub.Run()
|
||||
defer hub.Stop()
|
||||
|
||||
@@ -260,7 +264,10 @@ func TestRunStartEventPersistence_DisabledMode_StaleLastSeqForcesFullResync(t *t
|
||||
// last_seq ends up as whatever the 40th broadcast's real seq turns out to
|
||||
// be — captured dynamically so this test holds regardless of what value
|
||||
// scheme is in effect (raw 1..N pre-fix, or a seeded floor post-fix). ---
|
||||
hubOld := ws.NewHub(database, limiter, nil)
|
||||
hubOld, hubErr := ws.NewHub(ws.HubOptions{DB: database, Limiter: limiter, Settings: service.NewSettingsService(database)})
|
||||
if hubErr != nil {
|
||||
t.Fatalf("ws.NewHub: %v", hubErr)
|
||||
}
|
||||
go hubOld.Run()
|
||||
if persister, prunerDone := startEventPersister(ctx, log, cfg, hubOld, database); persister != nil || prunerDone != nil {
|
||||
t.Fatalf("startEventPersister with Enabled=false: want (nil, nil), got (%v, %v)", persister, prunerDone)
|
||||
@@ -275,7 +282,10 @@ func TestRunStartEventPersistence_DisabledMode_StaleLastSeqForcesFullResync(t *t
|
||||
|
||||
// --- Restart: hub B is a brand-new process-equivalent hub, same disabled
|
||||
// config, same (in-memory but never touched by persistence) database. ---
|
||||
hubNew := ws.NewHub(database, limiter, nil)
|
||||
hubNew, hubErr := ws.NewHub(ws.HubOptions{DB: database, Limiter: limiter, Settings: service.NewSettingsService(database)})
|
||||
if hubErr != nil {
|
||||
t.Fatalf("ws.NewHub: %v", hubErr)
|
||||
}
|
||||
go hubNew.Run()
|
||||
defer hubNew.Stop()
|
||||
if persister, prunerDone := startEventPersister(ctx, log, cfg, hubNew, database); persister != nil || prunerDone != nil {
|
||||
|
||||
@@ -8,12 +8,13 @@ import (
|
||||
"github.com/J3vb/OwnCord/Server/admin"
|
||||
"github.com/J3vb/OwnCord/Server/config"
|
||||
"github.com/J3vb/OwnCord/Server/db"
|
||||
"github.com/J3vb/OwnCord/Server/service"
|
||||
"github.com/J3vb/OwnCord/Server/storage"
|
||||
)
|
||||
|
||||
// startMaintenanceLoop starts the periodic maintenance loop and returns the
|
||||
// stop step the maintenance stage registers with App.Close.
|
||||
func startMaintenanceLoop(bgCtx context.Context, log *slog.Logger, cfg *config.Config, database *db.DB) func() {
|
||||
func startMaintenanceLoop(bgCtx context.Context, log *slog.Logger, cfg *config.Config, database *db.DB, settings *service.SettingsService) func() {
|
||||
// Periodically purge expired sessions and orphaned attachments.
|
||||
fileStorage, fileStorageErr := storage.New(cfg.Upload.StorageDir, cfg.Upload.MaxSizeMB)
|
||||
if fileStorageErr != nil {
|
||||
@@ -22,7 +23,7 @@ func startMaintenanceLoop(bgCtx context.Context, log *slog.Logger, cfg *config.C
|
||||
|
||||
stopMaintenance := make(chan struct{})
|
||||
maintenanceDone := make(chan struct{})
|
||||
go maintenanceLoop(bgCtx, log, database, fileStorage, stopMaintenance, maintenanceDone)
|
||||
go maintenanceLoop(bgCtx, log, database, fileStorage, settings, stopMaintenance, maintenanceDone)
|
||||
|
||||
return func() {
|
||||
// Backstop for early returns below (see hub.GracefulStop defer above),
|
||||
@@ -40,7 +41,7 @@ func startMaintenanceLoop(bgCtx context.Context, log *slog.Logger, cfg *config.C
|
||||
|
||||
// maintenanceLoop is the periodic maintenance goroutine started by
|
||||
// startMaintenanceLoop.
|
||||
func maintenanceLoop(bgCtx context.Context, log *slog.Logger, database *db.DB, fileStorage *storage.Storage, stopMaintenance, maintenanceDone chan struct{}) {
|
||||
func maintenanceLoop(bgCtx context.Context, log *slog.Logger, database *db.DB, fileStorage *storage.Storage, settings *service.SettingsService, stopMaintenance, maintenanceDone chan struct{}) {
|
||||
defer close(maintenanceDone)
|
||||
ticker := time.NewTicker(15 * time.Minute)
|
||||
defer ticker.Stop()
|
||||
@@ -57,7 +58,7 @@ func maintenanceLoop(bgCtx context.Context, log *slog.Logger, database *db.DB, f
|
||||
continue
|
||||
}
|
||||
|
||||
if maintenanceTick(bgCtx, log, database, fileStorage) {
|
||||
if maintenanceTick(bgCtx, log, database, fileStorage, settings) {
|
||||
consecutiveFailures++
|
||||
} else {
|
||||
consecutiveFailures = 0
|
||||
@@ -70,7 +71,7 @@ func maintenanceLoop(bgCtx context.Context, log *slog.Logger, database *db.DB, f
|
||||
|
||||
// maintenanceTick runs one maintenance pass and reports whether any step
|
||||
// of it failed.
|
||||
func maintenanceTick(bgCtx context.Context, log *slog.Logger, database *db.DB, fileStorage *storage.Storage) bool {
|
||||
func maintenanceTick(bgCtx context.Context, log *slog.Logger, database *db.DB, fileStorage *storage.Storage, settings *service.SettingsService) bool {
|
||||
tickFailed := false
|
||||
if err := database.DeleteExpiredSessions(bgCtx); err != nil {
|
||||
log.Warn("failed to delete expired sessions", "error", err)
|
||||
@@ -79,7 +80,7 @@ func maintenanceTick(bgCtx context.Context, log *slog.Logger, database *db.DB, f
|
||||
|
||||
// Scheduled backups + retention pruning, driven by the
|
||||
// backup_schedule / backup_retention admin settings.
|
||||
if err := admin.MaintainBackups(bgCtx, database); err != nil {
|
||||
if err := admin.MaintainBackups(bgCtx, database, settings); err != nil {
|
||||
log.Warn("backup maintenance failed", "error", err)
|
||||
tickFailed = true
|
||||
}
|
||||
|
||||
@@ -41,12 +41,11 @@ var DBImportAllow = map[string]DBImportEntry{
|
||||
// ── admin ─────────────────────────────────────────────────────────────
|
||||
"admin/admin.go": {"boundary", "", "holds the handle for the admin mux; no calls"},
|
||||
"admin/api.go": {"boundary", "", "passes the handle to handlers; no calls"},
|
||||
"admin/backup_maintenance.go": {"move", "settings-ops", "BackupToSafe, integrity check, settings reads"},
|
||||
"admin/handlers_backup.go": {"move", "settings-ops", "backup trigger and download; raw SQLDb for VACUUM INTO"},
|
||||
"admin/backup_maintenance.go": {"boundary", "", "scheduled backup mechanics on the maintenance tick; settings via the service"},
|
||||
"admin/handlers_backup.go": {"boundary", "", "backup create/list/delete/restore owns the handle: VACUUM INTO, WAL checkpoint, close-and-swap"},
|
||||
"admin/handlers_channel_perms.go": {"move", "channel", "override CRUD decides permission policy in the handler"},
|
||||
"admin/handlers_channels.go": {"move", "channel", "channel CRUD + audit"},
|
||||
"admin/handlers_roles.go": {"move", "role", "two reads; service/role.go already owns the writes"},
|
||||
"admin/handlers_settings.go": {"move", "settings-ops", "BeginTx in a handler; TOTP census"},
|
||||
"admin/handlers_tokens.go": {"move", "auth", "API-token CRUD duplicated in token_cli.go"},
|
||||
"admin/handlers_users.go": {"move", "user", "user list, stats, lookups"},
|
||||
"admin/helpers.go": {"adapter", "", "Role/User types in response helpers"},
|
||||
@@ -91,14 +90,18 @@ var DBImportAllow = map[string]DBImportEntry{
|
||||
"ws/eventstore.go": {"adapter", "", "PersistedEvent type; store is an interface"},
|
||||
"ws/handlers.go": {"move", "channel", "channel, role, session-ban and DM reads in command handlers"},
|
||||
"ws/handlers_chat.go": {"adapter", "", "pure NewDMChannelInfo helper"},
|
||||
"ws/hub.go": {"move", "settings-ops", "GetSetting at construction"},
|
||||
"ws/hub_broadcast.go": {"move", "channel", "visibility refresh reads channels, roles, users"},
|
||||
"ws/hub.go": {"boundary", "", "Hub state holds the handle the families read through; no calls"},
|
||||
"ws/hub_options.go": {"boundary", "", "construction validates and stores the handle; no calls"},
|
||||
"ws/hub_broadcast.go": {"move", "channel", "member broadcast payloads read the user and role they announce"},
|
||||
"ws/hub_presence.go": {"adapter", "", "presence coalescer; pure BroadcastStatus helper and the MemberSummary shape"},
|
||||
"ws/hub_visibility.go": {"move", "channel", "visibility and audience resolution reads channels, overrides, participants, users"},
|
||||
"ws/hub_sweep.go": {"move", "voice", "stale-voice sweep reads and leaves"},
|
||||
"ws/messages.go": {"adapter", "", "wire types + pure status helpers"},
|
||||
"ws/replay.go": {"move", "connection", "reconnect replay selection and delivery; serve.go's row split with its code in B3-5"},
|
||||
"ws/serve.go": {"move", "connection", "connect/disconnect lifecycle; B3-5 splits it by family first"},
|
||||
"ws/serve_auth.go": {"move", "auth", "session lookup on the WebSocket handshake"},
|
||||
"ws/serve_auth.go": {"move", "auth", "handshake auth: session, user and role lookups, connect audit, failed-handshake teardown"},
|
||||
"ws/serve_pumps.go": {"move", "user", "MarkUserDisconnected on pump exit"},
|
||||
"ws/serve_ready.go": {"move", "channel", "ready snapshot: channels, overrides, unreads, DMs, members"},
|
||||
"ws/serve_ready.go": {"move", "channel", "ready snapshot and fresh-connect: channels, overrides, unreads, DMs, members, stale-voice cleanup"},
|
||||
"ws/voice_join.go": {"move", "voice", "voice state reads and writes"},
|
||||
"ws/voice_moderation.go": {"move", "voice", "mute/deafen/move persist voice state"},
|
||||
}
|
||||
|
||||
@@ -201,4 +201,7 @@ type Store interface {
|
||||
GetSetting(ctx context.Context, key string) (string, error)
|
||||
SetSetting(ctx context.Context, key, value string) error
|
||||
GetAllSettings(ctx context.Context) (map[string]string, error)
|
||||
// ApplySettings upserts every pair in one transaction — the settings
|
||||
// PATCH is atomic from the caller's perspective (settings family).
|
||||
ApplySettings(ctx context.Context, updates map[string]string) error
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ type Services struct {
|
||||
Moderation *ModerationService
|
||||
Roles *RoleService
|
||||
Emoji *EmojiService
|
||||
Settings *SettingsService
|
||||
}
|
||||
|
||||
// New creates all domain services wired together.
|
||||
@@ -39,5 +40,6 @@ func New(st Store, limiter *auth.RateLimiter) *Services {
|
||||
Moderation: NewModerationService(st, permSvc),
|
||||
Roles: NewRoleService(st, permSvc),
|
||||
Emoji: NewEmojiService(st, permSvc),
|
||||
Settings: NewSettingsService(st),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
|
||||
"github.com/J3vb/OwnCord/Server/db"
|
||||
)
|
||||
|
||||
// SettingsService owns the server-settings policy the admin panel writes and
|
||||
// the rest of the server reads: which keys exist, how boolean values are
|
||||
// normalized, the require_2fa preconditions, and the atomic apply. Handlers
|
||||
// are thin adapters over it (B3-8 settings/audit family); the hub reads
|
||||
// server identity through it instead of the raw handle.
|
||||
type SettingsService struct {
|
||||
st Store
|
||||
}
|
||||
|
||||
// NewSettingsService creates a SettingsService.
|
||||
func NewSettingsService(st Store) *SettingsService {
|
||||
return &SettingsService{st: st}
|
||||
}
|
||||
|
||||
// allowedSettingKeys is the whitelist of keys that may be written via the
|
||||
// admin settings PATCH. Derived from the settings table in SCHEMA.md. The
|
||||
// policy lives here so no handler can grow its own copy.
|
||||
var allowedSettingKeys = map[string]struct{}{
|
||||
"server_name": {},
|
||||
"server_icon": {},
|
||||
"motd": {},
|
||||
"max_upload_bytes": {},
|
||||
"voice_quality": {},
|
||||
"require_2fa": {},
|
||||
"registration_open": {},
|
||||
"backup_schedule": {},
|
||||
"backup_retention": {},
|
||||
}
|
||||
|
||||
// List returns every setting as a key→value map.
|
||||
func (s *SettingsService) List(ctx context.Context) (map[string]string, error) {
|
||||
return s.st.GetAllSettings(ctx)
|
||||
}
|
||||
|
||||
// Setting returns one setting's value. Errors wrap db.ErrNotFound for a
|
||||
// missing key, exactly as the store reports it — the hub's identity reads
|
||||
// and the backup scheduler both branch on that.
|
||||
func (s *SettingsService) Setting(ctx context.Context, key string) (string, error) {
|
||||
return s.st.GetSetting(ctx, key)
|
||||
}
|
||||
|
||||
// Patch validates, normalizes and atomically applies updates, then writes
|
||||
// one audit row per changed key attributed to actorID. The returned map is
|
||||
// the full settings table after the apply (the admin panel re-renders from
|
||||
// it). Validation failures return ErrBadRequest with the reason; nothing is
|
||||
// written unless every key passes.
|
||||
func (s *SettingsService) Patch(ctx context.Context, actorID int64, updates map[string]string) (map[string]string, error) {
|
||||
// Validate all keys against the whitelist before writing anything so the
|
||||
// operation is atomic from the caller's perspective. The %.0w verb wraps
|
||||
// ErrBadRequest without adding its text: the admin surface's response
|
||||
// bodies are pinned to exactly these messages, prefix-free.
|
||||
for key := range updates {
|
||||
if _, ok := allowedSettingKeys[key]; !ok {
|
||||
return nil, fmt.Errorf("unknown setting key: %q%.0w", key, ErrBadRequest)
|
||||
}
|
||||
}
|
||||
|
||||
normalized, err := normalizeSettingUpdates(updates)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s%.0w", err.Error(), ErrBadRequest)
|
||||
}
|
||||
|
||||
if err := s.validateRequire2FAUpdate(ctx, normalized); err != nil {
|
||||
return nil, fmt.Errorf("%s%.0w", err.Error(), ErrBadRequest)
|
||||
}
|
||||
|
||||
if err := s.st.ApplySettings(ctx, normalized); err != nil {
|
||||
return nil, fmt.Errorf("Patch: %w", err)
|
||||
}
|
||||
for key := range normalized {
|
||||
slog.Info("setting changed", "actor_id", actorID, "key", key)
|
||||
db.WriteAudit(context.WithoutCancel(ctx), s.st, actorID, "setting_change", "setting", 0,
|
||||
fmt.Sprintf("%s updated", key))
|
||||
}
|
||||
|
||||
return s.st.GetAllSettings(ctx)
|
||||
}
|
||||
|
||||
func normalizeSettingUpdates(updates map[string]string) (map[string]string, error) {
|
||||
normalized := make(map[string]string, len(updates))
|
||||
for key, value := range updates {
|
||||
normalized[key] = value
|
||||
switch key {
|
||||
case "require_2fa", "registration_open":
|
||||
parsed, err := parseSettingsPatchBool(value)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", key, err)
|
||||
}
|
||||
if parsed {
|
||||
normalized[key] = "1"
|
||||
} else {
|
||||
normalized[key] = "0"
|
||||
}
|
||||
}
|
||||
}
|
||||
return normalized, nil
|
||||
}
|
||||
|
||||
func (s *SettingsService) validateRequire2FAUpdate(ctx context.Context, updates map[string]string) error {
|
||||
targetRequire2FA, err := s.targetBoolSetting(ctx, updates, "require_2fa")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !targetRequire2FA {
|
||||
return nil
|
||||
}
|
||||
|
||||
registrationOpen, err := s.targetBoolSetting(ctx, updates, "registration_open")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if registrationOpen {
|
||||
return fmt.Errorf("require_2fa cannot be enabled while registration is open")
|
||||
}
|
||||
|
||||
// The enrollment count only matters when this request is actually turning
|
||||
// require_2fa on. Without this guard, an unrelated PATCH (motd, server
|
||||
// name, backup settings, ...) inherits require_2fa's *current* value via
|
||||
// targetBoolSetting's DB fallback and gets rejected by a precondition
|
||||
// about a value it never touches — wedging the whole settings page once
|
||||
// any non-banned user without TOTP exists.
|
||||
if _, changingRequire2FA := updates["require_2fa"]; !changingRequire2FA {
|
||||
return nil
|
||||
}
|
||||
|
||||
count, err := s.st.CountUsersWithoutTOTP(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to validate 2FA enrollment")
|
||||
}
|
||||
if count > 0 {
|
||||
return fmt.Errorf("require_2fa cannot be enabled until all users have 2FA enabled")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *SettingsService) targetBoolSetting(ctx context.Context, updates map[string]string, key string) (bool, error) {
|
||||
if value, ok := updates[key]; ok {
|
||||
return parseSettingsPatchBool(value)
|
||||
}
|
||||
value, err := s.st.GetSetting(ctx, key)
|
||||
if errors.Is(err, db.ErrNotFound) {
|
||||
return false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return parseSettingsPatchBool(value)
|
||||
}
|
||||
|
||||
// parseSettingsPatchBool is auth.go's parseBooleanSettingValue with the
|
||||
// admin PATCH's own error wording ("invalid boolean value", no "setting").
|
||||
// Both messages are pinned by their sides' tests, so the twins stay
|
||||
// separate rather than either surface changing its reply.
|
||||
func parseSettingsPatchBool(value string) (bool, error) {
|
||||
switch strings.ToLower(strings.TrimSpace(value)) {
|
||||
case "1", "true":
|
||||
return true, nil
|
||||
case "0", "false":
|
||||
return false, nil
|
||||
default:
|
||||
return false, fmt.Errorf("invalid boolean value %q", value)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/J3vb/OwnCord/Server/db"
|
||||
)
|
||||
|
||||
// The settings family's service-level characterization (B3-8). The admin
|
||||
// handler surface is pinned by admin/api_test.go's TestAdminAPI_PatchSettings_*
|
||||
// rows; these tests pin the same policy at the service seam the handlers now
|
||||
// delegate to, plus the contracts only the service exposes (Setting's
|
||||
// ErrNotFound wrap, the audit rows, multi-key atomic apply).
|
||||
|
||||
func newSettingsService(t *testing.T) (*SettingsService, *db.DB) {
|
||||
t.Helper()
|
||||
database := newTestDB(t)
|
||||
return NewSettingsService(database), database
|
||||
}
|
||||
|
||||
func TestSettings_ListContainsMigratedDefaults(t *testing.T) {
|
||||
svc, _ := newSettingsService(t)
|
||||
all, err := svc.List(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("List: %v", err)
|
||||
}
|
||||
if _, ok := all["server_name"]; !ok {
|
||||
t.Fatalf("List missing server_name; got keys %v", len(all))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSettings_PatchRejectsUnknownKeyWritingNothing(t *testing.T) {
|
||||
svc, database := newSettingsService(t)
|
||||
before, _ := database.GetSetting(context.Background(), "server_name")
|
||||
|
||||
_, err := svc.Patch(context.Background(), 1, map[string]string{
|
||||
"server_name": "changed",
|
||||
"nope": "x",
|
||||
})
|
||||
if !errors.Is(err, ErrBadRequest) {
|
||||
t.Fatalf("err = %v, want ErrBadRequest", err)
|
||||
}
|
||||
if want := `unknown setting key: "nope"`; !strings.Contains(err.Error(), want) {
|
||||
t.Fatalf("err = %q, want it to contain %q", err, want)
|
||||
}
|
||||
after, _ := database.GetSetting(context.Background(), "server_name")
|
||||
if after != before {
|
||||
t.Fatalf("server_name changed to %q despite the rejected key", after)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSettings_PatchNormalizesBooleans(t *testing.T) {
|
||||
svc, database := newSettingsService(t)
|
||||
if _, err := svc.Patch(context.Background(), 1, map[string]string{
|
||||
"registration_open": "TRUE",
|
||||
}); err != nil {
|
||||
t.Fatalf("Patch: %v", err)
|
||||
}
|
||||
got, err := database.GetSetting(context.Background(), "registration_open")
|
||||
if err != nil || got != "1" {
|
||||
t.Fatalf("registration_open = %q, %v; want \"1\"", got, err)
|
||||
}
|
||||
if _, err := svc.Patch(context.Background(), 1, map[string]string{
|
||||
"registration_open": "false",
|
||||
}); err != nil {
|
||||
t.Fatalf("Patch: %v", err)
|
||||
}
|
||||
got, _ = database.GetSetting(context.Background(), "registration_open")
|
||||
if got != "0" {
|
||||
t.Fatalf("registration_open = %q, want \"0\"", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSettings_PatchRejectsInvalidBoolean(t *testing.T) {
|
||||
svc, _ := newSettingsService(t)
|
||||
_, err := svc.Patch(context.Background(), 1, map[string]string{
|
||||
"registration_open": "maybe",
|
||||
})
|
||||
if !errors.Is(err, ErrBadRequest) {
|
||||
t.Fatalf("err = %v, want ErrBadRequest", err)
|
||||
}
|
||||
if want := `registration_open: invalid boolean value "maybe"`; !strings.Contains(err.Error(), want) {
|
||||
t.Fatalf("err = %q, want it to contain %q", err, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSettings_Require2FARejectedWhileRegistrationOpen(t *testing.T) {
|
||||
svc, _ := newSettingsService(t)
|
||||
_, err := svc.Patch(context.Background(), 1, map[string]string{
|
||||
"require_2fa": "1",
|
||||
"registration_open": "1",
|
||||
})
|
||||
if !errors.Is(err, ErrBadRequest) {
|
||||
t.Fatalf("err = %v, want ErrBadRequest", err)
|
||||
}
|
||||
if want := "require_2fa cannot be enabled while registration is open"; !strings.Contains(err.Error(), want) {
|
||||
t.Fatalf("err = %q, want it to contain %q", err, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSettings_Require2FARejectedUntilAllEnrolled(t *testing.T) {
|
||||
svc, database := newSettingsService(t)
|
||||
seedUser(t, database, &db.User{ID: 1, Username: "un-enrolled"})
|
||||
|
||||
_, err := svc.Patch(context.Background(), 1, map[string]string{
|
||||
"require_2fa": "1",
|
||||
"registration_open": "0",
|
||||
})
|
||||
if !errors.Is(err, ErrBadRequest) {
|
||||
t.Fatalf("err = %v, want ErrBadRequest", err)
|
||||
}
|
||||
if want := "require_2fa cannot be enabled until all users have 2FA enabled"; !strings.Contains(err.Error(), want) {
|
||||
t.Fatalf("err = %q, want it to contain %q", err, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSettings_Require2FAAllowedWithNoUnenrolledUsers(t *testing.T) {
|
||||
svc, database := newSettingsService(t)
|
||||
if _, err := svc.Patch(context.Background(), 1, map[string]string{
|
||||
"require_2fa": "1",
|
||||
"registration_open": "0",
|
||||
}); err != nil {
|
||||
t.Fatalf("Patch: %v", err)
|
||||
}
|
||||
got, _ := database.GetSetting(context.Background(), "require_2fa")
|
||||
if got != "1" {
|
||||
t.Fatalf("require_2fa = %q, want \"1\"", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSettings_UnrelatedKeyNotBlockedByRequire2FAGate(t *testing.T) {
|
||||
svc, database := newSettingsService(t)
|
||||
// require_2fa already on, and a user without TOTP exists: an unrelated
|
||||
// PATCH must not inherit the gate (the wedged-settings-page regression).
|
||||
if err := database.SetSetting(context.Background(), "require_2fa", "1"); err != nil {
|
||||
t.Fatalf("SetSetting: %v", err)
|
||||
}
|
||||
seedUser(t, database, &db.User{ID: 1, Username: "un-enrolled"})
|
||||
|
||||
if _, err := svc.Patch(context.Background(), 1, map[string]string{"motd": "hello"}); err != nil {
|
||||
t.Fatalf("unrelated Patch blocked: %v", err)
|
||||
}
|
||||
got, _ := database.GetSetting(context.Background(), "motd")
|
||||
if got != "hello" {
|
||||
t.Fatalf("motd = %q, want \"hello\"", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSettings_PatchAppliesAllKeysAndAudits(t *testing.T) {
|
||||
svc, database := newSettingsService(t)
|
||||
after, err := svc.Patch(context.Background(), 7, map[string]string{
|
||||
"server_name": "Renamed",
|
||||
"motd": "Welcome",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Patch: %v", err)
|
||||
}
|
||||
if after["server_name"] != "Renamed" || after["motd"] != "Welcome" {
|
||||
t.Fatalf("returned map = %q/%q, want the applied values", after["server_name"], after["motd"])
|
||||
}
|
||||
|
||||
entries, err := database.GetAuditLog(context.Background(), 10, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("GetAuditLog: %v", err)
|
||||
}
|
||||
changes := 0
|
||||
for _, e := range entries {
|
||||
if e.Action == "setting_change" && e.ActorID == 7 {
|
||||
changes++
|
||||
}
|
||||
}
|
||||
if changes != 2 {
|
||||
t.Fatalf("setting_change audit rows = %d, want 2", changes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSettings_SettingWrapsErrNotFound(t *testing.T) {
|
||||
svc, _ := newSettingsService(t)
|
||||
if _, err := svc.Setting(context.Background(), "no_such_key"); !errors.Is(err, db.ErrNotFound) {
|
||||
t.Fatalf("err = %v, want db.ErrNotFound", err)
|
||||
}
|
||||
name, err := svc.Setting(context.Background(), "server_name")
|
||||
if err != nil || name == "" {
|
||||
t.Fatalf("Setting(server_name) = %q, %v", name, err)
|
||||
}
|
||||
}
|
||||
@@ -16,7 +16,6 @@ import (
|
||||
"github.com/J3vb/OwnCord/Server/db"
|
||||
"github.com/J3vb/OwnCord/Server/permissions"
|
||||
"github.com/J3vb/OwnCord/Server/service"
|
||||
"github.com/J3vb/OwnCord/Server/ws"
|
||||
)
|
||||
|
||||
func seedVisibilityUser(t *testing.T, database *db.DB, username string, roleID int) *db.User {
|
||||
@@ -51,7 +50,7 @@ func sortedKeys(m map[int64]bool) []int64 {
|
||||
func TestChannelVisibility_RESTWSAgreement(t *testing.T) {
|
||||
database := openServeTestDB(t)
|
||||
limiter := auth.NewRateLimiter()
|
||||
hub := ws.NewHub(database, limiter, nil)
|
||||
hub := newTestHubDeps(t, database, limiter, nil)
|
||||
svc := service.New(database, limiter)
|
||||
|
||||
// Seed one channel of each server type plus a dm channel (never visible).
|
||||
@@ -177,7 +176,7 @@ func TestChannelVisibility_RESTWSAgreement(t *testing.T) {
|
||||
func TestChannelVisibility_UserOverrideAgreement(t *testing.T) {
|
||||
database := openServeTestDB(t)
|
||||
limiter := auth.NewRateLimiter()
|
||||
hub := ws.NewHub(database, limiter, nil)
|
||||
hub := newTestHubDeps(t, database, limiter, nil)
|
||||
svc := service.New(database, limiter)
|
||||
|
||||
openID, err := database.CreateChannel(context.Background(), "open", "text", "", "", 0)
|
||||
|
||||
@@ -82,9 +82,8 @@ func newCoverageHub(t *testing.T) (*ws.Hub, *db.DB) {
|
||||
limiter := auth.NewRateLimiter()
|
||||
st := database
|
||||
svc := service.New(st, limiter)
|
||||
hub := ws.NewHub(database, limiter, svc)
|
||||
|
||||
// Inject a test LiveKit client so voice_join passes the livekit!=nil guard.
|
||||
// A test LiveKit client so voice_join passes the livekit!=nil guard.
|
||||
lk, err := ws.NewLiveKitClient(&config.VoiceConfig{
|
||||
LiveKitAPIKey: "test-api-key-12345",
|
||||
LiveKitAPISecret: "test-api-secret-67890abcdef",
|
||||
@@ -93,7 +92,7 @@ func newCoverageHub(t *testing.T) (*ws.Hub, *db.DB) {
|
||||
if err != nil {
|
||||
t.Fatalf("NewLiveKitClient: %v", err)
|
||||
}
|
||||
hub.SetLiveKit(lk)
|
||||
hub := newTestHubWith(t, ws.HubOptions{DB: database, Limiter: limiter, Services: svc, LiveKit: lk})
|
||||
|
||||
go hub.Run()
|
||||
t.Cleanup(func() { hub.Stop() })
|
||||
|
||||
@@ -241,7 +241,7 @@ func TestApplySetChannelID_TransientPermissionLookupError_KeepsFocus(t *testing.
|
||||
limiter := auth.NewRateLimiter()
|
||||
store := &erroringOverridesStore{Store: database}
|
||||
svc := service.New(store, limiter)
|
||||
hub := ws.NewHub(database, limiter, svc)
|
||||
hub := newTestHubDeps(t, database, limiter, svc)
|
||||
go hub.Run()
|
||||
t.Cleanup(func() { hub.Stop() })
|
||||
|
||||
|
||||
@@ -12,15 +12,14 @@ import (
|
||||
"github.com/J3vb/OwnCord/Server/ws"
|
||||
)
|
||||
|
||||
// compile-time check that SetPluginRegistry is exported.
|
||||
var _ = (*ws.Hub)(nil)
|
||||
// The registry arrives via HubOptions since B3-4; these tests wire it at
|
||||
// construction.
|
||||
|
||||
// ─── chat_command dispatch via HandleMessageForTest ───────────────────────────
|
||||
|
||||
// TestChatCommand_NoRegistry returns an error when no plugin registry is wired.
|
||||
func TestChatCommand_NoRegistry_ReturnsError(t *testing.T) {
|
||||
hub, database := newTestHub(t)
|
||||
_ = database
|
||||
hub, _ := newTestHub(t)
|
||||
send := make(chan []byte, 4)
|
||||
c := ws.NewTestClient(hub, 1, send)
|
||||
hub.Register(c)
|
||||
@@ -53,17 +52,16 @@ func TestChatCommand_NoRegistry_ReturnsError(t *testing.T) {
|
||||
// TestChatCommand_UnknownCommand returns an error when the registry has no
|
||||
// plugin owning the command.
|
||||
func TestChatCommand_UnknownCommand_ReturnsError(t *testing.T) {
|
||||
hub, database := newTestHub(t)
|
||||
send := make(chan []byte, 4)
|
||||
c := ws.NewTestClient(hub, 1, send)
|
||||
hub.Register(c)
|
||||
defer hub.Unregister(c)
|
||||
|
||||
database := openTestDB(t)
|
||||
reg, err := plugin.NewRegistry(plugin.Config{Store: database})
|
||||
if err != nil {
|
||||
t.Fatalf("NewRegistry: %v", err)
|
||||
}
|
||||
hub.SetPluginRegistry(reg)
|
||||
hub := newTestHubWith(t, ws.HubOptions{DB: database, PluginRegistry: reg})
|
||||
send := make(chan []byte, 4)
|
||||
c := ws.NewTestClient(hub, 1, send)
|
||||
hub.Register(c)
|
||||
defer hub.Unregister(c)
|
||||
|
||||
raw, _ := json.Marshal(map[string]any{
|
||||
"type": "chat_command",
|
||||
@@ -117,17 +115,16 @@ func TestChatCommand_MalformedPayload_ReturnsBadRequest(t *testing.T) {
|
||||
// running DispatchCommand (and therefore the plugin's WASM invocation) once
|
||||
// per frame with no cap.
|
||||
func TestChatCommand_RateLimited_ReturnsError(t *testing.T) {
|
||||
hub, database := newTestHub(t)
|
||||
send := make(chan []byte, 32)
|
||||
c := ws.NewTestClient(hub, 1, send)
|
||||
hub.Register(c)
|
||||
defer hub.Unregister(c)
|
||||
|
||||
database := openTestDB(t)
|
||||
reg, err := plugin.NewRegistry(plugin.Config{Store: database})
|
||||
if err != nil {
|
||||
t.Fatalf("NewRegistry: %v", err)
|
||||
}
|
||||
hub.SetPluginRegistry(reg)
|
||||
hub := newTestHubWith(t, ws.HubOptions{DB: database, PluginRegistry: reg})
|
||||
send := make(chan []byte, 32)
|
||||
c := ws.NewTestClient(hub, 1, send)
|
||||
hub.Register(c)
|
||||
defer hub.Unregister(c)
|
||||
|
||||
sawRateLimited := false
|
||||
for i := range 20 {
|
||||
|
||||
@@ -79,7 +79,7 @@ func newHandlerHub(t *testing.T) (*ws.Hub, *db.DB) {
|
||||
limiter := auth.NewRateLimiter()
|
||||
st := database
|
||||
svc := service.New(st, limiter)
|
||||
hub := ws.NewHub(database, limiter, svc)
|
||||
hub := newTestHubDeps(t, database, limiter, svc)
|
||||
go hub.Run()
|
||||
t.Cleanup(func() { hub.Stop() })
|
||||
return hub, database
|
||||
|
||||
@@ -158,7 +158,7 @@ func TestComputeAllowedChannels_DMLookupErrorIsFatal(t *testing.T) {
|
||||
|
||||
userID, _ := database.CreateUser(context.Background(), "dm-lookup-err", "hash", 1)
|
||||
user, _ := database.GetUserByID(context.Background(), userID)
|
||||
h := NewHub(database, auth.NewRateLimiter(), nil)
|
||||
h := newTestHub(t, database, auth.NewRateLimiter(), nil)
|
||||
|
||||
// Fault-inject exactly the DM lookup; the earlier role/channel lookups
|
||||
// keep working.
|
||||
@@ -236,7 +236,7 @@ func TestFailedHandshake_MarksUserOfflineWhenNoReplacementRemains(t *testing.T)
|
||||
if err := database.UpdateUserStatus(context.Background(), userID, "online"); err != nil {
|
||||
t.Fatalf("UpdateUserStatus: %v", err)
|
||||
}
|
||||
h := NewHub(database, auth.NewRateLimiter(), nil)
|
||||
h := newTestHub(t, database, auth.NewRateLimiter(), nil)
|
||||
|
||||
// Old connection A holds the slot; B replaces it (registerNow kicks A);
|
||||
// A's readPump defer runs while B holds the slot → teardown skipped.
|
||||
|
||||
@@ -80,7 +80,7 @@ func TestSweepStaleVoiceStates_EvictionIsScopedToCheckedChannel(t *testing.T) {
|
||||
t.Fatalf("UpsertChannelOverride: %v", err)
|
||||
}
|
||||
|
||||
h := NewHub(database, auth.NewRateLimiter(), nil)
|
||||
h := newTestHub(t, database, auth.NewRateLimiter(), nil)
|
||||
c := NewTestClient(h, uid, make(chan []byte, 2048))
|
||||
h.clients[uid] = c
|
||||
|
||||
@@ -135,7 +135,7 @@ func TestHandleVoiceJoin_AbortedSwitchDoesNotResurrectVoiceTopicSubscription(t *
|
||||
t.Fatalf("GetVoiceState: %v", err)
|
||||
}
|
||||
|
||||
h := NewHub(database, auth.NewRateLimiter(), nil)
|
||||
h := newTestHub(t, database, auth.NewRateLimiter(), nil)
|
||||
t.Cleanup(h.Stop) // ends the background leave retries the blocked delete spawns
|
||||
lk, err := NewLiveKitClient(&config.VoiceConfig{
|
||||
LiveKitAPIKey: "harvest-key",
|
||||
|
||||
+11
-465
@@ -4,7 +4,6 @@ package ws
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"os"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
@@ -60,17 +59,18 @@ type Hub struct {
|
||||
broadcastDrops atomic.Uint64 // counts messages dropped due to full broadcast channel
|
||||
|
||||
// Phase B Step 7 — event persistence. nil = ring buffer only. Atomic
|
||||
// because main.go wires these after NewRouter has already started the
|
||||
// Run loop, which reads them on the broadcast/replay paths.
|
||||
// because internal/app wires these one lifecycle stage after Run has
|
||||
// started, which reads them on the broadcast/replay paths.
|
||||
eventPersister atomic.Pointer[EventPersister]
|
||||
eventStore atomic.Pointer[EventStore] // read path for cold-tier replay
|
||||
|
||||
// Phase C Step 9 — plugin wiring.
|
||||
pluginRegistry *plugin.Registry // slash-command dispatch; nil = no plugins; wire before Run
|
||||
pluginRegistry *plugin.Registry // slash-command dispatch; nil = no plugins; HubOptions field (B3-4)
|
||||
pluginSink atomic.Pointer[plugin.EventSink] // hub→plugin event fan-out; nil = no plugins
|
||||
|
||||
// running flips when Run starts; plain-field setters check it so a late
|
||||
// call fails loudly instead of racing the dispatch loop.
|
||||
// running flips when Run starts. The pre-Run setters that used to check
|
||||
// it died in B3-4 (their fields are HubOptions now); tests still read it
|
||||
// via RunningForTest to wait for the dispatch loop.
|
||||
running atomic.Bool
|
||||
|
||||
// dispatchExited flips when Run returns for good — normal Stop or the
|
||||
@@ -97,7 +97,7 @@ type Hub struct {
|
||||
connRejects atomic.Uint64
|
||||
|
||||
// coldReplayLimit caps persisted-event replay per reconnect. 0 = the
|
||||
// compiled-in default (maxColdReplay). Set via ConfigureReplay before Run.
|
||||
// compiled-in default (maxColdReplay). HubOptions.ReplayColdLimit (B3-4).
|
||||
coldReplayLimit int
|
||||
|
||||
// In-flight guards for the DB-heavy sweeps Run kicks off in their own
|
||||
@@ -119,6 +119,10 @@ type Hub struct {
|
||||
visibilityChangeSeq atomic.Uint64
|
||||
|
||||
// Settings cache — avoids per-connection DB queries for server_name/motd.
|
||||
// settings is the read seam the cache below refreshes through —
|
||||
// the B3-8 settings family owns the underlying reads.
|
||||
settings SettingsReader
|
||||
|
||||
settingsMu syncutil.RWMutex
|
||||
settingsName string
|
||||
settingsMotd string
|
||||
@@ -137,133 +141,6 @@ type Hub struct {
|
||||
presenceFlushArmed bool
|
||||
}
|
||||
|
||||
// NewHub creates a Hub ready to be started with Run.
|
||||
// It also initializes the settings cache from the database.
|
||||
// If svc is non-nil, V2 handlers receive service references for business logic delegation.
|
||||
func NewHub(database *db.DB, limiter *auth.RateLimiter, svc *service.Services) *Hub {
|
||||
reg := NewHandlerRegistry()
|
||||
|
||||
h := &Hub{
|
||||
clients: make(map[int64]*Client),
|
||||
db: database,
|
||||
limiter: limiter,
|
||||
broadcast: make(chan broadcastMsg, 1024),
|
||||
clientEvents: make(chan clientEvent, 64),
|
||||
stop: make(chan struct{}),
|
||||
pubsub: NewPubSub(),
|
||||
topicLimiter: NewTopicRateLimiter(topicRateLimitPerSecond, time.Second),
|
||||
replayBuf: NewEventRingBuffer(1000),
|
||||
registry: reg,
|
||||
permChecker: permissions.NewChecker(database),
|
||||
settingsName: "OwnCord Server",
|
||||
settingsMotd: "Welcome!",
|
||||
voiceKeyHolders: make(map[int64]int64),
|
||||
fatalFn: func() { os.Exit(1) },
|
||||
}
|
||||
|
||||
// V2 handler registrations (need Hub fields for deps).
|
||||
registerPingHandler(reg, PingDeps{Limiter: h.limiter})
|
||||
|
||||
chatDeps := ChatDeps{
|
||||
Limiter: h.limiter,
|
||||
}
|
||||
presenceDeps := PresenceDeps{
|
||||
Limiter: h.limiter,
|
||||
}
|
||||
reactionDeps := ReactionDeps{}
|
||||
callDeps := CallDeps{Limiter: h.limiter}
|
||||
if svc != nil {
|
||||
chatDeps.MessageSvc = svc.Messages
|
||||
presenceDeps.ChannelSvc = svc.Channels
|
||||
reactionDeps.MessageSvc = svc.Messages
|
||||
callDeps.DMSvc = svc.DMs
|
||||
h.messageSvc = svc.Messages
|
||||
h.perms = svc.Permissions
|
||||
// So @here's offline narrowing can tell a disconnected idle/dnd reader
|
||||
// (users.status keeps their last *chosen* value across a disconnect)
|
||||
// from one who is actually still connected — the same live-connection
|
||||
// rule presentableMembers applies to the members array.
|
||||
svc.Messages.SetOnlineChecker(h.IsUserConnected)
|
||||
// So every DM payload DMService builds (GET/POST /dms, POST
|
||||
// /dms/group, PATCH /dms/{id}, and every broadcastDMOpen refresh)
|
||||
// applies the same live-connection rule instead of only the ready
|
||||
// payload's presentableDMChannels doing so (OC-0304).
|
||||
svc.DMs.SetOnlineChecker(h.IsUserConnected)
|
||||
}
|
||||
|
||||
registerChatHandlers(reg, chatDeps)
|
||||
registerPresenceHandlers(reg, presenceDeps)
|
||||
registerReactionHandlers(reg, reactionDeps)
|
||||
registerCallHandlers(reg, callDeps)
|
||||
// Phase C Step 9 — plugin slash commands. Registry is read live because
|
||||
// SetPluginRegistry wires it after NewHub; MessageSvc gates broadcasts.
|
||||
reg.RegisterV2(MsgTypeChatCommand, handleChatCommandV2, PluginDeps{
|
||||
// A nil registry must yield a nil interface, not a typed-nil
|
||||
// *plugin.Registry — the handler's "no plugins loaded" check is an
|
||||
// interface comparison.
|
||||
Registry: func() CommandDispatcher {
|
||||
if h.pluginRegistry == nil {
|
||||
return nil
|
||||
}
|
||||
return h.pluginRegistry
|
||||
},
|
||||
MessageSvc: h.messageSvc,
|
||||
Limiter: h.limiter,
|
||||
})
|
||||
registerVoiceControlsV2(reg, VoiceDeps{
|
||||
DB: h.db,
|
||||
Limiter: h.limiter,
|
||||
Permissions: h.permChecker,
|
||||
PermSvc: h.perms,
|
||||
LiveKit: h.livekit,
|
||||
TokenGen: h, // Hub delegates to h.livekit at call time (set via SetLiveKit)
|
||||
KeyHolder: h,
|
||||
Mod: h,
|
||||
})
|
||||
|
||||
h.refreshSettingsLocked(context.Background())
|
||||
return h
|
||||
}
|
||||
|
||||
// getCachedSettings returns server_name and motd, refreshing the cache if stale.
|
||||
func (h *Hub) getCachedSettings(ctx context.Context) (string, string) {
|
||||
h.settingsMu.RLock()
|
||||
if time.Since(h.settingsLastUpdate) < settingsCacheTTL {
|
||||
name, motd := h.settingsName, h.settingsMotd
|
||||
h.settingsMu.RUnlock()
|
||||
return name, motd
|
||||
}
|
||||
h.settingsMu.RUnlock()
|
||||
|
||||
h.settingsMu.Lock()
|
||||
defer h.settingsMu.Unlock()
|
||||
// Double-check after acquiring write lock.
|
||||
if time.Since(h.settingsLastUpdate) < settingsCacheTTL {
|
||||
return h.settingsName, h.settingsMotd
|
||||
}
|
||||
h.refreshSettingsLocked(ctx)
|
||||
return h.settingsName, h.settingsMotd
|
||||
}
|
||||
|
||||
// refreshSettingsLocked reloads server_name and motd from the DB.
|
||||
// Caller must hold settingsMu (write lock) or call during init.
|
||||
func (h *Hub) refreshSettingsLocked(ctx context.Context) {
|
||||
if h.db == nil {
|
||||
return
|
||||
}
|
||||
// The refresh serves the hub-wide settings cache, not the connection that
|
||||
// happened to trigger it — a dying connection's ctx must not fail the
|
||||
// fetches (the TTL stamp below would then pin stale values for 30s).
|
||||
ctx = context.WithoutCancel(ctx)
|
||||
if name, err := h.db.GetSetting(ctx, "server_name"); err == nil {
|
||||
h.settingsName = name
|
||||
}
|
||||
if motd, err := h.db.GetSetting(ctx, "motd"); err == nil {
|
||||
h.settingsMotd = motd
|
||||
}
|
||||
h.settingsLastUpdate = time.Now()
|
||||
}
|
||||
|
||||
// Run starts the hub's dispatch loop. It blocks until Stop is called.
|
||||
// Must be called in its own goroutine.
|
||||
//
|
||||
@@ -412,37 +289,6 @@ func (h *Hub) GracefulStopContext(ctx context.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
// bumpVisibilityWatermark ratchets visibilityChangeSeq up to the current seq,
|
||||
// never down. All three writers (RefreshChannelVisibility,
|
||||
// revokeUnreadableChannels, DMChannelOpenEvent in emit.go) must go through
|
||||
// this instead of a plain Store: a plain Store(Load(&h.seq)) lets a writer
|
||||
// that read an older h.seq — e.g. one that spent time in a per-topic DB loop
|
||||
// — finish and overwrite a concurrently stored higher watermark with its
|
||||
// stale value, silently regressing the forced-full-resync boundary mustFullResync
|
||||
// depends on being monotonic. Mirrors SeedSeq's CAS-max pattern.
|
||||
func (h *Hub) bumpVisibilityWatermark() {
|
||||
for {
|
||||
cur := h.visibilityChangeSeq.Load()
|
||||
next := atomic.LoadUint64(&h.seq)
|
||||
if next <= cur {
|
||||
return
|
||||
}
|
||||
if h.visibilityChangeSeq.CompareAndSwap(cur, next) {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MarkVisibilityChanged bumps the visibility watermark. It is the exported
|
||||
// entry point REST handlers (api.markDMVisibilityChanged, reached via a
|
||||
// dmVisibilityMarker type assertion) use to force the same full-resync
|
||||
// guarantee for an unsequenced, targeted DM event that the WS-side emitter of
|
||||
// the same event (emit.go DMChannelOpenEvent) already gets via
|
||||
// bumpVisibilityWatermark directly.
|
||||
func (h *Hub) MarkVisibilityChanged() {
|
||||
h.bumpVisibilityWatermark()
|
||||
}
|
||||
|
||||
// IsUserConnected returns true if a client with the given userID is already
|
||||
// registered in the hub. Safe to call from any goroutine.
|
||||
func (h *Hub) IsUserConnected(userID int64) bool {
|
||||
@@ -460,16 +306,6 @@ func (h *Hub) GetClient(userID int64) *Client {
|
||||
return h.clients[userID]
|
||||
}
|
||||
|
||||
// Register queues a client for registration with the hub.
|
||||
func (h *Hub) Register(c *Client) {
|
||||
h.clientEvents <- clientEvent{c: c, add: true}
|
||||
}
|
||||
|
||||
// Unregister queues a client for removal from the hub.
|
||||
func (h *Hub) Unregister(c *Client) {
|
||||
h.clientEvents <- clientEvent{c: c}
|
||||
}
|
||||
|
||||
// clientEvent is a register (add=true) or unregister (add=false) request.
|
||||
// Both kinds share one channel so per-connection ordering is preserved.
|
||||
type clientEvent struct {
|
||||
@@ -477,246 +313,6 @@ type clientEvent struct {
|
||||
add bool
|
||||
}
|
||||
|
||||
// registerNow adds c to the hub and subscribes it to its topics.
|
||||
//
|
||||
// readableChannelIDs is the set of channels the user holds READ_MESSAGES on,
|
||||
// as computed by the handshake (serve.go). It gates the inherited voice-channel
|
||||
// subscription only; a nil set denies it (fail closed).
|
||||
//
|
||||
// Replacing an existing connection strips its subscriptions (UnsubscribeAll)
|
||||
// and re-subscribes the new one (Subscribe) as two separate PubSub-lock
|
||||
// acquisitions — back to back, but not atomic. A caller that must not lose a
|
||||
// broadcast concurrently racing the replacement (i.e. one deliverBroadcast
|
||||
// could deliver in the gap between those two acquisitions) has to call this
|
||||
// while holding h.seqMu, the same lock deliverBroadcast holds for its entire
|
||||
// critical section (seq allocation, replay-buffer push, and publish) — that
|
||||
// serializes the two entirely, rather than merely narrowing the window. See
|
||||
// serve.go's handleReconnect, which re-reads the replay tail and calls
|
||||
// registerNow inside one h.seqMu section for exactly this reason.
|
||||
func (h *Hub) registerNow(c *Client, readableChannelIDs map[int64]bool) {
|
||||
// Voice channel the replaced connection was in, if any. Re-elected below,
|
||||
// after the hub lock is released.
|
||||
var replacedVoiceChID int64
|
||||
|
||||
h.mu.Lock()
|
||||
if old, exists := h.clients[c.userID]; exists {
|
||||
oldE2EEKey, oldE2EESig := old.getE2EEPubKey()
|
||||
oldVoiceChID, oldVoiceJoinToken, oldVoiceJoinCompleted := old.clearVoiceState()
|
||||
replacedVoiceChID = oldVoiceChID
|
||||
// A moderator-imposed mute/deafen stashed by voice_mod_move
|
||||
// (setPendingModFlags) lives ONLY on the old *Client between the
|
||||
// target's eviction (which deletes the voice_states row that state
|
||||
// normally lives in) and the target's own re-join, which consumes it
|
||||
// via takePendingModFlags (voice_join.go). Any client replacement —
|
||||
// reconnect or full resync alike — must carry it to the new *Client
|
||||
// or it is silently destroyed and the mute is lost (OC-0302).
|
||||
// Unlike the voice-state transfer below, this has none of the
|
||||
// voiceJoinCompleted supersession concerns, so it is not gated on
|
||||
// c.lastSeq > 0: take-and-clear leaves nothing behind for old to
|
||||
// double-serve, and a stash nobody set is always (false, false).
|
||||
if pendingMuted, pendingDeafened := old.takePendingModFlags(); pendingMuted || pendingDeafened {
|
||||
c.setPendingModFlags(pendingMuted, pendingDeafened)
|
||||
}
|
||||
if c.lastSeq > 0 {
|
||||
// Network reconnect — preserve voice state so the user stays
|
||||
// in voice during brief WS drops.
|
||||
//
|
||||
// Gated on oldVoiceJoinCompleted (OC-0270): a join that
|
||||
// voiceJoinPersist has merely committed to the DB and set on the
|
||||
// old client, but that voiceJoinComplete has not yet finished, is
|
||||
// still racing its own supersession guards in voice_join.go
|
||||
// (voice_join.go:423, :470) — both compare the old client's live
|
||||
// voiceChID/voiceJoinToken against the values captured when the
|
||||
// join started. Clearing the old client's state above as part of
|
||||
// this very transfer makes those guards read as "superseded" and
|
||||
// abort the join (no token delivered, no voice_state broadcast,
|
||||
// no VoiceTopic subscribe) — while the DB row and the new
|
||||
// client's transferred state still agree, so sweepStaleVoiceStates
|
||||
// never reaps it. Transferring only a completed join avoids
|
||||
// resurrecting exactly that half-finished state; an incomplete
|
||||
// one instead leaves the new client with voiceChID 0, so the
|
||||
// still-committed row now disagrees with hub state and the next
|
||||
// sweep tick reaps it, letting the user rejoin.
|
||||
if c.getVoiceChID() == 0 && oldVoiceJoinCompleted {
|
||||
c.setVoiceState(oldVoiceChID, oldVoiceJoinToken)
|
||||
// c.setVoiceState above resets the fresh-join-in-progress flag
|
||||
// it defaults to; restore it since we just verified the old
|
||||
// client's join over this same (chID, token) had completed.
|
||||
c.markVoiceJoinCompleteIfMatch(oldVoiceChID, oldVoiceJoinToken)
|
||||
// The announced ECDH key must survive with the voice state:
|
||||
// the client keeps its keypair across a WS blip and only
|
||||
// re-announces on a LiveKit-room reconnect, so without the
|
||||
// transfer voice_join replays nothing for this user and new
|
||||
// joiners' key exchanges time out.
|
||||
c.setE2EEPubKey(oldE2EEKey, oldE2EESig)
|
||||
}
|
||||
// The focused channel must transfer too: the client never
|
||||
// re-sends channel_focus on a resume (mountChannel early-returns
|
||||
// on the same channel), so without it the ChannelTopic
|
||||
// re-subscribe below is a no-op and the message stream dies
|
||||
// silently. READ-gated like every ChannelTopic subscription;
|
||||
// a nil set denies (fail closed).
|
||||
if oldChID := old.getChannelID(); oldChID != 0 &&
|
||||
c.getChannelID() == 0 && readableChannelIDs[oldChID] {
|
||||
c.mu.Lock()
|
||||
c.channelID = oldChID
|
||||
c.mu.Unlock()
|
||||
}
|
||||
}
|
||||
// Fresh connections (lastSeq == 0): do NOT transfer voice state.
|
||||
// Stale voice cleanup (DB + broadcast + LiveKit) is owned entirely
|
||||
// by the handshake path in serve.go, which runs before registerNow.
|
||||
// registerNow only handles in-memory client replacement.
|
||||
|
||||
// Kick the stale connection atomically before registering
|
||||
// the new one — prevents TOCTOU races on duplicate login.
|
||||
// closeSend MUST precede UnsubscribeAll: Subscribe refuses clients
|
||||
// whose send is closed, so this ordering leaves the old connection's
|
||||
// in-flight handlers no window to re-take a stripped topic.
|
||||
slog.Warn("hub: kicking stale connection for re-registering user",
|
||||
"user_id", c.userID, "last_seq", c.lastSeq)
|
||||
old.closeSend()
|
||||
|
||||
// Remove the old client from all pub/sub topics before replacing.
|
||||
h.pubsub.UnsubscribeAll(old)
|
||||
}
|
||||
h.clients[c.userID] = c
|
||||
|
||||
// Subscribe the new client to its default pub/sub topics immediately
|
||||
// after UnsubscribeAll(old) above, with nothing in between.
|
||||
//
|
||||
// This does NOT make strip+resubscribe atomic, and must not be read as
|
||||
// doing so: the two are separate ps.mu acquisitions, and PublishGlobal
|
||||
// takes ps.mu alone (never h.mu), so a deliverBroadcast landing between
|
||||
// them still finds no subscriber for this user. That frame is
|
||||
// unrecoverable — its seq was already allocated and pushed to the replay
|
||||
// buffer, the resuming client's replay snapshot was taken even earlier,
|
||||
// and the client tracks only max(seq), so the next frame silently
|
||||
// advances past the hole. Only a caller holding h.seqMu closes that
|
||||
// window; see this function's doc comment and serve.go's handleReconnect.
|
||||
//
|
||||
// What the ordering does buy is the smallest possible gap for the callers
|
||||
// that cannot hold seqMu — the fresh-connect path, whose buildReady
|
||||
// rebuilds state from the DB afterwards, and the clientEvents path, which
|
||||
// runs on the hub goroutine and so cannot race deliverBroadcast at all.
|
||||
// The registration log line (a syscall-backed slog call) and
|
||||
// updateKeyHolder (keyHolderMu plus a full h.clients scan under
|
||||
// h.mu.RLock) both used to sit in that gap; both now run after the
|
||||
// subscribes. Keeping the subscribes under h.mu is incidental but free:
|
||||
// pubsub uses its own independent lock and never calls back into the hub,
|
||||
// so h.mu → ps.mu adds no lock-ordering risk.
|
||||
h.pubsub.Subscribe(c, TopicGlobal)
|
||||
h.pubsub.Subscribe(c, UserTopic(c.userID))
|
||||
// If the client already has a focused channel (e.g. test clients created with
|
||||
// NewTestClientWithChannel, or reconnecting clients), subscribe immediately so
|
||||
// deliverBroadcast can reach them without waiting for a channel_focus message.
|
||||
if chID := c.getChannelID(); chID != 0 {
|
||||
h.pubsub.Subscribe(c, ChannelTopic(chID))
|
||||
}
|
||||
// If the client is already in a voice channel (e.g. reconnect), restore its
|
||||
// subscriptions without a new voice_join (a same-channel rejoin is rejected
|
||||
// with ALREADY_JOINED) or channel_focus.
|
||||
if voiceChID := c.getVoiceChID(); voiceChID != 0 {
|
||||
// VoiceTopic is the only transport for voice_e2ee_announce relays and
|
||||
// carries nothing else, for a channel the user already joined via the
|
||||
// CONNECT_VOICE-gated voice_join — so no READ gate.
|
||||
h.pubsub.Subscribe(c, VoiceTopic(voiceChID))
|
||||
// Voice membership is gated on CONNECT_VOICE alone, so it must not by
|
||||
// itself grant a channel's message stream: subscribe only when the
|
||||
// handshake confirmed READ_MESSAGES on that channel.
|
||||
if readableChannelIDs[voiceChID] {
|
||||
h.pubsub.Subscribe(c, ChannelTopic(voiceChID))
|
||||
}
|
||||
}
|
||||
total := len(h.clients)
|
||||
h.mu.Unlock()
|
||||
|
||||
slog.Info("hub: client registered", "user_id", c.userID, "total_clients", total)
|
||||
|
||||
// A fresh connect (lastSeq == 0) drops the replaced connection's voice state
|
||||
// without transferring it, so that channel just lost a participant and the
|
||||
// E2EE key holder may need to move. handleVoiceLeave never runs on this path
|
||||
// — readPump skips it when replaced, and it early-returns on already-cleared
|
||||
// state — so re-elect here. Must be outside h.mu: updateKeyHolder takes
|
||||
// keyHolderMu and then h.mu.RLock. The recompute reads live client voice
|
||||
// state, so it is idempotent and also correct when the state was transferred.
|
||||
// It runs after the subscribe block above; updateKeyHolder only reads
|
||||
// h.clients' voice state and writes voiceKeyHolders, so it has no
|
||||
// ordering dependency on pub/sub subscriptions.
|
||||
if replacedVoiceChID != 0 {
|
||||
h.updateKeyHolder(replacedVoiceChID)
|
||||
}
|
||||
|
||||
// Re-sync this connection's local E2EE peer-key map now that it is
|
||||
// reachable (OC-0276). voice_e2ee_announce is delivered as an
|
||||
// unsequenced pub/sub frame (sendToVoiceChannelExcept, voice_e2ee.go),
|
||||
// bypassing deliverBroadcast/h.replayBuf entirely — so on a network
|
||||
// reconnect (the transfer above), neither reconnect replay tier can ever
|
||||
// redeliver a peer's key, or a mid-call key rotation, that was announced
|
||||
// while this socket was down. voiceJoinComplete's relay
|
||||
// (voice_join.go) only runs on a brand-new voice_join, never here, so
|
||||
// without this call a resumed connection's peer-key map would silently
|
||||
// and permanently desync from its (correctly replayed) voice roster.
|
||||
// c.getVoiceChID() reflects the transfer above, so this covers a
|
||||
// resumed connection as well as a client pre-set into a voice channel
|
||||
// (e.g. NewTestClientWithChannel); it is a no-op whenever c is not
|
||||
// currently in a voice channel, which is the common case (fresh login).
|
||||
if voiceChID := c.getVoiceChID(); voiceChID != 0 {
|
||||
h.sendVoicePeerKeys(c, voiceChID)
|
||||
// Re-relay THIS client's own stored key back onto VoiceTopic (OC-0316).
|
||||
// voice_e2ee_offer (the room-key-bearing message) is a targeted,
|
||||
// unsequenced send that is silently dropped if this socket was down
|
||||
// when it went out (sendToUserIfInVoiceChannel, voice_e2ee.go) — and
|
||||
// unlike voice_e2ee_announce it has no reconnect-replay recovery
|
||||
// path either. A key rotation sent during the outage otherwise
|
||||
// strands this client on a dead key with no signal and no retry
|
||||
// until the key holder's next periodic rotation. The client's
|
||||
// duplicate-announce handling already re-wraps and re-offers the
|
||||
// CURRENT room key whenever it sees a peer announce a key it
|
||||
// already knows, so re-announcing our own (unchanged) key is enough
|
||||
// to make the key holder re-offer — no client change needed.
|
||||
if key, sig := c.getE2EEPubKey(); key != "" {
|
||||
h.sendToVoiceChannelExcept(voiceChID, c.userID, buildVoiceE2EEAnnounce(c.userID, key, sig))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Hub) unregisterNow(c *Client) bool {
|
||||
h.mu.Lock()
|
||||
current, exists := h.clients[c.userID]
|
||||
if exists && current == c {
|
||||
delete(h.clients, c.userID)
|
||||
slog.Info("hub: client unregistered", "user_id", c.userID, "total_clients", len(h.clients))
|
||||
h.mu.Unlock()
|
||||
h.pubsub.UnsubscribeAll(c)
|
||||
return false // not replaced
|
||||
}
|
||||
h.mu.Unlock()
|
||||
// exists means a *different* client holds the slot — a genuine replacement,
|
||||
// whose teardown must not mark the live connection's user offline. An absent
|
||||
// entry means this client was already kicked (every kick path deletes it via
|
||||
// kickClient), which is a real disconnect and still needs the offline
|
||||
// presence broadcast and voice cleanup in readPump's defer.
|
||||
return exists
|
||||
}
|
||||
|
||||
// shouldMarkOffline reports whether a disconnect teardown should run
|
||||
// MarkUserDisconnected and broadcast an offline presence for c's user.
|
||||
//
|
||||
// `replaced` (unregisterNow's return, sampled once at the start of teardown)
|
||||
// is necessary but not sufficient: both readPump's defer and
|
||||
// unregisterFailedHandshake sample it BEFORE handleVoiceLeave, which can
|
||||
// block for seconds (DB delete, audience scan, a LiveKit call bounded by
|
||||
// lkTimeout=5s). A reconnect landing during that window registers a new
|
||||
// client for the same user and is invisible to the stale boolean, so the
|
||||
// dead connection's teardown would otherwise mark the live session offline
|
||||
// (OC-0019). Re-checking h.clients at decision time closes that gap: any
|
||||
// entry present once c has been removed is necessarily a newer connection —
|
||||
// unregisterNow only ever deletes c's own slot, never someone else's.
|
||||
func (h *Hub) shouldMarkOffline(c *Client, replaced bool) bool {
|
||||
return !replaced && h.GetClient(c.userID) == nil
|
||||
}
|
||||
|
||||
// ClientCount returns the number of currently registered clients (test helper).
|
||||
func (h *Hub) ClientCount() int {
|
||||
h.mu.RLock()
|
||||
@@ -752,30 +348,6 @@ func (h *Hub) ConnRejectCount() uint64 {
|
||||
return h.connRejects.Load()
|
||||
}
|
||||
|
||||
// ConfigureReplay resizes the reconnect replay budget: the in-memory ring and
|
||||
// the persisted-event cap per reconnect (event_persistence.replay_ring_size /
|
||||
// replay_cold_limit). Zero or negative values keep the compiled-in defaults.
|
||||
// Must be called before Run — the dispatch loop reads replayBuf unlocked.
|
||||
func (h *Hub) ConfigureReplay(ringSize, coldLimit int) {
|
||||
if h.rejectIfRunning("ConfigureReplay") {
|
||||
return
|
||||
}
|
||||
if ringSize > 0 {
|
||||
h.replayBuf = NewEventRingBuffer(ringSize)
|
||||
}
|
||||
if coldLimit > 0 {
|
||||
h.coldReplayLimit = coldLimit
|
||||
}
|
||||
}
|
||||
|
||||
// maxColdReplayLimit returns the effective persisted-replay cap.
|
||||
func (h *Hub) maxColdReplayLimit() int {
|
||||
if h.coldReplayLimit > 0 {
|
||||
return h.coldReplayLimit
|
||||
}
|
||||
return maxColdReplay
|
||||
}
|
||||
|
||||
// EventPersisterStats returns the attached persister's lifetime counters.
|
||||
// ok is false when event persistence is disabled (no persister attached).
|
||||
func (h *Hub) EventPersisterStats() (persisted, dropped, flushes, errs uint64, ok bool) {
|
||||
@@ -787,32 +359,6 @@ func (h *Hub) EventPersisterStats() (persisted, dropped, flushes, errs uint64, o
|
||||
return persisted, dropped, flushes, errs, true
|
||||
}
|
||||
|
||||
// VoiceSessionCount returns the number of clients currently in a voice channel.
|
||||
func (h *Hub) VoiceSessionCount() int {
|
||||
h.mu.RLock()
|
||||
defer h.mu.RUnlock()
|
||||
count := 0
|
||||
for _, c := range h.clients {
|
||||
if c.getVoiceChID() != 0 {
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
// rejectIfRunning reports whether Run has already started, logging an error
|
||||
// when it has. Plain-field setters must be wired before Run: the dispatch
|
||||
// loop and connection goroutines read those fields without synchronization,
|
||||
// so a late set would be a data race. Late calls are dropped.
|
||||
func (h *Hub) rejectIfRunning(setter string) bool {
|
||||
if h.running.Load() {
|
||||
slog.Error("ws: setter called after Hub.Run started; ignoring (must be wired before Run)",
|
||||
"setter", setter)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// topicRateLimitPerSecond is the default maximum messages per second for any
|
||||
// single channel topic. Prevents a busy channel from saturating the broadcast
|
||||
// loop and starving other channels.
|
||||
|
||||
@@ -6,7 +6,6 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/J3vb/OwnCord/Server/db"
|
||||
"github.com/J3vb/OwnCord/Server/permissions"
|
||||
"github.com/J3vb/OwnCord/Server/telemetry"
|
||||
)
|
||||
|
||||
@@ -80,69 +79,6 @@ func (h *Hub) BroadcastToAllExcept(excludeUserID int64, msg []byte) {
|
||||
}
|
||||
}
|
||||
|
||||
// broadcastVoiceEvent enqueues a voice_state / voice_leave message for the
|
||||
// connected clients whose current role may READ channelID.
|
||||
//
|
||||
// These events used to go out via BroadcastToAll, which handed every
|
||||
// authenticated client the membership and camera/mute state of voice channels
|
||||
// that channel_overrides hides from their role — while the equivalent read path
|
||||
// (buildReady) deliberately filters voice states to readable channels. Tagging
|
||||
// the event with its real channel id also makes reconnect replay filter it,
|
||||
// where a channelID of 0 was replayed unconditionally.
|
||||
//
|
||||
// The audience is resolved here, on the caller's goroutine, so the hub's
|
||||
// dispatch loop never blocks on permission lookups.
|
||||
func (h *Hub) broadcastVoiceEvent(ctx context.Context, channelID int64, msg []byte) {
|
||||
// A room's own participants must always receive its voice_state /
|
||||
// voice_leave: voice membership is gated on CONNECT_VOICE alone, so the
|
||||
// READ filter can exclude a live participant — whose client then keeps a
|
||||
// stale E2EE key holder, stalling rotation and locking new joiners out
|
||||
// until e2ee_timeout. Union the READ audience with the room's current
|
||||
// participants; what outsiders may observe is unchanged.
|
||||
audience := h.channelReadAudience(ctx, channelID)
|
||||
seen := make(map[int64]struct{}, len(audience))
|
||||
for _, uid := range audience {
|
||||
seen[uid] = struct{}{}
|
||||
}
|
||||
h.mu.RLock()
|
||||
for uid, c := range h.clients {
|
||||
if _, ok := seen[uid]; !ok && c.getVoiceChID() == channelID {
|
||||
audience = append(audience, uid)
|
||||
}
|
||||
}
|
||||
h.mu.RUnlock()
|
||||
h.broadcastChannelScopedTo(channelID, msg, audience, "voice event")
|
||||
}
|
||||
|
||||
// broadcastVoiceEventWithLeaver is broadcastVoiceEvent extended to guarantee
|
||||
// leaverID is in the audience even though the caller has already cleared
|
||||
// their client-side voice state — which means broadcastVoiceEvent's own
|
||||
// still-in-the-room participant union can no longer see them. Every path
|
||||
// that tears down a voice participant whose client state is cleared before
|
||||
// the voice_leave goes out needs this: voice membership is gated on
|
||||
// CONNECT_VOICE alone, so a leaver without READ_MESSAGES on the channel
|
||||
// would otherwise never learn the server already ended their call. Mirrors
|
||||
// CleanupVoiceForChannel's per-batch leaver union, for the single-leaver case.
|
||||
func (h *Hub) broadcastVoiceEventWithLeaver(ctx context.Context, channelID int64, msg []byte, leaverID int64) {
|
||||
audience := h.channelReadAudience(ctx, channelID)
|
||||
seen := make(map[int64]struct{}, len(audience)+1)
|
||||
for _, uid := range audience {
|
||||
seen[uid] = struct{}{}
|
||||
}
|
||||
h.mu.RLock()
|
||||
for uid, c := range h.clients {
|
||||
if _, ok := seen[uid]; !ok && c.getVoiceChID() == channelID {
|
||||
seen[uid] = struct{}{}
|
||||
audience = append(audience, uid)
|
||||
}
|
||||
}
|
||||
h.mu.RUnlock()
|
||||
if _, ok := seen[leaverID]; !ok {
|
||||
audience = append(audience, leaverID)
|
||||
}
|
||||
h.broadcastChannelScopedTo(channelID, msg, audience, "voice event")
|
||||
}
|
||||
|
||||
// broadcastChannelScoped enqueues msg for exactly the connected clients whose
|
||||
// current role may READ channelID, tagged with that channel id so reconnect
|
||||
// replay filters it too (EventsSinceFiltered replays a channelID of 0
|
||||
@@ -173,138 +109,6 @@ func (h *Hub) broadcastChannelScopedTo(channelID int64, msg []byte, recipients [
|
||||
}
|
||||
}
|
||||
|
||||
// channelReadAudience returns the connected user IDs whose current role may READ
|
||||
// channelID. Always non-nil, so an empty result means "deliver to nobody"
|
||||
// rather than "no filter". Each user's verdict comes from the cached
|
||||
// PermissionService when the hub has one (one in-memory lookup per connected
|
||||
// user; a miss repopulates from the user's CURRENT role, so a mid-session
|
||||
// reassignment is still honored). Caching is safe here because revocation is
|
||||
// delivered synchronously at every mutation site: a role change calls
|
||||
// InvalidateUser (admin/handlers_users.go) and a channel-override change calls
|
||||
// InvalidateAll (admin/handlers_channel_perms.go) before the hub fan-out runs,
|
||||
// with the 30s cache TTL as a backstop; the F6 gen-counter guard in the service
|
||||
// prevents a populate racing an invalidation from caching stale data. Fails
|
||||
// closed: a client whose role cannot be resolved is left out. Bare test hubs
|
||||
// without a service fall back to live per-call lookups, memoised for the
|
||||
// duration of the call. Mirrors RefreshChannelVisibility, which resolves
|
||||
// visibility the same way.
|
||||
func (h *Hub) channelReadAudience(ctx context.Context, channelID int64) []int64 {
|
||||
return h.channelReadAudienceImpl(ctx, channelID, false)
|
||||
}
|
||||
|
||||
// channelReadAudienceIgnoringArchived is channelReadAudience without the
|
||||
// Archived short-circuit (OC-0022). CleanupVoiceForChannel's only two
|
||||
// callers (admin/handlers_channels.go's archive and delete paths) always
|
||||
// commit archived=1 to the channel before evicting its voice participants —
|
||||
// deliberately, per admin/api_test.go's
|
||||
// TestAdminAPI_DeleteChannel_ArchivesBeforeVoiceCleanup, so a concurrent
|
||||
// voice_join sees the archived gate. That means channelReadAudience's own
|
||||
// Archived check, evaluated from CleanupVoiceForChannel, always sees the
|
||||
// channel already archived and always returns nobody: the voice_leave that
|
||||
// should tell every bystander who could see the room a moment ago that the
|
||||
// call ended never reaches them, only the evicted participants themselves
|
||||
// (added back by CleanupVoiceForChannel's own loop). This resolves that same
|
||||
// pre-archival READ audience for exactly that one broadcast, leaving every
|
||||
// other channelReadAudience call site (and its archived-channel behavior)
|
||||
// untouched.
|
||||
func (h *Hub) channelReadAudienceIgnoringArchived(ctx context.Context, channelID int64) []int64 {
|
||||
return h.channelReadAudienceImpl(ctx, channelID, true)
|
||||
}
|
||||
|
||||
func (h *Hub) channelReadAudienceImpl(ctx context.Context, channelID int64, ignoreArchived bool) []int64 {
|
||||
h.mu.RLock()
|
||||
userIDs := make([]int64, 0, len(h.clients))
|
||||
for uid := range h.clients {
|
||||
userIDs = append(userIDs, uid)
|
||||
}
|
||||
h.mu.RUnlock()
|
||||
|
||||
// A DM channel carries no channel_overrides rows, so every connected
|
||||
// user whose base role holds READ_MESSAGES would otherwise pass the role
|
||||
// scan below — leaking a private DM call's voice_state/voice_leave
|
||||
// events to the whole server. Resolve the DM's real audience (its
|
||||
// participants, intersected with who is actually connected) instead,
|
||||
// mirroring the IsDMParticipant membership rule hasChannelAccess uses.
|
||||
var ref permissions.ChannelRef
|
||||
if h.db != nil {
|
||||
ch, err := h.db.GetChannel(ctx, channelID)
|
||||
if err != nil {
|
||||
// Fail closed: an unresolvable channel must not fall through to
|
||||
// the role scan, which would treat it as a readable non-DM channel.
|
||||
slog.Error("ws: channelReadAudience GetChannel failed, denying",
|
||||
"channel_id", channelID, "err", err)
|
||||
return []int64{}
|
||||
}
|
||||
// Fail closed on a missing row too (OC-0090): GetChannel returns
|
||||
// (nil, nil) for a deleted channel, and falling through would hand a
|
||||
// channel with no override rows left to the role scan below — which
|
||||
// resolves to every connected user with base READ_MESSAGES, leaking
|
||||
// e.g. a closed group-DM's voice_leave server-wide. Callers that
|
||||
// tear down voice union the room's participants and the leaver back
|
||||
// in afterwards, so eviction/E2EE-teardown signals still arrive.
|
||||
if ch == nil {
|
||||
return []int64{}
|
||||
}
|
||||
if ch.Type == "dm" {
|
||||
return h.channelReadAudienceDM(ctx, channelID, userIDs)
|
||||
}
|
||||
ref = channelRef(ch)
|
||||
// CanViewChannel hides an archived channel from everyone, mirroring
|
||||
// RefreshChannelVisibility and VisibleChannelIDs: without that, an
|
||||
// admin edit to an archived channel (or a voice teardown inside one)
|
||||
// would fan out to every connected user whose base role holds
|
||||
// READ_MESSAGES, none of whom have the channel in their sidebar.
|
||||
// ignoreArchived resolves the pre-archival audience instead — see
|
||||
// channelReadAudienceIgnoringArchived.
|
||||
if ignoreArchived {
|
||||
ref.Archived = false
|
||||
}
|
||||
}
|
||||
|
||||
// Resolved per USER, not memoised per role: channel_user_overrides is the
|
||||
// last layer of the resolution order, so two members of the same role can
|
||||
// legitimately disagree about one channel and a per-role memo would hand
|
||||
// one of them the other's verdict. The verdict is CanViewChannel over
|
||||
// subjectFor (cached service or live checker); an unresolvable user is
|
||||
// left out.
|
||||
audience := make([]int64, 0, len(userIDs))
|
||||
for _, uid := range userIDs {
|
||||
sub, err := h.subjectFor(ctx, uid, channelID)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
sub.Channel = ref
|
||||
if permissions.CanViewChannel(sub) == nil {
|
||||
audience = append(audience, uid)
|
||||
}
|
||||
}
|
||||
return audience
|
||||
}
|
||||
|
||||
// channelReadAudienceDM resolves the audience of a DM channel: the DM's
|
||||
// participants, intersected with the connected userIDs. Split verbatim out of
|
||||
// channelReadAudienceImpl; the reason a DM must not fall through to the role
|
||||
// scan is on the call site.
|
||||
func (h *Hub) channelReadAudienceDM(ctx context.Context, channelID int64, userIDs []int64) []int64 {
|
||||
participantIDs, err := h.db.GetDMParticipantIDs(ctx, channelID)
|
||||
if err != nil {
|
||||
slog.Error("ws: channelReadAudience GetDMParticipantIDs failed, denying",
|
||||
"channel_id", channelID, "err", err)
|
||||
return []int64{}
|
||||
}
|
||||
connected := make(map[int64]struct{}, len(userIDs))
|
||||
for _, uid := range userIDs {
|
||||
connected[uid] = struct{}{}
|
||||
}
|
||||
audience := make([]int64, 0, len(participantIDs))
|
||||
for _, uid := range participantIDs {
|
||||
if _, ok := connected[uid]; ok {
|
||||
audience = append(audience, uid)
|
||||
}
|
||||
}
|
||||
return audience
|
||||
}
|
||||
|
||||
// BroadcastServerRestart sends a server_restart message to all connected clients.
|
||||
// reason describes why the server is restarting (e.g., "update").
|
||||
// delaySeconds tells clients how long until the server actually shuts down.
|
||||
@@ -354,167 +158,6 @@ func (h *Hub) BroadcastChannelDelete(channelID int64) {
|
||||
// voiceJoinPostTokenRaceHook / cleanupVoiceRaceClearHook pattern.
|
||||
var refreshChannelVisibilityRaceHook func(userID int64)
|
||||
|
||||
// RefreshChannelVisibility re-evaluates which connected clients may see ch
|
||||
// after a channel_overrides change and sends targeted channel_create /
|
||||
// channel_delete messages so sidebars converge without a reconnect. Clients
|
||||
// that lose visibility are also unsubscribed from the channel topic and have
|
||||
// their focused channel cleared so live messages stop flowing.
|
||||
//
|
||||
// The sends deliberately bypass the sequenced broadcast/replay path: a
|
||||
// replayed channel_delete would be filtered by the allowed-channel set
|
||||
// computed at replay time, which after an override change is exactly the
|
||||
// inverse of the intended audience. Clients tolerate seq-less messages.
|
||||
func (h *Hub) RefreshChannelVisibility(ch *db.Channel) {
|
||||
if ch == nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Bump the watermark immediately, before the h.clients snapshot below and
|
||||
// the (potentially slow — up to two DB round trips per connected client)
|
||||
// fan-out loop that follows it. A reconnect handshake re-checks this
|
||||
// watermark right before it registers (OC-0206); bumping only at the end,
|
||||
// after the loop, left a window where that re-check could still observe
|
||||
// the pre-change value even though this function's snapshot — taken next
|
||||
// — will never include a client that registers mid-loop. Ratcheted
|
||||
// upward only (see bumpVisibilityWatermark), so this is a no-op whenever
|
||||
// a concurrent writer already pushed the watermark higher; the trailing
|
||||
// bump below still runs and covers any change to h.seq made during the
|
||||
// loop itself.
|
||||
h.bumpVisibilityWatermark()
|
||||
|
||||
h.mu.RLock()
|
||||
clients := make([]*Client, 0, len(h.clients))
|
||||
for _, c := range h.clients {
|
||||
clients = append(clients, c)
|
||||
}
|
||||
h.mu.RUnlock()
|
||||
|
||||
// Called via the admin HubBroadcaster interface, which carries no context;
|
||||
// the targeted re-sync must complete regardless of the triggering request.
|
||||
ctx := context.Background()
|
||||
|
||||
// Visibility is CanViewChannel — the single predicate shared with
|
||||
// buildReady / REST ListVisibleChannels — resolved per user from their
|
||||
// CURRENT role (c.user is a connect-time snapshot). With a
|
||||
// PermissionService the subject comes from the per-user cache — safe
|
||||
// because the admin handlers invalidate (InvalidateAll on override
|
||||
// change, InvalidateUser on role change) before calling into the hub, so
|
||||
// the lookups below repopulate from post-change data; the 30s TTL is only
|
||||
// a backstop and the F6 gen-counter guard keeps a racing populate from
|
||||
// caching stale rows. Without a service (bare test hubs) each client is
|
||||
// resolved live. Fails closed: an unresolvable role loses visibility
|
||||
// rather than keeping a stale grant.
|
||||
//
|
||||
// Deliberately NOT memoised per role: channel_user_overrides is the last
|
||||
// layer of the resolution order, so two members of the same role can
|
||||
// legitimately disagree about one channel — exactly the case a per-user
|
||||
// override edit creates, and exactly the fan-out this function targets.
|
||||
for _, c := range clients {
|
||||
if c.user == nil {
|
||||
continue
|
||||
}
|
||||
sub, err := h.subjectFor(ctx, c.user.ID, ch.ID)
|
||||
if err != nil {
|
||||
slog.Warn("hub: RefreshChannelVisibility could not resolve permissions, revoking",
|
||||
"user_id", c.user.ID, "channel_id", ch.ID, "err", err)
|
||||
}
|
||||
sub.Channel = channelRef(ch)
|
||||
visible := err == nil && permissions.CanViewChannel(sub) == nil
|
||||
|
||||
if refreshChannelVisibilityRaceHook != nil {
|
||||
refreshChannelVisibilityRaceHook(c.user.ID)
|
||||
}
|
||||
|
||||
// Re-resolve the live client immediately before acting: the permission
|
||||
// lookups above (a PermissionService call, or two DB round trips in the
|
||||
// bare-hub branch) give a reconnect room to replace this user's *Client
|
||||
// in h.clients with a new connection under the same user ID. Acting on
|
||||
// the stale snapshot pointer c would target a dead socket, and
|
||||
// Unsubscribe would be a no-op — unsubscribeLocked's identity guard
|
||||
// leaves a topic alone when the current holder differs from the client
|
||||
// passed in — stranding the replacement with a subscription (or a
|
||||
// missing one) exactly inverted from what this fan-out just decided.
|
||||
// A nil result means the user disconnected entirely since the
|
||||
// snapshot; nothing to act on.
|
||||
live := h.GetClient(c.user.ID)
|
||||
if live == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if visible {
|
||||
// Idempotent add on the client; also refreshes channel metadata.
|
||||
// Addressed per client so it can carry this recipient's own
|
||||
// can_send verdict — the whole point of this fan-out is that a
|
||||
// permission change just made those verdicts diverge.
|
||||
live.sendMsg(buildChannelCreateFor(ch, h.refreshChannelVisibilityCanSend(ctx, ch, c.user.ID)))
|
||||
continue
|
||||
}
|
||||
live.sendMsg(buildChannelDelete(ch.ID))
|
||||
h.pubsub.Unsubscribe(live, ChannelTopic(ch.ID))
|
||||
live.mu.Lock()
|
||||
if live.channelID == ch.ID {
|
||||
live.channelID = 0
|
||||
}
|
||||
live.mu.Unlock()
|
||||
}
|
||||
|
||||
// Clients not connected right now missed the targeted sends above. Move
|
||||
// the watermark so any resume from a seq at or before this point is
|
||||
// forced onto the full-ready path instead of replay. Ratcheted upward
|
||||
// only — see bumpVisibilityWatermark — so a concurrent writer that read
|
||||
// an older seq cannot regress a watermark another writer already pushed
|
||||
// higher.
|
||||
h.bumpVisibilityWatermark()
|
||||
}
|
||||
|
||||
// refreshChannelVisibilityCanSend is the can_send verdict the ready payload
|
||||
// ships per channel (channelCanSend), recomputed for one live user from their
|
||||
// CURRENT role: permissions.CanSendMessage over the subject subjectFor
|
||||
// resolves in either the service or the bare-hub branch, failing closed on a
|
||||
// lookup error (S-12).
|
||||
//
|
||||
// Without this, can_send is only ever computed at connect time, so a role
|
||||
// edit or override edit leaves every connected client's composer stuck on
|
||||
// its stale connect-time verdict until the socket is rebuilt.
|
||||
func (h *Hub) refreshChannelVisibilityCanSend(ctx context.Context, ch *db.Channel, userID int64) bool {
|
||||
sub, err := h.subjectFor(ctx, userID, ch.ID)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
sub.Channel = channelRef(ch)
|
||||
return permissions.CanSendMessage(sub) == nil
|
||||
}
|
||||
|
||||
// RefreshAllChannelVisibility re-runs RefreshChannelVisibility for every
|
||||
// non-DM channel. A role's permission mask is the base every channel's
|
||||
// effective permission is computed from, so editing or deleting a role can
|
||||
// change visibility of *any* channel at once — where a channel_overrides edit
|
||||
// touches exactly one. DM channels are skipped: their access is participant-
|
||||
// based and no role change can alter it.
|
||||
//
|
||||
// Called via the admin HubBroadcaster interface (no context), so the channel
|
||||
// list is read against Background — the re-sync must complete regardless of the
|
||||
// triggering request. The caller invalidates the permission cache first, as the
|
||||
// channel-override handlers do, so the per-client lookups below repopulate from
|
||||
// post-change data.
|
||||
func (h *Hub) RefreshAllChannelVisibility() {
|
||||
if h.db == nil {
|
||||
return
|
||||
}
|
||||
ctx := context.Background()
|
||||
channels, err := h.db.ListChannels(ctx)
|
||||
if err != nil {
|
||||
slog.Warn("hub: RefreshAllChannelVisibility could not list channels", "err", err)
|
||||
return
|
||||
}
|
||||
for i := range channels {
|
||||
if channels[i].Type == "dm" {
|
||||
continue
|
||||
}
|
||||
h.RefreshChannelVisibility(&channels[i])
|
||||
}
|
||||
}
|
||||
|
||||
// BroadcastRolesUpdate sends the full role list to every connected client so
|
||||
// name colors and permission-gated affordances converge without a reconnect.
|
||||
//
|
||||
@@ -597,151 +240,6 @@ func (h *Hub) BroadcastUserUpdate(u UserUpdate) {
|
||||
h.BroadcastToAll(buildUserUpdate(u))
|
||||
}
|
||||
|
||||
// presenceCoalesceWindow is how long QueuePresence buffers connect/disconnect
|
||||
// presence before flushing. Long enough to collapse a socket flap
|
||||
// (disconnect+reconnect through a proxy blip) into one frame, short enough
|
||||
// that a genuine arrival still looks immediate to humans.
|
||||
const presenceCoalesceWindow = 300 * time.Millisecond
|
||||
|
||||
// pendingPresence is the coalescer's latest-wins entry for one user.
|
||||
type pendingPresence struct {
|
||||
status string
|
||||
customStatus *string
|
||||
}
|
||||
|
||||
// QueuePresence coalesces connect/disconnect presence broadcasts: the latest
|
||||
// state per user is buffered for presenceCoalesceWindow and then flushed via
|
||||
// BroadcastPresence. Each un-coalesced presence change is a sequenced global
|
||||
// broadcast — an O(connected clients) fan-out under seqMu — so a reconnect
|
||||
// storm (proxy blip, deploy, network hiccup) used to fire O(users) of them
|
||||
// from the connect critical path all at once. Latest-wins is exactly
|
||||
// presence's semantics: a flap inside the window collapses to its final
|
||||
// state, and the flushed frames are ordinary sequenced presence messages, so
|
||||
// the wire format and replay behaviour are unchanged. User-chosen status
|
||||
// changes (presence_update handler) do not pass through here.
|
||||
func (h *Hub) QueuePresence(userID int64, status string, customStatus *string) {
|
||||
h.presenceMu.Lock()
|
||||
if h.presenceQueue == nil {
|
||||
h.presenceQueue = make(map[int64]pendingPresence)
|
||||
}
|
||||
h.presenceQueue[userID] = pendingPresence{status: status, customStatus: customStatus}
|
||||
armed := h.presenceFlushArmed
|
||||
h.presenceFlushArmed = true
|
||||
h.presenceMu.Unlock()
|
||||
if !armed {
|
||||
time.AfterFunc(presenceCoalesceWindow, h.flushPresenceQueue)
|
||||
}
|
||||
}
|
||||
|
||||
// dropQueuedPresenceAndBroadcast atomically removes any coalesced presence
|
||||
// still queued for userID and runs broadcast, both under presenceMu. Called
|
||||
// when a fresher presence for that user is delivered directly (the
|
||||
// presence_update handler path, via EmitEvents), so the delete and the send
|
||||
// of the fresher frame can never straddle flushPresenceQueue's own
|
||||
// snapshot-and-broadcast critical section (OC-0005).
|
||||
//
|
||||
// Holding presenceMu across the delete AND the broadcast — rather than just
|
||||
// the delete — is what actually closes the race: whichever of this call and
|
||||
// flushPresenceQueue acquires presenceMu second also enqueues its broadcast
|
||||
// second.
|
||||
// - If this call goes first, it deletes the entry before flush can ever
|
||||
// snapshot it, so flush never broadcasts the stale state at all.
|
||||
// - If flush goes first, this call's delete is a no-op against the
|
||||
// already-cleared queue, but its broadcast still cannot run until flush's
|
||||
// own broadcast has already been enqueued — so the fresher frame is
|
||||
// stamped with the higher seq by deliverBroadcast's single FIFO consumer
|
||||
// and every client's final view converges on it, not the stale one.
|
||||
//
|
||||
// broadcast runs with presenceMu held: every current caller (BroadcastToAll,
|
||||
// BroadcastToAllExcept) only enqueues onto h.broadcast's non-blocking
|
||||
// channel send, so this cannot block and introduces no new lock-order edge.
|
||||
// Both callers sharing that same channel also means the "enqueues second"
|
||||
// ordering guarantee above translates directly into delivery order: both
|
||||
// broadcasts are drained by the same single-consumer hub dispatch loop
|
||||
// (deliverBroadcast), in the order they were enqueued.
|
||||
func (h *Hub) dropQueuedPresenceAndBroadcast(userID int64, broadcast func()) {
|
||||
h.presenceMu.Lock()
|
||||
defer h.presenceMu.Unlock()
|
||||
delete(h.presenceQueue, userID)
|
||||
broadcast()
|
||||
}
|
||||
|
||||
// presenceFlushRaceHook, when non-nil, runs once per flushPresenceQueue call
|
||||
// immediately after the coalesced queue has been snapshotted and cleared,
|
||||
// while presenceMu is still held. Test-only (always nil in production): the
|
||||
// snapshot-to-broadcast window is too narrow to land a real concurrent
|
||||
// dropQueuedPresenceAndBroadcast reliably, so tests use this hook to
|
||||
// reproduce that interleaving deterministically. Mirrors the established
|
||||
// refreshChannelVisibilityRaceHook / voiceJoinPostTokenRaceHook pattern.
|
||||
//
|
||||
// It is handed the Hub being flushed, and a test that installs it MUST
|
||||
// ignore calls for any other Hub. The hook is package-global but flushes are
|
||||
// per-Hub, and QueuePresence's AfterFunc outlives the test that armed it:
|
||||
// dropQueuedPresenceAndBroadcast clears the queue without disarming the
|
||||
// timer, so a sibling test's 300ms flush fires long after that test returned
|
||||
// — into whatever hook is installed by then. Passing the Hub is what lets the
|
||||
// installer tell its own flush from that stray one; without it, a hook body
|
||||
// that is only safe to run once (closing a channel, say) panics.
|
||||
var presenceFlushRaceHook func(*Hub)
|
||||
|
||||
// flushPresenceQueue drains the coalescer and broadcasts each user's latest
|
||||
// presence, all under presenceMu (OC-0005). Runs on the AfterFunc timer
|
||||
// goroutine.
|
||||
//
|
||||
// presenceMu is held across the broadcast loop, not just the snapshot: it
|
||||
// used to be released beforehand, which let a concurrent
|
||||
// dropQueuedPresenceAndBroadcast (nee dropQueuedPresence) call race in after
|
||||
// the snapshot had already escaped the lock. The drop was then a guaranteed
|
||||
// no-op against the live (already-nilled) map, AND nothing constrained
|
||||
// whether that call's own fresher broadcast landed on h.broadcast before or
|
||||
// after this loop's stale one — so the stale connect-time presence could win
|
||||
// the seq race and permanently overwrite a user-chosen status. Holding the
|
||||
// lock here forces the two critical sections to serialize, which is what
|
||||
// dropQueuedPresenceAndBroadcast's ordering guarantee depends on.
|
||||
func (h *Hub) flushPresenceQueue() {
|
||||
h.presenceMu.Lock()
|
||||
defer h.presenceMu.Unlock()
|
||||
queued := h.presenceQueue
|
||||
h.presenceQueue = nil
|
||||
h.presenceFlushArmed = false
|
||||
if presenceFlushRaceHook != nil {
|
||||
presenceFlushRaceHook(h)
|
||||
}
|
||||
for uid, p := range queued {
|
||||
h.BroadcastPresence(uid, p.status, p.customStatus)
|
||||
}
|
||||
}
|
||||
|
||||
// BroadcastPresence fans a presence change out with the invisible mapping
|
||||
// applied: everyone else sees db.BroadcastStatus(status), the user themselves
|
||||
// sees the truth. It is the non-handler counterpart of presenceEvents, used by
|
||||
// the connect and disconnect paths (via the QueuePresence coalescer, which
|
||||
// delivers through here).
|
||||
func (h *Hub) BroadcastPresence(userID int64, status string, customStatus *string) {
|
||||
public := db.BroadcastStatus(status)
|
||||
if public == status {
|
||||
h.BroadcastToAll(buildPresenceMsg(userID, status, customStatus))
|
||||
return
|
||||
}
|
||||
// The public frame's status already collapsed to db.BroadcastStatus, but
|
||||
// customStatus does not: passing it through verbatim would tell every
|
||||
// other client an "offline" member's real free-text status, which is a
|
||||
// tell that they are actually online. Blank it explicitly (not omitted —
|
||||
// presencePayload.CustomStatus has no omitempty) so the client clears any
|
||||
// cached text, matching what db.MemberSummary.ForViewer already does for
|
||||
// the ready payload's member list.
|
||||
//
|
||||
// Normal priority, excluding the owner (BroadcastToAllExcept), not
|
||||
// broadcastExcludeLow: the low-priority queue is unsequenced and dropped
|
||||
// (not disconnected) on overflow, so it could silently lose this frame
|
||||
// with no replay recovery, and — since writePump always drains normal
|
||||
// strictly before low — deliver it out of order against the very
|
||||
// connect/disconnect presence frames this same coalescer flush also
|
||||
// produces for other users via BroadcastToAll (OC-0003).
|
||||
h.BroadcastToAllExcept(userID, buildPresenceMsg(userID, public, nil))
|
||||
h.SendToUser(userID, buildPresenceMsg(userID, status, customStatus))
|
||||
}
|
||||
|
||||
// BroadcastMemberUpdate sends a member_update message to all connected clients
|
||||
// and re-evaluates the reassigned user's live channel subscriptions.
|
||||
func (h *Hub) BroadcastMemberUpdate(userID int64, roleName string) {
|
||||
@@ -770,112 +268,6 @@ func (h *Hub) BroadcastMemberUpdate(userID int64, roleName string) {
|
||||
// pattern as refreshChannelVisibilityRaceHook.
|
||||
var revokeUnreadableChannelsPreActRaceHook func(userID int64)
|
||||
|
||||
func (h *Hub) revokeUnreadableChannels(userID int64) {
|
||||
// Ratcheted upward only (see bumpVisibilityWatermark), and evaluated at
|
||||
// defer-RUN time — not the plain Store(Load(&h.seq)) this used to be,
|
||||
// whose argument would have been evaluated at this defer STATEMENT,
|
||||
// capturing entry-time seq and stomping any higher watermark stored by a
|
||||
// concurrent writer during the per-topic DB loop below. Deferred because
|
||||
// it must cover the early returns too: a user who is offline, or whose
|
||||
// socket is closed below, converges via the full-ready path.
|
||||
defer h.bumpVisibilityWatermark()
|
||||
|
||||
// Also bump immediately, before the h.clients lookup below and the
|
||||
// per-topic DB loop (a GetChannel round trip per revoked topic) that
|
||||
// follows it — see RefreshChannelVisibility's matching early bump and
|
||||
// OC-0206. Ratcheted upward only, so this is a no-op whenever a
|
||||
// concurrent writer already pushed the watermark higher; the deferred
|
||||
// bump above still covers every return path, including the early ones.
|
||||
h.bumpVisibilityWatermark()
|
||||
|
||||
if h.db == nil {
|
||||
return
|
||||
}
|
||||
h.mu.RLock()
|
||||
c, ok := h.clients[userID]
|
||||
h.mu.RUnlock()
|
||||
if !ok || c.user == nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Called via the admin HubBroadcaster interface, which carries no context;
|
||||
// the re-evaluation must complete regardless of the triggering request.
|
||||
ctx := context.Background()
|
||||
|
||||
// c.user is a connect-time snapshot and the role just changed, so resolve
|
||||
// the current user — and through it the current role — from the DB.
|
||||
var allowed map[int64]bool
|
||||
user, err := h.db.GetUserByID(ctx, userID)
|
||||
if err == nil && user != nil {
|
||||
// Same predicate as the ready payload and reconnect replay filtering.
|
||||
allowed, err = h.computeAllowedChannels(ctx, h.db, user)
|
||||
}
|
||||
if err != nil || user == nil {
|
||||
// Visibility unresolved. Keeping the old subscriptions would leak, and
|
||||
// revoking them all would hollow out a sidebar the user may still be
|
||||
// entitled to, so close the socket instead: the client reconnects and
|
||||
// rebuilds from a ready payload computed with the new role. kickClient
|
||||
// rather than DisconnectUser — the latter sends a BANNED error, which
|
||||
// makes the client clear its credentials instead of reconnecting.
|
||||
slog.Warn("hub: role change visibility unresolved, closing socket",
|
||||
"user_id", userID, "err", err)
|
||||
// Re-resolve before kicking: the lookups above are DB round trips a
|
||||
// reconnect can overlap, and kicking the stale snapshot would close a
|
||||
// dead socket while the replacement keeps its subscriptions.
|
||||
if live := h.GetClient(userID); live != nil {
|
||||
h.kickClient(live)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
for _, topic := range h.pubsub.TopicsForClient(userID) {
|
||||
chID := channelTopicID(topic)
|
||||
if chID == 0 || allowed[chID] {
|
||||
continue
|
||||
}
|
||||
// DM access is gated on dm_participants, which no role change can
|
||||
// alter, while allowed sources DMs from dm_open_state — a DM the user
|
||||
// has closed (or every DM, if the DM lookup inside
|
||||
// computeAllowedChannels failed) is missing from allowed even though
|
||||
// its subscription is still legitimate. Never revoke a DM topic here;
|
||||
// on a lookup error close the socket rather than guess.
|
||||
ch, chErr := h.db.GetChannel(ctx, chID)
|
||||
if chErr != nil {
|
||||
slog.Warn("hub: role change channel lookup failed, closing socket",
|
||||
"user_id", userID, "channel_id", chID, "err", chErr)
|
||||
if live := h.GetClient(userID); live != nil {
|
||||
h.kickClient(live)
|
||||
}
|
||||
return
|
||||
}
|
||||
if ch != nil && ch.Type == "dm" {
|
||||
continue
|
||||
}
|
||||
if revokeUnreadableChannelsPreActRaceHook != nil {
|
||||
revokeUnreadableChannelsPreActRaceHook(userID)
|
||||
}
|
||||
// Re-resolve the live client immediately before acting: the DB round
|
||||
// trips above (and computeAllowedChannels before the loop) give a
|
||||
// reconnect room to replace this user's *Client in h.clients. Acting
|
||||
// on the snapshot c would target the dead socket, and Unsubscribe
|
||||
// would no-op on unsubscribeLocked's identity guard — stranding the
|
||||
// replacement with the revoked topic (audit-2026-08-19 F-2; mirrors
|
||||
// RefreshChannelVisibility's live re-resolve). A nil result means the
|
||||
// user disconnected entirely; nothing left to revoke.
|
||||
live := h.GetClient(userID)
|
||||
if live == nil {
|
||||
return
|
||||
}
|
||||
live.sendMsg(buildChannelDelete(chID))
|
||||
h.pubsub.Unsubscribe(live, topic)
|
||||
live.mu.Lock()
|
||||
if live.channelID == chID {
|
||||
live.channelID = 0
|
||||
}
|
||||
live.mu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
// SendToUser delivers msg directly to the client identified by userID.
|
||||
// Returns true if the client was found and the message was queued.
|
||||
func (h *Hub) SendToUser(userID int64, msg []byte) bool {
|
||||
|
||||
+5
-11
@@ -53,20 +53,14 @@ func (h *Hub) SetEventStore(s EventStore) {
|
||||
h.eventStore.Store(&s)
|
||||
}
|
||||
|
||||
// SetPluginRegistry wires the plugin.Registry so the hub can dispatch
|
||||
// slash commands (chat_command messages) to plugin-owned handlers.
|
||||
// Pass nil to disable plugin command dispatch. Must be called before Run;
|
||||
// late calls are ignored with an error log.
|
||||
func (h *Hub) SetPluginRegistry(r *plugin.Registry) {
|
||||
if h.rejectIfRunning("SetPluginRegistry") {
|
||||
return
|
||||
}
|
||||
h.pluginRegistry = r
|
||||
}
|
||||
|
||||
// SetPluginEventSink wires the plugin.EventSink so the hub fans out each
|
||||
// sequenced broadcast to subscribed plugins. Pass nil to disable. Safe to
|
||||
// call at any time, including after Run has started.
|
||||
//
|
||||
// Deliberately still a setter after B3-4: the sink consumes the built hub's
|
||||
// broadcaster (sink.SetBroadcaster(hub.BroadcastToChannel)), so it cannot
|
||||
// exist before the hub does — a genuine two-phase wire, unlike the registry,
|
||||
// which is a HubOptions field.
|
||||
func (h *Hub) SetPluginEventSink(s *plugin.EventSink) {
|
||||
h.pluginSink.Store(s)
|
||||
}
|
||||
|
||||
@@ -5,18 +5,9 @@ import (
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// SetLiveKit sets the LiveKit client on the hub. Must be called before Run;
|
||||
// late calls are ignored with an error log.
|
||||
func (h *Hub) SetLiveKit(lk *LiveKitClient) {
|
||||
if h.rejectIfRunning("SetLiveKit") {
|
||||
return
|
||||
}
|
||||
h.livekit = lk
|
||||
}
|
||||
|
||||
// GenerateToken delegates to the LiveKit client. Returns an error if LiveKit
|
||||
// is not configured. Satisfies VoiceTokenGenerator so the Hub can be passed
|
||||
// as a dep at registration time (before SetLiveKit is called).
|
||||
// is not configured (HubOptions.LiveKit was nil). Satisfies
|
||||
// VoiceTokenGenerator so the Hub can be passed as a dep at registration time.
|
||||
func (h *Hub) GenerateToken(userID int64, username string, channelID int64, voiceJoinToken string, canPublish, canSubscribe, canVideo, canScreenShare bool) (string, error) {
|
||||
if h.livekit == nil {
|
||||
return "", fmt.Errorf("voice not configured")
|
||||
@@ -43,11 +34,6 @@ func (h *Hub) LiveKitHealthCheck(ctx context.Context) (bool, error) {
|
||||
return h.livekit.HealthCheck(ctx)
|
||||
}
|
||||
|
||||
// SetLiveKitProcess sets the LiveKit process manager on the hub. Must be
|
||||
// called before Run; late calls are ignored with an error log.
|
||||
func (h *Hub) SetLiveKitProcess(p *LiveKitProcess) {
|
||||
if h.rejectIfRunning("SetLiveKitProcess") {
|
||||
return
|
||||
}
|
||||
h.lkProcess = p
|
||||
}
|
||||
// The LiveKit process manager arrives via HubOptions.LiveKitProcess (B3-4);
|
||||
// its only hub consumer is the voice_join guard reading IsRunning to fail
|
||||
// closed while the supervised SFU is down.
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
package ws
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/J3vb/OwnCord/Server/auth"
|
||||
"github.com/J3vb/OwnCord/Server/db"
|
||||
"github.com/J3vb/OwnCord/Server/permissions"
|
||||
"github.com/J3vb/OwnCord/Server/plugin"
|
||||
"github.com/J3vb/OwnCord/Server/service"
|
||||
)
|
||||
|
||||
// HubOptions carries everything a Hub needs before Run starts (S-11 / B3-4).
|
||||
// The four pre-Run setters this struct replaced (SetLiveKit,
|
||||
// SetLiveKitProcess, SetPluginRegistry, ConfigureReplay) were all guarded by
|
||||
// rejectIfRunning — construction-phase wiring pretending to be mutable state.
|
||||
// What genuinely IS mutable after Run stays a setter: the event persister,
|
||||
// event store and plugin event sink are atomic hot-swaps that the app wires
|
||||
// after the dispatch loop starts (internal/app/persistence.go), and
|
||||
// SetPendingVoiceModFlags is per-user runtime state.
|
||||
type HubOptions struct {
|
||||
// DB and Limiter are required: every dispatch path reads the database,
|
||||
// and the handler deps capture the limiter at registration. NewHub
|
||||
// refuses to build a Hub without them.
|
||||
DB *db.DB
|
||||
Limiter *auth.RateLimiter
|
||||
|
||||
// Services is the domain layer V2 handlers delegate to. Production
|
||||
// always passes it; nil is the degraded fixture many ws tests build,
|
||||
// where handlers keep their direct-DB fallback paths.
|
||||
Services *service.Services
|
||||
|
||||
// Settings is required: the hub's settings cache (server name and MOTD
|
||||
// on every auth_ok) reads through it instead of the raw handle — the
|
||||
// B3-8 settings family owns those reads. Production passes
|
||||
// Services.Settings; test helpers default it over the test database.
|
||||
Settings SettingsReader
|
||||
|
||||
// LiveKit is the voice token signer; nil means voice is not configured
|
||||
// and every voice join is refused. LiveKitProcess is the supervised
|
||||
// companion SFU — it requires LiveKit, because a process no client can
|
||||
// sign tokens for is unusable, and the voice_join guard reads a non-nil
|
||||
// process's IsRunning to fail closed while it is down.
|
||||
LiveKit *LiveKitClient
|
||||
LiveKitProcess *LiveKitProcess
|
||||
|
||||
// PluginRegistry enables plugin slash-command dispatch; nil disables it.
|
||||
// The plugin event sink is NOT here: it consumes the built hub's
|
||||
// broadcaster, so it stays the two-phase SetPluginEventSink.
|
||||
PluginRegistry *plugin.Registry
|
||||
|
||||
// Replay budget (event_persistence.replay_ring_size / replay_cold_limit).
|
||||
// Zero keeps the compiled-in defaults; negative is refused rather than
|
||||
// silently ignored.
|
||||
ReplayRingSize int
|
||||
ReplayColdLimit int
|
||||
}
|
||||
|
||||
// NewHub creates a Hub ready to be started with Run, validating that the
|
||||
// required collaborators are present — before B3-4, construction always
|
||||
// succeeded and a missing collaborator surfaced as a later panic or a
|
||||
// silently refused setter call. It also initializes the settings cache from
|
||||
// the database. If opts.Services is non-nil, V2 handlers receive service
|
||||
// references for business logic delegation.
|
||||
func NewHub(opts HubOptions) (*Hub, error) {
|
||||
if opts.DB == nil {
|
||||
return nil, errors.New("ws: HubOptions.DB is required (every dispatch path reads it)")
|
||||
}
|
||||
if opts.Limiter == nil {
|
||||
return nil, errors.New("ws: HubOptions.Limiter is required (handler deps capture it at registration)")
|
||||
}
|
||||
if opts.Settings == nil {
|
||||
return nil, errors.New("ws: HubOptions.Settings is required (the settings cache reads through it)")
|
||||
}
|
||||
if opts.LiveKitProcess != nil && opts.LiveKit == nil {
|
||||
return nil, errors.New("ws: HubOptions.LiveKitProcess without LiveKit — a supervised SFU no client can sign tokens for")
|
||||
}
|
||||
if opts.ReplayRingSize < 0 || opts.ReplayColdLimit < 0 {
|
||||
return nil, fmt.Errorf("ws: negative replay budget (ring %d, cold %d)", opts.ReplayRingSize, opts.ReplayColdLimit)
|
||||
}
|
||||
|
||||
database, limiter, svc := opts.DB, opts.Limiter, opts.Services
|
||||
settingsReader := opts.Settings
|
||||
|
||||
ringSize := 1000
|
||||
if opts.ReplayRingSize > 0 {
|
||||
ringSize = opts.ReplayRingSize
|
||||
}
|
||||
|
||||
reg := NewHandlerRegistry()
|
||||
|
||||
h := &Hub{
|
||||
clients: make(map[int64]*Client),
|
||||
db: database,
|
||||
limiter: limiter,
|
||||
settings: settingsReader,
|
||||
broadcast: make(chan broadcastMsg, 1024),
|
||||
clientEvents: make(chan clientEvent, 64),
|
||||
stop: make(chan struct{}),
|
||||
pubsub: NewPubSub(),
|
||||
topicLimiter: NewTopicRateLimiter(topicRateLimitPerSecond, time.Second),
|
||||
replayBuf: NewEventRingBuffer(ringSize),
|
||||
registry: reg,
|
||||
permChecker: permissions.NewChecker(database),
|
||||
settingsName: "OwnCord Server",
|
||||
settingsMotd: "Welcome!",
|
||||
voiceKeyHolders: make(map[int64]int64),
|
||||
fatalFn: func() { os.Exit(1) },
|
||||
livekit: opts.LiveKit,
|
||||
lkProcess: opts.LiveKitProcess,
|
||||
pluginRegistry: opts.PluginRegistry,
|
||||
}
|
||||
if opts.ReplayColdLimit > 0 {
|
||||
h.coldReplayLimit = opts.ReplayColdLimit
|
||||
}
|
||||
|
||||
// V2 handler registrations (need Hub fields for deps).
|
||||
registerPingHandler(reg, PingDeps{Limiter: h.limiter})
|
||||
|
||||
chatDeps := ChatDeps{
|
||||
Limiter: h.limiter,
|
||||
}
|
||||
presenceDeps := PresenceDeps{
|
||||
Limiter: h.limiter,
|
||||
}
|
||||
reactionDeps := ReactionDeps{}
|
||||
callDeps := CallDeps{Limiter: h.limiter}
|
||||
if svc != nil {
|
||||
chatDeps.MessageSvc = svc.Messages
|
||||
presenceDeps.ChannelSvc = svc.Channels
|
||||
reactionDeps.MessageSvc = svc.Messages
|
||||
callDeps.DMSvc = svc.DMs
|
||||
h.messageSvc = svc.Messages
|
||||
h.perms = svc.Permissions
|
||||
// So @here's offline narrowing can tell a disconnected idle/dnd reader
|
||||
// (users.status keeps their last *chosen* value across a disconnect)
|
||||
// from one who is actually still connected — the same live-connection
|
||||
// rule presentableMembers applies to the members array.
|
||||
svc.Messages.SetOnlineChecker(h.IsUserConnected)
|
||||
// So every DM payload DMService builds (GET/POST /dms, POST
|
||||
// /dms/group, PATCH /dms/{id}, and every broadcastDMOpen refresh)
|
||||
// applies the same live-connection rule instead of only the ready
|
||||
// payload's presentableDMChannels doing so (OC-0304).
|
||||
svc.DMs.SetOnlineChecker(h.IsUserConnected)
|
||||
}
|
||||
|
||||
registerChatHandlers(reg, chatDeps)
|
||||
registerPresenceHandlers(reg, presenceDeps)
|
||||
registerReactionHandlers(reg, reactionDeps)
|
||||
registerCallHandlers(reg, callDeps)
|
||||
// Phase C Step 9 — plugin slash commands. The registry closure predates
|
||||
// B3-4 (the registry used to arrive via a post-construction setter); it
|
||||
// stays a closure for the nil-interface reason below. MessageSvc gates
|
||||
// broadcasts.
|
||||
reg.RegisterV2(MsgTypeChatCommand, handleChatCommandV2, PluginDeps{
|
||||
// A nil registry must yield a nil interface, not a typed-nil
|
||||
// *plugin.Registry — the handler's "no plugins loaded" check is an
|
||||
// interface comparison.
|
||||
Registry: func() CommandDispatcher {
|
||||
if h.pluginRegistry == nil {
|
||||
return nil
|
||||
}
|
||||
return h.pluginRegistry
|
||||
},
|
||||
MessageSvc: h.messageSvc,
|
||||
Limiter: h.limiter,
|
||||
})
|
||||
registerVoiceControlsV2(reg, VoiceDeps{
|
||||
DB: h.db,
|
||||
Limiter: h.limiter,
|
||||
Permissions: h.permChecker,
|
||||
PermSvc: h.perms,
|
||||
LiveKit: h.livekit,
|
||||
TokenGen: h, // Hub delegates to h.livekit at call time
|
||||
KeyHolder: h,
|
||||
Mod: h,
|
||||
})
|
||||
|
||||
h.refreshSettingsLocked(context.Background())
|
||||
return h, nil
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
package ws
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/J3vb/OwnCord/Server/db"
|
||||
)
|
||||
|
||||
// pendingPresence is the coalescer's latest-wins entry for one user.
|
||||
type pendingPresence struct {
|
||||
status string
|
||||
customStatus *string
|
||||
}
|
||||
|
||||
// QueuePresence coalesces connect/disconnect presence broadcasts: the latest
|
||||
// state per user is buffered for presenceCoalesceWindow and then flushed via
|
||||
// BroadcastPresence. Each un-coalesced presence change is a sequenced global
|
||||
// broadcast — an O(connected clients) fan-out under seqMu — so a reconnect
|
||||
// storm (proxy blip, deploy, network hiccup) used to fire O(users) of them
|
||||
// from the connect critical path all at once. Latest-wins is exactly
|
||||
// presence's semantics: a flap inside the window collapses to its final
|
||||
// state, and the flushed frames are ordinary sequenced presence messages, so
|
||||
// the wire format and replay behaviour are unchanged. User-chosen status
|
||||
// changes (presence_update handler) do not pass through here.
|
||||
func (h *Hub) QueuePresence(userID int64, status string, customStatus *string) {
|
||||
h.presenceMu.Lock()
|
||||
if h.presenceQueue == nil {
|
||||
h.presenceQueue = make(map[int64]pendingPresence)
|
||||
}
|
||||
h.presenceQueue[userID] = pendingPresence{status: status, customStatus: customStatus}
|
||||
armed := h.presenceFlushArmed
|
||||
h.presenceFlushArmed = true
|
||||
h.presenceMu.Unlock()
|
||||
if !armed {
|
||||
time.AfterFunc(presenceCoalesceWindow, h.flushPresenceQueue)
|
||||
}
|
||||
}
|
||||
|
||||
// dropQueuedPresenceAndBroadcast atomically removes any coalesced presence
|
||||
// still queued for userID and runs broadcast, both under presenceMu. Called
|
||||
// when a fresher presence for that user is delivered directly (the
|
||||
// presence_update handler path, via EmitEvents), so the delete and the send
|
||||
// of the fresher frame can never straddle flushPresenceQueue's own
|
||||
// snapshot-and-broadcast critical section (OC-0005).
|
||||
//
|
||||
// Holding presenceMu across the delete AND the broadcast — rather than just
|
||||
// the delete — is what actually closes the race: whichever of this call and
|
||||
|
||||
// presenceCoalesceWindow is how long QueuePresence buffers connect/disconnect
|
||||
// presence before flushing. Long enough to collapse a socket flap
|
||||
// (disconnect+reconnect through a proxy blip) into one frame, short enough
|
||||
// that a genuine arrival still looks immediate to humans.
|
||||
const presenceCoalesceWindow = 300 * time.Millisecond
|
||||
|
||||
// presenceFlushRaceHook, when non-nil, runs once per flushPresenceQueue call
|
||||
// immediately after the coalesced queue has been snapshotted and cleared,
|
||||
// while presenceMu is still held. Test-only (always nil in production): the
|
||||
// snapshot-to-broadcast window is too narrow to land a real concurrent
|
||||
// dropQueuedPresenceAndBroadcast reliably, so tests use this hook to
|
||||
// reproduce that interleaving deterministically. Mirrors the established
|
||||
// refreshChannelVisibilityRaceHook / voiceJoinPostTokenRaceHook pattern.
|
||||
//
|
||||
// It is handed the Hub being flushed, and a test that installs it MUST
|
||||
// ignore calls for any other Hub. The hook is package-global but flushes are
|
||||
// per-Hub, and QueuePresence's AfterFunc outlives the test that armed it:
|
||||
// dropQueuedPresenceAndBroadcast clears the queue without disarming the
|
||||
// timer, so a sibling test's 300ms flush fires long after that test returned
|
||||
// — into whatever hook is installed by then. Passing the Hub is what lets the
|
||||
// installer tell its own flush from that stray one; without it, a hook body
|
||||
// that is only safe to run once (closing a channel, say) panics.
|
||||
var presenceFlushRaceHook func(*Hub)
|
||||
|
||||
// flushPresenceQueue acquires presenceMu second also enqueues its broadcast
|
||||
// second.
|
||||
// - If this call goes first, it deletes the entry before flush can ever
|
||||
// snapshot it, so flush never broadcasts the stale state at all.
|
||||
// - If flush goes first, this call's delete is a no-op against the
|
||||
// already-cleared queue, but its broadcast still cannot run until flush's
|
||||
// own broadcast has already been enqueued — so the fresher frame is
|
||||
// stamped with the higher seq by deliverBroadcast's single FIFO consumer
|
||||
// and every client's final view converges on it, not the stale one.
|
||||
//
|
||||
// broadcast runs with presenceMu held: every current caller (BroadcastToAll,
|
||||
// BroadcastToAllExcept) only enqueues onto h.broadcast's non-blocking
|
||||
// channel send, so this cannot block and introduces no new lock-order edge.
|
||||
// Both callers sharing that same channel also means the "enqueues second"
|
||||
// ordering guarantee above translates directly into delivery order: both
|
||||
// broadcasts are drained by the same single-consumer hub dispatch loop
|
||||
// (deliverBroadcast), in the order they were enqueued.
|
||||
func (h *Hub) dropQueuedPresenceAndBroadcast(userID int64, broadcast func()) {
|
||||
h.presenceMu.Lock()
|
||||
defer h.presenceMu.Unlock()
|
||||
delete(h.presenceQueue, userID)
|
||||
broadcast()
|
||||
}
|
||||
|
||||
// flushPresenceQueue drains the coalescer and broadcasts each user's latest
|
||||
// presence, all under presenceMu (OC-0005). Runs on the AfterFunc timer
|
||||
// goroutine.
|
||||
//
|
||||
// presenceMu is held across the broadcast loop, not just the snapshot: it
|
||||
// used to be released beforehand, which let a concurrent
|
||||
// dropQueuedPresenceAndBroadcast (nee dropQueuedPresence) call race in after
|
||||
// the snapshot had already escaped the lock. The drop was then a guaranteed
|
||||
// no-op against the live (already-nilled) map, AND nothing constrained
|
||||
// whether that call's own fresher broadcast landed on h.broadcast before or
|
||||
// after this loop's stale one — so the stale connect-time presence could win
|
||||
// the seq race and permanently overwrite a user-chosen status. Holding the
|
||||
// lock here forces the two critical sections to serialize, which is what
|
||||
// dropQueuedPresenceAndBroadcast's ordering guarantee depends on.
|
||||
func (h *Hub) flushPresenceQueue() {
|
||||
h.presenceMu.Lock()
|
||||
defer h.presenceMu.Unlock()
|
||||
queued := h.presenceQueue
|
||||
h.presenceQueue = nil
|
||||
h.presenceFlushArmed = false
|
||||
if presenceFlushRaceHook != nil {
|
||||
presenceFlushRaceHook(h)
|
||||
}
|
||||
for uid, p := range queued {
|
||||
h.BroadcastPresence(uid, p.status, p.customStatus)
|
||||
}
|
||||
}
|
||||
|
||||
// BroadcastPresence fans a presence change out with the invisible mapping
|
||||
// applied: everyone else sees db.BroadcastStatus(status), the user themselves
|
||||
// sees the truth. It is the non-handler counterpart of presenceEvents, used by
|
||||
// the connect and disconnect paths (via the QueuePresence coalescer, which
|
||||
// delivers through here).
|
||||
func (h *Hub) BroadcastPresence(userID int64, status string, customStatus *string) {
|
||||
public := db.BroadcastStatus(status)
|
||||
if public == status {
|
||||
h.BroadcastToAll(buildPresenceMsg(userID, status, customStatus))
|
||||
return
|
||||
}
|
||||
// The public frame's status already collapsed to db.BroadcastStatus, but
|
||||
// customStatus does not: passing it through verbatim would tell every
|
||||
// other client an "offline" member's real free-text status, which is a
|
||||
// tell that they are actually online. Blank it explicitly (not omitted —
|
||||
// presencePayload.CustomStatus has no omitempty) so the client clears any
|
||||
// cached text, matching what db.MemberSummary.ForViewer already does for
|
||||
// the ready payload's member list.
|
||||
//
|
||||
// Normal priority, excluding the owner (BroadcastToAllExcept), not
|
||||
// broadcastExcludeLow: the low-priority queue is unsequenced and dropped
|
||||
// (not disconnected) on overflow, so it could silently lose this frame
|
||||
// with no replay recovery, and — since writePump always drains normal
|
||||
// strictly before low — deliver it out of order against the very
|
||||
// connect/disconnect presence frames this same coalescer flush also
|
||||
// produces for other users via BroadcastToAll (OC-0003).
|
||||
h.BroadcastToAllExcept(userID, buildPresenceMsg(userID, public, nil))
|
||||
h.SendToUser(userID, buildPresenceMsg(userID, status, customStatus))
|
||||
}
|
||||
@@ -44,7 +44,7 @@ func TestRefreshChannelVisibility_ReconnectDuringFanOutActsOnLiveClient(t *testi
|
||||
|
||||
// Bare hub (svc=nil): h.perms is nil, so RefreshChannelVisibility takes the
|
||||
// GetUserByID+GetRoleByID branch this test targets.
|
||||
h := NewHub(database, auth.NewRateLimiter(), nil)
|
||||
h := newTestHub(t, database, auth.NewRateLimiter(), nil)
|
||||
|
||||
user, err := database.GetUserByID(ctx, uid)
|
||||
if err != nil || user == nil {
|
||||
|
||||
@@ -0,0 +1,253 @@
|
||||
package ws
|
||||
|
||||
import "log/slog"
|
||||
|
||||
// Register queues a client for registration with the hub.
|
||||
func (h *Hub) Register(c *Client) {
|
||||
h.clientEvents <- clientEvent{c: c, add: true}
|
||||
}
|
||||
|
||||
// Unregister queues a client for removal from the hub.
|
||||
func (h *Hub) Unregister(c *Client) {
|
||||
h.clientEvents <- clientEvent{c: c}
|
||||
}
|
||||
|
||||
// registerNow adds c to the hub and subscribes it to its topics.
|
||||
//
|
||||
// readableChannelIDs is the set of channels the user holds READ_MESSAGES on,
|
||||
// as computed by the handshake (serve.go). It gates the inherited voice-channel
|
||||
// subscription only; a nil set denies it (fail closed).
|
||||
//
|
||||
// Replacing an existing connection strips its subscriptions (UnsubscribeAll)
|
||||
// and re-subscribes the new one (Subscribe) as two separate PubSub-lock
|
||||
// acquisitions — back to back, but not atomic. A caller that must not lose a
|
||||
// broadcast concurrently racing the replacement (i.e. one deliverBroadcast
|
||||
// could deliver in the gap between those two acquisitions) has to call this
|
||||
// while holding h.seqMu, the same lock deliverBroadcast holds for its entire
|
||||
// critical section (seq allocation, replay-buffer push, and publish) — that
|
||||
// serializes the two entirely, rather than merely narrowing the window. See
|
||||
// serve.go's handleReconnect, which re-reads the replay tail and calls
|
||||
// registerNow inside one h.seqMu section for exactly this reason.
|
||||
func (h *Hub) registerNow(c *Client, readableChannelIDs map[int64]bool) {
|
||||
// Voice channel the replaced connection was in, if any. Re-elected below,
|
||||
// after the hub lock is released.
|
||||
var replacedVoiceChID int64
|
||||
|
||||
h.mu.Lock()
|
||||
if old, exists := h.clients[c.userID]; exists {
|
||||
oldE2EEKey, oldE2EESig := old.getE2EEPubKey()
|
||||
oldVoiceChID, oldVoiceJoinToken, oldVoiceJoinCompleted := old.clearVoiceState()
|
||||
replacedVoiceChID = oldVoiceChID
|
||||
// A moderator-imposed mute/deafen stashed by voice_mod_move
|
||||
// (setPendingModFlags) lives ONLY on the old *Client between the
|
||||
// target's eviction (which deletes the voice_states row that state
|
||||
// normally lives in) and the target's own re-join, which consumes it
|
||||
// via takePendingModFlags (voice_join.go). Any client replacement —
|
||||
// reconnect or full resync alike — must carry it to the new *Client
|
||||
// or it is silently destroyed and the mute is lost (OC-0302).
|
||||
// Unlike the voice-state transfer below, this has none of the
|
||||
// voiceJoinCompleted supersession concerns, so it is not gated on
|
||||
// c.lastSeq > 0: take-and-clear leaves nothing behind for old to
|
||||
// double-serve, and a stash nobody set is always (false, false).
|
||||
if pendingMuted, pendingDeafened := old.takePendingModFlags(); pendingMuted || pendingDeafened {
|
||||
c.setPendingModFlags(pendingMuted, pendingDeafened)
|
||||
}
|
||||
if c.lastSeq > 0 {
|
||||
// Network reconnect — preserve voice state so the user stays
|
||||
// in voice during brief WS drops.
|
||||
//
|
||||
// Gated on oldVoiceJoinCompleted (OC-0270): a join that
|
||||
// voiceJoinPersist has merely committed to the DB and set on the
|
||||
// old client, but that voiceJoinComplete has not yet finished, is
|
||||
// still racing its own supersession guards in voice_join.go
|
||||
// (voice_join.go:423, :470) — both compare the old client's live
|
||||
// voiceChID/voiceJoinToken against the values captured when the
|
||||
// join started. Clearing the old client's state above as part of
|
||||
// this very transfer makes those guards read as "superseded" and
|
||||
// abort the join (no token delivered, no voice_state broadcast,
|
||||
// no VoiceTopic subscribe) — while the DB row and the new
|
||||
// client's transferred state still agree, so sweepStaleVoiceStates
|
||||
// never reaps it. Transferring only a completed join avoids
|
||||
// resurrecting exactly that half-finished state; an incomplete
|
||||
// one instead leaves the new client with voiceChID 0, so the
|
||||
// still-committed row now disagrees with hub state and the next
|
||||
// sweep tick reaps it, letting the user rejoin.
|
||||
if c.getVoiceChID() == 0 && oldVoiceJoinCompleted {
|
||||
c.setVoiceState(oldVoiceChID, oldVoiceJoinToken)
|
||||
// c.setVoiceState above resets the fresh-join-in-progress flag
|
||||
// it defaults to; restore it since we just verified the old
|
||||
// client's join over this same (chID, token) had completed.
|
||||
c.markVoiceJoinCompleteIfMatch(oldVoiceChID, oldVoiceJoinToken)
|
||||
// The announced ECDH key must survive with the voice state:
|
||||
// the client keeps its keypair across a WS blip and only
|
||||
// re-announces on a LiveKit-room reconnect, so without the
|
||||
// transfer voice_join replays nothing for this user and new
|
||||
// joiners' key exchanges time out.
|
||||
c.setE2EEPubKey(oldE2EEKey, oldE2EESig)
|
||||
}
|
||||
// The focused channel must transfer too: the client never
|
||||
// re-sends channel_focus on a resume (mountChannel early-returns
|
||||
// on the same channel), so without it the ChannelTopic
|
||||
// re-subscribe below is a no-op and the message stream dies
|
||||
// silently. READ-gated like every ChannelTopic subscription;
|
||||
// a nil set denies (fail closed).
|
||||
if oldChID := old.getChannelID(); oldChID != 0 &&
|
||||
c.getChannelID() == 0 && readableChannelIDs[oldChID] {
|
||||
c.mu.Lock()
|
||||
c.channelID = oldChID
|
||||
c.mu.Unlock()
|
||||
}
|
||||
}
|
||||
// Fresh connections (lastSeq == 0): do NOT transfer voice state.
|
||||
// Stale voice cleanup (DB + broadcast + LiveKit) is owned entirely
|
||||
// by the handshake path in serve.go, which runs before registerNow.
|
||||
// registerNow only handles in-memory client replacement.
|
||||
|
||||
// Kick the stale connection atomically before registering
|
||||
// the new one — prevents TOCTOU races on duplicate login.
|
||||
// closeSend MUST precede UnsubscribeAll: Subscribe refuses clients
|
||||
// whose send is closed, so this ordering leaves the old connection's
|
||||
// in-flight handlers no window to re-take a stripped topic.
|
||||
slog.Warn("hub: kicking stale connection for re-registering user",
|
||||
"user_id", c.userID, "last_seq", c.lastSeq)
|
||||
old.closeSend()
|
||||
|
||||
// Remove the old client from all pub/sub topics before replacing.
|
||||
h.pubsub.UnsubscribeAll(old)
|
||||
}
|
||||
h.clients[c.userID] = c
|
||||
|
||||
// Subscribe the new client to its default pub/sub topics immediately
|
||||
// after UnsubscribeAll(old) above, with nothing in between.
|
||||
//
|
||||
// This does NOT make strip+resubscribe atomic, and must not be read as
|
||||
// doing so: the two are separate ps.mu acquisitions, and PublishGlobal
|
||||
// takes ps.mu alone (never h.mu), so a deliverBroadcast landing between
|
||||
// them still finds no subscriber for this user. That frame is
|
||||
// unrecoverable — its seq was already allocated and pushed to the replay
|
||||
// buffer, the resuming client's replay snapshot was taken even earlier,
|
||||
// and the client tracks only max(seq), so the next frame silently
|
||||
// advances past the hole. Only a caller holding h.seqMu closes that
|
||||
// window; see this function's doc comment and serve.go's handleReconnect.
|
||||
//
|
||||
// What the ordering does buy is the smallest possible gap for the callers
|
||||
// that cannot hold seqMu — the fresh-connect path, whose buildReady
|
||||
// rebuilds state from the DB afterwards, and the clientEvents path, which
|
||||
// runs on the hub goroutine and so cannot race deliverBroadcast at all.
|
||||
// The registration log line (a syscall-backed slog call) and
|
||||
// updateKeyHolder (keyHolderMu plus a full h.clients scan under
|
||||
// h.mu.RLock) both used to sit in that gap; both now run after the
|
||||
// subscribes. Keeping the subscribes under h.mu is incidental but free:
|
||||
// pubsub uses its own independent lock and never calls back into the hub,
|
||||
// so h.mu → ps.mu adds no lock-ordering risk.
|
||||
h.pubsub.Subscribe(c, TopicGlobal)
|
||||
h.pubsub.Subscribe(c, UserTopic(c.userID))
|
||||
// If the client already has a focused channel (e.g. test clients created with
|
||||
// NewTestClientWithChannel, or reconnecting clients), subscribe immediately so
|
||||
// deliverBroadcast can reach them without waiting for a channel_focus message.
|
||||
if chID := c.getChannelID(); chID != 0 {
|
||||
h.pubsub.Subscribe(c, ChannelTopic(chID))
|
||||
}
|
||||
// If the client is already in a voice channel (e.g. reconnect), restore its
|
||||
// subscriptions without a new voice_join (a same-channel rejoin is rejected
|
||||
// with ALREADY_JOINED) or channel_focus.
|
||||
if voiceChID := c.getVoiceChID(); voiceChID != 0 {
|
||||
// VoiceTopic is the only transport for voice_e2ee_announce relays and
|
||||
// carries nothing else, for a channel the user already joined via the
|
||||
// CONNECT_VOICE-gated voice_join — so no READ gate.
|
||||
h.pubsub.Subscribe(c, VoiceTopic(voiceChID))
|
||||
// Voice membership is gated on CONNECT_VOICE alone, so it must not by
|
||||
// itself grant a channel's message stream: subscribe only when the
|
||||
// handshake confirmed READ_MESSAGES on that channel.
|
||||
if readableChannelIDs[voiceChID] {
|
||||
h.pubsub.Subscribe(c, ChannelTopic(voiceChID))
|
||||
}
|
||||
}
|
||||
total := len(h.clients)
|
||||
h.mu.Unlock()
|
||||
|
||||
slog.Info("hub: client registered", "user_id", c.userID, "total_clients", total)
|
||||
|
||||
// A fresh connect (lastSeq == 0) drops the replaced connection's voice state
|
||||
// without transferring it, so that channel just lost a participant and the
|
||||
// E2EE key holder may need to move. handleVoiceLeave never runs on this path
|
||||
// — readPump skips it when replaced, and it early-returns on already-cleared
|
||||
// state — so re-elect here. Must be outside h.mu: updateKeyHolder takes
|
||||
// keyHolderMu and then h.mu.RLock. The recompute reads live client voice
|
||||
// state, so it is idempotent and also correct when the state was transferred.
|
||||
// It runs after the subscribe block above; updateKeyHolder only reads
|
||||
// h.clients' voice state and writes voiceKeyHolders, so it has no
|
||||
// ordering dependency on pub/sub subscriptions.
|
||||
if replacedVoiceChID != 0 {
|
||||
h.updateKeyHolder(replacedVoiceChID)
|
||||
}
|
||||
|
||||
// Re-sync this connection's local E2EE peer-key map now that it is
|
||||
// reachable (OC-0276). voice_e2ee_announce is delivered as an
|
||||
// unsequenced pub/sub frame (sendToVoiceChannelExcept, voice_e2ee.go),
|
||||
// bypassing deliverBroadcast/h.replayBuf entirely — so on a network
|
||||
// reconnect (the transfer above), neither reconnect replay tier can ever
|
||||
// redeliver a peer's key, or a mid-call key rotation, that was announced
|
||||
// while this socket was down. voiceJoinComplete's relay
|
||||
// (voice_join.go) only runs on a brand-new voice_join, never here, so
|
||||
// without this call a resumed connection's peer-key map would silently
|
||||
// and permanently desync from its (correctly replayed) voice roster.
|
||||
// c.getVoiceChID() reflects the transfer above, so this covers a
|
||||
// resumed connection as well as a client pre-set into a voice channel
|
||||
// (e.g. NewTestClientWithChannel); it is a no-op whenever c is not
|
||||
// currently in a voice channel, which is the common case (fresh login).
|
||||
if voiceChID := c.getVoiceChID(); voiceChID != 0 {
|
||||
h.sendVoicePeerKeys(c, voiceChID)
|
||||
// Re-relay THIS client's own stored key back onto VoiceTopic (OC-0316).
|
||||
// voice_e2ee_offer (the room-key-bearing message) is a targeted,
|
||||
// unsequenced send that is silently dropped if this socket was down
|
||||
// when it went out (sendToUserIfInVoiceChannel, voice_e2ee.go) — and
|
||||
// unlike voice_e2ee_announce it has no reconnect-replay recovery
|
||||
// path either. A key rotation sent during the outage otherwise
|
||||
// strands this client on a dead key with no signal and no retry
|
||||
// until the key holder's next periodic rotation. The client's
|
||||
// duplicate-announce handling already re-wraps and re-offers the
|
||||
// CURRENT room key whenever it sees a peer announce a key it
|
||||
// already knows, so re-announcing our own (unchanged) key is enough
|
||||
// to make the key holder re-offer — no client change needed.
|
||||
if key, sig := c.getE2EEPubKey(); key != "" {
|
||||
h.sendToVoiceChannelExcept(voiceChID, c.userID, buildVoiceE2EEAnnounce(c.userID, key, sig))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Hub) unregisterNow(c *Client) bool {
|
||||
h.mu.Lock()
|
||||
current, exists := h.clients[c.userID]
|
||||
if exists && current == c {
|
||||
delete(h.clients, c.userID)
|
||||
slog.Info("hub: client unregistered", "user_id", c.userID, "total_clients", len(h.clients))
|
||||
h.mu.Unlock()
|
||||
h.pubsub.UnsubscribeAll(c)
|
||||
return false // not replaced
|
||||
}
|
||||
h.mu.Unlock()
|
||||
// exists means a *different* client holds the slot — a genuine replacement,
|
||||
// whose teardown must not mark the live connection's user offline. An absent
|
||||
// entry means this client was already kicked (every kick path deletes it via
|
||||
// kickClient), which is a real disconnect and still needs the offline
|
||||
// presence broadcast and voice cleanup in readPump's defer.
|
||||
return exists
|
||||
}
|
||||
|
||||
// shouldMarkOffline reports whether a disconnect teardown should run
|
||||
// MarkUserDisconnected and broadcast an offline presence for c's user.
|
||||
//
|
||||
// `replaced` (unregisterNow's return, sampled once at the start of teardown)
|
||||
// is necessary but not sufficient: both readPump's defer and
|
||||
// unregisterFailedHandshake sample it BEFORE handleVoiceLeave, which can
|
||||
// block for seconds (DB delete, audience scan, a LiveKit call bounded by
|
||||
// lkTimeout=5s). A reconnect landing during that window registers a new
|
||||
// client for the same user and is invisible to the stale boolean, so the
|
||||
// dead connection's teardown would otherwise mark the live session offline
|
||||
// (OC-0019). Re-checking h.clients at decision time closes that gap: any
|
||||
// entry present once c has been removed is necessarily a newer connection —
|
||||
// unregisterNow only ever deletes c's own slot, never someone else's.
|
||||
func (h *Hub) shouldMarkOffline(c *Client, replaced bool) bool {
|
||||
return !replaced && h.GetClient(c.userID) == nil
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package ws
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
)
|
||||
|
||||
// SettingsReader is the hub's view of the settings family: one keyed read.
|
||||
// service.SettingsService satisfies it in production; test helpers back it
|
||||
// with a service over the test database. Defined on the consumer side so ws
|
||||
// depends on the capability, not the provider.
|
||||
type SettingsReader interface {
|
||||
Setting(ctx context.Context, key string) (string, error)
|
||||
}
|
||||
|
||||
// getCachedSettings returns server_name and motd, refreshing the cache if stale.
|
||||
func (h *Hub) getCachedSettings(ctx context.Context) (string, string) {
|
||||
h.settingsMu.RLock()
|
||||
if time.Since(h.settingsLastUpdate) < settingsCacheTTL {
|
||||
name, motd := h.settingsName, h.settingsMotd
|
||||
h.settingsMu.RUnlock()
|
||||
return name, motd
|
||||
}
|
||||
h.settingsMu.RUnlock()
|
||||
|
||||
h.settingsMu.Lock()
|
||||
defer h.settingsMu.Unlock()
|
||||
// Double-check after acquiring write lock.
|
||||
if time.Since(h.settingsLastUpdate) < settingsCacheTTL {
|
||||
return h.settingsName, h.settingsMotd
|
||||
}
|
||||
h.refreshSettingsLocked(ctx)
|
||||
return h.settingsName, h.settingsMotd
|
||||
}
|
||||
|
||||
// refreshSettingsLocked reloads server_name and motd through the settings
|
||||
// reader. Caller must hold settingsMu (write lock) or call during init.
|
||||
func (h *Hub) refreshSettingsLocked(ctx context.Context) {
|
||||
// The refresh serves the hub-wide settings cache, not the connection that
|
||||
// happened to trigger it — a dying connection's ctx must not fail the
|
||||
// fetches (the TTL stamp below would then pin stale values for 30s).
|
||||
ctx = context.WithoutCancel(ctx)
|
||||
if name, err := h.settings.Setting(ctx, "server_name"); err == nil {
|
||||
h.settingsName = name
|
||||
}
|
||||
if motd, err := h.settings.Setting(ctx, "motd"); err == nil {
|
||||
h.settingsMotd = motd
|
||||
}
|
||||
h.settingsLastUpdate = time.Now()
|
||||
}
|
||||
@@ -249,8 +249,8 @@ func simDMChannel(i, j int) int64 {
|
||||
}
|
||||
|
||||
func runHubSim(t *testing.T, seed uint64, steps int) (stats map[string]int, raced int) {
|
||||
hub, database := newTestHub(t)
|
||||
hub.ConfigureReplay(simRing, 0)
|
||||
database := openTestDB(t)
|
||||
hub := newTestHubWith(t, ws.HubOptions{DB: database, ReplayRingSize: simRing})
|
||||
hub.FreezeTopicLimiterForTest()
|
||||
s := &sim{
|
||||
t: t, hub: hub, seed: seed, steps: steps,
|
||||
|
||||
@@ -46,7 +46,7 @@ func TestSweepStaleVoiceStates_JoinCatchesUpDuringDeleteWindow(t *testing.T) {
|
||||
t.Fatalf("GetVoiceState: %v", err)
|
||||
}
|
||||
|
||||
h := NewHub(database, auth.NewRateLimiter(), nil)
|
||||
h := newTestHub(t, database, auth.NewRateLimiter(), nil)
|
||||
c := NewTestClient(h, uid, make(chan []byte, 8))
|
||||
h.clients[uid] = c
|
||||
// c.voiceChID is still 0 here — exactly like the joiner's client at the
|
||||
@@ -105,7 +105,7 @@ func TestCleanupVoiceForChannel_NotifiesReadAudienceOfArchivedChannel(t *testing
|
||||
t.Fatalf("JoinVoiceChannel(B): %v", err)
|
||||
}
|
||||
|
||||
h := NewHub(database, auth.NewRateLimiter(), nil)
|
||||
h := newTestHub(t, database, auth.NewRateLimiter(), nil)
|
||||
cA := NewTestClient(h, userA, make(chan []byte, 8))
|
||||
cB := NewTestClient(h, userB, make(chan []byte, 8))
|
||||
// bystander has READ_MESSAGES on the channel (harvestVoiceRoleID grants it
|
||||
|
||||
@@ -116,7 +116,7 @@ func TestSweepStaleVoiceStates_TransientPermissionErrorDoesNotEvict(t *testing.T
|
||||
t.Fatalf("JoinVoiceChannel: %v", err)
|
||||
}
|
||||
|
||||
h := NewHub(database, auth.NewRateLimiter(), nil)
|
||||
h := newTestHub(t, database, auth.NewRateLimiter(), nil)
|
||||
c := NewTestClient(h, uid, make(chan []byte, 8))
|
||||
c.setVoiceState(chID, "tok")
|
||||
h.clients[uid] = c
|
||||
@@ -170,7 +170,7 @@ func TestSweepStaleVoiceStates_GhostRemovalReelectsKeyHolder(t *testing.T) {
|
||||
t.Fatalf("JoinVoiceChannel(ghost): %v", err)
|
||||
}
|
||||
|
||||
h := NewHub(database, auth.NewRateLimiter(), nil)
|
||||
h := newTestHub(t, database, auth.NewRateLimiter(), nil)
|
||||
survivor := NewTestClient(h, survivorUID, make(chan []byte, 8))
|
||||
survivor.setVoiceState(chID, "tok-survivor")
|
||||
h.clients[survivorUID] = survivor
|
||||
@@ -225,7 +225,7 @@ func TestCleanupVoiceForChannel_ConcurrentJoinNotClobbered(t *testing.T) {
|
||||
t.Fatalf("JoinVoiceChannel: %v", err)
|
||||
}
|
||||
|
||||
h := NewHub(database, auth.NewRateLimiter(), nil)
|
||||
h := newTestHub(t, database, auth.NewRateLimiter(), nil)
|
||||
c := NewTestClient(h, uid, make(chan []byte, 8))
|
||||
h.clients[uid] = c
|
||||
c.setVoiceState(chA, "tok-a")
|
||||
|
||||
+43
-10
@@ -12,6 +12,7 @@ import (
|
||||
|
||||
"github.com/J3vb/OwnCord/Server/auth"
|
||||
"github.com/J3vb/OwnCord/Server/db"
|
||||
"github.com/J3vb/OwnCord/Server/service"
|
||||
"github.com/J3vb/OwnCord/Server/ws"
|
||||
)
|
||||
|
||||
@@ -38,7 +39,7 @@ func newTestHub(t testing.TB) (*ws.Hub, *db.DB) {
|
||||
t.Helper()
|
||||
database := openTestDB(t)
|
||||
limiter := auth.NewRateLimiter()
|
||||
hub := ws.NewHub(database, limiter, nil)
|
||||
hub := newTestHubDeps(t, database, limiter, nil)
|
||||
return hub, database
|
||||
}
|
||||
|
||||
@@ -669,10 +670,34 @@ func assertNotReceived(t *testing.T, ch <-chan []byte, label string) {
|
||||
|
||||
// ─── LiveKit lifecycle ────────────────────────────────────────────────────────
|
||||
|
||||
func TestHub_SetLiveKit_NilSafe(t *testing.T) {
|
||||
// TestHub_NilLiveKitOption: a hub built without LiveKit (voice not
|
||||
// configured) constructs fine and refuses token generation. Replaces the
|
||||
// pre-B3-4 SetLiveKit nil-safety test — the setter no longer exists.
|
||||
func TestHub_NilLiveKitOption(t *testing.T) {
|
||||
hub, _ := newTestHub(t)
|
||||
// Setting a nil LiveKit client must not panic.
|
||||
hub.SetLiveKit(nil)
|
||||
if _, err := hub.GenerateToken(1, "u", 1, "", false, false, false, false); err == nil {
|
||||
t.Fatal("GenerateToken on a voiceless hub must error")
|
||||
}
|
||||
}
|
||||
|
||||
// TestNewHub_RequiredCollaborators pins B3-4's validation: before it,
|
||||
// construction always succeeded (api tests built ws.NewHub(nil, nil, nil)
|
||||
// hubs) and a missing collaborator surfaced as a later panic.
|
||||
func TestNewHub_RequiredCollaborators(t *testing.T) {
|
||||
if _, err := ws.NewHub(ws.HubOptions{}); err == nil {
|
||||
t.Fatal("NewHub without DB must error")
|
||||
}
|
||||
database := openTestDB(t)
|
||||
if _, err := ws.NewHub(ws.HubOptions{DB: database}); err == nil {
|
||||
t.Fatal("NewHub without Limiter must error")
|
||||
}
|
||||
if _, err := ws.NewHub(ws.HubOptions{DB: database, Limiter: auth.NewRateLimiter()}); err == nil {
|
||||
t.Fatal("NewHub without a Settings reader must error")
|
||||
}
|
||||
settings := service.NewSettingsService(database)
|
||||
if _, err := ws.NewHub(ws.HubOptions{DB: database, Limiter: auth.NewRateLimiter(), Settings: settings, ReplayRingSize: -1}); err == nil {
|
||||
t.Fatal("NewHub with a negative replay ring must error")
|
||||
}
|
||||
}
|
||||
|
||||
// ─── GracefulStop ─────────────────────────────────────────────────────────────
|
||||
@@ -944,13 +969,21 @@ func TestHub_LiveKitHealthCheck_NilReturnsError(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// ─── SetLiveKitProcess ──────────────────────────────────────────────────────
|
||||
// ─── LiveKitProcess option ──────────────────────────────────────────────────
|
||||
|
||||
func TestHub_SetLiveKitProcess(t *testing.T) {
|
||||
hub, _ := newTestHub(t)
|
||||
hub.SetLiveKitProcess(nil)
|
||||
go hub.Run()
|
||||
hub.GracefulStop()
|
||||
// TestHub_LiveKitProcessRequiresClient pins B3-4's coherence rule: a
|
||||
// supervised process without a client is refused at construction — it used
|
||||
// to be a silently accepted setter call on a hub that could sign no tokens.
|
||||
func TestHub_LiveKitProcessRequiresClient(t *testing.T) {
|
||||
database := openTestDB(t)
|
||||
_, err := ws.NewHub(ws.HubOptions{
|
||||
DB: database,
|
||||
Limiter: auth.NewRateLimiter(),
|
||||
LiveKitProcess: &ws.LiveKitProcess{},
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("NewHub must refuse LiveKitProcess without LiveKit")
|
||||
}
|
||||
}
|
||||
|
||||
// ─── VoiceSessionCount ─────────────────────────────────────────────────────
|
||||
|
||||
@@ -0,0 +1,487 @@
|
||||
package ws
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"sync/atomic"
|
||||
|
||||
"github.com/J3vb/OwnCord/Server/db"
|
||||
"github.com/J3vb/OwnCord/Server/permissions"
|
||||
)
|
||||
|
||||
// channelReadAudience returns the connected user IDs whose current role may READ
|
||||
// channelID. Always non-nil, so an empty result means "deliver to nobody"
|
||||
// rather than "no filter". Each user's verdict comes from the cached
|
||||
// PermissionService when the hub has one (one in-memory lookup per connected
|
||||
// user; a miss repopulates from the user's CURRENT role, so a mid-session
|
||||
// reassignment is still honored). Caching is safe here because revocation is
|
||||
// delivered synchronously at every mutation site: a role change calls
|
||||
// InvalidateUser (admin/handlers_users.go) and a channel-override change calls
|
||||
// InvalidateAll (admin/handlers_channel_perms.go) before the hub fan-out runs,
|
||||
// with the 30s cache TTL as a backstop; the F6 gen-counter guard in the service
|
||||
// prevents a populate racing an invalidation from caching stale data. Fails
|
||||
// closed: a client whose role cannot be resolved is left out. Bare test hubs
|
||||
// without a service fall back to live per-call lookups, memoised for the
|
||||
// duration of the call. Mirrors RefreshChannelVisibility, which resolves
|
||||
// visibility the same way.
|
||||
func (h *Hub) channelReadAudience(ctx context.Context, channelID int64) []int64 {
|
||||
return h.channelReadAudienceImpl(ctx, channelID, false)
|
||||
}
|
||||
|
||||
// channelReadAudienceIgnoringArchived is channelReadAudience without the
|
||||
// Archived short-circuit (OC-0022). CleanupVoiceForChannel's only two
|
||||
// callers (admin/handlers_channels.go's archive and delete paths) always
|
||||
// commit archived=1 to the channel before evicting its voice participants —
|
||||
// deliberately, per admin/api_test.go's
|
||||
// TestAdminAPI_DeleteChannel_ArchivesBeforeVoiceCleanup, so a concurrent
|
||||
// voice_join sees the archived gate. That means channelReadAudience's own
|
||||
// Archived check, evaluated from CleanupVoiceForChannel, always sees the
|
||||
// channel already archived and always returns nobody: the voice_leave that
|
||||
// should tell every bystander who could see the room a moment ago that the
|
||||
// call ended never reaches them, only the evicted participants themselves
|
||||
// (added back by CleanupVoiceForChannel's own loop). This resolves that same
|
||||
// pre-archival READ audience for exactly that one broadcast, leaving every
|
||||
// other channelReadAudience call site (and its archived-channel behavior)
|
||||
// untouched.
|
||||
func (h *Hub) channelReadAudienceIgnoringArchived(ctx context.Context, channelID int64) []int64 {
|
||||
return h.channelReadAudienceImpl(ctx, channelID, true)
|
||||
}
|
||||
|
||||
func (h *Hub) channelReadAudienceImpl(ctx context.Context, channelID int64, ignoreArchived bool) []int64 {
|
||||
h.mu.RLock()
|
||||
userIDs := make([]int64, 0, len(h.clients))
|
||||
for uid := range h.clients {
|
||||
userIDs = append(userIDs, uid)
|
||||
}
|
||||
h.mu.RUnlock()
|
||||
|
||||
// A DM channel carries no channel_overrides rows, so every connected
|
||||
// user whose base role holds READ_MESSAGES would otherwise pass the role
|
||||
// scan below — leaking a private DM call's voice_state/voice_leave
|
||||
// events to the whole server. Resolve the DM's real audience (its
|
||||
// participants, intersected with who is actually connected) instead,
|
||||
// mirroring the IsDMParticipant membership rule hasChannelAccess uses.
|
||||
var ref permissions.ChannelRef
|
||||
if h.db != nil {
|
||||
ch, err := h.db.GetChannel(ctx, channelID)
|
||||
if err != nil {
|
||||
// Fail closed: an unresolvable channel must not fall through to
|
||||
// the role scan, which would treat it as a readable non-DM channel.
|
||||
slog.Error("ws: channelReadAudience GetChannel failed, denying",
|
||||
"channel_id", channelID, "err", err)
|
||||
return []int64{}
|
||||
}
|
||||
// Fail closed on a missing row too (OC-0090): GetChannel returns
|
||||
// (nil, nil) for a deleted channel, and falling through would hand a
|
||||
// channel with no override rows left to the role scan below — which
|
||||
// resolves to every connected user with base READ_MESSAGES, leaking
|
||||
// e.g. a closed group-DM's voice_leave server-wide. Callers that
|
||||
// tear down voice union the room's participants and the leaver back
|
||||
// in afterwards, so eviction/E2EE-teardown signals still arrive.
|
||||
if ch == nil {
|
||||
return []int64{}
|
||||
}
|
||||
if ch.Type == "dm" {
|
||||
return h.channelReadAudienceDM(ctx, channelID, userIDs)
|
||||
}
|
||||
ref = channelRef(ch)
|
||||
// CanViewChannel hides an archived channel from everyone, mirroring
|
||||
// RefreshChannelVisibility and VisibleChannelIDs: without that, an
|
||||
// admin edit to an archived channel (or a voice teardown inside one)
|
||||
// would fan out to every connected user whose base role holds
|
||||
// READ_MESSAGES, none of whom have the channel in their sidebar.
|
||||
// ignoreArchived resolves the pre-archival audience instead — see
|
||||
// channelReadAudienceIgnoringArchived.
|
||||
if ignoreArchived {
|
||||
ref.Archived = false
|
||||
}
|
||||
}
|
||||
|
||||
// Resolved per USER, not memoised per role: channel_user_overrides is the
|
||||
// last layer of the resolution order, so two members of the same role can
|
||||
// legitimately disagree about one channel and a per-role memo would hand
|
||||
// one of them the other's verdict. The verdict is CanViewChannel over
|
||||
// subjectFor (cached service or live checker); an unresolvable user is
|
||||
// left out.
|
||||
audience := make([]int64, 0, len(userIDs))
|
||||
for _, uid := range userIDs {
|
||||
sub, err := h.subjectFor(ctx, uid, channelID)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
sub.Channel = ref
|
||||
if permissions.CanViewChannel(sub) == nil {
|
||||
audience = append(audience, uid)
|
||||
}
|
||||
}
|
||||
return audience
|
||||
}
|
||||
|
||||
// channelReadAudienceDM resolves the audience of a DM channel: the DM's
|
||||
// participants, intersected with the connected userIDs. Split verbatim out of
|
||||
// channelReadAudienceImpl; the reason a DM must not fall through to the role
|
||||
// scan is on the call site.
|
||||
func (h *Hub) channelReadAudienceDM(ctx context.Context, channelID int64, userIDs []int64) []int64 {
|
||||
participantIDs, err := h.db.GetDMParticipantIDs(ctx, channelID)
|
||||
if err != nil {
|
||||
slog.Error("ws: channelReadAudience GetDMParticipantIDs failed, denying",
|
||||
"channel_id", channelID, "err", err)
|
||||
return []int64{}
|
||||
}
|
||||
connected := make(map[int64]struct{}, len(userIDs))
|
||||
for _, uid := range userIDs {
|
||||
connected[uid] = struct{}{}
|
||||
}
|
||||
audience := make([]int64, 0, len(participantIDs))
|
||||
for _, uid := range participantIDs {
|
||||
if _, ok := connected[uid]; ok {
|
||||
audience = append(audience, uid)
|
||||
}
|
||||
}
|
||||
return audience
|
||||
}
|
||||
|
||||
// RefreshChannelVisibility re-evaluates which connected clients may see ch
|
||||
// after a channel_overrides change and sends targeted channel_create /
|
||||
// channel_delete messages so sidebars converge without a reconnect. Clients
|
||||
// that lose visibility are also unsubscribed from the channel topic and have
|
||||
// their focused channel cleared so live messages stop flowing.
|
||||
//
|
||||
// The sends deliberately bypass the sequenced broadcast/replay path: a
|
||||
// replayed channel_delete would be filtered by the allowed-channel set
|
||||
// computed at replay time, which after an override change is exactly the
|
||||
// inverse of the intended audience. Clients tolerate seq-less messages.
|
||||
func (h *Hub) RefreshChannelVisibility(ch *db.Channel) {
|
||||
if ch == nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Bump the watermark immediately, before the h.clients snapshot below and
|
||||
// the (potentially slow — up to two DB round trips per connected client)
|
||||
// fan-out loop that follows it. A reconnect handshake re-checks this
|
||||
// watermark right before it registers (OC-0206); bumping only at the end,
|
||||
// after the loop, left a window where that re-check could still observe
|
||||
// the pre-change value even though this function's snapshot — taken next
|
||||
// — will never include a client that registers mid-loop. Ratcheted
|
||||
// upward only (see bumpVisibilityWatermark), so this is a no-op whenever
|
||||
// a concurrent writer already pushed the watermark higher; the trailing
|
||||
// bump below still runs and covers any change to h.seq made during the
|
||||
// loop itself.
|
||||
h.bumpVisibilityWatermark()
|
||||
|
||||
h.mu.RLock()
|
||||
clients := make([]*Client, 0, len(h.clients))
|
||||
for _, c := range h.clients {
|
||||
clients = append(clients, c)
|
||||
}
|
||||
h.mu.RUnlock()
|
||||
|
||||
// Called via the admin HubBroadcaster interface, which carries no context;
|
||||
// the targeted re-sync must complete regardless of the triggering request.
|
||||
ctx := context.Background()
|
||||
|
||||
// Visibility is CanViewChannel — the single predicate shared with
|
||||
// buildReady / REST ListVisibleChannels — resolved per user from their
|
||||
// CURRENT role (c.user is a connect-time snapshot). With a
|
||||
// PermissionService the subject comes from the per-user cache — safe
|
||||
// because the admin handlers invalidate (InvalidateAll on override
|
||||
// change, InvalidateUser on role change) before calling into the hub, so
|
||||
// the lookups below repopulate from post-change data; the 30s TTL is only
|
||||
// a backstop and the F6 gen-counter guard keeps a racing populate from
|
||||
// caching stale rows. Without a service (bare test hubs) each client is
|
||||
// resolved live. Fails closed: an unresolvable role loses visibility
|
||||
// rather than keeping a stale grant.
|
||||
//
|
||||
// Deliberately NOT memoised per role: channel_user_overrides is the last
|
||||
// layer of the resolution order, so two members of the same role can
|
||||
// legitimately disagree about one channel — exactly the case a per-user
|
||||
// override edit creates, and exactly the fan-out this function targets.
|
||||
for _, c := range clients {
|
||||
if c.user == nil {
|
||||
continue
|
||||
}
|
||||
sub, err := h.subjectFor(ctx, c.user.ID, ch.ID)
|
||||
if err != nil {
|
||||
slog.Warn("hub: RefreshChannelVisibility could not resolve permissions, revoking",
|
||||
"user_id", c.user.ID, "channel_id", ch.ID, "err", err)
|
||||
}
|
||||
sub.Channel = channelRef(ch)
|
||||
visible := err == nil && permissions.CanViewChannel(sub) == nil
|
||||
|
||||
if refreshChannelVisibilityRaceHook != nil {
|
||||
refreshChannelVisibilityRaceHook(c.user.ID)
|
||||
}
|
||||
|
||||
// Re-resolve the live client immediately before acting: the permission
|
||||
// lookups above (a PermissionService call, or two DB round trips in the
|
||||
// bare-hub branch) give a reconnect room to replace this user's *Client
|
||||
// in h.clients with a new connection under the same user ID. Acting on
|
||||
// the stale snapshot pointer c would target a dead socket, and
|
||||
// Unsubscribe would be a no-op — unsubscribeLocked's identity guard
|
||||
// leaves a topic alone when the current holder differs from the client
|
||||
// passed in — stranding the replacement with a subscription (or a
|
||||
// missing one) exactly inverted from what this fan-out just decided.
|
||||
// A nil result means the user disconnected entirely since the
|
||||
// snapshot; nothing to act on.
|
||||
live := h.GetClient(c.user.ID)
|
||||
if live == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if visible {
|
||||
// Idempotent add on the client; also refreshes channel metadata.
|
||||
// Addressed per client so it can carry this recipient's own
|
||||
// can_send verdict — the whole point of this fan-out is that a
|
||||
// permission change just made those verdicts diverge.
|
||||
live.sendMsg(buildChannelCreateFor(ch, h.refreshChannelVisibilityCanSend(ctx, ch, c.user.ID)))
|
||||
continue
|
||||
}
|
||||
live.sendMsg(buildChannelDelete(ch.ID))
|
||||
h.pubsub.Unsubscribe(live, ChannelTopic(ch.ID))
|
||||
live.mu.Lock()
|
||||
if live.channelID == ch.ID {
|
||||
live.channelID = 0
|
||||
}
|
||||
live.mu.Unlock()
|
||||
}
|
||||
|
||||
// Clients not connected right now missed the targeted sends above. Move
|
||||
// the watermark so any resume from a seq at or before this point is
|
||||
// forced onto the full-ready path instead of replay. Ratcheted upward
|
||||
// only — see bumpVisibilityWatermark — so a concurrent writer that read
|
||||
// an older seq cannot regress a watermark another writer already pushed
|
||||
// higher.
|
||||
h.bumpVisibilityWatermark()
|
||||
}
|
||||
|
||||
// refreshChannelVisibilityCanSend is the can_send verdict the ready payload
|
||||
// ships per channel (channelCanSend), recomputed for one live user from their
|
||||
// CURRENT role: permissions.CanSendMessage over the subject subjectFor
|
||||
// resolves in either the service or the bare-hub branch, failing closed on a
|
||||
// lookup error (S-12).
|
||||
//
|
||||
// Without this, can_send is only ever computed at connect time, so a role
|
||||
// edit or override edit leaves every connected client's composer stuck on
|
||||
// its stale connect-time verdict until the socket is rebuilt.
|
||||
func (h *Hub) refreshChannelVisibilityCanSend(ctx context.Context, ch *db.Channel, userID int64) bool {
|
||||
sub, err := h.subjectFor(ctx, userID, ch.ID)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
sub.Channel = channelRef(ch)
|
||||
return permissions.CanSendMessage(sub) == nil
|
||||
}
|
||||
|
||||
// RefreshAllChannelVisibility re-runs RefreshChannelVisibility for every
|
||||
// non-DM channel. A role's permission mask is the base every channel's
|
||||
// effective permission is computed from, so editing or deleting a role can
|
||||
// change visibility of *any* channel at once — where a channel_overrides edit
|
||||
// touches exactly one. DM channels are skipped: their access is participant-
|
||||
// based and no role change can alter it.
|
||||
//
|
||||
// Called via the admin HubBroadcaster interface (no context), so the channel
|
||||
// list is read against Background — the re-sync must complete regardless of the
|
||||
// triggering request. The caller invalidates the permission cache first, as the
|
||||
// channel-override handlers do, so the per-client lookups below repopulate from
|
||||
// post-change data.
|
||||
func (h *Hub) RefreshAllChannelVisibility() {
|
||||
if h.db == nil {
|
||||
return
|
||||
}
|
||||
ctx := context.Background()
|
||||
channels, err := h.db.ListChannels(ctx)
|
||||
if err != nil {
|
||||
slog.Warn("hub: RefreshAllChannelVisibility could not list channels", "err", err)
|
||||
return
|
||||
}
|
||||
for i := range channels {
|
||||
if channels[i].Type == "dm" {
|
||||
continue
|
||||
}
|
||||
h.RefreshChannelVisibility(&channels[i])
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Hub) revokeUnreadableChannels(userID int64) {
|
||||
// Ratcheted upward only (see bumpVisibilityWatermark), and evaluated at
|
||||
// defer-RUN time — not the plain Store(Load(&h.seq)) this used to be,
|
||||
// whose argument would have been evaluated at this defer STATEMENT,
|
||||
// capturing entry-time seq and stomping any higher watermark stored by a
|
||||
// concurrent writer during the per-topic DB loop below. Deferred because
|
||||
// it must cover the early returns too: a user who is offline, or whose
|
||||
// socket is closed below, converges via the full-ready path.
|
||||
defer h.bumpVisibilityWatermark()
|
||||
|
||||
// Also bump immediately, before the h.clients lookup below and the
|
||||
// per-topic DB loop (a GetChannel round trip per revoked topic) that
|
||||
// follows it — see RefreshChannelVisibility's matching early bump and
|
||||
// OC-0206. Ratcheted upward only, so this is a no-op whenever a
|
||||
// concurrent writer already pushed the watermark higher; the deferred
|
||||
// bump above still covers every return path, including the early ones.
|
||||
h.bumpVisibilityWatermark()
|
||||
|
||||
if h.db == nil {
|
||||
return
|
||||
}
|
||||
h.mu.RLock()
|
||||
c, ok := h.clients[userID]
|
||||
h.mu.RUnlock()
|
||||
if !ok || c.user == nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Called via the admin HubBroadcaster interface, which carries no context;
|
||||
// the re-evaluation must complete regardless of the triggering request.
|
||||
ctx := context.Background()
|
||||
|
||||
// c.user is a connect-time snapshot and the role just changed, so resolve
|
||||
// the current user — and through it the current role — from the DB.
|
||||
var allowed map[int64]bool
|
||||
user, err := h.db.GetUserByID(ctx, userID)
|
||||
if err == nil && user != nil {
|
||||
// Same predicate as the ready payload and reconnect replay filtering.
|
||||
allowed, err = h.computeAllowedChannels(ctx, h.db, user)
|
||||
}
|
||||
if err != nil || user == nil {
|
||||
// Visibility unresolved. Keeping the old subscriptions would leak, and
|
||||
// revoking them all would hollow out a sidebar the user may still be
|
||||
// entitled to, so close the socket instead: the client reconnects and
|
||||
// rebuilds from a ready payload computed with the new role. kickClient
|
||||
// rather than DisconnectUser — the latter sends a BANNED error, which
|
||||
// makes the client clear its credentials instead of reconnecting.
|
||||
slog.Warn("hub: role change visibility unresolved, closing socket",
|
||||
"user_id", userID, "err", err)
|
||||
// Re-resolve before kicking: the lookups above are DB round trips a
|
||||
// reconnect can overlap, and kicking the stale snapshot would close a
|
||||
// dead socket while the replacement keeps its subscriptions.
|
||||
if live := h.GetClient(userID); live != nil {
|
||||
h.kickClient(live)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
for _, topic := range h.pubsub.TopicsForClient(userID) {
|
||||
chID := channelTopicID(topic)
|
||||
if chID == 0 || allowed[chID] {
|
||||
continue
|
||||
}
|
||||
// DM access is gated on dm_participants, which no role change can
|
||||
// alter, while allowed sources DMs from dm_open_state — a DM the user
|
||||
// has closed (or every DM, if the DM lookup inside
|
||||
// computeAllowedChannels failed) is missing from allowed even though
|
||||
// its subscription is still legitimate. Never revoke a DM topic here;
|
||||
// on a lookup error close the socket rather than guess.
|
||||
ch, chErr := h.db.GetChannel(ctx, chID)
|
||||
if chErr != nil {
|
||||
slog.Warn("hub: role change channel lookup failed, closing socket",
|
||||
"user_id", userID, "channel_id", chID, "err", chErr)
|
||||
if live := h.GetClient(userID); live != nil {
|
||||
h.kickClient(live)
|
||||
}
|
||||
return
|
||||
}
|
||||
if ch != nil && ch.Type == "dm" {
|
||||
continue
|
||||
}
|
||||
if revokeUnreadableChannelsPreActRaceHook != nil {
|
||||
revokeUnreadableChannelsPreActRaceHook(userID)
|
||||
}
|
||||
// Re-resolve the live client immediately before acting: the DB round
|
||||
// trips above (and computeAllowedChannels before the loop) give a
|
||||
// reconnect room to replace this user's *Client in h.clients. Acting
|
||||
// on the snapshot c would target the dead socket, and Unsubscribe
|
||||
// would no-op on unsubscribeLocked's identity guard — stranding the
|
||||
// replacement with the revoked topic (audit-2026-08-19 F-2; mirrors
|
||||
// RefreshChannelVisibility's live re-resolve). A nil result means the
|
||||
// user disconnected entirely; nothing left to revoke.
|
||||
live := h.GetClient(userID)
|
||||
if live == nil {
|
||||
return
|
||||
}
|
||||
live.sendMsg(buildChannelDelete(chID))
|
||||
h.pubsub.Unsubscribe(live, topic)
|
||||
live.mu.Lock()
|
||||
if live.channelID == chID {
|
||||
live.channelID = 0
|
||||
}
|
||||
live.mu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
// computeAllowedChannels returns the set of channel IDs a user may access,
|
||||
// including both server channels (filtered by ReadMessages permission) and
|
||||
// the user's open DM channels. The server-channel set comes from the single
|
||||
// permissions.Checker predicate shared with buildReady and REST
|
||||
// ListVisibleChannels, so replay-buffer filtering can never drift from the
|
||||
// ready payload's visible channels.
|
||||
func (h *Hub) computeAllowedChannels(ctx context.Context, database *db.DB, user *db.User) (map[int64]bool, error) {
|
||||
channels, err := database.ListChannels(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("computeAllowedChannels ListChannels: %w", err)
|
||||
}
|
||||
|
||||
role, err := database.GetRoleByID(ctx, user.RoleID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("computeAllowedChannels GetRoleByID: %w", err)
|
||||
}
|
||||
|
||||
// Nil role = zero access (fail closed). Admins skip the override fetch.
|
||||
allowed := make(map[int64]bool)
|
||||
if role != nil {
|
||||
var overrides map[int64]db.ChannelOverride
|
||||
if !permissions.HasAdmin(role.Permissions) {
|
||||
overrides, err = database.GetChannelOverridesFor(ctx, role.ID, user.ID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("computeAllowedChannels GetChannelOverridesFor: %w", err)
|
||||
}
|
||||
}
|
||||
allowed = h.permChecker.VisibleChannelIDs(role.Permissions, channelRefs(channels), permOverrides(overrides))
|
||||
}
|
||||
|
||||
// Include the user's open DM channels. Only the ID set matters here, so
|
||||
// use the PK-covered dm_open_state lookup instead of the full DM query.
|
||||
// Fatal like the three sibling lookups above: a silently DM-stripped
|
||||
// replay advances the client's lastSeq past DM events it never received —
|
||||
// a permanent hole. The caller's error path falls back to full ready.
|
||||
dmIDs, dmErr := database.GetUserDMChannelIDs(ctx, user.ID)
|
||||
if dmErr != nil {
|
||||
return nil, fmt.Errorf("computeAllowedChannels GetUserDMChannelIDs: %w", dmErr)
|
||||
}
|
||||
for _, id := range dmIDs {
|
||||
allowed[id] = true
|
||||
}
|
||||
|
||||
return allowed, nil
|
||||
}
|
||||
|
||||
// bumpVisibilityWatermark ratchets visibilityChangeSeq up to the current seq,
|
||||
// never down. All three writers (RefreshChannelVisibility,
|
||||
// revokeUnreadableChannels, DMChannelOpenEvent in emit.go) must go through
|
||||
// this instead of a plain Store: a plain Store(Load(&h.seq)) lets a writer
|
||||
// that read an older h.seq — e.g. one that spent time in a per-topic DB loop
|
||||
// — finish and overwrite a concurrently stored higher watermark with its
|
||||
// stale value, silently regressing the forced-full-resync boundary mustFullResync
|
||||
// depends on being monotonic. Mirrors SeedSeq's CAS-max pattern.
|
||||
func (h *Hub) bumpVisibilityWatermark() {
|
||||
for {
|
||||
cur := h.visibilityChangeSeq.Load()
|
||||
next := atomic.LoadUint64(&h.seq)
|
||||
if next <= cur {
|
||||
return
|
||||
}
|
||||
if h.visibilityChangeSeq.CompareAndSwap(cur, next) {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MarkVisibilityChanged bumps the visibility watermark. It is the exported
|
||||
// entry point REST handlers (api.markDMVisibilityChanged, reached via a
|
||||
// dmVisibilityMarker type assertion) use to force the same full-resync
|
||||
// guarantee for an unsequenced, targeted DM event that the WS-side emitter of
|
||||
// the same event (emit.go DMChannelOpenEvent) already gets via
|
||||
// bumpVisibilityWatermark directly.
|
||||
func (h *Hub) MarkVisibilityChanged() {
|
||||
h.bumpVisibilityWatermark()
|
||||
}
|
||||
@@ -74,7 +74,7 @@ func TestTheLoadTest(t *testing.T) {
|
||||
|
||||
limiter := auth.NewRateLimiter()
|
||||
svc := service.New(database, limiter)
|
||||
hub := ws.NewHub(database, limiter, svc)
|
||||
hub := newTestHubDeps(t, database, limiter, svc)
|
||||
|
||||
runDone := make(chan struct{})
|
||||
go func() {
|
||||
|
||||
@@ -30,7 +30,6 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/J3vb/OwnCord/Server/auth"
|
||||
"github.com/J3vb/OwnCord/Server/config"
|
||||
"github.com/J3vb/OwnCord/Server/db"
|
||||
)
|
||||
@@ -55,8 +54,7 @@ func TestVoiceJoin_GetChannelVoiceStatesError_RollsBackAndNotifiesClient(t *test
|
||||
t.Fatalf("NewLiveKitClient: %v", err)
|
||||
}
|
||||
|
||||
h := NewHub(database, auth.NewRateLimiter(), nil)
|
||||
h.SetLiveKit(lk)
|
||||
h := newTestHubWith(t, HubOptions{DB: database, LiveKit: lk})
|
||||
|
||||
send := make(chan []byte, 8)
|
||||
c := NewTestClient(h, uid, send)
|
||||
|
||||
@@ -25,7 +25,7 @@ func TestHandleMessageSessionRecheck_TransientDBErrorDoesNotKick(t *testing.T) {
|
||||
t.Fatalf("CreateSession: %v", err)
|
||||
}
|
||||
|
||||
h := NewHub(database, auth.NewRateLimiter(), nil)
|
||||
h := newTestHub(t, database, auth.NewRateLimiter(), nil)
|
||||
c := NewTestClient(h, uid, make(chan []byte, 8))
|
||||
c.tokenHash = tokenHash
|
||||
// Put the client one message away from the periodic recheck boundary, so
|
||||
|
||||
@@ -26,7 +26,6 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/J3vb/OwnCord/Server/auth"
|
||||
"github.com/J3vb/OwnCord/Server/config"
|
||||
"github.com/J3vb/OwnCord/Server/db"
|
||||
)
|
||||
@@ -49,8 +48,7 @@ func TestVoiceJoin_GetChannelVoiceStatesError_UnsubscribesVoiceTopic(t *testing.
|
||||
t.Fatalf("NewLiveKitClient: %v", err)
|
||||
}
|
||||
|
||||
h := NewHub(database, auth.NewRateLimiter(), nil)
|
||||
h.SetLiveKit(lk)
|
||||
h := newTestHubWith(t, HubOptions{DB: database, LiveKit: lk})
|
||||
|
||||
send := make(chan []byte, 8)
|
||||
c := NewTestClient(h, uid, send)
|
||||
|
||||
@@ -68,7 +68,7 @@ func TestReconnect_AuthOKReflectsSettledStatus_NotDisconnectTimeStatus(t *testin
|
||||
t.Fatalf("CreateSession: %v", err)
|
||||
}
|
||||
|
||||
hub := NewHub(database, auth.NewRateLimiter(), nil)
|
||||
hub := newTestHub(t, database, auth.NewRateLimiter(), nil)
|
||||
go hub.Run()
|
||||
defer hub.Stop()
|
||||
|
||||
|
||||
@@ -50,7 +50,7 @@ func TestFreshConnectCleanStaleVoice_ClearsOldClientVoiceState(t *testing.T) {
|
||||
t.Fatalf("GetVoiceState: %v", err)
|
||||
}
|
||||
|
||||
h := NewHub(database, auth.NewRateLimiter(), nil)
|
||||
h := newTestHub(t, database, auth.NewRateLimiter(), nil)
|
||||
|
||||
// The still-registered OLD client from the previous session.
|
||||
old := NewTestClient(h, uid, make(chan []byte, 32))
|
||||
@@ -113,7 +113,7 @@ func TestUpgradeAndAuth_RoleLookupFailure_FailsClosed(t *testing.T) {
|
||||
t.Fatalf("CreateSession: %v", err)
|
||||
}
|
||||
|
||||
hub := NewHub(database, auth.NewRateLimiter(), nil)
|
||||
hub := newTestHub(t, database, auth.NewRateLimiter(), nil)
|
||||
|
||||
type upgradeResult struct {
|
||||
c *Client
|
||||
@@ -182,7 +182,7 @@ func TestRefreshUserSnapshot_FailsClosedForBannedUser(t *testing.T) {
|
||||
t.Fatalf("GetUserByID: %v", err)
|
||||
}
|
||||
|
||||
hub := NewHub(database, auth.NewRateLimiter(), nil)
|
||||
hub := newTestHub(t, database, auth.NewRateLimiter(), nil)
|
||||
c := newClient(hub, nil, user, "", 0, ctx)
|
||||
|
||||
// The ban commits AFTER authenticateConn passed (c.user is still the
|
||||
|
||||
@@ -23,7 +23,6 @@ import (
|
||||
"slices"
|
||||
"testing"
|
||||
|
||||
"github.com/J3vb/OwnCord/Server/auth"
|
||||
"github.com/J3vb/OwnCord/Server/config"
|
||||
"github.com/J3vb/OwnCord/Server/db"
|
||||
"github.com/J3vb/OwnCord/Server/permissions"
|
||||
@@ -51,8 +50,7 @@ func TestRollbackVoiceJoin_BroadcastReachesLeaverWithoutReadAccess(t *testing.T)
|
||||
t.Fatalf("NewLiveKitClient: %v", err)
|
||||
}
|
||||
|
||||
h := NewHub(database, auth.NewRateLimiter(), nil)
|
||||
h.SetLiveKit(lk)
|
||||
h := newTestHubWith(t, HubOptions{DB: database, LiveKit: lk})
|
||||
|
||||
send := make(chan []byte, 8)
|
||||
c := NewTestClient(h, uid, send)
|
||||
|
||||
@@ -28,7 +28,7 @@ import (
|
||||
|
||||
func TestRegisterNow_ResyncsPeerE2EEKeyOnResume(t *testing.T) {
|
||||
database := newTeardownTestDB(t)
|
||||
hub := NewHub(database, auth.NewRateLimiter(), nil)
|
||||
hub := newTestHub(t, database, auth.NewRateLimiter(), nil)
|
||||
|
||||
const (
|
||||
chanID = int64(500)
|
||||
|
||||
@@ -43,7 +43,7 @@ func TestRefreshUserSnapshot_FailsClosedWhenNewRoleLookupFails(t *testing.T) {
|
||||
t.Fatalf("GetUserByID: %v", err)
|
||||
}
|
||||
|
||||
hub := NewHub(database, auth.NewRateLimiter(), nil)
|
||||
hub := newTestHub(t, database, auth.NewRateLimiter(), nil)
|
||||
c := newClient(hub, nil, user, "", 0, ctx)
|
||||
c.roleName = "member"
|
||||
|
||||
|
||||
@@ -30,7 +30,7 @@ import (
|
||||
|
||||
func TestRegisterNow_ReannouncesOwnKeyOnResume(t *testing.T) {
|
||||
database := newTeardownTestDB(t)
|
||||
hub := NewHub(database, auth.NewRateLimiter(), nil)
|
||||
hub := newTestHub(t, database, auth.NewRateLimiter(), nil)
|
||||
|
||||
const (
|
||||
chanID = int64(500)
|
||||
|
||||
@@ -43,7 +43,7 @@ func TestPermCache_RoleChangeInvalidationIsImmediate(t *testing.T) {
|
||||
database := openHandlerDB(t)
|
||||
limiter := auth.NewRateLimiter()
|
||||
svc := service.New(database, limiter)
|
||||
hub := ws.NewHub(database, limiter, svc)
|
||||
hub := newTestHubDeps(t, database, limiter, svc)
|
||||
go hub.Run()
|
||||
t.Cleanup(func() { hub.Stop() })
|
||||
|
||||
@@ -103,7 +103,7 @@ func TestPermCache_SecondCheckServedFromCache(t *testing.T) {
|
||||
limiter := auth.NewRateLimiter()
|
||||
store := &countingStore{Store: database}
|
||||
svc := service.New(store, limiter)
|
||||
hub := ws.NewHub(database, limiter, svc)
|
||||
hub := newTestHubDeps(t, database, limiter, svc)
|
||||
go hub.Run()
|
||||
t.Cleanup(func() { hub.Stop() })
|
||||
|
||||
|
||||
@@ -92,7 +92,7 @@ func TestRefreshChannelVisibilityCanSend_Parity(t *testing.T) {
|
||||
t.Fatalf("retype: %v", err)
|
||||
}
|
||||
|
||||
h := NewHub(database, auth.NewRateLimiter(), nil)
|
||||
h := newTestHub(t, database, auth.NewRateLimiter(), nil)
|
||||
permSvc := service.NewPermissionService(database, h.permChecker)
|
||||
|
||||
for _, chID := range []int64{textID, newsID} {
|
||||
@@ -143,7 +143,7 @@ func newViewParityFixture(t *testing.T) *viewParityFixture {
|
||||
if _, err := database.ExecContext(ctx, `UPDATE channels SET archived = 1 WHERE id = ?`, oldID); err != nil {
|
||||
t.Fatalf("archive: %v", err)
|
||||
}
|
||||
h := NewHub(database, auth.NewRateLimiter(), nil)
|
||||
h := newTestHub(t, database, auth.NewRateLimiter(), nil)
|
||||
user, err := database.GetUserByID(ctx, uid)
|
||||
if err != nil || user == nil {
|
||||
t.Fatalf("GetUserByID: %v", err)
|
||||
|
||||
@@ -357,7 +357,6 @@ func newEpochRig(t *testing.T, journey string) *epochRig {
|
||||
}
|
||||
|
||||
limiter := auth.NewRateLimiter()
|
||||
hub := ws.NewHub(database, limiter, service.New(database, limiter))
|
||||
|
||||
// A LiveKit client so voice_join clears the "voice not configured" guard.
|
||||
// The join token is minted locally; no LiveKit process is contacted.
|
||||
@@ -369,7 +368,10 @@ func newEpochRig(t *testing.T, journey string) *epochRig {
|
||||
if err != nil {
|
||||
t.Fatalf("NewLiveKitClient: %v", err)
|
||||
}
|
||||
hub.SetLiveKit(lk)
|
||||
hub := newTestHubWith(t, ws.HubOptions{
|
||||
DB: database, Limiter: limiter,
|
||||
Services: service.New(database, limiter), LiveKit: lk,
|
||||
})
|
||||
|
||||
go hub.Run()
|
||||
t.Cleanup(func() { hub.Stop() })
|
||||
|
||||
@@ -54,7 +54,7 @@ func TestReconnect_AuthFrameActiveChannelRestoresSubscription(t *testing.T) {
|
||||
t.Fatalf("CreateSession: %v", err)
|
||||
}
|
||||
|
||||
hub := NewHub(database, auth.NewRateLimiter(), nil)
|
||||
hub := newTestHub(t, database, auth.NewRateLimiter(), nil)
|
||||
go hub.Run()
|
||||
defer hub.Stop()
|
||||
|
||||
@@ -180,7 +180,7 @@ func TestReconnect_AuthFrameActiveChannelIsReadGated(t *testing.T) {
|
||||
t.Fatalf("CreateSession: %v", err)
|
||||
}
|
||||
|
||||
hub := NewHub(database, auth.NewRateLimiter(), nil)
|
||||
hub := newTestHub(t, database, auth.NewRateLimiter(), nil)
|
||||
go hub.Run()
|
||||
defer hub.Stop()
|
||||
|
||||
|
||||
@@ -87,7 +87,7 @@ func TestReconnect_BufferMiss_FallsBackToDBTier(t *testing.T) {
|
||||
}
|
||||
|
||||
// Build hub, attach the DB event store as the cold-tier read path.
|
||||
hub := ws.NewHub(database, limiter, nil)
|
||||
hub := newTestHubDeps(t, database, limiter, nil)
|
||||
hub.SetEventStore(eventStore)
|
||||
go hub.Run()
|
||||
defer hub.Stop()
|
||||
@@ -208,7 +208,7 @@ func TestReconnect_ColdTierAtRowLimit_ForcesFullReady(t *testing.T) {
|
||||
t.Fatalf("PersistEvents: persisted %d/%d, err=%v", n, len(events), err)
|
||||
}
|
||||
|
||||
hub := ws.NewHub(database, limiter, nil)
|
||||
hub := newTestHubDeps(t, database, limiter, nil)
|
||||
hub.SetEventStore(eventStore)
|
||||
go hub.Run()
|
||||
defer hub.Stop()
|
||||
@@ -304,7 +304,7 @@ func TestReconnect_ColdTierMergesRingBufferTail(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
hub := ws.NewHub(database, limiter, nil)
|
||||
hub := newTestHubDeps(t, database, limiter, nil)
|
||||
hub.SetEventStore(eventStore)
|
||||
go hub.Run()
|
||||
defer hub.Stop()
|
||||
|
||||
@@ -52,7 +52,7 @@ func TestReconnect_FullReadyFallbackDoesNotLeakRevokedChannelSubscription(t *tes
|
||||
t.Fatalf("CreateSession: %v", err)
|
||||
}
|
||||
|
||||
hub := NewHub(database, auth.NewRateLimiter(), nil)
|
||||
hub := newTestHub(t, database, auth.NewRateLimiter(), nil)
|
||||
go hub.Run()
|
||||
defer hub.Stop()
|
||||
|
||||
|
||||
@@ -75,7 +75,7 @@ func TestReconnect_InteriorGap_ForcesFullReady(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
hub := ws.NewHub(database, limiter, nil)
|
||||
hub := newTestHubDeps(t, database, limiter, nil)
|
||||
hub.SetEventStore(eventStore)
|
||||
go hub.Run()
|
||||
defer hub.Stop()
|
||||
|
||||
@@ -64,7 +64,7 @@ func TestReconnect_PrunedPrefix_ForcesFullReady(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
hub := ws.NewHub(database, limiter, nil)
|
||||
hub := newTestHubDeps(t, database, limiter, nil)
|
||||
hub.SetEventStore(eventStore)
|
||||
go hub.Run()
|
||||
defer hub.Stop()
|
||||
|
||||
@@ -46,7 +46,7 @@ func TestHandleReconnect_VisibilityChangeDuringHandshake_ForcesFullReady(t *test
|
||||
t.Fatalf("GetUserByID: %v", err)
|
||||
}
|
||||
|
||||
h := NewHub(database, auth.NewRateLimiter(), nil)
|
||||
h := newTestHub(t, database, auth.NewRateLimiter(), nil)
|
||||
|
||||
// Precondition: the channel starts out READ-visible to this user.
|
||||
allowedBefore, err := h.computeAllowedChannels(ctx, database, user)
|
||||
|
||||
@@ -25,8 +25,6 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/J3vb/OwnCord/Server/auth"
|
||||
)
|
||||
|
||||
func TestLiveVoiceEventsSince_ColdTierCapHit_DegradesToNil(t *testing.T) {
|
||||
@@ -49,9 +47,8 @@ func TestLiveVoiceEventsSince_ColdTierCapHit_DegradesToNil(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
hub := NewHub(database, auth.NewRateLimiter(), nil)
|
||||
hub := newTestHubWith(t, HubOptions{DB: database, ReplayColdLimit: coldCap})
|
||||
hub.SetEventStore(database)
|
||||
hub.ConfigureReplay(0, coldCap) // must run before Run(); this test never calls Run()
|
||||
|
||||
// Ring buffer is untouched (nothing pushed), so EventsSinceFiltered
|
||||
// returns nil and liveVoiceEventsSince must fall through to the cold tier.
|
||||
@@ -85,9 +82,8 @@ func TestLiveVoiceEventsSince_ColdTierExactCap_ReturnsCompleteWindow(t *testing.
|
||||
}
|
||||
}
|
||||
|
||||
hub := NewHub(database, auth.NewRateLimiter(), nil)
|
||||
hub := newTestHubWith(t, HubOptions{DB: database, ReplayColdLimit: coldCap})
|
||||
hub.SetEventStore(database)
|
||||
hub.ConfigureReplay(0, coldCap) // must run before Run(); this test never calls Run()
|
||||
|
||||
got := hub.liveVoiceEventsSince(ctx, 0, chID)
|
||||
if len(got) != len(types) {
|
||||
|
||||
@@ -72,7 +72,7 @@ func TestReconnect_ReplaysOwnVoiceRoomOutsideReadableChannels(t *testing.T) {
|
||||
t.Fatalf("CreateSession: %v", err)
|
||||
}
|
||||
|
||||
hub := NewHub(database, auth.NewRateLimiter(), nil)
|
||||
hub := newTestHub(t, database, auth.NewRateLimiter(), nil)
|
||||
go hub.Run()
|
||||
defer hub.Stop()
|
||||
|
||||
|
||||
@@ -0,0 +1,496 @@
|
||||
package ws
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"sync/atomic"
|
||||
|
||||
"github.com/coder/websocket"
|
||||
|
||||
"github.com/J3vb/OwnCord/Server/db"
|
||||
"github.com/J3vb/OwnCord/Server/telemetry"
|
||||
)
|
||||
|
||||
const (
|
||||
// maxColdReplay caps how many persisted events a single cold-tier reconnect
|
||||
// replay may return. A gap that reaches the cap cannot be replayed correctly
|
||||
// and falls back to a full ready — see handleReconnect.
|
||||
maxColdReplay = 5000
|
||||
)
|
||||
|
||||
// handleReconnect attempts to resume a client via replay. Its two return
|
||||
// values are independent signals for ServeWS:
|
||||
// - handled reports whether this function owns the outcome of the
|
||||
// connection attempt. false means "replay isn't possible, fall through
|
||||
// to handleFreshConnect for a full ready."
|
||||
// - startPumps reports whether ServeWS should start readPump/writePump.
|
||||
// It is only meaningful when handled is true, and is false on the
|
||||
// handshake-write-failure paths below: those paths already ran the full
|
||||
// unregisterFailedHandshake teardown and closed conn themselves, so no
|
||||
// pump may start — readPump's defer would find the client already gone
|
||||
// (unregisterNow reporting replaced=false) and run that same teardown a
|
||||
// second time (OC-0051): a duplicate MarkUserDisconnected, a duplicate
|
||||
// offline presence broadcast, and a duplicate hub seq for it.
|
||||
func (h *Hub) handleReconnect(
|
||||
ctx context.Context, conn *websocket.Conn, c *Client, database *db.DB, lastSeq uint64,
|
||||
) (handled, startPumps bool) {
|
||||
allowedChannelIDs, ok := h.reconnectPrecheck(ctx, database, c, lastSeq)
|
||||
if !ok {
|
||||
return false, false
|
||||
}
|
||||
|
||||
// Voice membership needs only CONNECT_VOICE, not READ_MESSAGES
|
||||
// (voice_join.go), so a live participant resuming can have their own room
|
||||
// excluded from allowedChannelIDs entirely — most commonly a DM voice call
|
||||
// after the DM was closed (computeAllowedChannels sources DM IDs from
|
||||
// dm_open_state). Capture it before registerNow performs the same
|
||||
// lookup/transfer, so replay can be supplemented below with the room's own
|
||||
// voice_state/voice_leave even though the room is outside the READ-gated
|
||||
// allowed set. It is never added to allowedChannelIDs itself — that map
|
||||
// also gates the ChannelTopic subscription in registerNow and would leak
|
||||
// the channel's chat to a user who cannot read it.
|
||||
var liveVoiceChID int64
|
||||
if old := h.GetClient(c.userID); old != nil {
|
||||
liveVoiceChID = old.getVoiceChID()
|
||||
}
|
||||
|
||||
events, replaySource, persistedTail, maxPersistedSeq := h.reconnectSelectReplay(ctx, c, lastSeq, allowedChannelIDs)
|
||||
if events == nil {
|
||||
h.reconnectTierFull.Add(1)
|
||||
telemetry.NewAppMetrics().WSReconnectTierTotal.Add(ctx, 1, telemetry.String("tier", "full"))
|
||||
return false, false
|
||||
}
|
||||
|
||||
// Register BEFORE writing replay data so broadcasts that arrive during
|
||||
// the write window are queued in the client's send buffer instead of
|
||||
// being lost (BUG-123). writePump hasn't started yet, so queued messages
|
||||
// will be drained once the pumps begin.
|
||||
//
|
||||
// The replay set built above can go stale between being read and c
|
||||
// becoming reachable: deliverBroadcast (the hub's own Run goroutine)
|
||||
// allocates a seq, pushes it to the ring buffer, and publishes to current
|
||||
// subscribers — all under h.seqMu — concurrently with this handshake
|
||||
// goroutine. registerNow is what subscribes this connection, so a
|
||||
// broadcast landing in the gap between the snapshot above and
|
||||
// registration reaches nobody, and the client's max(seq)-only tracking
|
||||
// means it can never be requested again once a later frame arrives.
|
||||
// Close the window by re-reading the ring-buffer-derived portion of
|
||||
// `events` and calling registerNow inside the SAME h.seqMu critical
|
||||
// section deliverBroadcast uses, so no seq can be allocated in between
|
||||
// (reconnectRegister below).
|
||||
// Restore the client's channel subscription BEFORE registration.
|
||||
//
|
||||
// registerNow copies the channel subscription from the OLD client entry,
|
||||
// but on a resume where the server already observed the previous socket
|
||||
// close there is no old entry to copy from — so without this the resumed
|
||||
// connection holds no ChannelTopic subscription until its post-auth_ok
|
||||
// channel_focus round trip completes. Everything broadcast to that channel
|
||||
// in the window (auth_ok write + up to maxColdReplay replay frames + pump
|
||||
// startup + one RTT) is delivered to nobody on this socket, and the client
|
||||
// can never ask for it back because it only ever reports max(seq).
|
||||
//
|
||||
// Set outside the h.seqMu section below so this does not introduce a
|
||||
// seqMu -> c.mu lock-order edge that nothing else in the hub has.
|
||||
//
|
||||
// c.authChannelID is attacker-controlled, so it is honoured only when the
|
||||
// freshly computed read-permission set contains it. Fail closed: an
|
||||
// unknown or now-unreadable id leaves channelID at 0, which is exactly the
|
||||
// pre-existing behaviour rather than a new denial.
|
||||
if c.authChannelID != 0 {
|
||||
if allowedChannelIDs[c.authChannelID] {
|
||||
c.mu.Lock()
|
||||
c.channelID = c.authChannelID
|
||||
c.mu.Unlock()
|
||||
} else {
|
||||
slog.Debug("ws handleReconnect: ignoring unreadable active_channel_id from auth frame",
|
||||
"user_id", c.userID, "channel_id", c.authChannelID)
|
||||
}
|
||||
}
|
||||
|
||||
events, ok = h.reconnectRegister(ctx, c, lastSeq, allowedChannelIDs, replaySource, persistedTail, maxPersistedSeq)
|
||||
if !ok {
|
||||
return false, false
|
||||
}
|
||||
|
||||
switch replaySource {
|
||||
case "buffer":
|
||||
h.reconnectTierBuf.Add(1)
|
||||
case "db":
|
||||
h.reconnectTierDB.Add(1)
|
||||
}
|
||||
telemetry.NewAppMetrics().WSReconnectTierTotal.Add(ctx, 1, telemetry.String("tier", replaySource))
|
||||
|
||||
// Best-effort supplement: the user's own live voice room may sit outside
|
||||
// allowedChannelIDs (see the capture of liveVoiceChID above), so its
|
||||
// voice_state/voice_leave would otherwise never reach this replay at all.
|
||||
// Tries the ring buffer first, then the cold-tier store; a miss on both
|
||||
// just leaves this one supplement as a no-op, not a regression versus the
|
||||
// pre-fix behaviour.
|
||||
if liveVoiceChID != 0 && !allowedChannelIDs[liveVoiceChID] {
|
||||
events = append(events, h.liveVoiceEventsSince(ctx, lastSeq, liveVoiceChID)...)
|
||||
}
|
||||
|
||||
// Settle the session's status BEFORE the auth_ok write below, mirroring
|
||||
// handleFreshConnect's ordering: reconnectWriteReplay reads c.user.Status
|
||||
// to build auth_ok, so if this ran after that write the resumed client
|
||||
// would be told its disconnect-time status (routinely "offline", since
|
||||
// MarkUserDisconnected just rewrote it) instead of the status it is about
|
||||
// to come online as and broadcast (OC-0222). Skips member_join — the user
|
||||
// was already known.
|
||||
applyConnectStatus(ctx, database, c)
|
||||
|
||||
if !h.reconnectWriteReplay(ctx, conn, c, lastSeq, events, replaySource) {
|
||||
// startPumps=false: the teardown inside reconnectWriteReplay already ran
|
||||
// in full. Starting readPump on this closed conn would hit an immediate
|
||||
// Read error and its defer would run the identical teardown a second
|
||||
// time (OC-0051).
|
||||
return true, false
|
||||
}
|
||||
|
||||
h.announceConnectPresence(c)
|
||||
|
||||
return true, true
|
||||
}
|
||||
|
||||
// reconnectPrecheck runs handleReconnect's two entry guards and, when replay is
|
||||
// still on the table, returns the read-permission set replay is filtered by.
|
||||
// ok=false means the caller must fall through to a full ready.
|
||||
func (h *Hub) reconnectPrecheck(
|
||||
ctx context.Context, database *db.DB, c *Client, lastSeq uint64,
|
||||
) (map[int64]bool, bool) {
|
||||
// Channel-visibility changes are delivered as targeted, unsequenced
|
||||
// messages, so replay cannot bring a client that missed one back into a
|
||||
// coherent state — force the full-ready path instead.
|
||||
if h.mustFullResync(lastSeq) {
|
||||
slog.Info("ws replay skipped (visibility changed since last_seq), sending full ready",
|
||||
"user_id", c.userID, "last_seq", lastSeq)
|
||||
h.reconnectTierFull.Add(1)
|
||||
telemetry.NewAppMetrics().WSReconnectTierTotal.Add(ctx, 1, telemetry.String("tier", "full"))
|
||||
return nil, false
|
||||
}
|
||||
// c.user is the auth-time snapshot; a role reassignment landing between
|
||||
// authenticateConn and here would otherwise be resolved from the OLD
|
||||
// RoleID for the rest of this socket's life — revokeUnreadableChannels
|
||||
// cannot reach a mid-handshake socket (it early-returns when the user is
|
||||
// not yet in h.clients), and nothing revalidates handshake-time
|
||||
// subscriptions afterwards (audit-2026-08-19 F-2). Re-read the row so
|
||||
// the permission set below is computed from the CURRENT role.
|
||||
if err := h.refreshUserSnapshot(ctx, database, c); err != nil {
|
||||
slog.Warn("ws handleReconnect: user re-read failed, falling back to full ready",
|
||||
"user_id", c.userID, "err", err)
|
||||
return nil, false
|
||||
}
|
||||
// Compute the set of channel IDs the reconnecting user can access so that
|
||||
// channel-scoped replay events are filtered by current permissions (M3).
|
||||
allowedChannelIDs, err := h.computeAllowedChannels(ctx, database, c.user)
|
||||
if err != nil {
|
||||
slog.Warn("ws handleReconnect: computeAllowedChannels failed, falling back to full ready",
|
||||
"user_id", c.userID, "err", err)
|
||||
return nil, false
|
||||
}
|
||||
return allowedChannelIDs, true
|
||||
}
|
||||
|
||||
// reconnectSelectReplay picks the tier that serves this resume — the ring
|
||||
// buffer when it still covers lastSeq, otherwise the cold-tier EventStore — and
|
||||
// returns the events found, the tier name, and (cold tier only) the persisted
|
||||
// rows plus their highest seq, which reconnectRegister needs for its re-read.
|
||||
// A nil events return means neither tier can replay and the caller must fall
|
||||
// through to a full ready.
|
||||
func (h *Hub) reconnectSelectReplay(
|
||||
ctx context.Context, c *Client, lastSeq uint64, allowedChannelIDs map[int64]bool,
|
||||
) ([][]byte, string, [][]byte, uint64) {
|
||||
var (
|
||||
events [][]byte
|
||||
replaySource = "buffer"
|
||||
persistedTail [][]byte // cold-tier rows only; re-merged with a fresh buffer tail below
|
||||
maxPersistedSeq uint64
|
||||
)
|
||||
if buf := h.ReplayBuffer().EventsSinceFiltered(lastSeq, allowedChannelIDs); buf != nil {
|
||||
events = buf
|
||||
return events, replaySource, persistedTail, maxPersistedSeq
|
||||
}
|
||||
// Phase B Step 7 — try cold-tier replay from the EventStore before
|
||||
// giving up and forcing a full ready re-sync.
|
||||
if esp := h.eventStore.Load(); esp != nil {
|
||||
es := *esp
|
||||
channelIDs := make([]int64, 0, len(allowedChannelIDs))
|
||||
for cid := range allowedChannelIDs {
|
||||
channelIDs = append(channelIDs, cid)
|
||||
}
|
||||
coldCap := h.maxColdReplayLimit()
|
||||
persisted, dbErr := es.GetEventsSinceForChannels(ctx, int64(lastSeq), channelIDs, coldCap) //nolint:gosec // lastSeq is a sequence counter bounded well below MaxInt64
|
||||
switch {
|
||||
case dbErr != nil:
|
||||
slog.Warn("ws handleReconnect: cold-tier replay query failed",
|
||||
"user_id", c.userID, "err", dbErr)
|
||||
case len(persisted) >= coldCap:
|
||||
// The query is "ORDER BY seq ASC LIMIT maxColdReplay", so a full
|
||||
// result means the gap exceeds the cap and the NEWEST events were
|
||||
// dropped. Replaying it would look like a complete resume to the
|
||||
// client — it tracks only max(seq) and cannot detect the hole —
|
||||
// silently losing state events that REST history never repairs.
|
||||
// Leave events nil so the fall-through forces a full ready.
|
||||
slog.Warn("ws handleReconnect: cold-tier replay hit the row cap, forcing full ready",
|
||||
"user_id", c.userID, "last_seq", lastSeq, "cap", coldCap)
|
||||
case len(persisted) > 0:
|
||||
// Retention pruning (PruneEventsOlderThan) deletes purely by
|
||||
// created_at with no seq-floor coordination, so this
|
||||
// channel-filtered result can be a surviving suffix left behind
|
||||
// after the events between lastSeq and persisted[0] were
|
||||
// pruned. Accepting it as-is would present a hole as a complete
|
||||
// resume, since the client tracks only max(seq). Probe the
|
||||
// store's oldest surviving seq UNFILTERED before trusting it —
|
||||
// a channel-filtered contiguity check on persisted itself can't
|
||||
// work, since a sparse per-channel result is legitimately
|
||||
// non-contiguous.
|
||||
oldest, oldestErr := es.GetEventsSince(ctx, 0, 1)
|
||||
switch {
|
||||
case oldestErr != nil:
|
||||
slog.Warn("ws handleReconnect: cold-tier oldest-seq probe failed, forcing full ready",
|
||||
"user_id", c.userID, "err", oldestErr)
|
||||
case len(oldest) == 0 || uint64(oldest[0].Seq) > lastSeq+1: //nolint:gosec // seq is a counter bounded well below MaxInt64
|
||||
var oldestSeq int64
|
||||
if len(oldest) > 0 {
|
||||
oldestSeq = oldest[0].Seq
|
||||
}
|
||||
slog.Warn("ws handleReconnect: retention pruning left a gap before last_seq, forcing full ready",
|
||||
"user_id", c.userID, "last_seq", lastSeq, "oldest_seq", oldestSeq)
|
||||
default:
|
||||
persistedTail, maxPersistedSeq = h.reconnectVetColdTail(ctx, c, es, lastSeq, persisted, allowedChannelIDs)
|
||||
if persistedTail != nil {
|
||||
events = persistedTail
|
||||
replaySource = "db"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return events, replaySource, persistedTail, maxPersistedSeq
|
||||
}
|
||||
|
||||
// reconnectVetColdTail turns a cold-tier result into a replayable tail, or
|
||||
// returns nil when it cannot be trusted: the range it covers must have no
|
||||
// interior gap, and the ring buffer must cover everything newer than its last
|
||||
// row. The returned seq is the highest one in persisted.
|
||||
func (h *Hub) reconnectVetColdTail(
|
||||
ctx context.Context, c *Client, es EventStore, lastSeq uint64,
|
||||
persisted []db.PersistedEvent, allowedChannelIDs map[int64]bool,
|
||||
) ([][]byte, uint64) {
|
||||
persistedTail := make([][]byte, 0, len(persisted))
|
||||
for _, p := range persisted {
|
||||
persistedTail = append(persistedTail, p.Payload)
|
||||
}
|
||||
maxPersistedSeq := uint64(persisted[len(persisted)-1].Seq) //nolint:gosec // seq is a counter bounded well below MaxInt64
|
||||
|
||||
// persisted is channel-filtered, so a hole in a channel
|
||||
// outside allowedChannelIDs would slip past a contiguity
|
||||
// check on persisted itself — and EventPersister can lose a
|
||||
// row outright (a full queue drops silently in Enqueue, a
|
||||
// per-row insert failure inside a batch flush is logged but
|
||||
// never surfaced here; see event_persister.go). Count the
|
||||
// UNFILTERED range (lastSeq, maxPersistedSeq] and require
|
||||
// every seq in it to be present. seq is the events table's
|
||||
// primary key, so the count can only come up short, never
|
||||
// over.
|
||||
expectedCount := maxPersistedSeq - lastSeq
|
||||
switch gapCount, gapErr := es.CountEventsInRange(ctx, int64(lastSeq), int64(maxPersistedSeq)); { //nolint:gosec // bounded well below MaxInt64
|
||||
case gapErr != nil:
|
||||
slog.Warn("ws handleReconnect: cold-tier contiguity probe failed, forcing full ready",
|
||||
"user_id", c.userID, "err", gapErr)
|
||||
persistedTail = nil
|
||||
case uint64(gapCount) != expectedCount: //nolint:gosec // bounded well below MaxInt64
|
||||
slog.Warn("ws handleReconnect: cold-tier replay has an interior gap, forcing full ready",
|
||||
"user_id", c.userID, "last_seq", lastSeq, "max_persisted_seq", maxPersistedSeq,
|
||||
"expected", expectedCount, "found", gapCount)
|
||||
persistedTail = nil
|
||||
}
|
||||
|
||||
if persistedTail != nil {
|
||||
// The EventPersister flushes asynchronously, so cold rows can
|
||||
// lag the live seq: events broadcast after the last flush sit
|
||||
// only in the ring buffer. Confirm the buffer can cover
|
||||
// everything above the newest persisted row — the
|
||||
// authoritative re-read happens atomically with registerNow
|
||||
// below, but a hole here must still force a full ready
|
||||
// rather than a replay with a silent gap at its end.
|
||||
switch tail := h.ReplayBuffer().EventsSinceFiltered(maxPersistedSeq, allowedChannelIDs); {
|
||||
case tail != nil:
|
||||
case atomic.LoadUint64(&h.seq) == maxPersistedSeq:
|
||||
// Post-restart empty buffer with the hub seq seeded from
|
||||
// the store max: nothing was broadcast after the last
|
||||
// persisted row, so the cold rows alone are complete.
|
||||
default:
|
||||
slog.Warn("ws handleReconnect: ring buffer cannot cover the post-flush tail, forcing full ready",
|
||||
"user_id", c.userID, "max_persisted_seq", maxPersistedSeq)
|
||||
persistedTail = nil
|
||||
}
|
||||
}
|
||||
return persistedTail, maxPersistedSeq
|
||||
}
|
||||
|
||||
// reconnectRegister re-reads the ring-buffer-derived portion of the replay and
|
||||
// registers c inside the SAME h.seqMu critical section deliverBroadcast uses,
|
||||
// so no seq can be allocated in between (see the comment in handleReconnect).
|
||||
// It returns the events to actually send; ok=false means one of the re-checks
|
||||
// tripped and the caller must fall through to a full ready.
|
||||
func (h *Hub) reconnectRegister(
|
||||
ctx context.Context, c *Client, lastSeq uint64, allowedChannelIDs map[int64]bool,
|
||||
replaySource string, persistedTail [][]byte, maxPersistedSeq uint64,
|
||||
) ([][]byte, bool) {
|
||||
var events [][]byte
|
||||
h.seqMu.Lock()
|
||||
switch replaySource {
|
||||
case "buffer":
|
||||
fresh := h.ReplayBuffer().EventsSinceFiltered(lastSeq, allowedChannelIDs)
|
||||
if fresh == nil {
|
||||
// The buffer window closed between the earlier check and this
|
||||
// lock (an extreme write burst evicted lastSeq) — there is
|
||||
// nothing left to fall back to for this attempt but a full ready.
|
||||
h.seqMu.Unlock()
|
||||
slog.Warn("ws handleReconnect: buffer window closed just before registration, forcing full ready",
|
||||
"user_id", c.userID, "last_seq", lastSeq)
|
||||
h.reconnectTierFull.Add(1)
|
||||
telemetry.NewAppMetrics().WSReconnectTierTotal.Add(ctx, 1, telemetry.String("tier", "full"))
|
||||
return nil, false
|
||||
}
|
||||
events = fresh
|
||||
case "db":
|
||||
switch tail := h.ReplayBuffer().EventsSinceFiltered(maxPersistedSeq, allowedChannelIDs); {
|
||||
case tail != nil:
|
||||
events = append(append([][]byte{}, persistedTail...), tail...)
|
||||
case atomic.LoadUint64(&h.seq) == maxPersistedSeq:
|
||||
events = persistedTail
|
||||
default:
|
||||
h.seqMu.Unlock()
|
||||
slog.Warn("ws handleReconnect: ring buffer cannot cover the post-flush tail just before registration, forcing full ready",
|
||||
"user_id", c.userID, "max_persisted_seq", maxPersistedSeq)
|
||||
h.reconnectTierFull.Add(1)
|
||||
telemetry.NewAppMetrics().WSReconnectTierTotal.Add(ctx, 1, telemetry.String("tier", "full"))
|
||||
return nil, false
|
||||
}
|
||||
}
|
||||
if handleReconnectPreRegisterRaceHook != nil {
|
||||
handleReconnectPreRegisterRaceHook()
|
||||
}
|
||||
// Re-check the watermark one last time, right before registerNow makes
|
||||
// this connection reachable. RefreshChannelVisibility and
|
||||
// revokeUnreadableChannels both iterate h.clients to fan out a targeted,
|
||||
// unsequenced channel_create/channel_delete — a snapshot this
|
||||
// still-mid-handshake connection is absent from — and both only bump the
|
||||
// watermark afterward. Without this re-check, a visibility change that
|
||||
// lands anywhere between the entry check above and here is missed twice:
|
||||
// the fan-out can't reach an unregistered client, and the entry check has
|
||||
// already passed, so nothing else catches it before this resume commits
|
||||
// to permissions computed before the change (OC-0206).
|
||||
if h.mustFullResync(lastSeq) {
|
||||
h.seqMu.Unlock()
|
||||
slog.Warn("ws handleReconnect: visibility changed during handshake, forcing full ready",
|
||||
"user_id", c.userID, "last_seq", lastSeq)
|
||||
h.reconnectTierFull.Add(1)
|
||||
telemetry.NewAppMetrics().WSReconnectTierTotal.Add(ctx, 1, telemetry.String("tier", "full"))
|
||||
return nil, false
|
||||
}
|
||||
h.registerNow(c, allowedChannelIDs)
|
||||
h.seqMu.Unlock()
|
||||
return events, true
|
||||
}
|
||||
|
||||
// reconnectWriteReplay writes the resume handshake: auth_ok followed by the
|
||||
// replayed events. A false return means a write failed, in which case the full
|
||||
// unregisterFailedHandshake teardown has already run and conn is closed, so the
|
||||
// caller must not start any pump (OC-0051).
|
||||
func (h *Hub) reconnectWriteReplay(
|
||||
ctx context.Context, conn *websocket.Conn, c *Client, lastSeq uint64,
|
||||
events [][]byte, replaySource string,
|
||||
) bool {
|
||||
// Replay succeeded — send auth_ok then missed events. The replay tier
|
||||
// is included in the payload so the client can attribute reconnect
|
||||
// behaviour without separate metric scraping.
|
||||
slog.Info("ws sending auth_ok (reconnect)", "user_id", c.userID, "username", c.user.Username, "role", c.roleName, "replay_source", replaySource)
|
||||
if err := handshakeWrite(ctx, conn, h.buildAuthOK(ctx, c.user, c.roleName, replaySource)); err != nil {
|
||||
slog.Warn("ws: failed to send auth_ok (reconnect)", "user_id", c.userID, "err", err)
|
||||
h.unregisterFailedHandshake(ctx, c)
|
||||
_ = conn.Close(websocket.StatusInternalError, "handshake failed")
|
||||
return false
|
||||
}
|
||||
for _, evt := range events {
|
||||
if err := handshakeWrite(ctx, conn, evt); err != nil {
|
||||
slog.Warn("ws: failed to send replay event", "user_id", c.userID, "err", err)
|
||||
h.unregisterFailedHandshake(ctx, c)
|
||||
_ = conn.Close(websocket.StatusInternalError, "handshake failed")
|
||||
return false
|
||||
}
|
||||
}
|
||||
slog.Info("ws replay completed", "user_id", c.userID, "events_replayed", len(events), "from_seq", lastSeq, "source", replaySource)
|
||||
return true
|
||||
}
|
||||
|
||||
// liveVoiceEventsSince returns voice_state/voice_leave events for chID at or
|
||||
// after afterSeq, bypassing the READ-gated channel filter entirely. Voice
|
||||
// membership needs only CONNECT_VOICE (voice_join.go), so a resuming
|
||||
// participant's own room is not always in their READ-visible set — a stock
|
||||
// example is a DM voice call after the DM was closed. Tries the ring buffer
|
||||
// first (fresh, so it observes anything pushed concurrently with the caller),
|
||||
// then falls back to the cold-tier store; returns nil, not an error, on a
|
||||
// miss in both, since this is a best-effort supplement to the main replay.
|
||||
func (h *Hub) liveVoiceEventsSince(ctx context.Context, afterSeq uint64, chID int64) [][]byte {
|
||||
if chID == 0 {
|
||||
return nil
|
||||
}
|
||||
only := map[int64]bool{chID: true}
|
||||
var raw [][]byte
|
||||
if buf := h.ReplayBuffer().EventsSinceFiltered(afterSeq, only); buf != nil {
|
||||
raw = buf
|
||||
} else if esp := h.eventStore.Load(); esp != nil {
|
||||
es := *esp
|
||||
coldCap := h.maxColdReplayLimit()
|
||||
// Fetch one row past the cap so truncation is decided by the presence
|
||||
// of that extra row, not by len == cap: a complete window of exactly
|
||||
// coldCap rows is not truncated and must replay in full (Codex review
|
||||
// on #1436). A result of at most coldCap rows is therefore complete.
|
||||
persisted, err := es.GetEventsSinceForChannels(ctx, int64(afterSeq), []int64{chID}, coldCap+1) //nolint:gosec // afterSeq is a sequence counter bounded well below MaxInt64
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
if len(persisted) > coldCap {
|
||||
// Same failure mode reconnectSelectReplay guards against above:
|
||||
// the query is "ORDER BY seq ASC LIMIT n", so a result past the
|
||||
// cap means the range exceeds it and any cap-sized window would
|
||||
// have silently dropped the NEWEST rows — for a voice room, quite
|
||||
// possibly the peer's voice_leave. Replaying a truncated window
|
||||
// would install a join whose matching leave was discarded, which
|
||||
// is worse than the documented best-effort miss this function
|
||||
// already returns on a plain lookup failure. A full ready isn't
|
||||
// available here (registerNow already ran before this supplement
|
||||
// runs), so nil is the correct degradation.
|
||||
slog.Warn("ws liveVoiceEventsSince: cold-tier supplement exceeds the row cap, skipping truncated window",
|
||||
"chID", chID, "after_seq", afterSeq, "cap", coldCap)
|
||||
return nil
|
||||
}
|
||||
raw = make([][]byte, 0, len(persisted))
|
||||
for _, p := range persisted {
|
||||
raw = append(raw, p.Payload)
|
||||
}
|
||||
}
|
||||
if len(raw) == 0 {
|
||||
return nil
|
||||
}
|
||||
filtered := make([][]byte, 0, len(raw))
|
||||
for _, evt := range raw {
|
||||
switch extractEventType(evt) {
|
||||
case MsgTypeVoiceState, MsgTypeVoiceLeaveBC:
|
||||
filtered = append(filtered, evt)
|
||||
}
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
// maxColdReplayLimit returns the effective persisted-replay cap. The budget
|
||||
// arrives via HubOptions (B3-4): the dispatch loop reads replayBuf unlocked,
|
||||
// so the ring is sized exactly once, at construction.
|
||||
func (h *Hub) maxColdReplayLimit() int {
|
||||
if h.coldReplayLimit > 0 {
|
||||
return h.coldReplayLimit
|
||||
}
|
||||
return maxColdReplay
|
||||
}
|
||||
@@ -154,7 +154,7 @@ func TestRefreshUserSnapshot_PicksUpRoleReassignment(t *testing.T) {
|
||||
t.Fatalf("GetUserByID: %v", err)
|
||||
}
|
||||
|
||||
hub := NewHub(database, auth.NewRateLimiter(), nil)
|
||||
hub := newTestHub(t, database, auth.NewRateLimiter(), nil)
|
||||
c := newClient(hub, nil, user, "", 0, ctx)
|
||||
c.roleName = "harvest-voice"
|
||||
|
||||
@@ -191,7 +191,7 @@ func TestFreshConnectFallback_RoleReassignMidReconnect_ResolvesFreshRole(t *test
|
||||
t.Fatalf("CreateSession: %v", err)
|
||||
}
|
||||
|
||||
hub := NewHub(database, auth.NewRateLimiter(), nil)
|
||||
hub := newTestHub(t, database, auth.NewRateLimiter(), nil)
|
||||
go hub.Run()
|
||||
defer hub.Stop()
|
||||
|
||||
@@ -277,7 +277,7 @@ func TestFreshConnectFallback_RoleReassignPreRegister_PostRegisterVerifyRevokes(
|
||||
t.Fatalf("CreateSession: %v", err)
|
||||
}
|
||||
|
||||
hub := NewHub(database, auth.NewRateLimiter(), nil)
|
||||
hub := newTestHub(t, database, auth.NewRateLimiter(), nil)
|
||||
go hub.Run()
|
||||
defer hub.Stop()
|
||||
|
||||
@@ -346,7 +346,7 @@ func TestRevokeUnreadableChannels_ActsOnReplacementClient(t *testing.T) {
|
||||
t.Fatalf("GetUserByID: %v", err)
|
||||
}
|
||||
|
||||
hub := NewHub(database, auth.NewRateLimiter(), nil)
|
||||
hub := newTestHub(t, database, auth.NewRateLimiter(), nil)
|
||||
c1 := newClient(hub, nil, user, "", 0, ctx)
|
||||
c2 := newClient(hub, nil, user, "", 0, ctx)
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@ import (
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/coder/websocket"
|
||||
@@ -14,8 +13,6 @@ import (
|
||||
"github.com/J3vb/OwnCord/Server/auth"
|
||||
"github.com/J3vb/OwnCord/Server/config"
|
||||
"github.com/J3vb/OwnCord/Server/db"
|
||||
"github.com/J3vb/OwnCord/Server/permissions"
|
||||
"github.com/J3vb/OwnCord/Server/telemetry"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -26,31 +23,8 @@ const (
|
||||
// wsReadLimitBytes is the maximum size of a single inbound WebSocket
|
||||
// message. Must match the client-side upload cap.
|
||||
wsReadLimitBytes = config.MaxMessageBytes
|
||||
|
||||
// maxColdReplay caps how many persisted events a single cold-tier reconnect
|
||||
// replay may return. A gap that reaches the cap cannot be replayed correctly
|
||||
// and falls back to a full ready — see handleReconnect.
|
||||
maxColdReplay = 5000
|
||||
)
|
||||
|
||||
// handshakeWrite writes one handshake-phase message (auth_ok, ready, or a
|
||||
// replay event) under writeTimeout, instead of the bare ctx every caller here
|
||||
// otherwise has on hand.
|
||||
//
|
||||
// Every handshake write runs against ctx = r.Context() from ServeWS.
|
||||
// websocket.Accept hijacks the connection, which stops net/http's own
|
||||
// mechanism for cancelling that context on client disconnect, so without this
|
||||
// wrapper ctx is never cancelled while the handler is blocked inside
|
||||
// conn.Write — a peer that stops reading (or whose receive window closes)
|
||||
// pins the write, the handler goroutine, and the socket forever (OC-0152).
|
||||
// writePumpWrite (serve_pumps.go) already bounds its writes the same way;
|
||||
// this brings the handshake writes in serve.go up to the same guarantee.
|
||||
func handshakeWrite(ctx context.Context, conn *websocket.Conn, msg []byte) error {
|
||||
wCtx, cancel := context.WithTimeout(ctx, writeTimeout)
|
||||
defer cancel()
|
||||
return conn.Write(wCtx, websocket.MessageText, msg)
|
||||
}
|
||||
|
||||
// ServeWS upgrades an HTTP connection to WebSocket, performs in-band auth,
|
||||
// then drives the client's read/write loops.
|
||||
// Do not wrap with AuthMiddleware — WS does its own auth.
|
||||
@@ -119,45 +93,6 @@ func ServeWS(hub *Hub, database *db.DB, allowedOrigins []string, maxConns int) h
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Hub) upgradeAndAuth(
|
||||
conn *websocket.Conn, database *db.DB, r *http.Request,
|
||||
) (*Client, uint64, error) {
|
||||
user, tokenHash, hint, err := authenticateConn(r.Context(), conn, database)
|
||||
if err != nil {
|
||||
slog.Warn("ws auth failed", "err", err, "remote", r.RemoteAddr)
|
||||
_ = conn.Close(websocket.StatusPolicyViolation, "authentication failed")
|
||||
return nil, 0, err
|
||||
}
|
||||
lastSeq := hint.LastSeq
|
||||
|
||||
c := newClient(h, conn, user, tokenHash, lastSeq, r.Context())
|
||||
c.remoteAddr = r.RemoteAddr
|
||||
// Untrusted until handleReconnect checks it against the allowed set.
|
||||
c.authChannelID = hint.ChannelID
|
||||
|
||||
// Look up role name for protocol-compliant payloads and cache on client.
|
||||
// Fail closed like the sibling lookup in handleFreshConnect (BUG-094):
|
||||
// this value is authoritative on the wire — auth_ok reports it as the
|
||||
// user's own role, member_join broadcasts it to every other client, and
|
||||
// every chat_message carries it — so a lookup failure must not silently
|
||||
// substitute "member" and pin the whole session to a fabricated role
|
||||
// (OC-0269).
|
||||
role, roleErr := database.GetRoleByID(r.Context(), user.RoleID)
|
||||
if roleErr != nil || role == nil {
|
||||
slog.Error("ws: role lookup failed during handshake, closing connection",
|
||||
"user_id", user.ID, "role_id", user.RoleID, "err", roleErr)
|
||||
_ = conn.Close(websocket.StatusInternalError, "role lookup failed")
|
||||
return nil, 0, fmt.Errorf("upgradeAndAuth: role lookup failed for user %d: %w", user.ID, roleErr)
|
||||
}
|
||||
c.roleName = strings.ToLower(role.Name)
|
||||
|
||||
slog.Info("websocket connected", "username", user.Username, "user_id", user.ID, "remote", r.RemoteAddr)
|
||||
db.WriteAudit(context.WithoutCancel(r.Context()), database, user.ID, "ws_connect", "user", user.ID,
|
||||
"WebSocket connected from "+r.RemoteAddr)
|
||||
|
||||
return c, lastSeq, nil
|
||||
}
|
||||
|
||||
// handleReconnectPreRegisterRaceHook, when non-nil, runs once inside
|
||||
// handleReconnect's h.seqMu critical section immediately before the
|
||||
// mustFullResync re-check that guards registerNow. Test-only (nil in
|
||||
@@ -175,179 +110,6 @@ var handleReconnectPreRegisterRaceHook func()
|
||||
// deterministically, same pattern as handleReconnectPreRegisterRaceHook.
|
||||
var freshConnectPreRegisterRaceHook func()
|
||||
|
||||
// handleReconnect attempts to resume a client via replay. Its two return
|
||||
// values are independent signals for ServeWS:
|
||||
// - handled reports whether this function owns the outcome of the
|
||||
// connection attempt. false means "replay isn't possible, fall through
|
||||
// to handleFreshConnect for a full ready."
|
||||
// - startPumps reports whether ServeWS should start readPump/writePump.
|
||||
// It is only meaningful when handled is true, and is false on the
|
||||
// handshake-write-failure paths below: those paths already ran the full
|
||||
// unregisterFailedHandshake teardown and closed conn themselves, so no
|
||||
// pump may start — readPump's defer would find the client already gone
|
||||
// (unregisterNow reporting replaced=false) and run that same teardown a
|
||||
// second time (OC-0051): a duplicate MarkUserDisconnected, a duplicate
|
||||
// offline presence broadcast, and a duplicate hub seq for it.
|
||||
func (h *Hub) handleReconnect(
|
||||
ctx context.Context, conn *websocket.Conn, c *Client, database *db.DB, lastSeq uint64,
|
||||
) (handled, startPumps bool) {
|
||||
allowedChannelIDs, ok := h.reconnectPrecheck(ctx, database, c, lastSeq)
|
||||
if !ok {
|
||||
return false, false
|
||||
}
|
||||
|
||||
// Voice membership needs only CONNECT_VOICE, not READ_MESSAGES
|
||||
// (voice_join.go), so a live participant resuming can have their own room
|
||||
// excluded from allowedChannelIDs entirely — most commonly a DM voice call
|
||||
// after the DM was closed (computeAllowedChannels sources DM IDs from
|
||||
// dm_open_state). Capture it before registerNow performs the same
|
||||
// lookup/transfer, so replay can be supplemented below with the room's own
|
||||
// voice_state/voice_leave even though the room is outside the READ-gated
|
||||
// allowed set. It is never added to allowedChannelIDs itself — that map
|
||||
// also gates the ChannelTopic subscription in registerNow and would leak
|
||||
// the channel's chat to a user who cannot read it.
|
||||
var liveVoiceChID int64
|
||||
if old := h.GetClient(c.userID); old != nil {
|
||||
liveVoiceChID = old.getVoiceChID()
|
||||
}
|
||||
|
||||
events, replaySource, persistedTail, maxPersistedSeq := h.reconnectSelectReplay(ctx, c, lastSeq, allowedChannelIDs)
|
||||
if events == nil {
|
||||
h.reconnectTierFull.Add(1)
|
||||
telemetry.NewAppMetrics().WSReconnectTierTotal.Add(ctx, 1, telemetry.String("tier", "full"))
|
||||
return false, false
|
||||
}
|
||||
|
||||
// Register BEFORE writing replay data so broadcasts that arrive during
|
||||
// the write window are queued in the client's send buffer instead of
|
||||
// being lost (BUG-123). writePump hasn't started yet, so queued messages
|
||||
// will be drained once the pumps begin.
|
||||
//
|
||||
// The replay set built above can go stale between being read and c
|
||||
// becoming reachable: deliverBroadcast (the hub's own Run goroutine)
|
||||
// allocates a seq, pushes it to the ring buffer, and publishes to current
|
||||
// subscribers — all under h.seqMu — concurrently with this handshake
|
||||
// goroutine. registerNow is what subscribes this connection, so a
|
||||
// broadcast landing in the gap between the snapshot above and
|
||||
// registration reaches nobody, and the client's max(seq)-only tracking
|
||||
// means it can never be requested again once a later frame arrives.
|
||||
// Close the window by re-reading the ring-buffer-derived portion of
|
||||
// `events` and calling registerNow inside the SAME h.seqMu critical
|
||||
// section deliverBroadcast uses, so no seq can be allocated in between
|
||||
// (reconnectRegister below).
|
||||
// Restore the client's channel subscription BEFORE registration.
|
||||
//
|
||||
// registerNow copies the channel subscription from the OLD client entry,
|
||||
// but on a resume where the server already observed the previous socket
|
||||
// close there is no old entry to copy from — so without this the resumed
|
||||
// connection holds no ChannelTopic subscription until its post-auth_ok
|
||||
// channel_focus round trip completes. Everything broadcast to that channel
|
||||
// in the window (auth_ok write + up to maxColdReplay replay frames + pump
|
||||
// startup + one RTT) is delivered to nobody on this socket, and the client
|
||||
// can never ask for it back because it only ever reports max(seq).
|
||||
//
|
||||
// Set outside the h.seqMu section below so this does not introduce a
|
||||
// seqMu -> c.mu lock-order edge that nothing else in the hub has.
|
||||
//
|
||||
// c.authChannelID is attacker-controlled, so it is honoured only when the
|
||||
// freshly computed read-permission set contains it. Fail closed: an
|
||||
// unknown or now-unreadable id leaves channelID at 0, which is exactly the
|
||||
// pre-existing behaviour rather than a new denial.
|
||||
if c.authChannelID != 0 {
|
||||
if allowedChannelIDs[c.authChannelID] {
|
||||
c.mu.Lock()
|
||||
c.channelID = c.authChannelID
|
||||
c.mu.Unlock()
|
||||
} else {
|
||||
slog.Debug("ws handleReconnect: ignoring unreadable active_channel_id from auth frame",
|
||||
"user_id", c.userID, "channel_id", c.authChannelID)
|
||||
}
|
||||
}
|
||||
|
||||
events, ok = h.reconnectRegister(ctx, c, lastSeq, allowedChannelIDs, replaySource, persistedTail, maxPersistedSeq)
|
||||
if !ok {
|
||||
return false, false
|
||||
}
|
||||
|
||||
switch replaySource {
|
||||
case "buffer":
|
||||
h.reconnectTierBuf.Add(1)
|
||||
case "db":
|
||||
h.reconnectTierDB.Add(1)
|
||||
}
|
||||
telemetry.NewAppMetrics().WSReconnectTierTotal.Add(ctx, 1, telemetry.String("tier", replaySource))
|
||||
|
||||
// Best-effort supplement: the user's own live voice room may sit outside
|
||||
// allowedChannelIDs (see the capture of liveVoiceChID above), so its
|
||||
// voice_state/voice_leave would otherwise never reach this replay at all.
|
||||
// Tries the ring buffer first, then the cold-tier store; a miss on both
|
||||
// just leaves this one supplement as a no-op, not a regression versus the
|
||||
// pre-fix behaviour.
|
||||
if liveVoiceChID != 0 && !allowedChannelIDs[liveVoiceChID] {
|
||||
events = append(events, h.liveVoiceEventsSince(ctx, lastSeq, liveVoiceChID)...)
|
||||
}
|
||||
|
||||
// Settle the session's status BEFORE the auth_ok write below, mirroring
|
||||
// handleFreshConnect's ordering: reconnectWriteReplay reads c.user.Status
|
||||
// to build auth_ok, so if this ran after that write the resumed client
|
||||
// would be told its disconnect-time status (routinely "offline", since
|
||||
// MarkUserDisconnected just rewrote it) instead of the status it is about
|
||||
// to come online as and broadcast (OC-0222). Skips member_join — the user
|
||||
// was already known.
|
||||
applyConnectStatus(ctx, database, c)
|
||||
|
||||
if !h.reconnectWriteReplay(ctx, conn, c, lastSeq, events, replaySource) {
|
||||
// startPumps=false: the teardown inside reconnectWriteReplay already ran
|
||||
// in full. Starting readPump on this closed conn would hit an immediate
|
||||
// Read error and its defer would run the identical teardown a second
|
||||
// time (OC-0051).
|
||||
return true, false
|
||||
}
|
||||
|
||||
h.announceConnectPresence(c)
|
||||
|
||||
return true, true
|
||||
}
|
||||
|
||||
// reconnectPrecheck runs handleReconnect's two entry guards and, when replay is
|
||||
// still on the table, returns the read-permission set replay is filtered by.
|
||||
// ok=false means the caller must fall through to a full ready.
|
||||
func (h *Hub) reconnectPrecheck(
|
||||
ctx context.Context, database *db.DB, c *Client, lastSeq uint64,
|
||||
) (map[int64]bool, bool) {
|
||||
// Channel-visibility changes are delivered as targeted, unsequenced
|
||||
// messages, so replay cannot bring a client that missed one back into a
|
||||
// coherent state — force the full-ready path instead.
|
||||
if h.mustFullResync(lastSeq) {
|
||||
slog.Info("ws replay skipped (visibility changed since last_seq), sending full ready",
|
||||
"user_id", c.userID, "last_seq", lastSeq)
|
||||
h.reconnectTierFull.Add(1)
|
||||
telemetry.NewAppMetrics().WSReconnectTierTotal.Add(ctx, 1, telemetry.String("tier", "full"))
|
||||
return nil, false
|
||||
}
|
||||
// c.user is the auth-time snapshot; a role reassignment landing between
|
||||
// authenticateConn and here would otherwise be resolved from the OLD
|
||||
// RoleID for the rest of this socket's life — revokeUnreadableChannels
|
||||
// cannot reach a mid-handshake socket (it early-returns when the user is
|
||||
// not yet in h.clients), and nothing revalidates handshake-time
|
||||
// subscriptions afterwards (audit-2026-08-19 F-2). Re-read the row so
|
||||
// the permission set below is computed from the CURRENT role.
|
||||
if err := h.refreshUserSnapshot(ctx, database, c); err != nil {
|
||||
slog.Warn("ws handleReconnect: user re-read failed, falling back to full ready",
|
||||
"user_id", c.userID, "err", err)
|
||||
return nil, false
|
||||
}
|
||||
// Compute the set of channel IDs the reconnecting user can access so that
|
||||
// channel-scoped replay events are filtered by current permissions (M3).
|
||||
allowedChannelIDs, err := h.computeAllowedChannels(ctx, database, c.user)
|
||||
if err != nil {
|
||||
slog.Warn("ws handleReconnect: computeAllowedChannels failed, falling back to full ready",
|
||||
"user_id", c.userID, "err", err)
|
||||
return nil, false
|
||||
}
|
||||
return allowedChannelIDs, true
|
||||
}
|
||||
|
||||
// refreshUserSnapshot replaces c.user (and, when the role changed, c.roleName)
|
||||
// with a fresh read of the user row. Handshake paths call it before
|
||||
// registerNow, while c is still invisible to every other goroutine, so the
|
||||
@@ -386,342 +148,6 @@ func (h *Hub) refreshUserSnapshot(ctx context.Context, database *db.DB, c *Clien
|
||||
return nil
|
||||
}
|
||||
|
||||
// reconnectSelectReplay picks the tier that serves this resume — the ring
|
||||
// buffer when it still covers lastSeq, otherwise the cold-tier EventStore — and
|
||||
// returns the events found, the tier name, and (cold tier only) the persisted
|
||||
// rows plus their highest seq, which reconnectRegister needs for its re-read.
|
||||
// A nil events return means neither tier can replay and the caller must fall
|
||||
// through to a full ready.
|
||||
func (h *Hub) reconnectSelectReplay(
|
||||
ctx context.Context, c *Client, lastSeq uint64, allowedChannelIDs map[int64]bool,
|
||||
) ([][]byte, string, [][]byte, uint64) {
|
||||
var (
|
||||
events [][]byte
|
||||
replaySource = "buffer"
|
||||
persistedTail [][]byte // cold-tier rows only; re-merged with a fresh buffer tail below
|
||||
maxPersistedSeq uint64
|
||||
)
|
||||
if buf := h.ReplayBuffer().EventsSinceFiltered(lastSeq, allowedChannelIDs); buf != nil {
|
||||
events = buf
|
||||
return events, replaySource, persistedTail, maxPersistedSeq
|
||||
}
|
||||
// Phase B Step 7 — try cold-tier replay from the EventStore before
|
||||
// giving up and forcing a full ready re-sync.
|
||||
if esp := h.eventStore.Load(); esp != nil {
|
||||
es := *esp
|
||||
channelIDs := make([]int64, 0, len(allowedChannelIDs))
|
||||
for cid := range allowedChannelIDs {
|
||||
channelIDs = append(channelIDs, cid)
|
||||
}
|
||||
coldCap := h.maxColdReplayLimit()
|
||||
persisted, dbErr := es.GetEventsSinceForChannels(ctx, int64(lastSeq), channelIDs, coldCap) //nolint:gosec // lastSeq is a sequence counter bounded well below MaxInt64
|
||||
switch {
|
||||
case dbErr != nil:
|
||||
slog.Warn("ws handleReconnect: cold-tier replay query failed",
|
||||
"user_id", c.userID, "err", dbErr)
|
||||
case len(persisted) >= coldCap:
|
||||
// The query is "ORDER BY seq ASC LIMIT maxColdReplay", so a full
|
||||
// result means the gap exceeds the cap and the NEWEST events were
|
||||
// dropped. Replaying it would look like a complete resume to the
|
||||
// client — it tracks only max(seq) and cannot detect the hole —
|
||||
// silently losing state events that REST history never repairs.
|
||||
// Leave events nil so the fall-through forces a full ready.
|
||||
slog.Warn("ws handleReconnect: cold-tier replay hit the row cap, forcing full ready",
|
||||
"user_id", c.userID, "last_seq", lastSeq, "cap", coldCap)
|
||||
case len(persisted) > 0:
|
||||
// Retention pruning (PruneEventsOlderThan) deletes purely by
|
||||
// created_at with no seq-floor coordination, so this
|
||||
// channel-filtered result can be a surviving suffix left behind
|
||||
// after the events between lastSeq and persisted[0] were
|
||||
// pruned. Accepting it as-is would present a hole as a complete
|
||||
// resume, since the client tracks only max(seq). Probe the
|
||||
// store's oldest surviving seq UNFILTERED before trusting it —
|
||||
// a channel-filtered contiguity check on persisted itself can't
|
||||
// work, since a sparse per-channel result is legitimately
|
||||
// non-contiguous.
|
||||
oldest, oldestErr := es.GetEventsSince(ctx, 0, 1)
|
||||
switch {
|
||||
case oldestErr != nil:
|
||||
slog.Warn("ws handleReconnect: cold-tier oldest-seq probe failed, forcing full ready",
|
||||
"user_id", c.userID, "err", oldestErr)
|
||||
case len(oldest) == 0 || uint64(oldest[0].Seq) > lastSeq+1: //nolint:gosec // seq is a counter bounded well below MaxInt64
|
||||
var oldestSeq int64
|
||||
if len(oldest) > 0 {
|
||||
oldestSeq = oldest[0].Seq
|
||||
}
|
||||
slog.Warn("ws handleReconnect: retention pruning left a gap before last_seq, forcing full ready",
|
||||
"user_id", c.userID, "last_seq", lastSeq, "oldest_seq", oldestSeq)
|
||||
default:
|
||||
persistedTail, maxPersistedSeq = h.reconnectVetColdTail(ctx, c, es, lastSeq, persisted, allowedChannelIDs)
|
||||
if persistedTail != nil {
|
||||
events = persistedTail
|
||||
replaySource = "db"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return events, replaySource, persistedTail, maxPersistedSeq
|
||||
}
|
||||
|
||||
// reconnectVetColdTail turns a cold-tier result into a replayable tail, or
|
||||
// returns nil when it cannot be trusted: the range it covers must have no
|
||||
// interior gap, and the ring buffer must cover everything newer than its last
|
||||
// row. The returned seq is the highest one in persisted.
|
||||
func (h *Hub) reconnectVetColdTail(
|
||||
ctx context.Context, c *Client, es EventStore, lastSeq uint64,
|
||||
persisted []db.PersistedEvent, allowedChannelIDs map[int64]bool,
|
||||
) ([][]byte, uint64) {
|
||||
persistedTail := make([][]byte, 0, len(persisted))
|
||||
for _, p := range persisted {
|
||||
persistedTail = append(persistedTail, p.Payload)
|
||||
}
|
||||
maxPersistedSeq := uint64(persisted[len(persisted)-1].Seq) //nolint:gosec // seq is a counter bounded well below MaxInt64
|
||||
|
||||
// persisted is channel-filtered, so a hole in a channel
|
||||
// outside allowedChannelIDs would slip past a contiguity
|
||||
// check on persisted itself — and EventPersister can lose a
|
||||
// row outright (a full queue drops silently in Enqueue, a
|
||||
// per-row insert failure inside a batch flush is logged but
|
||||
// never surfaced here; see event_persister.go). Count the
|
||||
// UNFILTERED range (lastSeq, maxPersistedSeq] and require
|
||||
// every seq in it to be present. seq is the events table's
|
||||
// primary key, so the count can only come up short, never
|
||||
// over.
|
||||
expectedCount := maxPersistedSeq - lastSeq
|
||||
switch gapCount, gapErr := es.CountEventsInRange(ctx, int64(lastSeq), int64(maxPersistedSeq)); { //nolint:gosec // bounded well below MaxInt64
|
||||
case gapErr != nil:
|
||||
slog.Warn("ws handleReconnect: cold-tier contiguity probe failed, forcing full ready",
|
||||
"user_id", c.userID, "err", gapErr)
|
||||
persistedTail = nil
|
||||
case uint64(gapCount) != expectedCount: //nolint:gosec // bounded well below MaxInt64
|
||||
slog.Warn("ws handleReconnect: cold-tier replay has an interior gap, forcing full ready",
|
||||
"user_id", c.userID, "last_seq", lastSeq, "max_persisted_seq", maxPersistedSeq,
|
||||
"expected", expectedCount, "found", gapCount)
|
||||
persistedTail = nil
|
||||
}
|
||||
|
||||
if persistedTail != nil {
|
||||
// The EventPersister flushes asynchronously, so cold rows can
|
||||
// lag the live seq: events broadcast after the last flush sit
|
||||
// only in the ring buffer. Confirm the buffer can cover
|
||||
// everything above the newest persisted row — the
|
||||
// authoritative re-read happens atomically with registerNow
|
||||
// below, but a hole here must still force a full ready
|
||||
// rather than a replay with a silent gap at its end.
|
||||
switch tail := h.ReplayBuffer().EventsSinceFiltered(maxPersistedSeq, allowedChannelIDs); {
|
||||
case tail != nil:
|
||||
case atomic.LoadUint64(&h.seq) == maxPersistedSeq:
|
||||
// Post-restart empty buffer with the hub seq seeded from
|
||||
// the store max: nothing was broadcast after the last
|
||||
// persisted row, so the cold rows alone are complete.
|
||||
default:
|
||||
slog.Warn("ws handleReconnect: ring buffer cannot cover the post-flush tail, forcing full ready",
|
||||
"user_id", c.userID, "max_persisted_seq", maxPersistedSeq)
|
||||
persistedTail = nil
|
||||
}
|
||||
}
|
||||
return persistedTail, maxPersistedSeq
|
||||
}
|
||||
|
||||
// reconnectRegister re-reads the ring-buffer-derived portion of the replay and
|
||||
// registers c inside the SAME h.seqMu critical section deliverBroadcast uses,
|
||||
// so no seq can be allocated in between (see the comment in handleReconnect).
|
||||
// It returns the events to actually send; ok=false means one of the re-checks
|
||||
// tripped and the caller must fall through to a full ready.
|
||||
func (h *Hub) reconnectRegister(
|
||||
ctx context.Context, c *Client, lastSeq uint64, allowedChannelIDs map[int64]bool,
|
||||
replaySource string, persistedTail [][]byte, maxPersistedSeq uint64,
|
||||
) ([][]byte, bool) {
|
||||
var events [][]byte
|
||||
h.seqMu.Lock()
|
||||
switch replaySource {
|
||||
case "buffer":
|
||||
fresh := h.ReplayBuffer().EventsSinceFiltered(lastSeq, allowedChannelIDs)
|
||||
if fresh == nil {
|
||||
// The buffer window closed between the earlier check and this
|
||||
// lock (an extreme write burst evicted lastSeq) — there is
|
||||
// nothing left to fall back to for this attempt but a full ready.
|
||||
h.seqMu.Unlock()
|
||||
slog.Warn("ws handleReconnect: buffer window closed just before registration, forcing full ready",
|
||||
"user_id", c.userID, "last_seq", lastSeq)
|
||||
h.reconnectTierFull.Add(1)
|
||||
telemetry.NewAppMetrics().WSReconnectTierTotal.Add(ctx, 1, telemetry.String("tier", "full"))
|
||||
return nil, false
|
||||
}
|
||||
events = fresh
|
||||
case "db":
|
||||
switch tail := h.ReplayBuffer().EventsSinceFiltered(maxPersistedSeq, allowedChannelIDs); {
|
||||
case tail != nil:
|
||||
events = append(append([][]byte{}, persistedTail...), tail...)
|
||||
case atomic.LoadUint64(&h.seq) == maxPersistedSeq:
|
||||
events = persistedTail
|
||||
default:
|
||||
h.seqMu.Unlock()
|
||||
slog.Warn("ws handleReconnect: ring buffer cannot cover the post-flush tail just before registration, forcing full ready",
|
||||
"user_id", c.userID, "max_persisted_seq", maxPersistedSeq)
|
||||
h.reconnectTierFull.Add(1)
|
||||
telemetry.NewAppMetrics().WSReconnectTierTotal.Add(ctx, 1, telemetry.String("tier", "full"))
|
||||
return nil, false
|
||||
}
|
||||
}
|
||||
if handleReconnectPreRegisterRaceHook != nil {
|
||||
handleReconnectPreRegisterRaceHook()
|
||||
}
|
||||
// Re-check the watermark one last time, right before registerNow makes
|
||||
// this connection reachable. RefreshChannelVisibility and
|
||||
// revokeUnreadableChannels both iterate h.clients to fan out a targeted,
|
||||
// unsequenced channel_create/channel_delete — a snapshot this
|
||||
// still-mid-handshake connection is absent from — and both only bump the
|
||||
// watermark afterward. Without this re-check, a visibility change that
|
||||
// lands anywhere between the entry check above and here is missed twice:
|
||||
// the fan-out can't reach an unregistered client, and the entry check has
|
||||
// already passed, so nothing else catches it before this resume commits
|
||||
// to permissions computed before the change (OC-0206).
|
||||
if h.mustFullResync(lastSeq) {
|
||||
h.seqMu.Unlock()
|
||||
slog.Warn("ws handleReconnect: visibility changed during handshake, forcing full ready",
|
||||
"user_id", c.userID, "last_seq", lastSeq)
|
||||
h.reconnectTierFull.Add(1)
|
||||
telemetry.NewAppMetrics().WSReconnectTierTotal.Add(ctx, 1, telemetry.String("tier", "full"))
|
||||
return nil, false
|
||||
}
|
||||
h.registerNow(c, allowedChannelIDs)
|
||||
h.seqMu.Unlock()
|
||||
return events, true
|
||||
}
|
||||
|
||||
// reconnectWriteReplay writes the resume handshake: auth_ok followed by the
|
||||
// replayed events. A false return means a write failed, in which case the full
|
||||
// unregisterFailedHandshake teardown has already run and conn is closed, so the
|
||||
// caller must not start any pump (OC-0051).
|
||||
func (h *Hub) reconnectWriteReplay(
|
||||
ctx context.Context, conn *websocket.Conn, c *Client, lastSeq uint64,
|
||||
events [][]byte, replaySource string,
|
||||
) bool {
|
||||
// Replay succeeded — send auth_ok then missed events. The replay tier
|
||||
// is included in the payload so the client can attribute reconnect
|
||||
// behaviour without separate metric scraping.
|
||||
slog.Info("ws sending auth_ok (reconnect)", "user_id", c.userID, "username", c.user.Username, "role", c.roleName, "replay_source", replaySource)
|
||||
if err := handshakeWrite(ctx, conn, h.buildAuthOK(ctx, c.user, c.roleName, replaySource)); err != nil {
|
||||
slog.Warn("ws: failed to send auth_ok (reconnect)", "user_id", c.userID, "err", err)
|
||||
h.unregisterFailedHandshake(ctx, c)
|
||||
_ = conn.Close(websocket.StatusInternalError, "handshake failed")
|
||||
return false
|
||||
}
|
||||
for _, evt := range events {
|
||||
if err := handshakeWrite(ctx, conn, evt); err != nil {
|
||||
slog.Warn("ws: failed to send replay event", "user_id", c.userID, "err", err)
|
||||
h.unregisterFailedHandshake(ctx, c)
|
||||
_ = conn.Close(websocket.StatusInternalError, "handshake failed")
|
||||
return false
|
||||
}
|
||||
}
|
||||
slog.Info("ws replay completed", "user_id", c.userID, "events_replayed", len(events), "from_seq", lastSeq, "source", replaySource)
|
||||
return true
|
||||
}
|
||||
|
||||
// liveVoiceEventsSince returns voice_state/voice_leave events for chID at or
|
||||
// after afterSeq, bypassing the READ-gated channel filter entirely. Voice
|
||||
// membership needs only CONNECT_VOICE (voice_join.go), so a resuming
|
||||
// participant's own room is not always in their READ-visible set — a stock
|
||||
// example is a DM voice call after the DM was closed. Tries the ring buffer
|
||||
// first (fresh, so it observes anything pushed concurrently with the caller),
|
||||
// then falls back to the cold-tier store; returns nil, not an error, on a
|
||||
// miss in both, since this is a best-effort supplement to the main replay.
|
||||
func (h *Hub) liveVoiceEventsSince(ctx context.Context, afterSeq uint64, chID int64) [][]byte {
|
||||
if chID == 0 {
|
||||
return nil
|
||||
}
|
||||
only := map[int64]bool{chID: true}
|
||||
var raw [][]byte
|
||||
if buf := h.ReplayBuffer().EventsSinceFiltered(afterSeq, only); buf != nil {
|
||||
raw = buf
|
||||
} else if esp := h.eventStore.Load(); esp != nil {
|
||||
es := *esp
|
||||
coldCap := h.maxColdReplayLimit()
|
||||
// Fetch one row past the cap so truncation is decided by the presence
|
||||
// of that extra row, not by len == cap: a complete window of exactly
|
||||
// coldCap rows is not truncated and must replay in full (Codex review
|
||||
// on #1436). A result of at most coldCap rows is therefore complete.
|
||||
persisted, err := es.GetEventsSinceForChannels(ctx, int64(afterSeq), []int64{chID}, coldCap+1) //nolint:gosec // afterSeq is a sequence counter bounded well below MaxInt64
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
if len(persisted) > coldCap {
|
||||
// Same failure mode reconnectSelectReplay guards against above:
|
||||
// the query is "ORDER BY seq ASC LIMIT n", so a result past the
|
||||
// cap means the range exceeds it and any cap-sized window would
|
||||
// have silently dropped the NEWEST rows — for a voice room, quite
|
||||
// possibly the peer's voice_leave. Replaying a truncated window
|
||||
// would install a join whose matching leave was discarded, which
|
||||
// is worse than the documented best-effort miss this function
|
||||
// already returns on a plain lookup failure. A full ready isn't
|
||||
// available here (registerNow already ran before this supplement
|
||||
// runs), so nil is the correct degradation.
|
||||
slog.Warn("ws liveVoiceEventsSince: cold-tier supplement exceeds the row cap, skipping truncated window",
|
||||
"chID", chID, "after_seq", afterSeq, "cap", coldCap)
|
||||
return nil
|
||||
}
|
||||
raw = make([][]byte, 0, len(persisted))
|
||||
for _, p := range persisted {
|
||||
raw = append(raw, p.Payload)
|
||||
}
|
||||
}
|
||||
if len(raw) == 0 {
|
||||
return nil
|
||||
}
|
||||
filtered := make([][]byte, 0, len(raw))
|
||||
for _, evt := range raw {
|
||||
switch extractEventType(evt) {
|
||||
case MsgTypeVoiceState, MsgTypeVoiceLeaveBC:
|
||||
filtered = append(filtered, evt)
|
||||
}
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
// unregisterFailedHandshake removes c after a post-registerNow handshake
|
||||
// write failure. No readPump ever starts for this connection — the
|
||||
// fresh-connect callers return an error that stops ServeWS before it starts
|
||||
// the pumps, and handleReconnect's callers report startPumps=false for the
|
||||
// same reason (OC-0051) — and the old connection this one replaced already
|
||||
// ran its defer (skipping teardown because this client held the slot) — so
|
||||
// when no replacement remains, the standard disconnect teardown must run
|
||||
// here or the user stays online forever.
|
||||
func (h *Hub) unregisterFailedHandshake(ctx context.Context, c *Client) {
|
||||
// Snapshot voice state BEFORE unregister, mirroring readPump's defer
|
||||
// (serve_pumps.go): once unregisterNow removes c, there is no way to tell
|
||||
// whether it still owned a (possibly just-transferred) voice session.
|
||||
voiceChID := c.getVoiceChID()
|
||||
replaced := h.unregisterNow(c)
|
||||
if !replaced {
|
||||
cleanupCtx := context.WithoutCancel(ctx)
|
||||
// A connection that inherited a transferred voice session (the
|
||||
// replay-failure fallback in handleFreshConnect deliberately keeps
|
||||
// the voice_states row and registerNow transfers it onto c) must have
|
||||
// that session torn down here too, or the row, the LiveKit
|
||||
// participant, and a stale E2EE key-holder entry all survive this
|
||||
// connection's death until the next sweep (up to 60s).
|
||||
if voiceChID != 0 {
|
||||
h.handleVoiceLeave(cleanupCtx, c)
|
||||
}
|
||||
}
|
||||
// shouldMarkOffline re-checks h.clients rather than trusting the
|
||||
// `replaced` snapshot alone: it was sampled before handleVoiceLeave,
|
||||
// which can block for seconds, so a reconnect landing during that window
|
||||
// would otherwise be invisible here and mark the live session's user
|
||||
// offline (OC-0019, mirrored from readPump's defer in serve_pumps.go).
|
||||
if h.shouldMarkOffline(c, replaced) {
|
||||
cleanupCtx := context.WithoutCancel(ctx)
|
||||
_ = h.db.MarkUserDisconnected(cleanupCtx, c.userID)
|
||||
// custom_status is nil, not c.user.CustomStatus: see the identical
|
||||
// note in serve_pumps.go's readPump defer — that field is an
|
||||
// auth-time snapshot, never updated, so broadcasting it here can
|
||||
// resurrect a status the user already changed or cleared.
|
||||
h.QueuePresence(c.userID, db.StatusOffline, nil)
|
||||
}
|
||||
}
|
||||
|
||||
// applyConnectStatus writes the status this session comes online as and caches
|
||||
// it on the client.
|
||||
//
|
||||
@@ -755,236 +181,3 @@ func applyConnectStatus(ctx context.Context, database *db.DB, c *Client) {
|
||||
func (h *Hub) announceConnectPresence(c *Client) {
|
||||
h.QueuePresence(c.userID, c.user.Status, c.user.CustomStatus)
|
||||
}
|
||||
|
||||
// computeAllowedChannels returns the set of channel IDs a user may access,
|
||||
// including both server channels (filtered by ReadMessages permission) and
|
||||
// the user's open DM channels. The server-channel set comes from the single
|
||||
// permissions.Checker predicate shared with buildReady and REST
|
||||
// ListVisibleChannels, so replay-buffer filtering can never drift from the
|
||||
// ready payload's visible channels.
|
||||
func (h *Hub) computeAllowedChannels(ctx context.Context, database *db.DB, user *db.User) (map[int64]bool, error) {
|
||||
channels, err := database.ListChannels(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("computeAllowedChannels ListChannels: %w", err)
|
||||
}
|
||||
|
||||
role, err := database.GetRoleByID(ctx, user.RoleID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("computeAllowedChannels GetRoleByID: %w", err)
|
||||
}
|
||||
|
||||
// Nil role = zero access (fail closed). Admins skip the override fetch.
|
||||
allowed := make(map[int64]bool)
|
||||
if role != nil {
|
||||
var overrides map[int64]db.ChannelOverride
|
||||
if !permissions.HasAdmin(role.Permissions) {
|
||||
overrides, err = database.GetChannelOverridesFor(ctx, role.ID, user.ID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("computeAllowedChannels GetChannelOverridesFor: %w", err)
|
||||
}
|
||||
}
|
||||
allowed = h.permChecker.VisibleChannelIDs(role.Permissions, channelRefs(channels), permOverrides(overrides))
|
||||
}
|
||||
|
||||
// Include the user's open DM channels. Only the ID set matters here, so
|
||||
// use the PK-covered dm_open_state lookup instead of the full DM query.
|
||||
// Fatal like the three sibling lookups above: a silently DM-stripped
|
||||
// replay advances the client's lastSeq past DM events it never received —
|
||||
// a permanent hole. The caller's error path falls back to full ready.
|
||||
dmIDs, dmErr := database.GetUserDMChannelIDs(ctx, user.ID)
|
||||
if dmErr != nil {
|
||||
return nil, fmt.Errorf("computeAllowedChannels GetUserDMChannelIDs: %w", dmErr)
|
||||
}
|
||||
for _, id := range dmIDs {
|
||||
allowed[id] = true
|
||||
}
|
||||
|
||||
return allowed, nil
|
||||
}
|
||||
|
||||
func (h *Hub) handleFreshConnect(
|
||||
ctx context.Context, conn *websocket.Conn, c *Client, database *db.DB,
|
||||
) error {
|
||||
// Clean stale voice state BEFORE building ready and registering.
|
||||
// When a user F5-reloads while in voice, the DB row from the previous
|
||||
// session must be removed so the ready payload doesn't include it and
|
||||
// other clients see a voice_leave broadcast.
|
||||
if vs, err := database.GetVoiceState(ctx, c.userID); err == nil && vs != nil {
|
||||
h.freshConnectCleanStaleVoice(ctx, database, c, vs)
|
||||
}
|
||||
|
||||
// c.user is the auth-time snapshot — re-read it so the ready payload and
|
||||
// any inherited subscriptions resolve from the user's CURRENT role, not
|
||||
// the one they held when the auth frame was evaluated (audit-2026-08-19
|
||||
// F-2; the resume path does the same in reconnectPrecheck). Fail closed
|
||||
// like the role lookup below.
|
||||
if err := h.refreshUserSnapshot(ctx, database, c); err != nil {
|
||||
slog.Error("ws: user re-read failed, disconnecting", "user_id", c.userID, "err", err)
|
||||
_ = conn.Close(websocket.StatusInternalError, "user lookup failed")
|
||||
return err
|
||||
}
|
||||
|
||||
// Look up role for permission-filtered ready payload.
|
||||
// Fail closed: if the role lookup fails, disconnect rather than serving
|
||||
// a permissive ready payload with nil role (BUG-094).
|
||||
userRole, roleErr := database.GetRoleByID(ctx, c.user.RoleID)
|
||||
if roleErr != nil || userRole == nil {
|
||||
slog.Error("ws: role lookup failed, disconnecting", "user_id", c.userID, "role_id", c.user.RoleID, "err", roleErr)
|
||||
_ = conn.Close(websocket.StatusInternalError, "role lookup failed")
|
||||
return fmt.Errorf("role lookup failed for user %d: %w", c.userID, roleErr)
|
||||
}
|
||||
|
||||
// Register BEFORE writing auth_ok + ready so broadcasts that arrive during
|
||||
// the write window are queued in the client's send buffer instead of
|
||||
// being lost (BUG-123). writePump hasn't started yet, so queued messages
|
||||
// will be drained once the pumps begin.
|
||||
//
|
||||
// Only the replay-failure fallback (lastSeq > 0) can inherit voice state
|
||||
// from the previous connection, so that is the only case where registerNow
|
||||
// needs the read-permission set. Fail closed on error: nil denies the
|
||||
// inherited voice-channel subscription.
|
||||
var allowedChannelIDs map[int64]bool
|
||||
if c.lastSeq > 0 {
|
||||
allowed, allowedErr := h.computeAllowedChannels(ctx, database, c.user)
|
||||
if allowedErr != nil {
|
||||
slog.Warn("ws handleFreshConnect: computeAllowedChannels failed, skipping voice channel subscription",
|
||||
"user_id", c.userID, "err", allowedErr)
|
||||
} else {
|
||||
allowedChannelIDs = allowed
|
||||
}
|
||||
}
|
||||
// handleReconnect may have promoted an auth-frame active_channel_id into
|
||||
// c.channelID (serve.go, honoured only when it was READ-visible at that
|
||||
// moment) and then aborted on one of its own re-checks — most notably the
|
||||
// final mustFullResync check, tripped by a permission revocation that
|
||||
// landed mid-handshake. None of those abort paths undo the c.channelID
|
||||
// write. registerNow subscribes c.channelID's ChannelTopic
|
||||
// unconditionally, so re-gate it here against the freshly recomputed
|
||||
// permission set before registering. Fail closed: a nil allowedChannelIDs
|
||||
// (lastSeq == 0, or the computeAllowedChannels error branch above) denies.
|
||||
if chID := c.getChannelID(); chID != 0 && !allowedChannelIDs[chID] {
|
||||
c.mu.Lock()
|
||||
c.channelID = 0
|
||||
c.mu.Unlock()
|
||||
}
|
||||
if freshConnectPreRegisterRaceHook != nil {
|
||||
freshConnectPreRegisterRaceHook()
|
||||
}
|
||||
h.registerNow(c, allowedChannelIDs)
|
||||
|
||||
// The re-read above and registerNow are not atomic: a role reassignment
|
||||
// committing in between finds this socket absent from h.clients (so its
|
||||
// revokeUnreadableChannels pass early-returns) yet builds our inherited
|
||||
// subscriptions from the pre-change role. One PK re-read after
|
||||
// registration makes the two orderings meet: a commit visible here is
|
||||
// pruned by our own revoke pass, and a commit that is not yet visible
|
||||
// necessarily runs its own revoke lookup after our registerNow and
|
||||
// finds us.
|
||||
// Scoped to the resume-fallback path — a pure fresh connect (lastSeq==0)
|
||||
// inherits no subscriptions; channel_focus and voice_join re-check live.
|
||||
if c.lastSeq > 0 {
|
||||
if fresh, err := database.GetUserByID(ctx, c.userID); err != nil || fresh == nil || fresh.RoleID != c.user.RoleID {
|
||||
//nolint:contextcheck // revokeUnreadableChannels takes no context by design (admin HubBroadcaster interface).
|
||||
h.revokeUnreadableChannels(c.userID)
|
||||
}
|
||||
}
|
||||
|
||||
// Settle the session's status before buildReady reads the member list, so
|
||||
// the ready payload and the presence broadcast below cannot disagree.
|
||||
applyConnectStatus(ctx, database, c)
|
||||
|
||||
// Fresh connection or replay fallback: full auth_ok + ready flow.
|
||||
slog.Info("ws sending auth_ok", "user_id", c.userID, "username", c.user.Username, "role", c.roleName)
|
||||
if err := handshakeWrite(ctx, conn, h.buildAuthOK(ctx, c.user, c.roleName, "none")); err != nil {
|
||||
slog.Warn("ws: failed to send auth_ok", "user_id", c.userID, "err", err)
|
||||
h.unregisterFailedHandshake(ctx, c)
|
||||
_ = conn.Close(websocket.StatusInternalError, "handshake failed")
|
||||
return err
|
||||
}
|
||||
if ready, readyErr := h.buildReady(ctx, database, c.userID, userRole); readyErr == nil {
|
||||
slog.Info("ws sending ready payload", "user_id", c.userID, "payload_bytes", len(ready))
|
||||
if err := handshakeWrite(ctx, conn, ready); err != nil {
|
||||
slog.Warn("ws: failed to send ready payload", "user_id", c.userID, "err", err)
|
||||
h.unregisterFailedHandshake(ctx, c)
|
||||
_ = conn.Close(websocket.StatusInternalError, "handshake failed")
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
slog.Error("buildReady failed", "user_id", c.userID, "err", readyErr)
|
||||
_ = handshakeWrite(ctx, conn, buildErrorMsg(ErrCodeInternal, "failed to build ready payload"))
|
||||
h.unregisterFailedHandshake(ctx, c)
|
||||
_ = conn.Close(websocket.StatusInternalError, "failed to build ready payload")
|
||||
return readyErr
|
||||
}
|
||||
|
||||
slog.Info("ws broadcasting member_join and presence", "user_id", c.userID, "username", c.user.Username)
|
||||
h.BroadcastToAll(buildMemberJoin(c.user, c.roleName))
|
||||
h.announceConnectPresence(c)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// freshConnectCleanStaleVoice removes the voice state left behind by this
|
||||
// user's previous session, unless that session is the still-registered
|
||||
// connection this one is about to inherit from.
|
||||
func (h *Hub) freshConnectCleanStaleVoice(ctx context.Context, database *db.DB, c *Client, vs *db.VoiceState) {
|
||||
// Replay-failure fallback (lastSeq > 0): registerNow below transfers
|
||||
// the still-registered old connection's live voice state into this
|
||||
// client. Deleting the DB row here — and the LiveKit participant,
|
||||
// whose removal token is the very JoinedAt being transferred — would
|
||||
// leave the user "in voice" on the hub only: voice_join bounces off
|
||||
// ALREADY_JOINED and sweepStaleVoiceStates never heals
|
||||
// memory-without-row. Keep the row so ready stays consistent. If the
|
||||
// old client unregisters before registerNow runs, the transfer is
|
||||
// skipped and the next sweep reaps the then-truly-stale row.
|
||||
if old := h.GetClient(c.userID); c.lastSeq > 0 && old != nil && old.getVoiceChID() == vs.ChannelID {
|
||||
slog.Info("ws fresh connect: keeping voice state for replay-failure fallback",
|
||||
"user_id", c.userID, "channel_id", vs.ChannelID)
|
||||
return
|
||||
}
|
||||
slog.Info("ws fresh connect: cleaning stale voice state",
|
||||
"user_id", c.userID, "channel_id", vs.ChannelID)
|
||||
if _, delErr := database.LeaveVoiceChannelIfMatch(ctx, c.userID, vs.ChannelID, vs.JoinedAt); delErr != nil {
|
||||
slog.Warn("ws fresh connect: LeaveVoiceChannelIfMatch failed", "err", delErr)
|
||||
}
|
||||
// The DB row is gone, but the still-registered OLD *Client (if any) is
|
||||
// otherwise only cleared by registerNow — which two early-return paths
|
||||
// further down handleFreshConnect (the refreshUserSnapshot and
|
||||
// GetRoleByID failure branches) can skip entirely. Without this, that
|
||||
// old client's in-memory voiceChID and the E2EE key-holder election for
|
||||
// this room survive as a memory-without-row ghost that
|
||||
// sweepStaleVoiceStates can never see, since it iterates DB rows
|
||||
// (OC-0252). Clearing here makes freshConnectCleanStaleVoice self
|
||||
// sufficient regardless of whether registerNow ever runs; registerNow's
|
||||
// own replacedVoiceChID re-election later becomes a redundant no-op
|
||||
// (clearVoiceState finds nothing left to clear), not a conflict.
|
||||
if old := h.GetClient(c.userID); old != nil {
|
||||
if _, cleared := old.clearVoiceStateIfMatch(vs.ChannelID); cleared {
|
||||
h.pubsub.Unsubscribe(old, VoiceTopic(vs.ChannelID))
|
||||
}
|
||||
}
|
||||
h.updateKeyHolder(vs.ChannelID)
|
||||
h.broadcastVoiceEvent(ctx, vs.ChannelID, buildVoiceLeave(vs.ChannelID, c.userID))
|
||||
if h.livekit == nil {
|
||||
return
|
||||
}
|
||||
// BUG-089: Capture stale join token so the goroutine only removes
|
||||
// the exact stale participant. The identity includes joinedAt, so
|
||||
// even if the user rejoins voice quickly, the new session has a
|
||||
// different identity and won't be removed. The removal must
|
||||
// complete even if this connection drops mid-handshake, so detach
|
||||
// from cancellation (values kept); shutdown is handled via h.stop.
|
||||
staleChID, staleUserID, staleJoinToken := vs.ChannelID, c.userID, vs.JoinedAt
|
||||
lkCtx := context.WithoutCancel(ctx)
|
||||
go func() {
|
||||
select {
|
||||
case <-h.stop:
|
||||
return
|
||||
default:
|
||||
}
|
||||
if err := h.livekit.RemoveParticipant(lkCtx, staleChID, staleUserID, staleJoinToken); err != nil {
|
||||
slog.Warn("ws fresh connect: RemoveParticipant failed (may already be gone)",
|
||||
"err", err, "user_id", staleUserID, "channel_id", staleChID)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
@@ -4,6 +4,9 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/coder/websocket"
|
||||
|
||||
@@ -103,3 +106,102 @@ func authenticateConn(parent context.Context, conn *websocket.Conn, database *db
|
||||
|
||||
return user, hash, resumeHint{LastSeq: p.LastSeq, ChannelID: p.ActiveChannelID}, nil
|
||||
}
|
||||
|
||||
// handshakeWrite writes one handshake-phase message (auth_ok, ready, or a
|
||||
// replay event) under writeTimeout, instead of the bare ctx every caller here
|
||||
// otherwise has on hand.
|
||||
//
|
||||
// Every handshake write runs against ctx = r.Context() from ServeWS.
|
||||
// websocket.Accept hijacks the connection, which stops net/http's own
|
||||
// mechanism for cancelling that context on client disconnect, so without this
|
||||
// wrapper ctx is never cancelled while the handler is blocked inside
|
||||
// conn.Write — a peer that stops reading (or whose receive window closes)
|
||||
// pins the write, the handler goroutine, and the socket forever (OC-0152).
|
||||
// writePumpWrite (serve_pumps.go) already bounds its writes the same way;
|
||||
// this brings the handshake writes in serve.go up to the same guarantee.
|
||||
func handshakeWrite(ctx context.Context, conn *websocket.Conn, msg []byte) error {
|
||||
wCtx, cancel := context.WithTimeout(ctx, writeTimeout)
|
||||
defer cancel()
|
||||
return conn.Write(wCtx, websocket.MessageText, msg)
|
||||
}
|
||||
|
||||
func (h *Hub) upgradeAndAuth(
|
||||
conn *websocket.Conn, database *db.DB, r *http.Request,
|
||||
) (*Client, uint64, error) {
|
||||
user, tokenHash, hint, err := authenticateConn(r.Context(), conn, database)
|
||||
if err != nil {
|
||||
slog.Warn("ws auth failed", "err", err, "remote", r.RemoteAddr)
|
||||
_ = conn.Close(websocket.StatusPolicyViolation, "authentication failed")
|
||||
return nil, 0, err
|
||||
}
|
||||
lastSeq := hint.LastSeq
|
||||
|
||||
c := newClient(h, conn, user, tokenHash, lastSeq, r.Context())
|
||||
c.remoteAddr = r.RemoteAddr
|
||||
// Untrusted until handleReconnect checks it against the allowed set.
|
||||
c.authChannelID = hint.ChannelID
|
||||
|
||||
// Look up role name for protocol-compliant payloads and cache on client.
|
||||
// Fail closed like the sibling lookup in handleFreshConnect (BUG-094):
|
||||
// this value is authoritative on the wire — auth_ok reports it as the
|
||||
// user's own role, member_join broadcasts it to every other client, and
|
||||
// every chat_message carries it — so a lookup failure must not silently
|
||||
// substitute "member" and pin the whole session to a fabricated role
|
||||
// (OC-0269).
|
||||
role, roleErr := database.GetRoleByID(r.Context(), user.RoleID)
|
||||
if roleErr != nil || role == nil {
|
||||
slog.Error("ws: role lookup failed during handshake, closing connection",
|
||||
"user_id", user.ID, "role_id", user.RoleID, "err", roleErr)
|
||||
_ = conn.Close(websocket.StatusInternalError, "role lookup failed")
|
||||
return nil, 0, fmt.Errorf("upgradeAndAuth: role lookup failed for user %d: %w", user.ID, roleErr)
|
||||
}
|
||||
c.roleName = strings.ToLower(role.Name)
|
||||
|
||||
slog.Info("websocket connected", "username", user.Username, "user_id", user.ID, "remote", r.RemoteAddr)
|
||||
db.WriteAudit(context.WithoutCancel(r.Context()), database, user.ID, "ws_connect", "user", user.ID,
|
||||
"WebSocket connected from "+r.RemoteAddr)
|
||||
|
||||
return c, lastSeq, nil
|
||||
}
|
||||
|
||||
// unregisterFailedHandshake removes c after a post-registerNow handshake
|
||||
// write failure. No readPump ever starts for this connection — the
|
||||
// fresh-connect callers return an error that stops ServeWS before it starts
|
||||
// the pumps, and handleReconnect's callers report startPumps=false for the
|
||||
// same reason (OC-0051) — and the old connection this one replaced already
|
||||
// ran its defer (skipping teardown because this client held the slot) — so
|
||||
// when no replacement remains, the standard disconnect teardown must run
|
||||
// here or the user stays online forever.
|
||||
func (h *Hub) unregisterFailedHandshake(ctx context.Context, c *Client) {
|
||||
// Snapshot voice state BEFORE unregister, mirroring readPump's defer
|
||||
// (serve_pumps.go): once unregisterNow removes c, there is no way to tell
|
||||
// whether it still owned a (possibly just-transferred) voice session.
|
||||
voiceChID := c.getVoiceChID()
|
||||
replaced := h.unregisterNow(c)
|
||||
if !replaced {
|
||||
cleanupCtx := context.WithoutCancel(ctx)
|
||||
// A connection that inherited a transferred voice session (the
|
||||
// replay-failure fallback in handleFreshConnect deliberately keeps
|
||||
// the voice_states row and registerNow transfers it onto c) must have
|
||||
// that session torn down here too, or the row, the LiveKit
|
||||
// participant, and a stale E2EE key-holder entry all survive this
|
||||
// connection's death until the next sweep (up to 60s).
|
||||
if voiceChID != 0 {
|
||||
h.handleVoiceLeave(cleanupCtx, c)
|
||||
}
|
||||
}
|
||||
// shouldMarkOffline re-checks h.clients rather than trusting the
|
||||
// `replaced` snapshot alone: it was sampled before handleVoiceLeave,
|
||||
// which can block for seconds, so a reconnect landing during that window
|
||||
// would otherwise be invisible here and mark the live session's user
|
||||
// offline (OC-0019, mirrored from readPump's defer in serve_pumps.go).
|
||||
if h.shouldMarkOffline(c, replaced) {
|
||||
cleanupCtx := context.WithoutCancel(ctx)
|
||||
_ = h.db.MarkUserDisconnected(cleanupCtx, c.userID)
|
||||
// custom_status is nil, not c.user.CustomStatus: see the identical
|
||||
// note in serve_pumps.go's readPump defer — that field is an
|
||||
// auth-time snapshot, never updated, so broadcasting it here can
|
||||
// resurrect a status the user already changed or cleared.
|
||||
h.QueuePresence(c.userID, db.StatusOffline, nil)
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user