diff --git a/Server/Makefile b/Server/Makefile index 54d2ca89..5eb9012b 100644 --- a/Server/Makefile +++ b/Server/Makefile @@ -1,8 +1,7 @@ # OwnCord Server — developer convenience targets # -# sqlc-generate Regenerate type-safe Go for both the sqlite and postgres -# engines defined in sqlc.yaml (db/dbgen + db/pgdbgen). -# sqlc-verify Fail if either committed dbgen output is stale (used by CI). +# sqlc-generate Regenerate type-safe Go from sqlc.yaml (db/dbgen). +# sqlc-verify Fail if the committed dbgen output is stale (used by CI). # sqlc-install Install the pinned sqlc version into $GOBIN. # otel-up Start Jaeger + Prometheus for local tracing development. # otel-down Stop and remove the OTel dev containers. @@ -17,13 +16,8 @@ sqlc-install: sqlc-generate: sqlc generate -# Verify only db/dbgen: the committed db/pgdbgen files carry hand-added -# `//go:build postgres` tags that `sqlc generate` strips, so a pgdbgen diff -# is expected noise. pgdbgen is scheduled for removal with the Postgres -# scaffolding; restore it after generating so verify leaves a clean tree. sqlc-verify: sqlc generate - @git checkout -- db/pgdbgen @git diff --exit-code db/dbgen || ( \ echo "ERROR: generated sqlc output is stale. Run 'make sqlc-generate' and commit the result." ; \ exit 1 ; \ diff --git a/Server/admin/admin.go b/Server/admin/admin.go index cf7c0879..b9ecad2b 100644 --- a/Server/admin/admin.go +++ b/Server/admin/admin.go @@ -9,6 +9,7 @@ import ( "github.com/go-chi/chi/v5" "github.com/owncord/server/db" + "github.com/owncord/server/service" "github.com/owncord/server/updater" ) @@ -22,11 +23,11 @@ var staticFiles embed.FS // // /api/* — admin REST API (all require ADMINISTRATOR permission) // /* — embedded static files (SPA; index.html for unknown paths) -func NewHandler(database *db.DB, version string, hub HubBroadcaster, u *updater.Updater, logBuf *RingBuffer, allowedOrigins []string, permInvalidator PermissionInvalidator) http.Handler { +func NewHandler(database *db.DB, version string, hub HubBroadcaster, u *updater.Updater, logBuf *RingBuffer, allowedOrigins []string, permInvalidator PermissionInvalidator, mod *service.ModerationService) http.Handler { r := chi.NewRouter() // Admin REST API mounted at /api - r.Mount("/api", NewAdminAPI(database, version, hub, u, logBuf, allowedOrigins, permInvalidator)) + r.Mount("/api", NewAdminAPI(database, version, hub, u, logBuf, allowedOrigins, permInvalidator, mod)) // Static files — serve from the "static" sub-tree of the embedded FS. // The //go:embed static directive in this package embeds as "static/…", diff --git a/Server/admin/admin_handler_test.go b/Server/admin/admin_handler_test.go index 8ba8ede0..37b68e14 100644 --- a/Server/admin/admin_handler_test.go +++ b/Server/admin/admin_handler_test.go @@ -19,7 +19,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) + h := admin.NewHandler(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) if h == nil { t.Fatal("NewHandler returned nil handler") } @@ -29,7 +29,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) + h := admin.NewHandler(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) req := httptest.NewRequest(http.MethodGet, "/", nil) w := httptest.NewRecorder() @@ -60,7 +60,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) + h := admin.NewHandler(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database)) req := httptest.NewRequest(http.MethodGet, "/", nil) w := httptest.NewRecorder() @@ -76,7 +76,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) + h := admin.NewHandler(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) req := httptest.NewRequest(http.MethodGet, "/api/setup/status", nil) w := httptest.NewRecorder() @@ -92,7 +92,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) + h := admin.NewHandler(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) // /api/stats requires authentication req := httptest.NewRequest(http.MethodGet, "/api/stats", nil) @@ -109,7 +109,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) + h := admin.NewHandler(database, "1.0.0", &mockHub{}, u, nil, nil, nil, newTestModService(database)) if h == nil { t.Fatal("NewHandler with updater returned nil handler") } @@ -148,7 +148,7 @@ func TestHandler_ServesEmbeddedFiles(t *testing.T) { // (position == 100) can reach backup endpoints. func TestOwnerOnlyMiddleware_OwnerAllowed(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) // createAdminUser creates an Owner-role user (role_id=1, position=100) ownerToken := createAdminUser(t, database) @@ -183,7 +183,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) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) // Create admin user (role_id=2, position=80) adminUID, _ := database.CreateUser("middlewareadmin", "hash", 2) @@ -201,7 +201,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) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) memberToken := createMemberUser(t, database) @@ -218,7 +218,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) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) w := doRequest(t, handler, http.MethodPost, "/backup", "", nil) diff --git a/Server/admin/api.go b/Server/admin/api.go index 0e2210ea..276bbb7e 100644 --- a/Server/admin/api.go +++ b/Server/admin/api.go @@ -6,6 +6,7 @@ import ( "github.com/go-chi/chi/v5" "github.com/owncord/server/auth" "github.com/owncord/server/db" + "github.com/owncord/server/service" "github.com/owncord/server/updater" ) @@ -14,7 +15,7 @@ import ( // NewAdminAPI returns a chi router with all /admin/api/* routes. All routes // are protected by adminAuthMiddleware which requires the ADMINISTRATOR bit, // except for the setup endpoints which are unauthenticated. -func NewAdminAPI(database *db.DB, version string, hub HubBroadcaster, u *updater.Updater, logBuf *RingBuffer, allowedOrigins []string, permInvalidator PermissionInvalidator) http.Handler { +func NewAdminAPI(database *db.DB, version string, hub HubBroadcaster, u *updater.Updater, logBuf *RingBuffer, allowedOrigins []string, permInvalidator PermissionInvalidator, mod *service.ModerationService) http.Handler { r := chi.NewRouter() // Setup endpoints — unauthenticated, only functional when no users exist. @@ -39,7 +40,7 @@ func NewAdminAPI(database *db.DB, version string, hub HubBroadcaster, u *updater r.Get("/stats", handleGetStats(database, hub)) r.Get("/users", handleListUsers(database)) - r.Patch("/users/{id}", handlePatchUser(database, hub, permInvalidator)) + r.Patch("/users/{id}", handlePatchUser(database, hub, permInvalidator, mod)) r.Delete("/users/{id}/sessions", handleForceLogout(database)) r.Get("/channels", handleListChannels(database)) r.Post("/channels", handleCreateChannel(database, hub)) diff --git a/Server/admin/api_edge_cases_test.go b/Server/admin/api_edge_cases_test.go index a730a34d..2060eac4 100644 --- a/Server/admin/api_edge_cases_test.go +++ b/Server/admin/api_edge_cases_test.go @@ -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) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(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) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) token := createAdminUser(t, database) // Create and ban a target user first. @@ -61,7 +61,7 @@ func TestAdminAPI_PatchUser_UnbanUser(t *testing.T) { // TestAdminAPI_PatchUser_InvalidBody verifies that a non-JSON body returns 400. func TestAdminAPI_PatchUser_InvalidBody(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil) + handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database)) token := createAdminUser(t, database) targetUID, _ := database.CreateUser("invalidbody", "hash", 3) @@ -83,7 +83,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) + handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database)) token := createAdminUser(t, database) body := map[string]any{ @@ -108,7 +108,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) + handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database)) token := createAdminUser(t, database) req := httptest.NewRequest(http.MethodPost, "/channels", bytes.NewReader([]byte("not-json"))) @@ -128,7 +128,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) + handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database)) token := createAdminUser(t, database) w := doRequest(t, handler, http.MethodDelete, "/users/notanumber/sessions", token, nil) @@ -144,7 +144,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) + handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database)) token := createAdminUser(t, database) chID, _ := database.AdminCreateChannel("malformed", "text", "", "", 0) @@ -166,7 +166,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) + handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database)) token := createAdminUser(t, database) // Passing limit=9999 should be silently capped to 500. @@ -183,7 +183,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) + handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database)) token := createAdminUser(t, database) w := doRequest(t, handler, http.MethodGet, "/updates", token, nil) @@ -199,7 +199,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) + handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database)) token := createAdminUser(t, database) w := doRequest(t, handler, http.MethodDelete, "/channels/notanumber", token, nil) @@ -215,7 +215,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) + handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database)) token := createAdminUser(t, database) body := map[string]any{"name": "x"} @@ -231,7 +231,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) + handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database)) token := createAdminUser(t, database) // Create several audit entries. @@ -262,7 +262,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) + handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database)) token := createAdminUser(t, database) w := doRequest(t, handler, http.MethodGet, "/stats", token, nil) @@ -289,7 +289,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) + handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database)) token := createAdminUser(t, database) w := doRequest(t, handler, http.MethodGet, "/audit-log?limit=notanumber", token, nil) @@ -303,7 +303,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) + handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database)) token := createAdminUser(t, database) // limit=0 triggers the n < 1 fallback in queryInt @@ -321,7 +321,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) + handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database)) token := createAdminUser(t, database) targetUID, _ := database.CreateUser("ban-nohub", "hash", 3) @@ -346,7 +346,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) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, logBuf, nil, nil, newTestModService(database)) token := createAdminUser(t, database) ticketResp := doRequest(t, handler, http.MethodPost, "/logs/ticket", token, nil) @@ -441,7 +441,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) + handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database)) token := createAdminUser(t, database) targetUID, _ := database.CreateUser("role-nohub", "hash", 3) @@ -466,7 +466,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) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) token := createAdminUser(t, database) targetUID, _ := database.CreateUser("banwithout", "hash", 3) @@ -487,7 +487,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) + handler := admin.NewAdminAPI(database, "1.0.0", hub, nil, nil, nil, nil, newTestModService(database)) token := createAdminUser(t, database) targetUID, _ := database.CreateUser("rolebroadcast", "hash", 3) @@ -509,7 +509,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) + handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database)) w := doRequest(t, handler, http.MethodGet, "/setup/status", "", nil) @@ -529,7 +529,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) + handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database)) _, _ = database.CreateUser("existing", "hash", 1) @@ -550,7 +550,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) + handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database)) body := map[string]string{ "username": "owner", @@ -581,7 +581,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) + handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database)) _, _ = database.CreateUser("existing", "hash", 1) @@ -600,7 +600,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) + handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database)) body := map[string]string{ "username": "", @@ -616,7 +616,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) + handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database)) body := map[string]string{ "username": "owner", @@ -632,7 +632,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) + handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database)) req := httptest.NewRequest(http.MethodPost, "/setup", bytes.NewReader([]byte("not-json"))) w := httptest.NewRecorder() diff --git a/Server/admin/api_test.go b/Server/admin/api_test.go index 30faa90a..3107b3d3 100644 --- a/Server/admin/api_test.go +++ b/Server/admin/api_test.go @@ -13,8 +13,20 @@ import ( "github.com/owncord/server/admin" "github.com/owncord/server/auth" "github.com/owncord/server/db" + "github.com/owncord/server/permissions" + "github.com/owncord/server/service" + "github.com/owncord/server/store" ) +// newTestModService builds a real ModerationService over the test database so +// PATCH-user ban paths exercise the production authorization (BAN_MEMBERS + +// role hierarchy) instead of a stub. +func newTestModService(database *db.DB) *service.ModerationService { + st := store.NewSQLiteStore(database) + checker := permissions.NewChecker(st) + return service.NewModerationService(st, service.NewPermissionService(st, checker)) +} + // adminSchema is a minimal in-memory schema for admin API tests. var adminSchema = []byte(` CREATE TABLE IF NOT EXISTS roles ( @@ -198,7 +210,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) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) token := createAdminUser(t, database) w := doRequest(t, handler, http.MethodGet, "/stats", token, nil) @@ -221,7 +233,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) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) w := doRequest(t, handler, http.MethodGet, "/stats", "", nil) @@ -232,7 +244,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) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) token := createMemberUser(t, database) w := doRequest(t, handler, http.MethodGet, "/stats", token, nil) @@ -246,7 +258,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) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) token := createAdminUser(t, database) w := doRequest(t, handler, http.MethodGet, "/users?limit=50&offset=0", token, nil) @@ -267,7 +279,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) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) token := createAdminUser(t, database) // No query params — should use defaults @@ -280,7 +292,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) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) w := doRequest(t, handler, http.MethodGet, "/users", "", nil) @@ -291,9 +303,77 @@ func TestAdminAPI_ListUsers_Unauthenticated(t *testing.T) { // ─── PATCH /admin/api/users/{id} ───────────────────────────────────────────── +// TestAdminAPI_PatchUser_BanHierarchy locks the W1-4 fix: the admin-auth +// perimeter alone no longer authorizes bans — ModerationService's role +// hierarchy runs on the live PATCH path, so an admin-panel actor cannot ban +// an equal- or higher-ranked user (previously any panel actor could ban the +// 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)) + ownerToken := createAdminUser(t, database) // Owner role (pos 100) + + // A second owner-rank user: equal position, cannot be banned. + peerUID, err := database.CreateUser("peerowner", "$2a$12$placeholder", 1) + if err != nil { + t.Fatalf("CreateUser peerowner: %v", err) + } + w := doRequest(t, handler, http.MethodPatch, "/users/"+itoa(peerUID), ownerToken, + map[string]any{"banned": true}) + if w.Code != http.StatusForbidden { + t.Fatalf("equal-rank ban: status = %d, want 403; body: %s", w.Code, w.Body.String()) + } + if u, _ := database.GetUserByID(peerUID); u.Banned { + t.Fatal("equal-rank target must not be banned") + } + + // A lower-positioned role that still holds ADMINISTRATOR (panel access): + // its holder must not be able to ban the higher-ranked owner. + if _, err := database.Exec( + `INSERT INTO roles (id, name, permissions, position, is_default) VALUES (9, 'JuniorAdmin', ?, 50, 0)`, + permissions.Administrator, + ); err != nil { + t.Fatalf("inserting junior admin role: %v", err) + } + juniorUID, err := database.CreateUser("junioradmin", "$2a$12$placeholder", 9) + if err != nil { + t.Fatalf("CreateUser junioradmin: %v", err) + } + juniorToken := "junior-token-" + t.Name() + if _, err := database.CreateSession(juniorUID, auth.HashToken(juniorToken), "test", "127.0.0.1"); err != nil { + t.Fatalf("CreateSession junior: %v", err) + } + ownerUser, err := database.GetUserByUsername("adminuser") + if err != nil || ownerUser == nil { + t.Fatalf("GetUserByUsername adminuser: %v", err) + } + w = doRequest(t, handler, http.MethodPatch, "/users/"+itoa(ownerUser.ID), juniorToken, + map[string]any{"banned": true}) + if w.Code != http.StatusForbidden { + t.Fatalf("junior bans owner: status = %d, want 403; body: %s", w.Code, w.Body.String()) + } + if u, _ := database.GetUserByID(ownerUser.ID); u.Banned { + t.Fatal("owner must not be banned by a lower rank") + } + + // Downward ban still works: junior admin (pos 50) bans a member (pos 40). + memberUID, err := database.CreateUser("banme", "$2a$12$placeholder", 3) + if err != nil { + t.Fatalf("CreateUser banme: %v", err) + } + w = doRequest(t, handler, http.MethodPatch, "/users/"+itoa(memberUID), juniorToken, + map[string]any{"banned": true, "ban_reason": "spam"}) + if w.Code != http.StatusOK { + t.Fatalf("junior bans member: status = %d, want 200; body: %s", w.Code, w.Body.String()) + } + if u, _ := database.GetUserByID(memberUID); !u.Banned { + t.Fatal("member should be banned by higher-ranked actor") + } +} + func TestAdminAPI_PatchUser_BanUser(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) token := createAdminUser(t, database) // Create a target user @@ -321,7 +401,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) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) token := createAdminUser(t, database) targetUID, _ := database.CreateUser("rolechange", "hash", 3) @@ -343,7 +423,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) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) token := createAdminUser(t, database) body := map[string]any{"banned": true} @@ -356,7 +436,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) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) token := createAdminUser(t, database) w := doRequest(t, handler, http.MethodPatch, "/users/abc", token, nil) @@ -370,7 +450,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) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) token := createAdminUser(t, database) targetUID, _ := database.CreateUser("logoutme", "hash", 3) @@ -390,7 +470,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) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) w := doRequest(t, handler, http.MethodDelete, "/users/1/sessions", "", nil) @@ -403,7 +483,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) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) token := createAdminUser(t, database) _, _ = database.AdminCreateChannel("general", "text", "", "", 0) @@ -427,7 +507,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) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) token := createAdminUser(t, database) body := map[string]any{ @@ -454,7 +534,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) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) token := createAdminUser(t, database) body := map[string]any{ @@ -471,7 +551,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) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) token := createAdminUser(t, database) chID, _ := database.AdminCreateChannel("old", "text", "", "", 0) @@ -492,7 +572,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) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) token := createAdminUser(t, database) body := map[string]any{"name": "x"} @@ -507,7 +587,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) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) token := createAdminUser(t, database) chID, _ := database.AdminCreateChannel("del-me", "text", "", "", 0) @@ -521,7 +601,7 @@ func TestAdminAPI_DeleteChannel_OK(t *testing.T) { func TestAdminAPI_DeleteChannel_NotFound(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) token := createAdminUser(t, database) w := doRequest(t, handler, http.MethodDelete, "/channels/99999", token, nil) @@ -535,7 +615,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) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) token := createAdminUser(t, database) uid, _ := database.CreateUser("actor", "hash", 1) @@ -558,7 +638,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) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) token := createAdminUser(t, database) w := doRequest(t, handler, http.MethodGet, "/audit-log", token, nil) @@ -578,7 +658,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) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) token := createAdminUser(t, database) w := doRequest(t, handler, http.MethodGet, "/settings", token, nil) @@ -600,7 +680,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) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) token := createAdminUser(t, database) body := map[string]string{ @@ -625,7 +705,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) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) token := createAdminUser(t, database) req := httptest.NewRequest(http.MethodPatch, "/settings", bytes.NewReader([]byte("not-json"))) @@ -642,7 +722,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) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) // Admin (role 2) can authenticate but is not Owner (role 1, position 100) adminUID, _ := database.CreateUser("adminonly", "hash", 2) @@ -659,7 +739,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) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) w := doRequest(t, handler, http.MethodPost, "/backup", "", nil) @@ -676,7 +756,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) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) token := createAdminUser(t, database) // Create a target user to act on. @@ -710,7 +790,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) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) token := createAdminUser(t, database) targetUID, _ := database.CreateUser("logoutctx", "hash", 3) @@ -742,7 +822,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) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) token := createAdminUser(t, database) body := map[string]string{ @@ -768,7 +848,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) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) token := createAdminUser(t, database) body := map[string]string{ @@ -812,7 +892,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) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) token := createAdminUser(t, database) value := "testvalue" @@ -833,7 +913,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) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) token := createAdminUser(t, database) body := map[string]string{} @@ -846,7 +926,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) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) token := createAdminUser(t, database) body := map[string]string{ @@ -862,7 +942,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) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) token := createAdminUser(t, database) if _, err := database.Exec(`UPDATE users SET totp_secret = ? WHERE id = 1`, "JBSWY3DPEHPK3PXP"); err != nil { @@ -882,7 +962,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) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) token := createAdminUser(t, database) body := map[string]string{ @@ -901,7 +981,7 @@ func TestAdminAPI_PatchSettings_RejectsInvalidBooleanValue(t *testing.T) { // expose the PasswordHash field in any returned user object. func TestAdminAPI_ListUsers_NoPasswordHash(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) token := createAdminUser(t, database) // Create a second user so the list is non-trivial. @@ -928,7 +1008,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) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) token := createAdminUser(t, database) w := doRequest(t, handler, http.MethodGet, "/users", token, nil) @@ -947,7 +1027,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) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) token := createAdminUser(t, database) w := doRequest(t, handler, http.MethodGet, "/users", token, nil) @@ -976,7 +1056,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) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) token := createAdminUser(t, database) targetUID, _ := database.CreateUser("patchvictim", "topsecretbcrypt", 3) @@ -1004,7 +1084,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) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) token := createAdminUser(t, database) targetUID, _ := database.CreateUser("patchtotp", "hash", 3) @@ -1077,7 +1157,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) + handler := admin.NewAdminAPI(database, "1.0.0", hub, nil, nil, nil, nil, newTestModService(database)) token := createAdminUser(t, database) body := map[string]any{ @@ -1100,7 +1180,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) + handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database)) token := createAdminUser(t, database) body := map[string]any{"name": "safe-channel", "type": "text"} @@ -1114,7 +1194,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) + handler := admin.NewAdminAPI(database, "1.0.0", hub, nil, nil, nil, nil, newTestModService(database)) token := createAdminUser(t, database) chID, _ := database.AdminCreateChannel("before", "text", "", "", 0) @@ -1135,7 +1215,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) + handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database)) token := createAdminUser(t, database) chID, _ := database.AdminCreateChannel("patchme", "text", "", "", 0) @@ -1150,7 +1230,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) + handler := admin.NewAdminAPI(database, "1.0.0", hub, nil, nil, nil, nil, newTestModService(database)) token := createAdminUser(t, database) chID, _ := database.AdminCreateChannel("delete-me", "text", "", "", 0) @@ -1170,7 +1250,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) + handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database)) token := createAdminUser(t, database) chID, _ := database.AdminCreateChannel("del-no-hub", "text", "", "", 0) diff --git a/Server/admin/handlers_backup_test.go b/Server/admin/handlers_backup_test.go index 748cd903..86299f41 100644 --- a/Server/admin/handlers_backup_test.go +++ b/Server/admin/handlers_backup_test.go @@ -40,7 +40,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) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) token := createAdminUser(t, database) w := doRequest(t, handler, http.MethodPost, "/backup", token, nil) @@ -75,7 +75,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) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) adminUID, _ := database.CreateUser("backupadmin", "hash", 2) token := "backup-admin-token" @@ -95,7 +95,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) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) token := createAdminUser(t, database) w := doRequest(t, handler, http.MethodGet, "/backups", token, nil) @@ -118,7 +118,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) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) token := createAdminUser(t, database) // Create a backup first. @@ -160,7 +160,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) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) token := createAdminUser(t, database) // Create a real backup file to delete. @@ -191,7 +191,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) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) token := createAdminUser(t, database) w := doRequest(t, handler, http.MethodDelete, "/backups/nonexistent.db", token, nil) @@ -206,7 +206,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) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) token := createAdminUser(t, database) // The chi router URL-decodes the path parameter, so ".." arrives decoded. @@ -224,7 +224,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) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) adminUID, _ := database.CreateUser("deladmin", "hash", 2) token := "del-admin-token" @@ -249,7 +249,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) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) token := createAdminUser(t, database) // Set up backup and data directories. @@ -293,7 +293,7 @@ func TestHandleRestoreBackup_Success(t *testing.T) { func TestHandleRestoreBackup_NotFound(t *testing.T) { _ = chdirTemp(t) database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) token := createAdminUser(t, database) w := doRequest(t, handler, http.MethodPost, "/backups/missing.db/restore", token, nil) @@ -308,7 +308,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) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) token := createAdminUser(t, database) w := doRequest(t, handler, http.MethodPost, "/backups/..evil.db/restore", token, nil) @@ -324,7 +324,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) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) token := createAdminUser(t, database) // Create data/ directory but make "backups" a file instead of a directory. @@ -352,7 +352,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) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) adminUID, _ := database.CreateUser("restoreadmin", "hash", 2) token := "restore-admin-token" diff --git a/Server/admin/handlers_channels_test.go b/Server/admin/handlers_channels_test.go index d8eaf3d9..e8d2c05f 100644 --- a/Server/admin/handlers_channels_test.go +++ b/Server/admin/handlers_channels_test.go @@ -12,7 +12,7 @@ import ( func TestCreateChannel_TextUnderTextCategory_OK(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) token := createAdminUser(t, database) body := map[string]any{ @@ -28,7 +28,7 @@ func TestCreateChannel_TextUnderTextCategory_OK(t *testing.T) { func TestCreateChannel_AnnouncementUnderTextCategory_OK(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) token := createAdminUser(t, database) body := map[string]any{ @@ -44,7 +44,7 @@ func TestCreateChannel_AnnouncementUnderTextCategory_OK(t *testing.T) { func TestCreateChannel_VoiceUnderVoiceCategory_OK(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) token := createAdminUser(t, database) body := map[string]any{ @@ -60,7 +60,7 @@ func TestCreateChannel_VoiceUnderVoiceCategory_OK(t *testing.T) { func TestCreateChannel_VoiceUnderTextCategory_Rejected(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) token := createAdminUser(t, database) body := map[string]any{ @@ -83,7 +83,7 @@ func TestCreateChannel_VoiceUnderTextCategory_Rejected(t *testing.T) { func TestCreateChannel_TextUnderVoiceCategory_Rejected(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) token := createAdminUser(t, database) body := map[string]any{ @@ -99,7 +99,7 @@ func TestCreateChannel_TextUnderVoiceCategory_Rejected(t *testing.T) { func TestCreateChannel_EmptyCategory_Allowed(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) token := createAdminUser(t, database) body := map[string]any{ @@ -115,7 +115,7 @@ func TestCreateChannel_EmptyCategory_Allowed(t *testing.T) { func TestCreateChannel_CaseInsensitiveVoiceCategory(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database)) token := createAdminUser(t, database) // "VOICE" in uppercase should still be treated as a voice category diff --git a/Server/admin/handlers_users.go b/Server/admin/handlers_users.go index 7b61da73..f3e9d628 100644 --- a/Server/admin/handlers_users.go +++ b/Server/admin/handlers_users.go @@ -2,11 +2,13 @@ package admin import ( "encoding/json" + "errors" "fmt" "log/slog" "net/http" "github.com/owncord/server/db" + "github.com/owncord/server/service" ) // ─── User Handlers ─────────────────────────────────────────────────────────── @@ -51,7 +53,21 @@ type patchUserRequest struct { BanReason *string `json:"ban_reason"` } -func handlePatchUser(database *db.DB, hub HubBroadcaster, permInvalidator PermissionInvalidator) http.HandlerFunc { +// writeModerationErr maps ModerationService errors onto admin API responses. +func writeModerationErr(w http.ResponseWriter, err error) { + switch { + case errors.Is(err, service.ErrForbidden): + writeErr(w, http.StatusForbidden, "FORBIDDEN", err.Error()) + case errors.Is(err, service.ErrNotFound): + writeErr(w, http.StatusNotFound, "NOT_FOUND", "user not found") + case errors.Is(err, service.ErrBadRequest): + writeErr(w, http.StatusBadRequest, "BAD_REQUEST", err.Error()) + default: + writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "moderation action failed") + } +} + +func handlePatchUser(database *db.DB, hub HubBroadcaster, permInvalidator PermissionInvalidator, mod *service.ModerationService) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { id, err := pathInt64(r, "id") if err != nil { @@ -84,21 +100,38 @@ func handlePatchUser(database *db.DB, hub HubBroadcaster, permInvalidator Permis return } - // Wrap role + ban updates in a transaction so both succeed or fail atomically. - tx, txErr := database.Begin() - if txErr != nil { - writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to begin transaction") - return - } - committed := false - defer func() { - if !committed { - _ = tx.Rollback() + // Ban/unban first: it routes through ModerationService, which enforces + // BAN_MEMBERS + role hierarchy (the admin-auth perimeter alone does + // not — any admin-panel actor could previously ban the owner). The + // service also audits and refuses before the role change runs, so a + // rejected ban never leaves a half-applied PATCH behind. + if req.Banned != nil { + if mod == nil { + // Fail closed rather than fall back to an unchecked UPDATE. + writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "moderation service unavailable") + return } - }() + banReason := "" + if req.BanReason != nil { + banReason = *req.BanReason + } + var actionErr error + if *req.Banned { + actionErr = mod.BanUser(r.Context(), actor, id, banReason, nil) + } else { + actionErr = mod.UnbanUser(r.Context(), actor, id) + } + if actionErr != nil { + writeModerationErr(w, actionErr) + return + } + if *req.Banned && hub != nil { + hub.BroadcastMemberBan(id) + } + } if req.RoleID != nil { - if _, err := tx.Exec(`UPDATE users SET role_id = ? WHERE id = ?`, *req.RoleID, id); err != nil { + if _, err := database.Exec(`UPDATE users SET role_id = ? WHERE id = ?`, *req.RoleID, id); err != nil { writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to update role") return } @@ -106,45 +139,6 @@ func handlePatchUser(database *db.DB, hub HubBroadcaster, permInvalidator Permis if permInvalidator != nil { permInvalidator.InvalidateUser(id) } - } - - banReason := "" - if req.Banned != nil { - if req.BanReason != nil { - banReason = *req.BanReason - } - if *req.Banned { - var expiresStr *string - if _, err := tx.Exec( - `UPDATE users SET banned = 1, ban_reason = ?, ban_expires = ? WHERE id = ?`, - banReason, expiresStr, id, - ); err != nil { - writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to ban user") - return - } - slog.Warn("user banned", "actor_id", actor, "target_user", user.Username, "reason", banReason) - } else { - if _, err := tx.Exec( - `UPDATE users SET banned = 0, ban_reason = NULL, ban_expires = NULL WHERE id = ?`, - id, - ); err != nil { - writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to unban user") - return - } - slog.Info("user unbanned", "actor_id", actor, "target_user", user.Username) - } - } - - if err := tx.Commit(); err != nil { - writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to commit user update") - return - } - committed = true - - // Post-commit side effects: audit logging and broadcasts. - // These run outside the transaction to avoid SQLite write-lock - // contention (LogAudit uses the main *sql.DB, not the tx). - if req.RoleID != nil { _ = database.LogAudit(actor, "role_change", "user", id, fmt.Sprintf("changed %s role to %d", user.Username, *req.RoleID)) if role, err := database.GetRoleByID(*req.RoleID); err == nil && role != nil { @@ -153,18 +147,6 @@ func handlePatchUser(database *db.DB, hub HubBroadcaster, permInvalidator Permis } } } - if req.Banned != nil { - if *req.Banned { - _ = database.LogAudit(actor, "user_ban", "user", id, - fmt.Sprintf("banned %s: %s", user.Username, banReason)) - if hub != nil { - hub.BroadcastMemberBan(id) - } - } else { - _ = database.LogAudit(actor, "user_unban", "user", id, - fmt.Sprintf("unbanned %s", user.Username)) - } - } updated, err := database.GetUserByID(id) if err != nil { diff --git a/Server/admin/middleware_and_spawn_test.go b/Server/admin/middleware_and_spawn_test.go index e1a5bc99..8b287829 100644 --- a/Server/admin/middleware_and_spawn_test.go +++ b/Server/admin/middleware_and_spawn_test.go @@ -249,7 +249,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) + handler := NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, nil) uid, err := database.CreateUser("noroleuser", "$2a$12$x", 1) if err != nil { diff --git a/Server/admin/middleware_coverage_test.go b/Server/admin/middleware_coverage_test.go index 982efe99..2c3a6f69 100644 --- a/Server/admin/middleware_coverage_test.go +++ b/Server/admin/middleware_coverage_test.go @@ -18,7 +18,7 @@ import ( // session has expired is rejected with 401. func TestAdminAuthMiddleware_ExpiredSession(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil) + handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database)) // Create a user and session, then manually expire the session by setting // expires_at to a past timestamp via the exported Exec helper. @@ -52,7 +52,7 @@ func TestAdminAuthMiddleware_ExpiredSession(t *testing.T) { // Authorization header returns 401. func TestAdminAuthMiddleware_MissingBearer(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil) + handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database)) w := doRequest(t, handler, http.MethodGet, "/stats", "", nil) @@ -65,7 +65,7 @@ func TestAdminAuthMiddleware_MissingBearer(t *testing.T) { // sessions table returns 401. func TestAdminAuthMiddleware_InvalidToken(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil) + handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database)) w := doRequest(t, handler, http.MethodGet, "/stats", "completely-invalid-token", nil) diff --git a/Server/admin/setup_handler.go b/Server/admin/setup_handler.go index aa4d48c9..4d1f169c 100644 --- a/Server/admin/setup_handler.go +++ b/Server/admin/setup_handler.go @@ -142,7 +142,10 @@ func handleSetup(database *db.DB, limiter *auth.RateLimiter, allowedOrigins []st _, _ = database.CreateChannel("General", "voice", "Voice Channels", "", 0) // Generate a bootstrap invite code so the owner can invite others. - inviteCode, err := database.CreateInvite(uid, 0, nil) // unlimited uses, no expiry + // Bound it (5 uses / 24h) rather than minting an unlimited, non-expiring + // invite — the owner can create fresh invites once logged in. + bootstrapInviteExpiry := time.Now().Add(24 * time.Hour) + inviteCode, err := database.CreateInvite(uid, 5, &bootstrapInviteExpiry) if err != nil { writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to generate invite code") return diff --git a/Server/admin/setup_handler_test.go b/Server/admin/setup_handler_test.go index 1341183f..8bbb57b2 100644 --- a/Server/admin/setup_handler_test.go +++ b/Server/admin/setup_handler_test.go @@ -11,7 +11,7 @@ import ( func TestSetupStatus_NeedsSetup(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil) + handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database)) rr := doRequest(t, handler, "GET", "/setup/status", "", nil) if rr.Code != http.StatusOK { @@ -32,7 +32,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) + handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database)) rr := doRequest(t, handler, "GET", "/setup/status", "", nil) if rr.Code != http.StatusOK { @@ -52,7 +52,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) + handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database)) rr := doRequest(t, handler, "POST", "/setup", "", map[string]string{ "username": "myadmin", @@ -97,7 +97,7 @@ func TestSetup_CreatesOwner(t *testing.T) { func TestSetup_BlockedAfterFirstUser(t *testing.T) { database := openAdminTestDB(t) - handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil) + handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database)) // First setup succeeds. rr := doRequest(t, handler, "POST", "/setup", "", map[string]string{ @@ -120,7 +120,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) + handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database)) rr := doRequest(t, handler, "POST", "/setup", "", map[string]string{ "username": "admin", @@ -133,7 +133,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) + handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database)) rr := doRequest(t, handler, "POST", "/setup", "", map[string]string{ "username": "", @@ -148,7 +148,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) + handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database)) const goroutines = 20 results := make(chan int, goroutines) diff --git a/Server/admin/update_handlers.go b/Server/admin/update_handlers.go index 3aa1574c..65368a8b 100644 --- a/Server/admin/update_handlers.go +++ b/Server/admin/update_handlers.go @@ -84,6 +84,17 @@ func handleApplyUpdate(u *updater.Updater, hub HubBroadcaster, _ string) http.Ha return } + // Snapshot the hash of the just-verified staged binary. It is re-checked + // immediately before rename+spawn to close the TOCTOU window between + // verification here and the swap in the background goroutine below. + stagedHash, err := updater.FileSHA256(newPath) + if err != nil { + slog.Error("update: failed to hash staged binary", "err", err) + _ = os.Remove(newPath) + writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to stage update") + return + } + // Respond to the client before shutting down. writeJSON(w, http.StatusOK, map[string]string{ "status": "applying", @@ -97,6 +108,14 @@ func handleApplyUpdate(u *updater.Updater, hub HubBroadcaster, _ string) http.Ha } time.Sleep(5 * time.Second) + // TOCTOU guard: re-verify the staged binary is byte-for-byte the one + // we verified before responding. If it was swapped between then and + // now, abort without renaming or spawning it. + if err := u.VerifyChecksum(newPath, stagedHash); err != nil { + slog.Error("update: staged binary re-verification failed, aborting update", "error", err) + return + } + // Rename: current -> .old, .new -> current _ = os.Remove(oldPath) // remove any stale .old if err := os.Rename(exePath, oldPath); err != nil { diff --git a/Server/admin/update_handlers_test.go b/Server/admin/update_handlers_test.go index 31923ed7..d9d3b8b8 100644 --- a/Server/admin/update_handlers_test.go +++ b/Server/admin/update_handlers_test.go @@ -34,7 +34,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) + handler := admin.NewAdminAPI(database, "1.0.0", nil, u, nil, nil, nil, newTestModService(database)) token := createAdminUser(t, database) w := doRequest(t, handler, http.MethodGet, "/updates", token, nil) @@ -73,7 +73,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) + handler := admin.NewAdminAPI(database, "1.0.0", nil, u, nil, nil, nil, newTestModService(database)) token := createAdminUser(t, database) w := doRequest(t, handler, http.MethodGet, "/updates", token, nil) @@ -106,7 +106,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) + handler := admin.NewAdminAPI(database, "1.0.0", nil, u, nil, nil, nil, newTestModService(database)) token := createAdminUser(t, database) w := doRequest(t, handler, http.MethodGet, "/updates", token, nil) @@ -123,7 +123,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) + handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database)) w := doRequest(t, handler, http.MethodGet, "/updates", "", nil) if w.Code != http.StatusUnauthorized { @@ -133,7 +133,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) + handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database)) // Create admin user (not owner - role 2) adminUID, _ := database.CreateUser("adminonly2", "hash", 2) @@ -153,7 +153,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) + handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database)) token := createAdminUser(t, database) w := doRequest(t, handler, http.MethodPost, "/updates/apply", token, nil) @@ -166,7 +166,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) + handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database)) token := createAdminUser(t, database) w := doRequest(t, handler, http.MethodPost, "/updates/apply", token, nil) @@ -198,7 +198,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) + handler := admin.NewAdminAPI(database, "1.0.0", nil, u, nil, nil, nil, newTestModService(database)) token := createAdminUser(t, database) w := doRequest(t, handler, http.MethodPost, "/updates/apply", token, nil) @@ -229,7 +229,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) + handler := admin.NewAdminAPI(database, "1.0.0", nil, u, nil, nil, nil, newTestModService(database)) token := createAdminUser(t, database) w := doRequest(t, handler, http.MethodPost, "/updates/apply", token, nil) @@ -257,7 +257,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) + handler := admin.NewAdminAPI(database, "1.0.0", nil, u, nil, nil, nil, newTestModService(database)) token := createAdminUser(t, database) w := doRequest(t, handler, http.MethodPost, "/updates/apply", token, nil) @@ -278,7 +278,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) + handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database)) w := doRequest(t, handler, http.MethodPost, "/updates/apply", "", nil) if w.Code != http.StatusUnauthorized { @@ -345,7 +345,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) + handler := admin.NewAdminAPI(database, "1.0.0", nil, u, nil, nil, nil, newTestModService(database)) token := createAdminUser(t, database) w := doRequest(t, handler, http.MethodPost, "/updates/apply", token, nil) diff --git a/Server/api/auth_handler.go b/Server/api/auth_handler.go index 73acfa99..48e021cc 100644 --- a/Server/api/auth_handler.go +++ b/Server/api/auth_handler.go @@ -317,7 +317,17 @@ func handleLogin(database *db.DB, limiter *auth.RateLimiter, partialStore *auth. failKey := "login_fail:" + ip userFailKey := "login_user_fail:" + req.Username - if user == nil || !auth.CheckPassword(user.PasswordHash, req.Password) { + // Always run the password check — with an empty hash when the user does + // not exist. auth.CheckPassword performs a dummy bcrypt comparison for an + // empty hash, so bcrypt executes on every path and response time stays + // constant, preventing timing-based username enumeration. (A `user == nil + // || CheckPassword(...)` short-circuit would skip bcrypt entirely for + // unknown usernames, reintroducing the timing side-channel.) + storedHash := "" + if user != nil { + storedHash = user.PasswordHash + } + if !auth.CheckPassword(storedHash, req.Password) { // Track failures per-IP; lockout on threshold. if !limiter.Allow(failKey, loginFailureThreshold, loginFailureWindow) { limiter.Lockout(lockKey, loginLockoutDuration) diff --git a/Server/api/client_update.go b/Server/api/client_update.go index 93d7d995..2700b17b 100644 --- a/Server/api/client_update.go +++ b/Server/api/client_update.go @@ -67,8 +67,10 @@ func handleClientUpdate(u *updater.Updater) http.HandlerFunc { return } - // Fetch the signature file content (small text file). - sigContent, err := u.FetchTextAsset(r.Context(), sigURL) + // Fetch the signature file content (small text file). Cached with the + // same TTL as the release info so this unauthenticated endpoint does not + // perform an outbound fetch on every request (DoS hardening). + sigContent, err := u.FetchTextAssetCached(r.Context(), sigURL) if err != nil { http.Error(w, "failed to fetch signature", http.StatusBadGateway) return diff --git a/Server/api/clientip_test.go b/Server/api/clientip_test.go index 271bc3c3..9126684f 100644 --- a/Server/api/clientip_test.go +++ b/Server/api/clientip_test.go @@ -4,6 +4,7 @@ package api // These live in package api (not api_test) so they can reach unexported symbols. import ( + "net/http" "net/http/httptest" "testing" ) @@ -153,6 +154,48 @@ func TestClientIP_XForwardedFor_UsedWhenNoXRealIP(t *testing.T) { } } +// TestClientIP_BroadTrustedCIDRKeepsClientsDistinct locks the W2-5 fix: with +// a trusted_proxies range broad enough to cover the clients themselves, the +// right-to-left walk exhausts; falling back to RemoteAddr would collapse +// every client into the proxy's own bucket (one user's failed logins would +// lock out everyone). The leftmost valid XFF entry keeps clients distinct. +func TestClientIP_BroadTrustedCIDRKeepsClientsDistinct(t *testing.T) { + trusted := []string{"10.0.0.0/8"} // covers proxy AND LAN clients + + newReq := func(xff string) *http.Request { + req := httptest.NewRequest("GET", "/", nil) + req.RemoteAddr = "10.0.0.2:9999" // the proxy + req.Header.Set("X-Forwarded-For", xff) + return req + } + + ip1 := clientIPWithProxies(newReq("10.5.1.7"), trusted) + ip2 := clientIPWithProxies(newReq("10.5.1.8"), trusted) + if ip1 != "10.5.1.7" || ip2 != "10.5.1.8" { + t.Fatalf("clients behind broad trusted CIDR collapsed: ip1=%q ip2=%q", ip1, ip2) + } + + // Multi-hop: leftmost valid entry (furthest upstream) wins on exhaustion. + ip3 := clientIPWithProxies(newReq("10.5.1.9, 10.0.0.3"), trusted) + if ip3 != "10.5.1.9" { + t.Fatalf("expected furthest-upstream entry, got %q", ip3) + } +} + +// TestClientIP_SpoofedXFFFromUntrustedRemoteIgnored: an untrusted connecting +// address never gets its forwarded headers honoured, exhaustion fallback or +// not. +func TestClientIP_SpoofedXFFFromUntrustedRemoteIgnored(t *testing.T) { + req := httptest.NewRequest("GET", "/", nil) + req.RemoteAddr = "203.0.113.9:1234" + req.Header.Set("X-Forwarded-For", "10.5.1.7") + + ip := clientIPWithProxies(req, []string{"10.0.0.0/8"}) + if ip != "203.0.113.9" { + t.Fatalf("spoofed XFF from untrusted remote honoured: got %q", ip) + } +} + func TestClientIP_RemoteAddrWithoutPort(t *testing.T) { // RemoteAddr sometimes has no port (e.g. Unix sockets in tests). req := httptest.NewRequest("GET", "/", nil) diff --git a/Server/api/constants.go b/Server/api/constants.go index 9ffc9cea..523e440b 100644 --- a/Server/api/constants.go +++ b/Server/api/constants.go @@ -31,6 +31,9 @@ const ( // livekitProxyRateLimitPerMinute is the maximum LiveKit proxy requests per IP per minute. livekitProxyRateLimitPerMinute = 30 + // clientUpdateRateLimitPerMinute is the maximum client-update checks per IP per minute. + clientUpdateRateLimitPerMinute = 30 + // loginFailureThreshold is the number of failed login attempts (within // loginFailureWindow) before the IP is locked out. loginFailureThreshold = 9 diff --git a/Server/api/livekit_proxy.go b/Server/api/livekit_proxy.go index 5b9b7f9d..9f7ccb7c 100644 --- a/Server/api/livekit_proxy.go +++ b/Server/api/livekit_proxy.go @@ -59,11 +59,9 @@ func NewLiveKitProxy(livekitURL string, allowedOrigins []string) http.Handler { blockedSegments := map[string]bool{"admin": true, "metrics": true, "debug": true, "twirp": true} return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - // Detect WebSocket upgrade requests. - if isWebSocketUpgrade(r) { - proxyWebSocket(w, r, &wsTarget, allowedOrigins) - return - } + // Enforce the path allowlist and Origin check for EVERY request, + // including WebSocket upgrades — otherwise a client could reach a + // blocked/admin endpoint simply by sending an Upgrade header. // Block sensitive LiveKit endpoints (exact segment match). for _, seg := range strings.Split(strings.ToLower(r.URL.Path), "/") { @@ -76,7 +74,7 @@ func NewLiveKitProxy(livekitURL string, allowedOrigins []string) http.Handler { } } - // Validate Origin header for HTTP requests (mirrors WS OriginPatterns). + // Validate Origin header (mirrors WS OriginPatterns). if !isOriginAllowed(r, allowedOrigins) { writeJSON(w, http.StatusForbidden, errorResponse{ Error: "FORBIDDEN", @@ -85,6 +83,12 @@ func NewLiveKitProxy(livekitURL string, allowedOrigins []string) http.Handler { return } + // Detect WebSocket upgrade requests. + if isWebSocketUpgrade(r) { + proxyWebSocket(w, r, &wsTarget, allowedOrigins) + return + } + httpProxy.ServeHTTP(w, r) }) } diff --git a/Server/api/livekit_ratelimit_test.go b/Server/api/livekit_ratelimit_test.go index 710d900d..074ff3c4 100644 --- a/Server/api/livekit_ratelimit_test.go +++ b/Server/api/livekit_ratelimit_test.go @@ -13,6 +13,44 @@ func okHandler(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) } +// TestRateLimitMiddlewareWithPrefix_SeparatesClientUpdateBucket locks the +// W2-1 fix: exhausting the client-update budget must not 429 the sensitive +// endpoints (verify-totp, password change) that ride the empty-prefix +// bucket for the same IP. +func TestRateLimitMiddlewareWithPrefix_SeparatesClientUpdateBucket(t *testing.T) { + limiter := auth.NewRateLimiter() + trustedProxies := []string{"127.0.0.0/8"} + + clientUpdate := rateLimitMiddlewareWithPrefix(limiter, "client_update:", 1, time.Minute, trustedProxies)(http.HandlerFunc(okHandler)) + sensitive := RateLimitMiddleware(limiter, 1, time.Minute, trustedProxies)(http.HandlerFunc(okHandler)) + + newReq := func(path string) *http.Request { + r := httptest.NewRequest(http.MethodGet, path, nil) + r.RemoteAddr = "127.0.0.1:9999" + r.Header.Set("X-Forwarded-For", "203.0.113.7") + return r + } + + // Exhaust the client-update bucket for this IP. + rec := httptest.NewRecorder() + clientUpdate.ServeHTTP(rec, newReq("/client-update")) + if rec.Code != http.StatusOK { + t.Fatalf("first client-update status = %d, want 200", rec.Code) + } + rec = httptest.NewRecorder() + clientUpdate.ServeHTTP(rec, newReq("/client-update")) + if rec.Code != http.StatusTooManyRequests { + t.Fatalf("second client-update status = %d, want 429", rec.Code) + } + + // The same IP's sensitive-endpoint budget must be untouched. + rec = httptest.NewRecorder() + sensitive.ServeHTTP(rec, newReq("/api/v1/auth/verify-totp")) + if rec.Code != http.StatusOK { + t.Fatalf("sensitive endpoint shares the client-update bucket: status = %d, want 200", rec.Code) + } +} + func TestRateLimitMiddlewareWithPrefix_SeparatesLiveKitBucket(t *testing.T) { limiter := auth.NewRateLimiter() trustedProxies := []string{"127.0.0.0/8"} diff --git a/Server/api/middleware.go b/Server/api/middleware.go index 7b1de13a..b270d4d5 100644 --- a/Server/api/middleware.go +++ b/Server/api/middleware.go @@ -205,8 +205,12 @@ func clientIPWithProxies(r *http.Request, trustedCIDRs []string) string { return remoteHost } - trusted, _ := isTrustedProxy(remoteHost, trustedCIDRs) - if !trusted { + // Parse the CIDR list once per request instead of once per XFF candidate. + // ponytail: parse at middleware construction if this ever shows in a + // profile — it would mean threading a parsed type through every caller. + nets := parseCIDRList(trustedCIDRs) + + if !ipInNets(remoteHost, nets) { return remoteHost } @@ -218,19 +222,70 @@ func clientIPWithProxies(r *http.Request, trustedCIDRs []string) string { } } - // Fall back to the leftmost (client) entry in X-Forwarded-For. + // Fall back to X-Forwarded-For, walking from the RIGHT and skipping entries + // that are themselves trusted proxies. The first non-trusted, valid address + // is the real client. Taking the leftmost entry (BUG-112) would trust a + // client-supplied value: a client can prepend a spoofed IP + // (`X-Forwarded-For: , `) that the proxy then appends to, + // letting it forge per-IP rate-limit and lockout keys. if xff := r.Header.Get("X-Forwarded-For"); xff != "" { - parts := strings.SplitN(xff, ",", 2) - if client := strings.TrimSpace(parts[0]); client != "" { - if net.ParseIP(client) != nil { - return client + parts := strings.Split(xff, ",") + leftmostValid := "" + for i := len(parts) - 1; i >= 0; i-- { + candidate := strings.TrimSpace(parts[i]) + if candidate == "" || net.ParseIP(candidate) == nil { + continue } + leftmostValid = candidate + if ipInNets(candidate, nets) { + continue // our own proxy hop, keep walking left + } + return candidate + } + // Every entry fell inside trustedCIDRs — a config that covers client + // networks too (e.g. trusted_proxies: 10.0.0.0/8 with LAN clients). + // Falling back to RemoteAddr here would collapse ALL clients behind + // the proxy into one rate-limit/lockout bucket, so one user's failed + // logins would lock out everyone. The leftmost valid entry is the + // furthest-upstream hop — the best distinct per-client key available + // under such a config. trusted_proxies must list only proxy hops; + // startup validation warns about entries that cannot be proxies. + if leftmostValid != "" { + return leftmostValid } } return remoteHost } +// parseCIDRList parses CIDR strings, silently skipping invalid entries — a +// misconfigured entry must not crash request handling (config load warns +// about them at startup). +func parseCIDRList(cidrs []string) []*net.IPNet { + nets := make([]*net.IPNet, 0, len(cidrs)) + for _, c := range cidrs { + if _, n, err := net.ParseCIDR(c); err == nil { + nets = append(nets, n) + } + } + return nets +} + +// ipInNets reports whether ipStr (a plain IP, no port) falls inside any of +// the parsed networks. +func ipInNets(ipStr string, nets []*net.IPNet) bool { + ip := net.ParseIP(ipStr) + if ip == nil { + return false + } + for _, n := range nets { + if n.Contains(ip) { + return true + } + } + return false +} + // isTrustedProxy reports whether remoteIP (a plain IP string, no port) falls // within any of the provided CIDR ranges. It returns an error if any CIDR is // malformed. diff --git a/Server/api/profile_handler.go b/Server/api/profile_handler.go index 00aaa177..4a26eec2 100644 --- a/Server/api/profile_handler.go +++ b/Server/api/profile_handler.go @@ -228,10 +228,22 @@ func handleChangePassword(svc *service.Services, limiter *auth.RateLimiter) http keepSessionID = sess.ID } - if _, err := svc.Users.ChangePassword(user.ID, hash, keepSessionID); err != nil { + res, err := svc.Users.ChangePassword(user.ID, hash, keepSessionID) + if err != nil { + // Only reachable when the password itself failed to commit. writeServiceError(w, err) return } + if res.RevokeFailed { + // Partial success: the password IS changed; only revoking the + // other sessions failed. A 5xx here would tell the user to retry + // with a password that no longer works. + writeJSON(w, http.StatusOK, map[string]any{ + "warning": "password changed, but other sessions could not be revoked; revoke them from the sessions list", + "sessions_revoked": res.SessionsRevoked, + }) + return + } w.WriteHeader(http.StatusNoContent) } diff --git a/Server/api/router.go b/Server/api/router.go index a53b218b..a40ef231 100644 --- a/Server/api/router.go +++ b/Server/api/router.go @@ -233,7 +233,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) + adminHandler := admin.NewHandler(database, ver, hub, u, logBuf, cfg.Server.AllowedOrigins, svc.Permissions, svc.Moderation) r.Group(func(r chi.Router) { r.Use(AdminIPRestrict(cfg.Server.AdminAllowedCIDRs, cfg.Server.TrustedProxies)) r.Mount("/admin", adminHandler) @@ -251,8 +251,16 @@ func NewRouter(cfg *config.Config, database *db.DB, ver string, logBuf *admin.Ri }) }) - // Client auto-update endpoint (unauthenticated). - MountClientUpdateRoute(r, u) + // Client auto-update endpoint (unauthenticated). Per-IP rate limited to + // bound abuse; the signature fetch is cached inside the updater (DoS fix). + // Dedicated key prefix (mirroring "livekit_proxy:"): the empty-prefix + // middleware would share per-IP buckets with verify-totp, password change, + // and the other sensitive endpoints, so a client's 30/min auto-poll could + // 429 its user's own 2FA or password change. + MountClientUpdateRoute( + r.With(rateLimitMiddlewareWithPrefix(limiter, "client_update:", clientUpdateRateLimitPerMinute, time.Minute, cfg.Server.TrustedProxies)), + u, + ) // Issue 15: Warn if AllowedOrigins contains wildcard. for _, o := range cfg.Server.AllowedOrigins { diff --git a/Server/api/totp_handler.go b/Server/api/totp_handler.go index 08d18508..e7730099 100644 --- a/Server/api/totp_handler.go +++ b/Server/api/totp_handler.go @@ -66,7 +66,14 @@ func handleVerifyTOTP(database *db.DB, partialStore *auth.PartialAuthStore, limi } totpRateLimitKey := fmt.Sprintf("totp_fail:%d", challenge.UserID) - if !limiter.Check(totpRateLimitKey, totpFailureRateLimit, totpFailureWindow) { + // Atomically record this attempt and reject once the per-user failure cap + // is reached. Recording up-front — rather than a read-only Check now and + // Allow only on failure — closes a TOCTOU where many concurrent requests + // reusing one valid partial token all pass the read-only check before any + // failure is recorded, defeating the per-user brute-force cap (the only + // cross-IP defence). A successful verification resets the counter below, + // so legitimate retries are not penalised. + if !limiter.Allow(totpRateLimitKey, totpFailureRateLimit, totpFailureWindow) { writeJSON(w, http.StatusTooManyRequests, errorResponse{ Error: "RATE_LIMITED", Message: "too many failed attempts, try again later", @@ -94,7 +101,8 @@ func handleVerifyTOTP(database *db.DB, partialStore *auth.PartialAuthStore, limi } if !auth.VerifyTOTPCodeOnce(secret, strings.TrimSpace(req.Code), time.Now().UTC(), user.ID, usedTOTPCodes) { - limiter.Allow(totpRateLimitKey, totpFailureRateLimit, totpFailureWindow) + // The attempt was already recorded atomically up-front via + // limiter.Allow; only the per-partial-token counter is advanced here. partialStore.RegisterFailure(partialToken, partialAuthMaxFailures) writeJSON(w, http.StatusUnauthorized, errorResponse{ Error: "UNAUTHORIZED", diff --git a/Server/api/upload_handler.go b/Server/api/upload_handler.go index d27b097b..b83a8292 100644 --- a/Server/api/upload_handler.go +++ b/Server/api/upload_handler.go @@ -304,7 +304,12 @@ func handleServeFile(database *db.DB, store *storage.Storage, allowedOrigins []s disposition = "attachment" } w.Header().Set("Content-Disposition", mime.FormatMediaType(disposition, map[string]string{"filename": aa.Filename})) - w.Header().Set("Cache-Control", fmt.Sprintf("public, max-age=%d, immutable", fileCacheMaxAgeSeconds)) + // These downloads are access-controlled, so they must never be stored by + // shared/proxy caches (info-leak). Mark private and force revalidation. + w.Header().Set("Cache-Control", fmt.Sprintf("private, max-age=%d, no-cache", fileCacheMaxAgeSeconds)) + // The Access-Control-Allow-Origin header below reflects the request + // Origin, so responses vary by Origin and must not be cross-served. + w.Header().Set("Vary", "Origin") // CORS: allow webview to read the response body using configured origins. if origin := r.Header.Get("Origin"); origin != "" { for _, allowed := range allowedOrigins { diff --git a/Server/api/upload_handler_test.go b/Server/api/upload_handler_test.go index 5651a183..9a68bab0 100644 --- a/Server/api/upload_handler_test.go +++ b/Server/api/upload_handler_test.go @@ -727,10 +727,11 @@ func TestServeFile_Success(t *testing.T) { t.Error("expected Content-Type header on served file") } - // Verify cache control header. + // Verify cache control header. Access-controlled downloads must be marked + // private + no-cache so shared/proxy caches never store them (info-leak). cc := rr2.Header().Get("Cache-Control") - if cc != "public, max-age=31536000, immutable" { - t.Errorf("Cache-Control = %q, want 'public, max-age=31536000, immutable'", cc) + if cc != "private, max-age=31536000, no-cache" { + t.Errorf("Cache-Control = %q, want 'private, max-age=31536000, no-cache'", cc) } // Verify Content-Disposition header. diff --git a/Server/api/waf.go b/Server/api/waf.go index 49636588..a21ac3c4 100644 --- a/Server/api/waf.go +++ b/Server/api/waf.go @@ -92,8 +92,12 @@ func NewWAFMiddleware(paranoiaLevel int) func(http.Handler) http.Handler { return } - // Process request body (if applicable) - if r.Body != nil && r.ContentLength > 0 { + // Process request body (if applicable). Use ContentLength != 0 so + // chunked requests (Transfer-Encoding: chunked → ContentLength == -1) + // are inspected too; otherwise the SQLi/XSS/RCE body rules are silently + // skipped for them. The read is bounded by SecRequestBodyLimit inside + // Coraza. ContentLength == 0 (no body) still skips inspection. + if r.Body != nil && r.ContentLength != 0 { if it, _, err := tx.ReadRequestBodyFrom(r.Body); it != nil { handleWAFInterruption(w, it) return diff --git a/Server/auth/totp_encrypt.go b/Server/auth/totp_encrypt.go index 12f8abc7..811160db 100644 --- a/Server/auth/totp_encrypt.go +++ b/Server/auth/totp_encrypt.go @@ -138,11 +138,14 @@ func DecryptTOTPSecret(key []byte, ciphertext string) (string, error) { nonce, sealed := data[:nonceSize], data[nonceSize:] plaintext, err := gcm.Open(nil, nonce, sealed, nil) if err != nil { - // Decryption failed -- likely an unencrypted legacy secret or wrong key. - // Return as-is for backwards compatibility. - slog.Warn("TOTP secret decryption failed — returning as plaintext (check TOTP_ENCRYPTION_KEY)", - "error", err) - return ciphertext, nil //nolint:nilerr + // The value has the full encrypted shape (valid hex, long enough for + // nonce+tag) but GCM authentication failed. That is a real error — a + // wrong TOTP_ENCRYPTION_KEY or a tampered/corrupted ciphertext — not a + // legacy plaintext secret (those are caught by the not-hex and + // too-short branches above). Fail CLOSED: returning the ciphertext as + // if it were the secret would silently mask key misconfiguration. + slog.Error("TOTP secret decryption failed — check TOTP_ENCRYPTION_KEY", "error", err) + return "", fmt.Errorf("decrypting TOTP secret: %w", err) } return string(plaintext), nil diff --git a/Server/config/config.go b/Server/config/config.go index bcba0651..2bd99919 100644 --- a/Server/config/config.go +++ b/Server/config/config.go @@ -6,6 +6,7 @@ import ( "encoding/hex" "fmt" "log/slog" + "net" "os" "strings" @@ -123,20 +124,12 @@ type ServerConfig struct { // cause the server to refuse to start with a clear error pointing at the // follow-up work — see Server/main.go. type DatabaseConfig struct { - // Type is "sqlite" or "postgres". Empty defaults to "sqlite". + // Type selects the database backend. "sqlite" (or empty, which defaults + // to it) is the only supported value. Type string `koanf:"type"` - // Path is the SQLite database file path. Only used when Type == "sqlite". + // Path is the SQLite database file path. Path string `koanf:"path"` - - // PostgreSQL connection settings. Only used when Type == "postgres". - Host string `koanf:"host"` - Port int `koanf:"port"` - User string `koanf:"user"` - Password string `koanf:"password"` - Name string `koanf:"name"` - SSLMode string `koanf:"sslmode"` // disable | require | verify-ca | verify-full - MaxConns int `koanf:"max_conns"` // pgxpool max connections; 0 = pgxpool default } // TLSConfig holds TLS/certificate settings. @@ -173,12 +166,8 @@ func defaults() Config { }, }, Database: DatabaseConfig{ - Type: "sqlite", - Path: "data/chatserver.db", - Host: "localhost", - Port: 5432, - Name: "owncord", - SSLMode: "disable", + Type: "sqlite", + Path: "data/chatserver.db", }, TLS: TLSConfig{ Mode: "self_signed", @@ -227,7 +216,10 @@ server: name: "OwnCord Server" data_dir: "data" # allowed_origins: [] # empty = deny cross-origin; set to ["*"] for dev or specific origins for prod - # trusted_proxies: [] # CIDRs of trusted reverse proxies, e.g. ["10.0.0.0/8"] + # trusted_proxies: [] # CIDRs of the reverse-proxy HOPS only (e.g. ["10.0.0.2/32"]). + # # Never list client networks here: a range that covers + # # clients degrades per-client rate limiting and lets + # # covered clients influence their own rate-limit key. # admin_allowed_cidrs: # CIDRs allowed to access /admin (default: private networks only) # - "127.0.0.0/8" # - "::1/128" @@ -236,16 +228,8 @@ server: # - "192.168.0.0/16" database: - type: "sqlite" # "sqlite" (default, zero-config) or "postgres" + type: "sqlite" # "sqlite" is the only supported backend path: "data/chatserver.db" - # PostgreSQL settings (only used when type: "postgres"): - # host: "localhost" - # port: 5432 - # user: "owncord" - # password: "" - # name: "owncord" - # sslmode: "disable" # disable | require | verify-ca | verify-full - # max_conns: 0 # pgxpool connection cap (0 = pgx default) tls: mode: "self_signed" # self_signed, acme, manual, off @@ -369,9 +353,26 @@ func Load(cfgPath string) (*Config, error) { cfg.Voice.LiveKitAPISecret = "" } + // Invalid CIDR entries are skipped at request time (they must not crash + // handling), which silently un-trusts a misconfigured proxy — warn once + // at startup instead. Common mistake: a bare IP without the /32 mask. + warnInvalidCIDRs("server.trusted_proxies", cfg.Server.TrustedProxies) + warnInvalidCIDRs("server.admin_allowed_cidrs", cfg.Server.AdminAllowedCIDRs) + return &cfg, nil } +// warnInvalidCIDRs logs a startup warning for each list entry that is not +// valid CIDR notation. +func warnInvalidCIDRs(key string, cidrs []string) { + for _, c := range cidrs { + if _, _, err := net.ParseCIDR(c); err != nil { + slog.Warn("config: ignoring invalid CIDR entry (use address/prefix notation, e.g. 10.0.0.1/32)", + "key", key, "entry", c) + } + } +} + // defaultLiveKitAPIKey and defaultLiveKitAPISecret are the well-known dev // credentials that ship in the default config. They must never be used in // production — NewLiveKitClient rejects them. diff --git a/Server/db/attachment_queries.go b/Server/db/attachment_queries.go index d8da2d1e..087b4c0c 100644 --- a/Server/db/attachment_queries.go +++ b/Server/db/attachment_queries.go @@ -91,23 +91,32 @@ func (d *DB) GetAttachmentWithChannel(id string) (*AttachmentAccess, error) { } // LinkAttachmentsToMessage sets message_id on attachments that are currently -// unlinked (message_id IS NULL). Returns the number of rows updated. -// Uses WHERE message_id IS NULL to prevent double-linking in a race. -func (d *DB) LinkAttachmentsToMessage(messageID int64, attachmentIDs []string) (int64, error) { +// unlinked (message_id IS NULL) and owned by uploaderID. Legacy rows with +// uploader_id IS NULL are treated as unowned and may be claimed by any +// sender. Rows that are already linked, owned by another user, or +// nonexistent are skipped rather than errors, so a client retry of a +// partially-completed send cannot fail the whole message. This single UPDATE +// is the atomic attachment-IDOR guard for message sends: ownership is +// enforced in the same statement that links, so there is no check-then-link +// race. Returns the number of rows updated. +func (d *DB) LinkAttachmentsToMessage(messageID, uploaderID int64, attachmentIDs []string) (int64, error) { if len(attachmentIDs) == 0 { return 0, nil } placeholders := make([]string, len(attachmentIDs)) - args := make([]any, 0, len(attachmentIDs)+1) + args := make([]any, 0, len(attachmentIDs)+2) args = append(args, messageID) for i, id := range attachmentIDs { placeholders[i] = "?" args = append(args, id) } + args = append(args, uploaderID) query := fmt.Sprintf( //nolint:gosec // G201: placeholder interpolation, not user input - `UPDATE attachments SET message_id = ? WHERE id IN (%s) AND message_id IS NULL`, + `UPDATE attachments SET message_id = ? + WHERE id IN (%s) AND message_id IS NULL + AND (uploader_id = ? OR uploader_id IS NULL)`, strings.Join(placeholders, ","), ) res, err := d.sqlDB.Exec(query, args...) diff --git a/Server/db/attachment_queries_test.go b/Server/db/attachment_queries_test.go index ab7339e4..4048b63c 100644 --- a/Server/db/attachment_queries_test.go +++ b/Server/db/attachment_queries_test.go @@ -57,7 +57,7 @@ func TestGetAttachmentByID_Found(t *testing.T) { func TestLinkAttachmentsToMessage_Empty(t *testing.T) { database := openMigratedMemory(t) - n, err := database.LinkAttachmentsToMessage(1, nil) + n, err := database.LinkAttachmentsToMessage(1, 1, nil) if err != nil { t.Fatalf("LinkAttachmentsToMessage(nil): %v", err) } @@ -84,7 +84,7 @@ func TestLinkAttachmentsToMessage_LinksUnlinked(t *testing.T) { } } - n, err := database.LinkAttachmentsToMessage(msgID, []string{"att-a", "att-b"}) + n, err := database.LinkAttachmentsToMessage(msgID, userID, []string{"att-a", "att-b"}) if err != nil { t.Fatalf("LinkAttachmentsToMessage: %v", err) } @@ -113,7 +113,7 @@ func TestLinkAttachmentsToMessage_SkipsAlreadyLinked(t *testing.T) { ) // Try to re-link to a different message — should skip (WHERE message_id IS NULL). - n, err := database.LinkAttachmentsToMessage(msg2, []string{"att-linked"}) + n, err := database.LinkAttachmentsToMessage(msg2, userID, []string{"att-linked"}) if err != nil { t.Fatalf("LinkAttachmentsToMessage: %v", err) } @@ -122,6 +122,50 @@ func TestLinkAttachmentsToMessage_SkipsAlreadyLinked(t *testing.T) { } } +// TestLinkAttachmentsToMessage_OwnershipGuard locks the atomic IDOR guard +// (W1-3): the link UPDATE itself enforces ownership, so a foreign attachment +// can never be claimed, legacy NULL-uploader rows remain claimable, and +// nonexistent ids are skipped without failing the statement. +func TestLinkAttachmentsToMessage_OwnershipGuard(t *testing.T) { + database := openMigratedMemory(t) + owner := seedUser(t, database, "att-owner") + other := seedUser(t, database, "att-other") + chID := seedChannel(t, database, "att-owner-ch") + msgID, _ := database.CreateMessage(chID, owner, "attachment carrier", nil) + + if err := database.CreateAttachment("att-owned", owner, "o.txt", "s-o.txt", "text/plain", 1, nil, nil); err != nil { + t.Fatalf("CreateAttachment att-owned: %v", err) + } + if err := database.CreateAttachment("att-foreign", other, "f.txt", "s-f.txt", "text/plain", 1, nil, nil); err != nil { + t.Fatalf("CreateAttachment att-foreign: %v", err) + } + // Legacy row from before uploader tracking: uploader_id IS NULL. + if _, err := database.Exec( + `INSERT INTO attachments (id, filename, stored_as, mime_type, size) + VALUES ('att-legacy', 'l.txt', 's-l.txt', 'text/plain', 1)`, + ); err != nil { + t.Fatalf("inserting legacy attachment: %v", err) + } + + n, err := database.LinkAttachmentsToMessage(msgID, owner, + []string{"att-owned", "att-foreign", "att-legacy", "att-missing"}) + if err != nil { + t.Fatalf("LinkAttachmentsToMessage: %v", err) + } + if n != 2 { + t.Errorf("expected 2 linked (owned + legacy), got %d", n) + } + if att, _ := database.GetAttachmentByID("att-owned"); att.MessageID == nil || *att.MessageID != msgID { + t.Error("owner's unlinked attachment should link") + } + if att, _ := database.GetAttachmentByID("att-foreign"); att.MessageID != nil { + t.Error("another user's attachment must never link (IDOR guard)") + } + if att, _ := database.GetAttachmentByID("att-legacy"); att.MessageID == nil { + t.Error("legacy NULL-uploader attachment should be claimable") + } +} + // ─── GetAttachmentsByMessageIDs ────────────────────────────────────────────── func TestGetAttachmentsByMessageIDs_Empty(t *testing.T) { diff --git a/Server/db/coverage_boost_test.go b/Server/db/coverage_boost_test.go index 4e7c7c9c..6202bafc 100644 --- a/Server/db/coverage_boost_test.go +++ b/Server/db/coverage_boost_test.go @@ -460,7 +460,7 @@ func TestDeleteOrphanedAttachments_KeepsLinked(t *testing.T) { // Create attachment and link it to a message. _ = database.CreateAttachment("linked-1", userID, "file.txt", "stored-linked.txt", "text/plain", 100, nil, nil) msgID, _ := database.CreateMessage(chID, userID, "with attachment", nil) - _, _ = database.LinkAttachmentsToMessage(msgID, []string{"linked-1"}) + _, _ = database.LinkAttachmentsToMessage(msgID, userID, []string{"linked-1"}) files, err := database.DeleteOrphanedAttachments("2099-01-01T00:00:00Z") if err != nil { diff --git a/Server/db/pgdbgen/admin.sql.go b/Server/db/pgdbgen/admin.sql.go deleted file mode 100644 index e3e4c257..00000000 --- a/Server/db/pgdbgen/admin.sql.go +++ /dev/null @@ -1,307 +0,0 @@ -//go:build postgres - -// Code generated by sqlc. DO NOT EDIT. -// versions: -// sqlc v1.30.0 -// source: admin.sql - -package pgdbgen - -import ( - "context" - - "github.com/jackc/pgx/v5/pgtype" -) - -const countActiveInvites = `-- name: CountActiveInvites :one -SELECT COUNT(*) FROM invites WHERE revoked = FALSE -` - -func (q *Queries) CountActiveInvites(ctx context.Context) (int64, error) { - row := q.db.QueryRow(ctx, countActiveInvites) - var count int64 - err := row.Scan(&count) - return count, err -} - -const countActiveMessages = `-- name: CountActiveMessages :one -SELECT COUNT(*) FROM messages WHERE deleted = FALSE -` - -func (q *Queries) CountActiveMessages(ctx context.Context) (int64, error) { - row := q.db.QueryRow(ctx, countActiveMessages) - var count int64 - err := row.Scan(&count) - return count, err -} - -const countChannels = `-- name: CountChannels :one -SELECT COUNT(*) FROM channels -` - -func (q *Queries) CountChannels(ctx context.Context) (int64, error) { - row := q.db.QueryRow(ctx, countChannels) - var count int64 - err := row.Scan(&count) - return count, err -} - -const forceLogoutUser = `-- name: ForceLogoutUser :exec -DELETE FROM sessions WHERE user_id = $1 -` - -func (q *Queries) ForceLogoutUser(ctx context.Context, userID int64) error { - _, err := q.db.Exec(ctx, forceLogoutUser, userID) - return err -} - -const getAllSettings = `-- name: GetAllSettings :many -SELECT key, value FROM settings -` - -func (q *Queries) GetAllSettings(ctx context.Context) ([]Setting, error) { - rows, err := q.db.Query(ctx, getAllSettings) - if err != nil { - return nil, err - } - defer rows.Close() - items := []Setting{} - for rows.Next() { - var i Setting - if err := rows.Scan(&i.Key, &i.Value); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - -const getAuditLog = `-- name: GetAuditLog :many -SELECT a.id, a.actor_id, COALESCE(u.username, '') AS actor_name, a.action, - a.target_type, a.target_id, a.detail, a.created_at -FROM audit_log a -LEFT JOIN users u ON u.id = a.actor_id -ORDER BY a.id DESC -LIMIT $1 OFFSET $2 -` - -type GetAuditLogParams struct { - Limit int32 `json:"limit"` - Offset int32 `json:"offset"` -} - -type GetAuditLogRow struct { - ID int64 `json:"id"` - ActorID int64 `json:"actorId"` - ActorName string `json:"actorName"` - Action string `json:"action"` - TargetType string `json:"targetType"` - TargetID int64 `json:"targetId"` - Detail string `json:"detail"` - CreatedAt pgtype.Timestamptz `json:"createdAt"` -} - -func (q *Queries) GetAuditLog(ctx context.Context, arg GetAuditLogParams) ([]GetAuditLogRow, error) { - rows, err := q.db.Query(ctx, getAuditLog, arg.Limit, arg.Offset) - if err != nil { - return nil, err - } - defer rows.Close() - items := []GetAuditLogRow{} - for rows.Next() { - var i GetAuditLogRow - if err := rows.Scan( - &i.ID, - &i.ActorID, - &i.ActorName, - &i.Action, - &i.TargetType, - &i.TargetID, - &i.Detail, - &i.CreatedAt, - ); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - -const getSetting = `-- name: GetSetting :one -SELECT value FROM settings WHERE key = $1 -` - -func (q *Queries) GetSetting(ctx context.Context, key string) (string, error) { - row := q.db.QueryRow(ctx, getSetting, key) - var value string - err := row.Scan(&value) - return value, err -} - -const getUserSessions = `-- name: GetUserSessions :many -SELECT id, user_id, token, device, ip_address, created_at, last_used, expires_at -FROM sessions WHERE user_id = $1 -ORDER BY created_at DESC -` - -func (q *Queries) GetUserSessions(ctx context.Context, userID int64) ([]Session, error) { - rows, err := q.db.Query(ctx, getUserSessions, userID) - if err != nil { - return nil, err - } - defer rows.Close() - items := []Session{} - for rows.Next() { - var i Session - if err := rows.Scan( - &i.ID, - &i.UserID, - &i.Token, - &i.Device, - &i.IpAddress, - &i.CreatedAt, - &i.LastUsed, - &i.ExpiresAt, - ); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - -const listAllUsers = `-- name: ListAllUsers :many -SELECT u.id, u.username, u.avatar, u.role_id, - u.status, u.created_at, u.last_seen, u.banned, u.ban_reason, u.ban_expires, - COALESCE(r.name, '') AS role_name -FROM users u -LEFT JOIN roles r ON r.id = u.role_id -ORDER BY u.id ASC -LIMIT $1 OFFSET $2 -` - -type ListAllUsersParams struct { - Limit int32 `json:"limit"` - Offset int32 `json:"offset"` -} - -type ListAllUsersRow struct { - ID int64 `json:"id"` - Username string `json:"username"` - Avatar *string `json:"avatar"` - RoleID int64 `json:"roleId"` - Status string `json:"status"` - CreatedAt pgtype.Timestamptz `json:"createdAt"` - LastSeen pgtype.Timestamptz `json:"lastSeen"` - Banned bool `json:"banned"` - BanReason *string `json:"banReason"` - BanExpires pgtype.Timestamptz `json:"banExpires"` - RoleName string `json:"roleName"` -} - -func (q *Queries) ListAllUsers(ctx context.Context, arg ListAllUsersParams) ([]ListAllUsersRow, error) { - rows, err := q.db.Query(ctx, listAllUsers, arg.Limit, arg.Offset) - if err != nil { - return nil, err - } - defer rows.Close() - items := []ListAllUsersRow{} - for rows.Next() { - var i ListAllUsersRow - if err := rows.Scan( - &i.ID, - &i.Username, - &i.Avatar, - &i.RoleID, - &i.Status, - &i.CreatedAt, - &i.LastSeen, - &i.Banned, - &i.BanReason, - &i.BanExpires, - &i.RoleName, - ); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - -const logAudit = `-- name: LogAudit :exec -INSERT INTO audit_log (actor_id, action, target_type, target_id, detail) -VALUES ($1, $2, $3, $4, $5) -` - -type LogAuditParams struct { - ActorID int64 `json:"actorId"` - Action string `json:"action"` - TargetType string `json:"targetType"` - TargetID int64 `json:"targetId"` - Detail string `json:"detail"` -} - -func (q *Queries) LogAudit(ctx context.Context, arg LogAuditParams) error { - _, err := q.db.Exec(ctx, logAudit, - arg.ActorID, - arg.Action, - arg.TargetType, - arg.TargetID, - arg.Detail, - ) - return err -} - -const setSetting = `-- name: SetSetting :exec -INSERT INTO settings (key, value) VALUES ($1, $2) -ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value -` - -type SetSettingParams struct { - Key string `json:"key"` - Value string `json:"value"` -} - -func (q *Queries) SetSetting(ctx context.Context, arg SetSettingParams) error { - _, err := q.db.Exec(ctx, setSetting, arg.Key, arg.Value) - return err -} - -const updateUserRole = `-- name: UpdateUserRole :exec -UPDATE users SET role_id = $1 WHERE id = $2 -` - -type UpdateUserRoleParams struct { - RoleID int64 `json:"roleId"` - ID int64 `json:"id"` -} - -func (q *Queries) UpdateUserRole(ctx context.Context, arg UpdateUserRoleParams) error { - _, err := q.db.Exec(ctx, updateUserRole, arg.RoleID, arg.ID) - return err -} - -const userCount = `-- name: UserCount :one - -SELECT COUNT(*) FROM users -` - -// PostgreSQL variants of the sqlite admin queries. -func (q *Queries) UserCount(ctx context.Context) (int64, error) { - row := q.db.QueryRow(ctx, userCount) - var count int64 - err := row.Scan(&count) - return count, err -} diff --git a/Server/db/pgdbgen/attachments.sql.go b/Server/db/pgdbgen/attachments.sql.go deleted file mode 100644 index f483e264..00000000 --- a/Server/db/pgdbgen/attachments.sql.go +++ /dev/null @@ -1,169 +0,0 @@ -//go:build postgres - -// Code generated by sqlc. DO NOT EDIT. -// versions: -// sqlc v1.30.0 -// source: attachments.sql - -package pgdbgen - -import ( - "context" - - "github.com/jackc/pgx/v5/pgtype" -) - -const createAttachment = `-- name: CreateAttachment :exec - -INSERT INTO attachments (id, uploader_id, filename, stored_as, mime_type, size, width, height) -VALUES ($1, $2, $3, $4, $5, $6, $7, $8) -` - -type CreateAttachmentParams struct { - ID string `json:"id"` - UploaderID *int64 `json:"uploaderId"` - Filename string `json:"filename"` - StoredAs string `json:"storedAs"` - MimeType string `json:"mimeType"` - Size int64 `json:"size"` - Width *int32 `json:"width"` - Height *int32 `json:"height"` -} - -// PostgreSQL variants of the sqlite attachments queries. -func (q *Queries) CreateAttachment(ctx context.Context, arg CreateAttachmentParams) error { - _, err := q.db.Exec(ctx, createAttachment, - arg.ID, - arg.UploaderID, - arg.Filename, - arg.StoredAs, - arg.MimeType, - arg.Size, - arg.Width, - arg.Height, - ) - return err -} - -const deleteAttachment = `-- name: DeleteAttachment :exec -DELETE FROM attachments WHERE id = $1 -` - -func (q *Queries) DeleteAttachment(ctx context.Context, id string) error { - _, err := q.db.Exec(ctx, deleteAttachment, id) - return err -} - -const deleteOrphanedAttachments = `-- name: DeleteOrphanedAttachments :many -DELETE FROM attachments WHERE message_id IS NULL AND uploaded_at < $1 RETURNING stored_as -` - -// Postgres timestamptz comparison — the caller passes a wall-clock time. -func (q *Queries) DeleteOrphanedAttachments(ctx context.Context, uploadedAt pgtype.Timestamptz) ([]string, error) { - rows, err := q.db.Query(ctx, deleteOrphanedAttachments, uploadedAt) - if err != nil { - return nil, err - } - defer rows.Close() - items := []string{} - for rows.Next() { - var stored_as string - if err := rows.Scan(&stored_as); err != nil { - return nil, err - } - items = append(items, stored_as) - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - -const getAttachmentByID = `-- name: GetAttachmentByID :one -SELECT id, message_id, filename, stored_as, mime_type, size, uploaded_at, uploader_id -FROM attachments WHERE id = $1 -` - -type GetAttachmentByIDRow struct { - ID string `json:"id"` - MessageID *int64 `json:"messageId"` - Filename string `json:"filename"` - StoredAs string `json:"storedAs"` - MimeType string `json:"mimeType"` - Size int64 `json:"size"` - UploadedAt pgtype.Timestamptz `json:"uploadedAt"` - UploaderID *int64 `json:"uploaderId"` -} - -func (q *Queries) GetAttachmentByID(ctx context.Context, id string) (GetAttachmentByIDRow, error) { - row := q.db.QueryRow(ctx, getAttachmentByID, id) - var i GetAttachmentByIDRow - err := row.Scan( - &i.ID, - &i.MessageID, - &i.Filename, - &i.StoredAs, - &i.MimeType, - &i.Size, - &i.UploadedAt, - &i.UploaderID, - ) - return i, err -} - -const getAttachmentWithChannel = `-- name: GetAttachmentWithChannel :one -SELECT a.id, a.message_id, a.filename, a.stored_as, a.mime_type, a.size, - a.uploaded_at, a.uploader_id, m.channel_id, c.type -FROM attachments a -LEFT JOIN messages m ON m.id = a.message_id -LEFT JOIN channels c ON c.id = m.channel_id -WHERE a.id = $1 -` - -type GetAttachmentWithChannelRow struct { - ID string `json:"id"` - MessageID *int64 `json:"messageId"` - Filename string `json:"filename"` - StoredAs string `json:"storedAs"` - MimeType string `json:"mimeType"` - Size int64 `json:"size"` - UploadedAt pgtype.Timestamptz `json:"uploadedAt"` - UploaderID *int64 `json:"uploaderId"` - ChannelID *int64 `json:"channelId"` - Type *string `json:"type"` -} - -func (q *Queries) GetAttachmentWithChannel(ctx context.Context, id string) (GetAttachmentWithChannelRow, error) { - row := q.db.QueryRow(ctx, getAttachmentWithChannel, id) - var i GetAttachmentWithChannelRow - err := row.Scan( - &i.ID, - &i.MessageID, - &i.Filename, - &i.StoredAs, - &i.MimeType, - &i.Size, - &i.UploadedAt, - &i.UploaderID, - &i.ChannelID, - &i.Type, - ) - return i, err -} - -const linkAttachmentToMessage = `-- name: LinkAttachmentToMessage :execrows -UPDATE attachments SET message_id = $1 WHERE id = $2 AND message_id IS NULL -` - -type LinkAttachmentToMessageParams struct { - MessageID *int64 `json:"messageId"` - ID string `json:"id"` -} - -func (q *Queries) LinkAttachmentToMessage(ctx context.Context, arg LinkAttachmentToMessageParams) (int64, error) { - result, err := q.db.Exec(ctx, linkAttachmentToMessage, arg.MessageID, arg.ID) - if err != nil { - return 0, err - } - return result.RowsAffected(), nil -} diff --git a/Server/db/pgdbgen/blocks.sql.go b/Server/db/pgdbgen/blocks.sql.go deleted file mode 100644 index 345a8812..00000000 --- a/Server/db/pgdbgen/blocks.sql.go +++ /dev/null @@ -1,86 +0,0 @@ -//go:build postgres - -// Code generated by sqlc. DO NOT EDIT. -// versions: -// sqlc v1.30.0 -// source: blocks.sql - -package pgdbgen - -import ( - "context" -) - -const blockUser = `-- name: BlockUser :exec - -INSERT INTO user_blocks (blocker_id, blocked_id) VALUES ($1, $2) -ON CONFLICT (blocker_id, blocked_id) DO NOTHING -` - -type BlockUserParams struct { - BlockerID int64 `json:"blockerId"` - BlockedID int64 `json:"blockedId"` -} - -// PostgreSQL variants of the sqlite user block queries. -// `INSERT OR IGNORE` becomes `INSERT ... ON CONFLICT DO NOTHING`. -func (q *Queries) BlockUser(ctx context.Context, arg BlockUserParams) error { - _, err := q.db.Exec(ctx, blockUser, arg.BlockerID, arg.BlockedID) - return err -} - -const isBlocked = `-- name: IsBlocked :one -SELECT 1 FROM user_blocks WHERE blocker_id = $1 AND blocked_id = $2 LIMIT 1 -` - -type IsBlockedParams struct { - BlockerID int64 `json:"blockerId"` - BlockedID int64 `json:"blockedId"` -} - -func (q *Queries) IsBlocked(ctx context.Context, arg IsBlockedParams) (int32, error) { - row := q.db.QueryRow(ctx, isBlocked, arg.BlockerID, arg.BlockedID) - var column_1 int32 - err := row.Scan(&column_1) - return column_1, err -} - -const isEitherBlocked = `-- name: IsEitherBlocked :one -SELECT 1 FROM user_blocks -WHERE (blocker_id = $1 AND blocked_id = $2) - OR (blocker_id = $3 AND blocked_id = $4) -LIMIT 1 -` - -type IsEitherBlockedParams struct { - BlockerID int64 `json:"blockerId"` - BlockedID int64 `json:"blockedId"` - BlockerID_2 int64 `json:"blockerId2"` - BlockedID_2 int64 `json:"blockedId2"` -} - -func (q *Queries) IsEitherBlocked(ctx context.Context, arg IsEitherBlockedParams) (int32, error) { - row := q.db.QueryRow(ctx, isEitherBlocked, - arg.BlockerID, - arg.BlockedID, - arg.BlockerID_2, - arg.BlockedID_2, - ) - var column_1 int32 - err := row.Scan(&column_1) - return column_1, err -} - -const unblockUser = `-- name: UnblockUser :exec -DELETE FROM user_blocks WHERE blocker_id = $1 AND blocked_id = $2 -` - -type UnblockUserParams struct { - BlockerID int64 `json:"blockerId"` - BlockedID int64 `json:"blockedId"` -} - -func (q *Queries) UnblockUser(ctx context.Context, arg UnblockUserParams) error { - _, err := q.db.Exec(ctx, unblockUser, arg.BlockerID, arg.BlockedID) - return err -} diff --git a/Server/db/pgdbgen/channels.sql.go b/Server/db/pgdbgen/channels.sql.go deleted file mode 100644 index 4b2c96fc..00000000 --- a/Server/db/pgdbgen/channels.sql.go +++ /dev/null @@ -1,381 +0,0 @@ -//go:build postgres - -// Code generated by sqlc. DO NOT EDIT. -// versions: -// sqlc v1.30.0 -// source: channels.sql - -package pgdbgen - -import ( - "context" - - "github.com/jackc/pgx/v5/pgtype" -) - -const adminUpdateChannel = `-- name: AdminUpdateChannel :exec -UPDATE channels -SET name = $1, topic = $2, slow_mode = $3, position = $4, archived = $5 -WHERE id = $6 -` - -type AdminUpdateChannelParams struct { - Name string `json:"name"` - Topic *string `json:"topic"` - SlowMode int32 `json:"slowMode"` - Position int32 `json:"position"` - Archived bool `json:"archived"` - ID int64 `json:"id"` -} - -func (q *Queries) AdminUpdateChannel(ctx context.Context, arg AdminUpdateChannelParams) error { - _, err := q.db.Exec(ctx, adminUpdateChannel, - arg.Name, - arg.Topic, - arg.SlowMode, - arg.Position, - arg.Archived, - arg.ID, - ) - return err -} - -const archiveChannel = `-- name: ArchiveChannel :exec -UPDATE channels SET archived = $1 WHERE id = $2 -` - -type ArchiveChannelParams struct { - Archived bool `json:"archived"` - ID int64 `json:"id"` -} - -func (q *Queries) ArchiveChannel(ctx context.Context, arg ArchiveChannelParams) error { - _, err := q.db.Exec(ctx, archiveChannel, arg.Archived, arg.ID) - return err -} - -const createChannel = `-- name: CreateChannel :one -INSERT INTO channels (name, type, category, topic, position) -VALUES ($1, $2, $3, $4, $5) -RETURNING id -` - -type CreateChannelParams struct { - Name string `json:"name"` - Type string `json:"type"` - Category *string `json:"category"` - Topic *string `json:"topic"` - Position int32 `json:"position"` -} - -func (q *Queries) CreateChannel(ctx context.Context, arg CreateChannelParams) (int64, error) { - row := q.db.QueryRow(ctx, createChannel, - arg.Name, - arg.Type, - arg.Category, - arg.Topic, - arg.Position, - ) - var id int64 - err := row.Scan(&id) - return id, err -} - -const deleteChannel = `-- name: DeleteChannel :exec -DELETE FROM channels WHERE id = $1 -` - -func (q *Queries) DeleteChannel(ctx context.Context, id int64) error { - _, err := q.db.Exec(ctx, deleteChannel, id) - return err -} - -const deleteChannelPermission = `-- name: DeleteChannelPermission :exec -DELETE FROM channel_overrides WHERE channel_id = $1 AND role_id = $2 -` - -type DeleteChannelPermissionParams struct { - ChannelID int64 `json:"channelId"` - RoleID int64 `json:"roleId"` -} - -func (q *Queries) DeleteChannelPermission(ctx context.Context, arg DeleteChannelPermissionParams) error { - _, err := q.db.Exec(ctx, deleteChannelPermission, arg.ChannelID, arg.RoleID) - return err -} - -const getChannel = `-- name: GetChannel :one -SELECT id, name, type, COALESCE(category, '') AS category, COALESCE(topic, '') AS topic, - position, slow_mode, archived, created_at, - COALESCE(voice_max_users, 0) AS voice_max_users, - voice_quality, - mixing_threshold, - COALESCE(voice_max_video, 0) AS voice_max_video -FROM channels WHERE id = $1 -` - -type GetChannelRow struct { - ID int64 `json:"id"` - Name string `json:"name"` - Type string `json:"type"` - Category string `json:"category"` - Topic string `json:"topic"` - Position int32 `json:"position"` - SlowMode int32 `json:"slowMode"` - Archived bool `json:"archived"` - CreatedAt pgtype.Timestamptz `json:"createdAt"` - VoiceMaxUsers int32 `json:"voiceMaxUsers"` - VoiceQuality *string `json:"voiceQuality"` - MixingThreshold *int32 `json:"mixingThreshold"` - VoiceMaxVideo int32 `json:"voiceMaxVideo"` -} - -func (q *Queries) GetChannel(ctx context.Context, id int64) (GetChannelRow, error) { - row := q.db.QueryRow(ctx, getChannel, id) - var i GetChannelRow - err := row.Scan( - &i.ID, - &i.Name, - &i.Type, - &i.Category, - &i.Topic, - &i.Position, - &i.SlowMode, - &i.Archived, - &i.CreatedAt, - &i.VoiceMaxUsers, - &i.VoiceQuality, - &i.MixingThreshold, - &i.VoiceMaxVideo, - ) - return i, err -} - -const getChannelPermission = `-- name: GetChannelPermission :one -SELECT allow, deny FROM channel_overrides WHERE channel_id = $1 AND role_id = $2 -` - -type GetChannelPermissionParams struct { - ChannelID int64 `json:"channelId"` - RoleID int64 `json:"roleId"` -} - -type GetChannelPermissionRow struct { - Allow int64 `json:"allow"` - Deny int64 `json:"deny"` -} - -func (q *Queries) GetChannelPermission(ctx context.Context, arg GetChannelPermissionParams) (GetChannelPermissionRow, error) { - row := q.db.QueryRow(ctx, getChannelPermission, arg.ChannelID, arg.RoleID) - var i GetChannelPermissionRow - err := row.Scan(&i.Allow, &i.Deny) - return i, err -} - -const getRoleChannelPermissions = `-- name: GetRoleChannelPermissions :many -SELECT channel_id, allow, deny FROM channel_overrides WHERE role_id = $1 -` - -type GetRoleChannelPermissionsRow struct { - ChannelID int64 `json:"channelId"` - Allow int64 `json:"allow"` - Deny int64 `json:"deny"` -} - -func (q *Queries) GetRoleChannelPermissions(ctx context.Context, roleID int64) ([]GetRoleChannelPermissionsRow, error) { - rows, err := q.db.Query(ctx, getRoleChannelPermissions, roleID) - if err != nil { - return nil, err - } - defer rows.Close() - items := []GetRoleChannelPermissionsRow{} - for rows.Next() { - var i GetRoleChannelPermissionsRow - if err := rows.Scan(&i.ChannelID, &i.Allow, &i.Deny); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - -const listChannels = `-- name: ListChannels :many - -SELECT id, name, type, COALESCE(category, '') AS category, COALESCE(topic, '') AS topic, - position, slow_mode, archived, created_at, - COALESCE(voice_max_users, 0) AS voice_max_users, - voice_quality, - mixing_threshold, - COALESCE(voice_max_video, 0) AS voice_max_video -FROM channels ORDER BY position ASC, id ASC -` - -type ListChannelsRow struct { - ID int64 `json:"id"` - Name string `json:"name"` - Type string `json:"type"` - Category string `json:"category"` - Topic string `json:"topic"` - Position int32 `json:"position"` - SlowMode int32 `json:"slowMode"` - Archived bool `json:"archived"` - CreatedAt pgtype.Timestamptz `json:"createdAt"` - VoiceMaxUsers int32 `json:"voiceMaxUsers"` - VoiceQuality *string `json:"voiceQuality"` - MixingThreshold *int32 `json:"mixingThreshold"` - VoiceMaxVideo int32 `json:"voiceMaxVideo"` -} - -// PostgreSQL variants of the sqlite channels queries. -func (q *Queries) ListChannels(ctx context.Context) ([]ListChannelsRow, error) { - rows, err := q.db.Query(ctx, listChannels) - if err != nil { - return nil, err - } - defer rows.Close() - items := []ListChannelsRow{} - for rows.Next() { - var i ListChannelsRow - if err := rows.Scan( - &i.ID, - &i.Name, - &i.Type, - &i.Category, - &i.Topic, - &i.Position, - &i.SlowMode, - &i.Archived, - &i.CreatedAt, - &i.VoiceMaxUsers, - &i.VoiceQuality, - &i.MixingThreshold, - &i.VoiceMaxVideo, - ); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - -const setChannelMixingThreshold = `-- name: SetChannelMixingThreshold :exec -UPDATE channels SET mixing_threshold = $1 WHERE id = $2 -` - -type SetChannelMixingThresholdParams struct { - MixingThreshold *int32 `json:"mixingThreshold"` - ID int64 `json:"id"` -} - -func (q *Queries) SetChannelMixingThreshold(ctx context.Context, arg SetChannelMixingThresholdParams) error { - _, err := q.db.Exec(ctx, setChannelMixingThreshold, arg.MixingThreshold, arg.ID) - return err -} - -const setChannelSlowMode = `-- name: SetChannelSlowMode :exec -UPDATE channels SET slow_mode = $1 WHERE id = $2 -` - -type SetChannelSlowModeParams struct { - SlowMode int32 `json:"slowMode"` - ID int64 `json:"id"` -} - -func (q *Queries) SetChannelSlowMode(ctx context.Context, arg SetChannelSlowModeParams) error { - _, err := q.db.Exec(ctx, setChannelSlowMode, arg.SlowMode, arg.ID) - return err -} - -const setChannelVoiceMaxUsers = `-- name: SetChannelVoiceMaxUsers :exec -UPDATE channels SET voice_max_users = $1 WHERE id = $2 -` - -type SetChannelVoiceMaxUsersParams struct { - VoiceMaxUsers int32 `json:"voiceMaxUsers"` - ID int64 `json:"id"` -} - -func (q *Queries) SetChannelVoiceMaxUsers(ctx context.Context, arg SetChannelVoiceMaxUsersParams) error { - _, err := q.db.Exec(ctx, setChannelVoiceMaxUsers, arg.VoiceMaxUsers, arg.ID) - return err -} - -const setChannelVoiceMaxVideo = `-- name: SetChannelVoiceMaxVideo :exec -UPDATE channels SET voice_max_video = $1 WHERE id = $2 -` - -type SetChannelVoiceMaxVideoParams struct { - VoiceMaxVideo int32 `json:"voiceMaxVideo"` - ID int64 `json:"id"` -} - -func (q *Queries) SetChannelVoiceMaxVideo(ctx context.Context, arg SetChannelVoiceMaxVideoParams) error { - _, err := q.db.Exec(ctx, setChannelVoiceMaxVideo, arg.VoiceMaxVideo, arg.ID) - return err -} - -const setChannelVoiceQuality = `-- name: SetChannelVoiceQuality :exec -UPDATE channels SET voice_quality = $1 WHERE id = $2 -` - -type SetChannelVoiceQualityParams struct { - VoiceQuality *string `json:"voiceQuality"` - ID int64 `json:"id"` -} - -func (q *Queries) SetChannelVoiceQuality(ctx context.Context, arg SetChannelVoiceQualityParams) error { - _, err := q.db.Exec(ctx, setChannelVoiceQuality, arg.VoiceQuality, arg.ID) - return err -} - -const updateChannel = `-- name: UpdateChannel :exec -UPDATE channels SET name = $1, topic = $2, slow_mode = $3 WHERE id = $4 -` - -type UpdateChannelParams struct { - Name string `json:"name"` - Topic *string `json:"topic"` - SlowMode int32 `json:"slowMode"` - ID int64 `json:"id"` -} - -func (q *Queries) UpdateChannel(ctx context.Context, arg UpdateChannelParams) error { - _, err := q.db.Exec(ctx, updateChannel, - arg.Name, - arg.Topic, - arg.SlowMode, - arg.ID, - ) - return err -} - -const upsertChannelPermission = `-- name: UpsertChannelPermission :exec -INSERT INTO channel_overrides (channel_id, role_id, allow, deny) -VALUES ($1, $2, $3, $4) -ON CONFLICT (channel_id, role_id) DO UPDATE SET - allow = EXCLUDED.allow, - deny = EXCLUDED.deny -` - -type UpsertChannelPermissionParams struct { - ChannelID int64 `json:"channelId"` - RoleID int64 `json:"roleId"` - Allow int64 `json:"allow"` - Deny int64 `json:"deny"` -} - -func (q *Queries) UpsertChannelPermission(ctx context.Context, arg UpsertChannelPermissionParams) error { - _, err := q.db.Exec(ctx, upsertChannelPermission, - arg.ChannelID, - arg.RoleID, - arg.Allow, - arg.Deny, - ) - return err -} diff --git a/Server/db/pgdbgen/db.go b/Server/db/pgdbgen/db.go deleted file mode 100644 index 09808de2..00000000 --- a/Server/db/pgdbgen/db.go +++ /dev/null @@ -1,34 +0,0 @@ -//go:build postgres - -// Code generated by sqlc. DO NOT EDIT. -// versions: -// sqlc v1.30.0 - -package pgdbgen - -import ( - "context" - - "github.com/jackc/pgx/v5" - "github.com/jackc/pgx/v5/pgconn" -) - -type DBTX interface { - Exec(context.Context, string, ...interface{}) (pgconn.CommandTag, error) - Query(context.Context, string, ...interface{}) (pgx.Rows, error) - QueryRow(context.Context, string, ...interface{}) pgx.Row -} - -func New(db DBTX) *Queries { - return &Queries{db: db} -} - -type Queries struct { - db DBTX -} - -func (q *Queries) WithTx(tx pgx.Tx) *Queries { - return &Queries{ - db: tx, - } -} diff --git a/Server/db/pgdbgen/dm.sql.go b/Server/db/pgdbgen/dm.sql.go deleted file mode 100644 index ab383391..00000000 --- a/Server/db/pgdbgen/dm.sql.go +++ /dev/null @@ -1,241 +0,0 @@ -//go:build postgres - -// Code generated by sqlc. DO NOT EDIT. -// versions: -// sqlc v1.30.0 -// source: dm.sql - -package pgdbgen - -import ( - "context" - - "github.com/jackc/pgx/v5/pgtype" -) - -const closeDM = `-- name: CloseDM :exec -DELETE FROM dm_open_state WHERE user_id = $1 AND channel_id = $2 -` - -type CloseDMParams struct { - UserID int64 `json:"userId"` - ChannelID int64 `json:"channelId"` -} - -func (q *Queries) CloseDM(ctx context.Context, arg CloseDMParams) error { - _, err := q.db.Exec(ctx, closeDM, arg.UserID, arg.ChannelID) - return err -} - -const findExistingDMChannel = `-- name: FindExistingDMChannel :one -SELECT dp1.channel_id -FROM dm_participants dp1 -JOIN dm_participants dp2 ON dp1.channel_id = dp2.channel_id -JOIN channels c ON c.id = dp1.channel_id -WHERE dp1.user_id = $1 AND dp2.user_id = $2 AND c.type = 'dm' -LIMIT 1 -` - -type FindExistingDMChannelParams struct { - UserID int64 `json:"userId"` - UserID_2 int64 `json:"userId2"` -} - -func (q *Queries) FindExistingDMChannel(ctx context.Context, arg FindExistingDMChannelParams) (int64, error) { - row := q.db.QueryRow(ctx, findExistingDMChannel, arg.UserID, arg.UserID_2) - var channel_id int64 - err := row.Scan(&channel_id) - return channel_id, err -} - -const getDMParticipantIDs = `-- name: GetDMParticipantIDs :many -SELECT user_id FROM dm_participants WHERE channel_id = $1 -` - -func (q *Queries) GetDMParticipantIDs(ctx context.Context, channelID int64) ([]int64, error) { - rows, err := q.db.Query(ctx, getDMParticipantIDs, channelID) - if err != nil { - return nil, err - } - defer rows.Close() - items := []int64{} - for rows.Next() { - var user_id int64 - if err := rows.Scan(&user_id); err != nil { - return nil, err - } - items = append(items, user_id) - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - -const getUserDMChannels = `-- name: GetUserDMChannels :many -SELECT - c.id AS channel_id, - u.id AS recipient_id, - u.username AS recipient_username, - COALESCE(u.avatar, '') AS recipient_avatar, - u.status AS recipient_status, - lm.id AS last_message_id, - COALESCE(lm.content, '') AS last_message, - COALESCE(lm.timestamp, dos.opened_at) AS last_message_at, - COUNT(CASE WHEN m_unread.id > COALESCE(rs.last_message_id, 0) - AND m_unread.deleted = FALSE THEN 1 END) AS unread_count -FROM dm_open_state dos -JOIN channels c ON c.id = dos.channel_id AND c.type = 'dm' -JOIN dm_participants dp ON dp.channel_id = c.id AND dp.user_id != $1 -JOIN users u ON u.id = dp.user_id -LEFT JOIN messages lm ON lm.id = ( - SELECT MAX(id) FROM messages WHERE channel_id = c.id AND deleted = FALSE -) -LEFT JOIN messages m_unread ON m_unread.channel_id = c.id -LEFT JOIN read_states rs ON rs.channel_id = c.id AND rs.user_id = $2 -WHERE dos.user_id = $3 -GROUP BY c.id, u.id, lm.id, lm.content, lm.timestamp, dos.opened_at -ORDER BY COALESCE(lm.timestamp, dos.opened_at) DESC -` - -type GetUserDMChannelsParams struct { - UserID int64 `json:"userId"` - UserID_2 int64 `json:"userId2"` - UserID_3 int64 `json:"userId3"` -} - -type GetUserDMChannelsRow struct { - ChannelID int64 `json:"channelId"` - RecipientID int64 `json:"recipientId"` - RecipientUsername string `json:"recipientUsername"` - RecipientAvatar string `json:"recipientAvatar"` - RecipientStatus string `json:"recipientStatus"` - LastMessageID *int64 `json:"lastMessageId"` - LastMessage string `json:"lastMessage"` - LastMessageAt pgtype.Timestamptz `json:"lastMessageAt"` - UnreadCount int64 `json:"unreadCount"` -} - -// For the "last message at" and "last message content" columns, sqlite -// COALESCEs to ” (empty string). Postgres TIMESTAMPTZ cannot COALESCE to an -// empty string, so we COALESCE to the dm_open_state.opened_at fallback and -// leave conversion to the store wrapper. -func (q *Queries) GetUserDMChannels(ctx context.Context, arg GetUserDMChannelsParams) ([]GetUserDMChannelsRow, error) { - rows, err := q.db.Query(ctx, getUserDMChannels, arg.UserID, arg.UserID_2, arg.UserID_3) - if err != nil { - return nil, err - } - defer rows.Close() - items := []GetUserDMChannelsRow{} - for rows.Next() { - var i GetUserDMChannelsRow - if err := rows.Scan( - &i.ChannelID, - &i.RecipientID, - &i.RecipientUsername, - &i.RecipientAvatar, - &i.RecipientStatus, - &i.LastMessageID, - &i.LastMessage, - &i.LastMessageAt, - &i.UnreadCount, - ); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - -const insertDMChannel = `-- name: InsertDMChannel :one - -INSERT INTO channels (name, type) VALUES ('', 'dm') RETURNING id -` - -// PostgreSQL variants of the sqlite DM queries. -// InsertDMChannel: sqlite uses :execresult (LastInsertId); postgres uses -// :one with RETURNING id. -// `INSERT OR IGNORE` becomes `INSERT ... ON CONFLICT DO NOTHING`. -func (q *Queries) InsertDMChannel(ctx context.Context) (int64, error) { - row := q.db.QueryRow(ctx, insertDMChannel) - var id int64 - err := row.Scan(&id) - return id, err -} - -const insertDMOpenState = `-- name: InsertDMOpenState :exec -INSERT INTO dm_open_state (user_id, channel_id) VALUES ($1, $2), ($3, $4) -ON CONFLICT (user_id, channel_id) DO NOTHING -` - -type InsertDMOpenStateParams struct { - UserID int64 `json:"userId"` - ChannelID int64 `json:"channelId"` - UserID_2 int64 `json:"userId2"` - ChannelID_2 int64 `json:"channelId2"` -} - -func (q *Queries) InsertDMOpenState(ctx context.Context, arg InsertDMOpenStateParams) error { - _, err := q.db.Exec(ctx, insertDMOpenState, - arg.UserID, - arg.ChannelID, - arg.UserID_2, - arg.ChannelID_2, - ) - return err -} - -const insertDMParticipants = `-- name: InsertDMParticipants :exec -INSERT INTO dm_participants (channel_id, user_id) VALUES ($1, $2), ($3, $4) -` - -type InsertDMParticipantsParams struct { - ChannelID int64 `json:"channelId"` - UserID int64 `json:"userId"` - ChannelID_2 int64 `json:"channelId2"` - UserID_2 int64 `json:"userId2"` -} - -func (q *Queries) InsertDMParticipants(ctx context.Context, arg InsertDMParticipantsParams) error { - _, err := q.db.Exec(ctx, insertDMParticipants, - arg.ChannelID, - arg.UserID, - arg.ChannelID_2, - arg.UserID_2, - ) - return err -} - -const isDMParticipant = `-- name: IsDMParticipant :one -SELECT user_id FROM dm_participants WHERE user_id = $1 AND channel_id = $2 -` - -type IsDMParticipantParams struct { - UserID int64 `json:"userId"` - ChannelID int64 `json:"channelId"` -} - -func (q *Queries) IsDMParticipant(ctx context.Context, arg IsDMParticipantParams) (int64, error) { - row := q.db.QueryRow(ctx, isDMParticipant, arg.UserID, arg.ChannelID) - var user_id int64 - err := row.Scan(&user_id) - return user_id, err -} - -const openDM = `-- name: OpenDM :exec -INSERT INTO dm_open_state (user_id, channel_id) VALUES ($1, $2) -ON CONFLICT (user_id, channel_id) DO NOTHING -` - -type OpenDMParams struct { - UserID int64 `json:"userId"` - ChannelID int64 `json:"channelId"` -} - -func (q *Queries) OpenDM(ctx context.Context, arg OpenDMParams) error { - _, err := q.db.Exec(ctx, openDM, arg.UserID, arg.ChannelID) - return err -} diff --git a/Server/db/pgdbgen/events.sql.go b/Server/db/pgdbgen/events.sql.go deleted file mode 100644 index d02eb71b..00000000 --- a/Server/db/pgdbgen/events.sql.go +++ /dev/null @@ -1,109 +0,0 @@ -//go:build postgres - -// Code generated by sqlc. DO NOT EDIT. -// versions: -// sqlc v1.30.0 -// source: events.sql - -package pgdbgen - -import ( - "context" - - "github.com/jackc/pgx/v5/pgtype" -) - -const getEventsSince = `-- name: GetEventsSince :many -SELECT seq, event_type, channel_id, payload, created_at -FROM events -WHERE seq > $1 -ORDER BY seq ASC -LIMIT $2 -` - -type GetEventsSinceParams struct { - Seq int64 `json:"seq"` - Limit int32 `json:"limit"` -} - -type GetEventsSinceRow struct { - Seq int64 `json:"seq"` - EventType string `json:"eventType"` - ChannelID int64 `json:"channelId"` - Payload []byte `json:"payload"` - CreatedAt pgtype.Timestamptz `json:"createdAt"` -} - -func (q *Queries) GetEventsSince(ctx context.Context, arg GetEventsSinceParams) ([]GetEventsSinceRow, error) { - rows, err := q.db.Query(ctx, getEventsSince, arg.Seq, arg.Limit) - if err != nil { - return nil, err - } - defer rows.Close() - items := []GetEventsSinceRow{} - for rows.Next() { - var i GetEventsSinceRow - if err := rows.Scan( - &i.Seq, - &i.EventType, - &i.ChannelID, - &i.Payload, - &i.CreatedAt, - ); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - -const getMaxEventSeq = `-- name: GetMaxEventSeq :one -SELECT COALESCE(MAX(seq), 0)::BIGINT FROM events -` - -func (q *Queries) GetMaxEventSeq(ctx context.Context) (int64, error) { - row := q.db.QueryRow(ctx, getMaxEventSeq) - var column_1 int64 - err := row.Scan(&column_1) - return column_1, err -} - -const persistEvent = `-- name: PersistEvent :exec -INSERT INTO events (seq, event_type, channel_id, payload) -VALUES ($1, $2, $3, $4) -` - -type PersistEventParams struct { - Seq int64 `json:"seq"` - EventType string `json:"eventType"` - ChannelID int64 `json:"channelId"` - Payload []byte `json:"payload"` -} - -// seq is supplied by the hub so the row seq matches the wrapped-payload seq. -// The schema's BIGSERIAL still owns the id column for inserts that omit seq, -// but PersistEvent always supplies an explicit value. -func (q *Queries) PersistEvent(ctx context.Context, arg PersistEventParams) error { - _, err := q.db.Exec(ctx, persistEvent, - arg.Seq, - arg.EventType, - arg.ChannelID, - arg.Payload, - ) - return err -} - -const pruneEventsOlderThan = `-- name: PruneEventsOlderThan :execrows -DELETE FROM events WHERE created_at < $1 -` - -func (q *Queries) PruneEventsOlderThan(ctx context.Context, createdAt pgtype.Timestamptz) (int64, error) { - result, err := q.db.Exec(ctx, pruneEventsOlderThan, createdAt) - if err != nil { - return 0, err - } - return result.RowsAffected(), nil -} diff --git a/Server/db/pgdbgen/invites.sql.go b/Server/db/pgdbgen/invites.sql.go deleted file mode 100644 index 32bac5a1..00000000 --- a/Server/db/pgdbgen/invites.sql.go +++ /dev/null @@ -1,140 +0,0 @@ -//go:build postgres - -// Code generated by sqlc. DO NOT EDIT. -// versions: -// sqlc v1.30.0 -// source: invites.sql - -package pgdbgen - -import ( - "context" - - "github.com/jackc/pgx/v5/pgtype" -) - -const createInvite = `-- name: CreateInvite :exec - -INSERT INTO invites (code, created_by, max_uses, expires_at) VALUES ($1, $2, $3, $4) -` - -type CreateInviteParams struct { - Code string `json:"code"` - CreatedBy int64 `json:"createdBy"` - MaxUses *int32 `json:"maxUses"` - ExpiresAt pgtype.Timestamptz `json:"expiresAt"` -} - -// PostgreSQL variants of the sqlite invites queries. -// The expiry check uses native timestamp comparison instead of sqlite's -// strftime('%s', …) trick. -func (q *Queries) CreateInvite(ctx context.Context, arg CreateInviteParams) error { - _, err := q.db.Exec(ctx, createInvite, - arg.Code, - arg.CreatedBy, - arg.MaxUses, - arg.ExpiresAt, - ) - return err -} - -const getInvite = `-- name: GetInvite :one -SELECT id, code, created_by, max_uses, use_count, expires_at, revoked, created_at -FROM invites WHERE code = $1 -` - -type GetInviteRow struct { - ID int64 `json:"id"` - Code string `json:"code"` - CreatedBy int64 `json:"createdBy"` - MaxUses *int32 `json:"maxUses"` - UseCount int32 `json:"useCount"` - ExpiresAt pgtype.Timestamptz `json:"expiresAt"` - Revoked bool `json:"revoked"` - CreatedAt pgtype.Timestamptz `json:"createdAt"` -} - -func (q *Queries) GetInvite(ctx context.Context, code string) (GetInviteRow, error) { - row := q.db.QueryRow(ctx, getInvite, code) - var i GetInviteRow - err := row.Scan( - &i.ID, - &i.Code, - &i.CreatedBy, - &i.MaxUses, - &i.UseCount, - &i.ExpiresAt, - &i.Revoked, - &i.CreatedAt, - ) - return i, err -} - -const listInvites = `-- name: ListInvites :many -SELECT id, code, created_by, max_uses, use_count, expires_at, revoked, created_at -FROM invites ORDER BY created_at DESC LIMIT 200 -` - -type ListInvitesRow struct { - ID int64 `json:"id"` - Code string `json:"code"` - CreatedBy int64 `json:"createdBy"` - MaxUses *int32 `json:"maxUses"` - UseCount int32 `json:"useCount"` - ExpiresAt pgtype.Timestamptz `json:"expiresAt"` - Revoked bool `json:"revoked"` - CreatedAt pgtype.Timestamptz `json:"createdAt"` -} - -func (q *Queries) ListInvites(ctx context.Context) ([]ListInvitesRow, error) { - rows, err := q.db.Query(ctx, listInvites) - if err != nil { - return nil, err - } - defer rows.Close() - items := []ListInvitesRow{} - for rows.Next() { - var i ListInvitesRow - if err := rows.Scan( - &i.ID, - &i.Code, - &i.CreatedBy, - &i.MaxUses, - &i.UseCount, - &i.ExpiresAt, - &i.Revoked, - &i.CreatedAt, - ); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - -const revokeInvite = `-- name: RevokeInvite :exec -UPDATE invites SET revoked = TRUE WHERE code = $1 -` - -func (q *Queries) RevokeInvite(ctx context.Context, code string) error { - _, err := q.db.Exec(ctx, revokeInvite, code) - return err -} - -const useInviteAtomic = `-- name: UseInviteAtomic :execrows -UPDATE invites SET use_count = use_count + 1 -WHERE code = $1 AND revoked = FALSE - AND (max_uses IS NULL OR use_count < max_uses) - AND (expires_at IS NULL OR expires_at > NOW()) -` - -func (q *Queries) UseInviteAtomic(ctx context.Context, code string) (int64, error) { - result, err := q.db.Exec(ctx, useInviteAtomic, code) - if err != nil { - return 0, err - } - return result.RowsAffected(), nil -} diff --git a/Server/db/pgdbgen/lockouts.sql.go b/Server/db/pgdbgen/lockouts.sql.go deleted file mode 100644 index d98de3ad..00000000 --- a/Server/db/pgdbgen/lockouts.sql.go +++ /dev/null @@ -1,74 +0,0 @@ -//go:build postgres - -// Code generated by sqlc. DO NOT EDIT. -// versions: -// sqlc v1.30.0 -// source: lockouts.sql - -package pgdbgen - -import ( - "context" - - "github.com/jackc/pgx/v5/pgtype" -) - -const cleanupExpiredLockouts = `-- name: CleanupExpiredLockouts :exec -DELETE FROM rate_lockouts WHERE expires_at <= $1 -` - -func (q *Queries) CleanupExpiredLockouts(ctx context.Context, expiresAt pgtype.Timestamptz) error { - _, err := q.db.Exec(ctx, cleanupExpiredLockouts, expiresAt) - return err -} - -const deleteLockout = `-- name: DeleteLockout :exec -DELETE FROM rate_lockouts WHERE key = $1 -` - -func (q *Queries) DeleteLockout(ctx context.Context, key string) error { - _, err := q.db.Exec(ctx, deleteLockout, key) - return err -} - -const loadActiveLockouts = `-- name: LoadActiveLockouts :many -SELECT key, expires_at FROM rate_lockouts WHERE expires_at > $1 -` - -func (q *Queries) LoadActiveLockouts(ctx context.Context, expiresAt pgtype.Timestamptz) ([]RateLockout, error) { - rows, err := q.db.Query(ctx, loadActiveLockouts, expiresAt) - if err != nil { - return nil, err - } - defer rows.Close() - items := []RateLockout{} - for rows.Next() { - var i RateLockout - if err := rows.Scan(&i.Key, &i.ExpiresAt); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - -const upsertLockout = `-- name: UpsertLockout :exec - -INSERT INTO rate_lockouts (key, expires_at) VALUES ($1, $2) -ON CONFLICT (key) DO UPDATE SET expires_at = EXCLUDED.expires_at -` - -type UpsertLockoutParams struct { - Key string `json:"key"` - ExpiresAt pgtype.Timestamptz `json:"expiresAt"` -} - -// PostgreSQL variants of the sqlite rate-lockout queries. -// `INSERT OR REPLACE` becomes `INSERT ... ON CONFLICT (key) DO UPDATE`. -func (q *Queries) UpsertLockout(ctx context.Context, arg UpsertLockoutParams) error { - _, err := q.db.Exec(ctx, upsertLockout, arg.Key, arg.ExpiresAt) - return err -} diff --git a/Server/db/pgdbgen/messages.sql.go b/Server/db/pgdbgen/messages.sql.go deleted file mode 100644 index bfdf17f1..00000000 --- a/Server/db/pgdbgen/messages.sql.go +++ /dev/null @@ -1,479 +0,0 @@ -//go:build postgres - -// Code generated by sqlc. DO NOT EDIT. -// versions: -// sqlc v1.30.0 -// source: messages.sql - -package pgdbgen - -import ( - "context" - - "github.com/jackc/pgx/v5/pgtype" -) - -const createMessage = `-- name: CreateMessage :one - -INSERT INTO messages (channel_id, user_id, content, reply_to) -VALUES ($1, $2, $3, $4) -RETURNING id -` - -type CreateMessageParams struct { - ChannelID int64 `json:"channelId"` - UserID int64 `json:"userId"` - Content string `json:"content"` - ReplyTo *int64 `json:"replyTo"` -} - -// PostgreSQL variants of the sqlite messages queries. -// `deleted = 0/1` and `pinned = 0/1` become FALSE/TRUE (columns are BOOLEAN). -// The FTS search queries are NOT included here: on postgres, messages.fts is -// a tsvector column with a GIN index (see migrations/postgres/001_initial_schema.sql) -// and FTS queries are hand-written in the postgres-specific store dispatch, -// mirroring how sqlite's FTS5 queries live in message_queries.go. -func (q *Queries) CreateMessage(ctx context.Context, arg CreateMessageParams) (int64, error) { - row := q.db.QueryRow(ctx, createMessage, - arg.ChannelID, - arg.UserID, - arg.Content, - arg.ReplyTo, - ) - var id int64 - err := row.Scan(&id) - return id, err -} - -const editMessageContent = `-- name: EditMessageContent :exec -UPDATE messages SET content = $1, edited_at = NOW() WHERE id = $2 -` - -type EditMessageContentParams struct { - Content string `json:"content"` - ID int64 `json:"id"` -} - -func (q *Queries) EditMessageContent(ctx context.Context, arg EditMessageContentParams) error { - _, err := q.db.Exec(ctx, editMessageContent, arg.Content, arg.ID) - return err -} - -const getChannelUnreadCounts = `-- name: GetChannelUnreadCounts :many -SELECT c.id, - COALESCE(MAX(m.id), 0)::BIGINT AS last_msg_id, - COUNT(CASE WHEN m.id > COALESCE(rs.last_message_id, 0) AND m.deleted = FALSE THEN 1 END) AS unread -FROM channels c -LEFT JOIN messages m ON m.channel_id = c.id AND m.deleted = FALSE -LEFT JOIN read_states rs ON rs.channel_id = c.id AND rs.user_id = $1 -WHERE c.type = 'text' -GROUP BY c.id -` - -type GetChannelUnreadCountsRow struct { - ID int64 `json:"id"` - LastMsgID int64 `json:"lastMsgId"` - Unread int64 `json:"unread"` -} - -func (q *Queries) GetChannelUnreadCounts(ctx context.Context, userID int64) ([]GetChannelUnreadCountsRow, error) { - rows, err := q.db.Query(ctx, getChannelUnreadCounts, userID) - if err != nil { - return nil, err - } - defer rows.Close() - items := []GetChannelUnreadCountsRow{} - for rows.Next() { - var i GetChannelUnreadCountsRow - if err := rows.Scan(&i.ID, &i.LastMsgID, &i.Unread); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - -const getLatestMessageID = `-- name: GetLatestMessageID :one -SELECT COALESCE(MAX(id), 0)::BIGINT FROM messages WHERE channel_id = $1 AND deleted = FALSE -` - -func (q *Queries) GetLatestMessageID(ctx context.Context, channelID int64) (int64, error) { - row := q.db.QueryRow(ctx, getLatestMessageID, channelID) - var column_1 int64 - err := row.Scan(&column_1) - return column_1, err -} - -const getMessage = `-- name: GetMessage :one -SELECT id, channel_id, user_id, content, reply_to, edited_at, deleted, pinned, timestamp -FROM messages WHERE id = $1 -` - -type GetMessageRow struct { - ID int64 `json:"id"` - ChannelID int64 `json:"channelId"` - UserID int64 `json:"userId"` - Content string `json:"content"` - ReplyTo *int64 `json:"replyTo"` - EditedAt pgtype.Timestamptz `json:"editedAt"` - Deleted bool `json:"deleted"` - Pinned bool `json:"pinned"` - Timestamp pgtype.Timestamptz `json:"timestamp"` -} - -func (q *Queries) GetMessage(ctx context.Context, id int64) (GetMessageRow, error) { - row := q.db.QueryRow(ctx, getMessage, id) - var i GetMessageRow - err := row.Scan( - &i.ID, - &i.ChannelID, - &i.UserID, - &i.Content, - &i.ReplyTo, - &i.EditedAt, - &i.Deleted, - &i.Pinned, - &i.Timestamp, - ) - return i, err -} - -const getMessagesByChannel = `-- name: GetMessagesByChannel :many -SELECT m.id, m.channel_id, m.user_id, m.content, m.reply_to, - m.edited_at, m.deleted, m.pinned, m.timestamp, - u.username, u.avatar -FROM messages m JOIN users u ON m.user_id = u.id -WHERE m.channel_id = $1 AND m.deleted = FALSE -ORDER BY m.id DESC LIMIT $2 -` - -type GetMessagesByChannelParams struct { - ChannelID int64 `json:"channelId"` - Limit int32 `json:"limit"` -} - -type GetMessagesByChannelRow struct { - ID int64 `json:"id"` - ChannelID int64 `json:"channelId"` - UserID int64 `json:"userId"` - Content string `json:"content"` - ReplyTo *int64 `json:"replyTo"` - EditedAt pgtype.Timestamptz `json:"editedAt"` - Deleted bool `json:"deleted"` - Pinned bool `json:"pinned"` - Timestamp pgtype.Timestamptz `json:"timestamp"` - Username string `json:"username"` - Avatar *string `json:"avatar"` -} - -func (q *Queries) GetMessagesByChannel(ctx context.Context, arg GetMessagesByChannelParams) ([]GetMessagesByChannelRow, error) { - rows, err := q.db.Query(ctx, getMessagesByChannel, arg.ChannelID, arg.Limit) - if err != nil { - return nil, err - } - defer rows.Close() - items := []GetMessagesByChannelRow{} - for rows.Next() { - var i GetMessagesByChannelRow - if err := rows.Scan( - &i.ID, - &i.ChannelID, - &i.UserID, - &i.Content, - &i.ReplyTo, - &i.EditedAt, - &i.Deleted, - &i.Pinned, - &i.Timestamp, - &i.Username, - &i.Avatar, - ); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - -const getMessagesByChannelBeforeCursor = `-- name: GetMessagesByChannelBeforeCursor :many -SELECT m.id, m.channel_id, m.user_id, m.content, m.reply_to, - m.edited_at, m.deleted, m.pinned, m.timestamp, - u.username, u.avatar -FROM messages m JOIN users u ON m.user_id = u.id -WHERE m.channel_id = $1 AND m.id < $2 AND m.deleted = FALSE -ORDER BY m.id DESC LIMIT $3 -` - -type GetMessagesByChannelBeforeCursorParams struct { - ChannelID int64 `json:"channelId"` - ID int64 `json:"id"` - Limit int32 `json:"limit"` -} - -type GetMessagesByChannelBeforeCursorRow struct { - ID int64 `json:"id"` - ChannelID int64 `json:"channelId"` - UserID int64 `json:"userId"` - Content string `json:"content"` - ReplyTo *int64 `json:"replyTo"` - EditedAt pgtype.Timestamptz `json:"editedAt"` - Deleted bool `json:"deleted"` - Pinned bool `json:"pinned"` - Timestamp pgtype.Timestamptz `json:"timestamp"` - Username string `json:"username"` - Avatar *string `json:"avatar"` -} - -func (q *Queries) GetMessagesByChannelBeforeCursor(ctx context.Context, arg GetMessagesByChannelBeforeCursorParams) ([]GetMessagesByChannelBeforeCursorRow, error) { - rows, err := q.db.Query(ctx, getMessagesByChannelBeforeCursor, arg.ChannelID, arg.ID, arg.Limit) - if err != nil { - return nil, err - } - defer rows.Close() - items := []GetMessagesByChannelBeforeCursorRow{} - for rows.Next() { - var i GetMessagesByChannelBeforeCursorRow - if err := rows.Scan( - &i.ID, - &i.ChannelID, - &i.UserID, - &i.Content, - &i.ReplyTo, - &i.EditedAt, - &i.Deleted, - &i.Pinned, - &i.Timestamp, - &i.Username, - &i.Avatar, - ); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - -const getMessagesForAPI = `-- name: GetMessagesForAPI :many -SELECT m.id, m.channel_id, m.user_id, u.username, u.avatar, - m.content, m.reply_to, m.edited_at, m.deleted, m.pinned, m.timestamp -FROM messages m JOIN users u ON m.user_id = u.id -WHERE m.channel_id = $1 AND m.deleted = FALSE -ORDER BY m.id DESC LIMIT $2 -` - -type GetMessagesForAPIParams struct { - ChannelID int64 `json:"channelId"` - Limit int32 `json:"limit"` -} - -type GetMessagesForAPIRow struct { - ID int64 `json:"id"` - ChannelID int64 `json:"channelId"` - UserID int64 `json:"userId"` - Username string `json:"username"` - Avatar *string `json:"avatar"` - Content string `json:"content"` - ReplyTo *int64 `json:"replyTo"` - EditedAt pgtype.Timestamptz `json:"editedAt"` - Deleted bool `json:"deleted"` - Pinned bool `json:"pinned"` - Timestamp pgtype.Timestamptz `json:"timestamp"` -} - -func (q *Queries) GetMessagesForAPI(ctx context.Context, arg GetMessagesForAPIParams) ([]GetMessagesForAPIRow, error) { - rows, err := q.db.Query(ctx, getMessagesForAPI, arg.ChannelID, arg.Limit) - if err != nil { - return nil, err - } - defer rows.Close() - items := []GetMessagesForAPIRow{} - for rows.Next() { - var i GetMessagesForAPIRow - if err := rows.Scan( - &i.ID, - &i.ChannelID, - &i.UserID, - &i.Username, - &i.Avatar, - &i.Content, - &i.ReplyTo, - &i.EditedAt, - &i.Deleted, - &i.Pinned, - &i.Timestamp, - ); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - -const getMessagesForAPIBeforeCursor = `-- name: GetMessagesForAPIBeforeCursor :many -SELECT m.id, m.channel_id, m.user_id, u.username, u.avatar, - m.content, m.reply_to, m.edited_at, m.deleted, m.pinned, m.timestamp -FROM messages m JOIN users u ON m.user_id = u.id -WHERE m.channel_id = $1 AND m.id < $2 AND m.deleted = FALSE -ORDER BY m.id DESC LIMIT $3 -` - -type GetMessagesForAPIBeforeCursorParams struct { - ChannelID int64 `json:"channelId"` - ID int64 `json:"id"` - Limit int32 `json:"limit"` -} - -type GetMessagesForAPIBeforeCursorRow struct { - ID int64 `json:"id"` - ChannelID int64 `json:"channelId"` - UserID int64 `json:"userId"` - Username string `json:"username"` - Avatar *string `json:"avatar"` - Content string `json:"content"` - ReplyTo *int64 `json:"replyTo"` - EditedAt pgtype.Timestamptz `json:"editedAt"` - Deleted bool `json:"deleted"` - Pinned bool `json:"pinned"` - Timestamp pgtype.Timestamptz `json:"timestamp"` -} - -func (q *Queries) GetMessagesForAPIBeforeCursor(ctx context.Context, arg GetMessagesForAPIBeforeCursorParams) ([]GetMessagesForAPIBeforeCursorRow, error) { - rows, err := q.db.Query(ctx, getMessagesForAPIBeforeCursor, arg.ChannelID, arg.ID, arg.Limit) - if err != nil { - return nil, err - } - defer rows.Close() - items := []GetMessagesForAPIBeforeCursorRow{} - for rows.Next() { - var i GetMessagesForAPIBeforeCursorRow - if err := rows.Scan( - &i.ID, - &i.ChannelID, - &i.UserID, - &i.Username, - &i.Avatar, - &i.Content, - &i.ReplyTo, - &i.EditedAt, - &i.Deleted, - &i.Pinned, - &i.Timestamp, - ); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - -const getPinnedMessageRows = `-- name: GetPinnedMessageRows :many -SELECT m.id, m.channel_id, m.user_id, u.username, u.avatar, - m.content, m.reply_to, m.edited_at, m.deleted, m.pinned, m.timestamp -FROM messages m JOIN users u ON m.user_id = u.id -WHERE m.channel_id = $1 AND m.pinned = TRUE AND m.deleted = FALSE -ORDER BY m.id DESC -` - -type GetPinnedMessageRowsRow struct { - ID int64 `json:"id"` - ChannelID int64 `json:"channelId"` - UserID int64 `json:"userId"` - Username string `json:"username"` - Avatar *string `json:"avatar"` - Content string `json:"content"` - ReplyTo *int64 `json:"replyTo"` - EditedAt pgtype.Timestamptz `json:"editedAt"` - Deleted bool `json:"deleted"` - Pinned bool `json:"pinned"` - Timestamp pgtype.Timestamptz `json:"timestamp"` -} - -func (q *Queries) GetPinnedMessageRows(ctx context.Context, channelID int64) ([]GetPinnedMessageRowsRow, error) { - rows, err := q.db.Query(ctx, getPinnedMessageRows, channelID) - if err != nil { - return nil, err - } - defer rows.Close() - items := []GetPinnedMessageRowsRow{} - for rows.Next() { - var i GetPinnedMessageRowsRow - if err := rows.Scan( - &i.ID, - &i.ChannelID, - &i.UserID, - &i.Username, - &i.Avatar, - &i.Content, - &i.ReplyTo, - &i.EditedAt, - &i.Deleted, - &i.Pinned, - &i.Timestamp, - ); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - -const setMessagePinned = `-- name: SetMessagePinned :execrows -UPDATE messages SET pinned = $1 WHERE id = $2 AND deleted = FALSE -` - -type SetMessagePinnedParams struct { - Pinned bool `json:"pinned"` - ID int64 `json:"id"` -} - -func (q *Queries) SetMessagePinned(ctx context.Context, arg SetMessagePinnedParams) (int64, error) { - result, err := q.db.Exec(ctx, setMessagePinned, arg.Pinned, arg.ID) - if err != nil { - return 0, err - } - return result.RowsAffected(), nil -} - -const softDeleteMessage = `-- name: SoftDeleteMessage :exec -UPDATE messages SET deleted = TRUE WHERE id = $1 -` - -func (q *Queries) SoftDeleteMessage(ctx context.Context, id int64) error { - _, err := q.db.Exec(ctx, softDeleteMessage, id) - return err -} - -const updateReadState = `-- name: UpdateReadState :exec -INSERT INTO read_states (user_id, channel_id, last_message_id) -VALUES ($1, $2, $3) -ON CONFLICT (user_id, channel_id) DO UPDATE SET last_message_id = EXCLUDED.last_message_id -` - -type UpdateReadStateParams struct { - UserID int64 `json:"userId"` - ChannelID int64 `json:"channelId"` - LastMessageID int64 `json:"lastMessageId"` -} - -func (q *Queries) UpdateReadState(ctx context.Context, arg UpdateReadStateParams) error { - _, err := q.db.Exec(ctx, updateReadState, arg.UserID, arg.ChannelID, arg.LastMessageID) - return err -} diff --git a/Server/db/pgdbgen/models.go b/Server/db/pgdbgen/models.go deleted file mode 100644 index c689ed8b..00000000 --- a/Server/db/pgdbgen/models.go +++ /dev/null @@ -1,218 +0,0 @@ -//go:build postgres - -// Code generated by sqlc. DO NOT EDIT. -// versions: -// sqlc v1.30.0 - -package pgdbgen - -import ( - "github.com/jackc/pgx/v5/pgtype" -) - -type Attachment struct { - ID string `json:"id"` - MessageID *int64 `json:"messageId"` - Filename string `json:"filename"` - StoredAs string `json:"storedAs"` - MimeType string `json:"mimeType"` - Size int64 `json:"size"` - UploadedAt pgtype.Timestamptz `json:"uploadedAt"` - Width *int32 `json:"width"` - Height *int32 `json:"height"` - UploaderID *int64 `json:"uploaderId"` -} - -type AuditLog struct { - ID int64 `json:"id"` - ActorID int64 `json:"actorId"` - Action string `json:"action"` - TargetType string `json:"targetType"` - TargetID int64 `json:"targetId"` - Detail string `json:"detail"` - CreatedAt pgtype.Timestamptz `json:"createdAt"` -} - -type Channel struct { - ID int64 `json:"id"` - Name string `json:"name"` - Type string `json:"type"` - Category *string `json:"category"` - Topic *string `json:"topic"` - Position int32 `json:"position"` - SlowMode int32 `json:"slowMode"` - Archived bool `json:"archived"` - CreatedAt pgtype.Timestamptz `json:"createdAt"` - VoiceMaxUsers int32 `json:"voiceMaxUsers"` - VoiceQuality *string `json:"voiceQuality"` - MixingThreshold *int32 `json:"mixingThreshold"` - VoiceMaxVideo int32 `json:"voiceMaxVideo"` -} - -type ChannelOverride struct { - ID int64 `json:"id"` - ChannelID int64 `json:"channelId"` - RoleID int64 `json:"roleId"` - Allow int64 `json:"allow"` - Deny int64 `json:"deny"` -} - -type DmOpenState struct { - UserID int64 `json:"userId"` - ChannelID int64 `json:"channelId"` - OpenedAt pgtype.Timestamptz `json:"openedAt"` -} - -type DmParticipant struct { - ChannelID int64 `json:"channelId"` - UserID int64 `json:"userId"` -} - -type Emoji struct { - ID int64 `json:"id"` - Shortcode string `json:"shortcode"` - Filename string `json:"filename"` - UploadedBy int64 `json:"uploadedBy"` - CreatedAt pgtype.Timestamptz `json:"createdAt"` -} - -type Event struct { - Seq int64 `json:"seq"` - EventType string `json:"eventType"` - Payload []byte `json:"payload"` - ChannelID int64 `json:"channelId"` - CreatedAt pgtype.Timestamptz `json:"createdAt"` -} - -type Invite struct { - ID int64 `json:"id"` - Code string `json:"code"` - CreatedBy int64 `json:"createdBy"` - RedeemedBy *int64 `json:"redeemedBy"` - MaxUses *int32 `json:"maxUses"` - UseCount int32 `json:"useCount"` - ExpiresAt pgtype.Timestamptz `json:"expiresAt"` - CreatedAt pgtype.Timestamptz `json:"createdAt"` - Revoked bool `json:"revoked"` -} - -type LoginAttempt struct { - ID int64 `json:"id"` - IpAddress string `json:"ipAddress"` - Username *string `json:"username"` - Success bool `json:"success"` - Timestamp pgtype.Timestamptz `json:"timestamp"` -} - -type Message struct { - ID int64 `json:"id"` - ChannelID int64 `json:"channelId"` - UserID int64 `json:"userId"` - Content string `json:"content"` - ReplyTo *int64 `json:"replyTo"` - EditedAt pgtype.Timestamptz `json:"editedAt"` - Deleted bool `json:"deleted"` - Pinned bool `json:"pinned"` - Timestamp pgtype.Timestamptz `json:"timestamp"` - Fts interface{} `json:"fts"` -} - -type Plugin struct { - ID int64 `json:"id"` - Name string `json:"name"` - Version string `json:"version"` - Enabled bool `json:"enabled"` - ManifestJson string `json:"manifestJson"` - InstalledAt pgtype.Timestamptz `json:"installedAt"` -} - -type PluginKv struct { - PluginID int64 `json:"pluginId"` - Key string `json:"key"` - Value []byte `json:"value"` -} - -type RateLockout struct { - Key string `json:"key"` - ExpiresAt pgtype.Timestamptz `json:"expiresAt"` -} - -type Reaction struct { - ID int64 `json:"id"` - MessageID int64 `json:"messageId"` - UserID int64 `json:"userId"` - Emoji string `json:"emoji"` -} - -type ReadState struct { - UserID int64 `json:"userId"` - ChannelID int64 `json:"channelId"` - LastMessageID int64 `json:"lastMessageId"` - MentionCount int32 `json:"mentionCount"` -} - -type Role struct { - ID int64 `json:"id"` - Name string `json:"name"` - Color *string `json:"color"` - Permissions int64 `json:"permissions"` - Position int32 `json:"position"` - IsDefault bool `json:"isDefault"` -} - -type Session struct { - ID int64 `json:"id"` - UserID int64 `json:"userId"` - Token string `json:"token"` - Device *string `json:"device"` - IpAddress *string `json:"ipAddress"` - CreatedAt pgtype.Timestamptz `json:"createdAt"` - LastUsed pgtype.Timestamptz `json:"lastUsed"` - ExpiresAt pgtype.Timestamptz `json:"expiresAt"` -} - -type Setting struct { - Key string `json:"key"` - Value string `json:"value"` -} - -type Sound struct { - ID int64 `json:"id"` - Name string `json:"name"` - Filename string `json:"filename"` - DurationMs int32 `json:"durationMs"` - UploadedBy int64 `json:"uploadedBy"` - CreatedAt pgtype.Timestamptz `json:"createdAt"` -} - -type User struct { - ID int64 `json:"id"` - Username string `json:"username"` - Password string `json:"password"` - Avatar *string `json:"avatar"` - RoleID int64 `json:"roleId"` - TotpSecret *string `json:"totpSecret"` - Status string `json:"status"` - CreatedAt pgtype.Timestamptz `json:"createdAt"` - LastSeen pgtype.Timestamptz `json:"lastSeen"` - Banned bool `json:"banned"` - BanReason *string `json:"banReason"` - BanExpires pgtype.Timestamptz `json:"banExpires"` -} - -type UserBlock struct { - BlockerID int64 `json:"blockerId"` - BlockedID int64 `json:"blockedId"` - CreatedAt pgtype.Timestamptz `json:"createdAt"` -} - -type VoiceState struct { - UserID int64 `json:"userId"` - ChannelID int64 `json:"channelId"` - Muted bool `json:"muted"` - Deafened bool `json:"deafened"` - Speaking bool `json:"speaking"` - JoinedAt pgtype.Timestamptz `json:"joinedAt"` - Camera bool `json:"camera"` - Screenshare bool `json:"screenshare"` -} diff --git a/Server/db/pgdbgen/plugins.sql.go b/Server/db/pgdbgen/plugins.sql.go deleted file mode 100644 index 3f24e863..00000000 --- a/Server/db/pgdbgen/plugins.sql.go +++ /dev/null @@ -1,175 +0,0 @@ -//go:build postgres - -// Code generated by sqlc. DO NOT EDIT. -// versions: -// sqlc v1.30.0 -// source: plugins.sql - -package pgdbgen - -import ( - "context" -) - -const disablePlugin = `-- name: DisablePlugin :exec -UPDATE plugins SET enabled = FALSE WHERE id = $1 -` - -func (q *Queries) DisablePlugin(ctx context.Context, id int64) error { - _, err := q.db.Exec(ctx, disablePlugin, id) - return err -} - -const enablePlugin = `-- name: EnablePlugin :exec -UPDATE plugins SET enabled = TRUE WHERE id = $1 -` - -func (q *Queries) EnablePlugin(ctx context.Context, id int64) error { - _, err := q.db.Exec(ctx, enablePlugin, id) - return err -} - -const getPlugin = `-- name: GetPlugin :one -SELECT id, name, version, enabled, manifest_json, installed_at FROM plugins WHERE id = $1 -` - -func (q *Queries) GetPlugin(ctx context.Context, id int64) (Plugin, error) { - row := q.db.QueryRow(ctx, getPlugin, id) - var i Plugin - err := row.Scan( - &i.ID, - &i.Name, - &i.Version, - &i.Enabled, - &i.ManifestJson, - &i.InstalledAt, - ) - return i, err -} - -const getPluginByName = `-- name: GetPluginByName :one -SELECT id, name, version, enabled, manifest_json, installed_at FROM plugins WHERE name = $1 -` - -func (q *Queries) GetPluginByName(ctx context.Context, name string) (Plugin, error) { - row := q.db.QueryRow(ctx, getPluginByName, name) - var i Plugin - err := row.Scan( - &i.ID, - &i.Name, - &i.Version, - &i.Enabled, - &i.ManifestJson, - &i.InstalledAt, - ) - return i, err -} - -const installPlugin = `-- name: InstallPlugin :one -INSERT INTO plugins (name, version, manifest_json) -VALUES ($1, $2, $3) -ON CONFLICT (name) DO UPDATE - SET version = excluded.version, - manifest_json = excluded.manifest_json -RETURNING id -` - -type InstallPluginParams struct { - Name string `json:"name"` - Version string `json:"version"` - ManifestJson string `json:"manifestJson"` -} - -func (q *Queries) InstallPlugin(ctx context.Context, arg InstallPluginParams) (int64, error) { - row := q.db.QueryRow(ctx, installPlugin, arg.Name, arg.Version, arg.ManifestJson) - var id int64 - err := row.Scan(&id) - return id, err -} - -const listPlugins = `-- name: ListPlugins :many -SELECT id, name, version, enabled, manifest_json, installed_at FROM plugins ORDER BY name -` - -func (q *Queries) ListPlugins(ctx context.Context) ([]Plugin, error) { - rows, err := q.db.Query(ctx, listPlugins) - if err != nil { - return nil, err - } - defer rows.Close() - items := []Plugin{} - for rows.Next() { - var i Plugin - if err := rows.Scan( - &i.ID, - &i.Name, - &i.Version, - &i.Enabled, - &i.ManifestJson, - &i.InstalledAt, - ); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - -const pluginKVDelete = `-- name: PluginKVDelete :exec -DELETE FROM plugin_kv WHERE plugin_id = $1 AND key = $2 -` - -type PluginKVDeleteParams struct { - PluginID int64 `json:"pluginId"` - Key string `json:"key"` -} - -func (q *Queries) PluginKVDelete(ctx context.Context, arg PluginKVDeleteParams) error { - _, err := q.db.Exec(ctx, pluginKVDelete, arg.PluginID, arg.Key) - return err -} - -const pluginKVGet = `-- name: PluginKVGet :one -SELECT value FROM plugin_kv WHERE plugin_id = $1 AND key = $2 -` - -type PluginKVGetParams struct { - PluginID int64 `json:"pluginId"` - Key string `json:"key"` -} - -func (q *Queries) PluginKVGet(ctx context.Context, arg PluginKVGetParams) ([]byte, error) { - row := q.db.QueryRow(ctx, pluginKVGet, arg.PluginID, arg.Key) - var value []byte - err := row.Scan(&value) - return value, err -} - -const pluginKVSet = `-- name: PluginKVSet :exec -INSERT INTO plugin_kv (plugin_id, key, value) -VALUES ($1, $2, $3) -ON CONFLICT (plugin_id, key) DO UPDATE SET value = excluded.value -` - -type PluginKVSetParams struct { - PluginID int64 `json:"pluginId"` - Key string `json:"key"` - Value []byte `json:"value"` -} - -func (q *Queries) PluginKVSet(ctx context.Context, arg PluginKVSetParams) error { - _, err := q.db.Exec(ctx, pluginKVSet, arg.PluginID, arg.Key, arg.Value) - return err -} - -const uninstallPlugin = `-- name: UninstallPlugin :exec -DELETE FROM plugins WHERE id = $1 -` - -func (q *Queries) UninstallPlugin(ctx context.Context, id int64) error { - _, err := q.db.Exec(ctx, uninstallPlugin, id) - return err -} diff --git a/Server/db/pgdbgen/profile.sql.go b/Server/db/pgdbgen/profile.sql.go deleted file mode 100644 index 4da8d7d3..00000000 --- a/Server/db/pgdbgen/profile.sql.go +++ /dev/null @@ -1,48 +0,0 @@ -//go:build postgres - -// Code generated by sqlc. DO NOT EDIT. -// versions: -// sqlc v1.30.0 -// source: profile.sql - -package pgdbgen - -import ( - "context" -) - -const updateUserPassword = `-- name: UpdateUserPassword :exec -UPDATE users SET password = $1 WHERE id = $2 -` - -type UpdateUserPasswordParams struct { - Password string `json:"password"` - ID int64 `json:"id"` -} - -func (q *Queries) UpdateUserPassword(ctx context.Context, arg UpdateUserPasswordParams) error { - _, err := q.db.Exec(ctx, updateUserPassword, arg.Password, arg.ID) - return err -} - -const updateUserProfile = `-- name: UpdateUserProfile :execrows - -UPDATE users SET username = $1, avatar = $2 WHERE id = $3 -` - -type UpdateUserProfileParams struct { - Username string `json:"username"` - Avatar *string `json:"avatar"` - ID int64 `json:"id"` -} - -// PostgreSQL variants of the sqlite profile queries. -// UpdateUserProfile uses :execrows because postgres has no LastInsertId; -// the caller checks rows-affected for existence. -func (q *Queries) UpdateUserProfile(ctx context.Context, arg UpdateUserProfileParams) (int64, error) { - result, err := q.db.Exec(ctx, updateUserProfile, arg.Username, arg.Avatar, arg.ID) - if err != nil { - return 0, err - } - return result.RowsAffected(), nil -} diff --git a/Server/db/pgdbgen/querier.go b/Server/db/pgdbgen/querier.go deleted file mode 100644 index cf97acc1..00000000 --- a/Server/db/pgdbgen/querier.go +++ /dev/null @@ -1,197 +0,0 @@ -//go:build postgres - -// Code generated by sqlc. DO NOT EDIT. -// versions: -// sqlc v1.30.0 - -package pgdbgen - -import ( - "context" - - "github.com/jackc/pgx/v5/pgtype" -) - -type Querier interface { - // PostgreSQL variants of the sqlite reactions queries. - AddReaction(ctx context.Context, arg AddReactionParams) error - AdminUpdateChannel(ctx context.Context, arg AdminUpdateChannelParams) error - ArchiveChannel(ctx context.Context, arg ArchiveChannelParams) error - BanUser(ctx context.Context, arg BanUserParams) error - // PostgreSQL variants of the sqlite user block queries. - // `INSERT OR IGNORE` becomes `INSERT ... ON CONFLICT DO NOTHING`. - BlockUser(ctx context.Context, arg BlockUserParams) error - CleanupExpiredLockouts(ctx context.Context, expiresAt pgtype.Timestamptz) error - ClearAllVoiceStates(ctx context.Context) error - ClearVoiceState(ctx context.Context, userID int64) error - CloseDM(ctx context.Context, arg CloseDMParams) error - CountActiveCameras(ctx context.Context, channelID int64) (int64, error) - CountActiveInvites(ctx context.Context) (int64, error) - CountActiveMessages(ctx context.Context) (int64, error) - CountChannels(ctx context.Context) (int64, error) - CountUsers(ctx context.Context) (int64, error) - CountUsersWithoutTOTP(ctx context.Context) (int64, error) - // PostgreSQL variants of the sqlite attachments queries. - CreateAttachment(ctx context.Context, arg CreateAttachmentParams) error - CreateChannel(ctx context.Context, arg CreateChannelParams) (int64, error) - // PostgreSQL variants of the sqlite invites queries. - // The expiry check uses native timestamp comparison instead of sqlite's - // strftime('%s', …) trick. - CreateInvite(ctx context.Context, arg CreateInviteParams) error - // PostgreSQL variants of the sqlite messages queries. - // `deleted = 0/1` and `pinned = 0/1` become FALSE/TRUE (columns are BOOLEAN). - // The FTS search queries are NOT included here: on postgres, messages.fts is - // a tsvector column with a GIN index (see migrations/postgres/001_initial_schema.sql) - // and FTS queries are hand-written in the postgres-specific store dispatch, - // mirroring how sqlite's FTS5 queries live in message_queries.go. - CreateMessage(ctx context.Context, arg CreateMessageParams) (int64, error) - CreateUser(ctx context.Context, arg CreateUserParams) (int64, error) - DeleteAttachment(ctx context.Context, id string) error - DeleteChannel(ctx context.Context, id int64) error - DeleteChannelPermission(ctx context.Context, arg DeleteChannelPermissionParams) error - // Use native timestamp comparison instead of sqlite's strftime trick. - DeleteExpiredSessions(ctx context.Context) error - DeleteLockout(ctx context.Context, key string) error - // Postgres timestamptz comparison — the caller passes a wall-clock time. - DeleteOrphanedAttachments(ctx context.Context, uploadedAt pgtype.Timestamptz) ([]string, error) - DeleteOtherSessions(ctx context.Context, arg DeleteOtherSessionsParams) (int64, error) - DeleteSessionByID(ctx context.Context, arg DeleteSessionByIDParams) error - DeleteSessionByToken(ctx context.Context, token string) error - DisablePlugin(ctx context.Context, id int64) error - EditMessageContent(ctx context.Context, arg EditMessageContentParams) error - EnableCameraIfUnderLimit(ctx context.Context, arg EnableCameraIfUnderLimitParams) (int64, error) - EnablePlugin(ctx context.Context, id int64) error - // Delete all but the N most recent sessions for a user. Postgres replaces - // sqlite's `LIMIT -1 OFFSET ?` with `OFFSET $2`. - EvictOldestSessions(ctx context.Context, arg EvictOldestSessionsParams) error - FindExistingDMChannel(ctx context.Context, arg FindExistingDMChannelParams) (int64, error) - ForceLogoutUser(ctx context.Context, userID int64) error - GetAllSettings(ctx context.Context) ([]Setting, error) - GetAllVoiceStates(ctx context.Context) ([]GetAllVoiceStatesRow, error) - GetAttachmentByID(ctx context.Context, id string) (GetAttachmentByIDRow, error) - GetAttachmentWithChannel(ctx context.Context, id string) (GetAttachmentWithChannelRow, error) - GetAuditLog(ctx context.Context, arg GetAuditLogParams) ([]GetAuditLogRow, error) - GetChannel(ctx context.Context, id int64) (GetChannelRow, error) - GetChannelPermission(ctx context.Context, arg GetChannelPermissionParams) (GetChannelPermissionRow, error) - GetChannelUnreadCounts(ctx context.Context, userID int64) ([]GetChannelUnreadCountsRow, error) - GetChannelVoiceStates(ctx context.Context, channelID int64) ([]GetChannelVoiceStatesRow, error) - GetDMParticipantIDs(ctx context.Context, channelID int64) ([]int64, error) - GetDefaultRole(ctx context.Context) (Role, error) - GetEventsSince(ctx context.Context, arg GetEventsSinceParams) ([]GetEventsSinceRow, error) - GetInvite(ctx context.Context, code string) (GetInviteRow, error) - GetLatestMessageID(ctx context.Context, channelID int64) (int64, error) - GetMaxEventSeq(ctx context.Context) (int64, error) - GetMessage(ctx context.Context, id int64) (GetMessageRow, error) - GetMessagesByChannel(ctx context.Context, arg GetMessagesByChannelParams) ([]GetMessagesByChannelRow, error) - GetMessagesByChannelBeforeCursor(ctx context.Context, arg GetMessagesByChannelBeforeCursorParams) ([]GetMessagesByChannelBeforeCursorRow, error) - GetMessagesForAPI(ctx context.Context, arg GetMessagesForAPIParams) ([]GetMessagesForAPIRow, error) - GetMessagesForAPIBeforeCursor(ctx context.Context, arg GetMessagesForAPIBeforeCursorParams) ([]GetMessagesForAPIBeforeCursorRow, error) - GetPinnedMessageRows(ctx context.Context, channelID int64) ([]GetPinnedMessageRowsRow, error) - GetPlugin(ctx context.Context, id int64) (Plugin, error) - GetPluginByName(ctx context.Context, name string) (Plugin, error) - GetReactionCounts(ctx context.Context, messageID int64) ([]GetReactionCountsRow, error) - // PostgreSQL variants of the sqlite roles queries. - // `is_default = 1` becomes `is_default = TRUE` since the column is BOOLEAN. - GetRoleByID(ctx context.Context, id int64) (Role, error) - GetRoleChannelPermissions(ctx context.Context, roleID int64) ([]GetRoleChannelPermissionsRow, error) - GetRoleForUser(ctx context.Context, id int64) (Role, error) - GetSessionByTokenHash(ctx context.Context, token string) (Session, error) - GetSessionWithBanStatus(ctx context.Context, token string) (GetSessionWithBanStatusRow, error) - GetSetting(ctx context.Context, key string) (string, error) - GetUserByID(ctx context.Context, id int64) (User, error) - // PostgreSQL variants of the sqlite users queries. - // Differences from sqlite: - // - `?` -> `$1`, `$2`, … - // - `COLLATE NOCASE` -> removed; the `username` column is CITEXT. - // - `datetime('now')` -> `NOW()` - // - `banned = 0/1` -> `banned = FALSE/TRUE` (column is BOOLEAN) - // - `:execresult INSERT` -> `:one ... RETURNING id` (pgx has no LastInsertId) - GetUserByUsername(ctx context.Context, username string) (User, error) - // For the "last message at" and "last message content" columns, sqlite - // COALESCEs to '' (empty string). Postgres TIMESTAMPTZ cannot COALESCE to an - // empty string, so we COALESCE to the dm_open_state.opened_at fallback and - // leave conversion to the store wrapper. - GetUserDMChannels(ctx context.Context, arg GetUserDMChannelsParams) ([]GetUserDMChannelsRow, error) - GetUserSessions(ctx context.Context, userID int64) ([]Session, error) - GetUserVoiceState(ctx context.Context, userID int64) (GetUserVoiceStateRow, error) - GetUserWithRole(ctx context.Context, id int64) (GetUserWithRoleRow, error) - // PostgreSQL variants of the sqlite DM queries. - // InsertDMChannel: sqlite uses :execresult (LastInsertId); postgres uses - // :one with RETURNING id. - // `INSERT OR IGNORE` becomes `INSERT ... ON CONFLICT DO NOTHING`. - InsertDMChannel(ctx context.Context) (int64, error) - InsertDMOpenState(ctx context.Context, arg InsertDMOpenStateParams) error - InsertDMParticipants(ctx context.Context, arg InsertDMParticipantsParams) error - // PostgreSQL variants of the sqlite sessions queries. - InsertSession(ctx context.Context, arg InsertSessionParams) (int64, error) - InstallPlugin(ctx context.Context, arg InstallPluginParams) (int64, error) - IsBlocked(ctx context.Context, arg IsBlockedParams) (int32, error) - IsDMParticipant(ctx context.Context, arg IsDMParticipantParams) (int64, error) - IsEitherBlocked(ctx context.Context, arg IsEitherBlockedParams) (int32, error) - // PostgreSQL variants of the sqlite voice queries. - // voice_states boolean columns (muted, deafened, speaking, camera, - // screenshare) use FALSE/TRUE instead of 0/1. - JoinVoiceChannel(ctx context.Context, arg JoinVoiceChannelParams) error - JoinVoiceChannelIfCapacity(ctx context.Context, arg JoinVoiceChannelIfCapacityParams) (int64, error) - LeaveVoiceChannel(ctx context.Context, userID int64) error - LeaveVoiceChannelIfMatch(ctx context.Context, arg LeaveVoiceChannelIfMatchParams) (int64, error) - LinkAttachmentToMessage(ctx context.Context, arg LinkAttachmentToMessageParams) (int64, error) - ListAllUsers(ctx context.Context, arg ListAllUsersParams) ([]ListAllUsersRow, error) - // PostgreSQL variants of the sqlite channels queries. - ListChannels(ctx context.Context) ([]ListChannelsRow, error) - ListInvites(ctx context.Context) ([]ListInvitesRow, error) - ListMembers(ctx context.Context) ([]ListMembersRow, error) - ListPlugins(ctx context.Context) ([]Plugin, error) - ListRoles(ctx context.Context) ([]Role, error) - ListUserSessions(ctx context.Context, userID int64) ([]Session, error) - LoadActiveLockouts(ctx context.Context, expiresAt pgtype.Timestamptz) ([]RateLockout, error) - LogAudit(ctx context.Context, arg LogAuditParams) error - OpenDM(ctx context.Context, arg OpenDMParams) error - // seq is supplied by the hub so the row seq matches the wrapped-payload seq. - // The schema's BIGSERIAL still owns the id column for inserts that omit seq, - // but PersistEvent always supplies an explicit value. - PersistEvent(ctx context.Context, arg PersistEventParams) error - PluginKVDelete(ctx context.Context, arg PluginKVDeleteParams) error - PluginKVGet(ctx context.Context, arg PluginKVGetParams) ([]byte, error) - PluginKVSet(ctx context.Context, arg PluginKVSetParams) error - PruneEventsOlderThan(ctx context.Context, createdAt pgtype.Timestamptz) (int64, error) - RemoveReaction(ctx context.Context, arg RemoveReactionParams) (int64, error) - ResetAllUserStatuses(ctx context.Context) error - RevokeInvite(ctx context.Context, code string) error - SetChannelMixingThreshold(ctx context.Context, arg SetChannelMixingThresholdParams) error - SetChannelSlowMode(ctx context.Context, arg SetChannelSlowModeParams) error - SetChannelVoiceMaxUsers(ctx context.Context, arg SetChannelVoiceMaxUsersParams) error - SetChannelVoiceMaxVideo(ctx context.Context, arg SetChannelVoiceMaxVideoParams) error - SetChannelVoiceQuality(ctx context.Context, arg SetChannelVoiceQualityParams) error - SetMessagePinned(ctx context.Context, arg SetMessagePinnedParams) (int64, error) - SetSetting(ctx context.Context, arg SetSettingParams) error - SoftDeleteMessage(ctx context.Context, id int64) error - TouchSession(ctx context.Context, token string) error - UnbanUser(ctx context.Context, id int64) error - UnblockUser(ctx context.Context, arg UnblockUserParams) error - UninstallPlugin(ctx context.Context, id int64) error - UpdateChannel(ctx context.Context, arg UpdateChannelParams) error - UpdateReadState(ctx context.Context, arg UpdateReadStateParams) error - UpdateUserPassword(ctx context.Context, arg UpdateUserPasswordParams) error - // PostgreSQL variants of the sqlite profile queries. - // UpdateUserProfile uses :execrows because postgres has no LastInsertId; - // the caller checks rows-affected for existence. - UpdateUserProfile(ctx context.Context, arg UpdateUserProfileParams) (int64, error) - UpdateUserRole(ctx context.Context, arg UpdateUserRoleParams) error - UpdateUserStatus(ctx context.Context, arg UpdateUserStatusParams) error - UpdateUserTOTPSecret(ctx context.Context, arg UpdateUserTOTPSecretParams) error - UpdateVoiceCamera(ctx context.Context, arg UpdateVoiceCameraParams) error - UpdateVoiceDeafen(ctx context.Context, arg UpdateVoiceDeafenParams) error - UpdateVoiceMute(ctx context.Context, arg UpdateVoiceMuteParams) error - UpdateVoiceScreenshare(ctx context.Context, arg UpdateVoiceScreenshareParams) error - UpdateVoiceSpeaking(ctx context.Context, arg UpdateVoiceSpeakingParams) error - UpsertChannelPermission(ctx context.Context, arg UpsertChannelPermissionParams) error - // PostgreSQL variants of the sqlite rate-lockout queries. - // `INSERT OR REPLACE` becomes `INSERT ... ON CONFLICT (key) DO UPDATE`. - UpsertLockout(ctx context.Context, arg UpsertLockoutParams) error - UseInviteAtomic(ctx context.Context, code string) (int64, error) - // PostgreSQL variants of the sqlite admin queries. - UserCount(ctx context.Context) (int64, error) -} - -var _ Querier = (*Queries)(nil) diff --git a/Server/db/pgdbgen/reactions.sql.go b/Server/db/pgdbgen/reactions.sql.go deleted file mode 100644 index 69927d98..00000000 --- a/Server/db/pgdbgen/reactions.sql.go +++ /dev/null @@ -1,78 +0,0 @@ -//go:build postgres - -// Code generated by sqlc. DO NOT EDIT. -// versions: -// sqlc v1.30.0 -// source: reactions.sql - -package pgdbgen - -import ( - "context" -) - -const addReaction = `-- name: AddReaction :exec - -INSERT INTO reactions (message_id, user_id, emoji) VALUES ($1, $2, $3) -` - -type AddReactionParams struct { - MessageID int64 `json:"messageId"` - UserID int64 `json:"userId"` - Emoji string `json:"emoji"` -} - -// PostgreSQL variants of the sqlite reactions queries. -func (q *Queries) AddReaction(ctx context.Context, arg AddReactionParams) error { - _, err := q.db.Exec(ctx, addReaction, arg.MessageID, arg.UserID, arg.Emoji) - return err -} - -const getReactionCounts = `-- name: GetReactionCounts :many -SELECT emoji, COUNT(*) AS count -FROM reactions WHERE message_id = $1 -GROUP BY emoji -` - -type GetReactionCountsRow struct { - Emoji string `json:"emoji"` - Count int64 `json:"count"` -} - -func (q *Queries) GetReactionCounts(ctx context.Context, messageID int64) ([]GetReactionCountsRow, error) { - rows, err := q.db.Query(ctx, getReactionCounts, messageID) - if err != nil { - return nil, err - } - defer rows.Close() - items := []GetReactionCountsRow{} - for rows.Next() { - var i GetReactionCountsRow - if err := rows.Scan(&i.Emoji, &i.Count); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - -const removeReaction = `-- name: RemoveReaction :execrows -DELETE FROM reactions WHERE message_id = $1 AND user_id = $2 AND emoji = $3 -` - -type RemoveReactionParams struct { - MessageID int64 `json:"messageId"` - UserID int64 `json:"userId"` - Emoji string `json:"emoji"` -} - -func (q *Queries) RemoveReaction(ctx context.Context, arg RemoveReactionParams) (int64, error) { - result, err := q.db.Exec(ctx, removeReaction, arg.MessageID, arg.UserID, arg.Emoji) - if err != nil { - return 0, err - } - return result.RowsAffected(), nil -} diff --git a/Server/db/pgdbgen/roles.sql.go b/Server/db/pgdbgen/roles.sql.go deleted file mode 100644 index 2da88cd1..00000000 --- a/Server/db/pgdbgen/roles.sql.go +++ /dev/null @@ -1,165 +0,0 @@ -//go:build postgres - -// Code generated by sqlc. DO NOT EDIT. -// versions: -// sqlc v1.30.0 -// source: roles.sql - -package pgdbgen - -import ( - "context" - - "github.com/jackc/pgx/v5/pgtype" -) - -const getDefaultRole = `-- name: GetDefaultRole :one -SELECT id, name, color, permissions, position, is_default -FROM roles WHERE is_default = TRUE LIMIT 1 -` - -func (q *Queries) GetDefaultRole(ctx context.Context) (Role, error) { - row := q.db.QueryRow(ctx, getDefaultRole) - var i Role - err := row.Scan( - &i.ID, - &i.Name, - &i.Color, - &i.Permissions, - &i.Position, - &i.IsDefault, - ) - return i, err -} - -const getRoleByID = `-- name: GetRoleByID :one - -SELECT id, name, color, permissions, position, is_default -FROM roles WHERE id = $1 -` - -// PostgreSQL variants of the sqlite roles queries. -// `is_default = 1` becomes `is_default = TRUE` since the column is BOOLEAN. -func (q *Queries) GetRoleByID(ctx context.Context, id int64) (Role, error) { - row := q.db.QueryRow(ctx, getRoleByID, id) - var i Role - err := row.Scan( - &i.ID, - &i.Name, - &i.Color, - &i.Permissions, - &i.Position, - &i.IsDefault, - ) - return i, err -} - -const getRoleForUser = `-- name: GetRoleForUser :one -SELECT r.id, r.name, r.color, r.permissions, r.position, r.is_default -FROM users u -JOIN roles r ON u.role_id = r.id -WHERE u.id = $1 -` - -func (q *Queries) GetRoleForUser(ctx context.Context, id int64) (Role, error) { - row := q.db.QueryRow(ctx, getRoleForUser, id) - var i Role - err := row.Scan( - &i.ID, - &i.Name, - &i.Color, - &i.Permissions, - &i.Position, - &i.IsDefault, - ) - return i, err -} - -const getUserWithRole = `-- name: GetUserWithRole :one -SELECT u.id, u.username, u.password, u.avatar, u.role_id, - u.totp_secret, u.status, u.created_at, u.last_seen, - u.banned, u.ban_reason, u.ban_expires, - r.id, r.name, r.color, r.permissions, r.position, r.is_default -FROM users u -JOIN roles r ON r.id = u.role_id -WHERE u.id = $1 -` - -type GetUserWithRoleRow struct { - ID int64 `json:"id"` - Username string `json:"username"` - Password string `json:"password"` - Avatar *string `json:"avatar"` - RoleID int64 `json:"roleId"` - TotpSecret *string `json:"totpSecret"` - Status string `json:"status"` - CreatedAt pgtype.Timestamptz `json:"createdAt"` - LastSeen pgtype.Timestamptz `json:"lastSeen"` - Banned bool `json:"banned"` - BanReason *string `json:"banReason"` - BanExpires pgtype.Timestamptz `json:"banExpires"` - ID_2 int64 `json:"id2"` - Name string `json:"name"` - Color *string `json:"color"` - Permissions int64 `json:"permissions"` - Position int32 `json:"position"` - IsDefault bool `json:"isDefault"` -} - -func (q *Queries) GetUserWithRole(ctx context.Context, id int64) (GetUserWithRoleRow, error) { - row := q.db.QueryRow(ctx, getUserWithRole, id) - var i GetUserWithRoleRow - err := row.Scan( - &i.ID, - &i.Username, - &i.Password, - &i.Avatar, - &i.RoleID, - &i.TotpSecret, - &i.Status, - &i.CreatedAt, - &i.LastSeen, - &i.Banned, - &i.BanReason, - &i.BanExpires, - &i.ID_2, - &i.Name, - &i.Color, - &i.Permissions, - &i.Position, - &i.IsDefault, - ) - return i, err -} - -const listRoles = `-- name: ListRoles :many -SELECT id, name, color, permissions, position, is_default -FROM roles ORDER BY position DESC -` - -func (q *Queries) ListRoles(ctx context.Context) ([]Role, error) { - rows, err := q.db.Query(ctx, listRoles) - if err != nil { - return nil, err - } - defer rows.Close() - items := []Role{} - for rows.Next() { - var i Role - if err := rows.Scan( - &i.ID, - &i.Name, - &i.Color, - &i.Permissions, - &i.Position, - &i.IsDefault, - ); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} diff --git a/Server/db/pgdbgen/sessions.sql.go b/Server/db/pgdbgen/sessions.sql.go deleted file mode 100644 index feeccda1..00000000 --- a/Server/db/pgdbgen/sessions.sql.go +++ /dev/null @@ -1,221 +0,0 @@ -//go:build postgres - -// Code generated by sqlc. DO NOT EDIT. -// versions: -// sqlc v1.30.0 -// source: sessions.sql - -package pgdbgen - -import ( - "context" - - "github.com/jackc/pgx/v5/pgtype" -) - -const deleteExpiredSessions = `-- name: DeleteExpiredSessions :exec -DELETE FROM sessions WHERE expires_at < NOW() -` - -// Use native timestamp comparison instead of sqlite's strftime trick. -func (q *Queries) DeleteExpiredSessions(ctx context.Context) error { - _, err := q.db.Exec(ctx, deleteExpiredSessions) - return err -} - -const deleteOtherSessions = `-- name: DeleteOtherSessions :execrows -DELETE FROM sessions WHERE user_id = $1 AND id != $2 -` - -type DeleteOtherSessionsParams struct { - UserID int64 `json:"userId"` - ID int64 `json:"id"` -} - -func (q *Queries) DeleteOtherSessions(ctx context.Context, arg DeleteOtherSessionsParams) (int64, error) { - result, err := q.db.Exec(ctx, deleteOtherSessions, arg.UserID, arg.ID) - if err != nil { - return 0, err - } - return result.RowsAffected(), nil -} - -const deleteSessionByID = `-- name: DeleteSessionByID :exec -DELETE FROM sessions WHERE id = $1 AND user_id = $2 -` - -type DeleteSessionByIDParams struct { - ID int64 `json:"id"` - UserID int64 `json:"userId"` -} - -func (q *Queries) DeleteSessionByID(ctx context.Context, arg DeleteSessionByIDParams) error { - _, err := q.db.Exec(ctx, deleteSessionByID, arg.ID, arg.UserID) - return err -} - -const deleteSessionByToken = `-- name: DeleteSessionByToken :exec -DELETE FROM sessions WHERE token = $1 -` - -func (q *Queries) DeleteSessionByToken(ctx context.Context, token string) error { - _, err := q.db.Exec(ctx, deleteSessionByToken, token) - return err -} - -const evictOldestSessions = `-- name: EvictOldestSessions :exec -DELETE FROM sessions WHERE id IN ( - SELECT s2.id FROM sessions AS s2 WHERE s2.user_id = $1 - ORDER BY s2.created_at DESC - OFFSET $2 -) -` - -type EvictOldestSessionsParams struct { - UserID int64 `json:"userId"` - Offset int32 `json:"offset"` -} - -// Delete all but the N most recent sessions for a user. Postgres replaces -// sqlite's `LIMIT -1 OFFSET ?` with `OFFSET $2`. -func (q *Queries) EvictOldestSessions(ctx context.Context, arg EvictOldestSessionsParams) error { - _, err := q.db.Exec(ctx, evictOldestSessions, arg.UserID, arg.Offset) - return err -} - -const getSessionByTokenHash = `-- name: GetSessionByTokenHash :one -SELECT id, user_id, token, device, ip_address, created_at, last_used, expires_at -FROM sessions WHERE token = $1 -` - -func (q *Queries) GetSessionByTokenHash(ctx context.Context, token string) (Session, error) { - row := q.db.QueryRow(ctx, getSessionByTokenHash, token) - var i Session - err := row.Scan( - &i.ID, - &i.UserID, - &i.Token, - &i.Device, - &i.IpAddress, - &i.CreatedAt, - &i.LastUsed, - &i.ExpiresAt, - ) - return i, err -} - -const getSessionWithBanStatus = `-- name: GetSessionWithBanStatus :one -SELECT s.id, s.user_id, s.token, s.device, s.ip_address, - s.created_at, s.last_used, s.expires_at, - u.banned, u.ban_reason, u.ban_expires -FROM sessions s -JOIN users u ON s.user_id = u.id -WHERE s.token = $1 -` - -type GetSessionWithBanStatusRow struct { - ID int64 `json:"id"` - UserID int64 `json:"userId"` - Token string `json:"token"` - Device *string `json:"device"` - IpAddress *string `json:"ipAddress"` - CreatedAt pgtype.Timestamptz `json:"createdAt"` - LastUsed pgtype.Timestamptz `json:"lastUsed"` - ExpiresAt pgtype.Timestamptz `json:"expiresAt"` - Banned bool `json:"banned"` - BanReason *string `json:"banReason"` - BanExpires pgtype.Timestamptz `json:"banExpires"` -} - -func (q *Queries) GetSessionWithBanStatus(ctx context.Context, token string) (GetSessionWithBanStatusRow, error) { - row := q.db.QueryRow(ctx, getSessionWithBanStatus, token) - var i GetSessionWithBanStatusRow - err := row.Scan( - &i.ID, - &i.UserID, - &i.Token, - &i.Device, - &i.IpAddress, - &i.CreatedAt, - &i.LastUsed, - &i.ExpiresAt, - &i.Banned, - &i.BanReason, - &i.BanExpires, - ) - return i, err -} - -const insertSession = `-- name: InsertSession :one - -INSERT INTO sessions (user_id, token, device, ip_address, expires_at) -VALUES ($1, $2, $3, $4, $5) -RETURNING id -` - -type InsertSessionParams struct { - UserID int64 `json:"userId"` - Token string `json:"token"` - Device *string `json:"device"` - IpAddress *string `json:"ipAddress"` - ExpiresAt pgtype.Timestamptz `json:"expiresAt"` -} - -// PostgreSQL variants of the sqlite sessions queries. -func (q *Queries) InsertSession(ctx context.Context, arg InsertSessionParams) (int64, error) { - row := q.db.QueryRow(ctx, insertSession, - arg.UserID, - arg.Token, - arg.Device, - arg.IpAddress, - arg.ExpiresAt, - ) - var id int64 - err := row.Scan(&id) - return id, err -} - -const listUserSessions = `-- name: ListUserSessions :many -SELECT id, user_id, token, device, ip_address, created_at, last_used, expires_at -FROM sessions -WHERE user_id = $1 -ORDER BY created_at DESC -` - -func (q *Queries) ListUserSessions(ctx context.Context, userID int64) ([]Session, error) { - rows, err := q.db.Query(ctx, listUserSessions, userID) - if err != nil { - return nil, err - } - defer rows.Close() - items := []Session{} - for rows.Next() { - var i Session - if err := rows.Scan( - &i.ID, - &i.UserID, - &i.Token, - &i.Device, - &i.IpAddress, - &i.CreatedAt, - &i.LastUsed, - &i.ExpiresAt, - ); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - -const touchSession = `-- name: TouchSession :exec -UPDATE sessions SET last_used = NOW() WHERE token = $1 -` - -func (q *Queries) TouchSession(ctx context.Context, token string) error { - _, err := q.db.Exec(ctx, touchSession, token) - return err -} diff --git a/Server/db/pgdbgen/users.sql.go b/Server/db/pgdbgen/users.sql.go deleted file mode 100644 index d4878c0d..00000000 --- a/Server/db/pgdbgen/users.sql.go +++ /dev/null @@ -1,219 +0,0 @@ -//go:build postgres - -// Code generated by sqlc. DO NOT EDIT. -// versions: -// sqlc v1.30.0 -// source: users.sql - -package pgdbgen - -import ( - "context" - - "github.com/jackc/pgx/v5/pgtype" -) - -const banUser = `-- name: BanUser :exec -UPDATE users SET banned = TRUE, ban_reason = $1, ban_expires = $2 WHERE id = $3 -` - -type BanUserParams struct { - BanReason *string `json:"banReason"` - BanExpires pgtype.Timestamptz `json:"banExpires"` - ID int64 `json:"id"` -} - -func (q *Queries) BanUser(ctx context.Context, arg BanUserParams) error { - _, err := q.db.Exec(ctx, banUser, arg.BanReason, arg.BanExpires, arg.ID) - return err -} - -const countUsers = `-- name: CountUsers :one -SELECT COUNT(*) FROM users -` - -func (q *Queries) CountUsers(ctx context.Context) (int64, error) { - row := q.db.QueryRow(ctx, countUsers) - var count int64 - err := row.Scan(&count) - return count, err -} - -const countUsersWithoutTOTP = `-- name: CountUsersWithoutTOTP :one -SELECT COUNT(*) FROM users WHERE banned = FALSE AND totp_secret IS NULL -` - -func (q *Queries) CountUsersWithoutTOTP(ctx context.Context) (int64, error) { - row := q.db.QueryRow(ctx, countUsersWithoutTOTP) - var count int64 - err := row.Scan(&count) - return count, err -} - -const createUser = `-- name: CreateUser :one -INSERT INTO users (username, password, role_id) -VALUES ($1, $2, $3) -RETURNING id -` - -type CreateUserParams struct { - Username string `json:"username"` - Password string `json:"password"` - RoleID int64 `json:"roleId"` -} - -func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (int64, error) { - row := q.db.QueryRow(ctx, createUser, arg.Username, arg.Password, arg.RoleID) - var id int64 - err := row.Scan(&id) - return id, err -} - -const getUserByID = `-- name: GetUserByID :one -SELECT id, username, password, avatar, role_id, totp_secret, status, - created_at, last_seen, banned, ban_reason, ban_expires -FROM users WHERE id = $1 -` - -func (q *Queries) GetUserByID(ctx context.Context, id int64) (User, error) { - row := q.db.QueryRow(ctx, getUserByID, id) - var i User - err := row.Scan( - &i.ID, - &i.Username, - &i.Password, - &i.Avatar, - &i.RoleID, - &i.TotpSecret, - &i.Status, - &i.CreatedAt, - &i.LastSeen, - &i.Banned, - &i.BanReason, - &i.BanExpires, - ) - return i, err -} - -const getUserByUsername = `-- name: GetUserByUsername :one - -SELECT id, username, password, avatar, role_id, totp_secret, status, - created_at, last_seen, banned, ban_reason, ban_expires -FROM users WHERE username = $1 -` - -// PostgreSQL variants of the sqlite users queries. -// Differences from sqlite: -// - `?` -> `$1`, `$2`, … -// - `COLLATE NOCASE` -> removed; the `username` column is CITEXT. -// - `datetime('now')` -> `NOW()` -// - `banned = 0/1` -> `banned = FALSE/TRUE` (column is BOOLEAN) -// - `:execresult INSERT` -> `:one ... RETURNING id` (pgx has no LastInsertId) -func (q *Queries) GetUserByUsername(ctx context.Context, username string) (User, error) { - row := q.db.QueryRow(ctx, getUserByUsername, username) - var i User - err := row.Scan( - &i.ID, - &i.Username, - &i.Password, - &i.Avatar, - &i.RoleID, - &i.TotpSecret, - &i.Status, - &i.CreatedAt, - &i.LastSeen, - &i.Banned, - &i.BanReason, - &i.BanExpires, - ) - return i, err -} - -const listMembers = `-- name: ListMembers :many -SELECT u.id, u.username, u.avatar, u.status, LOWER(r.name) -FROM users u -JOIN roles r ON u.role_id = r.id -WHERE u.banned = FALSE -ORDER BY u.username ASC -LIMIT 1000 -` - -type ListMembersRow struct { - ID int64 `json:"id"` - Username string `json:"username"` - Avatar *string `json:"avatar"` - Status string `json:"status"` - Lower string `json:"lower"` -} - -func (q *Queries) ListMembers(ctx context.Context) ([]ListMembersRow, error) { - rows, err := q.db.Query(ctx, listMembers) - if err != nil { - return nil, err - } - defer rows.Close() - items := []ListMembersRow{} - for rows.Next() { - var i ListMembersRow - if err := rows.Scan( - &i.ID, - &i.Username, - &i.Avatar, - &i.Status, - &i.Lower, - ); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - -const resetAllUserStatuses = `-- name: ResetAllUserStatuses :exec -UPDATE users SET status = 'offline' WHERE status != 'offline' -` - -func (q *Queries) ResetAllUserStatuses(ctx context.Context) error { - _, err := q.db.Exec(ctx, resetAllUserStatuses) - return err -} - -const unbanUser = `-- name: UnbanUser :exec -UPDATE users SET banned = FALSE, ban_reason = NULL, ban_expires = NULL WHERE id = $1 -` - -func (q *Queries) UnbanUser(ctx context.Context, id int64) error { - _, err := q.db.Exec(ctx, unbanUser, id) - return err -} - -const updateUserStatus = `-- name: UpdateUserStatus :exec -UPDATE users SET status = $1, last_seen = NOW() WHERE id = $2 -` - -type UpdateUserStatusParams struct { - Status string `json:"status"` - ID int64 `json:"id"` -} - -func (q *Queries) UpdateUserStatus(ctx context.Context, arg UpdateUserStatusParams) error { - _, err := q.db.Exec(ctx, updateUserStatus, arg.Status, arg.ID) - return err -} - -const updateUserTOTPSecret = `-- name: UpdateUserTOTPSecret :exec -UPDATE users SET totp_secret = $1 WHERE id = $2 -` - -type UpdateUserTOTPSecretParams struct { - TotpSecret *string `json:"totpSecret"` - ID int64 `json:"id"` -} - -func (q *Queries) UpdateUserTOTPSecret(ctx context.Context, arg UpdateUserTOTPSecretParams) error { - _, err := q.db.Exec(ctx, updateUserTOTPSecret, arg.TotpSecret, arg.ID) - return err -} diff --git a/Server/db/pgdbgen/voice.sql.go b/Server/db/pgdbgen/voice.sql.go deleted file mode 100644 index 718218c4..00000000 --- a/Server/db/pgdbgen/voice.sql.go +++ /dev/null @@ -1,371 +0,0 @@ -//go:build postgres - -// Code generated by sqlc. DO NOT EDIT. -// versions: -// sqlc v1.30.0 -// source: voice.sql - -package pgdbgen - -import ( - "context" - - "github.com/jackc/pgx/v5/pgtype" -) - -const clearAllVoiceStates = `-- name: ClearAllVoiceStates :exec -DELETE FROM voice_states -` - -func (q *Queries) ClearAllVoiceStates(ctx context.Context) error { - _, err := q.db.Exec(ctx, clearAllVoiceStates) - return err -} - -const clearVoiceState = `-- name: ClearVoiceState :exec -DELETE FROM voice_states WHERE user_id = $1 -` - -func (q *Queries) ClearVoiceState(ctx context.Context, userID int64) error { - _, err := q.db.Exec(ctx, clearVoiceState, userID) - return err -} - -const countActiveCameras = `-- name: CountActiveCameras :one -SELECT COUNT(*) FROM voice_states WHERE channel_id = $1 AND camera = TRUE -` - -func (q *Queries) CountActiveCameras(ctx context.Context, channelID int64) (int64, error) { - row := q.db.QueryRow(ctx, countActiveCameras, channelID) - var count int64 - err := row.Scan(&count) - return count, err -} - -const enableCameraIfUnderLimit = `-- name: EnableCameraIfUnderLimit :execrows -UPDATE voice_states SET camera = TRUE -WHERE voice_states.user_id = $1 AND voice_states.channel_id = $2 - AND (SELECT COUNT(*) FROM voice_states AS vs2 WHERE vs2.channel_id = $3 AND vs2.camera = TRUE) < $4 -` - -type EnableCameraIfUnderLimitParams struct { - UserID int64 `json:"userId"` - ChannelID int64 `json:"channelId"` - ChannelID_2 int64 `json:"channelId2"` - ChannelID_3 int64 `json:"channelId3"` -} - -func (q *Queries) EnableCameraIfUnderLimit(ctx context.Context, arg EnableCameraIfUnderLimitParams) (int64, error) { - result, err := q.db.Exec(ctx, enableCameraIfUnderLimit, - arg.UserID, - arg.ChannelID, - arg.ChannelID_2, - arg.ChannelID_3, - ) - if err != nil { - return 0, err - } - return result.RowsAffected(), nil -} - -const getAllVoiceStates = `-- name: GetAllVoiceStates :many -SELECT vs.user_id, vs.channel_id, u.username, - vs.muted, vs.deafened, vs.speaking, - vs.camera, vs.screenshare, vs.joined_at -FROM voice_states vs -JOIN users u ON u.id = vs.user_id -ORDER BY vs.channel_id, vs.joined_at ASC -` - -type GetAllVoiceStatesRow struct { - UserID int64 `json:"userId"` - ChannelID int64 `json:"channelId"` - Username string `json:"username"` - Muted bool `json:"muted"` - Deafened bool `json:"deafened"` - Speaking bool `json:"speaking"` - Camera bool `json:"camera"` - Screenshare bool `json:"screenshare"` - JoinedAt pgtype.Timestamptz `json:"joinedAt"` -} - -func (q *Queries) GetAllVoiceStates(ctx context.Context) ([]GetAllVoiceStatesRow, error) { - rows, err := q.db.Query(ctx, getAllVoiceStates) - if err != nil { - return nil, err - } - defer rows.Close() - items := []GetAllVoiceStatesRow{} - for rows.Next() { - var i GetAllVoiceStatesRow - if err := rows.Scan( - &i.UserID, - &i.ChannelID, - &i.Username, - &i.Muted, - &i.Deafened, - &i.Speaking, - &i.Camera, - &i.Screenshare, - &i.JoinedAt, - ); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - -const getChannelVoiceStates = `-- name: GetChannelVoiceStates :many -SELECT vs.user_id, vs.channel_id, u.username, - vs.muted, vs.deafened, vs.speaking, - vs.camera, vs.screenshare, vs.joined_at -FROM voice_states vs -JOIN users u ON u.id = vs.user_id -WHERE vs.channel_id = $1 -ORDER BY vs.joined_at ASC -` - -type GetChannelVoiceStatesRow struct { - UserID int64 `json:"userId"` - ChannelID int64 `json:"channelId"` - Username string `json:"username"` - Muted bool `json:"muted"` - Deafened bool `json:"deafened"` - Speaking bool `json:"speaking"` - Camera bool `json:"camera"` - Screenshare bool `json:"screenshare"` - JoinedAt pgtype.Timestamptz `json:"joinedAt"` -} - -func (q *Queries) GetChannelVoiceStates(ctx context.Context, channelID int64) ([]GetChannelVoiceStatesRow, error) { - rows, err := q.db.Query(ctx, getChannelVoiceStates, channelID) - if err != nil { - return nil, err - } - defer rows.Close() - items := []GetChannelVoiceStatesRow{} - for rows.Next() { - var i GetChannelVoiceStatesRow - if err := rows.Scan( - &i.UserID, - &i.ChannelID, - &i.Username, - &i.Muted, - &i.Deafened, - &i.Speaking, - &i.Camera, - &i.Screenshare, - &i.JoinedAt, - ); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - -const getUserVoiceState = `-- name: GetUserVoiceState :one -SELECT vs.user_id, vs.channel_id, u.username, - vs.muted, vs.deafened, vs.speaking, - vs.camera, vs.screenshare, vs.joined_at -FROM voice_states vs -JOIN users u ON u.id = vs.user_id -WHERE vs.user_id = $1 -` - -type GetUserVoiceStateRow struct { - UserID int64 `json:"userId"` - ChannelID int64 `json:"channelId"` - Username string `json:"username"` - Muted bool `json:"muted"` - Deafened bool `json:"deafened"` - Speaking bool `json:"speaking"` - Camera bool `json:"camera"` - Screenshare bool `json:"screenshare"` - JoinedAt pgtype.Timestamptz `json:"joinedAt"` -} - -func (q *Queries) GetUserVoiceState(ctx context.Context, userID int64) (GetUserVoiceStateRow, error) { - row := q.db.QueryRow(ctx, getUserVoiceState, userID) - var i GetUserVoiceStateRow - err := row.Scan( - &i.UserID, - &i.ChannelID, - &i.Username, - &i.Muted, - &i.Deafened, - &i.Speaking, - &i.Camera, - &i.Screenshare, - &i.JoinedAt, - ) - return i, err -} - -const joinVoiceChannel = `-- name: JoinVoiceChannel :exec - -INSERT INTO voice_states (user_id, channel_id, muted, deafened, speaking, camera, screenshare, joined_at) -VALUES ($1, $2, FALSE, FALSE, FALSE, FALSE, FALSE, $3) -ON CONFLICT (user_id) DO UPDATE SET - channel_id = EXCLUDED.channel_id, - muted = FALSE, - deafened = FALSE, - speaking = FALSE, - camera = FALSE, - screenshare = FALSE, - joined_at = EXCLUDED.joined_at -` - -type JoinVoiceChannelParams struct { - UserID int64 `json:"userId"` - ChannelID int64 `json:"channelId"` - JoinedAt pgtype.Timestamptz `json:"joinedAt"` -} - -// PostgreSQL variants of the sqlite voice queries. -// voice_states boolean columns (muted, deafened, speaking, camera, -// screenshare) use FALSE/TRUE instead of 0/1. -func (q *Queries) JoinVoiceChannel(ctx context.Context, arg JoinVoiceChannelParams) error { - _, err := q.db.Exec(ctx, joinVoiceChannel, arg.UserID, arg.ChannelID, arg.JoinedAt) - return err -} - -const joinVoiceChannelIfCapacity = `-- name: JoinVoiceChannelIfCapacity :execrows -INSERT INTO voice_states (user_id, channel_id, muted, deafened, speaking, camera, screenshare, joined_at) -SELECT $1, $2, FALSE, FALSE, FALSE, FALSE, FALSE, $3 -WHERE (SELECT COUNT(*) FROM voice_states AS vs2 WHERE vs2.channel_id = $4) < $5 -ON CONFLICT (user_id) DO UPDATE SET - channel_id = EXCLUDED.channel_id, - muted = FALSE, - deafened = FALSE, - speaking = FALSE, - camera = FALSE, - screenshare = FALSE, - joined_at = EXCLUDED.joined_at -` - -type JoinVoiceChannelIfCapacityParams struct { - UserID int64 `json:"userId"` - ChannelID int64 `json:"channelId"` - JoinedAt pgtype.Timestamptz `json:"joinedAt"` - ChannelID_2 int64 `json:"channelId2"` - ChannelID_3 int64 `json:"channelId3"` -} - -func (q *Queries) JoinVoiceChannelIfCapacity(ctx context.Context, arg JoinVoiceChannelIfCapacityParams) (int64, error) { - result, err := q.db.Exec(ctx, joinVoiceChannelIfCapacity, - arg.UserID, - arg.ChannelID, - arg.JoinedAt, - arg.ChannelID_2, - arg.ChannelID_3, - ) - if err != nil { - return 0, err - } - return result.RowsAffected(), nil -} - -const leaveVoiceChannel = `-- name: LeaveVoiceChannel :exec -DELETE FROM voice_states WHERE user_id = $1 -` - -func (q *Queries) LeaveVoiceChannel(ctx context.Context, userID int64) error { - _, err := q.db.Exec(ctx, leaveVoiceChannel, userID) - return err -} - -const leaveVoiceChannelIfMatch = `-- name: LeaveVoiceChannelIfMatch :execrows -DELETE FROM voice_states WHERE user_id = $1 AND channel_id = $2 AND joined_at = $3 -` - -type LeaveVoiceChannelIfMatchParams struct { - UserID int64 `json:"userId"` - ChannelID int64 `json:"channelId"` - JoinedAt pgtype.Timestamptz `json:"joinedAt"` -} - -func (q *Queries) LeaveVoiceChannelIfMatch(ctx context.Context, arg LeaveVoiceChannelIfMatchParams) (int64, error) { - result, err := q.db.Exec(ctx, leaveVoiceChannelIfMatch, arg.UserID, arg.ChannelID, arg.JoinedAt) - if err != nil { - return 0, err - } - return result.RowsAffected(), nil -} - -const updateVoiceCamera = `-- name: UpdateVoiceCamera :exec -UPDATE voice_states SET camera = $1 WHERE user_id = $2 -` - -type UpdateVoiceCameraParams struct { - Camera bool `json:"camera"` - UserID int64 `json:"userId"` -} - -func (q *Queries) UpdateVoiceCamera(ctx context.Context, arg UpdateVoiceCameraParams) error { - _, err := q.db.Exec(ctx, updateVoiceCamera, arg.Camera, arg.UserID) - return err -} - -const updateVoiceDeafen = `-- name: UpdateVoiceDeafen :exec -UPDATE voice_states SET deafened = $1 WHERE user_id = $2 -` - -type UpdateVoiceDeafenParams struct { - Deafened bool `json:"deafened"` - UserID int64 `json:"userId"` -} - -func (q *Queries) UpdateVoiceDeafen(ctx context.Context, arg UpdateVoiceDeafenParams) error { - _, err := q.db.Exec(ctx, updateVoiceDeafen, arg.Deafened, arg.UserID) - return err -} - -const updateVoiceMute = `-- name: UpdateVoiceMute :exec -UPDATE voice_states SET muted = $1 WHERE user_id = $2 -` - -type UpdateVoiceMuteParams struct { - Muted bool `json:"muted"` - UserID int64 `json:"userId"` -} - -func (q *Queries) UpdateVoiceMute(ctx context.Context, arg UpdateVoiceMuteParams) error { - _, err := q.db.Exec(ctx, updateVoiceMute, arg.Muted, arg.UserID) - return err -} - -const updateVoiceScreenshare = `-- name: UpdateVoiceScreenshare :exec -UPDATE voice_states SET screenshare = $1 WHERE user_id = $2 -` - -type UpdateVoiceScreenshareParams struct { - Screenshare bool `json:"screenshare"` - UserID int64 `json:"userId"` -} - -func (q *Queries) UpdateVoiceScreenshare(ctx context.Context, arg UpdateVoiceScreenshareParams) error { - _, err := q.db.Exec(ctx, updateVoiceScreenshare, arg.Screenshare, arg.UserID) - return err -} - -const updateVoiceSpeaking = `-- name: UpdateVoiceSpeaking :exec -UPDATE voice_states SET speaking = $1 WHERE user_id = $2 -` - -type UpdateVoiceSpeakingParams struct { - Speaking bool `json:"speaking"` - UserID int64 `json:"userId"` -} - -func (q *Queries) UpdateVoiceSpeaking(ctx context.Context, arg UpdateVoiceSpeakingParams) error { - _, err := q.db.Exec(ctx, updateVoiceSpeaking, arg.Speaking, arg.UserID) - return err -} diff --git a/Server/db/queries/postgres/admin.sql b/Server/db/queries/postgres/admin.sql deleted file mode 100644 index 614ed13e..00000000 --- a/Server/db/queries/postgres/admin.sql +++ /dev/null @@ -1,55 +0,0 @@ --- PostgreSQL variants of the sqlite admin queries. - --- name: UserCount :one -SELECT COUNT(*) FROM users; - --- name: CountActiveMessages :one -SELECT COUNT(*) FROM messages WHERE deleted = FALSE; - --- name: CountChannels :one -SELECT COUNT(*) FROM channels; - --- name: CountActiveInvites :one -SELECT COUNT(*) FROM invites WHERE revoked = FALSE; - --- name: ListAllUsers :many -SELECT u.id, u.username, u.avatar, u.role_id, - u.status, u.created_at, u.last_seen, u.banned, u.ban_reason, u.ban_expires, - COALESCE(r.name, '') AS role_name -FROM users u -LEFT JOIN roles r ON r.id = u.role_id -ORDER BY u.id ASC -LIMIT $1 OFFSET $2; - --- name: UpdateUserRole :exec -UPDATE users SET role_id = $1 WHERE id = $2; - --- name: ForceLogoutUser :exec -DELETE FROM sessions WHERE user_id = $1; - --- name: GetUserSessions :many -SELECT id, user_id, token, device, ip_address, created_at, last_used, expires_at -FROM sessions WHERE user_id = $1 -ORDER BY created_at DESC; - --- name: LogAudit :exec -INSERT INTO audit_log (actor_id, action, target_type, target_id, detail) -VALUES ($1, $2, $3, $4, $5); - --- name: GetAuditLog :many -SELECT a.id, a.actor_id, COALESCE(u.username, '') AS actor_name, a.action, - a.target_type, a.target_id, a.detail, a.created_at -FROM audit_log a -LEFT JOIN users u ON u.id = a.actor_id -ORDER BY a.id DESC -LIMIT $1 OFFSET $2; - --- name: GetSetting :one -SELECT value FROM settings WHERE key = $1; - --- name: SetSetting :exec -INSERT INTO settings (key, value) VALUES ($1, $2) -ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value; - --- name: GetAllSettings :many -SELECT key, value FROM settings; diff --git a/Server/db/queries/postgres/attachments.sql b/Server/db/queries/postgres/attachments.sql deleted file mode 100644 index cf3e0f06..00000000 --- a/Server/db/queries/postgres/attachments.sql +++ /dev/null @@ -1,27 +0,0 @@ --- PostgreSQL variants of the sqlite attachments queries. - --- name: CreateAttachment :exec -INSERT INTO attachments (id, uploader_id, filename, stored_as, mime_type, size, width, height) -VALUES ($1, $2, $3, $4, $5, $6, $7, $8); - --- name: GetAttachmentByID :one -SELECT id, message_id, filename, stored_as, mime_type, size, uploaded_at, uploader_id -FROM attachments WHERE id = $1; - --- name: GetAttachmentWithChannel :one -SELECT a.id, a.message_id, a.filename, a.stored_as, a.mime_type, a.size, - a.uploaded_at, a.uploader_id, m.channel_id, c.type -FROM attachments a -LEFT JOIN messages m ON m.id = a.message_id -LEFT JOIN channels c ON c.id = m.channel_id -WHERE a.id = $1; - --- name: LinkAttachmentToMessage :execrows -UPDATE attachments SET message_id = $1 WHERE id = $2 AND message_id IS NULL; - --- Postgres timestamptz comparison — the caller passes a wall-clock time. --- name: DeleteOrphanedAttachments :many -DELETE FROM attachments WHERE message_id IS NULL AND uploaded_at < $1 RETURNING stored_as; - --- name: DeleteAttachment :exec -DELETE FROM attachments WHERE id = $1; diff --git a/Server/db/queries/postgres/blocks.sql b/Server/db/queries/postgres/blocks.sql deleted file mode 100644 index 23dda418..00000000 --- a/Server/db/queries/postgres/blocks.sql +++ /dev/null @@ -1,18 +0,0 @@ --- PostgreSQL variants of the sqlite user block queries. --- `INSERT OR IGNORE` becomes `INSERT ... ON CONFLICT DO NOTHING`. - --- name: BlockUser :exec -INSERT INTO user_blocks (blocker_id, blocked_id) VALUES ($1, $2) -ON CONFLICT (blocker_id, blocked_id) DO NOTHING; - --- name: UnblockUser :exec -DELETE FROM user_blocks WHERE blocker_id = $1 AND blocked_id = $2; - --- name: IsBlocked :one -SELECT 1 FROM user_blocks WHERE blocker_id = $1 AND blocked_id = $2 LIMIT 1; - --- name: IsEitherBlocked :one -SELECT 1 FROM user_blocks -WHERE (blocker_id = $1 AND blocked_id = $2) - OR (blocker_id = $3 AND blocked_id = $4) -LIMIT 1; diff --git a/Server/db/queries/postgres/channels.sql b/Server/db/queries/postgres/channels.sql deleted file mode 100644 index 0f3c2b6a..00000000 --- a/Server/db/queries/postgres/channels.sql +++ /dev/null @@ -1,69 +0,0 @@ --- PostgreSQL variants of the sqlite channels queries. - --- name: ListChannels :many -SELECT id, name, type, COALESCE(category, '') AS category, COALESCE(topic, '') AS topic, - position, slow_mode, archived, created_at, - COALESCE(voice_max_users, 0) AS voice_max_users, - voice_quality, - mixing_threshold, - COALESCE(voice_max_video, 0) AS voice_max_video -FROM channels ORDER BY position ASC, id ASC; - --- name: GetChannel :one -SELECT id, name, type, COALESCE(category, '') AS category, COALESCE(topic, '') AS topic, - position, slow_mode, archived, created_at, - COALESCE(voice_max_users, 0) AS voice_max_users, - voice_quality, - mixing_threshold, - COALESCE(voice_max_video, 0) AS voice_max_video -FROM channels WHERE id = $1; - --- name: CreateChannel :one -INSERT INTO channels (name, type, category, topic, position) -VALUES ($1, $2, $3, $4, $5) -RETURNING id; - --- name: UpdateChannel :exec -UPDATE channels SET name = $1, topic = $2, slow_mode = $3 WHERE id = $4; - --- name: SetChannelSlowMode :exec -UPDATE channels SET slow_mode = $1 WHERE id = $2; - --- name: SetChannelVoiceMaxUsers :exec -UPDATE channels SET voice_max_users = $1 WHERE id = $2; - --- name: SetChannelVoiceMaxVideo :exec -UPDATE channels SET voice_max_video = $1 WHERE id = $2; - --- name: SetChannelVoiceQuality :exec -UPDATE channels SET voice_quality = $1 WHERE id = $2; - --- name: SetChannelMixingThreshold :exec -UPDATE channels SET mixing_threshold = $1 WHERE id = $2; - --- name: ArchiveChannel :exec -UPDATE channels SET archived = $1 WHERE id = $2; - --- name: DeleteChannel :exec -DELETE FROM channels WHERE id = $1; - --- name: AdminUpdateChannel :exec -UPDATE channels -SET name = $1, topic = $2, slow_mode = $3, position = $4, archived = $5 -WHERE id = $6; - --- name: UpsertChannelPermission :exec -INSERT INTO channel_overrides (channel_id, role_id, allow, deny) -VALUES ($1, $2, $3, $4) -ON CONFLICT (channel_id, role_id) DO UPDATE SET - allow = EXCLUDED.allow, - deny = EXCLUDED.deny; - --- name: GetChannelPermission :one -SELECT allow, deny FROM channel_overrides WHERE channel_id = $1 AND role_id = $2; - --- name: GetRoleChannelPermissions :many -SELECT channel_id, allow, deny FROM channel_overrides WHERE role_id = $1; - --- name: DeleteChannelPermission :exec -DELETE FROM channel_overrides WHERE channel_id = $1 AND role_id = $2; diff --git a/Server/db/queries/postgres/dm.sql b/Server/db/queries/postgres/dm.sql deleted file mode 100644 index 93cfbedc..00000000 --- a/Server/db/queries/postgres/dm.sql +++ /dev/null @@ -1,64 +0,0 @@ --- PostgreSQL variants of the sqlite DM queries. --- InsertDMChannel: sqlite uses :execresult (LastInsertId); postgres uses --- :one with RETURNING id. --- `INSERT OR IGNORE` becomes `INSERT ... ON CONFLICT DO NOTHING`. - --- name: InsertDMChannel :one -INSERT INTO channels (name, type) VALUES ('', 'dm') RETURNING id; - --- name: InsertDMParticipants :exec -INSERT INTO dm_participants (channel_id, user_id) VALUES ($1, $2), ($3, $4); - --- name: InsertDMOpenState :exec -INSERT INTO dm_open_state (user_id, channel_id) VALUES ($1, $2), ($3, $4) -ON CONFLICT (user_id, channel_id) DO NOTHING; - --- name: FindExistingDMChannel :one -SELECT dp1.channel_id -FROM dm_participants dp1 -JOIN dm_participants dp2 ON dp1.channel_id = dp2.channel_id -JOIN channels c ON c.id = dp1.channel_id -WHERE dp1.user_id = $1 AND dp2.user_id = $2 AND c.type = 'dm' -LIMIT 1; - --- name: OpenDM :exec -INSERT INTO dm_open_state (user_id, channel_id) VALUES ($1, $2) -ON CONFLICT (user_id, channel_id) DO NOTHING; - --- name: CloseDM :exec -DELETE FROM dm_open_state WHERE user_id = $1 AND channel_id = $2; - --- name: IsDMParticipant :one -SELECT user_id FROM dm_participants WHERE user_id = $1 AND channel_id = $2; - --- name: GetDMParticipantIDs :many -SELECT user_id FROM dm_participants WHERE channel_id = $1; - --- For the "last message at" and "last message content" columns, sqlite --- COALESCEs to '' (empty string). Postgres TIMESTAMPTZ cannot COALESCE to an --- empty string, so we COALESCE to the dm_open_state.opened_at fallback and --- leave conversion to the store wrapper. --- name: GetUserDMChannels :many -SELECT - c.id AS channel_id, - u.id AS recipient_id, - u.username AS recipient_username, - COALESCE(u.avatar, '') AS recipient_avatar, - u.status AS recipient_status, - lm.id AS last_message_id, - COALESCE(lm.content, '') AS last_message, - COALESCE(lm.timestamp, dos.opened_at) AS last_message_at, - COUNT(CASE WHEN m_unread.id > COALESCE(rs.last_message_id, 0) - AND m_unread.deleted = FALSE THEN 1 END) AS unread_count -FROM dm_open_state dos -JOIN channels c ON c.id = dos.channel_id AND c.type = 'dm' -JOIN dm_participants dp ON dp.channel_id = c.id AND dp.user_id != $1 -JOIN users u ON u.id = dp.user_id -LEFT JOIN messages lm ON lm.id = ( - SELECT MAX(id) FROM messages WHERE channel_id = c.id AND deleted = FALSE -) -LEFT JOIN messages m_unread ON m_unread.channel_id = c.id -LEFT JOIN read_states rs ON rs.channel_id = c.id AND rs.user_id = $2 -WHERE dos.user_id = $3 -GROUP BY c.id, u.id, lm.id, lm.content, lm.timestamp, dos.opened_at -ORDER BY COALESCE(lm.timestamp, dos.opened_at) DESC; diff --git a/Server/db/queries/postgres/events.sql b/Server/db/queries/postgres/events.sql deleted file mode 100644 index ec332592..00000000 --- a/Server/db/queries/postgres/events.sql +++ /dev/null @@ -1,19 +0,0 @@ --- name: PersistEvent :exec --- seq is supplied by the hub so the row seq matches the wrapped-payload seq. --- The schema's BIGSERIAL still owns the id column for inserts that omit seq, --- but PersistEvent always supplies an explicit value. -INSERT INTO events (seq, event_type, channel_id, payload) -VALUES ($1, $2, $3, $4); - --- name: GetMaxEventSeq :one -SELECT COALESCE(MAX(seq), 0)::BIGINT FROM events; - --- name: GetEventsSince :many -SELECT seq, event_type, channel_id, payload, created_at -FROM events -WHERE seq > $1 -ORDER BY seq ASC -LIMIT $2; - --- name: PruneEventsOlderThan :execrows -DELETE FROM events WHERE created_at < $1; diff --git a/Server/db/queries/postgres/invites.sql b/Server/db/queries/postgres/invites.sql deleted file mode 100644 index cd01e81f..00000000 --- a/Server/db/queries/postgres/invites.sql +++ /dev/null @@ -1,23 +0,0 @@ --- PostgreSQL variants of the sqlite invites queries. --- The expiry check uses native timestamp comparison instead of sqlite's --- strftime('%s', …) trick. - --- name: CreateInvite :exec -INSERT INTO invites (code, created_by, max_uses, expires_at) VALUES ($1, $2, $3, $4); - --- name: GetInvite :one -SELECT id, code, created_by, max_uses, use_count, expires_at, revoked, created_at -FROM invites WHERE code = $1; - --- name: UseInviteAtomic :execrows -UPDATE invites SET use_count = use_count + 1 -WHERE code = $1 AND revoked = FALSE - AND (max_uses IS NULL OR use_count < max_uses) - AND (expires_at IS NULL OR expires_at > NOW()); - --- name: RevokeInvite :exec -UPDATE invites SET revoked = TRUE WHERE code = $1; - --- name: ListInvites :many -SELECT id, code, created_by, max_uses, use_count, expires_at, revoked, created_at -FROM invites ORDER BY created_at DESC LIMIT 200; diff --git a/Server/db/queries/postgres/lockouts.sql b/Server/db/queries/postgres/lockouts.sql deleted file mode 100644 index 3e607926..00000000 --- a/Server/db/queries/postgres/lockouts.sql +++ /dev/null @@ -1,15 +0,0 @@ --- PostgreSQL variants of the sqlite rate-lockout queries. --- `INSERT OR REPLACE` becomes `INSERT ... ON CONFLICT (key) DO UPDATE`. - --- name: UpsertLockout :exec -INSERT INTO rate_lockouts (key, expires_at) VALUES ($1, $2) -ON CONFLICT (key) DO UPDATE SET expires_at = EXCLUDED.expires_at; - --- name: LoadActiveLockouts :many -SELECT key, expires_at FROM rate_lockouts WHERE expires_at > $1; - --- name: CleanupExpiredLockouts :exec -DELETE FROM rate_lockouts WHERE expires_at <= $1; - --- name: DeleteLockout :exec -DELETE FROM rate_lockouts WHERE key = $1; diff --git a/Server/db/queries/postgres/messages.sql b/Server/db/queries/postgres/messages.sql deleted file mode 100644 index 6b73e0ee..00000000 --- a/Server/db/queries/postgres/messages.sql +++ /dev/null @@ -1,79 +0,0 @@ --- PostgreSQL variants of the sqlite messages queries. --- `deleted = 0/1` and `pinned = 0/1` become FALSE/TRUE (columns are BOOLEAN). --- The FTS search queries are NOT included here: on postgres, messages.fts is --- a tsvector column with a GIN index (see migrations/postgres/001_initial_schema.sql) --- and FTS queries are hand-written in the postgres-specific store dispatch, --- mirroring how sqlite's FTS5 queries live in message_queries.go. - --- name: CreateMessage :one -INSERT INTO messages (channel_id, user_id, content, reply_to) -VALUES ($1, $2, $3, $4) -RETURNING id; - --- name: GetMessage :one -SELECT id, channel_id, user_id, content, reply_to, edited_at, deleted, pinned, timestamp -FROM messages WHERE id = $1; - --- name: GetMessagesByChannelBeforeCursor :many -SELECT m.id, m.channel_id, m.user_id, m.content, m.reply_to, - m.edited_at, m.deleted, m.pinned, m.timestamp, - u.username, u.avatar -FROM messages m JOIN users u ON m.user_id = u.id -WHERE m.channel_id = $1 AND m.id < $2 AND m.deleted = FALSE -ORDER BY m.id DESC LIMIT $3; - --- name: GetMessagesByChannel :many -SELECT m.id, m.channel_id, m.user_id, m.content, m.reply_to, - m.edited_at, m.deleted, m.pinned, m.timestamp, - u.username, u.avatar -FROM messages m JOIN users u ON m.user_id = u.id -WHERE m.channel_id = $1 AND m.deleted = FALSE -ORDER BY m.id DESC LIMIT $2; - --- name: GetMessagesForAPIBeforeCursor :many -SELECT m.id, m.channel_id, m.user_id, u.username, u.avatar, - m.content, m.reply_to, m.edited_at, m.deleted, m.pinned, m.timestamp -FROM messages m JOIN users u ON m.user_id = u.id -WHERE m.channel_id = $1 AND m.id < $2 AND m.deleted = FALSE -ORDER BY m.id DESC LIMIT $3; - --- name: GetMessagesForAPI :many -SELECT m.id, m.channel_id, m.user_id, u.username, u.avatar, - m.content, m.reply_to, m.edited_at, m.deleted, m.pinned, m.timestamp -FROM messages m JOIN users u ON m.user_id = u.id -WHERE m.channel_id = $1 AND m.deleted = FALSE -ORDER BY m.id DESC LIMIT $2; - --- name: GetPinnedMessageRows :many -SELECT m.id, m.channel_id, m.user_id, u.username, u.avatar, - m.content, m.reply_to, m.edited_at, m.deleted, m.pinned, m.timestamp -FROM messages m JOIN users u ON m.user_id = u.id -WHERE m.channel_id = $1 AND m.pinned = TRUE AND m.deleted = FALSE -ORDER BY m.id DESC; - --- name: EditMessageContent :exec -UPDATE messages SET content = $1, edited_at = NOW() WHERE id = $2; - --- name: SoftDeleteMessage :exec -UPDATE messages SET deleted = TRUE WHERE id = $1; - --- name: SetMessagePinned :execrows -UPDATE messages SET pinned = $1 WHERE id = $2 AND deleted = FALSE; - --- name: GetLatestMessageID :one -SELECT COALESCE(MAX(id), 0)::BIGINT FROM messages WHERE channel_id = $1 AND deleted = FALSE; - --- name: UpdateReadState :exec -INSERT INTO read_states (user_id, channel_id, last_message_id) -VALUES ($1, $2, $3) -ON CONFLICT (user_id, channel_id) DO UPDATE SET last_message_id = EXCLUDED.last_message_id; - --- name: GetChannelUnreadCounts :many -SELECT c.id, - COALESCE(MAX(m.id), 0)::BIGINT AS last_msg_id, - COUNT(CASE WHEN m.id > COALESCE(rs.last_message_id, 0) AND m.deleted = FALSE THEN 1 END) AS unread -FROM channels c -LEFT JOIN messages m ON m.channel_id = c.id AND m.deleted = FALSE -LEFT JOIN read_states rs ON rs.channel_id = c.id AND rs.user_id = $1 -WHERE c.type = 'text' -GROUP BY c.id; diff --git a/Server/db/queries/postgres/plugins.sql b/Server/db/queries/postgres/plugins.sql deleted file mode 100644 index d0349d86..00000000 --- a/Server/db/queries/postgres/plugins.sql +++ /dev/null @@ -1,36 +0,0 @@ --- name: InstallPlugin :one -INSERT INTO plugins (name, version, manifest_json) -VALUES ($1, $2, $3) -ON CONFLICT (name) DO UPDATE - SET version = excluded.version, - manifest_json = excluded.manifest_json -RETURNING id; - --- name: EnablePlugin :exec -UPDATE plugins SET enabled = TRUE WHERE id = $1; - --- name: DisablePlugin :exec -UPDATE plugins SET enabled = FALSE WHERE id = $1; - --- name: UninstallPlugin :exec -DELETE FROM plugins WHERE id = $1; - --- name: GetPlugin :one -SELECT id, name, version, enabled, manifest_json, installed_at FROM plugins WHERE id = $1; - --- name: GetPluginByName :one -SELECT id, name, version, enabled, manifest_json, installed_at FROM plugins WHERE name = $1; - --- name: ListPlugins :many -SELECT id, name, version, enabled, manifest_json, installed_at FROM plugins ORDER BY name; - --- name: PluginKVGet :one -SELECT value FROM plugin_kv WHERE plugin_id = $1 AND key = $2; - --- name: PluginKVSet :exec -INSERT INTO plugin_kv (plugin_id, key, value) -VALUES ($1, $2, $3) -ON CONFLICT (plugin_id, key) DO UPDATE SET value = excluded.value; - --- name: PluginKVDelete :exec -DELETE FROM plugin_kv WHERE plugin_id = $1 AND key = $2; diff --git a/Server/db/queries/postgres/profile.sql b/Server/db/queries/postgres/profile.sql deleted file mode 100644 index d95a03a4..00000000 --- a/Server/db/queries/postgres/profile.sql +++ /dev/null @@ -1,9 +0,0 @@ --- PostgreSQL variants of the sqlite profile queries. --- UpdateUserProfile uses :execrows because postgres has no LastInsertId; --- the caller checks rows-affected for existence. - --- name: UpdateUserProfile :execrows -UPDATE users SET username = $1, avatar = $2 WHERE id = $3; - --- name: UpdateUserPassword :exec -UPDATE users SET password = $1 WHERE id = $2; diff --git a/Server/db/queries/postgres/reactions.sql b/Server/db/queries/postgres/reactions.sql deleted file mode 100644 index 4980c16f..00000000 --- a/Server/db/queries/postgres/reactions.sql +++ /dev/null @@ -1,12 +0,0 @@ --- PostgreSQL variants of the sqlite reactions queries. - --- name: AddReaction :exec -INSERT INTO reactions (message_id, user_id, emoji) VALUES ($1, $2, $3); - --- name: RemoveReaction :execrows -DELETE FROM reactions WHERE message_id = $1 AND user_id = $2 AND emoji = $3; - --- name: GetReactionCounts :many -SELECT emoji, COUNT(*) AS count -FROM reactions WHERE message_id = $1 -GROUP BY emoji; diff --git a/Server/db/queries/postgres/roles.sql b/Server/db/queries/postgres/roles.sql deleted file mode 100644 index a52474d9..00000000 --- a/Server/db/queries/postgres/roles.sql +++ /dev/null @@ -1,29 +0,0 @@ --- PostgreSQL variants of the sqlite roles queries. --- `is_default = 1` becomes `is_default = TRUE` since the column is BOOLEAN. - --- name: GetRoleByID :one -SELECT id, name, color, permissions, position, is_default -FROM roles WHERE id = $1; - --- name: ListRoles :many -SELECT id, name, color, permissions, position, is_default -FROM roles ORDER BY position DESC; - --- name: GetRoleForUser :one -SELECT r.id, r.name, r.color, r.permissions, r.position, r.is_default -FROM users u -JOIN roles r ON u.role_id = r.id -WHERE u.id = $1; - --- name: GetUserWithRole :one -SELECT u.id, u.username, u.password, u.avatar, u.role_id, - u.totp_secret, u.status, u.created_at, u.last_seen, - u.banned, u.ban_reason, u.ban_expires, - r.id, r.name, r.color, r.permissions, r.position, r.is_default -FROM users u -JOIN roles r ON r.id = u.role_id -WHERE u.id = $1; - --- name: GetDefaultRole :one -SELECT id, name, color, permissions, position, is_default -FROM roles WHERE is_default = TRUE LIMIT 1; diff --git a/Server/db/queries/postgres/sessions.sql b/Server/db/queries/postgres/sessions.sql deleted file mode 100644 index 33845cd7..00000000 --- a/Server/db/queries/postgres/sessions.sql +++ /dev/null @@ -1,49 +0,0 @@ --- PostgreSQL variants of the sqlite sessions queries. - --- name: InsertSession :one -INSERT INTO sessions (user_id, token, device, ip_address, expires_at) -VALUES ($1, $2, $3, $4, $5) -RETURNING id; - --- Delete all but the N most recent sessions for a user. Postgres replaces --- sqlite's `LIMIT -1 OFFSET ?` with `OFFSET $2`. --- name: EvictOldestSessions :exec -DELETE FROM sessions WHERE id IN ( - SELECT s2.id FROM sessions AS s2 WHERE s2.user_id = $1 - ORDER BY s2.created_at DESC - OFFSET $2 -); - --- name: GetSessionByTokenHash :one -SELECT id, user_id, token, device, ip_address, created_at, last_used, expires_at -FROM sessions WHERE token = $1; - --- name: GetSessionWithBanStatus :one -SELECT s.id, s.user_id, s.token, s.device, s.ip_address, - s.created_at, s.last_used, s.expires_at, - u.banned, u.ban_reason, u.ban_expires -FROM sessions s -JOIN users u ON s.user_id = u.id -WHERE s.token = $1; - --- name: DeleteSessionByToken :exec -DELETE FROM sessions WHERE token = $1; - --- name: DeleteSessionByID :exec -DELETE FROM sessions WHERE id = $1 AND user_id = $2; - --- name: DeleteOtherSessions :execrows -DELETE FROM sessions WHERE user_id = $1 AND id != $2; - --- Use native timestamp comparison instead of sqlite's strftime trick. --- name: DeleteExpiredSessions :exec -DELETE FROM sessions WHERE expires_at < NOW(); - --- name: TouchSession :exec -UPDATE sessions SET last_used = NOW() WHERE token = $1; - --- name: ListUserSessions :many -SELECT id, user_id, token, device, ip_address, created_at, last_used, expires_at -FROM sessions -WHERE user_id = $1 -ORDER BY created_at DESC; diff --git a/Server/db/queries/postgres/users.sql b/Server/db/queries/postgres/users.sql deleted file mode 100644 index 6a244152..00000000 --- a/Server/db/queries/postgres/users.sql +++ /dev/null @@ -1,51 +0,0 @@ --- PostgreSQL variants of the sqlite users queries. --- Differences from sqlite: --- - `?` -> `$1`, `$2`, … --- - `COLLATE NOCASE` -> removed; the `username` column is CITEXT. --- - `datetime('now')` -> `NOW()` --- - `banned = 0/1` -> `banned = FALSE/TRUE` (column is BOOLEAN) --- - `:execresult INSERT` -> `:one ... RETURNING id` (pgx has no LastInsertId) - --- name: GetUserByUsername :one -SELECT id, username, password, avatar, role_id, totp_secret, status, - created_at, last_seen, banned, ban_reason, ban_expires -FROM users WHERE username = $1; - --- name: GetUserByID :one -SELECT id, username, password, avatar, role_id, totp_secret, status, - created_at, last_seen, banned, ban_reason, ban_expires -FROM users WHERE id = $1; - --- name: CreateUser :one -INSERT INTO users (username, password, role_id) -VALUES ($1, $2, $3) -RETURNING id; - --- name: UpdateUserStatus :exec -UPDATE users SET status = $1, last_seen = NOW() WHERE id = $2; - --- name: UpdateUserTOTPSecret :exec -UPDATE users SET totp_secret = $1 WHERE id = $2; - --- name: ResetAllUserStatuses :exec -UPDATE users SET status = 'offline' WHERE status != 'offline'; - --- name: BanUser :exec -UPDATE users SET banned = TRUE, ban_reason = $1, ban_expires = $2 WHERE id = $3; - --- name: UnbanUser :exec -UPDATE users SET banned = FALSE, ban_reason = NULL, ban_expires = NULL WHERE id = $1; - --- name: ListMembers :many -SELECT u.id, u.username, u.avatar, u.status, LOWER(r.name) -FROM users u -JOIN roles r ON u.role_id = r.id -WHERE u.banned = FALSE -ORDER BY u.username ASC -LIMIT 1000; - --- name: CountUsers :one -SELECT COUNT(*) FROM users; - --- name: CountUsersWithoutTOTP :one -SELECT COUNT(*) FROM users WHERE banned = FALSE AND totp_secret IS NULL; diff --git a/Server/db/queries/postgres/voice.sql b/Server/db/queries/postgres/voice.sql deleted file mode 100644 index c9db052a..00000000 --- a/Server/db/queries/postgres/voice.sql +++ /dev/null @@ -1,88 +0,0 @@ --- PostgreSQL variants of the sqlite voice queries. --- voice_states boolean columns (muted, deafened, speaking, camera, --- screenshare) use FALSE/TRUE instead of 0/1. - --- name: JoinVoiceChannel :exec -INSERT INTO voice_states (user_id, channel_id, muted, deafened, speaking, camera, screenshare, joined_at) -VALUES ($1, $2, FALSE, FALSE, FALSE, FALSE, FALSE, $3) -ON CONFLICT (user_id) DO UPDATE SET - channel_id = EXCLUDED.channel_id, - muted = FALSE, - deafened = FALSE, - speaking = FALSE, - camera = FALSE, - screenshare = FALSE, - joined_at = EXCLUDED.joined_at; - --- name: JoinVoiceChannelIfCapacity :execrows -INSERT INTO voice_states (user_id, channel_id, muted, deafened, speaking, camera, screenshare, joined_at) -SELECT $1, $2, FALSE, FALSE, FALSE, FALSE, FALSE, $3 -WHERE (SELECT COUNT(*) FROM voice_states AS vs2 WHERE vs2.channel_id = $4) < $5 -ON CONFLICT (user_id) DO UPDATE SET - channel_id = EXCLUDED.channel_id, - muted = FALSE, - deafened = FALSE, - speaking = FALSE, - camera = FALSE, - screenshare = FALSE, - joined_at = EXCLUDED.joined_at; - --- name: LeaveVoiceChannel :exec -DELETE FROM voice_states WHERE user_id = $1; - --- name: LeaveVoiceChannelIfMatch :execrows -DELETE FROM voice_states WHERE user_id = $1 AND channel_id = $2 AND joined_at = $3; - --- name: GetUserVoiceState :one -SELECT vs.user_id, vs.channel_id, u.username, - vs.muted, vs.deafened, vs.speaking, - vs.camera, vs.screenshare, vs.joined_at -FROM voice_states vs -JOIN users u ON u.id = vs.user_id -WHERE vs.user_id = $1; - --- name: GetChannelVoiceStates :many -SELECT vs.user_id, vs.channel_id, u.username, - vs.muted, vs.deafened, vs.speaking, - vs.camera, vs.screenshare, vs.joined_at -FROM voice_states vs -JOIN users u ON u.id = vs.user_id -WHERE vs.channel_id = $1 -ORDER BY vs.joined_at ASC; - --- name: GetAllVoiceStates :many -SELECT vs.user_id, vs.channel_id, u.username, - vs.muted, vs.deafened, vs.speaking, - vs.camera, vs.screenshare, vs.joined_at -FROM voice_states vs -JOIN users u ON u.id = vs.user_id -ORDER BY vs.channel_id, vs.joined_at ASC; - --- name: UpdateVoiceMute :exec -UPDATE voice_states SET muted = $1 WHERE user_id = $2; - --- name: UpdateVoiceDeafen :exec -UPDATE voice_states SET deafened = $1 WHERE user_id = $2; - --- name: UpdateVoiceSpeaking :exec -UPDATE voice_states SET speaking = $1 WHERE user_id = $2; - --- name: UpdateVoiceCamera :exec -UPDATE voice_states SET camera = $1 WHERE user_id = $2; - --- name: UpdateVoiceScreenshare :exec -UPDATE voice_states SET screenshare = $1 WHERE user_id = $2; - --- name: EnableCameraIfUnderLimit :execrows -UPDATE voice_states SET camera = TRUE -WHERE voice_states.user_id = $1 AND voice_states.channel_id = $2 - AND (SELECT COUNT(*) FROM voice_states AS vs2 WHERE vs2.channel_id = $3 AND vs2.camera = TRUE) < $4; - --- name: ClearVoiceState :exec -DELETE FROM voice_states WHERE user_id = $1; - --- name: ClearAllVoiceStates :exec -DELETE FROM voice_states; - --- name: CountActiveCameras :one -SELECT COUNT(*) FROM voice_states WHERE channel_id = $1 AND camera = TRUE; diff --git a/Server/go.mod b/Server/go.mod index 69a5c4bb..48165a87 100644 --- a/Server/go.mod +++ b/Server/go.mod @@ -8,7 +8,6 @@ require ( github.com/corazawaf/coraza/v3 v3.6.0 github.com/go-chi/chi/v5 v5.2.5 github.com/google/uuid v1.6.0 - github.com/jackc/pgx/v5 v5.9.1 github.com/knadh/koanf/parsers/yaml v1.1.0 github.com/knadh/koanf/providers/env v1.1.0 github.com/knadh/koanf/providers/file v1.2.1 @@ -69,9 +68,8 @@ require ( github.com/gotnospirit/makeplural v0.0.0-20180622080156-a5f48d94d976 // indirect github.com/gotnospirit/messageformat v0.0.0-20221001023931-dfe49f1eb092 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 // indirect - github.com/jackc/pgpassfile v1.0.0 // indirect - github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect - github.com/jackc/puddle/v2 v2.2.2 // indirect + github.com/hashicorp/go-cleanhttp v0.5.2 // indirect + github.com/hashicorp/go-retryablehttp v0.7.7 // indirect github.com/jxskiss/base62 v1.1.0 // indirect github.com/kaptinlin/go-i18n v0.1.4 // indirect github.com/kaptinlin/jsonschema v0.4.6 // indirect diff --git a/Server/go.sum b/Server/go.sum index bbd7e61a..f10fffaa 100644 --- a/Server/go.sum +++ b/Server/go.sum @@ -69,6 +69,8 @@ github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4 github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM= +github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE= github.com/fatih/structs v1.1.0 h1:Q7juDM0QtcnhCpeyLGQKyg4TOIghuNXrkL32pHAUMxo= github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= @@ -119,16 +121,14 @@ github.com/gotnospirit/messageformat v0.0.0-20221001023931-dfe49f1eb092 h1:c7gcN github.com/gotnospirit/messageformat v0.0.0-20221001023931-dfe49f1eb092/go.mod h1:ZZAN4fkkful3l1lpJwF8JbW41ZiG9TwJ2ZlqzQovBNU= github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 h1:HWRh5R2+9EifMyIHV7ZV+MIZqgz+PMpZ14Jynv3O2Zs= github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0/go.mod h1:JfhWUomR1baixubs02l85lZYYOm7LV6om4ceouMv45c= +github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ= +github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= +github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k= +github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M= +github.com/hashicorp/go-retryablehttp v0.7.7 h1:C8hUCYzor8PIfXHa4UrZkU4VvK8o9ISHxT2Q8+VepXU= +github.com/hashicorp/go-retryablehttp v0.7.7/go.mod h1:pkQpWZeYWskR+D1tR2O5OcBFOxfA7DoAO6xtkuQnHTk= github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= -github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= -github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= -github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= -github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= -github.com/jackc/pgx/v5 v5.9.1 h1:uwrxJXBnx76nyISkhr33kQLlUqjv7et7b9FjCen/tdc= -github.com/jackc/pgx/v5 v5.9.1/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= -github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= -github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/jcchavezs/mergefs v0.1.0 h1:7oteO7Ocl/fnfFMkoVLJxTveCjrsd//UB0j89xmnpec= github.com/jcchavezs/mergefs v0.1.0/go.mod h1:eRLTrsA+vFwQZ48hj8p8gki/5v9C2bFtHH5Mnn4bcGk= github.com/jxskiss/base62 v1.1.0 h1:A5zbF8v8WXx2xixnAKD2w+abC+sIzYJX+nxmhA6HWFw= @@ -173,6 +173,8 @@ github.com/livekit/server-sdk-go/v2 v2.16.0 h1:xbr6PLprgasruzEk4Qv2sHVcK6r+cebUv github.com/livekit/server-sdk-go/v2 v2.16.0/go.mod h1:+HCKTpzV21b/jvBtu+OmWbquUxaL74kHLI9ZwKmdhKU= github.com/magefile/mage v1.15.1-0.20250615140142-78acbaf2e3ae h1:yyMUG1VUd6IjV5jonMKpLXgwm9AzkfRsYisdCXc5OVI= github.com/magefile/mage v1.15.1-0.20250615140142-78acbaf2e3ae/go.mod h1:z5UZb/iS3GoOSn0JgWuiw7dxlurVYTu+/jHXqQg881A= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk= @@ -283,7 +285,6 @@ github.com/shoenig/test v1.7.0/go.mod h1:UxJ6u/x2v/TNs/LoLxBNJRV9DiwBBKYxXSyczsB github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= diff --git a/Server/main.go b/Server/main.go index d58b62c3..e983d5c3 100644 --- a/Server/main.go +++ b/Server/main.go @@ -95,30 +95,11 @@ func run(log *slog.Logger, logBuf *admin.RingBuffer) error { printBanner(cfg, version, tlsCfg != nil) // ── 4. Open database + run migrations ───────────────────────────────── - // The Phase A plan calls for two backends (sqlite, postgres) selected via - // config. SQLite is the only backend currently wired through the *db.DB - // type. The PostgreSQL scaffolding is in place (schema under - // Server/migrations/postgres, sqlc query files under - // Server/db/queries/postgres, PostgresStore behind the `postgres` build - // tag in Server/store/postgres.go), but PostgresStore's query methods - // are still stubs and the handler boundary still passes *db.DB directly - // rather than store.Store. Until both of those land, selecting - // type: "postgres" refuses to start with a clear pointer at what's left. - switch dbType := cfg.Database.Type; dbType { - case "", "sqlite": - // fall through to the existing SQLite path - case "postgres": - return fmt.Errorf("database.type=postgres is configured, but the postgres " + - "backend is not yet wired into the runtime. PostgresStore exists at " + - "Server/store/postgres.go behind the `postgres` build tag, with connection " + - "lifecycle fully implemented but query methods stubbed. What's still " + - "pending: (1) run `make sqlc-generate` to produce Server/db/pgdbgen/, " + - "(2) replace the stub query methods in postgres.go with wrappers around " + - "pgdbgen, and (3) refactor api/router.go and this main.go to thread " + - "store.Store through the handler boundary instead of *db.DB. Until those " + - "land, set database.type to \"sqlite\" or omit it to start the server") - default: - return fmt.Errorf("database.type=%q is not recognised; expected \"sqlite\" or \"postgres\"", dbType) + // SQLite is the only supported backend; the unfinished Postgres + // scaffolding (stubbed query layer, never wired into the runtime) was + // removed rather than completed. + if t := cfg.Database.Type; t != "" && t != "sqlite" { + return fmt.Errorf("database.type=%q is not supported; set \"sqlite\" or omit it", t) } database, err := db.Open(cfg.Database.Path) diff --git a/Server/migrations/postgres/001_initial_schema.sql b/Server/migrations/postgres/001_initial_schema.sql deleted file mode 100644 index 07fc8ab5..00000000 --- a/Server/migrations/postgres/001_initial_schema.sql +++ /dev/null @@ -1,336 +0,0 @@ --- Migration 001 (PostgreSQL): Initial schema --- --- This is a consolidated PostgreSQL translation of the SQLite migrations --- 001-013 found in Server/migrations/. PostgreSQL is a fresh-start backend, --- so we collapse the SQLite migration history into a single canonical schema --- file. Future PostgreSQL schema changes should land as 002_*.sql, 003_*.sql, --- etc., mirroring the SQLite numbering convention. --- --- DIFFERENCES FROM SQLITE: --- - INTEGER PRIMARY KEY AUTOINCREMENT -> BIGSERIAL PRIMARY KEY --- - TEXT NOT NULL DEFAULT (datetime('now')) -> TIMESTAMPTZ NOT NULL DEFAULT NOW() --- - INTEGER (used as bool) -> BOOLEAN NOT NULL DEFAULT FALSE --- - INTEGER permission bitfield -> BIGINT NOT NULL DEFAULT 0 --- - FTS5 virtual table -> tsvector column on messages + GIN index + --- trigger to keep tsvector in sync (see "Full-text search" section). --- - SQLite triggers using RAISE(ABORT) -> native CHECK constraints. --- - COLLATE NOCASE -> CITEXT extension on the username column. --- --- The store.MessageStore.SearchMessages implementation must dispatch on --- backend type because the query syntax differs (MATCH vs @@). - -CREATE EXTENSION IF NOT EXISTS citext; - --- ── roles ─────────────────────────────────────────────────────────────────── -CREATE TABLE IF NOT EXISTS roles ( - id BIGSERIAL PRIMARY KEY, - name TEXT NOT NULL UNIQUE, - color TEXT, - permissions BIGINT NOT NULL DEFAULT 0, - position INTEGER NOT NULL DEFAULT 0, - is_default BOOLEAN NOT NULL DEFAULT FALSE -); - --- Default roles. Permission bitfields match the SQLite seed values. --- Member final value (7779) reflects SQLite migrations 005 and 007 combined. -INSERT INTO roles (id, name, color, permissions, position, is_default) VALUES - (1, 'Owner', '#E74C3C', 2147483647, 100, FALSE), - (2, 'Admin', '#F39C12', 1073741823, 80, FALSE), - (3, 'Moderator', '#3498DB', 1048575, 60, FALSE), - (4, 'Member', NULL, 7779, 40, TRUE) -ON CONFLICT (id) DO NOTHING; - --- Reset the sequence past the seeded rows so user-created roles get IDs >= 5. -SELECT setval('roles_id_seq', GREATEST((SELECT MAX(id) FROM roles), 1)); - --- ── users ─────────────────────────────────────────────────────────────────── -CREATE TABLE IF NOT EXISTS users ( - id BIGSERIAL PRIMARY KEY, - username CITEXT NOT NULL UNIQUE, - password TEXT NOT NULL, - avatar TEXT, - role_id BIGINT NOT NULL DEFAULT 4 REFERENCES roles(id), - totp_secret TEXT, - status TEXT NOT NULL DEFAULT 'offline', - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - last_seen TIMESTAMPTZ, - banned BOOLEAN NOT NULL DEFAULT FALSE, - ban_reason TEXT, - ban_expires TIMESTAMPTZ -); - --- ── sessions ──────────────────────────────────────────────────────────────── -CREATE TABLE IF NOT EXISTS sessions ( - id BIGSERIAL PRIMARY KEY, - user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, - token TEXT NOT NULL UNIQUE, - device TEXT, - ip_address TEXT, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - last_used TIMESTAMPTZ NOT NULL DEFAULT NOW(), - expires_at TIMESTAMPTZ NOT NULL -); - -CREATE INDEX IF NOT EXISTS idx_sessions_token ON sessions(token); -CREATE INDEX IF NOT EXISTS idx_sessions_user ON sessions(user_id); - --- ── channels ──────────────────────────────────────────────────────────────── --- Includes columns from migrations 001 + 004 (voice columns). --- The CHECK constraint replaces SQLite migration 013's trigger. -CREATE TABLE IF NOT EXISTS channels ( - id BIGSERIAL PRIMARY KEY, - name TEXT NOT NULL, - type TEXT NOT NULL DEFAULT 'text' - CHECK (type IN ('text', 'voice', 'dm')), - category TEXT, - topic TEXT, - position INTEGER NOT NULL DEFAULT 0, - slow_mode INTEGER NOT NULL DEFAULT 0, - archived BOOLEAN NOT NULL DEFAULT FALSE, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - voice_max_users INTEGER NOT NULL DEFAULT 0, - voice_quality TEXT, - mixing_threshold INTEGER, - voice_max_video INTEGER NOT NULL DEFAULT 25 -); - --- ── channel_overrides ─────────────────────────────────────────────────────── -CREATE TABLE IF NOT EXISTS channel_overrides ( - id BIGSERIAL PRIMARY KEY, - channel_id BIGINT NOT NULL REFERENCES channels(id) ON DELETE CASCADE, - role_id BIGINT NOT NULL REFERENCES roles(id) ON DELETE CASCADE, - allow BIGINT NOT NULL DEFAULT 0, - deny BIGINT NOT NULL DEFAULT 0, - UNIQUE (channel_id, role_id) -); - -CREATE INDEX IF NOT EXISTS idx_channel_overrides_channel_role - ON channel_overrides(channel_id, role_id); - --- ── messages + full-text search ───────────────────────────────────────────── --- PostgreSQL uses a tsvector column with a GIN index instead of the SQLite --- FTS5 virtual table. The fts column is maintained automatically by a --- trigger so application code doesn't need to set it. -CREATE TABLE IF NOT EXISTS messages ( - id BIGSERIAL PRIMARY KEY, - channel_id BIGINT NOT NULL REFERENCES channels(id) ON DELETE CASCADE, - user_id BIGINT NOT NULL REFERENCES users(id), - content TEXT NOT NULL, - reply_to BIGINT REFERENCES messages(id) ON DELETE SET NULL, - edited_at TIMESTAMPTZ, - deleted BOOLEAN NOT NULL DEFAULT FALSE, - pinned BOOLEAN NOT NULL DEFAULT FALSE, - timestamp TIMESTAMPTZ NOT NULL DEFAULT NOW(), - fts tsvector -); - -CREATE INDEX IF NOT EXISTS idx_messages_channel ON messages(channel_id, id DESC); -CREATE INDEX IF NOT EXISTS idx_messages_user ON messages(user_id); -CREATE INDEX IF NOT EXISTS idx_messages_fts ON messages USING GIN (fts); - -CREATE OR REPLACE FUNCTION messages_fts_update() RETURNS trigger AS $$ -BEGIN - NEW.fts := to_tsvector('simple', COALESCE(NEW.content, '')); - RETURN NEW; -END; -$$ LANGUAGE plpgsql; - -DROP TRIGGER IF EXISTS trg_messages_fts_update ON messages; -CREATE TRIGGER trg_messages_fts_update - BEFORE INSERT OR UPDATE OF content ON messages - FOR EACH ROW - EXECUTE FUNCTION messages_fts_update(); - --- ── attachments ───────────────────────────────────────────────────────────── --- Combines migrations 001 + 008 (width, height) + 010 (uploader_id). -CREATE TABLE IF NOT EXISTS attachments ( - id TEXT PRIMARY KEY, - message_id BIGINT REFERENCES messages(id) ON DELETE CASCADE, - filename TEXT NOT NULL, - stored_as TEXT NOT NULL, - mime_type TEXT NOT NULL, - size BIGINT NOT NULL, - uploaded_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - width INTEGER, - height INTEGER, - uploader_id BIGINT REFERENCES users(id) -); - -CREATE INDEX IF NOT EXISTS idx_attachments_uploader ON attachments(uploader_id); - --- ── reactions ─────────────────────────────────────────────────────────────── -CREATE TABLE IF NOT EXISTS reactions ( - id BIGSERIAL PRIMARY KEY, - message_id BIGINT NOT NULL REFERENCES messages(id) ON DELETE CASCADE, - user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, - emoji TEXT NOT NULL, - UNIQUE (message_id, user_id, emoji) -); - --- ── invites ───────────────────────────────────────────────────────────────── -CREATE TABLE IF NOT EXISTS invites ( - id BIGSERIAL PRIMARY KEY, - code TEXT NOT NULL UNIQUE, - created_by BIGINT NOT NULL REFERENCES users(id), - redeemed_by BIGINT REFERENCES users(id), - max_uses INTEGER, - use_count INTEGER NOT NULL DEFAULT 0, - expires_at TIMESTAMPTZ, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - revoked BOOLEAN NOT NULL DEFAULT FALSE -); - -CREATE INDEX IF NOT EXISTS idx_invites_code ON invites(code); - --- ── read_states ───────────────────────────────────────────────────────────── -CREATE TABLE IF NOT EXISTS read_states ( - user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, - channel_id BIGINT NOT NULL REFERENCES channels(id) ON DELETE CASCADE, - last_message_id BIGINT NOT NULL DEFAULT 0, - mention_count INTEGER NOT NULL DEFAULT 0, - PRIMARY KEY (user_id, channel_id) -); - --- ── audit_log ─────────────────────────────────────────────────────────────── --- Phase-6 canonical column names (matches SQLite migration 003). -CREATE TABLE IF NOT EXISTS audit_log ( - id BIGSERIAL PRIMARY KEY, - actor_id BIGINT NOT NULL DEFAULT 0, - action TEXT NOT NULL, - target_type TEXT NOT NULL DEFAULT '', - target_id BIGINT NOT NULL DEFAULT 0, - detail TEXT NOT NULL DEFAULT '', - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -); - -CREATE INDEX IF NOT EXISTS idx_audit_timestamp ON audit_log(created_at DESC); -CREATE INDEX IF NOT EXISTS idx_audit_log_actor ON audit_log(actor_id); - --- ── login_attempts ────────────────────────────────────────────────────────── -CREATE TABLE IF NOT EXISTS login_attempts ( - id BIGSERIAL PRIMARY KEY, - ip_address TEXT NOT NULL, - username TEXT, - success BOOLEAN NOT NULL DEFAULT FALSE, - timestamp TIMESTAMPTZ NOT NULL DEFAULT NOW() -); - -CREATE INDEX IF NOT EXISTS idx_login_ip ON login_attempts(ip_address, timestamp); - --- ── settings ──────────────────────────────────────────────────────────────── -CREATE TABLE IF NOT EXISTS settings ( - key TEXT PRIMARY KEY, - value TEXT NOT NULL -); - -INSERT INTO settings (key, value) VALUES - ('server_name', 'OwnCord Server'), - ('server_icon', ''), - ('motd', 'Welcome!'), - ('max_upload_bytes', '26214400'), - ('voice_quality', 'high'), - ('require_2fa', '0'), - ('registration_open', '0'), - ('backup_schedule', 'daily'), - ('backup_retention', '7'), - ('schema_version', '1') -ON CONFLICT (key) DO NOTHING; - --- ── emoji ─────────────────────────────────────────────────────────────────── -CREATE TABLE IF NOT EXISTS emoji ( - id BIGSERIAL PRIMARY KEY, - shortcode TEXT NOT NULL UNIQUE, - filename TEXT NOT NULL, - uploaded_by BIGINT NOT NULL REFERENCES users(id), - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -); - --- ── sounds ────────────────────────────────────────────────────────────────── -CREATE TABLE IF NOT EXISTS sounds ( - id BIGSERIAL PRIMARY KEY, - name TEXT NOT NULL, - filename TEXT NOT NULL, - duration_ms INTEGER NOT NULL, - uploaded_by BIGINT NOT NULL REFERENCES users(id), - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -); - --- ── voice_states ──────────────────────────────────────────────────────────── --- Combines migrations 002 + 004 (camera, screenshare). -CREATE TABLE IF NOT EXISTS voice_states ( - user_id BIGINT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, - channel_id BIGINT NOT NULL REFERENCES channels(id) ON DELETE CASCADE, - muted BOOLEAN NOT NULL DEFAULT FALSE, - deafened BOOLEAN NOT NULL DEFAULT FALSE, - speaking BOOLEAN NOT NULL DEFAULT FALSE, - joined_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - camera BOOLEAN NOT NULL DEFAULT FALSE, - screenshare BOOLEAN NOT NULL DEFAULT FALSE -); - -CREATE INDEX IF NOT EXISTS idx_voice_states_channel ON voice_states(channel_id); - --- ── direct messages ───────────────────────────────────────────────────────── -CREATE TABLE IF NOT EXISTS dm_participants ( - channel_id BIGINT NOT NULL REFERENCES channels(id) ON DELETE CASCADE, - user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, - PRIMARY KEY (channel_id, user_id) -); - -CREATE INDEX IF NOT EXISTS idx_dm_participants_user ON dm_participants(user_id); - -CREATE TABLE IF NOT EXISTS dm_open_state ( - user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, - channel_id BIGINT NOT NULL REFERENCES channels(id) ON DELETE CASCADE, - opened_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - PRIMARY KEY (user_id, channel_id) -); - --- ── rate_lockouts ─────────────────────────────────────────────────────────── -CREATE TABLE IF NOT EXISTS rate_lockouts ( - key TEXT PRIMARY KEY, - expires_at TIMESTAMPTZ NOT NULL -); - --- ── user_blocks ───────────────────────────────────────────────────────────── -CREATE TABLE IF NOT EXISTS user_blocks ( - blocker_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, - blocked_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - PRIMARY KEY (blocker_id, blocked_id), - CHECK (blocker_id <> blocked_id) -); - -CREATE INDEX IF NOT EXISTS idx_user_blocks_blocked ON user_blocks(blocked_id, blocker_id); - --- ── events (Phase B Step 7: event persistence) ────────────────────────────── --- Cold-storage replay buffer for WebSocket reconnections that fall outside the --- in-memory ring window. Pruned by a background goroutine after the configured --- retention window (default 24h). -CREATE TABLE IF NOT EXISTS events ( - seq BIGSERIAL PRIMARY KEY, - event_type TEXT NOT NULL, - payload BYTEA NOT NULL, - channel_id BIGINT NOT NULL DEFAULT 0, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -); - -CREATE INDEX IF NOT EXISTS idx_events_channel_seq ON events(channel_id, seq); -CREATE INDEX IF NOT EXISTS idx_events_created_at ON events(created_at); - --- ── plugins (Phase C Step 9: Wazero plugin runtime) ───────────────────────── -CREATE TABLE IF NOT EXISTS plugins ( - id BIGSERIAL PRIMARY KEY, - name TEXT NOT NULL UNIQUE, - version TEXT NOT NULL, - enabled BOOLEAN NOT NULL DEFAULT FALSE, - manifest_json TEXT NOT NULL, - installed_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -); - -CREATE TABLE IF NOT EXISTS plugin_kv ( - plugin_id BIGINT NOT NULL REFERENCES plugins(id) ON DELETE CASCADE, - key TEXT NOT NULL, - value BYTEA NOT NULL, - PRIMARY KEY (plugin_id, key) -); diff --git a/Server/migrations/postgres/migrations.go b/Server/migrations/postgres/migrations.go deleted file mode 100644 index 824b8c73..00000000 --- a/Server/migrations/postgres/migrations.go +++ /dev/null @@ -1,17 +0,0 @@ -// Package postgres holds embedded SQL migration files for the PostgreSQL -// backend of the OwnCord server. PostgreSQL is opt-in via the -// `database.type = "postgres"` setting in owncord.yaml. -// -// PostgreSQL migrations are numbered independently from the SQLite migration -// set in Server/migrations/. The two are NOT interchangeable: PostgreSQL -// uses native types (BIGSERIAL, TIMESTAMPTZ, BOOLEAN, tsvector) and a -// consolidated initial schema rather than the historical SQLite migration -// chain. -package postgres - -import "embed" - -// FS holds all PostgreSQL migration SQL files embedded at compile time. -// -//go:embed *.sql -var FS embed.FS diff --git a/Server/plugin/host_commands.go b/Server/plugin/host_commands.go index 2cd9b026..fd9b972b 100644 --- a/Server/plugin/host_commands.go +++ b/Server/plugin/host_commands.go @@ -33,7 +33,12 @@ func (r *Registry) RegisterCommand(cmd string, inst *Instance) error { } r.mu.Lock() defer r.mu.Unlock() - if existing, ok := r.commands[cmd]; ok && existing != inst { + // Ownership is compared by plugin identity (manifest name — unique per + // registry), not instance pointer: an in-place upgrade replaces the + // *Instance, and the same plugin must be able to re-bind its own + // commands. A *different* plugin claiming an owned command is still + // refused (cross-plugin command-hijack protection). + if existing, ok := r.commands[cmd]; ok && existing.Manifest.Name != inst.Manifest.Name { return fmt.Errorf("plugin: command %q already registered by %q", cmd, existing.Manifest.Name) } r.commands[cmd] = inst diff --git a/Server/plugin/host_http.go b/Server/plugin/host_http.go index 34056d50..319a9278 100644 --- a/Server/plugin/host_http.go +++ b/Server/plugin/host_http.go @@ -65,9 +65,10 @@ func (r *Registry) HTTPDo(ctx context.Context, inst *Instance, req HTTPRequest) if !r.hostAllowed(host) { return nil, fmt.Errorf("%w: %s", ErrHTTPHostDenied, host) } - if err := rejectPrivateAddrs(ctx, host); err != nil { - return nil, fmt.Errorf("%w: %v", ErrHTTPHostDenied, err) - } + // No pre-resolve here: the transport's guarded dial resolves once, + // validates every address, and dials only vetted IPs — it is the + // authoritative SSRF check, and a second lookup would just cost an extra + // DNS round trip while re-opening the rebinding TOCTOU it exists to close. httpReq, err := http.NewRequestWithContext(ctx, req.Method, req.URL, bytes.NewReader(req.Body)) if err != nil { @@ -76,28 +77,13 @@ func (r *Registry) HTTPDo(ctx context.Context, inst *Instance, req HTTPRequest) for k, v := range req.Header { httpReq.Header.Set(k, v) } - // Custom transport with a guarded DialContext: every actual TCP dial - // re-checks the resolved IP, closing the DNS-rebinding TOCTOU window - // between rejectPrivateAddrs above and the underlying dial. - dialer := &net.Dialer{Timeout: httpTimeout} - transport := &http.Transport{ - DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) { - h, _, splitErr := net.SplitHostPort(addr) - if splitErr != nil { - return nil, splitErr - } - ip := net.ParseIP(h) - if ip == nil { - // Hostname — resolve and validate every address before dial. - if err := rejectPrivateAddrs(ctx, h); err != nil { - return nil, fmt.Errorf("%w: %v", ErrHTTPHostDenied, err) - } - } else if err := ipAllowed(ip); err != nil { - return nil, fmt.Errorf("%w: %v", ErrHTTPHostDenied, err) - } - return dialer.DialContext(ctx, network, addr) - }, - } + // Custom transport with a guarded DialContext: the host is resolved once, + // every candidate IP is validated against the blocklist, and the actual + // connection is made to a specific vetted IP — never re-resolved by + // hostname. This closes the DNS-rebinding TOCTOU window where a second + // lookup (the one net.Dialer would perform on a hostname) could return an + // internal IP after an earlier check had approved the name. + transport := &http.Transport{DialContext: guardedDialContext()} client := &http.Client{ Timeout: httpTimeout, Transport: transport, @@ -111,9 +97,8 @@ func (r *Registry) HTTPDo(ctx context.Context, inst *Instance, req HTTPRequest) if !r.hostAllowed(h) { return fmt.Errorf("%w: redirect to %s", ErrHTTPHostDenied, h) } - if err := rejectPrivateAddrs(redirReq.Context(), h); err != nil { - return fmt.Errorf("%w: redirect to private addr: %v", ErrHTTPHostDenied, err) - } + // Address vetting happens in the guarded dial the redirect will + // flow through — no pre-resolve needed here either. return nil }, } @@ -172,29 +157,59 @@ func (r *Registry) hostAllowed(host string) bool { return false } -// rejectPrivateAddrs resolves host and returns an error if any resolved -// address is loopback, link-local, private (RFC1918), or unspecified. -// This prevents an allowlisted hostname from being repointed at internal -// services via DNS. -func rejectPrivateAddrs(ctx context.Context, host string) error { - // If host is already an IP literal, check it directly. - if ip := net.ParseIP(host); ip != nil { - return ipAllowed(ip) +// lookupIPAddr and dialContext are swappable seams so the guarded dial can be +// tested without real DNS or network reachability. +var ( + lookupIPAddr = func(ctx context.Context, host string) ([]net.IPAddr, error) { + return (&net.Resolver{}).LookupIPAddr(ctx, host) } - resolver := &net.Resolver{} - ips, err := resolver.LookupIPAddr(ctx, host) - if err != nil { - return fmt.Errorf("dns lookup failed: %w", err) + dialContext = func(ctx context.Context, network, addr string) (net.Conn, error) { + d := &net.Dialer{Timeout: httpTimeout} + return d.DialContext(ctx, network, addr) } - if len(ips) == 0 { - return fmt.Errorf("no addresses for %s", host) - } - for _, addr := range ips { - if err := ipAllowed(addr.IP); err != nil { - return err +) + +// guardedDialContext returns the SSRF-guarded dial used by HTTPDo's +// transport: resolve once, validate every returned address, then dial vetted +// concrete IPs. All addresses are validated before any dial (one poisoned +// record among them refuses the whole request), and every vetted address is +// tried in order — a dual-stack or round-robin host whose first record is +// down must still connect via the next one. +func guardedDialContext() func(ctx context.Context, network, addr string) (net.Conn, error) { + return func(ctx context.Context, network, addr string) (net.Conn, error) { + h, port, splitErr := net.SplitHostPort(addr) + if splitErr != nil { + return nil, splitErr } + // IP literal: validate and dial as-is (no resolution happens). + if ip := net.ParseIP(h); ip != nil { + if err := ipAllowed(ip); err != nil { + return nil, fmt.Errorf("%w: %v", ErrHTTPHostDenied, err) + } + return dialContext(ctx, network, addr) + } + ips, lookupErr := lookupIPAddr(ctx, h) + if lookupErr != nil { + return nil, fmt.Errorf("%w: dns lookup failed: %v", ErrHTTPHostDenied, lookupErr) + } + if len(ips) == 0 { + return nil, fmt.Errorf("%w: no addresses for %s", ErrHTTPHostDenied, h) + } + for _, resolved := range ips { + if err := ipAllowed(resolved.IP); err != nil { + return nil, fmt.Errorf("%w: %v", ErrHTTPHostDenied, err) + } + } + var dialErr error + for _, resolved := range ips { + conn, err := dialContext(ctx, network, net.JoinHostPort(resolved.IP.String(), port)) + if err == nil { + return conn, nil + } + dialErr = err + } + return nil, dialErr } - return nil } // cgnRange covers RFC6598 carrier-grade NAT (100.64.0.0/10). net.IP.IsPrivate diff --git a/Server/plugin/host_http_test.go b/Server/plugin/host_http_test.go index 454a799c..77793617 100644 --- a/Server/plugin/host_http_test.go +++ b/Server/plugin/host_http_test.go @@ -5,6 +5,8 @@ package plugin import ( + "context" + "errors" "net" "testing" ) @@ -114,6 +116,70 @@ func TestIPAllowedAcceptsPublic(t *testing.T) { } } +// TestGuardedDial_FallsBackAcrossVettedIPs locks the W2-6 fix: an allowlisted +// dual-stack/round-robin host whose first record is unreachable must connect +// via the next vetted record instead of hard-failing. +func TestGuardedDial_FallsBackAcrossVettedIPs(t *testing.T) { + origLookup, origDial := lookupIPAddr, dialContext + t.Cleanup(func() { lookupIPAddr, dialContext = origLookup, origDial }) + + lookupIPAddr = func(_ context.Context, _ string) ([]net.IPAddr, error) { + return []net.IPAddr{ + {IP: net.ParseIP("192.0.2.1")}, // TEST-NET, "down" + {IP: net.ParseIP("192.0.2.2")}, // "reachable" + }, nil + } + var attempts []string + c1, c2 := net.Pipe() + t.Cleanup(func() { _ = c1.Close(); _ = c2.Close() }) + dialContext = func(_ context.Context, _ string, addr string) (net.Conn, error) { + attempts = append(attempts, addr) + if addr == "192.0.2.1:443" { + return nil, errors.New("connection refused") + } + return c1, nil + } + + conn, err := guardedDialContext()(context.Background(), "tcp", "api.example.com:443") + if err != nil { + t.Fatalf("guarded dial should fall back to the next vetted IP: %v", err) + } + if conn != c1 { + t.Fatal("expected the fallback connection") + } + want := []string{"192.0.2.1:443", "192.0.2.2:443"} + if len(attempts) != 2 || attempts[0] != want[0] || attempts[1] != want[1] { + t.Fatalf("dial attempts = %v, want %v", attempts, want) + } +} + +// TestGuardedDial_PrivateRecordRefusesBeforeAnyDial: one private record among +// the resolved set refuses the whole request before a single dial happens. +func TestGuardedDial_PrivateRecordRefusesBeforeAnyDial(t *testing.T) { + origLookup, origDial := lookupIPAddr, dialContext + t.Cleanup(func() { lookupIPAddr, dialContext = origLookup, origDial }) + + lookupIPAddr = func(_ context.Context, _ string) ([]net.IPAddr, error) { + return []net.IPAddr{ + {IP: net.ParseIP("192.0.2.1")}, + {IP: net.ParseIP("10.0.0.5")}, // poisoned private record + }, nil + } + dialed := false + dialContext = func(_ context.Context, _ string, _ string) (net.Conn, error) { + dialed = true + return nil, errors.New("must not be reached") + } + + _, err := guardedDialContext()(context.Background(), "tcp", "api.example.com:443") + if !errors.Is(err, ErrHTTPHostDenied) { + t.Fatalf("want ErrHTTPHostDenied, got %v", err) + } + if dialed { + t.Fatal("no dial may happen when any resolved record is private") + } +} + func TestIPAllowedNilRejected(t *testing.T) { if err := ipAllowed(nil); err == nil { t.Fatal("nil IP should be rejected") diff --git a/Server/plugin/manifest.go b/Server/plugin/manifest.go index 8e0afd62..8eae6faf 100644 --- a/Server/plugin/manifest.go +++ b/Server/plugin/manifest.go @@ -17,7 +17,7 @@ // go get github.com/tetratelabs/wazero // go build -tags wazero ./... // -// This mirrors the postgres / otel build-tag approach used elsewhere in the +// This mirrors the otel / wazero build-tag approach used elsewhere in the // repo so the default build stays self-contained. package plugin diff --git a/Server/plugin/plugin_test.go b/Server/plugin/plugin_test.go index 8f9cc216..518b8be7 100644 --- a/Server/plugin/plugin_test.go +++ b/Server/plugin/plugin_test.go @@ -146,3 +146,70 @@ func TestStorageGatedByCapability(t *testing.T) { t.Fatalf("expected v, got %q", got) } } + +// TestReinstallRebindsCommands locks the W2-3 fix: an in-place upgrade +// replaces the *Instance, so stale command bindings must be cleared on +// reinstall and ownership compared by plugin identity — the same plugin can +// re-bind its own commands while a different plugin still cannot hijack them. +func TestReinstallRebindsCommands(t *testing.T) { + mem := store.NewMemStore() + reg, err := NewRegistry(Config{Directory: t.TempDir(), Store: mem}) + if err != nil { + t.Fatalf("NewRegistry: %v", err) + } + t.Cleanup(func() { _ = reg.Close(context.Background()) }) + + ctx := context.Background() + manifest, err := ParseManifest([]byte(`{"name":"upgrader","version":"0.1.0","entrypoint":"p.wasm","permissions":["commands"]}`)) + if err != nil { + t.Fatalf("ParseManifest: %v", err) + } + if err := reg.installFromDisk(ctx, foundPlugin{Manifest: manifest, WASMPath: "p.wasm"}); err != nil { + t.Fatalf("install v1: %v", err) + } + reg.mu.RLock() + v1 := reg.byName["upgrader"] + reg.mu.RUnlock() + if err := reg.RegisterCommand("greet", v1); err != nil { + t.Fatalf("RegisterCommand v1: %v", err) + } + + // In-place upgrade: same plugin name, fresh instance. + manifest2, err := ParseManifest([]byte(`{"name":"upgrader","version":"0.2.0","entrypoint":"p.wasm","permissions":["commands"]}`)) + if err != nil { + t.Fatalf("ParseManifest v2: %v", err) + } + if err := reg.installFromDisk(ctx, foundPlugin{Manifest: manifest2, WASMPath: "p.wasm"}); err != nil { + t.Fatalf("install v2: %v", err) + } + reg.mu.RLock() + v2 := reg.byName["upgrader"] + _, stillBound := reg.commands["greet"] + reg.mu.RUnlock() + if v2 == v1 { + t.Fatal("reinstall should produce a fresh instance") + } + if stillBound { + t.Fatal("stale command binding survived reinstall") + } + + // The upgraded plugin re-binds its own command. + if err := reg.RegisterCommand("greet", v2); err != nil { + t.Fatalf("RegisterCommand after upgrade: %v", err) + } + + // A different plugin still cannot hijack an owned command. + other, err := ParseManifest([]byte(`{"name":"other","version":"0.1.0","entrypoint":"o.wasm","permissions":["commands"]}`)) + if err != nil { + t.Fatalf("ParseManifest other: %v", err) + } + if err := reg.installFromDisk(ctx, foundPlugin{Manifest: other, WASMPath: "o.wasm"}); err != nil { + t.Fatalf("install other: %v", err) + } + reg.mu.RLock() + otherInst := reg.byName["other"] + reg.mu.RUnlock() + if err := reg.RegisterCommand("greet", otherInst); err == nil { + t.Fatal("cross-plugin hijack must still be refused") + } +} diff --git a/Server/plugin/registry.go b/Server/plugin/registry.go index a7ac9583..4ab01d43 100644 --- a/Server/plugin/registry.go +++ b/Server/plugin/registry.go @@ -183,6 +183,21 @@ func (r *Registry) installFromDisk(ctx context.Context, found foundPlugin) error } r.mu.Lock() defer r.mu.Unlock() + // Re-install: tear down the old instance and drop its command bindings. + // Bindings are keyed to the old *Instance, so leaving them in place both + // blocked the fresh instance from re-registering its own commands and + // kept dispatch routing into the orphaned old module until restart. + if old := r.byName[found.Manifest.Name]; old != nil { + r.platformDeactivate(old) + for cmd, owner := range r.commands { + if owner == old { + delete(r.commands, cmd) + } + } + if old.ID != id { + delete(r.plugins, old.ID) + } + } inst := &Instance{ ID: id, Manifest: found.Manifest, diff --git a/Server/plugin/sandbox_wazero.go b/Server/plugin/sandbox_wazero.go index ec0de81c..508ac585 100644 --- a/Server/plugin/sandbox_wazero.go +++ b/Server/plugin/sandbox_wazero.go @@ -1,11 +1,11 @@ //go:build wazero // Phase C Step 9 — Real Wazero-backed plugin runtime. Compiled only with -// `-tags wazero`; matches the postgres / otel build-tag pattern used +// `-tags wazero`; matches the otel build-tag pattern used // elsewhere in the repo so the default sqlite-only build does not pull // wazero into go.mod at runtime. // -// Architecture +// # Architecture // // The wazero-tagged build provides: // @@ -36,8 +36,10 @@ import ( "encoding/json" "fmt" "io" + "log/slog" "os" "strings" + "time" "github.com/tetratelabs/wazero" "github.com/tetratelabs/wazero/api" @@ -121,17 +123,34 @@ func (r *Registry) activateWithRuntime(ctx context.Context, platform any, inst * return fmt.Errorf("plugin %q: instantiate: %w", inst.Manifest.Name, err) } - // Register the module and auto-bind any commands the plugin exports - // via list_commands. The plugin must also have declared the `commands` - // capability in its manifest, otherwise no binding happens. + // Store the module under the lock, then auto-bind any commands the plugin + // exports via list_commands. Binding is routed through RegisterCommand so + // each name goes through the same normalization (trim "/" + lowercase) and + // conflict check the direct registration path uses: a command already owned + // by a DIFFERENT plugin is refused rather than silently clobbered, closing + // the cross-plugin command-hijack hole. RegisterCommand acquires r.mu + // itself, so it is called outside the lock below to avoid re-entrant + // locking. The plugin must also have declared the `commands` capability in + // its manifest, otherwise no binding happens. r.mu.Lock() + if inst.module != nil { + // Lost a concurrent activation race (e.g. two dispatches both saw a + // closed module); keep the winner's module and discard ours. The + // winner already handled command binding. + r.mu.Unlock() + _ = module.Close(ctx) + return nil + } inst.module = module + r.mu.Unlock() if inst.Manifest.HasCapability(CapCommands) { for _, cmd := range listExportedCommands(ctx, module) { - r.commands[cmd] = inst + if err := r.RegisterCommand(cmd, inst); err != nil { + slog.Warn("plugin: skipping command binding", + "plugin", inst.Manifest.Name, "command", cmd, "err", err) + } } } - r.mu.Unlock() return nil } @@ -160,10 +179,33 @@ func (r *Registry) platformDeactivate(inst *Instance) { // fall back to the not-found response. A plugin that exports // command_dispatch but lacks allocate returns a user-facing diagnostic. func (r *Registry) invokeCommand(ctx context.Context, inst *Instance, userID, channelID int64, cmd string, args []string) (*CommandResult, bool) { - if inst == nil || inst.module == nil { + if inst == nil { return nil, false } - mod, ok := inst.module.(api.Module) + r.mu.RLock() + moduleAny := inst.module + enabled := inst.Enabled + r.mu.RUnlock() + if moduleAny == nil { + // A previous CPU-budget overrun or guest trap closed the module + // (releaseClosedModule cleared it). Re-instantiate lazily so one bad + // command doesn't brick the plugin until an admin disable/enable + // cycle or a server restart. Re-instantiation resets the guest's + // in-memory state. Disabled plugins stay dark. + if !enabled { + return nil, false + } + if err := r.activate(ctx, inst); err != nil { + return &CommandResult{Reply: fmt.Sprintf("plugin %s: reactivate: %v", inst.Manifest.Name, err)}, true + } + r.mu.RLock() + moduleAny = inst.module + r.mu.RUnlock() + if moduleAny == nil { + return nil, false + } + } + mod, ok := moduleAny.(api.Module) if !ok { return nil, false } @@ -195,10 +237,38 @@ func (r *Registry) invokeCommand(ctx context.Context, inst *Instance, userID, ch return &CommandResult{Reply: fmt.Sprintf("plugin %s: marshal payload: %v", inst.Manifest.Name, err)}, true } + // Enforce the plugin's CPU budget. The effective budget is the manifest's + // Resources.CPUBudgetMs, falling back to the configured default, then a + // hard 100ms floor so a zero/negative value can never mean "no limit". + // Every guest call (allocate / command_dispatch / deallocate) runs under + // this deadline instead of the long-lived WebSocket context. The runtime + // was created WithCloseOnContextDone(true), so an expired deadline closes + // the module and interrupts a runaway guest (e.g. `for {}`) — the Call + // returns an error rather than panicking, which the paths below surface, + // and releaseClosedModule then marks the instance for lazy + // re-instantiation on the next dispatch. + // + // The budget is wall-clock over guest execution. No host imports are + // wired into the runtime yet, so host-call time cannot be attributed to + // the guest today; when host functions (host_http, host_storage, …) land, + // their execution time must be excluded from this budget — otherwise the + // floor would kill any command performing a host HTTP call (httpTimeout + // is 10s against a 100ms floor). + budgetMs := inst.Manifest.Resources.CPUBudgetMs + if budgetMs <= 0 { + budgetMs = r.cfg.CPUBudgetMs + } + if budgetMs <= 0 { + budgetMs = 100 + } + callCtx, cancel := context.WithTimeout(ctx, time.Duration(budgetMs)*time.Millisecond) + defer cancel() + // Allocate guest memory for the input payload. size := uint64(len(payload)) - ptrs, callErr := allocFn.Call(ctx, size) + ptrs, callErr := allocFn.Call(callCtx, size) if callErr != nil || len(ptrs) == 0 { + r.releaseClosedModule(inst, mod) return &CommandResult{Reply: fmt.Sprintf("plugin %s: allocate(%d): %v", inst.Manifest.Name, size, callErr)}, true } ptr := ptrs[0] @@ -208,14 +278,23 @@ func (r *Registry) invokeCommand(ctx context.Context, inst *Instance, userID, ch return &CommandResult{Reply: fmt.Sprintf("plugin %s: memory write at %d failed", inst.Manifest.Name, ptr)}, true } - results, callErr := dispatchFn.Call(ctx, ptr, size) + results, callErr := dispatchFn.Call(callCtx, ptr, size) // Free the input buffer regardless of dispatch outcome. if deallocFn != nil { - _, _ = deallocFn.Call(ctx, ptr, size) + _, _ = deallocFn.Call(callCtx, ptr, size) } if callErr != nil { + // A failed guest call may have closed the module (deadline, trap, + // parent-context cancellation); release it so the next dispatch + // re-instantiates instead of dispatching into a dead module forever. + r.releaseClosedModule(inst, mod) + // Surface a CPU-budget overrun as a clean, specific error rather than + // leaking the raw "module closed with context deadline exceeded". + if callCtx.Err() == context.DeadlineExceeded { + return &CommandResult{Reply: fmt.Sprintf("plugin %s: command exceeded CPU budget of %dms", inst.Manifest.Name, budgetMs)}, true + } return &CommandResult{Reply: fmt.Sprintf("plugin %s: dispatch: %v", inst.Manifest.Name, callErr)}, true } if len(results) < 2 { @@ -239,6 +318,22 @@ func (r *Registry) invokeCommand(ctx context.Context, inst *Instance, userID, ch return &CommandResult{Reply: dr.Reply}, true } +// releaseClosedModule drops inst.module when the wazero runtime closed it out +// from under us (CPU-budget deadline via WithCloseOnContextDone, a guest +// trap, or parent-context cancellation), so the next dispatch lazily +// re-instantiates the plugin instead of erroring on a dead module forever. +// The pointer guard keeps a concurrent re-activation's fresh module intact. +func (r *Registry) releaseClosedModule(inst *Instance, mod api.Module) { + if !mod.IsClosed() { + return + } + r.mu.Lock() + if inst.module == mod { + inst.module = nil + } + r.mu.Unlock() +} + // listExportedCommands calls the plugin's optional `list_commands` export // which returns (ptr u32, len u32) pointing to a JSON array of command name // strings. If the export is absent or returns invalid JSON, an empty slice diff --git a/Server/plugin/sandbox_wazero_test.go b/Server/plugin/sandbox_wazero_test.go index 1309559b..5c91dbf3 100644 --- a/Server/plugin/sandbox_wazero_test.go +++ b/Server/plugin/sandbox_wazero_test.go @@ -23,6 +23,7 @@ import ( "context" "os" "path/filepath" + "strings" "testing" "github.com/owncord/server/store" @@ -211,6 +212,118 @@ func TestWazeroDisablePluginFreesModule(t *testing.T) { } } +// spinWASM implements the command-dispatch ABI with input-dependent runtime: +// +// (module +// (memory (export "memory") 1) +// (func (export "allocate") (param i32) (result i32) i32.const 8) +// (func (export "deallocate") (param i32 i32)) +// (func (export "command_dispatch") (param i32 i32) (result i32 i32) +// local.get 1 ;; payload length +// i32.const 100 +// i32.gt_u +// if (loop br 0 end) end ;; payloads over 100 bytes spin forever +// i32.const 0 i32.const 0)) +// +// A dispatch with no args stays under 100 payload bytes and returns +// immediately; long args push the JSON payload over 100 bytes and trigger an +// infinite loop, which the CPU budget must interrupt. +var spinWASM = []byte{ + 0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00, // \0asm v1 + // type section: (i32)->i32, (i32,i32)->(), (i32,i32)->(i32,i32) + 0x01, 0x12, 0x03, + 0x60, 0x01, 0x7f, 0x01, 0x7f, + 0x60, 0x02, 0x7f, 0x7f, 0x00, + 0x60, 0x02, 0x7f, 0x7f, 0x02, 0x7f, 0x7f, + // function section: 3 funcs using types 0,1,2 + 0x03, 0x04, 0x03, 0x00, 0x01, 0x02, + // memory section: 1 page, no max + 0x05, 0x03, 0x01, 0x00, 0x01, + // export section: memory, allocate, deallocate, command_dispatch + 0x07, 0x35, 0x04, + 0x06, 0x6d, 0x65, 0x6d, 0x6f, 0x72, 0x79, 0x02, 0x00, + 0x08, 0x61, 0x6c, 0x6c, 0x6f, 0x63, 0x61, 0x74, 0x65, 0x00, 0x00, + 0x0a, 0x64, 0x65, 0x61, 0x6c, 0x6c, 0x6f, 0x63, 0x61, 0x74, 0x65, 0x00, 0x01, + 0x10, 0x63, 0x6f, 0x6d, 0x6d, 0x61, 0x6e, 0x64, 0x5f, 0x64, 0x69, 0x73, 0x70, 0x61, 0x74, 0x63, 0x68, 0x00, 0x02, + // code section + 0x0a, 0x1e, 0x03, + // allocate: return 8 + 0x04, 0x00, 0x41, 0x08, 0x0b, + // deallocate: nop + 0x02, 0x00, 0x0b, + // command_dispatch: spin if len>100 else return (0,0) + 0x14, 0x00, + 0x20, 0x01, // local.get 1 + 0x41, 0xe4, 0x00, // i32.const 100 + 0x4b, // i32.gt_u + 0x04, 0x40, // if + 0x03, 0x40, // loop + 0x0c, 0x00, // br 0 + 0x0b, // end loop + 0x0b, // end if + 0x41, 0x00, // i32.const 0 + 0x41, 0x00, // i32.const 0 + 0x0b, // end +} + +// TestWazeroCPUBudgetOverrunDoesNotBrickPlugin locks in the W1-1 fix: an +// over-budget command must return the budget error, and the SAME plugin must +// serve the next command via lazy re-instantiation — not stay dead until an +// admin disable/enable cycle or server restart. +func TestWazeroCPUBudgetOverrunDoesNotBrickPlugin(t *testing.T) { + dir := t.TempDir() + manifest := `{"name":"spinner","version":"0.1.0","entrypoint":"hello.wasm","permissions":["commands"]}` + writeTestPlugin(t, dir, "spinner", manifest, spinWASM) + + reg, mem := newWazeroTestRegistry(t, dir) + ctx := context.Background() + if err := reg.LoadAll(ctx); err != nil { + t.Fatal(err) + } + rows, _ := mem.ListPlugins(ctx) + if err := reg.EnablePlugin(ctx, rows[0].ID); err != nil { + t.Fatalf("EnablePlugin: %v", err) + } + reg.mu.RLock() + inst := reg.plugins[rows[0].ID] + reg.mu.RUnlock() + if err := reg.RegisterCommand("spin", inst); err != nil { + t.Fatalf("RegisterCommand: %v", err) + } + + // Baseline: a small payload dispatches fine. + result, ok := reg.DispatchCommand(ctx, 1, 2, "spin", nil) + if !ok || result == nil { + t.Fatalf("baseline dispatch failed: ok=%v result=%+v", ok, result) + } + if strings.Contains(result.Reply, "CPU budget") { + t.Fatalf("baseline dispatch should not hit the budget: %q", result.Reply) + } + + // Overrun: a long arg pushes the payload over the spin threshold; the + // 100ms budget must interrupt it and surface the budget error. + result, ok = reg.DispatchCommand(ctx, 1, 2, "spin", []string{strings.Repeat("x", 200)}) + if !ok || result == nil { + t.Fatalf("overrun dispatch returned no result: ok=%v", ok) + } + if !strings.Contains(result.Reply, "CPU budget") { + t.Fatalf("expected CPU budget error, got %q", result.Reply) + } + + // The plugin must still work: the next small dispatch re-instantiates the + // module lazily instead of dispatching into the closed one forever. + result, ok = reg.DispatchCommand(ctx, 1, 2, "spin", nil) + if !ok || result == nil { + t.Fatalf("post-overrun dispatch failed: ok=%v result=%+v", ok, result) + } + if strings.Contains(result.Reply, "CPU budget") || strings.Contains(result.Reply, "module closed") { + t.Fatalf("plugin still bricked after overrun: %q", result.Reply) + } + if !inst.Enabled { + t.Fatal("overrun must not disable the plugin") + } +} + func TestWazeroInvalidWASMFailsActivation(t *testing.T) { dir := t.TempDir() manifest := `{"name":"brokey","version":"0.1.0","entrypoint":"hello.wasm","permissions":["commands"]}` diff --git a/Server/service/message.go b/Server/service/message.go index 3ea6ab49..9dd0a4d8 100644 --- a/Server/service/message.go +++ b/Server/service/message.go @@ -180,10 +180,13 @@ func (s *MessageService) SendMessage(ctx context.Context, p SendMessageParams) ( return nil, fmt.Errorf("%w: failed to save message", ErrInternal) } - // Link attachments. + // Link attachments. Ownership is enforced atomically inside the link + // UPDATE itself (uploader match + still unlinked), so another user's + // upload, an already-linked attachment, or a nonexistent id is skipped by + // the statement — no check-then-link race and no N+1 pre-verification. var attachments []db.AttachmentInfo if len(p.AttachmentIDs) > 0 { - linked, linkErr := s.st.LinkAttachmentsToMessage(msgID, p.AttachmentIDs) + linked, linkErr := s.st.LinkAttachmentsToMessage(msgID, p.UserID, p.AttachmentIDs) if linkErr != nil { slog.Error("MessageService.SendMessage LinkAttachments", "err", linkErr, "msg_id", msgID) // Cleanup: soft-delete the message. @@ -192,6 +195,10 @@ func (s *MessageService) SendMessage(ctx context.Context, p SendMessageParams) ( } return nil, fmt.Errorf("%w: failed to send message with attachments", ErrInternal) } + if linked < int64(len(p.AttachmentIDs)) { + slog.Warn("MessageService.SendMessage: skipped attachments (not owned, already linked, or missing)", + "msg_id", msgID, "user_id", p.UserID, "requested", len(p.AttachmentIDs), "linked", linked) + } if linked > 0 { attMap, attErr := s.st.GetAttachmentsByMessageIDs([]int64{msgID}) if attErr != nil { @@ -435,7 +442,10 @@ func (s *MessageService) handleReaction(userID, msgID int64, emoji string, add b if dmErr != nil || !ok { return nil, fmt.Errorf("%w: not a DM participant", ErrBadRequest) } - } else if !s.perms.HasChannelPerm(userID, msg.ChannelID, permissions.AddReactions) { + } else if !s.perms.HasChannelPerm(userID, msg.ChannelID, permissions.ReadMessages|permissions.AddReactions) { + // Require READ_MESSAGES in addition to ADD_REACTIONS so a user cannot + // react in a channel they cannot read. Mirrors checkSendPermission, + // which requires ReadMessages|SendMessages for non-DM sends. return nil, fmt.Errorf("%w: missing ADD_REACTIONS permission", ErrForbidden) } @@ -667,6 +677,19 @@ func (s *MessageService) GetAccessibleChannelIDs(userID int64) ([]int64, error) return ids, nil } +// CanPost reports whether userID may post into channelID, applying the same +// checks as a real message send: channel permissions via the cached checker +// for regular channels; participant membership AND block status for DMs. +// Exists so gates outside the send flow (the plugin broadcast path) share +// exactly this policy instead of hand-rolling a weaker copy. +func (s *MessageService) CanPost(userID, channelID int64) error { + ch, err := s.st.GetChannel(channelID) + if err != nil || ch == nil { + return fmt.Errorf("%w: channel not found", ErrNotFound) + } + return s.checkSendPermission(userID, channelID, ch.Type == "dm") +} + // checkSendPermission validates send permission for DM and non-DM channels. func (s *MessageService) checkSendPermission(userID, channelID int64, isDM bool) error { if isDM { diff --git a/Server/service/message_test.go b/Server/service/message_test.go index 599164a1..3d9aa170 100644 --- a/Server/service/message_test.go +++ b/Server/service/message_test.go @@ -56,6 +56,125 @@ func TestSendMessage_Valid(t *testing.T) { } } +// TestCanPost_DMBlockEnforced locks the W2-7 property: the plugin-broadcast +// gate delegates to CanPost, so a blocked user is refused from posting into +// a DM — the old broadcast gate's DM branch skipped the block check entirely. +func TestCanPost_DMBlockEnforced(t *testing.T) { + ms := store.NewMemStore() + ms.SeedRole(&db.Role{ + ID: permissions.MemberRoleID, Name: "member", + Permissions: permissions.SendMessages | permissions.ReadMessages, Position: 1, + }) + ms.SeedUserRole(1, permissions.MemberRoleID) + ms.SeedUserRole(2, permissions.MemberRoleID) + ms.SeedUser(&db.User{ID: 1, Username: "alice"}) + ms.SeedUser(&db.User{ID: 2, Username: "bob"}) + ms.SeedChannel(&db.Channel{ID: 50, Name: "dm-1-2", Type: "dm"}) + ms.SeedDMParticipant(50, 1) + ms.SeedDMParticipant(50, 2) + checker := permissions.NewChecker(ms) + svc := NewMessageService(ms, NewPermissionService(ms, checker), nil) + + if err := svc.CanPost(1, 50); err != nil { + t.Fatalf("unblocked DM participant should be allowed: %v", err) + } + ms.SeedBlock(2, 1) // bob blocks alice + if err := svc.CanPost(1, 50); !errors.Is(err, ErrBlocked) { + t.Fatalf("blocked user must be refused: got %v", err) + } + if err := svc.CanPost(3, 50); !errors.Is(err, ErrForbidden) { + t.Fatalf("non-participant must be refused: got %v", err) + } + if err := svc.CanPost(1, 999); !errors.Is(err, ErrNotFound) { + t.Fatalf("missing channel must be NotFound: got %v", err) + } +} + +// TestCanPost_ChannelPermissionRequired: regular channels still require +// READ|SEND via the cached checker. +func TestCanPost_ChannelPermissionRequired(t *testing.T) { + ms := store.NewMemStore() + ms.SeedRole(&db.Role{ + ID: permissions.MemberRoleID, Name: "member", + Permissions: permissions.ReadMessages, Position: 1, // no SendMessages + }) + ms.SeedUserRole(1, permissions.MemberRoleID) + ms.SeedUser(&db.User{ID: 1, Username: "alice"}) + ms.SeedChannel(&db.Channel{ID: 10, Name: "general", Type: "text"}) + checker := permissions.NewChecker(ms) + svc := NewMessageService(ms, NewPermissionService(ms, checker), nil) + + if err := svc.CanPost(1, 10); !errors.Is(err, ErrForbidden) { + t.Fatalf("missing SEND_MESSAGES must refuse: got %v", err) + } +} + +// TestSendMessage_AttachmentOwnershipAtomic locks the W1-3 semantics: the +// link UPDATE itself enforces ownership, so a foreign, already-linked, or +// nonexistent attachment is skipped (never linked) while the message still +// sends — no check-then-link race, and retries cannot hard-fail. +func TestSendMessage_AttachmentOwnershipAtomic(t *testing.T) { + ms := store.NewMemStore() + ms.SeedRole(&db.Role{ + ID: permissions.MemberRoleID, + Name: "member", + Permissions: permissions.SendMessages | permissions.ReadMessages | permissions.AttachFiles, + Position: 1, + }) + ms.SeedUserRole(1, permissions.MemberRoleID) + ms.SeedUserRole(2, permissions.MemberRoleID) + ms.SeedUser(&db.User{ID: 1, Username: "alice", Status: "online"}) + ms.SeedUser(&db.User{ID: 2, Username: "mallory", Status: "online"}) + ms.SeedChannel(&db.Channel{ID: 10, Name: "general", Type: "text"}) + checker := permissions.NewChecker(ms) + svc := NewMessageService(ms, NewPermissionService(ms, checker), nil) + + if err := ms.CreateAttachment("att-own", 1, "a.png", "s-a.png", "image/png", 10, nil, nil); err != nil { + t.Fatal(err) + } + if err := ms.CreateAttachment("att-foreign", 2, "b.png", "s-b.png", "image/png", 10, nil, nil); err != nil { + t.Fatal(err) + } + + result, err := svc.SendMessage(context.Background(), SendMessageParams{ + ChannelID: 10, UserID: 1, Username: "alice", RoleName: "member", + Content: "with files", + AttachmentIDs: []string{"att-own", "att-foreign", "att-missing"}, + }) + if err != nil { + t.Fatalf("SendMessage: %v", err) + } + if result.MessageID <= 0 { + t.Fatal("message should persist even when some attachments are skipped") + } + + own, _ := ms.GetAttachmentByID("att-own") + if own.MessageID == nil || *own.MessageID != result.MessageID { + t.Error("sender's own attachment should be linked to the new message") + } + foreign, _ := ms.GetAttachmentByID("att-foreign") + if foreign.MessageID != nil { + t.Error("another user's attachment must never be linked (IDOR guard)") + } + + // A retry naming the now-linked attachment must still send. + retry, err := svc.SendMessage(context.Background(), SendMessageParams{ + ChannelID: 10, UserID: 1, Username: "alice", RoleName: "member", + Content: "retry", + AttachmentIDs: []string{"att-own"}, + }) + if err != nil { + t.Fatalf("retry with already-linked attachment should still send: %v", err) + } + if retry.MessageID <= 0 { + t.Fatal("retry should persist a message") + } + own2, _ := ms.GetAttachmentByID("att-own") + if own2.MessageID == nil || *own2.MessageID != result.MessageID { + t.Error("already-linked attachment must stay linked to the original message") + } +} + func TestSendMessage_EmptyContent(t *testing.T) { svc, _ := newTestMessageService() diff --git a/Server/service/moderation.go b/Server/service/moderation.go index d454322c..d5c692a6 100644 --- a/Server/service/moderation.go +++ b/Server/service/moderation.go @@ -6,24 +6,66 @@ import ( "log/slog" "time" + "github.com/owncord/server/permissions" "github.com/owncord/server/store" "github.com/owncord/server/telemetry" ) // ModerationService handles user ban/unban operations. type ModerationService struct { - st store.Store + st store.Store + perms *PermissionService } // NewModerationService creates a ModerationService. -func NewModerationService(st store.Store) *ModerationService { - return &ModerationService{st: st} +func NewModerationService(st store.Store, perms *PermissionService) *ModerationService { + return &ModerationService{st: st, perms: perms} +} + +// requireBanPermission verifies the actor holds BAN_MEMBERS (or the +// Administrator bypass). It deliberately takes no target: it runs before any +// target lookup so an actor without ban authority always sees Forbidden and +// never NotFound — the ban path cannot be used to enumerate user ids. +func (s *ModerationService) requireBanPermission(actorID int64) error { + if s.perms == nil { + // No permission service wired — fail closed rather than allow unchecked bans. + return fmt.Errorf("%w: permission service unavailable", ErrForbidden) + } + actorRole, err := s.perms.GetRoleForUser(actorID) + if err != nil || actorRole == nil { + return fmt.Errorf("%w: failed to load actor role", ErrForbidden) + } + if !permissions.HasAdmin(actorRole.Permissions) && + !permissions.HasPerm(actorRole.Permissions, permissions.BanMembers) { + return fmt.Errorf("%w: missing BAN_MEMBERS permission", ErrForbidden) + } + return nil +} + +// requireOutranks enforces the role hierarchy: the actor must strictly +// outrank the target so a user cannot ban a peer or a higher-ranked user +// (e.g. the owner) — mirroring the position-based hierarchy used elsewhere. +// Runs after requireBanPermission and the existence check, so only callers +// that already hold ban authority reach it. +func (s *ModerationService) requireOutranks(actorID, targetID int64) error { + actorRole, err := s.perms.GetRoleForUser(actorID) + if err != nil || actorRole == nil { + return fmt.Errorf("%w: failed to load actor role", ErrForbidden) + } + targetRole, err := s.perms.GetRoleForUser(targetID) + if err != nil || targetRole == nil { + return fmt.Errorf("%w: failed to load target role", ErrForbidden) + } + if actorRole.Position <= targetRole.Position { + return fmt.Errorf("%w: cannot moderate a user of equal or higher rank", ErrForbidden) + } + return nil } // BanUser bans a target user. Validates the target exists and // prevents self-banning. -func (s *ModerationService) BanUser(actorID, targetID int64, reason string, expires *time.Time) error { - ctx, span := telemetry.GlobalTracer("service/moderation").Start(context.Background(), "ModerationService.BanUser", +func (s *ModerationService) BanUser(ctx context.Context, actorID, targetID int64, reason string, expires *time.Time) error { + ctx, span := telemetry.GlobalTracer("service/moderation").Start(ctx, "ModerationService.BanUser", telemetry.Int64("actor_id", actorID), telemetry.Int64("target_id", targetID), ) @@ -41,16 +83,24 @@ func (s *ModerationService) BanUser(actorID, targetID int64, reason string, expi return fmt.Errorf("%w: cannot ban yourself", ErrBadRequest) } + // Authorization before existence: an actor without ban authority learns + // nothing about which user ids exist. + if err := s.requireBanPermission(actorID); err != nil { + return err + } target, err := s.st.GetUserByID(targetID) if err != nil || target == nil { return fmt.Errorf("%w: user not found", ErrNotFound) } + if err := s.requireOutranks(actorID, targetID); err != nil { + return err + } if err := s.st.BanUser(targetID, reason, expires); err != nil { return fmt.Errorf("%w: failed to ban user", ErrInternal) } - if err := s.st.LogAudit(actorID, "ban", "user", targetID, reason); err != nil { + if err := s.st.LogAudit(actorID, "user_ban", "user", targetID, reason); err != nil { slog.Error("failed to log audit entry", "error", err) } @@ -59,21 +109,28 @@ func (s *ModerationService) BanUser(actorID, targetID int64, reason string, expi } // UnbanUser removes a ban on a target user. -func (s *ModerationService) UnbanUser(actorID, targetID int64) error { +func (s *ModerationService) UnbanUser(_ context.Context, actorID, targetID int64) error { if targetID <= 0 { return fmt.Errorf("%w: user_id must be positive", ErrBadRequest) } + // Authorization before existence — see BanUser. + if err := s.requireBanPermission(actorID); err != nil { + return err + } target, err := s.st.GetUserByID(targetID) if err != nil || target == nil { return fmt.Errorf("%w: user not found", ErrNotFound) } + if err := s.requireOutranks(actorID, targetID); err != nil { + return err + } if err := s.st.UnbanUser(targetID); err != nil { return fmt.Errorf("%w: failed to unban user", ErrInternal) } - if err := s.st.LogAudit(actorID, "unban", "user", targetID, ""); err != nil { + if err := s.st.LogAudit(actorID, "user_unban", "user", targetID, ""); err != nil { slog.Error("failed to log audit entry", "error", err) } diff --git a/Server/service/moderation_test.go b/Server/service/moderation_test.go new file mode 100644 index 00000000..37b316a3 --- /dev/null +++ b/Server/service/moderation_test.go @@ -0,0 +1,108 @@ +package service + +import ( + "context" + "errors" + "fmt" + "testing" + + "github.com/owncord/server/db" + "github.com/owncord/server/permissions" + "github.com/owncord/server/store" +) + +// newTestModerationService seeds a MemStore with a role hierarchy: +// owner (pos 100, Administrator) > mod (pos 80, BanMembers) > member (pos 40). +// Users: 1=owner, 2=mod, 3=member, 4=member, 5=mod (equal rank to 2). +func newTestModerationService() (*ModerationService, *store.MemStore) { + ms := store.NewMemStore() + ms.SeedRole(&db.Role{ID: 1, Name: "owner", Permissions: permissions.Administrator, Position: 100}) + ms.SeedRole(&db.Role{ID: 2, Name: "mod", Permissions: permissions.BanMembers, Position: 80}) + ms.SeedRole(&db.Role{ID: 3, Name: "member", Permissions: permissions.SendMessages, Position: 40}) + for userID, roleID := range map[int64]int64{1: 1, 2: 2, 3: 3, 4: 3, 5: 2} { + ms.SeedUserRole(userID, roleID) + ms.SeedUser(&db.User{ID: userID, Username: fmt.Sprintf("u%d", userID), Status: "offline"}) + } + checker := permissions.NewChecker(ms) + return NewModerationService(ms, NewPermissionService(ms, checker)), ms +} + +func TestBanUser_RequiresBanPermission(t *testing.T) { + svc, _ := newTestModerationService() + + // A member without BAN_MEMBERS is refused. + if err := svc.BanUser(context.Background(), 3, 4, "nope", nil); !errors.Is(err, ErrForbidden) { + t.Fatalf("member ban attempt: want ErrForbidden, got %v", err) + } + // And gets Forbidden — not NotFound — for a nonexistent target, so the + // ban path cannot be used to enumerate user ids. + if err := svc.BanUser(context.Background(), 3, 999, "probe", nil); !errors.Is(err, ErrForbidden) { + t.Fatalf("unauthorized probe of missing id: want ErrForbidden, got %v", err) + } +} + +func TestBanUser_HierarchyEnforced(t *testing.T) { + svc, ms := newTestModerationService() + + // Equal rank: mod cannot ban mod. + if err := svc.BanUser(context.Background(), 2, 5, "peer", nil); !errors.Is(err, ErrForbidden) { + t.Fatalf("equal-rank ban: want ErrForbidden, got %v", err) + } + // Higher rank target: mod cannot ban the owner. + if err := svc.BanUser(context.Background(), 2, 1, "coup", nil); !errors.Is(err, ErrForbidden) { + t.Fatalf("ban owner: want ErrForbidden, got %v", err) + } + owner, _ := ms.GetUserByID(1) + if owner.Banned { + t.Fatal("owner must not be banned") + } +} + +func TestBanUser_AuthorizedSucceeds(t *testing.T) { + svc, ms := newTestModerationService() + + if err := svc.BanUser(context.Background(), 2, 3, "spam", nil); err != nil { + t.Fatalf("authorized ban: %v", err) + } + target, _ := ms.GetUserByID(3) + if !target.Banned { + t.Fatal("target should be banned") + } + if target.BanReason == nil || *target.BanReason != "spam" { + t.Fatalf("ban reason not recorded: %v", target.BanReason) + } + + // Authorized actor gets a real NotFound for a missing target. + if err := svc.BanUser(context.Background(), 2, 999, "gone", nil); !errors.Is(err, ErrNotFound) { + t.Fatalf("authorized ban of missing id: want ErrNotFound, got %v", err) + } + // Self-ban is a bad request regardless of authority. + if err := svc.BanUser(context.Background(), 2, 2, "self", nil); !errors.Is(err, ErrBadRequest) { + t.Fatalf("self ban: want ErrBadRequest, got %v", err) + } +} + +func TestUnbanUser_AuthorizationMatrix(t *testing.T) { + svc, ms := newTestModerationService() + + if err := svc.BanUser(context.Background(), 1, 3, "setup", nil); err != nil { + t.Fatalf("setup ban: %v", err) + } + + // No BAN_MEMBERS → Forbidden (member 4 trying to unban member 3). + if err := svc.UnbanUser(context.Background(), 4, 3); !errors.Is(err, ErrForbidden) { + t.Fatalf("member unban: want ErrForbidden, got %v", err) + } + // Equal rank → Forbidden. + if err := svc.UnbanUser(context.Background(), 2, 5); !errors.Is(err, ErrForbidden) { + t.Fatalf("equal-rank unban: want ErrForbidden, got %v", err) + } + // Authorized → succeeds. + if err := svc.UnbanUser(context.Background(), 2, 3); err != nil { + t.Fatalf("authorized unban: %v", err) + } + target, _ := ms.GetUserByID(3) + if target.Banned { + t.Fatal("target should be unbanned") + } +} diff --git a/Server/service/service.go b/Server/service/service.go index c8d7ecee..113fa924 100644 --- a/Server/service/service.go +++ b/Server/service/service.go @@ -36,7 +36,7 @@ func New(st store.Store, limiter *auth.RateLimiter) *Services { DMs: NewDMService(st), Invites: NewInviteService(st), Blocks: NewBlockService(st), - Moderation: NewModerationService(st), + Moderation: NewModerationService(st, permSvc), Voice: NewVoiceService(st, permSvc), } } diff --git a/Server/service/user.go b/Server/service/user.go index 3a267270..4332556d 100644 --- a/Server/service/user.go +++ b/Server/service/user.go @@ -51,19 +51,43 @@ func (s *UserService) UpdateProfile(ctx context.Context, userID int64, username return user, nil } +// ChangePasswordResult reports a completed password change. RevokeFailed is +// set when the password committed but other sessions could not be revoked — +// a partial success the caller must surface as a warning, never as a 5xx: +// the old password is already unusable, so telling the user the change +// "failed" walks them into retrying with a dead password and tripping the +// password-confirm lockout. +type ChangePasswordResult struct { + SessionsRevoked int64 + RevokeFailed bool +} + // ChangePassword updates the user's password and revokes other sessions. -// Returns the number of other sessions revoked. -func (s *UserService) ChangePassword(userID int64, newPasswordHash string, keepSessionID int64) (int64, error) { +func (s *UserService) ChangePassword(userID int64, newPasswordHash string, keepSessionID int64) (ChangePasswordResult, error) { if err := s.st.UpdateUserPassword(userID, newPasswordHash); err != nil { - return 0, fmt.Errorf("%w: failed to update password", ErrInternal) + return ChangePasswordResult{}, fmt.Errorf("%w: failed to update password", ErrInternal) } + + // The password is committed from here on: every path below reports + // success and writes the audit row. + var res ChangePasswordResult revoked, err := s.st.DeleteOtherSessions(userID, keepSessionID) + res.SessionsRevoked = revoked if err != nil { slog.Error("UserService.ChangePassword DeleteOtherSessions", "err", err, "user_id", userID) + // One bounded compensating retry: revocation is the security tail of + // the change and a single immediate retry covers transient write-lock + // contention. ponytail: one retry, add backoff only if logs show it. + if revokedRetry, retryErr := s.st.DeleteOtherSessions(userID, keepSessionID); retryErr == nil { + res.SessionsRevoked += revokedRetry + } else { + res.RevokeFailed = true + } } _ = s.st.LogAudit(userID, "password_change", "user", userID, "password changed") - slog.Info("password changed", "user_id", userID, "sessions_revoked", revoked) - return revoked, nil + slog.Info("password changed", "user_id", userID, + "sessions_revoked", res.SessionsRevoked, "revoke_failed", res.RevokeFailed) + return res, nil } // ListSessions returns all active sessions for a user. diff --git a/Server/service/user_test.go b/Server/service/user_test.go new file mode 100644 index 00000000..34c7674f --- /dev/null +++ b/Server/service/user_test.go @@ -0,0 +1,81 @@ +package service + +import ( + "errors" + "slices" + "testing" + + "github.com/owncord/server/db" + "github.com/owncord/server/store" +) + +// pwStore wraps MemStore with controllable DeleteOtherSessions behavior and +// audit capture, so the committed-password partial-success contract (W2-2) +// is testable. +type pwStore struct { + *store.MemStore + failRevokes int // number of DeleteOtherSessions calls that fail before succeeding + revokeCalls int + audits []string +} + +func (f *pwStore) DeleteOtherSessions(_, _ int64) (int64, error) { + f.revokeCalls++ + if f.revokeCalls <= f.failRevokes { + return 0, errors.New("session table locked") + } + return 2, nil +} + +func (f *pwStore) LogAudit(_ int64, action, _ string, _ int64, _ string) error { + f.audits = append(f.audits, action) + return nil +} + +// TestChangePassword_RevokeFailureIsPartialSuccess locks the W2-2 contract: +// once the password is committed, revocation failure must never surface as an +// error (the old password is dead; a "failed" report walks the user into the +// confirm lockout), and the audit row must still be written. +func TestChangePassword_RevokeFailureIsPartialSuccess(t *testing.T) { + ms := store.NewMemStore() + ms.SeedUser(&db.User{ID: 7, Username: "pat", PasswordHash: "oldhash"}) + fs := &pwStore{MemStore: ms, failRevokes: 99} + svc := NewUserService(fs) + + res, err := svc.ChangePassword(7, "newhash", 1) + if err != nil { + t.Fatalf("committed password change must not return an error: %v", err) + } + if !res.RevokeFailed { + t.Fatal("RevokeFailed should be set when revocation keeps failing") + } + if u, _ := ms.GetUserByID(7); u.PasswordHash != "newhash" { + t.Fatal("password should be committed") + } + if !slices.Contains(fs.audits, "password_change") { + t.Fatal("audit row must be written even when revocation fails") + } +} + +// TestChangePassword_RetryRecoversRevocation: a single transient revocation +// failure is absorbed by the bounded compensating retry. +func TestChangePassword_RetryRecoversRevocation(t *testing.T) { + ms := store.NewMemStore() + ms.SeedUser(&db.User{ID: 7, Username: "pat", PasswordHash: "oldhash"}) + fs := &pwStore{MemStore: ms, failRevokes: 1} + svc := NewUserService(fs) + + res, err := svc.ChangePassword(7, "newhash", 1) + if err != nil { + t.Fatalf("ChangePassword: %v", err) + } + if res.RevokeFailed { + t.Fatal("retry should have recovered the revocation") + } + if res.SessionsRevoked != 2 { + t.Fatalf("SessionsRevoked = %d, want 2", res.SessionsRevoked) + } + if fs.revokeCalls != 2 { + t.Fatalf("expected exactly one retry (2 calls), got %d", fs.revokeCalls) + } +} diff --git a/Server/sqlc.yaml b/Server/sqlc.yaml index 60042c41..4abde8d5 100644 --- a/Server/sqlc.yaml +++ b/Server/sqlc.yaml @@ -16,23 +16,3 @@ sql: emit_empty_slices: true emit_json_tags: true json_tags_case_style: "camel" - - # ── PostgreSQL backend (Phase A Step 3) ─────────────────────────────────── - # Postgres is opt-in via database.type = "postgres" in owncord.yaml. - # The generated querier lives in a separate package (pgdbgen) so sqlite - # and postgres code can coexist without import collisions. - - engine: "postgresql" - queries: "db/queries/postgres" - schema: "migrations/postgres" - gen: - go: - package: "pgdbgen" - out: "db/pgdbgen" - sql_package: "pgx/v5" - emit_interface: true - emit_pointers_for_null_types: true - emit_prepared_queries: false - emit_exact_table_names: false - emit_empty_slices: true - emit_json_tags: true - json_tags_case_style: "camel" diff --git a/Server/store/memstore.go b/Server/store/memstore.go index 549ed96e..1837c54a 100644 --- a/Server/store/memstore.go +++ b/Server/store/memstore.go @@ -39,6 +39,10 @@ type MemStore struct { blocks map[int64]map[int64]bool // userID -> channelID -> lastReadMessageID readStates map[int64]map[int64]int64 + // attachment id -> row. Tracks uploader_id/message_id so the atomic + // link-ownership guard is exercised against a store that really records + // ownership instead of a (nil, nil) stub. + attachments map[string]*db.Attachment // Phase B Step 7 / Phase C Step 9 — events + plugin KV. Lazily initialised // via ensureEvents() so existing tests that constructed a bare MemStore @@ -58,6 +62,7 @@ func NewMemStore() *MemStore { channelOverrides: make(map[int64]map[int64]db.ChannelOverride), reactions: make(map[int64]map[int64]map[string]bool), dmParticipants: make(map[int64]map[int64]bool), + attachments: make(map[string]*db.Attachment), blocks: make(map[int64]map[int64]bool), readStates: make(map[int64]map[int64]int64), } @@ -128,9 +133,9 @@ func (m *MemStore) SeedBlock(blockerID, blockedID int64) { // ---------- Store interface: top-level ---------- -func (m *MemStore) Close() error { return nil } -func (m *MemStore) SQLDb() *sql.DB { return nil } -func (m *MemStore) WithTx(_ context.Context, fn func(Store) error) error { return fn(m) } +func (m *MemStore) Close() error { return nil } +func (m *MemStore) SQLDb() *sql.DB { return nil } +func (m *MemStore) WithTx(_ context.Context, fn func(Store) error) error { return fn(m) } // ---------- MessageStore ---------- @@ -273,8 +278,26 @@ func (m *MemStore) GetLatestMessageID(channelID int64) (int64, error) { return latest, nil } -func (m *MemStore) LinkAttachmentsToMessage(_ int64, _ []string) (int64, error) { - return 0, nil +// LinkAttachmentsToMessage mirrors the SQL guard in db.LinkAttachmentsToMessage: +// only unlinked attachments owned by uploaderID (or legacy rows with a nil +// uploader) are claimed; everything else is skipped, not an error. +func (m *MemStore) LinkAttachmentsToMessage(messageID, uploaderID int64, attachmentIDs []string) (int64, error) { + m.mu.Lock() + defer m.mu.Unlock() + var n int64 + for _, id := range attachmentIDs { + att, ok := m.attachments[id] + if !ok || att.MessageID != nil { + continue + } + if att.UploaderID != nil && *att.UploaderID != uploaderID { + continue + } + mid := messageID + att.MessageID = &mid + n++ + } + return n, nil } func (m *MemStore) GetAttachmentsByMessageIDs(_ []int64) (map[int64][]db.AttachmentInfo, error) { @@ -403,8 +426,13 @@ func (m *MemStore) UpdateUserProfile(_ int64, _ string, _ *string) error { panic("memstore: not implemented: UpdateUserProfile") } -func (m *MemStore) UpdateUserPassword(_ int64, _ string) error { - panic("memstore: not implemented: UpdateUserPassword") +func (m *MemStore) UpdateUserPassword(userID int64, hash string) error { + m.mu.Lock() + defer m.mu.Unlock() + if u, ok := m.users[userID]; ok { + u.PasswordHash = hash + } + return nil } func (m *MemStore) UpdateUserStatus(id int64, status string) error { @@ -645,7 +673,14 @@ func (m *MemStore) GetDMParticipantIDs(channelID int64) ([]int64, error) { return ids, nil } -func (m *MemStore) GetDMRecipient(_ int64, _ int64) (*db.User, error) { +func (m *MemStore) GetDMRecipient(channelID, userID int64) (*db.User, error) { + m.mu.Lock() + defer m.mu.Unlock() + for uid := range m.dmParticipants[channelID] { + if uid != userID { + return m.users[uid], nil + } + } return nil, nil } @@ -681,12 +716,21 @@ func (m *MemStore) ListBlockedUsers(_ int64) ([]int64, error) { // ---------- AttachmentStore ---------- -func (m *MemStore) CreateAttachment(_ string, _ int64, _, _, _ string, _ int64, _, _ *int) error { - panic("memstore: not implemented: CreateAttachment") +func (m *MemStore) CreateAttachment(id string, uploaderID int64, filename, storedAs, mimeType string, size int64, _, _ *int) error { + m.mu.Lock() + defer m.mu.Unlock() + uid := uploaderID + m.attachments[id] = &db.Attachment{ + ID: id, UploaderID: &uid, Filename: filename, + StoredAs: storedAs, MimeType: mimeType, Size: size, + } + return nil } -func (m *MemStore) GetAttachmentByID(_ string) (*db.Attachment, error) { - panic("memstore: not implemented: GetAttachmentByID") +func (m *MemStore) GetAttachmentByID(id string) (*db.Attachment, error) { + m.mu.Lock() + defer m.mu.Unlock() + return m.attachments[id], nil } func (m *MemStore) GetAttachmentWithChannel(_ string) (*db.AttachmentAccess, error) { @@ -711,12 +755,26 @@ func (m *MemStore) ListAllUsers(_ int, _ int) ([]db.UserWithRole, error) { panic("memstore: not implemented: ListAllUsers") } -func (m *MemStore) BanUser(_ int64, _ string, _ *time.Time) error { - panic("memstore: not implemented: BanUser") +func (m *MemStore) BanUser(userID int64, reason string, _ *time.Time) error { + m.mu.Lock() + defer m.mu.Unlock() + if u, ok := m.users[userID]; ok { + u.Banned = true + r := reason + u.BanReason = &r + } + return nil } -func (m *MemStore) UnbanUser(_ int64) error { - panic("memstore: not implemented: UnbanUser") +func (m *MemStore) UnbanUser(userID int64) error { + m.mu.Lock() + defer m.mu.Unlock() + if u, ok := m.users[userID]; ok { + u.Banned = false + u.BanReason = nil + u.BanExpires = nil + } + return nil } func (m *MemStore) LogAudit(_ int64, _, _ string, _ int64, _ string) error { diff --git a/Server/store/postgres.go b/Server/store/postgres.go deleted file mode 100644 index d99377cf..00000000 --- a/Server/store/postgres.go +++ /dev/null @@ -1,833 +0,0 @@ -//go:build postgres - -// Package store — PostgreSQL backend. -// -// This file is compiled only when the `postgres` build tag is set. The -// default `go build ./...` produces a sqlite-only binary with zero postgres -// dependencies. To enable postgres support: -// -// go get github.com/jackc/pgx/v5 -// go build -tags postgres ./... -// -// The connection lifecycle methods (Open, Close, SQLDb, WithTx) are fully -// implemented against the pgx stdlib driver. Query methods are stubbed — -// they satisfy the Store interface so the type-assertion check at the -// bottom of this file passes, but each returns ErrPostgresNotImplemented at -// runtime. The stubs will be replaced incrementally as Server/db/pgdbgen/ -// is generated from the query files in Server/db/queries/postgres/ via -// `make sqlc-generate`, and PostgresStore methods migrate to wrap the -// generated querier. -// -// Until the store-everywhere refactor lands in main.go / router.go, this -// type is not yet wired into the runtime — see phase-a-foundation.md's -// "Pending" section. Constructing a PostgresStore in isolation works, but -// main.go will still refuse to start with type: "postgres" until the -// boundary refactor lands. - -package store - -import ( - "context" - "database/sql" - "errors" - "fmt" - "strings" - "time" - - // pgx's stdlib driver exposes pgx as a database/sql driver, letting - // PostgresStore reuse the same *sql.DB patterns as SQLiteStore. Once the - // sqlc-generated pgdbgen package is wired in, this import shifts to the - // native pgxpool API for zero-overhead query execution. - _ "github.com/jackc/pgx/v5/stdlib" - - "github.com/owncord/server/config" - "github.com/owncord/server/db" -) - -// ErrPostgresNotImplemented is returned by every query method that is not -// yet backed by sqlc-generated code. It is a sentinel error so callers and -// tests can detect "postgres path reached but implementation pending". -var ErrPostgresNotImplemented = errors.New("postgres backend: query not yet implemented (awaiting sqlc-generated pgdbgen)") - -// PostgresStore implements store.Store against a PostgreSQL database. -// It wraps a *sql.DB opened with pgx's stdlib driver. The query methods are -// currently stubs; see the package-level comment for the migration path. -type PostgresStore struct { - sqlDB *sql.DB -} - -// NewPostgresStore creates a PostgresStore from an already-open *sql.DB. -// Callers that want a one-step constructor should use OpenPostgres. -func NewPostgresStore(sqlDB *sql.DB) *PostgresStore { - return &PostgresStore{sqlDB: sqlDB} -} - -// OpenPostgres dials a PostgreSQL server using the connection settings in -// cfg and returns a ready-to-use PostgresStore. The caller is responsible -// for calling Close when done. Connection pooling is handled by *sql.DB; -// cfg.MaxConns > 0 caps the pool at that size, otherwise the database/sql -// default is used. -func OpenPostgres(cfg *config.DatabaseConfig) (*PostgresStore, error) { - if cfg == nil { - return nil, errors.New("OpenPostgres: nil config") - } - dsn := fmt.Sprintf( - "host=%s port=%d user=%s password=%s dbname=%s sslmode=%s", - cfg.Host, cfg.Port, cfg.User, cfg.Password, cfg.Name, cfg.SSLMode, - ) - sqlDB, err := sql.Open("pgx", dsn) - if err != nil { - return nil, fmt.Errorf("OpenPostgres: open: %w", err) - } - if cfg.MaxConns > 0 { - sqlDB.SetMaxOpenConns(cfg.MaxConns) - sqlDB.SetMaxIdleConns(cfg.MaxConns) - } - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - if err := sqlDB.PingContext(ctx); err != nil { - _ = sqlDB.Close() - return nil, fmt.Errorf("OpenPostgres: ping: %w", err) - } - return &PostgresStore{sqlDB: sqlDB}, nil -} - -// Close releases the underlying database connection pool. -func (s *PostgresStore) Close() error { return s.sqlDB.Close() } - -// SQLDb returns the underlying *sql.DB for callers that need raw access -// (backup, migration runners, ad-hoc queries). -func (s *PostgresStore) SQLDb() *sql.DB { return s.sqlDB } - -// WithTx runs fn inside a transaction. The transaction is committed if fn -// returns nil, otherwise rolled back. Postgres supports full transactional -// semantics (unlike SQLite's single-writer model), so concurrent callers -// are safe. -func (s *PostgresStore) WithTx(ctx context.Context, fn func(Store) error) error { - tx, err := s.sqlDB.BeginTx(ctx, nil) - if err != nil { - return fmt.Errorf("PostgresStore.WithTx: begin: %w", err) - } - if txErr := fn(s); txErr != nil { - _ = tx.Rollback() - return txErr - } - return tx.Commit() -} - -// ── MessageStore (stubs) ──────────────────────────────────────────────────── - -func (s *PostgresStore) CreateMessage(channelID, userID int64, content string, replyTo *int64) (int64, error) { - return 0, ErrPostgresNotImplemented -} - -func (s *PostgresStore) GetMessage(id int64) (*db.Message, error) { - return nil, ErrPostgresNotImplemented -} - -func (s *PostgresStore) GetMessages(channelID, before int64, limit int) ([]db.MessageWithUser, error) { - return nil, ErrPostgresNotImplemented -} - -func (s *PostgresStore) GetMessagesForAPI(channelID, before int64, limit int, requestingUserID int64) ([]db.MessageAPIResponse, error) { - return nil, ErrPostgresNotImplemented -} - -func (s *PostgresStore) EditMessage(id, userID int64, content string) error { - return ErrPostgresNotImplemented -} - -func (s *PostgresStore) DeleteMessage(id, userID int64, isMod bool) error { - return ErrPostgresNotImplemented -} - -func (s *PostgresStore) SearchMessages(query string, channelID *int64, limit int) ([]db.MessageSearchResult, error) { - return nil, ErrPostgresNotImplemented -} - -func (s *PostgresStore) SearchMessagesInChannels(query string, channelIDs []int64, limit int) ([]db.MessageSearchResult, error) { - return nil, ErrPostgresNotImplemented -} - -func (s *PostgresStore) GetPinnedMessages(channelID int64, requestingUserID int64) ([]db.MessageAPIResponse, error) { - return nil, ErrPostgresNotImplemented -} - -func (s *PostgresStore) SetMessagePinned(id int64, pinned bool) error { - return ErrPostgresNotImplemented -} - -func (s *PostgresStore) AddReaction(messageID, userID int64, emoji string) error { - return ErrPostgresNotImplemented -} - -func (s *PostgresStore) RemoveReaction(messageID, userID int64, emoji string) error { - return ErrPostgresNotImplemented -} - -func (s *PostgresStore) GetReactions(messageID int64) ([]db.ReactionCount, error) { - return nil, ErrPostgresNotImplemented -} - -func (s *PostgresStore) UpdateReadState(userID, channelID, lastReadMessageID int64) error { - return ErrPostgresNotImplemented -} - -func (s *PostgresStore) GetChannelUnreadCounts(userID int64) (map[int64]db.ChannelUnread, error) { - return nil, ErrPostgresNotImplemented -} - -func (s *PostgresStore) GetLatestMessageID(channelID int64) (int64, error) { - return 0, ErrPostgresNotImplemented -} - -func (s *PostgresStore) LinkAttachmentsToMessage(messageID int64, attachmentIDs []string) (int64, error) { - return 0, ErrPostgresNotImplemented -} - -func (s *PostgresStore) GetAttachmentsByMessageIDs(msgIDs []int64) (map[int64][]db.AttachmentInfo, error) { - return nil, ErrPostgresNotImplemented -} - -// ── ChannelStore (stubs) ──────────────────────────────────────────────────── - -func (s *PostgresStore) ListChannels() ([]db.Channel, error) { - return nil, ErrPostgresNotImplemented -} - -func (s *PostgresStore) GetChannel(id int64) (*db.Channel, error) { - return nil, ErrPostgresNotImplemented -} - -func (s *PostgresStore) CreateChannel(name, chanType, category, topic string, position int) (int64, error) { - return 0, ErrPostgresNotImplemented -} - -func (s *PostgresStore) UpdateChannel(id int64, name, topic string, slowMode int) error { - return ErrPostgresNotImplemented -} - -func (s *PostgresStore) DeleteChannel(id int64) error { - return ErrPostgresNotImplemented -} - -func (s *PostgresStore) SetChannelSlowMode(id int64, slowMode int) error { - return ErrPostgresNotImplemented -} - -func (s *PostgresStore) SetChannelVoiceMaxUsers(id int64, maxUsers int) error { - return ErrPostgresNotImplemented -} - -func (s *PostgresStore) GetChannelPermissions(channelID, roleID int64) (int64, int64, error) { - return 0, 0, ErrPostgresNotImplemented -} - -func (s *PostgresStore) GetAllChannelPermissionsForRole(roleID int64) (map[int64]db.ChannelOverride, error) { - return nil, ErrPostgresNotImplemented -} - -func (s *PostgresStore) GetChannelTypes(ids []int64) (map[int64]string, error) { - return nil, ErrPostgresNotImplemented -} - -// ── UserStore (stubs) ─────────────────────────────────────────────────────── - -func (s *PostgresStore) GetUserByID(id int64) (*db.User, error) { - return nil, ErrPostgresNotImplemented -} - -func (s *PostgresStore) GetUserByUsername(username string) (*db.User, error) { - return nil, ErrPostgresNotImplemented -} - -func (s *PostgresStore) CreateUser(username, passwordHash string, roleID int) (int64, error) { - return 0, ErrPostgresNotImplemented -} - -func (s *PostgresStore) CreateOwnerIfEmpty(username, passwordHash string, roleID int) (int64, error) { - return 0, ErrPostgresNotImplemented -} - -func (s *PostgresStore) CreateUserWithInvite(username, passwordHash string, roleID int, inviteCode string) (int64, error) { - return 0, ErrPostgresNotImplemented -} - -func (s *PostgresStore) UpdateUserProfile(userID int64, username string, avatar *string) error { - return ErrPostgresNotImplemented -} - -func (s *PostgresStore) UpdateUserPassword(userID int64, newPasswordHash string) error { - return ErrPostgresNotImplemented -} - -func (s *PostgresStore) UpdateUserStatus(id int64, status string) error { - return ErrPostgresNotImplemented -} - -func (s *PostgresStore) UpdateUserTOTPSecret(id int64, secret *string) error { - return ErrPostgresNotImplemented -} - -func (s *PostgresStore) UpdateUserRole(userID, roleID int64) error { - return ErrPostgresNotImplemented -} - -func (s *PostgresStore) ResetAllUserStatuses() error { - return ErrPostgresNotImplemented -} - -func (s *PostgresStore) DeleteAccount(ctx context.Context, userID int64) error { - return ErrPostgresNotImplemented -} - -func (s *PostgresStore) ListMembers() ([]db.MemberSummary, error) { - return nil, ErrPostgresNotImplemented -} - -// ── SessionStore (stubs) ──────────────────────────────────────────────────── - -func (s *PostgresStore) CreateSession(userID int64, tokenHash, device, ip string) (int64, error) { - return 0, ErrPostgresNotImplemented -} - -func (s *PostgresStore) GetSessionByTokenHash(tokenHash string) (*db.Session, error) { - return nil, ErrPostgresNotImplemented -} - -func (s *PostgresStore) GetSessionWithBanStatus(tokenHash string) (*db.SessionWithBanStatus, error) { - return nil, ErrPostgresNotImplemented -} - -func (s *PostgresStore) DeleteSession(tokenHash string) error { - return ErrPostgresNotImplemented -} - -func (s *PostgresStore) DeleteOtherSessions(userID, keepSessionID int64) (int64, error) { - return 0, ErrPostgresNotImplemented -} - -func (s *PostgresStore) DeleteExpiredSessions() error { - return ErrPostgresNotImplemented -} - -func (s *PostgresStore) DeleteSessionByID(sessionID, userID int64) error { - return ErrPostgresNotImplemented -} - -func (s *PostgresStore) TouchSession(tokenHash string) error { - return ErrPostgresNotImplemented -} - -func (s *PostgresStore) ListUserSessions(userID int64) ([]db.Session, error) { - return nil, ErrPostgresNotImplemented -} - -func (s *PostgresStore) ForceLogoutUser(userID int64) error { - return ErrPostgresNotImplemented -} - -func (s *PostgresStore) GetUserSessions(userID int64) ([]db.Session, error) { - return nil, ErrPostgresNotImplemented -} - -// ── RoleStore (stubs) ─────────────────────────────────────────────────────── - -func (s *PostgresStore) GetRoleByID(id int64) (*db.Role, error) { - return nil, ErrPostgresNotImplemented -} - -func (s *PostgresStore) GetRoleForUser(userID int64) (*db.Role, error) { - return nil, ErrPostgresNotImplemented -} - -func (s *PostgresStore) GetUserWithRole(userID int64) (*db.User, *db.Role, error) { - return nil, nil, ErrPostgresNotImplemented -} - -func (s *PostgresStore) ListRoles() ([]*db.Role, error) { - return nil, ErrPostgresNotImplemented -} - -// ── InviteStore (stubs) ───────────────────────────────────────────────────── - -func (s *PostgresStore) CreateInvite(createdBy int64, maxUses int, expiresAt *time.Time) (string, error) { - return "", ErrPostgresNotImplemented -} - -func (s *PostgresStore) GetInvite(code string) (*db.Invite, error) { - return nil, ErrPostgresNotImplemented -} - -func (s *PostgresStore) ListInvites() ([]*db.Invite, error) { - return nil, ErrPostgresNotImplemented -} - -func (s *PostgresStore) UseInviteAtomic(code string) error { - return ErrPostgresNotImplemented -} - -func (s *PostgresStore) RevokeInvite(code string) error { - return ErrPostgresNotImplemented -} - -// ── VoiceStore (stubs) ────────────────────────────────────────────────────── - -func (s *PostgresStore) JoinVoiceChannel(userID, channelID int64) error { - return ErrPostgresNotImplemented -} - -func (s *PostgresStore) JoinVoiceChannelIfCapacity(userID, channelID int64, maxUsers int) error { - return ErrPostgresNotImplemented -} - -func (s *PostgresStore) LeaveVoiceChannel(userID int64) error { - return ErrPostgresNotImplemented -} - -func (s *PostgresStore) LeaveVoiceChannelIfMatch(userID, expectedChannelID int64, expectedJoinedAt string) (bool, error) { - return false, ErrPostgresNotImplemented -} - -func (s *PostgresStore) GetVoiceState(userID int64) (*db.VoiceState, error) { - return nil, ErrPostgresNotImplemented -} - -func (s *PostgresStore) GetChannelVoiceStates(channelID int64) ([]db.VoiceState, error) { - return nil, ErrPostgresNotImplemented -} - -func (s *PostgresStore) GetAllVoiceStates() ([]db.VoiceState, error) { - return nil, ErrPostgresNotImplemented -} - -func (s *PostgresStore) UpdateVoiceMute(userID int64, muted bool) error { - return ErrPostgresNotImplemented -} - -func (s *PostgresStore) UpdateVoiceDeafen(userID int64, deafened bool) error { - return ErrPostgresNotImplemented -} - -func (s *PostgresStore) ClearVoiceState(userID int64) error { - return ErrPostgresNotImplemented -} - -func (s *PostgresStore) ClearAllVoiceStates() error { - return ErrPostgresNotImplemented -} - -func (s *PostgresStore) CountActiveCameras(channelID int64) (int, error) { - return 0, ErrPostgresNotImplemented -} - -func (s *PostgresStore) UpdateVoiceCamera(userID int64, camera bool) error { - return ErrPostgresNotImplemented -} - -func (s *PostgresStore) EnableCameraIfUnderLimit(userID, channelID int64, maxVideo int) (bool, error) { - return false, ErrPostgresNotImplemented -} - -func (s *PostgresStore) UpdateVoiceScreenshare(userID int64, screenshare bool) error { - return ErrPostgresNotImplemented -} - -func (s *PostgresStore) CountChannelVoiceUsers(channelID int64) (int, error) { - return 0, ErrPostgresNotImplemented -} - -// ── DMStore (stubs) ───────────────────────────────────────────────────────── - -func (s *PostgresStore) GetOrCreateDMChannel(user1ID, user2ID int64) (*db.Channel, bool, error) { - return nil, false, ErrPostgresNotImplemented -} - -func (s *PostgresStore) GetUserDMChannels(userID int64) ([]db.DMChannelInfo, error) { - return nil, ErrPostgresNotImplemented -} - -func (s *PostgresStore) OpenDM(userID, channelID int64) error { - return ErrPostgresNotImplemented -} - -func (s *PostgresStore) CloseDM(userID, channelID int64) error { - return ErrPostgresNotImplemented -} - -func (s *PostgresStore) IsDMParticipant(userID, channelID int64) (bool, error) { - return false, ErrPostgresNotImplemented -} - -func (s *PostgresStore) GetDMParticipantIDs(channelID int64) ([]int64, error) { - return nil, ErrPostgresNotImplemented -} - -func (s *PostgresStore) GetDMRecipient(channelID, requestingUserID int64) (*db.User, error) { - return nil, ErrPostgresNotImplemented -} - -// ── BlockStore (stubs) ────────────────────────────────────────────────────── - -func (s *PostgresStore) BlockUser(blockerID, blockedID int64) error { - return ErrPostgresNotImplemented -} - -func (s *PostgresStore) UnblockUser(blockerID, blockedID int64) error { - return ErrPostgresNotImplemented -} - -func (s *PostgresStore) IsBlocked(blockerID, blockedID int64) (bool, error) { - return false, ErrPostgresNotImplemented -} - -func (s *PostgresStore) IsEitherBlocked(userA, userB int64) (bool, error) { - return false, ErrPostgresNotImplemented -} - -func (s *PostgresStore) ListBlockedUsers(blockerID int64) ([]int64, error) { - return nil, ErrPostgresNotImplemented -} - -// ── AttachmentStore (stubs) ───────────────────────────────────────────────── - -func (s *PostgresStore) CreateAttachment(id string, uploaderID int64, filename, storedAs, mimeType string, size int64, width, height *int) error { - return ErrPostgresNotImplemented -} - -func (s *PostgresStore) GetAttachmentByID(id string) (*db.Attachment, error) { - return nil, ErrPostgresNotImplemented -} - -func (s *PostgresStore) GetAttachmentWithChannel(id string) (*db.AttachmentAccess, error) { - return nil, ErrPostgresNotImplemented -} - -func (s *PostgresStore) DeleteOrphanedAttachments(cutoff string) ([]string, error) { - return nil, ErrPostgresNotImplemented -} - -// ── AdminStore (stubs) ────────────────────────────────────────────────────── - -func (s *PostgresStore) UserCount() (int64, error) { - return 0, ErrPostgresNotImplemented -} - -func (s *PostgresStore) GetServerStats() (*db.ServerStats, error) { - return nil, ErrPostgresNotImplemented -} - -func (s *PostgresStore) ListAllUsers(limit, offset int) ([]db.UserWithRole, error) { - return nil, ErrPostgresNotImplemented -} - -func (s *PostgresStore) BanUser(id int64, reason string, expires *time.Time) error { - return ErrPostgresNotImplemented -} - -func (s *PostgresStore) UnbanUser(id int64) error { - return ErrPostgresNotImplemented -} - -func (s *PostgresStore) LogAudit(actorID int64, action, targetType string, targetID int64, detail string) error { - return ErrPostgresNotImplemented -} - -func (s *PostgresStore) GetAuditLog(limit, offset int) ([]db.AuditEntry, error) { - return nil, ErrPostgresNotImplemented -} - -func (s *PostgresStore) AdminCreateChannel(name, chanType, category, topic string, position int) (int64, error) { - return 0, ErrPostgresNotImplemented -} - -func (s *PostgresStore) AdminUpdateChannel(id int64, name, topic string, slowMode, position int, archived bool) error { - return ErrPostgresNotImplemented -} - -func (s *PostgresStore) AdminDeleteChannel(id int64) error { - return ErrPostgresNotImplemented -} - -func (s *PostgresStore) BackupTo(path string) error { - return ErrPostgresNotImplemented -} - -func (s *PostgresStore) BackupToSafe(path, safeRoot string) error { - return ErrPostgresNotImplemented -} - -func (s *PostgresStore) CountUsersWithoutTOTP() (int, error) { - return 0, ErrPostgresNotImplemented -} - -// ── SettingsStore (stubs) ─────────────────────────────────────────────────── - -func (s *PostgresStore) GetSetting(key string) (string, error) { - return "", ErrPostgresNotImplemented -} - -func (s *PostgresStore) SetSetting(key, value string) error { - return ErrPostgresNotImplemented -} - -func (s *PostgresStore) GetAllSettings() (map[string]string, error) { - return nil, ErrPostgresNotImplemented -} - -// ── EventStore (Phase B Step 7) ────────────────────────────────────────────── - -func (s *PostgresStore) PersistEvent(ctx context.Context, seq int64, eventType string, channelID int64, payload []byte) error { - _, err := s.sqlDB.ExecContext(ctx, - `INSERT INTO events (seq, event_type, channel_id, payload) VALUES ($1, $2, $3, $4)`, - seq, eventType, channelID, payload, - ) - if err != nil { - return fmt.Errorf("PersistEvent: %w", err) - } - return nil -} - -func (s *PostgresStore) GetEventsSince(ctx context.Context, afterSeq int64, limit int) ([]db.PersistedEvent, error) { - rows, err := s.sqlDB.QueryContext(ctx, - `SELECT seq, event_type, channel_id, payload, created_at - FROM events - WHERE seq > $1 - ORDER BY seq ASC - LIMIT $2`, - afterSeq, limit, - ) - if err != nil { - return nil, fmt.Errorf("GetEventsSince: %w", err) - } - defer rows.Close() - return scanPgEventRows(rows) -} - -func (s *PostgresStore) GetEventsSinceForChannels(ctx context.Context, afterSeq int64, channelIDs []int64, limit int) ([]db.PersistedEvent, error) { - if len(channelIDs) == 0 { - rows, err := s.sqlDB.QueryContext(ctx, - `SELECT seq, event_type, channel_id, payload, created_at - FROM events - WHERE seq > $1 AND channel_id = 0 - ORDER BY seq ASC - LIMIT $2`, - afterSeq, limit, - ) - if err != nil { - return nil, fmt.Errorf("GetEventsSinceForChannels (global only): %w", err) - } - defer rows.Close() - return scanPgEventRows(rows) - } - - placeholders := make([]string, len(channelIDs)) - args := make([]any, 0, len(channelIDs)+2) - args = append(args, afterSeq) - for i, cid := range channelIDs { - placeholders[i] = fmt.Sprintf("$%d", i+2) - args = append(args, cid) - } - args = append(args, limit) - - query := fmt.Sprintf( - `SELECT seq, event_type, channel_id, payload, created_at - FROM events - WHERE seq > $1 - AND (channel_id = 0 OR channel_id IN (%s)) - ORDER BY seq ASC - LIMIT $%d`, - strings.Join(placeholders, ","), - len(channelIDs)+2, - ) - rows, err := s.sqlDB.QueryContext(ctx, query, args...) - if err != nil { - return nil, fmt.Errorf("GetEventsSinceForChannels: %w", err) - } - defer rows.Close() - return scanPgEventRows(rows) -} - -func (s *PostgresStore) PruneEventsOlderThan(ctx context.Context, cutoff time.Time) (int64, error) { - res, err := s.sqlDB.ExecContext(ctx, - `DELETE FROM events WHERE created_at < $1`, - cutoff.UTC(), - ) - if err != nil { - return 0, fmt.Errorf("PruneEventsOlderThan: %w", err) - } - n, err := res.RowsAffected() - if err != nil { - return 0, fmt.Errorf("PruneEventsOlderThan RowsAffected: %w", err) - } - return n, nil -} - -func (s *PostgresStore) GetMaxEventSeq(ctx context.Context) (int64, error) { - var maxSeq int64 - err := s.sqlDB.QueryRowContext(ctx, - `SELECT COALESCE(MAX(seq), 0)::BIGINT FROM events`, - ).Scan(&maxSeq) - if err != nil { - return 0, fmt.Errorf("GetMaxEventSeq: %w", err) - } - return maxSeq, nil -} - -// ── PluginStore (Phase C Step 9) ──────────────────────────────────────────── - -func (s *PostgresStore) InstallPlugin(ctx context.Context, name, version, manifestJSON string) (int64, error) { - var id int64 - err := s.sqlDB.QueryRowContext(ctx, - `INSERT INTO plugins (name, version, manifest_json) - VALUES ($1, $2, $3) - ON CONFLICT (name) DO UPDATE - SET version = excluded.version, - manifest_json = excluded.manifest_json - RETURNING id`, - name, version, manifestJSON, - ).Scan(&id) - if err != nil { - return 0, fmt.Errorf("InstallPlugin: %w", err) - } - return id, nil -} - -func (s *PostgresStore) EnablePlugin(ctx context.Context, id int64) error { - _, err := s.sqlDB.ExecContext(ctx, `UPDATE plugins SET enabled = TRUE WHERE id = $1`, id) - return err -} - -func (s *PostgresStore) DisablePlugin(ctx context.Context, id int64) error { - _, err := s.sqlDB.ExecContext(ctx, `UPDATE plugins SET enabled = FALSE WHERE id = $1`, id) - return err -} - -func (s *PostgresStore) UninstallPlugin(ctx context.Context, id int64) error { - _, err := s.sqlDB.ExecContext(ctx, `DELETE FROM plugins WHERE id = $1`, id) - return err -} - -func (s *PostgresStore) GetPlugin(ctx context.Context, id int64) (*db.PluginRow, error) { - row := s.sqlDB.QueryRowContext(ctx, - `SELECT id, name, version, enabled, manifest_json, installed_at FROM plugins WHERE id = $1`, - id, - ) - return scanPgPluginRow(row) -} - -func (s *PostgresStore) GetPluginByName(ctx context.Context, name string) (*db.PluginRow, error) { - row := s.sqlDB.QueryRowContext(ctx, - `SELECT id, name, version, enabled, manifest_json, installed_at FROM plugins WHERE name = $1`, - name, - ) - return scanPgPluginRow(row) -} - -func (s *PostgresStore) ListPlugins(ctx context.Context) ([]db.PluginRow, error) { - rows, err := s.sqlDB.QueryContext(ctx, - `SELECT id, name, version, enabled, manifest_json, installed_at FROM plugins ORDER BY name`, - ) - if err != nil { - return nil, fmt.Errorf("ListPlugins: %w", err) - } - defer rows.Close() - var out []db.PluginRow - for rows.Next() { - var p db.PluginRow - if err := rows.Scan(&p.ID, &p.Name, &p.Version, &p.Enabled, &p.ManifestJSON, &p.InstalledAt); err != nil { - return nil, fmt.Errorf("ListPlugins scan: %w", err) - } - out = append(out, p) - } - return out, rows.Err() -} - -func (s *PostgresStore) PluginKVGet(ctx context.Context, pluginID int64, key string) ([]byte, error) { - var v []byte - err := s.sqlDB.QueryRowContext(ctx, - `SELECT value FROM plugin_kv WHERE plugin_id = $1 AND key = $2`, - pluginID, key, - ).Scan(&v) - if err != nil { - return nil, err - } - return v, nil -} - -func (s *PostgresStore) PluginKVSet(ctx context.Context, pluginID int64, key string, value []byte) error { - _, err := s.sqlDB.ExecContext(ctx, - `INSERT INTO plugin_kv (plugin_id, key, value) VALUES ($1, $2, $3) - ON CONFLICT (plugin_id, key) DO UPDATE SET value = excluded.value`, - pluginID, key, value, - ) - return err -} - -func (s *PostgresStore) PluginKVDelete(ctx context.Context, pluginID int64, key string) error { - _, err := s.sqlDB.ExecContext(ctx, - `DELETE FROM plugin_kv WHERE plugin_id = $1 AND key = $2`, - pluginID, key, - ) - return err -} - -func (s *PostgresStore) PluginKVScan(ctx context.Context, pluginID int64, prefix string, limit int) (map[string][]byte, error) { - rows, err := s.sqlDB.QueryContext(ctx, - `SELECT key, value FROM plugin_kv WHERE plugin_id = $1 AND key LIKE $2 ORDER BY key LIMIT $3`, - pluginID, prefix+"%", limit, - ) - if err != nil { - return nil, fmt.Errorf("PluginKVScan: %w", err) - } - defer rows.Close() - out := make(map[string][]byte) - for rows.Next() { - var k string - var v []byte - if err := rows.Scan(&k, &v); err != nil { - return nil, err - } - out[k] = v - } - return out, rows.Err() -} - -// ── postgres scan helpers ──────────────────────────────────────────────────── - -type pgRowScanner interface { - Scan(dest ...any) error -} - -func scanPgPluginRow(row pgRowScanner) (*db.PluginRow, error) { - var p db.PluginRow - if err := row.Scan(&p.ID, &p.Name, &p.Version, &p.Enabled, &p.ManifestJSON, &p.InstalledAt); err != nil { - return nil, err - } - return &p, nil -} - -type pgRowsScanner interface { - Next() bool - Scan(dest ...any) error - Err() error -} - -func scanPgEventRows(rows pgRowsScanner) ([]db.PersistedEvent, error) { - var out []db.PersistedEvent - for rows.Next() { - var e db.PersistedEvent - if err := rows.Scan(&e.Seq, &e.EventType, &e.ChannelID, &e.Payload, &e.CreatedAt); err != nil { - return nil, fmt.Errorf("scanPgEventRows: %w", err) - } - out = append(out, e) - } - if err := rows.Err(); err != nil { - return nil, err - } - return out, nil -} - -// Compile-time interface check — fails to compile if any Store method is -// missing a PostgresStore receiver. -var _ Store = (*PostgresStore)(nil) diff --git a/Server/store/sqlite.go b/Server/store/sqlite.go index 42062c52..64f7aba5 100644 --- a/Server/store/sqlite.go +++ b/Server/store/sqlite.go @@ -109,8 +109,8 @@ func (s *SQLiteStore) GetChannelUnreadCounts(userID int64) (map[int64]db.Channel func (s *SQLiteStore) GetLatestMessageID(channelID int64) (int64, error) { return s.db.GetLatestMessageID(channelID) } -func (s *SQLiteStore) LinkAttachmentsToMessage(messageID int64, attachmentIDs []string) (int64, error) { - return s.db.LinkAttachmentsToMessage(messageID, attachmentIDs) +func (s *SQLiteStore) LinkAttachmentsToMessage(messageID, uploaderID int64, attachmentIDs []string) (int64, error) { + return s.db.LinkAttachmentsToMessage(messageID, uploaderID, attachmentIDs) } func (s *SQLiteStore) GetAttachmentsByMessageIDs(msgIDs []int64) (map[int64][]db.AttachmentInfo, error) { return s.db.GetAttachmentsByMessageIDs(msgIDs) @@ -118,7 +118,7 @@ func (s *SQLiteStore) GetAttachmentsByMessageIDs(msgIDs []int64) (map[int64][]db // ── ChannelStore ──────────────────────────────────────────────────────────── -func (s *SQLiteStore) ListChannels() ([]db.Channel, error) { return s.db.ListChannels() } +func (s *SQLiteStore) ListChannels() ([]db.Channel, error) { return s.db.ListChannels() } func (s *SQLiteStore) GetChannel(id int64) (*db.Channel, error) { return s.db.GetChannel(id) } func (s *SQLiteStore) CreateChannel(name, chanType, category, topic string, position int) (int64, error) { return s.db.CreateChannel(name, chanType, category, topic, position) @@ -126,8 +126,10 @@ func (s *SQLiteStore) CreateChannel(name, chanType, category, topic string, posi func (s *SQLiteStore) UpdateChannel(id int64, name, topic string, slowMode int) error { return s.db.UpdateChannel(id, name, topic, slowMode) } -func (s *SQLiteStore) DeleteChannel(id int64) error { return s.db.DeleteChannel(id) } -func (s *SQLiteStore) SetChannelSlowMode(id int64, sm int) error { return s.db.SetChannelSlowMode(id, sm) } +func (s *SQLiteStore) DeleteChannel(id int64) error { return s.db.DeleteChannel(id) } +func (s *SQLiteStore) SetChannelSlowMode(id int64, sm int) error { + return s.db.SetChannelSlowMode(id, sm) +} func (s *SQLiteStore) SetChannelVoiceMaxUsers(id int64, max int) error { return s.db.SetChannelVoiceMaxUsers(id, max) } @@ -192,21 +194,25 @@ func (s *SQLiteStore) DeleteSession(tokenHash string) error { return s.db.Delete func (s *SQLiteStore) DeleteOtherSessions(userID, keepSessionID int64) (int64, error) { return s.db.DeleteOtherSessions(userID, keepSessionID) } -func (s *SQLiteStore) DeleteExpiredSessions() error { return s.db.DeleteExpiredSessions() } -func (s *SQLiteStore) DeleteSessionByID(sid, uid int64) error { return s.db.DeleteSessionByID(sid, uid) } -func (s *SQLiteStore) TouchSession(tokenHash string) error { return s.db.TouchSession(tokenHash) } +func (s *SQLiteStore) DeleteExpiredSessions() error { return s.db.DeleteExpiredSessions() } +func (s *SQLiteStore) DeleteSessionByID(sid, uid int64) error { + return s.db.DeleteSessionByID(sid, uid) +} +func (s *SQLiteStore) TouchSession(tokenHash string) error { return s.db.TouchSession(tokenHash) } func (s *SQLiteStore) ListUserSessions(userID int64) ([]db.Session, error) { return s.db.ListUserSessions(userID) } -func (s *SQLiteStore) ForceLogoutUser(userID int64) error { return s.db.ForceLogoutUser(userID) } +func (s *SQLiteStore) ForceLogoutUser(userID int64) error { return s.db.ForceLogoutUser(userID) } func (s *SQLiteStore) GetUserSessions(userID int64) ([]db.Session, error) { return s.db.GetUserSessions(userID) } // ── RoleStore ─────────────────────────────────────────────────────────────── -func (s *SQLiteStore) GetRoleByID(id int64) (*db.Role, error) { return s.db.GetRoleByID(id) } -func (s *SQLiteStore) GetRoleForUser(userID int64) (*db.Role, error) { return s.db.GetRoleForUser(userID) } +func (s *SQLiteStore) GetRoleByID(id int64) (*db.Role, error) { return s.db.GetRoleByID(id) } +func (s *SQLiteStore) GetRoleForUser(userID int64) (*db.Role, error) { + return s.db.GetRoleForUser(userID) +} func (s *SQLiteStore) GetUserWithRole(userID int64) (*db.User, *db.Role, error) { return s.db.GetUserWithRole(userID) } @@ -217,10 +223,10 @@ func (s *SQLiteStore) ListRoles() ([]*db.Role, error) { return s.db.ListRoles() func (s *SQLiteStore) CreateInvite(createdBy int64, maxUses int, expiresAt *time.Time) (string, error) { return s.db.CreateInvite(createdBy, maxUses, expiresAt) } -func (s *SQLiteStore) GetInvite(code string) (*db.Invite, error) { return s.db.GetInvite(code) } -func (s *SQLiteStore) ListInvites() ([]*db.Invite, error) { return s.db.ListInvites() } -func (s *SQLiteStore) UseInviteAtomic(code string) error { return s.db.UseInviteAtomic(code) } -func (s *SQLiteStore) RevokeInvite(code string) error { return s.db.RevokeInvite(code) } +func (s *SQLiteStore) GetInvite(code string) (*db.Invite, error) { return s.db.GetInvite(code) } +func (s *SQLiteStore) ListInvites() ([]*db.Invite, error) { return s.db.ListInvites() } +func (s *SQLiteStore) UseInviteAtomic(code string) error { return s.db.UseInviteAtomic(code) } +func (s *SQLiteStore) RevokeInvite(code string) error { return s.db.RevokeInvite(code) } // ── VoiceStore ────────────────────────────────────────────────────────────── @@ -240,15 +246,21 @@ func (s *SQLiteStore) GetVoiceState(userID int64) (*db.VoiceState, error) { func (s *SQLiteStore) GetChannelVoiceStates(channelID int64) ([]db.VoiceState, error) { return s.db.GetChannelVoiceStates(channelID) } -func (s *SQLiteStore) GetAllVoiceStates() ([]db.VoiceState, error) { return s.db.GetAllVoiceStates() } -func (s *SQLiteStore) UpdateVoiceMute(userID int64, m bool) error { return s.db.UpdateVoiceMute(userID, m) } -func (s *SQLiteStore) UpdateVoiceDeafen(userID int64, d bool) error { return s.db.UpdateVoiceDeafen(userID, d) } -func (s *SQLiteStore) ClearVoiceState(userID int64) error { return s.db.ClearVoiceState(userID) } -func (s *SQLiteStore) ClearAllVoiceStates() error { return s.db.ClearAllVoiceStates() } +func (s *SQLiteStore) GetAllVoiceStates() ([]db.VoiceState, error) { return s.db.GetAllVoiceStates() } +func (s *SQLiteStore) UpdateVoiceMute(userID int64, m bool) error { + return s.db.UpdateVoiceMute(userID, m) +} +func (s *SQLiteStore) UpdateVoiceDeafen(userID int64, d bool) error { + return s.db.UpdateVoiceDeafen(userID, d) +} +func (s *SQLiteStore) ClearVoiceState(userID int64) error { return s.db.ClearVoiceState(userID) } +func (s *SQLiteStore) ClearAllVoiceStates() error { return s.db.ClearAllVoiceStates() } func (s *SQLiteStore) CountActiveCameras(channelID int64) (int, error) { return s.db.CountActiveCameras(channelID) } -func (s *SQLiteStore) UpdateVoiceCamera(userID int64, c bool) error { return s.db.UpdateVoiceCamera(userID, c) } +func (s *SQLiteStore) UpdateVoiceCamera(userID int64, c bool) error { + return s.db.UpdateVoiceCamera(userID, c) +} func (s *SQLiteStore) EnableCameraIfUnderLimit(userID, channelID int64, maxVideo int) (bool, error) { return s.db.EnableCameraIfUnderLimit(userID, channelID, maxVideo) } @@ -267,7 +279,7 @@ func (s *SQLiteStore) GetOrCreateDMChannel(user1ID, user2ID int64) (*db.Channel, func (s *SQLiteStore) GetUserDMChannels(userID int64) ([]db.DMChannelInfo, error) { return s.db.GetUserDMChannels(userID) } -func (s *SQLiteStore) OpenDM(userID, channelID int64) error { return s.db.OpenDM(userID, channelID) } +func (s *SQLiteStore) OpenDM(userID, channelID int64) error { return s.db.OpenDM(userID, channelID) } func (s *SQLiteStore) CloseDM(userID, channelID int64) error { return s.db.CloseDM(userID, channelID) } func (s *SQLiteStore) IsDMParticipant(userID, channelID int64) (bool, error) { return s.db.IsDMParticipant(userID, channelID) @@ -314,7 +326,7 @@ func (s *SQLiteStore) DeleteOrphanedAttachments(cutoff string) ([]string, error) // ── AdminStore ────────────────────────────────────────────────────────────── -func (s *SQLiteStore) UserCount() (int64, error) { return s.db.UserCount() } +func (s *SQLiteStore) UserCount() (int64, error) { return s.db.UserCount() } func (s *SQLiteStore) GetServerStats() (*db.ServerStats, error) { return s.db.GetServerStats() } func (s *SQLiteStore) ListAllUsers(limit, offset int) ([]db.UserWithRole, error) { return s.db.ListAllUsers(limit, offset) diff --git a/Server/store/store.go b/Server/store/store.go index 925a078b..3d6c9df3 100644 --- a/Server/store/store.go +++ b/Server/store/store.go @@ -58,7 +58,7 @@ type MessageStore interface { UpdateReadState(userID, channelID, lastReadMessageID int64) error GetChannelUnreadCounts(userID int64) (map[int64]db.ChannelUnread, error) GetLatestMessageID(channelID int64) (int64, error) - LinkAttachmentsToMessage(messageID int64, attachmentIDs []string) (int64, error) + LinkAttachmentsToMessage(messageID, uploaderID int64, attachmentIDs []string) (int64, error) GetAttachmentsByMessageIDs(msgIDs []int64) (map[int64][]db.AttachmentInfo, error) } diff --git a/Server/telemetry/telemetry_otel.go b/Server/telemetry/telemetry_otel.go index b4b78822..fd9c3506 100644 --- a/Server/telemetry/telemetry_otel.go +++ b/Server/telemetry/telemetry_otel.go @@ -1,7 +1,7 @@ //go:build otel // Phase B Step 8 — Real OpenTelemetry-backed implementation. Compiled only -// with `-tags otel`, matching the postgres / wazero build-tag pattern used +// with `-tags otel`, matching the wazero build-tag pattern used // elsewhere in the repo. The default build ships telemetry_default.go with a // no-op provider so sqlite-only binaries do not pull the OTel SDK in. // diff --git a/Server/updater/updater.go b/Server/updater/updater.go index f06271b8..34f71898 100644 --- a/Server/updater/updater.go +++ b/Server/updater/updater.go @@ -117,11 +117,20 @@ type Updater struct { cacheExpiry time.Time cachedErr error errCacheExpiry time.Time + textAssetCache map[string]textAssetCacheEntry mu syncutil.Mutex httpClient *http.Client signingKeyText string } +// textAssetCacheEntry caches a small text asset (e.g. a client update .sig +// file) alongside the release cache so repeated requests are served from +// memory instead of re-fetching from GitHub on every call. +type textAssetCacheEntry struct { + content string + expiry time.Time +} + // NewUpdater creates an Updater for the given repository. func NewUpdater(currentVersion, githubToken, repoOwner, repoName string) *Updater { return &Updater{ @@ -606,21 +615,30 @@ func assetFilenameFromURL(rawURL string) (string, error) { return filename, nil } -// VerifyChecksum computes the SHA256 hash of the file at filePath and -// compares it (case-insensitive) against expectedHash. -func (u *Updater) VerifyChecksum(filePath, expectedHash string) error { - f, err := os.Open(filePath) +// FileSHA256 returns the hex-encoded SHA256 of the file at path. Exported so +// callers that snapshot a verified binary (the admin update TOCTOU re-check) +// share this exact hashing instead of duplicating it. +func FileSHA256(path string) (string, error) { + f, err := os.Open(path) if err != nil { - return fmt.Errorf("opening file for checksum: %w", err) + return "", fmt.Errorf("opening file for checksum: %w", err) } defer f.Close() //nolint:errcheck h := sha256.New() if _, err := io.Copy(h, f); err != nil { - return fmt.Errorf("computing checksum: %w", err) + return "", fmt.Errorf("computing checksum: %w", err) } + return hex.EncodeToString(h.Sum(nil)), nil +} - actual := hex.EncodeToString(h.Sum(nil)) +// VerifyChecksum computes the SHA256 hash of the file at filePath and +// compares it (case-insensitive) against expectedHash. +func (u *Updater) VerifyChecksum(filePath, expectedHash string) error { + actual, err := FileSHA256(filePath) + if err != nil { + return err + } if !strings.EqualFold(actual, expectedHash) { return fmt.Errorf("checksum mismatch: expected %s, got %s", expectedHash, actual) } @@ -728,6 +746,36 @@ func (u *Updater) FetchTextAsset(ctx context.Context, url string) (string, error return string(data), nil } +// FetchTextAssetCached is FetchTextAsset with an in-memory cache keyed by URL, +// using the same cacheTTL as the release cache. It lets unauthenticated, +// unrate-limited callers (e.g. the client-update endpoint) be served from +// memory instead of triggering an outbound fetch on every request. +func (u *Updater) FetchTextAssetCached(ctx context.Context, url string) (string, error) { + now := time.Now() + + u.mu.Lock() + if entry, ok := u.textAssetCache[url]; ok && now.Before(entry.expiry) { + content := entry.content + u.mu.Unlock() + return content, nil + } + u.mu.Unlock() + + content, err := u.FetchTextAsset(ctx, url) + if err != nil { + return "", err + } + + u.mu.Lock() + if u.textAssetCache == nil { + u.textAssetCache = make(map[string]textAssetCacheEntry) + } + u.textAssetCache[url] = textAssetCacheEntry{content: content, expiry: now.Add(cacheTTL)} + u.mu.Unlock() + + return content, nil +} + // downloadFile downloads the content at url and writes it to destPath. func (u *Updater) downloadFile(ctx context.Context, url, destPath string) error { req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) diff --git a/Server/ws/coverage_boost_test.go b/Server/ws/coverage_boost_test.go index c1914f04..4d887594 100644 --- a/Server/ws/coverage_boost_test.go +++ b/Server/ws/coverage_boost_test.go @@ -47,6 +47,7 @@ CREATE TABLE IF NOT EXISTS audit_log ( CREATE TABLE IF NOT EXISTS attachments ( id TEXT PRIMARY KEY, message_id INTEGER REFERENCES messages(id) ON DELETE CASCADE, + uploader_id INTEGER REFERENCES users(id), filename TEXT NOT NULL, stored_as TEXT NOT NULL, mime_type TEXT NOT NULL, diff --git a/Server/ws/emit_test.go b/Server/ws/emit_test.go index a49087cb..09fe4774 100644 --- a/Server/ws/emit_test.go +++ b/Server/ws/emit_test.go @@ -28,8 +28,7 @@ func newEmitTestHub() *Hub { return &Hub{ clients: make(map[int64]*Client), broadcast: make(chan broadcastMsg, 64), - register: make(chan *Client, 16), - unregister: make(chan *Client, 16), + clientEvents: make(chan clientEvent, 32), stop: make(chan struct{}), pubsub: NewPubSub(), replayBuf: NewEventRingBuffer(100), diff --git a/Server/ws/handler_v2_voice_e2ee_offer_test.go b/Server/ws/handler_v2_voice_e2ee_offer_test.go index a2e2be55..eee3843d 100644 --- a/Server/ws/handler_v2_voice_e2ee_offer_test.go +++ b/Server/ws/handler_v2_voice_e2ee_offer_test.go @@ -5,6 +5,8 @@ import ( "encoding/base64" "strings" "testing" + + "github.com/owncord/server/auth" ) var ( @@ -50,6 +52,47 @@ func TestVoiceE2EEOfferV2_HappyPath(t *testing.T) { } } +// TestVoiceE2EEOfferV2_RotationBurstNotRateLimited locks the W1-2 fix: the +// key holder rotates by sending one offer per peer back-to-back, so the +// limiter is keyed per (sender, target) and must admit an entire rotation +// burst in a large call — while repeated offers at one victim stay capped. +func TestVoiceE2EEOfferV2_RotationBurstNotRateLimited(t *testing.T) { + deps := offerDeps(true) + deps.Limiter = auth.NewRateLimiter() + info := ClientInfo{UserID: 1, VoiceChannelID: 100} + + // 8-participant call: one offer to each of 7 peers, immediately. + for target := int64(2); target <= 8; target++ { + cmd := VoiceE2EEOfferCmd{userID: 1, targetUserID: target, encryptedKey: validEncKey, iv: validIV} + if result := handleVoiceE2EEOfferV2(context.Background(), cmd, info, deps); result.Error != nil { + t.Fatalf("rotation offer to peer %d rejected: %v", target, result.Error) + } + } + + // Join/leave churn triggers back-to-back rotations — a second full burst + // must also pass. + for target := int64(2); target <= 8; target++ { + cmd := VoiceE2EEOfferCmd{userID: 1, targetUserID: target, encryptedKey: validEncKey, iv: validIV} + if result := handleVoiceE2EEOfferV2(context.Background(), cmd, info, deps); result.Error != nil { + t.Fatalf("second rotation offer to peer %d rejected: %v", target, result.Error) + } + } + + // Spamming a single victim is still limited: 2 offers spent above, the + // per-target budget is 5/sec, so within 4 more attempts one must trip. + var limited bool + for i := 0; i < 4; i++ { + cmd := VoiceE2EEOfferCmd{userID: 1, targetUserID: 2, encryptedKey: validEncKey, iv: validIV} + if result := handleVoiceE2EEOfferV2(context.Background(), cmd, info, deps); result.Error != nil { + limited = true + break + } + } + if !limited { + t.Fatal("same-target offer spam must still hit the rate limit") + } +} + func TestVoiceE2EEOfferV2_NotInVoiceChannel(t *testing.T) { deps := offerDeps(true) cmd := VoiceE2EEOfferCmd{userID: 1, targetUserID: 2, encryptedKey: validEncKey, iv: validIV} diff --git a/Server/ws/handlers_command.go b/Server/ws/handlers_command.go index 7600c6e7..618b9bd9 100644 --- a/Server/ws/handlers_command.go +++ b/Server/ws/handlers_command.go @@ -11,11 +11,12 @@ package ws import ( "context" "encoding/json" + "errors" "fmt" "log/slog" "strings" - "github.com/owncord/server/permissions" + "github.com/owncord/server/service" ) const MsgTypeChatCommand = "chat_command" @@ -84,9 +85,12 @@ func handlePluginCommand(ctx context.Context, h *Hub, c *Client, reqID string, p } if result.Broadcast != "" && p.ChannelID != 0 { - // Verify the invoking client has permission to send to this channel - // before broadcasting the plugin result to all channel members. - if !h.requireChannelPerm(c, p.ChannelID, permissions.SendMessages, "SEND_MESSAGES") { + // Verify the invoking client can post to this channel before broadcasting + // the plugin result to all channel members. Mirrors the normal send path: + // non-DM channels require READ_MESSAGES|SEND_MESSAGES (so a user cannot + // post into a channel they cannot read), and DM channels are validated by + // participant membership rather than role permissions. + if !h.requireChannelBroadcastAccess(c, p.ChannelID) { return } // Channel broadcast — visible to everyone in the channel. @@ -96,6 +100,38 @@ func handlePluginCommand(ctx context.Context, h *Hub, c *Client, reqID string, p } } +// requireChannelBroadcastAccess reports whether the client may post to +// channelID, by delegating to the SAME service-layer check a real message +// send runs (MessageService.CanPost: cached channel permissions; DM +// membership AND DM blocks). The previous RequireChannelAccess route skipped +// the block check in its DM branch — a blocked user's plugin broadcast could +// reach the person who blocked them — and issued a raw GetRoleByID per +// broadcast, bypassing the permission cache. On failure it sends an error to +// the client and returns false. +func (h *Hub) requireChannelBroadcastAccess(c *Client, channelID int64) bool { + if c.user == nil { + c.sendMsg(buildErrorMsg(ErrCodeForbidden, "not authenticated")) + return false + } + if h.messageSvc == nil { + // No service wired (bare test hub) — fail closed rather than allow + // an ungated broadcast. + c.sendMsg(buildErrorMsg(ErrCodeForbidden, "broadcast gate unavailable")) + return false + } + if err := h.messageSvc.CanPost(c.userID, channelID); err != nil { + if errors.Is(err, service.ErrNotFound) { + c.sendMsg(buildErrorMsg(ErrCodeNotFound, "channel not found")) + return false + } + slog.Warn("ws plugin broadcast permission denied", + "user_id", c.userID, "channel_id", channelID, "err", err) + c.sendMsg(buildErrorMsg(ErrCodeForbidden, "missing permission to post in this channel")) + return false + } + return true +} + // buildCommandReply builds an ephemeral command_reply envelope. func buildCommandReply(reqID, text string) []byte { type payload struct { diff --git a/Server/ws/handlers_test.go b/Server/ws/handlers_test.go index 7db04d3d..2726cb0e 100644 --- a/Server/ws/handlers_test.go +++ b/Server/ws/handlers_test.go @@ -43,6 +43,7 @@ CREATE TABLE IF NOT EXISTS audit_log ( CREATE TABLE IF NOT EXISTS attachments ( id TEXT PRIMARY KEY, message_id INTEGER REFERENCES messages(id) ON DELETE CASCADE, + uploader_id INTEGER REFERENCES users(id), filename TEXT NOT NULL, stored_as TEXT NOT NULL, mime_type TEXT NOT NULL, diff --git a/Server/ws/handlers_voice.go b/Server/ws/handlers_voice.go index fb0da596..408f1ba8 100644 --- a/Server/ws/handlers_voice.go +++ b/Server/ws/handlers_voice.go @@ -3,6 +3,7 @@ package ws import ( "context" "encoding/json" + "fmt" ) // registerVoiceHandlersV1 registers voice handlers that remain V1 (complex @@ -12,6 +13,17 @@ func registerVoiceHandlersV1(r *HandlerRegistry) { h.handleVoiceJoin(ctx, c, payload) }) r.Register(MsgTypeVoiceLeave, func(ctx context.Context, h *Hub, c *Client, _ string, _ json.RawMessage) { + // Rate limit only the explicit client-initiated voice_leave message. + // handleVoiceLeave is also invoked internally for disconnect and + // channel-switch cleanup (serve.go, voice_join.go); those paths must + // never be throttled or they would leak ghost voice states, so the + // limit lives here in the dispatch wrapper rather than inside the shared + // handleVoiceLeave routine. Mirrors the voice control Limiter idiom. + ratKey := fmt.Sprintf("voice_leave:%d", c.userID) + if h.limiter != nil && !h.limiter.Allow(ratKey, voiceLeaveRateLimit, voiceLeaveWindow) { + c.sendMsg(buildErrorMsg(ErrCodeRateLimited, "too many voice leave attempts")) + return + } h.handleVoiceLeave(ctx, c) }) } diff --git a/Server/ws/hub.go b/Server/ws/hub.go index 48deda38..43453778 100644 --- a/Server/ws/hub.go +++ b/Server/ws/hub.go @@ -29,13 +29,18 @@ type broadcastMsg struct { // Hub manages all active WebSocket clients and routes messages between them. // All exported methods are safe to call from multiple goroutines. type Hub struct { - clients map[int64]*Client - mu syncutil.RWMutex - db *db.DB - limiter *auth.RateLimiter - broadcast chan broadcastMsg - register chan *Client - unregister chan *Client + clients map[int64]*Client + mu syncutil.RWMutex + db *db.DB + limiter *auth.RateLimiter + broadcast chan broadcastMsg + // clientEvents carries register AND unregister requests on one channel so + // a connection's Register→Unregister sequence is processed in submission + // order. With two separate channels, Run's select picked randomly between + // them when both were ready — a fast connect/disconnect could process the + // unregister first (a no-op for an unknown client) and then the register, + // admitting an already-dead client as a ghost until the stale sweep. + clientEvents chan clientEvent stop chan struct{} stopOnce sync.Once gracefulOnce sync.Once @@ -43,6 +48,10 @@ type Hub struct { lkProcess *LiveKitProcess registry *HandlerRegistry permChecker *permissions.Checker + // messageSvc gates plugin broadcasts through the same posting policy as a + // real message send (permissions, DM membership, DM blocks). Nil only in + // bare test hubs; the broadcast gate fails closed then. + messageSvc *service.MessageService pubsub *PubSub // topic-based pub/sub for O(subscribers) broadcast topicLimiter *TopicRateLimiter // per-topic throughput caps @@ -90,8 +99,7 @@ func NewHub(database *db.DB, limiter *auth.RateLimiter, svc *service.Services) * db: database, limiter: limiter, broadcast: make(chan broadcastMsg, 1024), - register: make(chan *Client, 32), - unregister: make(chan *Client, 32), + clientEvents: make(chan clientEvent, 64), stop: make(chan struct{}), pubsub: NewPubSub(), topicLimiter: NewTopicRateLimiter(topicRateLimitPerSecond, time.Second), @@ -117,6 +125,7 @@ func NewHub(database *db.DB, limiter *auth.RateLimiter, svc *service.Services) * chatDeps.MessageSvc = svc.Messages presenceDeps.ChannelSvc = svc.Channels reactionDeps.MessageSvc = svc.Messages + h.messageSvc = svc.Messages } registerChatHandlers(reg, chatDeps) @@ -258,10 +267,12 @@ func (h *Hub) Run() { select { case <-h.stop: return - case c := <-h.register: - h.registerNow(c) - case c := <-h.unregister: - h.unregisterNow(c) + case ev := <-h.clientEvents: + if ev.add { + h.registerNow(ev.c) + } else { + h.unregisterNow(ev.c) + } case bm := <-h.broadcast: h.deliverBroadcast(bm) case <-staleTicker.C: @@ -376,12 +387,19 @@ func (h *Hub) GetClient(userID int64) *Client { // Register queues a client for registration with the hub. func (h *Hub) Register(c *Client) { - h.register <- c + h.clientEvents <- clientEvent{c: c, add: true} } // Unregister queues a client for removal from the hub. func (h *Hub) Unregister(c *Client) { - h.unregister <- c + 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 { + c *Client + add bool } func (h *Hub) registerNow(c *Client) { diff --git a/Server/ws/livekit_webhook.go b/Server/ws/livekit_webhook.go index 5ff3e774..ac846a87 100644 --- a/Server/ws/livekit_webhook.go +++ b/Server/ws/livekit_webhook.go @@ -2,9 +2,7 @@ package ws import ( "context" - "encoding/json" "fmt" - "io" "log/slog" "net/http" "strconv" @@ -12,8 +10,13 @@ import ( "github.com/livekit/protocol/auth" "github.com/livekit/protocol/livekit" + "github.com/livekit/protocol/webhook" ) +// webhookMaxBodyBytes bounds the webhook request body to prevent unbounded +// reads from an unauthenticated caller. +const webhookMaxBodyBytes = 64 * 1024 + // NewLiveKitWebhookHandler returns an HTTP handler that processes LiveKit // webhook events. It synchronises LiveKit room state back into OwnCord's // voice_states DB — primarily for crash recovery when a participant @@ -22,56 +25,35 @@ import ( // Speaker detection is handled client-side via LiveKit's // RoomEvent.ActiveSpeakersChanged (lower latency than webhooks). func (h *Hub) NewLiveKitWebhookHandler(apiKey, apiSecret string) http.HandlerFunc { + // The SDK receiver verifies the token signature AND that the token's sha256 + // claim matches the request body hash, binding verification to the body so + // a captured token cannot be replayed against a forged payload. + provider := auth.NewSimpleKeyProvider(apiKey, apiSecret) + return func(w http.ResponseWriter, r *http.Request) { // Check Authorization header BEFORE reading the body to avoid // allocating memory for unauthenticated requests. - authHeader := r.Header.Get("Authorization") - if authHeader == "" { + if r.Header.Get("Authorization") == "" { slog.Warn("livekit webhook: missing Authorization header") http.Error(w, "unauthorized", http.StatusUnauthorized) return } - body, err := io.ReadAll(io.LimitReader(r.Body, 64*1024)) + // Bound the body before the SDK reads it (ReceiveWebhookEvent uses an + // unbounded io.ReadAll internally). + r.Body = http.MaxBytesReader(w, r.Body, webhookMaxBodyBytes) + + // ReceiveWebhookEvent verifies the JWT signature, the token's body-hash + // claim, and the exp/nbf claims, then parses the payload. This replaces + // the previous manual ParseAPIToken/Verify sequence, which was not bound + // to the request body (forgery/replay). + event, err := webhook.ReceiveWebhookEvent(r, provider) if err != nil { - slog.Error("livekit webhook: read body failed", "error", err) - http.Error(w, "bad request", http.StatusBadRequest) - return - } - - // LiveKit sends "Bearer " in the Authorization header. - tokenStr := strings.TrimPrefix(authHeader, "Bearer ") - verifier, err := auth.ParseAPIToken(tokenStr) - if err != nil { - slog.Warn("livekit webhook: invalid token", "error", err) + slog.Warn("livekit webhook: verification failed", "error", err) http.Error(w, "unauthorized", http.StatusUnauthorized) return } - if verifier.APIKey() != apiKey { - slog.Warn("livekit webhook: API key mismatch", - "got", verifier.APIKey(), "want", apiKey) - http.Error(w, "unauthorized", http.StatusUnauthorized) - return - } - - // Verify checks both the HMAC signature and the exp/nbf claims - // (via jwt.Claims.Validate with Time: time.Now() inside the SDK). - // Expired tokens are rejected with an error here. - if _, _, err := verifier.Verify(apiSecret); err != nil { - slog.Warn("livekit webhook: token verification failed", "error", err) - http.Error(w, "unauthorized", http.StatusUnauthorized) - return - } - - // Parse the webhook event payload. - var event livekit.WebhookEvent - if err := json.Unmarshal(body, &event); err != nil { - slog.Warn("livekit webhook: invalid JSON", "error", err) - http.Error(w, "bad request", http.StatusBadRequest) - return - } - slog.Info("livekit webhook received", "event", event.Event, "room", event.GetRoom().GetName(), @@ -80,9 +62,9 @@ func (h *Hub) NewLiveKitWebhookHandler(apiKey, apiSecret string) http.HandlerFun switch event.Event { case "participant_joined": - h.handleWebhookParticipantJoined(r.Context(), &event) + h.handleWebhookParticipantJoined(r.Context(), event) case "participant_left": - h.handleWebhookParticipantLeft(r.Context(), &event) + h.handleWebhookParticipantLeft(r.Context(), event) default: slog.Debug("livekit webhook: unhandled event", "event", event.Event) } diff --git a/Server/ws/voice_e2ee.go b/Server/ws/voice_e2ee.go index 1a28235d..3263eb9b 100644 --- a/Server/ws/voice_e2ee.go +++ b/Server/ws/voice_e2ee.go @@ -3,6 +3,17 @@ package ws import ( "context" "encoding/base64" + "fmt" + "time" +) + +// Voice E2EE rate limits. Both the announce and offer relays fan out to every +// other voice participant (and an offer can force a key rotation / disconnect +// for peers), so a single user must not be able to spam them. Mirrors the +// named-constant Limiter idiom used by the voice control handlers. +const ( + voiceE2EERateLimit = 5 + voiceE2EEWindow = time.Second ) // validateBase64Loose checks that s is valid padded (StdEncoding) or unpadded @@ -82,11 +93,16 @@ func (h *Hub) computeIsKeyHolder(channelID, userID int64) bool { // It validates the public key and returns a SetE2EEPubKey mutation plus a // VoiceE2EEAnnounceEvent for relay to other voice channel participants. func handleVoiceE2EEAnnounceV2(_ context.Context, cmd Command, info ClientInfo, deps any) Result { - _ = deps.(VoiceDeps) + d := deps.(VoiceDeps) announceCmd := cmd.(VoiceE2EEAnnounceCmd) userID := info.UserID voiceChID := info.VoiceChannelID + ratKey := fmt.Sprintf("voice_e2ee_announce:%d", userID) + if d.Limiter != nil && !d.Limiter.Allow(ratKey, voiceE2EERateLimit, voiceE2EEWindow) { + return Result{Error: ClientError{Code: ErrCodeRateLimited, Message: "too many e2ee announcements"}} + } + if voiceChID == 0 { return Result{Error: ClientError{Code: ErrCodeVoiceError, Message: "not in a voice channel"}} } @@ -131,6 +147,20 @@ func handleVoiceE2EEOfferV2(_ context.Context, cmd Command, info ClientInfo, dep offerCmd := cmd.(VoiceE2EEOfferCmd) voiceChID := info.VoiceChannelID + // A legitimate rotation is a burst of one offer per peer (fired on + // join/leave and the periodic re-key), so the budget must not depend on + // channel size — keyed per sender alone, the 6th+ peer's offer was + // silently rate-limited and that peer could never decrypt audio again. + // Keying per (sender, target) admits any single rotation regardless of + // participant count while still capping repeated offers at one victim — + // the abuse this limit exists for, since an offer can force the target to + // re-key or disconnect. Cross-target spray stays bounded per victim and + // requires holding key-holder status in that channel. + ratKey := fmt.Sprintf("voice_e2ee_offer:%d:%d", info.UserID, offerCmd.TargetUserID()) + if d.Limiter != nil && !d.Limiter.Allow(ratKey, voiceE2EERateLimit, voiceE2EEWindow) { + return Result{Error: ClientError{Code: ErrCodeRateLimited, Message: "too many e2ee offers"}} + } + if voiceChID == 0 { return Result{Error: ClientError{Code: ErrCodeVoiceError, Message: "not in a voice channel"}} } diff --git a/Server/ws/voice_join.go b/Server/ws/voice_join.go index 311405f5..473cc1c2 100644 --- a/Server/ws/voice_join.go +++ b/Server/ws/voice_join.go @@ -12,6 +12,19 @@ import ( "github.com/owncord/server/permissions" ) +// Voice join/leave rate limits. voice_join and voice_leave each fan out a +// broadcast to every connected client, so a single user must not be able to +// trigger them in a tight loop. Mirrors the named-constant idiom used by the +// voice control handlers (see voice_broadcast.go / voice_controls.go). +// voiceLeaveRateLimit/Window are consumed by the voice_leave message dispatch +// in handlers_voice.go (same package). +const ( + voiceJoinRateLimit = 5 + voiceJoinWindow = time.Second + voiceLeaveRateLimit = 5 + voiceLeaveWindow = time.Second +) + // validVoiceQuality returns true if q is an accepted voice quality preset. // Uses voiceQualities (defined in voice_broadcast.go) as the single source of truth. func validVoiceQuality(q string) bool { @@ -30,6 +43,15 @@ func validVoiceQuality(q string) bool { // 8. Broadcasts voice_state to all clients. // 9. Sends voice_config to the joiner. func (h *Hub) handleVoiceJoin(ctx context.Context, c *Client, payload json.RawMessage) { + // Rate limit: voice_join broadcasts a voice_state update to every connected + // client, so cap how often a single user can trigger the fan-out. Mirrors the + // Limiter.Allow(...) idiom used by the voice control handlers. + ratKey := fmt.Sprintf("voice_join:%d", c.userID) + if h.limiter != nil && !h.limiter.Allow(ratKey, voiceJoinRateLimit, voiceJoinWindow) { + c.sendMsg(buildErrorMsg(ErrCodeRateLimited, "too many voice join attempts")) + return + } + channelID, err := parseChannelID(payload) if err != nil || channelID <= 0 { c.sendMsg(buildErrorMsg(ErrCodeBadRequest, "channel_id must be a positive integer")) diff --git a/docs/plans/security-hardening-remediation.md b/docs/plans/security-hardening-remediation.md new file mode 100644 index 00000000..405290f0 --- /dev/null +++ b/docs/plans/security-hardening-remediation.md @@ -0,0 +1,285 @@ +# Plan: Remediate security-hardening review regressions + +**Status:** design only, not implemented +**Owner:** TBD +**Tracks:** code review of branch `fix/security-hardening-review` (2026-07-17) +**Estimated effort:** 2–4 focused days + +## Why + +The `fix/security-hardening-review` branch lands a broad, well-intentioned +security pass (login timing fix, TOTP TOCTOU, X-Forwarded-For spoofing, +LiveKit webhook signature binding, plugin SSRF/DNS-rebinding, WASM CPU +budgets, attachment IDOR, ban authorization, WAF chunked-body inspection, +update-binary re-verification, and several rate limits). A recall-oriented +code review confirmed that most of it is sound, but that several of the +hardening changes introduced new correctness/availability regressions — +verified against the source, not just the diff. + +This plan sequences the fixes. It is ordered by severity: availability- and +backend-breaking bugs first, then behavioral regressions, then cleanup. Each +item names the root cause, the fix at the right altitude, the files, and the +verification that must pass. Every new fix ships with tests — several of the +regressions below slipped through precisely because the new security code had +no coverage (repo rule: "Target 80%+ coverage; TDD is the expected workflow"). + +## Non-goals + +- Not a redesign of the plugin runtime, the rate limiter, or the permission + model — each fix stays local and generalizes only where a bandaid would + otherwise recur. +- Not reverting the hardening. The security intent of every change is kept; + only the regressions are corrected. +- Not implementing the Postgres backend beyond what is needed to stop the + attachment path from hard-erroring (see W1-3). + +## Wave 1 — Availability & backend-breaking (HIGH) + +### W1-1. Plugin CPU budget must not permanently brick the module +- **File:** `Server/plugin/sandbox_wazero.go` (~line 224, `invokeCommand`) +- **Root cause:** the runtime is built `WithCloseOnContextDone(true)` + (line 72). The new per-call `context.WithTimeout` wraps `allocate`, + `command_dispatch`, and `deallocate`, so an expired deadline *closes the + module*. `inst.module` is only cleared by `platformDeactivate`, so nothing + re-instantiates it — one over-budget command bricks the plugin for all + users until admin disable/enable or restart. The budget is wall-clock, + floored at 100 ms, so any host HTTP call (`httpTimeout` = 10 s) trips it. +- **Fix:** after a `context.DeadlineExceeded` overrun, mark the instance so + the next dispatch re-instantiates the module (re-run `activateWithRuntime` + lazily), or reset `inst.module = nil` and re-activate on demand. Separately, + scope the deadline to the guest CPU call only — do **not** count host-call + time (host functions should run under the parent ctx, or the budget clock + should pause during host calls). Reconsider the floor so legitimate work + isn't killed. +- **Verify:** new test (build tag `wazero`) that (a) a command exceeding the + budget returns the budget error *and* a subsequent command on the same + plugin still succeeds; (b) a command performing a host HTTP call within + `httpTimeout` is not killed by the CPU budget. + +### W1-2. E2EE key rotation drops peers in 7+ participant calls +- **Files:** `Server/ws/voice_e2ee.go` (~line 151); + `Client/tauri-client/src/lib/livekitSession.ts` (~lines 1317-1349) +- **Root cause:** the `voice_e2ee_offer` limit is 5/sec, but the key holder + loops over every peer sending one offer each, back-to-back, with no pacing + and no retry on `RATE_LIMITED`. With 6+ peers, offers to the 6th+ peer are + rejected and silently dropped — those peers never get the rotated key and + their audio never decrypts. Fires on join/leave and the 5-minute periodic + rotation. +- **Fix (choose one, prefer server-side):** + - Server: exempt the fan-out relay from the tight per-message cap — rate + limit the *rotation event* (one budget per rotation) rather than each + per-peer offer; or scale the limit to channel size. + - Client: add bounded pacing + retry/backoff on `RATE_LIMITED` so all peers + eventually receive the offer. + - Preferred: server distinguishes "offer burst that belongs to one + rotation" from spam, so a single user still can't spam unrelated offers. +- **Verify:** integration test with 8 voice participants confirming every peer + receives the rotated key after a join/leave and after a periodic rotation. + +### W1-3. Attachment-ownership check breaks Postgres and isn't atomic +- **Files:** `Server/service/message.go` (~line 183); + `Server/db/queries/*attachment*.sql` + regenerate `dbgen`/`pgdbgen`; + `Server/store/postgres.go`, `Server/store/sqlite.go` +- **Root cause:** the new per-attachment `GetAttachmentByID` loop (a) calls a + method that returns `ErrPostgresNotImplemented` on `PostgresStore` + (`postgres.go:498`), so every attachment-bearing `SendMessage` hard-errors + on Postgres; (b) is a check-then-link TOCTOU (the race pattern this PR fixes + elsewhere); (c) is an N+1 query on the hot send path; (d) rejects legacy + `uploader_id IS NULL` rows and already-linked attachments on retry (W2-4). +- **Fix (right altitude):** delete the loop. Enforce ownership atomically in + the shared link query — add `AND uploader_id = ?` (and keep + `AND message_id IS NULL`) to `LinkAttachmentsToMessage`, and return + `ErrForbidden` when `RowsAffected != len(AttachmentIDs)`. Edit the SQL in + `Server/db/queries/` and run `make sqlc-generate` (never hand-edit `dbgen`/ + `pgdbgen`); update the SQLite + Postgres migrations as a pair. This makes + the check atomic, one query, and backend-agnostic, and removes the need for + the `MemStore.GetAttachmentByID` `(nil,nil)` contortion. +- **Verify:** service tests (SQLite *and* a Postgres path or a store fake that + implements the link semantics) covering: own unlinked attachment links; + another user's attachment is refused; nonexistent id is skipped; already + linked id is refused; `RowsAffected` mismatch → no message persisted. + +### W1-4. Ban authorization guards dead code +- **Files:** `Server/admin/handlers_users.go` (`handlePatchUser`, ~line 112); + `Server/service/moderation.go` +- **Root cause:** `requireBanAuthority` (BAN_MEMBERS + role hierarchy) is + wired only into `ModerationService.BanUser`/`UnbanUser`, which have no + production callers. The live ban path is `handlePatchUser`, which runs + `UPDATE users SET banned = 1 ...` directly with no BAN_MEMBERS/hierarchy + check, so any admin-panel actor can ban an equal- or higher-ranked user + (including the owner). +- **Fix:** route `handlePatchUser`'s ban/unban branch through + `ModerationService.BanUser`/`UnbanUser` (so the new authorization actually + runs), or lift `requireBanAuthority` into the handler. Keep the + admin-IP/admin-auth perimeter; add the permission + hierarchy check on top. + Move the target-existence check *after* authorization so a caller without + BAN_MEMBERS can't enumerate user ids via NotFound-vs-Forbidden. +- **Verify:** handler test — actor without BAN_MEMBERS is refused; actor of + equal/lower rank than target is refused; owner-rank target can't be banned + by a lower rank; authorized actor succeeds. + +## Wave 2 — Behavioral regressions (MED-HIGH → MED) + +### W2-1. Client-update rate limiter shares the auth bucket +- **File:** `Server/api/router.go` (~line 257) +- **Root cause:** it uses the empty-prefix `RateLimitMiddleware` on the shared + `limiter`, colliding per-IP with `verifyTOTP`, the sensitive endpoints, and + profile/password. A 30/min auto-poll drains the shared per-IP budget → + spurious 429s on 2FA and password change. +- **Fix:** give the client-update route a dedicated key prefix via + `rateLimitMiddlewareWithPrefix(limiter, "client_update:", ...)`, mirroring + the LiveKit proxy's `"livekit_proxy:"` prefix (`router.go:192`). +- **Verify:** test that hammering `/client-update` to its limit does not 429 a + subsequent `verify-totp`/password request from the same IP. + +### W2-2. ChangePassword reports failure after the password is committed +- **Files:** `Server/service/user.go` (~line 60); caller + `Server/api/profile_handler.go` (~line 231) +- **Root cause:** `UpdateUserPassword` commits first; if `DeleteOtherSessions` + then errors, the function returns `ErrInternal`, the caller emits 500, and + the `password_change` audit entry is skipped. The user is told it failed + while the new password is live; retrying with the old password fails and can + trip the password-confirm lockout. +- **Fix:** treat session-revocation failure as a partial success, not a total + failure. Log + audit the password change, return success with a + `sessions_revoked`/warning signal the client can surface, or perform a + bounded compensating retry of `DeleteOtherSessions`. Do not report a 5xx for + an already-committed change; always write the audit row. +- **Verify:** test that when `DeleteOtherSessions` errors, the audit row is + still written and the handler does not return a 5xx that implies the + password is unchanged. + +### W2-3. Plugin activation via RegisterCommand breaks in-place upgrades +- **Files:** `Server/plugin/sandbox_wazero.go` (~line 140); + `Server/plugin/host_commands.go` (~line 36); + `Server/plugin/registry.go` (`installFromDisk`, `InstallFromZip`) +- **Root cause:** `RegisterCommand` refuses when `existing != inst` by + *pointer*, but `installFromDisk` replaces `r.plugins[id]`/`r.byName` with a + fresh `*Instance` without clearing the old command bindings. Re-installing an + enabled plugin leaves stale bindings that block re-registration; dispatch + keeps routing to the orphaned old module until restart. The old + `r.commands[cmd] = inst` overwrote unconditionally. +- **Fix:** compare ownership by plugin identity (name/id), not instance + pointer — allow the same plugin to re-bind its own command — and/or clear a + plugin's stale command bindings during reinstall/deactivation before + re-activation. Preserve the cross-plugin hijack protection (a *different* + plugin still can't claim an owned command). +- **Verify:** test that upgrading an enabled plugin in place rebinds its + commands and dispatch routes to the new module; a different plugin claiming + an owned command is still refused. + +### W2-4. Attachment check rejects legit retries and legacy uploads +- **File:** `Server/service/message.go` (~line 191) +- **Root cause:** `att.MessageID != nil → ErrForbidden` means a client retry of + a send whose first attempt already linked the attachment can never succeed; + the same branch rejects legacy `uploader_id IS NULL` rows. +- **Fix:** subsumed by W1-3 — the atomic link UPDATE skips already-linked/ + non-owned rows instead of failing the whole send. Decide explicit policy for + legacy NULL-uploader rows (migrate/backfill vs. treat as unowned). +- **Verify:** covered by W1-3 tests (already-linked id → skipped, not fatal). + +### W2-5. XFF right-to-left walk collapses/spoofs on broad trusted CIDRs +- **File:** `Server/api/middleware.go` (~line 227, `clientIPWithProxies`) +- **Root cause:** the walk skips every entry inside `trustedCIDRs`. With a + broad config (e.g. `trusted_proxies: 10.0.0.0/8` covering LAN clients), the + real client entry is skipped, the loop exhausts, and it falls back to the + proxy's own `RemoteAddr` — collapsing all clients into one lockout bucket + (one user's failed logins lock out everyone), or letting a client at a + trusted IP forge the key. +- **Fix:** when the walk exhausts without a non-trusted candidate, return the + left-most *valid* XFF entry (the furthest-upstream client) rather than + `RemoteAddr`, so distinct clients keep distinct keys. Document that + `trusted_proxies` should list only proxy hops, and validate config on + startup. Pre-parse `trustedCIDRs` into `[]*net.IPNet` once (see W3-3). +- **Verify:** test with `trusted_proxies=10.0.0.0/8`, proxy at `10.0.0.2`, + two LAN clients `10.5.1.7` / `10.5.1.8` behind it → distinct keys; a spoofed + leftmost entry from an untrusted RemoteAddr is ignored. + +### W2-6. SSRF-hardened dialer loses multi-address fallback +- **File:** `Server/plugin/host_http.go` (~line 115) +- **Root cause:** after validating every resolved IP, it dials only `ips[0]`, + dropping Happy-Eyeballs/next-record fallback. An allowlisted dual-stack or + round-robin host whose first record is down now hard-fails. +- **Fix:** loop over the vetted IPs and try each until one connects, keeping + the "dial only vetted concrete IPs" property. Also remove the redundant + `rejectPrivateAddrs` pre-resolve at line 68 (keep the cheap `hostAllowed` + allowlist) since the dial-time resolve+validate is authoritative — saves a + second DNS round trip (W3-2). +- **Verify:** test that a host resolving to `[unreachable, reachable]` still + connects via the reachable vetted address; a host resolving to a private IP + is still refused. + +### W2-7. Plugin-broadcast gate omits the block check +- **Files:** `Server/ws/handlers_command.go` (~line 107); + `Server/permissions/checker.go` (~line 79) +- **Root cause:** `requireChannelBroadcastAccess` routes through + `RequireChannelAccess`, whose DM branch checks only participant membership, + while the real send path (`checkSendPermission`) also enforces + `IsEitherBlocked`. A blocked user's plugin broadcast can reach the person who + blocked them. It also issues a raw `GetRoleByID` per broadcast, bypassing the + service-layer permission cache. +- **Fix:** route the broadcast gate through the shared service-layer send check + (`MessageService.checkSendPermission` or an extracted equivalent) so DM + block, slow-mode, and future policy apply uniformly and the perm cache is + used. Avoids a fourth hand-rolled permission helper on `Hub`. +- **Verify:** test that a blocked user's plugin broadcast into a DM is refused. + +## Wave 3 — Cleanup, efficiency, hardening depth (LOW-MED) + +### W3-1. Updater text-asset cache: add coalescing + negative caching +- **File:** `Server/updater/updater.go` (~line 729, `FetchTextAssetCached`) +- **Fix:** guard refresh with `golang.org/x/sync/singleflight` so a TTL-expiry + burst issues one outbound fetch; briefly cache errors so an upstream outage + doesn't re-fetch on every request. Evict superseded keys. +- **Verify:** concurrent cold-cache test issues exactly one upstream fetch. + +### W3-2. De-duplicate the update binary hashing +- **File:** `Server/admin/update_handlers.go` (~line 166, `fileSHA256`) +- **Fix:** `fileSHA256` duplicates `updater.VerifyChecksum`'s hashing body and + re-reads the just-verified binary. Export one hashing helper from the updater + package (or have `DownloadAndVerify` return the checksum it already computed) + and reuse it for the TOCTOU snapshot. + +### W3-3. Update TOCTOU guard depth + XFF CIDR pre-parsing +- **Files:** `Server/admin/update_handlers.go` (~line 117); + `Server/api/middleware.go` (`isTrustedProxy`) +- **Fix:** the re-verify narrows but does not close the swap window (verify by + path, then rename+spawn by path). Real closure needs fd-based verification or + `O_EXCL` staging in the updater package. Separately, `isTrustedProxy` + re-parses every CIDR string per call on the request hot path — pre-parse into + `[]*net.IPNet` once at middleware construction. + +### W3-4. Cache-Control header contradiction +- **File:** `Server/api/upload_handler.go` (~line 309) + test at + `upload_handler_test.go` (~line 733) +- **Fix:** `private, max-age=31536000, no-cache` is self-contradictory — + `no-cache` forces revalidation, so the year-long `max-age` is dead weight. + Use `private, no-cache` and update the test assertion. + +### W3-5. Restore test coverage lost to the MemStore change +- **File:** `Server/store/memstore.go` (~line 693) +- **Fix:** subsumed by W1-3 (atomic link removes the need for the `(nil,nil)` + stub). If MemStore keeps attachment stubs, ensure the ownership behavior is + covered by a store that actually tracks `uploader_id`/`message_id` so the + IDOR guard cannot silently regress in tests. + +## Sequencing + +1. **W1** first (availability/backend-breaking). W1-3 unblocks W2-4 and W3-5. +2. **W2** next. W2-5 pairs with the CIDR pre-parse in W3-3. W2-6 pairs with the + redundant-resolve removal. +3. **W3** last, or fold each item into the related Wave-1/2 change. + +## Cross-cutting requirements + +- **Tests:** every fix ships with tests (repo rule: 80%+ coverage, TDD). Add + the missing coverage for the *existing* new security code too: + `requireBanAuthority`, `FetchTextAssetCached`, `requireChannelBroadcastAccess`, + fail-closed `DecryptTOTPSecret`. +- **Build-tag matrix:** W1-1/W2-3 touch `//go:build wazero` code — verify the + default, `otel`, `wazero`, and `otel,wazero` variants all still build. +- **Two backends:** W1-3 changes queries — edit `Server/db/queries/` + both + migration trees and run `make sqlc-generate`; do not hand-edit `dbgen`/ + `pgdbgen`. CI runs `make sqlc-verify`. +- **CI gates:** `go test -race`, `-tags deadlock`, `golangci-lint run`, + `govulncheck ./...` must pass before merge. diff --git a/tools/project-map/.gitignore b/tools/project-map/.gitignore deleted file mode 100644 index ceddaa37..00000000 --- a/tools/project-map/.gitignore +++ /dev/null @@ -1 +0,0 @@ -.cache/ diff --git a/tools/project-map/dashboard.html b/tools/project-map/dashboard.html deleted file mode 100644 index 3cc05a36..00000000 --- a/tools/project-map/dashboard.html +++ /dev/null @@ -1,1960 +0,0 @@ - - - - - -OwnCord Project Map - - - - - -
-

OwnCord Project Map

-
- - - -
-
- -
-
Loading project data...
-
- - - - - - - - - - diff --git a/tools/project-map/index.mjs b/tools/project-map/index.mjs deleted file mode 100644 index 2fc75c0b..00000000 --- a/tools/project-map/index.mjs +++ /dev/null @@ -1,100 +0,0 @@ -#!/usr/bin/env node -/** - * OwnCord Project Map Generator - * - * Scans the repository, collects test coverage, parses the backlog, - * scores priorities, and outputs a markdown report + terminal summary. - * - * Usage: - * node index.mjs # full run (runs tests for coverage) - * node index.mjs --quick # skip test runs, use cached coverage - * node index.mjs --research # interactive research launcher - * node index.mjs --serve # launch web dashboard - */ - -import { scanModules } from './lib/scanner.mjs'; -import { collectGoCoverage } from './lib/go-coverage.mjs'; -import { collectVitestCoverage } from './lib/vitest-coverage.mjs'; -import { parseBacklog } from './lib/backlog-parser.mjs'; -import { scorePriorities } from './lib/priority-engine.mjs'; -import { generateReport } from './lib/report-generator.mjs'; -import { printTerminalSummary } from './lib/terminal-summary.mjs'; -import { launchResearchAgent } from './lib/research-agent.mjs'; -import { scanGitHistory } from './lib/git-scanner.mjs'; -import { parseSessionHistory } from './lib/session-parser.mjs'; -import { scanTechnicalDebt } from './lib/debt-scanner.mjs'; -import { buildImportGraph } from './lib/import-graph.mjs'; -import { generateSuggestions } from './lib/suggestion-engine.mjs'; -import { resolve, dirname } from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { existsSync, mkdirSync } from 'node:fs'; - -const __dirname = dirname(fileURLToPath(import.meta.url)); -const ROOT = resolve(__dirname, '../..'); -const CACHE_DIR = resolve(__dirname, '.cache'); -const REPORT_PATH = resolve(ROOT, 'docs/brain/00-Overview/Project-Map.md'); - -const args = process.argv.slice(2); -const quick = args.includes('--quick'); -const research = args.includes('--research'); -const serve = args.includes('--serve'); - -if (!existsSync(CACHE_DIR)) mkdirSync(CACHE_DIR, { recursive: true }); - -async function main() { - if (research) { - await launchResearchAgent(ROOT); - return; - } - - if (serve) { - // Dynamic import to launch the server - await import('./server.mjs'); - return; - } - - console.log(quick - ? '\n Project Map (quick mode — using cached data)\n' - : '\n Project Map (full mode — running tests for coverage)\n'); - - // Core data - const modules = await scanModules(ROOT); - const goCoverage = await collectGoCoverage(ROOT, CACHE_DIR, quick); - const vitestCoverage = await collectVitestCoverage(ROOT, CACHE_DIR, quick); - const backlog = await parseBacklog(ROOT); - const priorities = scorePriorities(modules, goCoverage, vitestCoverage, backlog); - - // Enhanced data (graceful failures) - let gitData = null, sessionData = null, debtData = null, importGraph = null, suggestions = null; - try { gitData = await scanGitHistory(ROOT, CACHE_DIR, quick); } catch (e) { console.error(` [Git] ${e.message}`); } - try { sessionData = await parseSessionHistory(ROOT, CACHE_DIR, quick); } catch (e) { console.error(` [Session] ${e.message}`); } - try { debtData = await scanTechnicalDebt(ROOT, CACHE_DIR, quick); } catch (e) { console.error(` [Debt] ${e.message}`); } - try { importGraph = await buildImportGraph(ROOT, CACHE_DIR, quick); } catch (e) { console.error(` [Graph] ${e.message}`); } - try { - suggestions = await generateSuggestions(CACHE_DIR, { - priorities, goCoverage, vitestCoverage, backlog, gitData, debtData, importGraph, sessionData, - }); - } catch (e) { console.error(` [Suggestions] ${e.message}`); } - - // Generate markdown report - await generateReport(REPORT_PATH, modules, goCoverage, vitestCoverage, backlog, priorities); - - // Print terminal summary - printTerminalSummary(modules, goCoverage, vitestCoverage, backlog, priorities); - - // Print top suggestions - if (suggestions?.suggestions?.length) { - const CYAN = '\x1b[36m', BOLD = '\x1b[1m', DIM = '\x1b[2m', RESET = '\x1b[0m'; - console.log(`\n ${CYAN}${BOLD}SMART SUGGESTIONS (${suggestions.strategy})${RESET}`); - console.log(` ${DIM}${'─'.repeat(60)}${RESET}`); - for (const s of suggestions.suggestions.slice(0, 5)) { - console.log(` ${CYAN}${s.rank}.${RESET} ${BOLD}${s.module}${RESET} (score: ${s.score}) — ${DIM}${s.rationale}${RESET}`); - } - console.log(''); - } -} - -main().catch(err => { - console.error('Project map failed:', err.message); - process.exit(1); -}); diff --git a/tools/project-map/lib/agent-manager.mjs b/tools/project-map/lib/agent-manager.mjs deleted file mode 100644 index 53dc5bf3..00000000 --- a/tools/project-map/lib/agent-manager.mjs +++ /dev/null @@ -1,988 +0,0 @@ -/** - * Fleet Agent Manager — concurrent job queue with worktree isolation. - * - * Reworked from single-job queue to support parallel agent execution. - * Each agent runs in its own git worktree via worktree-manager. - * - * Key changes from v1: - * - activeAgents Map replaces queueLocked boolean - * - QueueStore provides atomic read-modify-write for queue file - * - Ring buffer caps live output at 500KB per agent - * - Provisioning state for worktree creation phase - * - MAX_CONCURRENT configurable parallel limit - * - Timeout escalation: 80% warning → SIGTERM → 5s → SIGKILL - */ -import { execFileSync, spawn } from 'node:child_process'; -import { randomBytes } from 'node:crypto'; -import { - existsSync, - mkdirSync, - readFileSync, - writeFileSync, - readdirSync, - statSync, - unlinkSync, -} from 'node:fs'; -import { resolve, join } from 'node:path'; - -import { - createWorktree, - destroyWorktree, - cleanupStaleWorktrees, -} from './worktree-manager.mjs'; - -// --------------------------------------------------------------------------- -// Constants -// --------------------------------------------------------------------------- - -const QUEUE_FILE = 'agent-queue.json'; -const RESULTS_DIR = 'agent-results'; -const PROMPTS_DIR = 'agent-prompts'; - -const ALLOWED_TYPES = new Set([ - 'research', - 'write-tests', - 'code-review', - 'security-audit', - 'fix-debt', - 'custom', -]); - -const TIMEOUTS = { - research: 600_000, - 'write-tests': 1_200_000, - 'code-review': 900_000, - 'security-audit': 1_200_000, - 'fix-debt': 900_000, - custom: 1_800_000, -}; - -/** Default max concurrent agents */ -let MAX_CONCURRENT = 4; - -/** Ring buffer cap per agent (bytes) */ -const RING_BUFFER_MAX = 512_000; // 500KB - -// --------------------------------------------------------------------------- -// QueueStore — centralized queue I/O with atomic updates -// --------------------------------------------------------------------------- - -class QueueStore { - #cacheDir; - #updateLock = false; - - constructor(cacheDir) { - this.#cacheDir = cacheDir; - } - - /** Read current queue state */ - get() { - const file = resolve(this.#cacheDir, QUEUE_FILE); - if (!existsSync(file)) return []; - try { - const raw = readFileSync(file, 'utf8'); - const parsed = JSON.parse(raw); - return Array.isArray(parsed) ? parsed : []; - } catch { - return []; - } - } - - /** Get a single job by ID */ - getJob(jobId) { - return this.get().find(j => j.id === jobId) ?? null; - } - - /** - * Atomic read-modify-write. The updater function receives the current - * jobs array and must return the new jobs array. - * Prevents concurrent reads from overwriting each other's changes. - */ - update(updaterFn) { - if (this.#updateLock) { - throw new Error('QueueStore: concurrent update detected'); - } - this.#updateLock = true; - try { - const jobs = this.get(); - const updated = updaterFn(jobs); - writeFileSync( - resolve(this.#cacheDir, QUEUE_FILE), - JSON.stringify(updated, null, 2), - ); - return updated; - } finally { - this.#updateLock = false; - } - } -} - -/** Module-level store instance — set via init() or createJob() */ -let store = null; - -function ensureStore(cacheDir) { - if (!store || store._dir !== cacheDir) { - store = new QueueStore(cacheDir); - store._dir = cacheDir; - } - return store; -} - -// --------------------------------------------------------------------------- -// Helpers — directory setup -// --------------------------------------------------------------------------- - -function ensureDirs(cacheDir) { - mkdirSync(resolve(cacheDir, RESULTS_DIR), { recursive: true }); - mkdirSync(resolve(cacheDir, PROMPTS_DIR), { recursive: true }); -} - -// --------------------------------------------------------------------------- -// Helpers — scope path resolution -// --------------------------------------------------------------------------- - -const GO_PACKAGES = new Set([ - 'api', 'ws', 'db', 'auth', 'config', 'admin', 'migrations', 'scripts', -]); - -const TS_AREAS = new Set([ - 'lib', 'stores', 'components', 'pages', -]); - -function resolveScopePath(target) { - if (GO_PACKAGES.has(target)) return `Server/${target}/`; - if (TS_AREAS.has(target)) return `Client/tauri-client/src/${target}/`; - if (target === 'tauri-rust') return 'Client/tauri-client/src-tauri/src/'; - return target; -} - -// --------------------------------------------------------------------------- -// Helpers — process checking -// --------------------------------------------------------------------------- - -function isProcessAlive(pid) { - const safePid = parseInt(pid, 10); - if (!Number.isInteger(safePid) || safePid <= 0) return false; - try { - process.kill(safePid, 0); - return true; - } catch { - return false; - } -} - -function isClaudeProcess(pid) { - const safePid = parseInt(pid, 10); - if (!Number.isInteger(safePid) || safePid <= 0) return false; - try { - if (process.platform === 'win32') { - const out = execFileSync( - 'tasklist', - ['/FI', `PID eq ${safePid}`, '/FO', 'CSV', '/NH'], - { stdio: 'pipe', timeout: 5_000 }, - ).toString(); - const lower = out.toLowerCase(); - return lower.includes('claude') || lower.includes('node'); - } - const out = execFileSync('ps', ['-p', String(safePid), '-o', 'comm='], { - stdio: 'pipe', - timeout: 5_000, - }).toString().trim().toLowerCase(); - return out.includes('claude') || out.includes('node'); - } catch { - return false; - } -} - -function killProcess(pid) { - const safePid = parseInt(pid, 10); - if (!Number.isInteger(safePid) || safePid <= 0) return; - try { - process.kill(safePid, 'SIGTERM'); - setTimeout(() => { - try { - process.kill(safePid, 0); - process.kill(safePid, 'SIGKILL'); - } catch { /* already dead */ } - }, 5_000); - } catch { /* already dead */ } -} - -// --------------------------------------------------------------------------- -// Active agents — tracks running processes -// --------------------------------------------------------------------------- - -/** Map */ -const activeAgents = new Map(); - -/** Processing flag — guards the auto-process interval from double-spawn */ -let isProcessing = false; - -// --------------------------------------------------------------------------- -// Ring buffer for live output -// --------------------------------------------------------------------------- - -const liveOutputBuffers = new Map(); - -function appendToRingBuffer(jobId, text) { - const current = liveOutputBuffers.get(jobId) || ''; - let updated = current + text; - - if (updated.length > RING_BUFFER_MAX) { - const truncateMarker = '\n[... output truncated — showing last 500KB ...]\n'; - updated = truncateMarker + updated.slice(updated.length - RING_BUFFER_MAX + truncateMarker.length); - } - - liveOutputBuffers.set(jobId, updated); -} - -export function getLiveOutput(jobId) { - return liveOutputBuffers.get(jobId) || ''; -} - -export function clearLiveOutput(jobId) { - liveOutputBuffers.delete(jobId); -} - -// --------------------------------------------------------------------------- -// Prompt templates -// --------------------------------------------------------------------------- - -function buildPrompt(job, _root) { - const { type, target, customPrompt } = job; - const scopePath = resolveScopePath(target); - - switch (type) { - case 'research': - return [ - `You are a research agent for OwnCord. Investigate the ${target} module.`, - 'Focus on: coverage gaps, potential bugs, edge cases, security concerns, missing functionality.', - `Scope: ${scopePath}`, - 'Read relevant source and test files. Prioritize findings as CRITICAL/HIGH/MEDIUM/LOW.', - 'Output a structured markdown report.', - ].join('\n'); - - case 'write-tests': - return [ - 'You are a test-writing agent for OwnCord.', - `Module: ${target}`, - `Read the source files in ${scopePath}.`, - 'Read docs/brain/06-Specs/TESTING-STRATEGY.md for test patterns.', - 'Write tests targeting untested functions and branches. Aim for 80%+ coverage.', - 'Save tests to the appropriate test directory.', - ].join('\n'); - - case 'code-review': - return [ - 'You are a code review agent for OwnCord.', - `Review recent changes in the ${target} module for bugs, security issues, code quality.`, - `Scope: ${scopePath}`, - 'Output findings with file:line references and severity ratings.', - ].join('\n'); - - case 'security-audit': - return [ - 'You are a security audit agent for OwnCord.', - `Perform an OWASP Top 10 review of the ${target} module.`, - `Scope: ${scopePath}`, - 'Check for: injection, auth bypass, XSS, CSRF, path traversal, hardcoded secrets.', - 'Output findings with severity, file:line, and remediation steps.', - ].join('\n'); - - case 'fix-debt': - return [ - 'You are a technical debt agent for OwnCord.', - `Address TODO/FIXME/HACK items in the ${target} module.`, - `Scope: ${scopePath}`, - 'For each debt marker, either fix it or explain why it should remain.', - ].join('\n'); - - case 'custom': - return customPrompt; - - default: - return `Investigate ${target} in OwnCord.`; - } -} - -// --------------------------------------------------------------------------- -// Activity hint parsing — extract file paths from agent stdout -// --------------------------------------------------------------------------- - -const FILE_PATH_PATTERNS = [ - /Server\/[\w/.-]+\.go/g, - /Client\/[\w/.-]+\.tsx?/g, - /(? '+' + l).join('\n'); - } else { - const args = baselineSha - ? ['diff', baselineSha, '--', f.path] - : ['diff', '--', f.path]; - const content = gitArgs(args, diffRoot); - diffs[f.path] = content; - } - } catch { - diffs[f.path] = ''; - } - } - - return { files, diffs, freshness }; -} - -// --------------------------------------------------------------------------- -// Public API — configuration -// --------------------------------------------------------------------------- - -export function setMaxConcurrent(n) { - const val = parseInt(n, 10); - if (Number.isInteger(val) && val >= 1 && val <= 8) { - MAX_CONCURRENT = val; - } -} - -export function getMaxConcurrent() { - return MAX_CONCURRENT; -} - -export function getActiveCount() { - return activeAgents.size; -} - -export function getActiveAgentIds() { - return [...activeAgents.keys()]; -} - -// --------------------------------------------------------------------------- -// Public API — health check -// --------------------------------------------------------------------------- - -export function healthCheck() { - try { - const version = execFileSync('claude', ['--version'], { stdio: 'pipe' }) - .toString() - .trim(); - return { available: true, version }; - } catch (err) { - return { available: false, error: err.message || 'Claude CLI not found' }; - } -} - -// --------------------------------------------------------------------------- -// Public API — job CRUD -// --------------------------------------------------------------------------- - -export function getJobs(cacheDir) { - const s = ensureStore(cacheDir); - const jobs = s.get(); - const sorted = [...jobs].sort((a, b) => { - const priDiff = (b.priority ?? 1) - (a.priority ?? 1); - if (priDiff !== 0) return priDiff; - return new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(); - }); - return { jobs: sorted }; -} - -export function createJob(cacheDir, { type, target, priority, customPrompt }) { - if (!ALLOWED_TYPES.has(type)) { - throw new Error(`Invalid job type "${type}". Allowed: ${[...ALLOWED_TYPES].join(', ')}`); - } - if (!target || typeof target !== 'string' || target.trim().length === 0) { - throw new Error('Job target must be a non-empty string'); - } - if (type === 'custom' && (!customPrompt || typeof customPrompt !== 'string' || customPrompt.trim().length === 0)) { - throw new Error('Custom jobs require a non-empty customPrompt'); - } - - ensureDirs(cacheDir); - const s = ensureStore(cacheDir); - - const job = { - id: `job-${Date.now()}-${randomBytes(4).toString('hex')}`, - type, - target: target.trim(), - status: 'queued', - createdAt: new Date().toISOString(), - startedAt: null, - completedAt: null, - resultPath: null, - error: null, - priority: typeof priority === 'number' ? priority : 1, - retryCount: 0, - maxRetries: 2, - pid: null, - customPrompt: customPrompt ?? null, - // Fleet fields - worktreePath: null, - branchName: null, - configMethod: null, - }; - - s.update(jobs => [...jobs, job]); - return job; -} - -export function cancelJob(cacheDir, jobId) { - const s = ensureStore(cacheDir); - const job = s.getJob(jobId); - if (!job) throw new Error(`Job "${jobId}" not found`); - - if (job.status === 'queued') { - s.update(jobs => jobs.filter(j => j.id !== jobId)); - return { ok: true }; - } - - if (job.status === 'provisioning' || job.status === 'running') { - // Kill process if running - const agent = activeAgents.get(jobId); - if (agent?.pid) killProcess(agent.pid); - if (agent?.timer) clearTimeout(agent.timer); - if (agent?.warningTimer) clearTimeout(agent.warningTimer); - activeAgents.delete(jobId); - - // Destroy worktree - if (job.worktreePath) { - try { - const root = resolve(job.worktreePath, '..', '..'); - destroyWorktree(root, jobId); - } catch (err) { - console.error(` [Agent] Worktree cleanup failed for ${jobId}: ${err.message}`); - } - } - - s.update(jobs => jobs.map(j => - j.id === jobId - ? { ...j, status: 'cancelled', completedAt: new Date().toISOString(), pid: null } - : j, - )); - setTimeout(() => clearLiveOutput(jobId), 5000); - return { ok: true }; - } - - // Terminal statuses (review, done, failed, cancelled) — just remove from list - if (['review', 'dead', 'cancelled'].includes(job.status)) { - if (job.worktreePath) { - try { - const root = resolve(job.worktreePath, '..', '..'); - destroyWorktree(root, jobId); - } catch (err) { - console.error(` [Agent] Worktree cleanup failed for ${jobId}: ${err.message}`); - } - } - s.update(jobs => jobs.filter(j => j.id !== jobId)); - return { ok: true }; - } - - throw new Error(`Cannot cancel job "${jobId}" with status "${job.status}"`); -} - -export function getJobResult(cacheDir, jobId) { - const resultFile = resolve(cacheDir, RESULTS_DIR, `${jobId}.md`); - if (!existsSync(resultFile)) return { content: null }; - return { content: readFileSync(resultFile, 'utf8') }; -} - -// --------------------------------------------------------------------------- -// Public API — fleet queue processing -// --------------------------------------------------------------------------- - -/** Override CLI command for testing */ -let _spawnCommand = 'claude'; -export function setSpawnCommand(cmd) { - if (process.env.NODE_ENV !== 'test') { - throw new Error('setSpawnCommand is only available in test environments'); - } - if (typeof cmd !== 'string' || cmd.length === 0 || cmd.includes('/') || cmd.includes('\\')) { - throw new Error('setSpawnCommand: cmd must be a simple command name'); - } - _spawnCommand = cmd; -} - -/** Check processing state (for diagnostics) */ -export function getIsProcessing() { return isProcessing; } - -/** - * Process the queue — spawn agents up to MAX_CONCURRENT. - * Returns after spawning; agents run asynchronously. - * - * @param {string} root — project root - * @param {string} cacheDir — cache directory - * @param {function} [onOutput] — callback (jobId, chunk) for streaming - * @param {function} [onWarning] — callback (jobId, message) for timeout warnings - * @returns {Promise<{ launched: number, skipped: string[] }>} - */ -export async function processQueue(root, cacheDir, onOutput, onWarning) { - if (isProcessing) return { launched: 0, skipped: ['locked'] }; - isProcessing = true; - - try { - ensureDirs(cacheDir); - const s = ensureStore(cacheDir); - const jobs = s.get(); - - // How many slots available? - const slotsAvailable = MAX_CONCURRENT - activeAgents.size; - if (slotsAvailable <= 0) { - return { launched: 0, skipped: ['at_capacity'] }; - } - - // Pick highest-priority queued jobs - const queued = jobs - .filter(j => j.status === 'queued') - .sort((a, b) => { - const priDiff = (b.priority ?? 1) - (a.priority ?? 1); - if (priDiff !== 0) return priDiff; - return new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(); - }) - .slice(0, slotsAvailable); - - if (queued.length === 0) { - return { launched: 0, skipped: [] }; - } - - let launched = 0; - const skipped = []; - - for (const job of queued) { - try { - // Mark provisioning - s.update(jobs => jobs.map(j => - j.id === job.id ? { ...j, status: 'provisioning' } : j, - )); - - // Create worktree - let worktreeInfo; - try { - worktreeInfo = createWorktree(root, job.id); - } catch (wtErr) { - console.error(` [Agent] Worktree creation failed for ${job.id}: ${wtErr.message}`); - // Re-queue or mark dead - const newRetry = (job.retryCount ?? 0) + 1; - const nextStatus = newRetry < (job.maxRetries ?? 2) ? 'queued' : 'dead'; - s.update(jobs => jobs.map(j => - j.id === job.id - ? { ...j, status: nextStatus, error: `Worktree failed: ${wtErr.message}`, retryCount: newRetry, pid: null } - : j, - )); - skipped.push(job.id); - continue; - } - - // Build prompt and spawn agent - const prompt = buildPrompt(job, root); - const timeout = TIMEOUTS[job.type] ?? TIMEOUTS.custom; - const resultPath = resolve(cacheDir, RESULTS_DIR, `${job.id}.md`); - - // Save prompt for debugging - const promptFile = resolve(cacheDir, PROMPTS_DIR, `${job.id}.txt`); - try { writeFileSync(promptFile, prompt, 'utf8'); } catch { /* non-critical */ } - - // Spawn claude in the worktree - const child = spawn(_spawnCommand, [ - '-p', prompt, - '--dangerously-skip-permissions', - '--output-format', 'text', - ], { - cwd: worktreeInfo.worktreePath, - shell: false, - stdio: ['pipe', 'pipe', 'pipe'], - }); - - const pid = child.pid ?? null; - if (pid === null) { - console.error(` [Agent] Failed to get PID for ${job.id}`); - destroyWorktree(root, job.id); - const newRetry = (job.retryCount ?? 0) + 1; - const nextStatus = newRetry < (job.maxRetries ?? 2) ? 'queued' : 'dead'; - s.update(jobs => jobs.map(j => - j.id === job.id - ? { ...j, status: nextStatus, error: 'Failed to start process', retryCount: newRetry, pid: null, worktreePath: null } - : j, - )); - skipped.push(job.id); - continue; - } - - // Capture baseline SHA for diff tracking - let baselineSha = null; - try { - baselineSha = gitArgs(['rev-parse', 'HEAD'], worktreeInfo.worktreePath); - } catch { /* non-critical */ } - - // Mark running - s.update(jobs => jobs.map(j => - j.id === job.id - ? { - ...j, - status: 'running', - startedAt: new Date().toISOString(), - pid, - worktreePath: worktreeInfo.worktreePath, - branchName: worktreeInfo.branchName, - configMethod: worktreeInfo.configMethod, - baselineSha, - preExistingDirtyFiles: [], - } - : j, - )); - - // Initialize live output - liveOutputBuffers.set(job.id, ''); - let stdout = ''; - - child.stdout.on('data', (chunk) => { - const text = chunk.toString(); - stdout += text; - appendToRingBuffer(job.id, text); - if (typeof onOutput === 'function') { - try { onOutput(job.id, text); } catch { /* non-critical */ } - } - }); - - child.stderr.on('data', (chunk) => { - const text = chunk.toString(); - appendToRingBuffer(job.id, `[stderr] ${text}`); - }); - - // Timeout warning at 80% - const warningTimer = setTimeout(() => { - if (typeof onWarning === 'function') { - try { onWarning(job.id, `Agent approaching timeout (80% of ${timeout / 1000}s)`); } catch { /* */ } - } - }, timeout * 0.8); - - // Hard timeout — SIGTERM then SIGKILL - const timer = setTimeout(() => { - killProcess(pid); - }, timeout); - - // Track active agent - activeAgents.set(job.id, { - pid, - worktreePath: worktreeInfo.worktreePath, - branchName: worktreeInfo.branchName, - child, - timer, - warningTimer, - }); - - // Handle completion - child.on('close', (code) => { - clearTimeout(timer); - clearTimeout(warningTimer); - activeAgents.delete(job.id); - - const currentJob = ensureStore(cacheDir).getJob(job.id); - if (!currentJob) return; - - // If cancelled while running, don't overwrite - if (currentJob.status === 'cancelled') { - setTimeout(() => clearLiveOutput(job.id), 5000); - return; - } - - const timedOut = !isProcessAlive(pid) && code !== 0; - - if (code === 0) { - // Success — save result, mark as review (worktree kept for merge) - writeFileSync(resultPath, stdout, 'utf8'); - s.update(jobs => jobs.map(j => - j.id === job.id - ? { ...j, status: 'review', completedAt: new Date().toISOString(), resultPath, pid: null } - : j, - )); - } else { - // Failure - const newRetry = (currentJob.retryCount ?? 0) + 1; - const maxRetries = currentJob.maxRetries ?? 2; - const errorMsg = code === null - ? `Timed out after ${timeout / 1000}s` - : `Exited with code ${code}`; - const nextStatus = newRetry < maxRetries ? 'queued' : 'dead'; - - // Save partial output - if (stdout.length > 0) { - writeFileSync(resultPath, stdout, 'utf8'); - } - - // Destroy worktree on failure - try { destroyWorktree(root, job.id); } catch { /* best effort */ } - - s.update(jobs => jobs.map(j => - j.id === job.id - ? { - ...j, - status: nextStatus, - completedAt: nextStatus === 'dead' ? new Date().toISOString() : null, - startedAt: nextStatus === 'queued' ? null : currentJob.startedAt, - error: errorMsg, - retryCount: newRetry, - pid: null, - resultPath: stdout.length > 0 ? resultPath : null, - worktreePath: null, - branchName: nextStatus === 'queued' ? null : currentJob.branchName, - } - : j, - )); - } - - setTimeout(() => clearLiveOutput(job.id), 5000); - }); - - child.on('error', (err) => { - clearTimeout(timer); - clearTimeout(warningTimer); - activeAgents.delete(job.id); - - try { destroyWorktree(root, job.id); } catch { /* best effort */ } - - const newRetry = (job.retryCount ?? 0) + 1; - const maxRetries = job.maxRetries ?? 2; - const nextStatus = newRetry < maxRetries ? 'queued' : 'dead'; - - s.update(jobs => jobs.map(j => - j.id === job.id - ? { - ...j, - status: nextStatus, - completedAt: nextStatus === 'dead' ? new Date().toISOString() : null, - error: err.message, - retryCount: newRetry, - pid: null, - worktreePath: null, - } - : j, - )); - - setTimeout(() => clearLiveOutput(job.id), 5000); - }); - - launched += 1; - } catch (err) { - console.error(` [Agent] Unexpected error launching ${job.id}: ${err.message}`); - skipped.push(job.id); - } - } - - return { launched, skipped }; - } finally { - isProcessing = false; - } -} - -// --------------------------------------------------------------------------- -// Public API — orphan recovery -// --------------------------------------------------------------------------- - -export function recoverOrphans(cacheDir, root) { - const s = ensureStore(cacheDir); - const jobs = s.get(); - let recovered = 0; - - const updated = jobs.map((job) => { - if (job.status !== 'running' && job.status !== 'provisioning') return job; - - const pid = job.pid; - const alive = pid && isProcessAlive(pid); - const isClaude = alive && isClaudeProcess(pid); - - if (alive && isClaude) return job; - - // Orphan detected - recovered += 1; - - // Destroy worktree if it exists - if (job.worktreePath && root) { - try { destroyWorktree(root, job.id); } catch { /* best effort */ } - } - - const newRetry = (job.retryCount ?? 0) + 1; - const maxRetries = job.maxRetries ?? 2; - - if (newRetry < maxRetries) { - return { - ...job, - status: 'queued', - startedAt: null, - error: 'Orphaned process — re-queued', - retryCount: newRetry, - pid: null, - worktreePath: null, - branchName: null, - }; - } - - return { - ...job, - status: 'dead', - completedAt: new Date().toISOString(), - error: 'Orphaned process — max retries exceeded', - retryCount: newRetry, - pid: null, - worktreePath: null, - branchName: null, - }; - }); - - if (recovered > 0) { - s.update(() => updated); - } - - // Also clean stale worktree directories - if (root) { - try { - cleanupStaleWorktrees(root, (jobId) => { - const job = jobs.find(j => j.id === jobId); - return job?.status === 'running' && job?.pid && isProcessAlive(job.pid); - }); - } catch { /* best effort */ } - } - - return { recovered }; -} - -// --------------------------------------------------------------------------- -// Public API — result pruning -// --------------------------------------------------------------------------- - -export function pruneResults(cacheDir) { - const dir = resolve(cacheDir, RESULTS_DIR); - if (!existsSync(dir)) return { pruned: 0 }; - - let entries; - try { - entries = readdirSync(dir) - .map((name) => { - const full = join(dir, name); - try { - const stat = statSync(full); - return { name, path: full, mtime: stat.mtimeMs }; - } catch { - return null; - } - }) - .filter(Boolean); - } catch { - return { pruned: 0 }; - } - - const thirtyDaysMs = 30 * 24 * 60 * 60 * 1000; - const cutoff = Date.now() - thirtyDaysMs; - let pruned = 0; - - const remaining = []; - for (const entry of entries) { - if (entry.mtime < cutoff) { - try { unlinkSync(entry.path); pruned += 1; } catch { /* skip */ } - } else { - remaining.push(entry); - } - } - - if (remaining.length > 50) { - remaining.sort((a, b) => a.mtime - b.mtime); - const excess = remaining.slice(0, remaining.length - 50); - for (const entry of excess) { - try { unlinkSync(entry.path); pruned += 1; } catch { /* skip */ } - } - } - - return { pruned }; -} - -// --------------------------------------------------------------------------- -// Legacy exports for backward compatibility (used by existing tests) -// --------------------------------------------------------------------------- - -/** @deprecated Use getIsProcessing() instead */ -export function isQueueLocked() { return isProcessing; } - -/** @deprecated Use internal reset instead */ -export function resetQueueLock() { isProcessing = false; } diff --git a/tools/project-map/lib/backlog-parser.mjs b/tools/project-map/lib/backlog-parser.mjs deleted file mode 100644 index 4a1c9aa3..00000000 --- a/tools/project-map/lib/backlog-parser.mjs +++ /dev/null @@ -1,124 +0,0 @@ -/** - * Backlog parser — reads Backlog.md and extracts open tasks, - * mapping them to project modules by keyword matching. - */ -import { readFileSync, existsSync } from 'node:fs'; -import { resolve } from 'node:path'; - -// Keywords that map tasks to modules -const MODULE_KEYWORDS = { - // Server Go packages - 'admin': ['admin', 'admin panel', 'admin api'], - 'api': ['api', 'REST', 'endpoint', 'handler', 'middleware'], - 'auth': ['auth', '2FA', 'TOTP', 'login', 'register', 'password', 'session', 'token'], - 'db': ['database', 'SQLite', 'migration', 'schema', 'query', 'db'], - 'ws': ['websocket', 'WS', 'hub', 'voice', 'broadcast', 'ringbuffer', 'reconnect'], - 'permissions': ['permission', 'role', 'RBAC'], - 'storage': ['upload', 'file storage', 'attachment'], - 'config': ['config', 'settings'], - // Client areas - 'lib': ['livekitSession', 'audioPipeline', 'dispatcher', 'tenor', 'ptt', 'notification', 'theme'], - 'stores': ['store', 'state management'], - 'components': ['component', 'UI', 'sidebar', 'overlay', 'widget', 'picker', 'modal'], - 'pages': ['page', 'ConnectPage', 'MainPage', 'ChatArea', 'SidebarArea'], - // Rust - 'tauri-rust': ['Rust', 'Tauri', 'tray', 'hotkey', 'credential', 'proxy', 'updater', 'ptt.rs'], - // Cross-cutting - 'protocol': ['protocol', 'message type', 'payload'], - 'e2e': ['E2E', 'Playwright', 'end-to-end'], - 'livekit': ['LiveKit', 'voice', 'video', 'spatial audio', 'whisper', 'screen sharing', 'streaming'], - // Feature areas - 'gaming': ['game detection', 'game time', 'LAN', 'tournament', 'leaderboard', 'seat map', 'Xfire'], - 'community': ['poll', 'gallery', 'scheduler', 'activity feed', 'pinned notes'], - 'platform': ['theme engine', 'webhook', 'bot', 'plugin', 'backup', 'monitoring'], - 'ai': ['AI', 'noise cancellation', 'translation', 'summarization', 'overlay'], -}; - -function matchModule(description) { - const lower = description.toLowerCase(); - const matches = []; - - for (const [mod, keywords] of Object.entries(MODULE_KEYWORDS)) { - for (const kw of keywords) { - const kwLower = kw.toLowerCase(); - const re = new RegExp(`\\b${kwLower}\\b`); - if (re.test(lower)) { - matches.push(mod); - break; - } - } - } - - return matches.length > 0 ? matches : ['unclassified']; -} - -function extractPhase(sectionHeader) { - if (sectionHeader.includes('Bug')) return 'bug'; - if (sectionHeader.includes('Code Review')) return 'code-review'; - if (sectionHeader.includes('Medium Priority') || sectionHeader.includes('P2')) return 'medium'; - if (sectionHeader.includes('High Priority') || sectionHeader.includes('P0') || sectionHeader.includes('P1')) return 'high'; - if (sectionHeader.includes('Deferred')) return 'deferred'; - if (/\bR1\b/.test(sectionHeader)) return 'roadmap-r1'; - if (/\bR2\b/.test(sectionHeader)) return 'roadmap-r2'; - if (/\bR3\b/.test(sectionHeader)) return 'roadmap-r3'; - if (/\bR4\b/.test(sectionHeader)) return 'roadmap-r4'; - if (/\bR5\b/.test(sectionHeader)) return 'roadmap-r5'; - if (/\bR6\b/.test(sectionHeader)) return 'roadmap-r6'; - return 'other'; -} - -export async function parseBacklog(root) { - const backlogPath = resolve(root, 'docs/brain/02-Tasks/Backlog.md'); - if (!existsSync(backlogPath)) { - console.log(' [Backlog] File not found'); - return { tasks: [], openCount: 0, doneCount: 0, byModule: {}, byPhase: {} }; - } - - const content = readFileSync(backlogPath, 'utf8'); - const lines = content.split('\n'); - - const tasks = []; - let currentSection = ''; - - for (const line of lines) { - // Track section headers - if (line.startsWith('## ') || line.startsWith('### ')) { - currentSection = line.replace(/^#+\s*/, ''); - } - - // Match task lines in two formats: - // - [ ] **T-XXX:** description (colon inside bold) - // - [ ] **T-XXX**: description (colon after bold) - const taskMatch = line.match(/^- \[([ x])\] \*\*T-(\d+)(?::\*\*|\*\*:)\s*(.+)/); - if (taskMatch) { - const done = taskMatch[1] === 'x'; - const id = `T-${taskMatch[2]}`; - const description = taskMatch[3].replace(/\s*—\s*\d{4}-\d{2}-\d{2}$/, '').trim(); - const modules = matchModule(description); - const phase = extractPhase(currentSection); - - tasks.push({ id, description, done, modules, phase, section: currentSection }); - } - } - - // Aggregate - const openTasks = tasks.filter(t => !t.done); - const doneTasks = tasks.filter(t => t.done); - - const byModule = {}; - for (const task of openTasks) { - for (const mod of task.modules) { - if (!byModule[mod]) byModule[mod] = []; - byModule[mod].push(task); - } - } - - const byPhase = {}; - for (const task of openTasks) { - if (!byPhase[task.phase]) byPhase[task.phase] = []; - byPhase[task.phase].push(task); - } - - console.log(` [Backlog] ${openTasks.length} open tasks, ${doneTasks.length} done`); - return { tasks, openCount: openTasks.length, doneCount: doneTasks.length, byModule, byPhase }; -} diff --git a/tools/project-map/lib/debt-scanner.mjs b/tools/project-map/lib/debt-scanner.mjs deleted file mode 100644 index cf73a16a..00000000 --- a/tools/project-map/lib/debt-scanner.mjs +++ /dev/null @@ -1,311 +0,0 @@ -import { readdir, readFile, stat, mkdir, writeFile } from 'node:fs/promises'; -import { join, relative, sep, posix } from 'node:path'; - -const MARKER_RE = /\b(TODO|FIXME|HACK|XXX)\b[:\s]*(.*)/i; - -const SCAN_DIRS = [ - { dir: 'Server', ext: '.go', skipFile: '_test.go', skipDirs: ['vendor'] }, - { dir: 'Client/tauri-client/src', ext: '.ts', skipFile: null, skipDirs: ['node_modules', 'dist'] }, - { dir: 'Client/tauri-client/src-tauri/src', ext: '.rs', skipFile: null, skipDirs: ['target'] }, -]; - -const LARGE_FILE_WARNING = 400; -const LARGE_FILE_CRITICAL = 800; -const LONG_FUNCTION_LINES = 50; -const DEEP_NESTING_THRESHOLD = 4; - -/** - * Recursively collect files matching an extension, skipping specified directories. - */ -async function collectFiles(base, ext, skipFile, skipDirs) { - const results = []; - - async function walk(dir) { - let entries; - try { - entries = await readdir(dir, { withFileTypes: true }); - } catch { - return; - } - for (const entry of entries) { - if (entry.isDirectory()) { - if (skipDirs.includes(entry.name)) continue; - await walk(join(dir, entry.name)); - } else if (entry.isFile() && entry.name.endsWith(ext)) { - if (skipFile && entry.name.endsWith(skipFile)) continue; - results.push(join(dir, entry.name)); - } - } - } - - await walk(base); - return results; -} - -/** - * Derive the module name from a file path relative to root. - */ -function getModule(relPath) { - const parts = relPath.split(/[/\\]/); - - // Server/{package}/file.go -> package name - if (parts[0] === 'Server' && parts.length >= 2) { - return parts.length >= 3 ? parts[1] : 'server-root'; - } - - // Client/tauri-client/src-tauri/src/file.rs -> "tauri-rust" - if ( - parts[0] === 'Client' && - parts[1] === 'tauri-client' && - parts[2] === 'src-tauri' - ) { - return 'tauri-rust'; - } - - // Client/tauri-client/src/{area}/file.ts -> area name - if ( - parts[0] === 'Client' && - parts[1] === 'tauri-client' && - parts[2] === 'src' - ) { - return parts.length >= 5 ? parts[3] : 'client-root'; - } - - return 'unknown'; -} - -/** - * Normalize path to forward slashes for consistent output. - */ -function normalizePath(p) { - return p.split(sep).join(posix.sep); -} - -/** - * Detect long functions via brace-depth tracking. - * Returns array of { line, name, lines }. - */ -function detectLongFunctions(content, ext) { - const lines = content.split('\n'); - const functions = []; - - // Stack: { name, startLine, depth } - let current = null; - let braceDepth = 0; - - for (let i = 0; i < lines.length; i++) { - const line = lines[i]; - const trimmed = line.trimStart(); - - // Detect function start - let funcName = null; - - if (ext === '.go') { - const match = trimmed.match(/^func\s+(?:\([^)]*\)\s*)?(\w+)/); - if (match) funcName = match[1]; - } else if (ext === '.ts') { - // Named function declaration - const fnMatch = trimmed.match(/^(?:export\s+)?(?:async\s+)?function\s+(\w+)/); - if (fnMatch) { - funcName = fnMatch[1]; - } else { - // Arrow function: const name = (...) => { - const arrowMatch = trimmed.match(/^(?:export\s+)?(?:const|let|var)\s+(\w+)\s*=\s*(?:async\s+)?(?:\([^)]*\)|[^=])*=>\s*\{/); - if (arrowMatch) { - funcName = arrowMatch[1]; - } - } - } else if (ext === '.rs') { - const match = trimmed.match(/^(?:pub\s+)?(?:async\s+)?fn\s+(\w+)/); - if (match) funcName = match[1]; - } - - if (funcName && current === null) { - current = { name: funcName, startLine: i + 1, depth: braceDepth }; - } - - // Track braces - for (const ch of line) { - if (ch === '{') braceDepth++; - if (ch === '}') braceDepth--; - } - if (braceDepth < 0) braceDepth = 0; - - // Check if current function has ended - if (current !== null && braceDepth <= current.depth) { - const funcLines = (i + 1) - current.startLine + 1; - if (funcLines > LONG_FUNCTION_LINES) { - functions.push({ - line: current.startLine, - name: current.name, - lines: funcLines, - }); - } - current = null; - } - } - - return functions; -} - -/** - * Scan a single file for all debt indicators. - */ -function scanFile(content, relPath, ext) { - const lines = content.split('\n'); - const mod = getModule(relPath); - const file = normalizePath(relPath); - - const markers = []; - const deepNesting = []; - - for (let i = 0; i < lines.length; i++) { - const line = lines[i]; - - // Marker detection - const markerMatch = line.match(MARKER_RE); - if (markerMatch) { - markers.push({ - type: markerMatch[1].toUpperCase(), - file, - line: i + 1, - text: markerMatch[2].trim(), - module: mod, - }); - } - - // Deep nesting detection - const leadingSpaces = line.match(/^(\s*)/)[1]; - const tabCount = (leadingSpaces.match(/\t/g) || []).length; - const spaceCount = leadingSpaces.replace(/\t/g, '').length; - const depth = tabCount + Math.floor(spaceCount / 4); - - if (depth >= DEEP_NESTING_THRESHOLD && line.trim().length > 0) { - deepNesting.push({ - file, - line: i + 1, - depth, - module: mod, - }); - } - } - - // Large file detection - let largeFile = null; - if (lines.length > LARGE_FILE_WARNING) { - largeFile = { - file, - lines: lines.length, - module: mod, - severity: lines.length > LARGE_FILE_CRITICAL ? 'critical' : 'warning', - }; - } - - // Long function detection - const longFunctions = detectLongFunctions(content, ext).map((f) => ({ - file, - line: f.line, - name: f.name, - lines: f.lines, - module: mod, - })); - - return { markers, largeFile, longFunctions, deepNesting }; -} - -/** - * Scan the codebase for technical debt indicators. - * - * @param {string} root - Absolute path to the repository root - * @param {string} cacheDir - Directory to store cached results - * @param {boolean} quick - If true and cache exists, return cached data - * @returns {Promise} Technical debt report - */ -export async function scanTechnicalDebt(root, cacheDir, quick) { - const cachePath = join(cacheDir, 'debt-data.json'); - - if (quick) { - try { - const cached = await readFile(cachePath, 'utf-8'); - return JSON.parse(cached); - } catch { - // Cache miss, proceed with full scan - } - } - - const allMarkers = []; - const allLargeFiles = []; - const allLongFunctions = []; - let allDeepNesting = []; - - for (const { dir, ext, skipFile, skipDirs } of SCAN_DIRS) { - const baseDir = join(root, ...dir.split('/')); - const files = await collectFiles(baseDir, ext, skipFile, skipDirs); - - for (const filePath of files) { - let content; - try { - content = await readFile(filePath, 'utf-8'); - } catch { - continue; - } - - const relPath = relative(root, filePath); - const result = scanFile(content, relPath, ext); - - allMarkers.push(...result.markers); - if (result.largeFile) allLargeFiles.push(result.largeFile); - allLongFunctions.push(...result.longFunctions); - allDeepNesting.push(...result.deepNesting); - } - } - - // Keep only top 20 deepest nesting instances - allDeepNesting.sort((a, b) => b.depth - a.depth); - allDeepNesting = allDeepNesting.slice(0, 20); - - // Build per-module summary - const byModule = {}; - const ensureModule = (mod) => { - if (!byModule[mod]) { - byModule[mod] = { markers: 0, largeFiles: 0, longFunctions: 0 }; - } - }; - - for (const m of allMarkers) { - ensureModule(m.module); - byModule[m.module].markers++; - } - for (const f of allLargeFiles) { - ensureModule(f.module); - byModule[f.module].largeFiles++; - } - for (const f of allLongFunctions) { - ensureModule(f.module); - byModule[f.module].longFunctions++; - } - - const report = { - timestamp: new Date().toISOString(), - markers: allMarkers, - largeFiles: allLargeFiles, - longFunctions: allLongFunctions, - deepNesting: allDeepNesting, - summary: { - totalMarkers: allMarkers.length, - totalLargeFiles: allLargeFiles.length, - totalLongFunctions: allLongFunctions.length, - byModule, - }, - }; - - // Write cache - try { - await mkdir(cacheDir, { recursive: true }); - await writeFile(cachePath, JSON.stringify(report, null, 2), 'utf-8'); - } catch { - // Non-fatal: cache write failure is acceptable - } - - return report; -} diff --git a/tools/project-map/lib/file-watcher.mjs b/tools/project-map/lib/file-watcher.mjs deleted file mode 100644 index a5832825..00000000 --- a/tools/project-map/lib/file-watcher.mjs +++ /dev/null @@ -1,115 +0,0 @@ -/** - * File watcher + SSE auto-refresh. - * Watches key directories and notifies connected SSE clients on changes. - */ -import { watch } from 'node:fs'; -import { resolve } from 'node:path'; - -export function createFileWatcher(root, onChangeCallback) { - const watchDirs = [ - resolve(root, 'Server'), - resolve(root, 'Client/tauri-client/src'), - resolve(root, 'Client/tauri-client/src-tauri/src'), - resolve(root, 'docs/brain'), - ]; - - // Debounce: only fire callback once per 2 seconds - let debounceTimer = null; - let destroyed = false; - const debounceMs = 2000; - - function handleChange(eventType, filename) { - if (destroyed) return; - - // Ignore non-source files - if (filename && ( - filename.includes('node_modules') || - filename.includes('.git') || - filename.includes('dist') || - filename.includes('target') || - filename.endsWith('.swp') || - filename.endsWith('~') - )) return; - - if (debounceTimer) clearTimeout(debounceTimer); - debounceTimer = setTimeout(() => { - if (destroyed) return; - try { - onChangeCallback({ eventType, filename, timestamp: new Date().toISOString() }); - } catch (err) { - console.error('[Watch] callback error:', err.message); - } - }, debounceMs); - } - - const watchers = []; - for (const dir of watchDirs) { - try { - const w = watch(dir, { recursive: true }, handleChange); - watchers.push(w); - } catch { - // Directory might not exist — skip silently - } - } - - return { - close() { - destroyed = true; - if (debounceTimer) { clearTimeout(debounceTimer); debounceTimer = null; } - for (const w of watchers) { - try { w.close(); } catch { /* ignore */ } - } - }, - }; -} - -/** - * SSE (Server-Sent Events) manager. - * Maintains a list of connected clients and broadcasts events to all. - */ -export function createSSEManager() { - const clients = new Set(); - - const heartbeatInterval = setInterval(() => { - for (const client of clients) { - try { client.write(': ping\n\n'); } catch { clients.delete(client); } - } - }, 30000); - - function handleConnection(req, res) { - res.writeHead(200, { - 'Content-Type': 'text/event-stream', - 'Cache-Control': 'no-cache', - 'Connection': 'keep-alive', - }); - - // Send initial connected event - res.write(`data: ${JSON.stringify({ type: 'connected', timestamp: new Date().toISOString() })}\n\n`); - - clients.add(res); - - req.on('close', () => { - clients.delete(res); - }); - } - - function broadcast(event) { - const data = `data: ${JSON.stringify(event)}\n\n`; - for (const client of clients) { - try { client.write(data); } catch { clients.delete(client); } - } - } - - function close() { - clearInterval(heartbeatInterval); - for (const client of clients) { - try { client.end(); } catch { /* ignore */ } - } - clients.clear(); - } - - // Safety net: clean up on process exit to prevent leaked intervals - process.on('exit', () => { clearInterval(heartbeatInterval); }); - - return { handleConnection, broadcast, close, get clientCount() { return clients.size; } }; -} diff --git a/tools/project-map/lib/git-scanner.mjs b/tools/project-map/lib/git-scanner.mjs deleted file mode 100644 index 89b999b9..00000000 --- a/tools/project-map/lib/git-scanner.mjs +++ /dev/null @@ -1,341 +0,0 @@ -/** - * Git history scanner — analyzes recent commits to provide per-module - * activity, file churn, staleness, velocity, and commit-type breakdown. - * - * Zero external dependencies — Node built-ins only. - */ -import { execFileSync } from 'node:child_process'; -import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs'; -import { join } from 'node:path'; - -const WINDOW_DAYS = 30; -const TOP_CHURN = 20; -const RECENT_LIMIT = 15; - -const COMMIT_TYPES = ['feat', 'fix', 'test', 'refactor', 'docs', 'chore', 'perf']; - -/** - * Map a file path to its logical module name. - * Server/{package}/ → package name (e.g. "ws", "db", "auth") - * Client/tauri-client/src/{area}/ → area name (e.g. "components", "lib") - * Client/tauri-client/src-tauri/src/ → "tauri-rust" - * - * NOTE: names are unprefixed to match scanner.mjs, backlog-parser.mjs, - * debt-scanner.mjs, and suggestion-engine.mjs canonical keys. - */ -function resolveModule(filePath) { - const normalized = filePath.replace(/\\/g, '/'); - - if (normalized.startsWith('Server/')) { - const parts = normalized.split('/'); - // parts[1] is a subdirectory only when there are 3+ segments - // e.g. Server/ws/handler.go → 'ws', Server/main.go → 'server-root' - return parts.length >= 3 ? parts[1] : 'server-root'; - } - - if (normalized.startsWith('Client/tauri-client/src-tauri/src/')) { - return 'tauri-rust'; - } - - if (normalized.startsWith('Client/tauri-client/src/')) { - const parts = normalized.replace('Client/tauri-client/src/', '').split('/'); - return parts.length >= 1 && parts[0] !== '' ? parts[0] : 'client-root'; - } - - if (normalized.startsWith('Client/tauri-client/')) { - return 'client-config'; - } - - if (normalized.startsWith('Client/')) { - return 'client-other'; - } - - // Root-level files (README, .gitignore, etc.) - return 'root'; -} - -/** - * Parse a conventional-commit prefix from a message. - * Returns one of the known types or "other". - */ -function parseCommitType(message) { - const match = message.match(/^(\w+)(?:\(.+?\))?[!]?:/); - if (!match) return 'other'; - const prefix = match[1].toLowerCase(); - return COMMIT_TYPES.includes(prefix) ? prefix : 'other'; -} - -/** - * Run a git command and return stdout as a string. - * Uses execFileSync with argument arrays to avoid shell injection. - */ -function git(args, root) { - return execFileSync('git', args, { - cwd: root, - encoding: 'utf8', - maxBuffer: 10 * 1024 * 1024, - stdio: ['pipe', 'pipe', 'pipe'], - }); -} - -/** - * Parse the raw git log output into structured commit objects. - * - * git log --format="%H|%ai|%s" --name-only produces: - * HASH|DATE|SUBJECT - * (blank line) - * file1 - * file2 - * HASH|DATE|SUBJECT - * (blank line) - * file1 - * ... - * - * We detect header lines by the HASH|DATE|SUBJECT pattern and - * accumulate file lines until the next header. - */ -function parseGitLog(raw) { - const commits = []; - const lines = raw.trim().split('\n'); - - let current = null; - - for (const line of lines) { - const trimmed = line.trim(); - if (trimmed === '') continue; - - // Detect header: 40-char hex hash, then pipe, then date, then pipe, then subject - const pipeIdx1 = trimmed.indexOf('|'); - const pipeIdx2 = pipeIdx1 !== -1 ? trimmed.indexOf('|', pipeIdx1 + 1) : -1; - const isHeader = pipeIdx1 === 40 && pipeIdx2 !== -1; - - if (isHeader) { - // Finalize previous commit - if (current) { - current.modules = [...new Set(current.files.map(resolveModule))]; - commits.push(current); - } - - const hash = trimmed.slice(0, pipeIdx1); - const date = trimmed.slice(pipeIdx1 + 1, pipeIdx2); - const message = trimmed.slice(pipeIdx2 + 1); - const type = parseCommitType(message); - - current = { hash, date, message, type, files: [], modules: [] }; - } else if (current) { - current.files.push(trimmed); - } - } - - // Finalize last commit - if (current) { - current.modules = [...new Set(current.files.map(resolveModule))]; - commits.push(current); - } - - return commits; -} - -/** - * Build per-module commit data from the parsed commits. - */ -function buildCommitsByModule(commits) { - const byModule = {}; - - for (const commit of commits) { - for (const mod of commit.modules) { - if (!byModule[mod]) { - byModule[mod] = { count: 0, lastCommit: commit.date, commits: [] }; - } - byModule[mod].count += 1; - byModule[mod].commits.push({ - hash: commit.hash, - date: commit.date, - message: commit.message, - type: commit.type, - }); - // Keep the most recent date - if (commit.date > byModule[mod].lastCommit) { - byModule[mod].lastCommit = commit.date; - } - } - } - - return byModule; -} - -/** - * Compute file churn — number of commits touching each file. - * Returns top N most churned files. - */ -function buildFileChurn(commits) { - const churnMap = {}; - - for (const commit of commits) { - for (const file of commit.files) { - if (!churnMap[file]) { - churnMap[file] = { file, commits: 0, module: resolveModule(file) }; - } - churnMap[file].commits += 1; - } - } - - return Object.values(churnMap) - .sort((a, b) => b.commits - a.commits) - .slice(0, TOP_CHURN); -} - -/** - * Calculate staleness — days since last commit per module. - */ -function buildStaleness(commitsByModule) { - const now = Date.now(); - const staleness = {}; - - for (const [mod, data] of Object.entries(commitsByModule)) { - const lastDate = new Date(data.lastCommit); - const daysSince = Math.floor((now - lastDate.getTime()) / (1000 * 60 * 60 * 24)); - staleness[mod] = { - daysSinceLastCommit: daysSince, - lastCommitDate: data.lastCommit, - }; - } - - return staleness; -} - -/** - * Build daily velocity and rolling 7-day average. - */ -function buildVelocity(commits) { - // Build a map of date → commit count - const dailyMap = {}; - const now = new Date(); - - for (let i = 0; i < WINDOW_DAYS; i++) { - const d = new Date(now); - d.setDate(d.getDate() - i); - const key = d.toISOString().slice(0, 10); - dailyMap[key] = 0; - } - - for (const commit of commits) { - const key = commit.date.slice(0, 10); - if (key in dailyMap) { - dailyMap[key] += 1; - } - } - - const daily = Object.entries(dailyMap) - .sort(([a], [b]) => a.localeCompare(b)) - .map(([date, count]) => ({ date, count })); - - // Rolling 7-day average (use last 7 days) - const last7 = daily.slice(-7); - const weeklyAvg = last7.length > 0 - ? Math.round((last7.reduce((s, d) => s + d.count, 0) / last7.length) * 100) / 100 - : 0; - - // Trend: compare first half vs second half of the window - const mid = Math.floor(daily.length / 2); - const firstHalf = daily.slice(0, mid); - const secondHalf = daily.slice(mid); - - const avgFirst = firstHalf.length > 0 - ? firstHalf.reduce((s, d) => s + d.count, 0) / firstHalf.length - : 0; - const avgSecond = secondHalf.length > 0 - ? secondHalf.reduce((s, d) => s + d.count, 0) / secondHalf.length - : 0; - - let trend = 'stable'; - const delta = avgSecond - avgFirst; - if (delta > 0.3) trend = 'accelerating'; - else if (delta < -0.3) trend = 'decelerating'; - - return { daily, weeklyAvg, trend }; -} - -/** - * Build commit type breakdown. - */ -function buildCommitTypes(commits) { - const types = { feat: 0, fix: 0, test: 0, refactor: 0, docs: 0, chore: 0, other: 0 }; - - for (const commit of commits) { - const bucket = commit.type in types ? commit.type : 'other'; - types[bucket] += 1; - } - - return types; -} - -/** - * Main entry point. Scans git history for the last 30 days and returns - * structured data about module activity, churn, staleness, and velocity. - * - * @param {string} root - Repository root directory - * @param {string} cacheDir - Directory to store cached results - * @param {boolean} quick - If true and cache exists, return cached data - * @returns {Promise} Structured git history data - */ -// Exported for testing -export { resolveModule }; - -export async function scanGitHistory(root, cacheDir, quick = false) { - const cacheFile = join(cacheDir, 'git-data.json'); - - // Quick mode: return cache if available - if (quick && existsSync(cacheFile)) { - try { - const cached = JSON.parse(readFileSync(cacheFile, 'utf8')); - return cached; - } catch { - // Cache corrupt — fall through to fresh scan - } - } - - // Fetch git log with file names - const raw = git( - ['log', `--since=${WINDOW_DAYS} days ago`, '--format=%H|%ai|%s', '--name-only'], - root, - ); - - const commits = parseGitLog(raw); - const commitsByModule = buildCommitsByModule(commits); - const fileChurn = buildFileChurn(commits); - const staleness = buildStaleness(commitsByModule); - const velocity = buildVelocity(commits); - const commitTypes = buildCommitTypes(commits); - - const recentCommits = commits.slice(0, RECENT_LIMIT).map(c => ({ - hash: c.hash, - date: c.date, - message: c.message, - type: c.type, - modules: c.modules, - })); - - const result = { - timestamp: new Date().toISOString(), - commitsByModule, - fileChurn, - staleness, - velocity, - commitTypes, - recentCommits, - totalCommits30d: commits.length, - }; - - // Persist cache - try { - if (!existsSync(cacheDir)) { - mkdirSync(cacheDir, { recursive: true }); - } - writeFileSync(cacheFile, JSON.stringify(result, null, 2), 'utf8'); - } catch { - // Non-fatal — scanning still succeeds without cache write - } - - return result; -} diff --git a/tools/project-map/lib/go-coverage.mjs b/tools/project-map/lib/go-coverage.mjs deleted file mode 100644 index c890cabe..00000000 --- a/tools/project-map/lib/go-coverage.mjs +++ /dev/null @@ -1,131 +0,0 @@ -/** - * Go test coverage collector. - * Runs `go test ./... -cover -short -json` and parses per-package coverage. - * Caches results to .cache/go-coverage.json. - */ -import { execFileSync } from 'node:child_process'; -import { readFileSync, writeFileSync, existsSync } from 'node:fs'; -import { resolve } from 'node:path'; - -const CACHE_FILE = 'go-coverage.json'; - -// Extract short package name from full Go module path -// e.g. "github.com/owncord/server/admin" -> "admin" -// e.g. "github.com/owncord/server" -> "root" -function extractPkgName(fullPath) { - const parts = fullPath.split('/'); - const last = parts[parts.length - 1]; - // If the package ends with "server" it's the root package - if (last === 'server') return 'server-root'; - return last; -} - -function parseGoCoverage(jsonLines) { - const coverage = {}; - - for (const line of jsonLines.split('\n')) { - if (!line.trim()) continue; - try { - const entry = JSON.parse(line); - // Look for coverage output lines - if (entry.Action === 'output' && entry.Output) { - // Match: "coverage: 67.3% of statements" - const coverMatch = entry.Output.match(/coverage:\s+([\d.]+)%\s+of\s+statements/); - if (coverMatch && entry.Package) { - const pkg = extractPkgName(entry.Package); - coverage[pkg] = { - percentage: parseFloat(coverMatch[1]), - package: pkg, - }; - } - // Match: "[no test files]" - if (entry.Output.includes('[no test files]') && entry.Package) { - const pkg = extractPkgName(entry.Package); - coverage[pkg] = { - percentage: null, - package: pkg, - noTests: true, - }; - } - } - // Also check for pass/fail - if (entry.Action === 'pass' && entry.Package) { - const pkg = extractPkgName(entry.Package); - if (!coverage[pkg]) { - coverage[pkg] = { percentage: 0, package: pkg }; - } - coverage[pkg].passed = true; - } - if (entry.Action === 'fail' && entry.Package) { - const pkg = extractPkgName(entry.Package); - if (!coverage[pkg]) { - coverage[pkg] = { percentage: 0, package: pkg }; - } - coverage[pkg].failed = true; - } - } catch { /* skip non-JSON lines */ } - } - - return coverage; -} - -export async function collectGoCoverage(root, cacheDir, quick) { - const cacheFile = resolve(cacheDir, CACHE_FILE); - - if (quick && existsSync(cacheFile)) { - console.log(' [Go] Using cached coverage data'); - try { - const cached = JSON.parse(readFileSync(cacheFile, 'utf8')); - if (cached.hasFailures) { - console.warn(' [Go Coverage] Cached data has test failures — re-running tests'); - // Fall through to full collection instead of returning cached - } else { - return cached; - } - } catch { - console.warn(' [Go] Corrupt cache — re-running tests'); - } - } - - const serverDir = resolve(root, 'Server'); - if (!existsSync(serverDir)) { - console.log(' [Go] Server directory not found, skipping'); - return {}; - } - - console.log(' [Go] Running tests with coverage (this may take a minute)...'); - try { - const output = execFileSync('go', ['test', './...', '-cover', '-short', '-json', '-count=1'], { - cwd: serverDir, - encoding: 'utf8', - timeout: 300_000, // 5 min - maxBuffer: 10 * 1024 * 1024, - }); - - const coverage = parseGoCoverage(output); - - // Cache results - const result = { - timestamp: new Date().toISOString(), - packages: coverage, - }; - writeFileSync(cacheFile, JSON.stringify(result, null, 2)); - console.log(` [Go] Coverage collected for ${Object.keys(coverage).length} packages`); - return result; - } catch (err) { - // go test returns non-zero on test failure but still produces output - if (err.stdout) { - const coverage = parseGoCoverage(err.stdout); - const result = { - timestamp: new Date().toISOString(), - packages: coverage, - hasFailures: true, - }; - writeFileSync(cacheFile, JSON.stringify(result, null, 2)); - console.log(` [Go] Coverage collected (some tests failed)`); - return result; - } - console.error(` [Go] Failed to collect coverage: ${err.message}`); - return { timestamp: new Date().toISOString(), packages: {}, error: err.message }; - } -} diff --git a/tools/project-map/lib/import-graph.mjs b/tools/project-map/lib/import-graph.mjs deleted file mode 100644 index e6d810a5..00000000 --- a/tools/project-map/lib/import-graph.mjs +++ /dev/null @@ -1,472 +0,0 @@ -/** - * Import-graph builder — parses Go, TypeScript, and Rust source files to - * construct a dependency graph with fan-in/fan-out metrics and cycle detection. - * Zero external dependencies — Node built-ins only. - */ -import { readFileSync, readdirSync, existsSync, mkdirSync, writeFileSync } from 'node:fs'; -import { join, resolve, basename, relative } from 'node:path'; - -// --------------------------------------------------------------------------- -// File discovery -// --------------------------------------------------------------------------- - -/** - * Recursively collect files matching `extensions` under `dir`, skipping - * common non-source directories. - */ -function collectFiles(dir, extensions, skipSuffix = []) { - const results = []; - if (!existsSync(dir)) return results; - - function walk(d) { - let entries; - try { entries = readdirSync(d, { withFileTypes: true }); } catch { return; } - for (const entry of entries) { - if (entry.name.startsWith('.') || ['node_modules', 'vendor', 'dist', 'target'].includes(entry.name)) continue; - const full = join(d, entry.name); - if (entry.isDirectory()) { - walk(full); - } else if (entry.isFile() - && extensions.some(ext => entry.name.endsWith(ext)) - && !skipSuffix.some(suf => entry.name.endsWith(suf))) { - results.push(full); - } - } - } - - walk(dir); - return results; -} - -// --------------------------------------------------------------------------- -// Go import parsing -// --------------------------------------------------------------------------- - -const GO_MODULE_PREFIX = 'github.com/owncord/server/'; - -/** - * Parse a single Go file and return the set of internal package short-names - * it imports (e.g. "db", "auth", "ws"). - */ -function parseGoImports(content) { - const deps = new Set(); - // Match import blocks: import ( ... ) - const blockRe = /import\s*\(([^)]*)\)/gs; - let blockMatch; - while ((blockMatch = blockRe.exec(content)) !== null) { - const block = blockMatch[1]; - const lineRe = /["']([^"']+)["']/g; - let lineMatch; - while ((lineMatch = lineRe.exec(block)) !== null) { - const path = lineMatch[1]; - if (path.startsWith(GO_MODULE_PREFIX)) { - deps.add(path.slice(GO_MODULE_PREFIX.length).split('/')[0]); - } - } - } - // Match single-line imports: import "..." - const singleRe = /import\s+["']([^"']+)["']/g; - let singleMatch; - while ((singleMatch = singleRe.exec(content)) !== null) { - const path = singleMatch[1]; - if (path.startsWith(GO_MODULE_PREFIX)) { - deps.add(path.slice(GO_MODULE_PREFIX.length).split('/')[0]); - } - } - return deps; -} - -/** - * Scan Go source files under `serverDir` and return a map of - * package-name -> Set. - */ -function scanGoImports(serverDir) { - const graph = new Map(); - const files = collectFiles(serverDir, ['.go'], ['_test.go']); - - for (const file of files) { - // Determine package from directory name relative to server root - const rel = relative(serverDir, file); - const pkg = rel.includes('/') || rel.includes('\\') - ? rel.split(/[/\\]/)[0] - : '.'; // root-level files (main.go etc.) - - if (pkg === '.' || pkg === 'scripts' || pkg === 'migrations') continue; - - if (!graph.has(pkg)) graph.set(pkg, new Set()); - - let content; - try { content = readFileSync(file, 'utf8'); } catch { continue; } - - const deps = parseGoImports(content); - const existing = graph.get(pkg); - for (const dep of deps) { - if (dep !== pkg) existing.add(dep); - } - } - - return graph; -} - -// --------------------------------------------------------------------------- -// TypeScript import parsing -// --------------------------------------------------------------------------- - -/** Known TS path-alias prefixes that map to source directories. */ -const TS_ALIAS_MAP = { - '@lib': 'lib', - '@stores': 'stores', - '@components': 'components', - '@pages': 'pages', - '@styles': 'styles', - '@types': 'types', -}; - -/** - * Parse a TypeScript file and return the set of internal module names it - * imports (e.g. "lib", "stores", "components"). - */ -function parseTsImports(content) { - const deps = new Set(); - - // Match: import ... from '...' / import '...' (including type imports) - const re = /import\s+(?:type\s+)?(?:[^'"]*from\s+)?['"]([^'"]+)['"]/g; - let m; - while ((m = re.exec(content)) !== null) { - const specifier = m[1]; - - // Check @alias paths: @lib/foo -> "lib" - for (const [alias, dir] of Object.entries(TS_ALIAS_MAP)) { - if (specifier.startsWith(alias + '/') || specifier === alias) { - deps.add(dir); - break; - } - } - - // Check relative paths: ../lib/foo -> "lib", ./sibling stays in same dir - if (specifier.startsWith('../')) { - const segment = specifier.slice(3).split('/')[0]; - if (segment && Object.values(TS_ALIAS_MAP).includes(segment)) { - deps.add(segment); - } - } - } - - return deps; -} - -/** - * Determine which TS "module" a file belongs to based on its directory. - * Files directly in src/ are grouped as "main". - */ -function tsModuleName(file, srcDir) { - const rel = relative(srcDir, file).replace(/\\/g, '/'); - const parts = rel.split('/'); - if (parts.length === 1) return basename(parts[0], '.ts'); // top-level file -> its name - return parts[0]; // first directory segment -} - -/** - * Scan TypeScript files under `srcDir` and return a dependency map. - */ -function scanTsImports(srcDir) { - const graph = new Map(); - const files = collectFiles(srcDir, ['.ts']); - - for (const file of files) { - const mod = tsModuleName(file, srcDir); - if (!graph.has(mod)) graph.set(mod, new Set()); - - let content; - try { content = readFileSync(file, 'utf8'); } catch { continue; } - - const deps = parseTsImports(content); - const existing = graph.get(mod); - for (const dep of deps) { - if (dep !== mod) existing.add(dep); - } - } - - return graph; -} - -// --------------------------------------------------------------------------- -// Rust import parsing -// --------------------------------------------------------------------------- - -/** - * Parse a Rust file and return module names referenced via `mod` declarations - * and `use crate::` paths. - */ -function parseRustImports(content) { - const deps = new Set(); - - // mod declarations: mod foo; - const modRe = /^\s*(?:pub\s+)?mod\s+(\w+)\s*;/gm; - let m; - while ((m = modRe.exec(content)) !== null) { - deps.add(m[1]); - } - - // use crate::foo (possibly ::bar::baz) - const useRe = /use\s+crate::(\w+)/g; - while ((m = useRe.exec(content)) !== null) { - deps.add(m[1]); - } - - return deps; -} - -/** - * Determine Rust module name from filename. - * lib.rs and main.rs -> "lib" / "main"; others -> stem. - */ -function rustModuleName(file) { - const name = basename(file, '.rs'); - return name; -} - -/** - * Scan Rust files and return a dependency map. - */ -function scanRustImports(rustDir) { - const graph = new Map(); - const files = collectFiles(rustDir, ['.rs']); - - for (const file of files) { - const mod = rustModuleName(file); - if (!graph.has(mod)) graph.set(mod, new Set()); - - let content; - try { content = readFileSync(file, 'utf8'); } catch { continue; } - - const deps = parseRustImports(content); - const existing = graph.get(mod); - for (const dep of deps) { - if (dep !== mod) existing.add(dep); - } - } - - return graph; -} - -// --------------------------------------------------------------------------- -// Graph analysis -// --------------------------------------------------------------------------- - -/** - * Merge multiple per-language graphs into a single adjacency list (object). - * Each value is a Map>; we also track which language - * each module belongs to. - */ -function mergeGraphs(goGraph, tsGraph, rustGraph) { - const merged = {}; // module -> string[] - const types = {}; // module -> 'go' | 'typescript' | 'rust' - - for (const [mod, deps] of goGraph) { - const key = `go/${mod}`; - merged[key] = [...deps].map(d => `go/${d}`); - types[key] = 'go'; - } - for (const [mod, deps] of tsGraph) { - const key = `ts/${mod}`; - merged[key] = [...deps].map(d => `ts/${d}`); - types[key] = 'typescript'; - } - for (const [mod, deps] of rustGraph) { - const key = `rs/${mod}`; - merged[key] = [...deps].map(d => `rs/${d}`); - types[key] = 'rust'; - } - - return { merged, types }; -} - -/** - * Compute fan-in (how many modules depend on this one) and fan-out (how many - * modules this one depends on). - */ -function computeMetrics(graph) { - const fanIn = {}; - const fanOut = {}; - - for (const mod of Object.keys(graph)) { - fanOut[mod] = graph[mod].length; - if (!(mod in fanIn)) fanIn[mod] = 0; - for (const dep of graph[mod]) { - fanIn[dep] = (fanIn[dep] || 0) + 1; - } - } - - return { fanIn, fanOut }; -} - -/** - * Detect all cycles in the directed graph using iterative DFS with an - * explicit stack to avoid call-stack overflow on large graphs. - * Returns an array of cycles, each cycle being an array of module names. - */ -function detectCycles(graph) { - const WHITE = 0; // unvisited - const GRAY = 1; // in current path - const BLACK = 2; // fully explored - - const color = {}; - for (const node of Object.keys(graph)) { - color[node] = WHITE; - } - - const cycles = []; - - for (const start of Object.keys(graph)) { - if (color[start] !== WHITE) continue; - - // Stack entries: [node, neighborIndex, pathSoFar] - const stack = [[start, 0, [start]]]; - color[start] = GRAY; - - while (stack.length > 0) { - const top = stack[stack.length - 1]; - const node = top[0]; - const neighbors = graph[node] || []; - - if (top[1] >= neighbors.length) { - // All neighbors explored — backtrack - color[node] = BLACK; - stack.pop(); - continue; - } - - const neighbor = neighbors[top[1]]; - top[1]++; - - if (color[neighbor] === GRAY) { - // Found a cycle — extract the cycle portion from the current path - const fullPath = [...top[2], neighbor]; - const cycleStart = fullPath.indexOf(neighbor); - if (cycleStart !== -1 && cycleStart < fullPath.length - 1) { - cycles.push(fullPath.slice(cycleStart)); - } - } else if (color[neighbor] === WHITE) { - color[neighbor] = GRAY; - stack.push([neighbor, 0, [...top[2], neighbor]]); - } - } - } - - return cycles; -} - -// --------------------------------------------------------------------------- -// Main entry point -// --------------------------------------------------------------------------- - -/** - * Build the full import graph for the OwnCord project. - * - * @param {string} root — project root directory - * @param {string} cacheDir — directory for caching results - * @param {boolean} quick — if true, return cached results when available - * @returns {Promise} — import graph data - */ -export async function buildImportGraph(root, cacheDir, quick) { - const cacheFile = join(cacheDir, 'import-graph.json'); - - // Quick mode: return cache if it exists - if (quick && existsSync(cacheFile)) { - try { - const cached = JSON.parse(readFileSync(cacheFile, 'utf8')); - return cached; - } catch { - // Cache corrupt — rebuild - } - } - - // Scan each language - const serverDir = resolve(root, 'Server'); - const tsSrcDir = resolve(root, 'Client', 'tauri-client', 'src'); - const rustDir = resolve(root, 'Client', 'tauri-client', 'src-tauri', 'src'); - - const goGraph = scanGoImports(serverDir); - const tsGraph = scanTsImports(tsSrcDir); - const rustGraph = scanRustImports(rustDir); - - // Merge into unified adjacency list - const { merged, types } = mergeGraphs(goGraph, tsGraph, rustGraph); - - // Compute metrics - const { fanIn, fanOut } = computeMetrics(merged); - - // Build nodes array - const nodes = Object.keys(merged).map(mod => { - const fi = fanIn[mod] || 0; - const fo = fanOut[mod] || 0; - return { - name: mod, - type: types[mod] || 'go', - fanIn: fi, - fanOut: fo, - coupling: fi * fo, - }; - }); - - // Also include nodes that appear only as dependencies (no outgoing edges) - for (const mod of Object.keys(fanIn)) { - if (!(mod in merged)) { - const fi = fanIn[mod] || 0; - nodes.push({ - name: mod, - type: types[mod] || inferType(mod), - fanIn: fi, - fanOut: 0, - coupling: 0, - }); - } - } - - // Build edges array - const edges = []; - for (const [from, deps] of Object.entries(merged)) { - for (const to of deps) { - edges.push({ from, to }); - } - } - - // Detect cycles - const cycles = detectCycles(merged); - - // Top 5 most coupled - const mostCoupled = [...nodes] - .sort((a, b) => b.coupling - a.coupling) - .slice(0, 5) - .map(({ name, coupling, fanIn, fanOut }) => ({ name, coupling, fanIn, fanOut })); - - const result = { - timestamp: new Date().toISOString(), - graph: merged, - nodes, - edges, - cycles, - mostCoupled, - }; - - // Write cache - try { - mkdirSync(cacheDir, { recursive: true }); - writeFileSync(cacheFile, JSON.stringify(result, null, 2), 'utf8'); - } catch { - // Non-fatal — proceed without caching - } - - return result; -} - -/** - * Infer language type from a prefixed module name. - */ -function inferType(mod) { - if (mod.startsWith('go/')) return 'go'; - if (mod.startsWith('ts/')) return 'typescript'; - if (mod.startsWith('rs/')) return 'rust'; - return 'go'; -} diff --git a/tools/project-map/lib/morning-briefing.mjs b/tools/project-map/lib/morning-briefing.mjs deleted file mode 100644 index 0ab209eb..00000000 --- a/tools/project-map/lib/morning-briefing.mjs +++ /dev/null @@ -1,190 +0,0 @@ -/** - * Morning Briefing — digest of overnight agent results + session suggestions. - * Composes data from session-parser, agent-manager, suggestion-engine, and backlog-parser. - */ -import { readFileSync, existsSync, readdirSync, statSync } from 'node:fs'; -import { resolve, join } from 'node:path'; - -/** - * Generate a morning briefing digest. - * Shows what happened since the last session (agent results, coverage changes, etc.) - * and suggests what to work on next. - */ -export async function generateBriefing(root, cacheDir, { - sessionData = null, - suggestions = null, - backlog = null, - agentJobs = null, -} = {}) { - const briefing = { - timestamp: new Date().toISOString(), - greeting: '', - agentResults: [], - deadJobs: [], - coverageChanges: [], - suggestedTasks: [], - autoQueueSuggestions: [], - stats: { completedJobs: 0, failedJobs: 0, deadJobs: 0 }, - }; - - // Determine last session date for "since last session" filtering - const lastSessionDate = sessionData?.lastSession?.date - ? new Date(sessionData.lastSession.date) - : null; - - // Greeting - if (lastSessionDate) { - const daysSince = Math.floor((Date.now() - lastSessionDate.getTime()) / 86400000); - if (daysSince === 0) { - briefing.greeting = 'Welcome back! You had a session earlier today.'; - } else if (daysSince === 1) { - briefing.greeting = 'Good morning! Last session was yesterday.'; - } else { - briefing.greeting = `Welcome back! It's been ${daysSince} days since your last session.`; - } - } else { - briefing.greeting = 'Welcome! This is your first time opening the briefing.'; - } - - // Agent results since last session - if (agentJobs?.jobs) { - for (const job of agentJobs.jobs) { - if (job.status === 'completed') { - const completedAt = job.completedAt ? new Date(job.completedAt) : null; - if (!lastSessionDate || (completedAt && completedAt > lastSessionDate)) { - // Read result summary (first 500 chars) - let summary = ''; - const resultPath = resolve(cacheDir, `agent-results/${job.id}.md`); - if (existsSync(resultPath)) { - try { - const content = readFileSync(resultPath, 'utf8'); - summary = content.slice(0, 500).split('\n').slice(0, 10).join('\n'); - if (content.length > 500) summary += '\n...'; - } catch { /* skip */ } - } - briefing.agentResults.push({ - id: job.id, - type: job.type, - target: job.target, - completedAt: job.completedAt, - summary, - }); - briefing.stats.completedJobs++; - } - } - - if (job.status === 'failed') briefing.stats.failedJobs++; - - if (job.status === 'dead') { - briefing.deadJobs.push({ - id: job.id, - type: job.type, - target: job.target, - error: job.error, - retryCount: job.retryCount, - }); - briefing.stats.deadJobs++; - } - } - } - - // Top suggested tasks from suggestion engine - if (suggestions?.suggestions) { - briefing.suggestedTasks = suggestions.suggestions.slice(0, 5).map(s => ({ - module: s.module, - score: s.score, - rationale: s.rationale, - breakdown: s.breakdown, - })); - } - - // Auto-queue suggestions — only when agent jobs context is available - briefing.autoQueueSuggestions = agentJobs - ? generateAutoQueueSuggestions(suggestions, backlog, sessionData, agentJobs) - : []; - - return briefing; -} - -/** - * Generate smart auto-queue suggestions. - * Recommends agent jobs based on current project state. - */ -function generateAutoQueueSuggestions(suggestions, backlog, sessionData, agentJobs) { - const existing = new Set( - (agentJobs?.jobs || []) - .filter(j => ['queued', 'running', 'completed'].includes(j.status)) - .map(j => `${j.type}:${j.target}`) - ); - - const suggestions_list = []; - - if (!suggestions?.suggestions) return suggestions_list; - - for (const s of suggestions.suggestions.slice(0, 10)) { - // Coverage gap → suggest write-tests - if (s.breakdown?.coverage > 20) { - const key = `write-tests:${s.module}`; - if (!existing.has(key)) { - suggestions_list.push({ - type: 'write-tests', - target: s.module, - rationale: `Coverage gap on ${s.module} (score: ${s.breakdown.coverage})`, - priority: 2, - }); - } - } - - // High debt → suggest fix-debt - if (s.breakdown?.debt > 15) { - const key = `fix-debt:${s.module}`; - if (!existing.has(key)) { - suggestions_list.push({ - type: 'fix-debt', - target: s.module, - rationale: `Tech debt markers in ${s.module} (score: ${s.breakdown.debt})`, - priority: 3, - }); - } - } - - // Open bugs → suggest code-review - if (s.breakdown?.bugs > 10) { - const key = `code-review:${s.module}`; - if (!existing.has(key)) { - suggestions_list.push({ - type: 'code-review', - target: s.module, - rationale: `Open bugs/review items in ${s.module} (score: ${s.breakdown.bugs})`, - priority: 1, - }); - } - } - } - - // Stale modules → suggest research (limit to top 2) - const staleModules = suggestions.suggestions - .filter(s => s.signals?.some(sig => sig.signal === 'stale')) - .slice(0, 2); - - for (const s of staleModules) { - const key = `research:${s.module}`; - if (!existing.has(key)) { - suggestions_list.push({ - type: 'research', - target: s.module, - rationale: `Module ${s.module} has been stale — investigate status`, - priority: 4, - }); - } - } - - // Deduplicate and limit to 5 - const seen = new Set(); - return suggestions_list.filter(s => { - const key = `${s.type}:${s.target}`; - if (seen.has(key)) return false; - seen.add(key); - return true; - }).slice(0, 5); -} diff --git a/tools/project-map/lib/priority-engine.mjs b/tools/project-map/lib/priority-engine.mjs deleted file mode 100644 index 96c85b0b..00000000 --- a/tools/project-map/lib/priority-engine.mjs +++ /dev/null @@ -1,180 +0,0 @@ -/** - * Priority scoring engine. - * Ranks modules by where work is most needed based on: - * - Coverage gap (distance from 80% target) - * - Module size (larger modules = higher impact) - * - Open bug/task count - * - Roadmap position (earlier phases = higher priority) - */ - -const COVERAGE_TARGET = 80; - -const PHASE_WEIGHTS = { - 'bug': 10, - 'high': 8, - 'code-review': 7, - 'medium': 5, - 'deferred': 3, - 'roadmap-r1': 4, - 'roadmap-r2': 3, - 'roadmap-r3': 2, - 'roadmap-r4': 1.5, - 'roadmap-r5': 1, - 'roadmap-r6': 0.5, - 'other': 2, -}; - -function coverageGapScore(coveragePct) { - if (coveragePct === null || coveragePct === undefined) return 50; // no tests = high gap - const gap = Math.max(0, COVERAGE_TARGET - coveragePct); - return gap * 1.5; // 1.5 points per percent below target -} - -function sizeScore(sourceFiles, sourceLines) { - // Larger modules are higher impact - return Math.min(30, sourceFiles * 2 + (sourceLines / 200)); -} - -function taskScore(openTasks) { - if (!openTasks || openTasks.length === 0) return 0; - let score = 0; - for (const task of openTasks) { - score += PHASE_WEIGHTS[task.phase] || 2; - } - return score; -} - -export function scorePriorities(modules, goCoverage, vitestCoverage, backlog) { - const scores = []; - - // Score Go packages - for (const pkg of modules.go) { - const covData = goCoverage.packages?.[pkg.name]; - const coverage = covData?.percentage ?? null; - const openTasks = backlog.byModule[pkg.name] || []; - - const cScore = coverageGapScore(coverage); - const sScore = sizeScore(pkg.sourceFiles, pkg.sourceLines); - const tScore = taskScore(openTasks); - const total = cScore + sScore + tScore; - - scores.push({ - name: pkg.name, - type: 'go', - path: pkg.path, - coverage, - coverageGap: cScore, - sizeImpact: sScore, - taskWeight: tScore, - totalScore: total, - openTaskCount: openTasks.length, - openTasks: openTasks.map(t => t.id), - recommendation: getRecommendation(coverage, openTasks, pkg), - }); - } - - // Score TypeScript areas - const tsAreas = vitestCoverage.areas || {}; - for (const dir of modules.typescript.filter(d => d.type === 'typescript')) { - const areaCov = tsAreas[dir.name]; - const coverage = areaCov?.statements ?? (tsAreas._total?.statements ?? null); - const openTasks = backlog.byModule[dir.name] || []; - - const cScore = coverageGapScore(coverage); - const sScore = sizeScore(dir.sourceFiles, dir.sourceLines); - const tScore = taskScore(openTasks); - const total = cScore + sScore + tScore; - - scores.push({ - name: dir.name, - type: 'typescript', - path: dir.path, - coverage, - coverageGap: cScore, - sizeImpact: sScore, - taskWeight: tScore, - totalScore: total, - openTaskCount: openTasks.length, - openTasks: openTasks.map(t => t.id), - recommendation: getRecommendation(coverage, openTasks, dir), - }); - } - - // Score Rust - for (const rust of modules.rust) { - const openTasks = backlog.byModule['tauri-rust'] || []; - const cScore = coverageGapScore(null); // no Rust tests - const sScore = sizeScore(rust.sourceFiles, rust.sourceLines); - const tScore = taskScore(openTasks); - const total = cScore + sScore + tScore; - - scores.push({ - name: rust.name, - type: 'rust', - path: rust.path, - coverage: null, - coverageGap: cScore, - sizeImpact: sScore, - taskWeight: tScore, - totalScore: total, - openTaskCount: openTasks.length, - openTasks: openTasks.map(t => t.id), - recommendation: 'Add Rust unit tests — currently zero test coverage', - }); - } - - // Score cross-cutting areas from backlog - const crossCutting = ['livekit', 'gaming', 'community', 'platform', 'ai', 'protocol']; - for (const area of crossCutting) { - const openTasks = backlog.byModule[area] || []; - if (openTasks.length === 0) continue; - - const tScore = taskScore(openTasks); - scores.push({ - name: area, - type: 'feature-area', - path: '', - coverage: null, - coverageGap: 0, - sizeImpact: 0, - taskWeight: tScore, - totalScore: tScore, - openTaskCount: openTasks.length, - openTasks: openTasks.map(t => t.id), - recommendation: `${openTasks.length} open task(s) in backlog`, - }); - } - - // Sort by total score descending - scores.sort((a, b) => b.totalScore - a.totalScore); - - return scores; -} - -function getRecommendation(coverage, openTasks, module) { - const parts = []; - - if (coverage === null) { - parts.push('No test coverage'); - } else if (coverage < 60) { - parts.push(`Coverage critically low (${coverage.toFixed(1)}%)`); - } else if (coverage < COVERAGE_TARGET) { - parts.push(`Coverage below target (${coverage.toFixed(1)}% < ${COVERAGE_TARGET}%)`); - } - - if (openTasks.length > 0) { - const bugs = openTasks.filter(t => t.phase === 'bug'); - const reviews = openTasks.filter(t => t.phase === 'code-review'); - if (bugs.length > 0) parts.push(`${bugs.length} open bug(s)`); - if (reviews.length > 0) parts.push(`${reviews.length} code review fix(es)`); - if (parts.length === 0 || (bugs.length === 0 && reviews.length === 0)) { - parts.push(`${openTasks.length} open task(s)`); - } - } - - if (module.sourceFiles > 15) { - parts.push('Large module — high impact'); - } - - return parts.length > 0 ? parts.join('; ') : 'Good shape'; -} diff --git a/tools/project-map/lib/report-generator.mjs b/tools/project-map/lib/report-generator.mjs deleted file mode 100644 index 4331dd5c..00000000 --- a/tools/project-map/lib/report-generator.mjs +++ /dev/null @@ -1,162 +0,0 @@ -/** - * Markdown report generator. - * Writes the full project map to docs/brain/00-Overview/Project-Map.md. - */ -import { writeFileSync, mkdirSync } from 'node:fs'; -import { dirname } from 'node:path'; - -function badge(coverage) { - if (coverage === null || coverage === undefined) return '---'; - return `${coverage.toFixed(1)}%`; -} - -function statusIcon(coverage) { - if (coverage === null || coverage === undefined) return 'No tests'; - if (coverage >= 80) return 'Above target'; - if (coverage >= 70) return 'Near target'; - if (coverage >= 50) return 'Below target'; - return 'Critical'; -} - -export async function generateReport(reportPath, modules, goCoverage, vitestCoverage, backlog, priorities) { - mkdirSync(dirname(reportPath), { recursive: true }); - - const now = new Date().toISOString().replace('T', ' ').slice(0, 19); - const lines = []; - - lines.push('# OwnCord Project Map'); - lines.push(''); - lines.push(`> Auto-generated on ${now} by \`tools/project-map\``); - lines.push(`> Run \`node tools/project-map/index.mjs\` to regenerate`); - lines.push(''); - - // --- Summary --- - lines.push('## Summary'); - lines.push(''); - lines.push(`| Metric | Value |`); - lines.push(`|--------|-------|`); - lines.push(`| Go packages | ${modules.summary.goPackages} |`); - lines.push(`| Go source files | ${modules.summary.goSourceFiles} |`); - lines.push(`| Go test files | ${modules.summary.goTestFiles} |`); - lines.push(`| TypeScript source files | ${modules.summary.tsSourceFiles} |`); - lines.push(`| TypeScript test files | ${modules.summary.tsTestFiles} |`); - lines.push(`| Rust source files | ${modules.summary.rustSourceFiles} |`); - lines.push(`| Rust test files | ${modules.summary.rustTestFiles} |`); - lines.push(`| Open backlog tasks | ${backlog.openCount} |`); - lines.push(`| Completed tasks | ${backlog.doneCount} |`); - lines.push(`| Completion rate | ${backlog.doneCount > 0 ? ((backlog.doneCount / (backlog.openCount + backlog.doneCount)) * 100).toFixed(1) : 0}% |`); - lines.push(''); - - // --- Server Coverage --- - lines.push('## Server (Go) — Test Coverage'); - lines.push(''); - lines.push('| Package | Source Files | Test Files | Coverage | Status |'); - lines.push('|---------|-------------|------------|----------|--------|'); - - for (const pkg of modules.go) { - const covData = goCoverage.packages?.[pkg.name]; - const cov = covData?.percentage ?? null; - const status = covData?.noTests ? 'No tests' : statusIcon(cov); - const failed = covData?.failed ? ' (FAILING)' : ''; - lines.push(`| \`${pkg.name}/\` | ${pkg.sourceFiles} | ${pkg.testFiles} | ${badge(cov)} | ${status}${failed} |`); - } - lines.push(''); - - // --- Client Coverage --- - lines.push('## Client (TypeScript) — Test Coverage'); - lines.push(''); - const tsAreas = vitestCoverage.areas || {}; - const totalCov = tsAreas._total; - if (totalCov) { - lines.push(`**Overall:** ${totalCov.statements.toFixed(1)}% statements, ${totalCov.branches.toFixed(1)}% branches, ${totalCov.functions.toFixed(1)}% functions`); - lines.push(''); - } - if (vitestCoverage.testCount) { - lines.push(`**Total tests:** ${vitestCoverage.testCount}`); - lines.push(''); - } - - lines.push('| Area | Source Files | Coverage (stmts) | Status |'); - lines.push('|------|-------------|------------------|--------|'); - for (const dir of modules.typescript.filter(d => d.type === 'typescript')) { - const areaCov = tsAreas[dir.name]; - const cov = areaCov?.statements ?? null; - lines.push(`| \`${dir.name}/\` | ${dir.sourceFiles} | ${badge(cov)} | ${statusIcon(cov)} |`); - } - lines.push(''); - - // Test file counts - lines.push('| Test Suite | Files |'); - lines.push('|-----------|-------|'); - for (const dir of modules.typescript.filter(d => d.type !== 'typescript')) { - lines.push(`| \`${dir.name}\` | ${dir.testFiles} |`); - } - lines.push(''); - - // --- Rust --- - lines.push('## Client (Rust/Tauri) — Status'); - lines.push(''); - for (const rust of modules.rust) { - lines.push(`| Metric | Value |`); - lines.push(`|--------|-------|`); - lines.push(`| Source files | ${rust.sourceFiles} |`); - lines.push(`| Lines of code | ${rust.sourceLines} |`); - lines.push(`| Test files | ${rust.testFiles} |`); - lines.push(`| Coverage | No test infrastructure |`); - } - lines.push(''); - - // --- Backlog by Phase --- - lines.push('## Open Work — By Phase'); - lines.push(''); - const phaseOrder = ['bug', 'high', 'code-review', 'medium', 'deferred', 'roadmap-r1', 'roadmap-r2', 'roadmap-r3', 'roadmap-r4', 'roadmap-r5', 'roadmap-r6', 'other']; - const phaseLabels = { - 'bug': 'Bugs', 'high': 'High Priority', 'code-review': 'Code Review', - 'medium': 'Medium Priority', 'deferred': 'Deferred Features', - 'roadmap-r1': 'R1: Community Essentials', 'roadmap-r2': 'R2: Gaming DNA', - 'roadmap-r3': 'R3: Voice Power', 'roadmap-r4': 'R4: LAN Party Toolkit', - 'roadmap-r5': 'R5: Platform & Extensibility', 'roadmap-r6': 'R6: Future Vision', - 'other': 'Other', - }; - - for (const phase of phaseOrder) { - const tasks = backlog.byPhase[phase]; - if (!tasks || tasks.length === 0) continue; - lines.push(`### ${phaseLabels[phase] || phase} (${tasks.length})`); - lines.push(''); - for (const task of tasks) { - lines.push(`- **${task.id}:** ${task.description}`); - } - lines.push(''); - } - - // --- Where to Work Next --- - lines.push('## Where to Work Next'); - lines.push(''); - lines.push('Ranked by priority score (coverage gap + module size + open tasks):'); - lines.push(''); - lines.push('| # | Module | Type | Score | Coverage | Open Tasks | Recommendation |'); - lines.push('|---|--------|------|-------|----------|------------|----------------|'); - - const top = priorities.slice(0, 15); - top.forEach((p, i) => { - const cov = p.coverage !== null ? `${p.coverage.toFixed(1)}%` : '---'; - lines.push(`| ${i + 1} | \`${p.name}\` | ${p.type} | ${p.totalScore.toFixed(0)} | ${cov} | ${p.openTaskCount} | ${p.recommendation} |`); - }); - lines.push(''); - - // --- Research --- - lines.push('## Research'); - lines.push(''); - lines.push('Use `node tools/project-map/index.mjs --research` to launch a Claude Code agent'); - lines.push('that investigates a specific area and saves findings to `docs/brain/00-Overview/Research/`.'); - lines.push(''); - - // --- Staleness --- - lines.push('---'); - lines.push(`*Last generated: ${now}*`); - lines.push(''); - - writeFileSync(reportPath, lines.join('\n'), 'utf8'); - console.log(`\n Report written to: ${reportPath}`); -} diff --git a/tools/project-map/lib/research-agent.mjs b/tools/project-map/lib/research-agent.mjs deleted file mode 100644 index 9c254237..00000000 --- a/tools/project-map/lib/research-agent.mjs +++ /dev/null @@ -1,223 +0,0 @@ -/** - * Research agent launcher. - * Spawns a Claude Code subprocess to investigate a specific area - * and saves findings to docs/brain/00-Overview/Research/. - */ -import { execFileSync, spawn } from 'node:child_process'; -import { existsSync, mkdirSync, writeFileSync, readFileSync } from 'node:fs'; -import { resolve } from 'node:path'; -import { createInterface } from 'node:readline'; - -const RESEARCH_DIR = 'docs/brain/00-Overview/Research'; - -const RESEARCH_AREAS = { - 'server-api': { - label: 'Server API package', - description: 'Analyze Server/api/ for coverage gaps, missing error handling, untested endpoints', - scope: 'Server/api/', - }, - 'server-ws': { - label: 'Server WebSocket package', - description: 'Analyze Server/ws/ for coverage gaps, race conditions, edge cases in voice/chat handlers', - scope: 'Server/ws/', - }, - 'server-auth': { - label: 'Server auth package', - description: 'Analyze Server/auth/ for security gaps, missing test cases, TOTP edge cases', - scope: 'Server/auth/', - }, - 'server-db': { - label: 'Server database package', - description: 'Analyze Server/db/ for missing indexes, query performance, untested queries', - scope: 'Server/db/', - }, - 'server-admin': { - label: 'Server admin package', - description: 'Analyze Server/admin/ for test coverage issues, build-tag gating, missing functionality', - scope: 'Server/admin/', - }, - 'client-livekit': { - label: 'Client LiveKit session', - description: 'Analyze livekitSession.ts and related audio/video code for coverage gaps and edge cases', - scope: 'Client/tauri-client/src/lib/livekitSession.ts', - }, - 'client-stores': { - label: 'Client stores', - description: 'Analyze reactive stores for state management edge cases, race conditions, memory leaks', - scope: 'Client/tauri-client/src/stores/', - }, - 'rust-backend': { - label: 'Tauri Rust backend', - description: 'Analyze Rust backend for missing tests, security gaps in proxy/credential code', - scope: 'Client/tauri-client/src-tauri/src/', - }, - 'e2e-coverage': { - label: 'E2E test coverage', - description: 'Analyze E2E test suite for missing critical user flows, flaky tests, gaps', - scope: 'Client/tauri-client/tests/e2e/', - }, - 'security': { - label: 'Security audit', - description: 'Review codebase for OWASP Top 10 vulnerabilities, auth bypass, injection, XSS', - scope: 'Server/ Client/tauri-client/src/', - }, - 'protocol': { - label: 'Protocol compliance', - description: 'Check server and client code against docs/brain/06-Specs/PROTOCOL.md for drift', - scope: 'Server/ws/ Client/tauri-client/src/lib/dispatcher.ts', - }, -}; - -function buildPrompt(areaKey, area, root) { - const date = new Date().toISOString().slice(0, 10); - const outputFile = `${RESEARCH_DIR}/${areaKey}-${date}.md`; - - return `You are a research agent investigating the OwnCord project. - -## Task -${area.description} - -## Scope -Focus on: ${area.scope} - -## Instructions -1. Read the relevant source files and test files -2. Identify: - - Coverage gaps (functions/branches not tested) - - Potential bugs or edge cases - - Security concerns - - Code quality issues - - Missing functionality vs specs -3. For each finding, note the file path and line number -4. Prioritize findings as CRITICAL, HIGH, MEDIUM, or LOW - -## Output -Write your findings to: ${outputFile} - -Use this format: ---- -# Research: ${area.label} -Date: ${date} -Scope: ${area.scope} - -## Summary -[2-3 sentence overview] - -## Findings - -### CRITICAL -- [finding with file:line reference] - -### HIGH -- [finding with file:line reference] - -### MEDIUM -- [finding with file:line reference] - -### LOW -- [finding with file:line reference] - -## Recommendations -[Prioritized list of what to fix/improve next] ---- - -After writing the file, print a brief summary of what you found.`; -} - -async function promptUser(question) { - const rl = createInterface({ input: process.stdin, output: process.stdout }); - return new Promise(resolve => { - rl.question(question, answer => { - rl.close(); - resolve(answer.trim()); - }); - }); -} - -export async function launchResearchAgent(root) { - console.log('\n Research Agent Launcher\n'); - console.log(' Available research areas:\n'); - - const keys = Object.keys(RESEARCH_AREAS); - keys.forEach((key, i) => { - const area = RESEARCH_AREAS[key]; - console.log(` ${i + 1}. ${area.label} — ${area.description.slice(0, 70)}...`); - }); - - console.log(`\n ${keys.length + 1}. Custom (enter your own research prompt)`); - - const choice = await promptUser('\n Enter number (or "q" to quit): '); - - if (choice === 'q' || choice === '') { - console.log(' Cancelled.'); - return; - } - - const num = parseInt(choice, 10); - if (isNaN(num) || num < 1 || num > keys.length + 1) { - console.log(' Invalid choice.'); - return; - } - - // Ensure research directory exists - const researchDir = resolve(root, RESEARCH_DIR); - mkdirSync(researchDir, { recursive: true }); - - let prompt; - let areaKey; - - if (num <= keys.length) { - areaKey = keys[num - 1]; - const area = RESEARCH_AREAS[areaKey]; - prompt = buildPrompt(areaKey, area, root); - console.log(`\n Launching research on: ${area.label}`); - } else { - const customPrompt = await promptUser(' Enter your research prompt: '); - if (!customPrompt) { - console.log(' Cancelled.'); - return; - } - areaKey = 'custom'; - prompt = customPrompt; - console.log(`\n Launching custom research...`); - } - - // Save the prompt for reference - const date = new Date().toISOString().slice(0, 10); - const promptFile = resolve(researchDir, `${areaKey}-${date}-prompt.txt`); - // Boundary check: ensure promptFile stays within researchDir - if (!promptFile.startsWith(resolve(researchDir))) { - console.error(' Error: prompt file path escapes research directory'); - return; - } - writeFileSync(promptFile, prompt, 'utf8'); - - console.log(` Prompt saved to: ${promptFile}`); - console.log(`\n To run the research agent, execute:\n`); - console.log(` claude --print "${promptFile.replace(/\\/g, '/')}"`); - console.log(`\n Or copy the prompt and paste it into a Claude Code session.`); - console.log(` The agent will save findings to: ${RESEARCH_DIR}/${areaKey}-${date}.md\n`); - - // Try to launch claude directly if available - try { - execFileSync('claude', ['--version'], { stdio: 'pipe' }); - const launch = await promptUser(' Claude CLI detected. Launch now? (y/n): '); - if (launch.toLowerCase() === 'y') { - console.log('\n Spawning Claude Code agent...\n'); - // Use prompt file instead of inline prompt to avoid shell injection - const child = spawn('claude', ['--print', promptFile], { - cwd: root, - stdio: 'inherit', - shell: false, - }); - child.on('close', (code) => { - console.log(`\n Research agent exited with code ${code}`); - console.log(` Check ${RESEARCH_DIR}/${areaKey}-${date}.md for findings.\n`); - }); - // Wait for completion - await new Promise(resolve => child.on('close', resolve)); - } - } catch { - // Claude CLI not available — just show instructions - } -} diff --git a/tools/project-map/lib/scanner.mjs b/tools/project-map/lib/scanner.mjs deleted file mode 100644 index a5618253..00000000 --- a/tools/project-map/lib/scanner.mjs +++ /dev/null @@ -1,198 +0,0 @@ -/** - * Module scanner — discovers Go packages, TypeScript directories, and Rust files. - * Returns a structured inventory of the project. - */ -import { readdirSync, readFileSync, existsSync } from 'node:fs'; -import { resolve, join } from 'node:path'; - -function scanDir(dir, extensions, recursive = true) { - let fileCount = 0; - let lineCount = 0; - - function walk(d) { - if (!existsSync(d)) return; - let entries; - try { entries = readdirSync(d, { withFileTypes: true }); } catch { return; } - for (const entry of entries) { - if (entry.name.startsWith('.') || entry.name === 'node_modules' || entry.name === 'vendor' || entry.name === 'dist' || entry.name === 'target') continue; - const full = join(d, entry.name); - if (entry.isDirectory() && recursive) { - walk(full); - } else if (entry.isFile() && extensions.some(ext => entry.name.endsWith(ext))) { - fileCount++; - try { - lineCount += readFileSync(full, 'utf8').split('\n').length; - } catch { /* skip */ } - } - } - } - walk(dir); - return { fileCount, lineCount }; -} - -function scanGoDir(dir, name, pathPrefix) { - const source = scanDir(dir, ['.go'], false); - const testSource = { fileCount: 0, lineCount: 0 }; - - try { - for (const f of readdirSync(dir)) { - if (f.endsWith('_test.go')) { - testSource.fileCount++; - try { - testSource.lineCount += readFileSync(join(dir, f), 'utf8').split('\n').length; - } catch { /* skip */ } - } - } - } catch { /* skip */ } - - const srcFiles = source.fileCount - testSource.fileCount; - const srcLines = source.lineCount - testSource.lineCount; - - if (srcFiles > 0 || testSource.fileCount > 0) { - return { - name, - type: 'go', - path: pathPrefix, - sourceFiles: srcFiles, - sourceLines: srcLines, - testFiles: testSource.fileCount, - testLines: testSource.lineCount, - }; - } - return null; -} - -function scanGoPackages(serverDir) { - const packages = []; - if (!existsSync(serverDir)) return packages; - - // Scan root-level Go files (main.go, etc.) - const rootPkg = scanGoDir(serverDir, 'server-root', 'Server'); - if (rootPkg) packages.push(rootPkg); - - // Scan subdirectory packages - for (const entry of readdirSync(serverDir, { withFileTypes: true })) { - if (!entry.isDirectory() || entry.name.startsWith('.') || entry.name === 'vendor') continue; - const pkg = scanGoDir(join(serverDir, entry.name), entry.name, `Server/${entry.name}`); - if (pkg) packages.push(pkg); - } - return packages; -} - -function scanTSDirectories(clientSrcDir) { - const dirs = []; - if (!existsSync(clientSrcDir)) return dirs; - - const scanAreas = ['lib', 'stores', 'components', 'pages', 'styles']; - for (const area of scanAreas) { - const areaDir = join(clientSrcDir, area); - if (!existsSync(areaDir)) continue; - const source = scanDir(areaDir, ['.ts', '.tsx', '.js', '.jsx'], true); - dirs.push({ - name: area, - type: 'typescript', - path: `Client/tauri-client/src/${area}`, - sourceFiles: source.fileCount, - sourceLines: source.lineCount, - testFiles: 0, // tests are in a separate dir - testLines: 0, - }); - } - - // Count test files - const testDir = resolve(clientSrcDir, '../tests'); - if (existsSync(testDir)) { - for (const subdir of ['unit', 'integration']) { - const td = join(testDir, subdir); - if (!existsSync(td)) continue; - const tests = scanDir(td, ['.ts', '.tsx', '.test.ts', '.test.tsx', '.spec.ts'], true); - dirs.push({ - name: `tests/${subdir}`, - type: 'typescript-tests', - path: `Client/tauri-client/tests/${subdir}`, - sourceFiles: 0, - sourceLines: 0, - testFiles: tests.fileCount, - testLines: tests.lineCount, - }); - } - - // E2E tests - const e2eDir = join(testDir, 'e2e'); - if (existsSync(e2eDir)) { - const e2e = scanDir(e2eDir, ['.ts', '.spec.ts'], true); - dirs.push({ - name: 'tests/e2e', - type: 'e2e-tests', - path: `Client/tauri-client/tests/e2e`, - sourceFiles: 0, - sourceLines: 0, - testFiles: e2e.fileCount, - testLines: e2e.lineCount, - }); - } - } - - return dirs; -} - -function scanRustFiles(rustDir) { - if (!existsSync(rustDir)) return []; - const source = scanDir(rustDir, ['.rs'], true); - const testCount = (() => { - let count = 0; - function walk(d) { - try { - for (const entry of readdirSync(d, { withFileTypes: true })) { - const full = join(d, entry.name); - if (entry.isDirectory()) walk(full); - else if (entry.name.endsWith('_test.rs') || entry.name === 'tests.rs') count++; - else if (entry.name.endsWith('.rs')) { - // Check for #[cfg(test)] inside the file - try { - const content = readFileSync(full, 'utf8'); - if (content.includes('#[cfg(test)]')) count++; - } catch { /* skip */ } - } - } - } catch { /* skip */ } - } - walk(rustDir); - return count; - })(); - - return [{ - name: 'tauri-rust', - type: 'rust', - path: 'Client/tauri-client/src-tauri/src', - sourceFiles: source.fileCount, - sourceLines: source.lineCount, - testFiles: testCount, - testLines: 0, - }]; -} - -export async function scanModules(root) { - const serverDir = resolve(root, 'Server'); - const clientSrcDir = resolve(root, 'Client/tauri-client/src'); - const rustDir = resolve(root, 'Client/tauri-client/src-tauri/src'); - - const goPackages = scanGoPackages(serverDir); - const tsDirectories = scanTSDirectories(clientSrcDir); - const rustFiles = scanRustFiles(rustDir); - - return { - go: goPackages, - typescript: tsDirectories, - rust: rustFiles, - summary: { - goPackages: goPackages.length, - goSourceFiles: goPackages.reduce((s, p) => s + p.sourceFiles, 0), - goTestFiles: goPackages.reduce((s, p) => s + p.testFiles, 0), - tsSourceFiles: tsDirectories.filter(d => d.type === 'typescript').reduce((s, d) => s + d.sourceFiles, 0), - tsTestFiles: tsDirectories.filter(d => d.type !== 'typescript').reduce((s, d) => s + d.testFiles, 0), - rustSourceFiles: rustFiles.reduce((s, r) => s + r.sourceFiles, 0), - rustTestFiles: rustFiles.reduce((s, r) => s + r.testFiles, 0), - }, - }; -} diff --git a/tools/project-map/lib/session-manager.mjs b/tools/project-map/lib/session-manager.mjs deleted file mode 100644 index d3b40b92..00000000 --- a/tools/project-map/lib/session-manager.mjs +++ /dev/null @@ -1,607 +0,0 @@ -/** - * Session manager — consolidates session planning, tracking, and vault writing. - * - * Manages the full session lifecycle: plan → start → poll → end, with - * automatic vault integration (session logs, task file updates). - * - * Zero external dependencies — Node built-ins only. - */ -import { execFileSync } from 'node:child_process'; -import { readFileSync, writeFileSync, existsSync, mkdirSync, renameSync, unlinkSync, readdirSync } from 'node:fs'; -import { resolve, join } from 'node:path'; - -import { generateSuggestions } from './suggestion-engine.mjs'; -import { parseSessionHistory } from './session-parser.mjs'; -import { parseBacklog } from './backlog-parser.mjs'; - -// --------------------------------------------------------------------------- -// Constants -// --------------------------------------------------------------------------- - -const SESSION_STATE_FILE = 'session-state.json'; -const STALE_THRESHOLD_MS = 30 * 60 * 1000; // 30 minutes -const TASK_ID_REGEX = /\b(?:fix|close|resolve|implement|complete)\s+T-(\d+)/gi; -const SHA_RE = /^[0-9a-f]{40}$/i; - -function validateSha(sha) { - if (!SHA_RE.test(sha)) throw new Error(`Invalid git SHA: "${sha}"`); - return sha; -} - -// --------------------------------------------------------------------------- -// Git helpers -// --------------------------------------------------------------------------- - -function git(args, root) { - return execFileSync('git', args, { - cwd: root, - encoding: 'utf8', - maxBuffer: 10 * 1024 * 1024, - stdio: ['pipe', 'pipe', 'pipe'], - }).trim(); -} - -function getHeadSha(root) { - return git(['rev-parse', 'HEAD'], root); -} - -// --------------------------------------------------------------------------- -// File path to module mapping (mirrors git-scanner.mjs logic) -// --------------------------------------------------------------------------- - -function fileToModule(filePath) { - const normalized = filePath.replace(/\\/g, '/'); - - if (normalized.startsWith('Server/')) { - const parts = normalized.split('/'); - // parts[1] is a subdirectory only when there are 3+ segments - // e.g. Server/ws/handler.go → 'ws', Server/main.go → 'server-root' - return parts.length >= 3 ? parts[1] : 'server-root'; - } - - if (normalized.startsWith('Client/tauri-client/src-tauri/src/')) { - return 'tauri-rust'; - } - - if (normalized.startsWith('Client/tauri-client/src/')) { - const area = normalized.replace('Client/tauri-client/src/', '').split('/')[0]; - return area || 'client-root'; - } - - if (normalized.startsWith('Client/tauri-client/')) { - return 'client-config'; - } - - return 'root'; -} - -// --------------------------------------------------------------------------- -// Session state I/O -// --------------------------------------------------------------------------- - -function stateFilePath(cacheDir) { - return resolve(cacheDir, SESSION_STATE_FILE); -} - -function readSessionState(cacheDir) { - const fp = stateFilePath(cacheDir); - if (!existsSync(fp)) return null; - - try { - return JSON.parse(readFileSync(fp, 'utf8')); - } catch { - console.warn(' [Session] Corrupt session-state.json — renaming and treating as inactive'); - try { - const corruptPath = `${fp}.corrupt.${Date.now()}`; - renameSync(fp, corruptPath); - } catch { /* best effort */ } - return null; - } -} - -function writeSessionState(cacheDir, state) { - if (!existsSync(cacheDir)) { - mkdirSync(cacheDir, { recursive: true }); - } - writeFileSync(stateFilePath(cacheDir), JSON.stringify(state, null, 2)); -} - -// --------------------------------------------------------------------------- -// Safe vault write — temp file → validate → atomic rename → rollback copy -// --------------------------------------------------------------------------- - -function safeWriteFile(targetPath, content) { - const dir = resolve(targetPath, '..'); - if (!existsSync(dir)) { - mkdirSync(dir, { recursive: true }); - } - - const tmpPath = targetPath + '.tmp'; - const backupPath = targetPath + '.bak'; - - // Write to temp file - writeFileSync(tmpPath, content, 'utf8'); - - // Validate: temp file content must match what was written - const written = readFileSync(tmpPath, 'utf8'); - if (written !== content) { - unlinkSync(tmpPath); - throw new Error(`Safe write validation failed: content mismatch at ${tmpPath}`); - } - - // Keep one rollback copy of existing file - if (existsSync(targetPath)) { - try { - if (existsSync(backupPath)) unlinkSync(backupPath); - renameSync(targetPath, backupPath); - } catch { - // Non-fatal — proceed without backup - } - } - - // Atomic rename - renameSync(tmpPath, targetPath); -} - -// --------------------------------------------------------------------------- -// Vault helpers -// --------------------------------------------------------------------------- - -function readVaultFile(filePath) { - try { - if (!existsSync(filePath)) return null; - return readFileSync(filePath, 'utf8'); - } catch (err) { - console.warn(` [Session] Failed to read vault file ${filePath}: ${err.message}`); - return null; - } -} - -function formatDuration(startIso) { - const ms = Date.now() - new Date(startIso).getTime(); - const minutes = Math.floor(ms / 60000); - const hours = Math.floor(minutes / 60); - const mins = minutes % 60; - if (hours > 0) return `${hours}h ${mins}m`; - return `${mins}m`; -} - -function generateSessionLogPath(root, summary) { - const sessionsDir = resolve(root, 'docs/brain/03-Sessions'); - const today = new Date().toISOString().slice(0, 10); - const slug = summary - .toLowerCase() - .replace(/[^a-z0-9]+/g, '-') - .replace(/^-|-$/g, '') - .slice(0, 40); - - let baseName = `${today}-${slug || 'session'}`; - let filePath = join(sessionsDir, `${baseName}.md`); - let counter = 1; - - while (existsSync(filePath)) { - filePath = join(sessionsDir, `${baseName}-${counter}.md`); - counter++; - } - - return filePath; -} - -function buildSessionLogContent(state, autoMarker = '') { - const today = new Date().toISOString().slice(0, 10); - const tasksCompleted = state.commits - .flatMap(c => extractTaskIds(c)) - .filter((id, i, arr) => arr.indexOf(id) === i); - - const modulesStr = state.modulesTouched.length > 0 - ? state.modulesTouched.join(', ') - : 'none'; - - const commitsList = state.commits.length > 0 - ? state.commits.map(c => `- ${c}`).join('\n') - : '- (no commits)'; - - const filesList = state.filesChanged.length > 0 - ? state.filesChanged.slice(0, 20).map(f => `- ${f}`).join('\n') - : '- (no files changed)'; - - const tasksTable = tasksCompleted.length > 0 - ? tasksCompleted.map(id => `| ${id} | completed | Done |`).join('\n') - : '| — | — | — |'; - - const marker = autoMarker ? ` ${autoMarker}` : ''; - - return `--- -date: ${today} -summary: "Session${marker} — ${state.modulesTouched.slice(0, 3).join(', ') || 'general'}" -tasks-completed: ${tasksCompleted.length} ---- - -# Session — ${today}${marker} - -## Goal - -*Auto-generated session log from session manager* - -## What Was Done - -### Commits -${commitsList} - -### Files Changed (${state.filesChanged.length} total) -${filesList}${state.filesChanged.length > 20 ? `\n- ...and ${state.filesChanged.length - 20} more` : ''} - -### Modules Touched -${modulesStr} - -## Decisions Made - -- *See commit messages for details* - -## Blockers / Issues - -- - -## Next Steps - -- - -## Tasks Touched - -| Task | Action | Status | -| ---- | ------ | ------ | -${tasksTable} -`; -} - -// --------------------------------------------------------------------------- -// Task ID extraction from commit messages -// --------------------------------------------------------------------------- - -function extractTaskIds(commitMessage) { - const ids = []; - let match; - const regex = new RegExp(TASK_ID_REGEX.source, TASK_ID_REGEX.flags); - while ((match = regex.exec(commitMessage)) !== null) { - ids.push(`T-${match[1]}`); - } - return ids; -} - -// --------------------------------------------------------------------------- -// Vault task file updates -// --------------------------------------------------------------------------- - -function updateVaultTasks(root, completedTaskIds, preloadedInProgressContent) { - const inProgressPath = resolve(root, 'docs/brain/02-Tasks/In Progress.md'); - const donePath = resolve(root, 'docs/brain/02-Tasks/Done.md'); - const today = new Date().toISOString().slice(0, 10); - const updated = []; - - const inProgressContent = preloadedInProgressContent ?? readVaultFile(inProgressPath); - const doneContent = readVaultFile(donePath); - - if (!inProgressContent || !doneContent) { - console.warn(' [Session] Cannot update vault tasks — file read failed'); - return updated; - } - - const idSet = new Set(completedTaskIds); - const movedTasks = []; - const remainingLines = []; - - // Parse In Progress.md, extract completed tasks - for (const line of inProgressContent.split('\n')) { - const taskMatch = line.match(/^- \[ \] \*\*T-(\d+):\*\*\s*(.+)/); - if (taskMatch && idSet.has(`T-${taskMatch[1]}`)) { - movedTasks.push({ - id: `T-${taskMatch[1]}`, - description: taskMatch[2].trim(), - }); - } else { - remainingLines.push(line); - } - } - - if (movedTasks.length === 0) return updated; - - // Write updated In Progress.md - safeWriteFile(inProgressPath, remainingLines.join('\n')); - - // Append to Done.md - const doneEntries = movedTasks - .map(t => `- [x] **${t.id}:** ${t.description} — completed ${today}`) - .join('\n'); - - const sectionHeader = `\n## Session (${today})\n\n`; - const insertionPoint = doneContent.indexOf('\n## '); - let newDoneContent; - - if (insertionPoint !== -1) { - // Insert after the main heading, before the first section - newDoneContent = doneContent.slice(0, insertionPoint) + - sectionHeader + doneEntries + '\n' + - doneContent.slice(insertionPoint); - } else { - newDoneContent = doneContent + sectionHeader + doneEntries + '\n'; - } - - safeWriteFile(donePath, newDoneContent); - - for (const t of movedTasks) { - updated.push(t.id); - } - - return updated; -} - -// Exported for testing -export { fileToModule }; - -// --------------------------------------------------------------------------- -// Exported functions -// --------------------------------------------------------------------------- - -/** - * Generate a session plan combining suggestions, last session, and backlog. - */ -export async function generatePlan(root, cacheDir) { - // Gather data from existing engines - const [sessionData, backlog] = await Promise.all([ - parseSessionHistory(root, cacheDir, true), - parseBacklog(root), - ]); - - const suggestions = await generateSuggestions(cacheDir, { - backlog, - sessionData, - }); - - // Build greeting - const last = sessionData.lastSession; - const greeting = last.date - ? `Welcome back! Last session: ${last.date} — ${last.summary || 'no summary'}` - : 'Welcome! This appears to be the first session.'; - - // Map suggestions to plan tasks - const tasks = suggestions.suggestions.map((s, i) => { - const topSignals = s.signals || []; - const relatedFiles = topSignals - .filter(sig => sig.signal === 'open-bug' || sig.signal === 'open-task') - .map(sig => sig.value) - .slice(0, 5); - - const score = s.score || 0; - let estimatedFocus = 'small'; - if (score > 100) estimatedFocus = 'large'; - else if (score > 40) estimatedFocus = 'medium'; - - return { - priority: Math.min(i + 1, 5), - title: `Focus on ${s.module}`, - rationale: s.rationale, - estimatedFocus, - relatedFiles, - module: s.module, - }; - }); - - // In-progress tasks - const inProgress = sessionData.inProgress.map(t => ({ - id: t.id, - description: t.description, - })); - - return { - greeting, - tasks, - lastSession: { - date: last.date, - summary: last.summary, - tasksCompleted: last.tasksCompleted, - }, - inProgress, - }; -} - -/** - * Start a new session. Records HEAD SHA and creates session state. - * Rejects if a session is already active. - */ -export function startSession(root, cacheDir) { - const existing = readSessionState(cacheDir); - if (existing && existing.active) { - throw new Error( - 'Session already active (started at ' + existing.startedAt + '). ' + - 'End or recover it before starting a new one.' - ); - } - - const baselineSha = getHeadSha(root); - const state = { - active: true, - startedAt: new Date().toISOString(), - baselineSha, - filesChanged: [], - commits: [], - modulesTouched: [], - }; - - writeSessionState(cacheDir, state); - return { ...state }; -} - -/** - * Get the current session status with live diff stats. - */ -export function getSessionStatus(cacheDir) { - const state = readSessionState(cacheDir); - if (!state || !state.active) { - return { active: false }; - } - - return { ...state }; -} - -/** - * Poll for changes since session baseline. Updates session state with - * current file changes, commits, and modules touched. - */ -export function pollChanges(root, cacheDir) { - const state = readSessionState(cacheDir); - if (!state || !state.active) { - return { active: false }; - } - - let filesChanged = []; - let newCommits = []; - - try { - const diffOutput = git(['diff', '--name-only', validateSha(state.baselineSha)], root); - filesChanged = diffOutput ? diffOutput.split('\n').filter(Boolean) : []; - } catch (err) { - throw new Error(`Failed to get git diff: ${err.message}`); - } - - try { - const logOutput = git(['log', '--oneline', `${validateSha(state.baselineSha)}..HEAD`], root); - newCommits = logOutput ? logOutput.split('\n').filter(Boolean) : []; - } catch (err) { - throw new Error(`Failed to get git log: ${err.message}`); - } - - // Derive modules from changed files - const moduleSet = new Set(); - for (const file of filesChanged) { - moduleSet.add(fileToModule(file)); - } - - const updatedState = { - ...state, - filesChanged, - commits: newCommits, - modulesTouched: [...moduleSet], - lastHeartbeat: new Date().toISOString(), - }; - - writeSessionState(cacheDir, updatedState); - return { ...updatedState }; -} - -/** - * End the current session. Generates a vault session log, updates task - * files, and clears session state. - */ -export function endSession(root, cacheDir) { - const state = readSessionState(cacheDir); - if (!state || !state.active) { - throw new Error('No active session to end.'); - } - - // Final poll to capture latest changes - const finalState = pollChanges(root, cacheDir); - - // Generate session log - const logContent = buildSessionLogContent(finalState); - const logPath = generateSessionLogPath( - root, - finalState.modulesTouched.slice(0, 3).join('-') || 'session' - ); - - safeWriteFile(logPath, logContent); - - // Extract task IDs from commit messages and update vault - const allTaskIds = finalState.commits - .flatMap(c => extractTaskIds(c)) - .filter((id, i, arr) => arr.indexOf(id) === i); - - let tasksUpdated = []; - if (allTaskIds.length > 0) { - // Verify task IDs exist in vault before updating - const inProgressContent = readVaultFile( - resolve(root, 'docs/brain/02-Tasks/In Progress.md') - ); - if (inProgressContent) { - const knownIds = allTaskIds.filter(id => inProgressContent.includes(id)); - const unknownIds = allTaskIds.filter(id => !inProgressContent.includes(id)); - - for (const uid of unknownIds) { - console.warn(` [Session] Task ${uid} not found in In Progress — skipping`); - } - - if (knownIds.length > 0) { - tasksUpdated = updateVaultTasks(root, knownIds, inProgressContent); - } - } - } - - // Clear session state - const closedState = { - active: false, - closedAt: new Date().toISOString(), - startedAt: finalState.startedAt, - baselineSha: finalState.baselineSha, - }; - writeSessionState(cacheDir, closedState); - - return { - sessionLog: logPath, - tasksUpdated, - filesChanged: finalState.filesChanged.length, - duration: formatDuration(finalState.startedAt), - }; -} - -/** - * Recover a stale session on dashboard startup. - * If session-state.json shows active=true but last git activity was - * >30 minutes ago, auto-close it with a partial log. - */ -export function recoverStaleSession(root, cacheDir) { - const state = readSessionState(cacheDir); - if (!state || !state.active) { - return { recovered: false }; - } - - // Use session-specific timestamps: lastHeartbeat > startedAt - // Do NOT use repo-wide git log — that reflects any commit, not this session's activity - const lastActivityTime = new Date(state.lastHeartbeat || state.startedAt).getTime(); - - const elapsed = Date.now() - lastActivityTime; - if (elapsed < STALE_THRESHOLD_MS) { - return { recovered: false }; - } - - // Auto-close the stale session - let finalState; - try { - finalState = pollChanges(root, cacheDir); - } catch { - // If poll fails, use whatever state we have - finalState = { ...state }; - } - - const logContent = buildSessionLogContent(finalState, '[auto-closed]'); - const logPath = generateSessionLogPath(root, 'auto-closed'); - - try { - safeWriteFile(logPath, logContent); - } catch (err) { - console.warn(` [Session] Failed to write auto-close log: ${err.message}`); - } - - // Clear session state - const closedState = { - active: false, - closedAt: new Date().toISOString(), - startedAt: state.startedAt, - baselineSha: state.baselineSha, - autoRecovered: true, - }; - writeSessionState(cacheDir, closedState); - - return { - recovered: true, - log: logPath, - }; -} diff --git a/tools/project-map/lib/session-parser.mjs b/tools/project-map/lib/session-parser.mjs deleted file mode 100644 index 417db167..00000000 --- a/tools/project-map/lib/session-parser.mjs +++ /dev/null @@ -1,357 +0,0 @@ -/** - * Session parser — reads session log files from docs/brain/03-Sessions/, - * builds a timeline, tracks progress over time, calculates streaks, - * and extracts last-session and task-status info. - */ -import { readFileSync, readdirSync, existsSync, mkdirSync, writeFileSync } from 'node:fs'; -import { resolve, join } from 'node:path'; - -// --------------------------------------------------------------------------- -// Module keyword map — maps session content to touched modules -// --------------------------------------------------------------------------- - -const MODULE_KEYWORDS = { - 'admin': ['admin panel', 'admin api', 'admin'], - 'api': ['REST', 'endpoint', 'handler', 'middleware', 'api'], - 'auth': ['auth', '2FA', 'TOTP', 'login', 'register', 'password', 'session', 'token'], - 'db': ['database', 'SQLite', 'migration', 'schema', 'query', 'db'], - 'ws': ['websocket', 'hub', 'broadcast', 'ringbuffer', 'reconnect'], - 'voice': ['voice', 'audio', 'LiveKit', 'livekit', 'WebRTC', 'SFU', 'RTP', 'VAD'], - 'permissions': ['permission', 'role', 'RBAC'], - 'storage': ['upload', 'file storage', 'attachment'], - 'config': ['config', 'settings'], - 'lib': ['livekitSession', 'audioPipeline', 'dispatcher', 'tenor', 'ptt', 'notification', 'theme'], - 'stores': ['store', 'state management'], - 'components': ['component', 'sidebar', 'overlay', 'widget', 'picker', 'modal'], - 'pages': ['ConnectPage', 'MainPage', 'ChatArea', 'SidebarArea'], - 'tauri-rust': ['Rust', 'Tauri', 'tray', 'hotkey', 'credential', 'proxy', 'updater'], - 'protocol': ['protocol', 'message type', 'payload'], - 'e2e': ['E2E', 'Playwright', 'end-to-end'], - 'tests': ['test', 'coverage', 'vitest', 'unit test', 'integration test'], - 'docs': ['documentation', 'README', 'CLAUDE.md', 'spec', 'docs'], - 'security': ['security', 'XSS', 'CSRF', 'injection', 'audit', 'vulnerability'], - 'ci': ['CI', 'GitHub Actions', 'lint', 'golangci'], -}; - -// --------------------------------------------------------------------------- -// Frontmatter parser -// --------------------------------------------------------------------------- - -/** - * Parse YAML-like frontmatter delimited by --- lines. - * Returns { meta: { key: value }, body: string }. - */ -function parseFrontmatter(content) { - const lines = content.split('\n').map(l => l.replace(/\r$/, '')); - if (lines[0].trim() !== '---') { - return { meta: {}, body: content }; - } - - const meta = {}; - let endIdx = -1; - - for (let i = 1; i < lines.length; i++) { - if (lines[i].trim() === '---') { - endIdx = i; - break; - } - const colonIdx = lines[i].indexOf(':'); - if (colonIdx > 0) { - const key = lines[i].slice(0, colonIdx).trim(); - let val = lines[i].slice(colonIdx + 1).trim(); - // Strip surrounding quotes - if ((val.startsWith('"') && val.endsWith('"')) || - (val.startsWith("'") && val.endsWith("'"))) { - val = val.slice(1, -1); - } - meta[key] = val; - } - } - - if (endIdx === -1) { - return { meta: {}, body: content }; - } - - const body = lines.slice(endIdx + 1).join('\n'); - return { meta, body }; -} - -// --------------------------------------------------------------------------- -// Module detection from session body text -// --------------------------------------------------------------------------- - -function detectModules(body) { - const lower = body.toLowerCase(); - const found = []; - - for (const [mod, keywords] of Object.entries(MODULE_KEYWORDS)) { - for (const kw of keywords) { - if (lower.includes(kw.toLowerCase())) { - found.push(mod); - break; - } - } - } - - return found.length > 0 ? found : ['general']; -} - -// --------------------------------------------------------------------------- -// Task file parsers (Done.md, In Progress.md) -// --------------------------------------------------------------------------- - -function parseDoneFile(filePath) { - if (!existsSync(filePath)) return []; - - const content = readFileSync(filePath, 'utf8'); - const lines = content.split('\n').map(l => l.replace(/\r$/, '')); - const tasks = []; - let currentSection = ''; - - for (const line of lines) { - if (line.startsWith('## ')) { - currentSection = line.replace(/^##\s*/, ''); - } - - // Match both formats: - // - [x] **T-XXX:** description — completed YYYY-MM-DD - // - [x] **T-XXX**: description — YYYY-MM-DD - const match = line.match( - /^- \[x\] \*\*T-(\d+)[:\*]*\*?\*?\s*(.+?)(?:\s*—\s*(?:completed\s+)?(\d{4}-\d{2}-\d{2}))?$/ - ); - if (match) { - const id = `T-${match[1]}`; - const description = match[2] - .replace(/\s*—\s*(?:completed\s+)?\d{4}-\d{2}-\d{2}$/, '') - .trim(); - // Date from the line itself, or try to extract from section header - const date = match[3] || extractDateFromSection(currentSection) || ''; - tasks.push({ id, description, date }); - } - } - - return tasks; -} - -function extractDateFromSection(section) { - const match = section.match(/\((\d{4}-\d{2}-\d{2})\)/); - return match ? match[1] : null; -} - -function parseInProgressFile(filePath) { - if (!existsSync(filePath)) return []; - - const content = readFileSync(filePath, 'utf8'); - const lines = content.split('\n').map(l => l.replace(/\r$/, '')); - const tasks = []; - - for (const line of lines) { - // Match: - [ ] **T-XXX:** description - const match = line.match(/^- \[ \] \*\*T-(\d+):\*\*\s*(.+)/); - if (match) { - const id = `T-${match[1]}`; - const description = match[2].trim(); - tasks.push({ id, description }); - } - - // Also match lines without checkbox but with task ID - const altMatch = line.match(/^\*\*T-(\d+):\*\*\s*(.+)/); - if (!match && altMatch) { - const id = `T-${altMatch[1]}`; - const description = altMatch[2].trim(); - tasks.push({ id, description }); - } - } - - return tasks; -} - -// --------------------------------------------------------------------------- -// Streak calculation -// --------------------------------------------------------------------------- - -function calculateStreaks(sortedDates) { - if (sortedDates.length === 0) { - return { current: 0, longest: 0, totalSessions: 0 }; - } - - // Deduplicate dates (multiple sessions on same day count as 1) - const uniqueDates = [...new Set(sortedDates)].sort(); - const totalSessions = sortedDates.length; - - let longest = 1; - let currentStreak = 1; - let streakAtEnd = 1; - - for (let i = 1; i < uniqueDates.length; i++) { - const prev = new Date(uniqueDates[i - 1] + 'T00:00:00Z'); - const curr = new Date(uniqueDates[i] + 'T00:00:00Z'); - const diffMs = curr.getTime() - prev.getTime(); - const diffDays = Math.round(diffMs / (1000 * 60 * 60 * 24)); - - if (diffDays === 1) { - currentStreak++; - } else { - currentStreak = 1; - } - - if (currentStreak > longest) { - longest = currentStreak; - } - - streakAtEnd = currentStreak; - } - - // Check if the most recent session date is today or yesterday - // to determine if the current streak is still "active" - const lastDate = new Date(uniqueDates[uniqueDates.length - 1] + 'T00:00:00Z'); - const today = new Date(); - today.setUTCHours(0, 0, 0, 0); - const daysSinceLast = Math.round((today.getTime() - lastDate.getTime()) / (1000 * 60 * 60 * 24)); - - const current = daysSinceLast <= 1 ? streakAtEnd : 0; - - return { current, longest, totalSessions }; -} - -// --------------------------------------------------------------------------- -// Main export -// --------------------------------------------------------------------------- - -/** - * Parse all session logs and task files, returning a structured timeline. - * - * @param {string} root - Repository root directory - * @param {string} cacheDir - Directory to store cached results - * @param {boolean} quick - If true and cache exists, return cached data - * @returns {Promise} - Parsed session history - */ -export async function parseSessionHistory(root, cacheDir, quick) { - const cachePath = join(cacheDir, 'session-data.json'); - - // Quick mode: return cached data if available - if (quick && existsSync(cachePath)) { - try { - const cached = JSON.parse(readFileSync(cachePath, 'utf8')); - console.log(' [Sessions] Returning cached session data'); - return cached; - } catch { - // Cache corrupt — fall through to fresh parse - } - } - - const sessionsDir = resolve(root, 'docs/brain/03-Sessions'); - const donePath = resolve(root, 'docs/brain/02-Tasks/Done.md'); - const inProgressPath = resolve(root, 'docs/brain/02-Tasks/In Progress.md'); - - // ----------------------------------------------------------------------- - // 1. Parse all session files - // ----------------------------------------------------------------------- - - const sessions = []; - - if (existsSync(sessionsDir)) { - const files = readdirSync(sessionsDir) - .filter(f => f.endsWith('.md') && f !== 'index.md') - .sort(); - - for (const fileName of files) { - const filePath = join(sessionsDir, fileName); - const content = readFileSync(filePath, 'utf8'); - const { meta, body } = parseFrontmatter(content); - - const date = meta.date || fileName.slice(0, 10); - const summary = meta.summary || ''; - const tasksCompleted = parseInt(meta['tasks-completed'] || '0', 10); - const modulesTouched = detectModules(body); - - sessions.push({ - date, - summary, - tasksCompleted, - modulesTouched, - fileName, - }); - } - } - - // Sort by date ascending - sessions.sort((a, b) => a.date.localeCompare(b.date)); - - console.log(` [Sessions] Parsed ${sessions.length} session files`); - - // ----------------------------------------------------------------------- - // 2. Build progress over time (cumulative tasks completed) - // ----------------------------------------------------------------------- - - const progressOverTime = []; - let cumulative = 0; - - for (const session of sessions) { - cumulative += session.tasksCompleted; - progressOverTime.push({ - date: session.date, - cumulativeDone: cumulative, - sessionDone: session.tasksCompleted, - }); - } - - // ----------------------------------------------------------------------- - // 3. Calculate streaks - // ----------------------------------------------------------------------- - - const sessionDates = sessions.map(s => s.date); - const streaks = calculateStreaks(sessionDates); - - // ----------------------------------------------------------------------- - // 4. Extract last session info - // ----------------------------------------------------------------------- - - const lastSessionRaw = sessions.length > 0 ? sessions[sessions.length - 1] : null; - const lastSession = lastSessionRaw - ? { - date: lastSessionRaw.date, - summary: lastSessionRaw.summary, - tasksCompleted: lastSessionRaw.tasksCompleted, - modulesTouched: lastSessionRaw.modulesTouched, - } - : { date: '', summary: '', tasksCompleted: 0, modulesTouched: [] }; - - // ----------------------------------------------------------------------- - // 5. Parse task files - // ----------------------------------------------------------------------- - - const allDone = parseDoneFile(donePath); - // Last 10 completed tasks (file is ordered newest-first by section) - const recentlyDone = allDone.slice(0, 10); - - const inProgress = parseInProgressFile(inProgressPath); - - console.log(` [Sessions] ${allDone.length} done tasks, ${inProgress.length} in-progress`); - - // ----------------------------------------------------------------------- - // 6. Assemble result and cache - // ----------------------------------------------------------------------- - - const result = { - timestamp: new Date().toISOString(), - sessions, - progressOverTime, - streaks, - lastSession, - inProgress, - recentlyDone, - }; - - // Write cache - try { - if (!existsSync(cacheDir)) { - mkdirSync(cacheDir, { recursive: true }); - } - writeFileSync(cachePath, JSON.stringify(result, null, 2)); - } catch (err) { - console.warn(` [Sessions] Failed to write cache: ${err.message}`); - } - - return result; -} diff --git a/tools/project-map/lib/suggestion-engine.mjs b/tools/project-map/lib/suggestion-engine.mjs deleted file mode 100644 index 3a81b65e..00000000 --- a/tools/project-map/lib/suggestion-engine.mjs +++ /dev/null @@ -1,238 +0,0 @@ -/** - * Smart suggestion engine. - * Combines all signals (coverage, bugs, churn, staleness, coupling, debt, recent work) - * into ranked "Focus Next" recommendations. - */ -import { readFileSync, writeFileSync, existsSync } from 'node:fs'; -import { resolve } from 'node:path'; - -const CACHE_FILE = 'suggestions.json'; -const PREFS_FILE = 'user-prefs.json'; - -// Signal weights (tunable) -const WEIGHTS = { - coverageGap: 2.0, // per % below 80 - openBugs: 15, // per bug - codeReviewFixes: 10, // per fix - openTasks: 3, // per task - highChurn: 1.5, // per commit in last 30d - staleness: 0.5, // per day since last commit - coupling: 2.0, // per coupling score point - debtMarkers: 1.0, // per TODO/FIXME/HACK - largeFiles: 5, // per large file - longFunctions: 3, // per long function - recentWorkCooldown: -20, // penalty if worked on in last 2 sessions -}; - -function loadPrefs(cacheDir) { - const prefsFile = resolve(cacheDir, PREFS_FILE); - if (existsSync(prefsFile)) { - try { return JSON.parse(readFileSync(prefsFile, 'utf8')); } catch { /* skip */ } - } - return { recentlyWorked: [], strategy: 'balanced' }; -} - -function savePrefs(cacheDir, prefs) { - try { - writeFileSync(resolve(cacheDir, PREFS_FILE), JSON.stringify(prefs, null, 2)); - } catch { /* non-fatal — prefs write failure should not crash the server */ } -} - -export function markWorkedOn(cacheDir, moduleName) { - const prefs = loadPrefs(cacheDir); - prefs.recentlyWorked = [moduleName, ...prefs.recentlyWorked.filter(m => m !== moduleName)].slice(0, 5); - savePrefs(cacheDir, prefs); -} - -export function setStrategy(cacheDir, strategy) { - const prefs = loadPrefs(cacheDir); - prefs.strategy = strategy; - savePrefs(cacheDir, prefs); -} - -export async function generateSuggestions(cacheDir, { - priorities = [], - goCoverage = {}, - vitestCoverage = {}, - backlog = {}, - gitData = null, - debtData = null, - importGraph = null, - sessionData = null, -} = {}) { - const prefs = loadPrefs(cacheDir); - const strategy = prefs.strategy || 'balanced'; - const recentlyWorked = new Set(prefs.recentlyWorked || []); - - // Strategy multipliers - const strategyMult = { - balanced: { coverage: 1, bugs: 1, momentum: 1, debt: 1 }, - 'bugs-first': { coverage: 0.5, bugs: 2.5, momentum: 0.5, debt: 0.5 }, - 'coverage-first': { coverage: 2.5, bugs: 0.5, momentum: 0.5, debt: 0.5 }, - 'momentum-first': { coverage: 0.5, bugs: 0.5, momentum: 2.5, debt: 0.5 }, - 'debt-first': { coverage: 0.5, bugs: 0.5, momentum: 0.5, debt: 2.5 }, - }; - const mult = strategyMult[strategy] || strategyMult.balanced; - - // Build per-module signal aggregation - const moduleSignals = {}; - - function ensureModule(name) { - if (!moduleSignals[name]) { - moduleSignals[name] = { - name, - signals: [], - score: 0, - coverageScore: 0, - bugScore: 0, - momentumScore: 0, - debtScore: 0, - }; - } - return moduleSignals[name]; - } - - // 1. Coverage gaps (from priorities which already have this data) - for (const p of priorities) { - const m = ensureModule(p.name); - if (p.coverage !== null && p.coverage < 80) { - const gap = 80 - p.coverage; - const pts = gap * WEIGHTS.coverageGap * mult.coverage; - m.coverageScore += pts; - m.signals.push({ signal: 'coverage-gap', value: `${p.coverage.toFixed(1)}% (${gap.toFixed(0)}% below target)`, points: pts }); - } else if (p.coverage === null && p.type !== 'feature-area') { - const pts = 80 * WEIGHTS.coverageGap * 0.5 * mult.coverage; // unknown coverage = assume 50% gap - m.coverageScore += pts; - m.signals.push({ signal: 'no-coverage-data', value: 'No test coverage', points: pts }); - } - } - - // 2. Open bugs and tasks - for (const [phase, tasks] of Object.entries(backlog.byPhase || {})) { - for (const task of tasks) { - for (const mod of task.modules) { - const m = ensureModule(mod); - if (phase === 'bug') { - const pts = WEIGHTS.openBugs * mult.bugs; - m.bugScore += pts; - m.signals.push({ signal: 'open-bug', value: `${task.id}: ${task.description.slice(0, 60)}`, points: pts }); - } else if (phase === 'code-review') { - const pts = WEIGHTS.codeReviewFixes * mult.bugs; - m.bugScore += pts; - m.signals.push({ signal: 'code-review-fix', value: `${task.id}: ${task.description.slice(0, 60)}`, points: pts }); - } else { - const pts = WEIGHTS.openTasks * mult.momentum; - m.momentumScore += pts; - m.signals.push({ signal: 'open-task', value: `${task.id}: ${task.description.slice(0, 60)}`, points: pts }); - } - } - } - } - - // 3. Git signals (churn, staleness) - if (gitData) { - for (const [mod, data] of Object.entries(gitData.commitsByModule || {})) { - const m = ensureModule(mod); - // High churn = needs attention - if (data.count > 10) { - const pts = (data.count - 10) * WEIGHTS.highChurn * mult.momentum; - m.momentumScore += pts; - m.signals.push({ signal: 'high-churn', value: `${data.count} commits in 30d`, points: pts }); - } - } - - for (const [mod, data] of Object.entries(gitData.staleness || {})) { - const m = ensureModule(mod); - if (data.daysSinceLastCommit > 14) { - const pts = data.daysSinceLastCommit * WEIGHTS.staleness * mult.momentum; - m.momentumScore += pts; - m.signals.push({ signal: 'stale', value: `${data.daysSinceLastCommit} days since last commit`, points: pts }); - } - } - } - - // 4. Debt signals - if (debtData) { - for (const [mod, data] of Object.entries(debtData.summary?.byModule || {})) { - const m = ensureModule(mod); - if (data.markers > 0) { - const pts = data.markers * WEIGHTS.debtMarkers * mult.debt; - m.debtScore += pts; - m.signals.push({ signal: 'debt-markers', value: `${data.markers} TODO/FIXME/HACK`, points: pts }); - } - if (data.largeFiles > 0) { - const pts = data.largeFiles * WEIGHTS.largeFiles * mult.debt; - m.debtScore += pts; - m.signals.push({ signal: 'large-files', value: `${data.largeFiles} oversized file(s)`, points: pts }); - } - if (data.longFunctions > 0) { - const pts = data.longFunctions * WEIGHTS.longFunctions * mult.debt; - m.debtScore += pts; - m.signals.push({ signal: 'long-functions', value: `${data.longFunctions} long function(s)`, points: pts }); - } - } - } - - // 5. Coupling signals - if (importGraph) { - for (const node of importGraph.nodes || []) { - const m = ensureModule(node.name); - if (node.coupling > 10) { - const pts = node.coupling * WEIGHTS.coupling * mult.debt; - m.debtScore += pts; - m.signals.push({ signal: 'high-coupling', value: `Coupling score ${node.coupling} (fan-in ${node.fanIn}, fan-out ${node.fanOut})`, points: pts }); - } - } - } - - // 6. Recent work cooldown - for (const [name, m] of Object.entries(moduleSignals)) { - if (recentlyWorked.has(name)) { - const pts = WEIGHTS.recentWorkCooldown; - m.momentumScore += pts; - m.signals.push({ signal: 'recently-worked', value: 'Worked on recently — cooling down', points: pts }); - } - } - - // Compute total scores - for (const m of Object.values(moduleSignals)) { - m.score = m.coverageScore + m.bugScore + m.momentumScore + m.debtScore; - } - - // Sort and take top suggestions - const sorted = Object.values(moduleSignals) - .filter(m => m.score > 0) - .sort((a, b) => b.score - a.score); - - const suggestions = sorted.slice(0, 10).map((m, i) => { - // Build a human-readable rationale from top 3 signals - const topSignals = [...m.signals].sort((a, b) => b.points - a.points).slice(0, 3); - const rationale = topSignals.map(s => s.value).join('; '); - - return { - rank: i + 1, - module: m.name, - score: Math.round(m.score), - rationale, - breakdown: { - coverage: Math.round(m.coverageScore), - bugs: Math.round(m.bugScore), - momentum: Math.round(m.momentumScore), - debt: Math.round(m.debtScore), - }, - signals: m.signals, - }; - }); - - const result = { - timestamp: new Date().toISOString(), - strategy, - suggestions, - recentlyWorked: [...recentlyWorked], - }; - - try { - writeFileSync(resolve(cacheDir, CACHE_FILE), JSON.stringify(result, null, 2)); - } catch { /* non-fatal */ } - return result; -} diff --git a/tools/project-map/lib/terminal-summary.mjs b/tools/project-map/lib/terminal-summary.mjs deleted file mode 100644 index f17944ad..00000000 --- a/tools/project-map/lib/terminal-summary.mjs +++ /dev/null @@ -1,103 +0,0 @@ -/** - * Terminal summary — prints a color-coded overview to stdout. - */ - -const RESET = '\x1b[0m'; -const BOLD = '\x1b[1m'; -const DIM = '\x1b[2m'; -const RED = '\x1b[31m'; -const GREEN = '\x1b[32m'; -const YELLOW = '\x1b[33m'; -const CYAN = '\x1b[36m'; -const WHITE = '\x1b[37m'; - -function covColor(pct) { - if (pct === null || pct === undefined) return RED; - if (pct >= 80) return GREEN; - if (pct >= 60) return YELLOW; - return RED; -} - -function bar(pct, width = 20) { - if (pct === null || pct === undefined) return `${RED}${'░'.repeat(width)}${RESET} ---`; - const filled = Math.round((pct / 100) * width); - const empty = width - filled; - const color = covColor(pct); - return `${color}${'█'.repeat(filled)}${'░'.repeat(empty)}${RESET} ${pct.toFixed(1)}%`; -} - -function divider(title) { - const line = '─'.repeat(60); - return `\n ${CYAN}${BOLD}${title}${RESET}\n ${DIM}${line}${RESET}`; -} - -export function printTerminalSummary(modules, goCoverage, vitestCoverage, backlog, priorities) { - console.log(divider('PROJECT OVERVIEW')); - - const total = backlog.openCount + backlog.doneCount; - const pct = total > 0 ? ((backlog.doneCount / total) * 100).toFixed(1) : '0.0'; - console.log(` ${WHITE}Tasks: ${GREEN}${backlog.doneCount} done${RESET} / ${YELLOW}${backlog.openCount} open${RESET} (${pct}% complete)`); - console.log(` ${WHITE}Files: ${modules.summary.goSourceFiles} Go + ${modules.summary.tsSourceFiles} TS + ${modules.summary.rustSourceFiles} Rust${RESET}`); - console.log(` ${WHITE}Tests: ${modules.summary.goTestFiles} Go + ${modules.summary.tsTestFiles} TS + ${modules.summary.rustTestFiles} Rust${RESET}`); - - // Go coverage - console.log(divider('SERVER (GO) COVERAGE')); - for (const pkg of modules.go) { - const covData = goCoverage.packages?.[pkg.name]; - const cov = covData?.percentage ?? null; - const name = `${pkg.name}/`.padEnd(16); - const failed = covData?.failed ? ` ${RED}FAIL${RESET}` : ''; - console.log(` ${WHITE}${name}${RESET} ${bar(cov)}${failed}`); - } - - // TS coverage - console.log(divider('CLIENT (TYPESCRIPT) COVERAGE')); - const tsAreas = vitestCoverage.areas || {}; - const totalCov = tsAreas._total; - if (totalCov) { - console.log(` ${WHITE}${'Overall'.padEnd(16)}${RESET} ${bar(totalCov.statements)}`); - } - for (const dir of modules.typescript.filter(d => d.type === 'typescript')) { - const areaCov = tsAreas[dir.name]; - const cov = areaCov?.statements ?? null; - const name = `${dir.name}/`.padEnd(16); - console.log(` ${WHITE}${name}${RESET} ${bar(cov)}`); - } - - // Rust - console.log(divider('CLIENT (RUST) STATUS')); - for (const rust of modules.rust) { - console.log(` ${WHITE}${rust.sourceFiles} files, ${rust.sourceLines} lines${RESET} — ${RED}No test infrastructure${RESET}`); - } - - // Where to work next - console.log(divider('WHERE TO WORK NEXT (TOP 5)')); - const top5 = priorities.slice(0, 5); - top5.forEach((p, i) => { - const num = `${i + 1}.`.padEnd(3); - const name = p.name.padEnd(16); - const cov = p.coverage !== null ? `${p.coverage.toFixed(0)}%`.padEnd(5) : '--- '; - const tasks = p.openTaskCount > 0 ? `${YELLOW}${p.openTaskCount} task(s)${RESET}` : `${GREEN}0 tasks${RESET}`; - console.log(` ${CYAN}${num}${RESET} ${BOLD}${name}${RESET} ${covColor(p.coverage)}${cov}${RESET} ${tasks} — ${DIM}${p.recommendation}${RESET}`); - }); - - // Open bugs - const bugs = backlog.byPhase['bug'] || []; - if (bugs.length > 0) { - console.log(divider('OPEN BUGS')); - for (const bug of bugs) { - console.log(` ${RED}${bug.id}${RESET}: ${bug.description}`); - } - } - - // Code review items - const reviews = backlog.byPhase['code-review'] || []; - if (reviews.length > 0) { - console.log(divider('CODE REVIEW FIXES')); - for (const r of reviews) { - console.log(` ${YELLOW}${r.id}${RESET}: ${r.description}`); - } - } - - console.log(`\n ${DIM}Full report: docs/brain/00-Overview/Project-Map.md${RESET}\n`); -} diff --git a/tools/project-map/lib/vitest-coverage.mjs b/tools/project-map/lib/vitest-coverage.mjs deleted file mode 100644 index 8912277c..00000000 --- a/tools/project-map/lib/vitest-coverage.mjs +++ /dev/null @@ -1,216 +0,0 @@ -/** - * Vitest coverage collector. - * Runs vitest with coverage and parses the Istanbul coverage-final.json. - * Caches results to .cache/vitest-coverage.json. - */ -import { execFileSync } from 'node:child_process'; -import { readFileSync, writeFileSync, existsSync, statSync } from 'node:fs'; -import { resolve } from 'node:path'; - -// Coverage data older than this is considered stale and gets a warning flag -const FRESHNESS_MAX_AGE_MS = 24 * 60 * 60 * 1000; // 24 hours - -const CACHE_FILE = 'vitest-coverage.json'; - -function computeFileCoverage(fileData) { - const s = fileData.s || {}; - const b = fileData.b || {}; - const f = fileData.f || {}; - - const stmtTotal = Object.keys(s).length; - const stmtCovered = Object.values(s).filter(v => v > 0).length; - - let branchTotal = 0; - let branchCovered = 0; - for (const branches of Object.values(b)) { - for (const count of branches) { - branchTotal++; - if (count > 0) branchCovered++; - } - } - - const fnTotal = Object.keys(f).length; - const fnCovered = Object.values(f).filter(v => v > 0).length; - - return { - statements: stmtTotal > 0 ? (stmtCovered / stmtTotal) * 100 : 100, - branches: branchTotal > 0 ? (branchCovered / branchTotal) * 100 : 100, - functions: fnTotal > 0 ? (fnCovered / fnTotal) * 100 : 100, - stmtTotal, - stmtCovered, - branchTotal, - branchCovered, - fnTotal, - fnCovered, - }; -} - -function parseCoverageFinal(coverageJsonPath) { - if (!existsSync(coverageJsonPath)) return {}; - - const raw = JSON.parse(readFileSync(coverageJsonPath, 'utf8')); - const byArea = {}; - let totalStmt = 0, totalStmtCov = 0; - let totalBranch = 0, totalBranchCov = 0; - let totalFn = 0, totalFnCov = 0; - - for (const [filePath, fileData] of Object.entries(raw)) { - const normalized = filePath.replace(/\\/g, '/'); - const srcMatch = normalized.match(/src\/(\w+)\//); - const area = srcMatch ? srcMatch[1] : 'other'; - - const cov = computeFileCoverage(fileData); - - if (!byArea[area]) { - byArea[area] = { - files: 0, - stmtTotal: 0, stmtCovered: 0, - branchTotal: 0, branchCovered: 0, - fnTotal: 0, fnCovered: 0, - }; - } - - byArea[area].files++; - byArea[area].stmtTotal += cov.stmtTotal; - byArea[area].stmtCovered += cov.stmtCovered; - byArea[area].branchTotal += cov.branchTotal; - byArea[area].branchCovered += cov.branchCovered; - byArea[area].fnTotal += cov.fnTotal; - byArea[area].fnCovered += cov.fnCovered; - - totalStmt += cov.stmtTotal; - totalStmtCov += cov.stmtCovered; - totalBranch += cov.branchTotal; - totalBranchCov += cov.branchCovered; - totalFn += cov.fnTotal; - totalFnCov += cov.fnCovered; - } - - // Compute percentages - const result = {}; - for (const [area, data] of Object.entries(byArea)) { - result[area] = { - files: data.files, - statements: data.stmtTotal > 0 ? (data.stmtCovered / data.stmtTotal) * 100 : 100, - branches: data.branchTotal > 0 ? (data.branchCovered / data.branchTotal) * 100 : 100, - functions: data.fnTotal > 0 ? (data.fnCovered / data.fnTotal) * 100 : 100, - }; - } - - result._total = { - statements: totalStmt > 0 ? (totalStmtCov / totalStmt) * 100 : 0, - branches: totalBranch > 0 ? (totalBranchCov / totalBranch) * 100 : 0, - functions: totalFn > 0 ? (totalFnCov / totalFn) * 100 : 0, - }; - - return result; -} - -export async function collectVitestCoverage(root, cacheDir, quick) { - const cacheFile = resolve(cacheDir, CACHE_FILE); - - const clientDir = resolve(root, 'Client/tauri-client'); - - if (quick && existsSync(cacheFile)) { - console.log(' [TS] Using cached coverage data'); - let cached; - try { - cached = JSON.parse(readFileSync(cacheFile, 'utf8')); - } catch { - console.warn(' [TS] Corrupt cache — falling through to fresh collection'); - cached = null; - } - if (cached) { - // Still evaluate freshness of the underlying coverage-final.json - const coveragePath = resolve(clientDir, 'coverage/coverage-final.json'); - if (existsSync(coveragePath)) { - try { - const covStat = statSync(coveragePath); - const ageMs = Date.now() - covStat.mtimeMs; - if (ageMs > FRESHNESS_MAX_AGE_MS) { - cached.stale = true; - console.log(` [TS] coverage-final.json is ${Math.round(ageMs / 3600000)}h old — flagging as stale`); - } - } catch { /* stat failed, leave stale flag as-is */ } - } - return cached; - } - } - if (!existsSync(clientDir)) { - console.log(' [TS] Client directory not found, skipping'); - return {}; - } - - // Check if coverage-final.json already exists (from a previous test run) - const coveragePath = resolve(clientDir, 'coverage/coverage-final.json'); - let needsRun = !existsSync(coveragePath); - let stale = false; - - // Check freshness — don't blindly trust old coverage data - if (!needsRun) { - try { - const covStat = statSync(coveragePath); - const ageMs = Date.now() - covStat.mtimeMs; - if (ageMs > FRESHNESS_MAX_AGE_MS) { - stale = true; - console.log(` [TS] coverage-final.json is ${Math.round(ageMs / 3600000)}h old — flagging as stale`); - if (!quick) { - needsRun = true; // Full mode re-runs stale coverage - } - } - } catch { /* stat failed, treat as needing a run */ needsRun = true; } - } - - if (needsRun && !quick) { - console.log(' [TS] Running vitest with coverage (this may take a moment)...'); - try { - execFileSync('npx', ['vitest', 'run', '--coverage'], { - cwd: clientDir, - encoding: 'utf8', - timeout: 300_000, - maxBuffer: 10 * 1024 * 1024, - }); - } catch { - // vitest may exit non-zero but still produce coverage - } - } else if (!needsRun) { - console.log(' [TS] Using existing coverage-final.json'); - } - - let coverageData = {}; - if (existsSync(coveragePath)) { - coverageData = parseCoverageFinal(coveragePath); - } - - // Get test count from a quick vitest --reporter=json run if available - let testCount = 0; - const reportPath = resolve(clientDir, 'coverage-report.json'); - if (existsSync(reportPath)) { - try { - const report = JSON.parse(readFileSync(reportPath, 'utf8')); - testCount = report.numTotalTests ?? 0; - } catch { /* skip */ } - } - - // Instead of running vitest again, count files from coverage data - if (testCount === 0 && Object.keys(coverageData).length > 0) { - try { - const coverageJsonPath = resolve(clientDir, 'coverage', 'coverage-final.json'); - if (existsSync(coverageJsonPath)) { - const raw = JSON.parse(readFileSync(coverageJsonPath, 'utf8')); - testCount = Object.keys(raw).length; - } - } catch { /* keep testCount as 0 */ } - } - - const result = { - timestamp: new Date().toISOString(), - areas: coverageData, - testCount, - stale, - }; - - writeFileSync(cacheFile, JSON.stringify(result, null, 2)); - console.log(` [TS] Coverage collected for ${Object.keys(coverageData).length} areas, ${testCount} tests`); - return result; -} diff --git a/tools/project-map/lib/worktree-manager.mjs b/tools/project-map/lib/worktree-manager.mjs deleted file mode 100644 index 83d905fd..00000000 --- a/tools/project-map/lib/worktree-manager.mjs +++ /dev/null @@ -1,387 +0,0 @@ -/** - * Worktree Manager — git worktree lifecycle for isolated agent execution. - * - * Each agent job gets its own worktree under .worktrees/. - * Changes stay isolated until explicitly merged back. - * - * Lifecycle: - * createWorktree() → agent runs → mergeWorktree() → destroyWorktree() - * - * State machine: - * queued → provisioning → running → review → merged → archived - * → failed → (retry → queued) - * → cancelled - */ -import { execFileSync } from 'node:child_process'; -import { - existsSync, - mkdirSync, - symlinkSync, - cpSync, - rmSync, - readdirSync, - lstatSync, -} from 'node:fs'; -import { resolve, join } from 'node:path'; - -// --------------------------------------------------------------------------- -// Constants -// --------------------------------------------------------------------------- - -const WORKTREES_DIR = '.worktrees'; -const BRANCH_PREFIX = 'agent/'; -const GIT_TIMEOUT = 30_000; - -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -function git(args, cwd, opts = {}) { - return execFileSync('git', args, { - cwd, - encoding: 'utf8', - timeout: opts.timeout ?? GIT_TIMEOUT, - stdio: ['pipe', 'pipe', 'pipe'], - maxBuffer: 10 * 1024 * 1024, - }).trim(); -} - -/** - * Link or copy .claude/ and CLAUDE.md into a worktree so agents - * inherit project config, skills, and MCP server settings. - * - * Strategy: try symlink first (works if Developer Mode enabled on Windows). - * If symlink fails (EPERM), fall back to a full copy. - * - * @returns {'symlink'|'copy'|'none'} — method used - */ -function inheritConfig(rootDir, worktreePath) { - let method = 'none'; - - const claudeDir = resolve(rootDir, '.claude'); - const claudeMd = resolve(rootDir, 'CLAUDE.md'); - const targetDir = resolve(worktreePath, '.claude'); - const targetMd = resolve(worktreePath, 'CLAUDE.md'); - - if (existsSync(claudeDir) && !existsSync(targetDir)) { - // Try symlink - try { - symlinkSync(claudeDir, targetDir, 'junction'); // junction works without admin on Windows - method = 'symlink'; - } catch { - // Fallback to copy - try { - cpSync(claudeDir, targetDir, { recursive: true }); - method = 'copy'; - } catch (copyErr) { - console.error(` [Worktree] Failed to copy .claude/: ${copyErr.message}`); - } - } - } - - if (existsSync(claudeMd) && !existsSync(targetMd)) { - try { - symlinkSync(claudeMd, targetMd, 'file'); - } catch { - try { - cpSync(claudeMd, targetMd); - } catch { /* non-critical */ } - } - } - - return method; -} - -/** - * Remove inherited config from a worktree before destruction. - * Must handle both symlinks and copies. - */ -function removeInheritedConfig(worktreePath) { - const targets = [ - resolve(worktreePath, '.claude'), - resolve(worktreePath, 'CLAUDE.md'), - ]; - - for (const target of targets) { - if (!existsSync(target)) continue; - try { - rmSync(target, { recursive: true, force: true }); - } catch (err) { - console.error(` [Worktree] Failed to remove ${target}: ${err.message}`); - } - } -} - -// --------------------------------------------------------------------------- -// Merge mutex — only one merge at a time -// --------------------------------------------------------------------------- - -let mergeInProgress = false; - -export function isMergeInProgress() { - return mergeInProgress; -} - -// --------------------------------------------------------------------------- -// Public API -// --------------------------------------------------------------------------- - -/** - * Create an isolated git worktree for an agent job. - * - * @param {string} root — project root (must be a git repo) - * @param {string} jobId — unique job identifier (used for directory + branch name) - * @returns {{ worktreePath: string, branchName: string, configMethod: string }} - */ -export function createWorktree(root, jobId) { - const worktreesBase = resolve(root, WORKTREES_DIR); - if (!existsSync(worktreesBase)) { - mkdirSync(worktreesBase, { recursive: true }); - } - - const worktreePath = resolve(worktreesBase, jobId); - const branchName = `${BRANCH_PREFIX}${jobId}`; - - if (existsSync(worktreePath)) { - throw new Error(`Worktree already exists for job ${jobId}`); - } - - // Create worktree with a new branch based on current HEAD - git(['worktree', 'add', worktreePath, '-b', branchName], root); - - // Inherit project config - const configMethod = inheritConfig(root, worktreePath); - - return { worktreePath, branchName, configMethod }; -} - -/** - * Destroy a worktree and its branch. - * Handles the Windows-specific cleanup order: remove config → remove dir → prune → delete branch. - * - * @param {string} root — project root - * @param {string} jobId — job identifier - */ -export function destroyWorktree(root, jobId) { - const worktreePath = resolve(root, WORKTREES_DIR, jobId); - const branchName = `${BRANCH_PREFIX}${jobId}`; - - if (existsSync(worktreePath)) { - // Step 1: Remove inherited config (symlinks/copies) first - removeInheritedConfig(worktreePath); - - // Step 2: Try git worktree remove - try { - git(['worktree', 'remove', worktreePath, '--force'], root); - } catch { - // Fallback: force-remove directory + prune - try { - rmSync(worktreePath, { recursive: true, force: true }); - } catch (rmErr) { - console.error(` [Worktree] rm failed for ${jobId}: ${rmErr.message}`); - } - try { - git(['worktree', 'prune'], root); - } catch { /* best effort */ } - } - } else { - // Directory already gone — just prune - try { - git(['worktree', 'prune'], root); - } catch { /* best effort */ } - } - - // Step 3: Delete the branch - try { - git(['branch', '-D', branchName], root); - } catch { - // Branch may already be deleted or never created - } -} - -/** - * List all active worktrees. - * - * @param {string} root — project root - * @returns {Array<{ path: string, branch: string, head: string, jobId: string|null }>} - */ -export function listWorktrees(root) { - let raw; - try { - raw = git(['worktree', 'list', '--porcelain'], root); - } catch { - return []; - } - - const worktrees = []; - let current = {}; - - for (const line of raw.split('\n')) { - if (line.startsWith('worktree ')) { - if (current.path) worktrees.push(current); - current = { path: line.slice(9) }; - } else if (line.startsWith('HEAD ')) { - current.head = line.slice(5); - } else if (line.startsWith('branch ')) { - current.branch = line.slice(7); - } else if (line === '') { - if (current.path) worktrees.push(current); - current = {}; - } - } - if (current.path) worktrees.push(current); - - // Filter to agent worktrees only and extract jobId - return worktrees - .filter(w => w.branch && w.branch.includes(BRANCH_PREFIX)) - .map(w => ({ - ...w, - jobId: w.branch.replace(`refs/heads/${BRANCH_PREFIX}`, ''), - })); -} - -/** - * Merge a worktree's branch back into the current branch. - * - * Guards: - * - Only one merge at a time (mutex) - * - Working tree must be clean - * - * @param {string} root — project root - * @param {string} jobId — job identifier - * @param {string} [message] — optional merge commit message - * @returns {{ success: boolean, filesChanged: number, commitSha: string|null, conflicts: string[] }} - */ -export function mergeWorktree(root, jobId, message) { - if (mergeInProgress) { - return { success: false, filesChanged: 0, commitSha: null, conflicts: [], error: 'merge_in_progress' }; - } - - mergeInProgress = true; - - try { - // Guard: working tree must be clean - try { - git(['diff', '--quiet'], root); - git(['diff', '--quiet', '--cached'], root); - } catch { - return { success: false, filesChanged: 0, commitSha: null, conflicts: [], error: 'working_tree_dirty' }; - } - - const branchName = `${BRANCH_PREFIX}${jobId}`; - const commitMsg = message || `agent: merge results from ${jobId}`; - - // Guard: branch must exist - try { - git(['rev-parse', '--verify', branchName], root); - } catch { - return { success: false, filesChanged: 0, commitSha: null, conflicts: [], error: 'branch_not_found' }; - } - - // Attempt merge - try { - git(['merge', branchName, '--no-ff', '-m', commitMsg], root, { timeout: 60_000 }); - } catch (mergeErr) { - // Check if it's a conflict - try { - const conflictRaw = git(['diff', '--name-only', '--diff-filter=U'], root); - const conflicts = conflictRaw.split('\n').filter(Boolean); - - if (conflicts.length > 0) { - // Abort the merge - try { git(['merge', '--abort'], root); } catch { /* may already be clean */ } - return { success: false, filesChanged: 0, commitSha: null, conflicts }; - } - } catch { /* fall through */ } - - // Not a conflict — some other error - try { git(['merge', '--abort'], root); } catch { /* best effort */ } - throw mergeErr; - } - - // Success — get stats - const commitSha = git(['rev-parse', '--short', 'HEAD'], root); - let filesChanged = 0; - try { - const stat = git(['diff', '--stat', 'HEAD~1..HEAD', '--numstat'], root); - filesChanged = stat.split('\n').filter(Boolean).length; - } catch { /* non-critical */ } - - return { success: true, filesChanged, commitSha, conflicts: [] }; - } finally { - mergeInProgress = false; - } -} - -/** - * Clean up stale worktrees — those whose agent process is dead. - * Call on server startup. - * - * @param {string} root — project root - * @param {function} isJobAlive — callback (jobId) => boolean, checks if the agent process is still running - * @returns {{ cleaned: number, errors: string[] }} - */ -export function cleanupStaleWorktrees(root, isJobAlive) { - const worktreesBase = resolve(root, WORKTREES_DIR); - if (!existsSync(worktreesBase)) return { cleaned: 0, errors: [] }; - - let entries; - try { - entries = readdirSync(worktreesBase); - } catch { - return { cleaned: 0, errors: [] }; - } - - let cleaned = 0; - const errors = []; - - for (const entry of entries) { - const entryPath = resolve(worktreesBase, entry); - try { - const stat = lstatSync(entryPath); - if (!stat.isDirectory()) continue; - } catch { - continue; - } - - // Check if the agent for this worktree is still alive - const alive = typeof isJobAlive === 'function' ? isJobAlive(entry) : false; - if (alive) continue; - - // Dead worktree — clean up - try { - destroyWorktree(root, entry); - cleaned += 1; - } catch (err) { - errors.push(`${entry}: ${err.message}`); - } - } - - return { cleaned, errors }; -} - -/** - * Get disk usage estimate for a worktree directory (in bytes). - * Best-effort — returns 0 on failure. - */ -export function getWorktreeDiskUsage(root, jobId) { - const worktreePath = resolve(root, WORKTREES_DIR, jobId); - if (!existsSync(worktreePath)) return 0; - - try { - // Use git to count the worktree overhead (not the full repo — worktrees share objects) - const countOutput = git(['count-objects', '-vH'], worktreePath); - // Parse "size-pack: 42.00 MiB" or similar - const match = countOutput.match(/size-pack:\s*([\d.]+)\s*(\w+)/); - if (match) { - const size = parseFloat(match[1]); - const unit = match[2].toLowerCase(); - if (unit.startsWith('kib') || unit.startsWith('k')) return Math.round(size * 1024); - if (unit.startsWith('mib') || unit.startsWith('m')) return Math.round(size * 1024 * 1024); - if (unit.startsWith('gib') || unit.startsWith('g')) return Math.round(size * 1024 * 1024 * 1024); - return Math.round(size); - } - } catch { /* best effort */ } - - return 0; -} diff --git a/tools/project-map/package.json b/tools/project-map/package.json deleted file mode 100644 index c849d424..00000000 --- a/tools/project-map/package.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "name": "owncord-project-map", - "version": "1.0.0", - "private": true, - "type": "module", - "description": "Project map generator for OwnCord — completeness, testing, priorities", - "scripts": { - "map": "node index.mjs", - "map:quick": "node index.mjs --quick", - "map:research": "node index.mjs --research", - "test": "node --test --test-concurrency=1 tests/*.test.mjs" - } -} diff --git a/tools/project-map/server.mjs b/tools/project-map/server.mjs deleted file mode 100644 index e71fc167..00000000 --- a/tools/project-map/server.mjs +++ /dev/null @@ -1,661 +0,0 @@ -#!/usr/bin/env node -/** - * Project Map Web Dashboard - * Run: node tools/project-map/server.mjs - * Open: http://localhost:3333 - */ -import { createServer } from 'node:http'; -import { randomBytes } from 'node:crypto'; -import { readFileSync, writeFileSync as _writeFileSync, existsSync, mkdirSync } from 'node:fs'; -import { resolve, dirname } from 'node:path'; -import { fileURLToPath } from 'node:url'; - -import { scanModules } from './lib/scanner.mjs'; -import { collectGoCoverage } from './lib/go-coverage.mjs'; -import { collectVitestCoverage } from './lib/vitest-coverage.mjs'; -import { parseBacklog } from './lib/backlog-parser.mjs'; -import { scorePriorities } from './lib/priority-engine.mjs'; -import { scanGitHistory } from './lib/git-scanner.mjs'; -import { parseSessionHistory } from './lib/session-parser.mjs'; -import { scanTechnicalDebt } from './lib/debt-scanner.mjs'; -import { buildImportGraph } from './lib/import-graph.mjs'; -import { generateSuggestions, markWorkedOn, setStrategy } from './lib/suggestion-engine.mjs'; -import { createFileWatcher, createSSEManager } from './lib/file-watcher.mjs'; -import { generatePlan, startSession, getSessionStatus, pollChanges, endSession, recoverStaleSession } from './lib/session-manager.mjs'; -import { healthCheck, getJobs, createJob, cancelJob, getJobResult, processQueue, recoverOrphans, pruneResults, getLiveOutput, getJobDiff, parseActivityHints, getActiveCount, getMaxConcurrent, setMaxConcurrent, getActiveAgentIds } from './lib/agent-manager.mjs'; -import { mergeWorktree, destroyWorktree, listWorktrees, isMergeInProgress, getWorktreeDiskUsage } from './lib/worktree-manager.mjs'; -import { generateBriefing } from './lib/morning-briefing.mjs'; - -const __dirname = dirname(fileURLToPath(import.meta.url)); -const ROOT = resolve(__dirname, '../..'); -const CACHE_DIR = resolve(__dirname, '.cache'); -let PORT = parseInt(process.env.PORT || '3333', 10); -if (isNaN(PORT) || PORT < 1 || PORT > 65535) { console.error(`Invalid PORT "${process.env.PORT}", using 3333`); PORT = 3333; } - -if (!existsSync(CACHE_DIR)) mkdirSync(CACHE_DIR, { recursive: true }); - -// Auth token — generated per process, required for privileged endpoints -const AUTH_TOKEN = process.env.PROJECT_MAP_TOKEN || randomBytes(24).toString('hex'); - -// In-memory cache (mode-aware) -let cachedData = null; -let cachedDataMode = null; // 'quick' or 'full' -let collectInflight = null; - -// Valid job types -const VALID_JOB_TYPES = new Set(['research', 'write-tests', 'code-review', 'security-audit', 'fix-debt', 'custom']); -const JOB_ID_RE = /^job-\d+(-[a-f0-9]+)?$/; - -async function collectData(quick = true) { - console.log(` Collecting data (${quick ? 'quick' : 'full'} mode)...`); - - const modules = await scanModules(ROOT); - const goCoverage = await collectGoCoverage(ROOT, CACHE_DIR, quick); - const vitestCoverage = await collectVitestCoverage(ROOT, CACHE_DIR, quick); - const backlog = await parseBacklog(ROOT); - const priorities = scorePriorities(modules, goCoverage, vitestCoverage, backlog); - - let gitData = null, sessionData = null, debtData = null, importGraph = null; - try { gitData = await scanGitHistory(ROOT, CACHE_DIR, quick); } catch (e) { console.error(' [Git] Error:', e.message); } - try { sessionData = await parseSessionHistory(ROOT, CACHE_DIR, quick); } catch (e) { console.error(' [Session] Error:', e.message); } - try { debtData = await scanTechnicalDebt(ROOT, CACHE_DIR, quick); } catch (e) { console.error(' [Debt] Error:', e.message); } - try { importGraph = await buildImportGraph(ROOT, CACHE_DIR, quick); } catch (e) { console.error(' [Graph] Error:', e.message); } - - let suggestions = null; - try { - suggestions = await generateSuggestions(CACHE_DIR, { - priorities, goCoverage, vitestCoverage, backlog, gitData, debtData, importGraph, sessionData, - }); - } catch (e) { console.error(' [Suggestions] Error:', e.message); } - - // Agent jobs - let agentJobs = null; - try { agentJobs = getJobs(CACHE_DIR); } catch (e) { console.error(' [Jobs] Error:', e.message); } - - // Morning briefing - let briefing = null; - try { briefing = await generateBriefing(ROOT, CACHE_DIR, { sessionData, suggestions, backlog, agentJobs }); } catch (e) { console.error(' [Briefing] Error:', e.message); } - - return { - modules, goCoverage, vitestCoverage, backlog, priorities, - gitData, sessionData, debtData, importGraph, suggestions, - agentJobs, briefing, - agentHealth: null, // populated on demand - timestamp: new Date().toISOString(), - }; -} - -// SSE manager for live updates -const sse = createSSEManager(); - -// File watcher for auto-refresh -const watcher = createFileWatcher(ROOT, (change) => { - console.log(` [Watch] Change detected: ${change.filename}`); - cachedData = null; - sse.broadcast({ type: 'file-change', ...change }); -}); - -// Valid strategy values -const VALID_STRATEGIES = new Set(['balanced', 'bugs-first', 'coverage-first', 'momentum-first', 'debt-first']); - -// Parse JSON body from POST requests (with size limit and error handling) -async function parseBody(req) { - return new Promise((resolve) => { - let body = ''; - let destroyed = false; - req.on('data', chunk => { - if (destroyed) return; - body += chunk; - if (body.length > 4096) { destroyed = true; req.destroy(); resolve(null); } - }); - req.on('end', () => { - if (destroyed) return; - try { resolve(JSON.parse(body)); } catch { resolve(null); } - }); - req.on('error', () => { if (!destroyed) resolve(null); }); - }); -} - -function json(res, status, data) { - res.writeHead(status, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify(data)); -} - -// Auto-process agent queue on an interval -let processInterval = null; - -// Diff poller — polls git diff every 2s while a job is running -let diffPollerInterval = null; - -function startDiffPoller() { - if (diffPollerInterval) return; - console.log(' [Diff] Starting diff poller (2s interval)'); - diffPollerInterval = setInterval(() => { - try { - const { jobs } = getJobs(CACHE_DIR); - const running = jobs.filter(j => j.status === 'running' && j.baselineSha); - if (running.length === 0) { - stopDiffPoller(); - return; - } - for (const job of running) { - try { - const diffData = getJobDiff(ROOT, job.baselineSha, job.preExistingDirtyFiles || []); - sse.broadcast({ - type: 'job-diff', - jobId: job.id, - files: diffData.files, - diffs: diffData.diffs, - freshness: diffData.freshness, - }); - } catch (err) { - console.error(` [Diff] Error polling job ${job.id}:`, err.message); - } - } - } catch { /* silent */ } - }, 2000); -} - -function stopDiffPoller() { - if (!diffPollerInterval) return; - console.log(' [Diff] Stopping diff poller'); - clearInterval(diffPollerInterval); - diffPollerInterval = null; -} - -// Check bearer token for privileged endpoints -function isAuthorized(req) { - const auth = req.headers['authorization'] || ''; - return auth === `Bearer ${AUTH_TOKEN}`; -} - -function requireAuth(req, res) { - if (isAuthorized(req)) return true; - json(res, 401, { error: 'Unauthorized — pass Authorization: Bearer ' }); - return false; -} - -const BIND_HOST = process.env.PROJECT_MAP_HOST || '127.0.0.1'; - -const server = createServer(async (req, res) => { - const url = new URL(req.url, `http://localhost:${PORT}`); - res.setHeader('Access-Control-Allow-Origin', `http://localhost:${PORT}`); - res.setHeader('Access-Control-Allow-Methods', 'GET, POST, DELETE, OPTIONS'); - res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization'); - - if (req.method === 'OPTIONS') { res.writeHead(204); res.end(); return; } - - try { - // === EXISTING ROUTES === - - if (url.pathname === '/api/data') { - const quick = url.searchParams.get('full') !== '1'; - const requestedMode = quick ? 'quick' : 'full'; - // Don't reuse quick cache for full requests; always refresh if mode changed or forced - const cacheValid = cachedData && cachedDataMode === requestedMode && !url.searchParams.has('refresh'); - // Also allow full cache to serve quick requests (full is a superset) - const fullCacheForQuick = cachedData && cachedDataMode === 'full' && requestedMode === 'quick' && !url.searchParams.has('refresh'); - if (!cacheValid && !fullCacheForQuick) { - if (!collectInflight) { - collectInflight = collectData(quick).then(data => { - cachedData = data; - cachedDataMode = requestedMode; - collectInflight = null; - return data; - }).catch(err => { - collectInflight = null; - throw err; - }); - } - await collectInflight; - } - // Strip agentJobs and job-derived briefing fields from unauthenticated responses - if (!isAuthorized(req)) { - const { agentJobs: _stripped, briefing: fullBriefing, ...publicData } = cachedData; - if (fullBriefing) { - const { agentResults: _a, deadJobs: _d, stats: _s, autoQueueSuggestions: _q, ...publicBriefing } = fullBriefing; - publicData.briefing = publicBriefing; - } - json(res, 200, publicData); - } else { - json(res, 200, cachedData); - } - return; - } - - if (url.pathname === '/api/events') { - sse.handleConnection(req, res); - return; - } - - if (url.pathname === '/api/worked-on' && req.method === 'POST') { - if (!requireAuth(req, res)) return; - const body = await parseBody(req); - if (!body) { json(res, 400, { error: 'Invalid JSON' }); return; } - if (typeof body.module !== 'string' || !body.module || body.module.length > 100 || !/^[\w:/-]+$/.test(body.module)) { - json(res, 400, { error: 'Invalid module name' }); return; - } - markWorkedOn(CACHE_DIR, body.module); - cachedData = null; cachedDataMode = null; - json(res, 200, { ok: true }); - return; - } - - if (url.pathname === '/api/strategy' && req.method === 'POST') { - if (!requireAuth(req, res)) return; - const body = await parseBody(req); - if (!body) { json(res, 400, { error: 'Invalid JSON' }); return; } - if (!VALID_STRATEGIES.has(body.strategy)) { - json(res, 400, { error: `Invalid strategy. Valid: ${[...VALID_STRATEGIES].join(', ')}` }); return; - } - setStrategy(CACHE_DIR, body.strategy); - cachedData = null; cachedDataMode = null; - json(res, 200, { ok: true }); - return; - } - - // === SESSION ROUTES === - - if (url.pathname === '/api/session/plan' && req.method === 'GET') { - // Unauthenticated — returns non-sensitive planning suggestions - const plan = await generatePlan(ROOT, CACHE_DIR); - json(res, 200, plan); - return; - } - - if (url.pathname === '/api/session/start' && req.method === 'POST') { - if (!requireAuth(req, res)) return; - const state = startSession(ROOT, CACHE_DIR); - sse.broadcast({ type: 'session-started' }); - json(res, 200, state); - return; - } - - if (url.pathname === '/api/session/status' && req.method === 'GET') { - const status = getSessionStatus(CACHE_DIR); - // If active, poll for latest changes - if (status.active) { - try { pollChanges(ROOT, CACHE_DIR); } catch { /* best effort */ } - const updated = getSessionStatus(CACHE_DIR); - json(res, 200, updated); - } else { - json(res, 200, status); - } - return; - } - - if (url.pathname === '/api/session/end' && req.method === 'POST') { - if (!requireAuth(req, res)) return; - const result = endSession(ROOT, CACHE_DIR); - cachedData = null; // Vault changed - sse.broadcast({ type: 'session-ended', ...result }); - json(res, 200, result); - return; - } - - // === AGENT JOB ROUTES === - - if (url.pathname === '/api/agent/health' && req.method === 'GET') { - const health = healthCheck(); - json(res, 200, health); - return; - } - - if (url.pathname === '/api/jobs' && req.method === 'GET') { - if (!requireAuth(req, res)) return; - const jobs = getJobs(CACHE_DIR); - json(res, 200, jobs); - return; - } - - if (url.pathname === '/api/jobs' && req.method === 'POST') { - if (!requireAuth(req, res)) return; - const body = await parseBody(req); - if (!body) { json(res, 400, { error: 'Invalid JSON' }); return; } - if (!VALID_JOB_TYPES.has(body.type)) { - json(res, 400, { error: `Invalid job type. Valid: ${[...VALID_JOB_TYPES].join(', ')}` }); return; - } - if (!body.target || typeof body.target !== 'string' || !/^[\w:/-]+$/.test(body.target) || body.target.length > 100) { - json(res, 400, { error: 'Invalid target: must be alphanumeric with :/-_ only, max 100 chars' }); return; - } - if (body.type === 'custom') { - if (typeof body.customPrompt !== 'string' || body.customPrompt.trim().length === 0) { - json(res, 400, { error: 'customPrompt required for custom jobs' }); return; - } - if (body.customPrompt.length > 8000) { - json(res, 400, { error: 'customPrompt exceeds 8000 char limit' }); return; - } - } - // Health check before allowing job creation - const health = healthCheck(); - if (!health.available) { - json(res, 503, { error: 'Claude CLI not available', details: health.error }); return; - } - const job = createJob(CACHE_DIR, { - type: body.type, - target: body.target, - priority: body.priority, - customPrompt: body.customPrompt, - }); - json(res, 201, job); - return; - } - - // DELETE /api/jobs/:id - const deleteMatch = url.pathname.match(/^\/api\/jobs\/([^/]+)$/); - if (deleteMatch && req.method === 'DELETE') { - if (!requireAuth(req, res)) return; - const jobId = deleteMatch[1]; - if (!JOB_ID_RE.test(jobId)) { json(res, 400, { error: 'Invalid job ID format' }); return; } - try { - cancelJob(CACHE_DIR, jobId); - json(res, 200, { ok: true }); - } catch (err) { - json(res, 404, { error: err.message }); - } - return; - } - - // GET /api/jobs/:id/result - const resultMatch = url.pathname.match(/^\/api\/jobs\/([^/]+)\/result$/); - if (resultMatch && req.method === 'GET') { - if (!requireAuth(req, res)) return; - const jobId = resultMatch[1]; - if (!JOB_ID_RE.test(jobId)) { json(res, 400, { error: 'Invalid job ID format' }); return; } - const result = getJobResult(CACHE_DIR, jobId); - json(res, 200, result); - return; - } - - // GET /api/jobs/:id/output — live output for running jobs - const outputMatch = url.pathname.match(/^\/api\/jobs\/([^/]+)\/output$/); - if (outputMatch && req.method === 'GET') { - if (!requireAuth(req, res)) return; - const jobId = outputMatch[1]; - if (!JOB_ID_RE.test(jobId)) { json(res, 400, { error: 'Invalid job ID format' }); return; } - const output = getLiveOutput(jobId); - json(res, 200, { output }); - return; - } - - // GET /api/jobs/:id/diff — current git diff for a job - const diffMatch = url.pathname.match(/^\/api\/jobs\/([^/]+)\/diff$/); - if (diffMatch && req.method === 'GET') { - if (!requireAuth(req, res)) return; - const jobId = diffMatch[1]; - if (!JOB_ID_RE.test(jobId)) { json(res, 400, { error: 'Invalid job ID format' }); return; } - const { jobs } = getJobs(CACHE_DIR); - const job = jobs.find(j => j.id === jobId); - if (!job) { json(res, 404, { error: 'Job not found' }); return; } - if (!job.baselineSha) { json(res, 200, { files: [], diffs: {}, freshness: new Date().toISOString() }); return; } - try { - const diffData = getJobDiff(ROOT, job.baselineSha, job.preExistingDirtyFiles || []); - json(res, 200, diffData); - } catch (err) { - json(res, 500, { error: 'Failed to compute diff: ' + err.message }); - } - return; - } - - if (url.pathname === '/api/jobs/process' && req.method === 'POST') { - if (!requireAuth(req, res)) return; - startDiffPoller(); - const result = await processQueue(ROOT, CACHE_DIR, - (jobId, chunk) => { - sse.broadcast({ type: 'job-output', jobId, chunk }); - const hints = parseActivityHints(chunk); - for (const hint of hints) { - sse.broadcast({ type: 'job-activity', jobId, hint: hint.hint, file: hint.file }); - } - }, - (jobId, message) => { - sse.broadcast({ type: 'job-warning', jobId, message }); - }, - ); - if (result.launched > 0) { - sse.broadcast({ type: 'fleet-update', active: getActiveCount(), max: getMaxConcurrent() }); - const { jobs } = getJobs(CACHE_DIR); - if (!jobs.some(j => j.status === 'running')) stopDiffPoller(); - } - json(res, 200, result); - return; - } - - // POST /api/jobs/:id/merge — merge worktree back to current branch - const mergeMatch = url.pathname.match(/^\/api\/jobs\/([^/]+)\/merge$/); - if (mergeMatch && req.method === 'POST') { - if (!requireAuth(req, res)) return; - const jobId = mergeMatch[1]; - if (!JOB_ID_RE.test(jobId)) { json(res, 400, { error: 'Invalid job ID format' }); return; } - const { jobs } = getJobs(CACHE_DIR); - const job = jobs.find(j => j.id === jobId); - if (!job) { json(res, 404, { error: 'Job not found' }); return; } - if (job.status !== 'review') { json(res, 400, { error: `Cannot merge job with status "${job.status}" — must be in review` }); return; } - - // Broadcast merge lock - sse.broadcast({ type: 'merge-lock', locked: true, jobId }); - const body = await parseBody(req); - const message = body?.message || undefined; - - const result = mergeWorktree(ROOT, jobId, message); - - if (result.error === 'merge_in_progress') { - sse.broadcast({ type: 'merge-lock', locked: false, jobId }); - json(res, 423, { error: 'Another merge is in progress' }); - return; - } - if (result.error === 'working_tree_dirty') { - sse.broadcast({ type: 'merge-lock', locked: false, jobId }); - json(res, 400, { error: 'Working tree has uncommitted changes — commit or stash first' }); - return; - } - if (result.error === 'branch_not_found') { - sse.broadcast({ type: 'merge-lock', locked: false, jobId }); - json(res, 410, { error: 'Branch no longer exists — worktree was already cleaned up. Use Dismiss to clear this job.' }); - return; - } - if (!result.success && result.conflicts.length > 0) { - sse.broadcast({ type: 'merge-lock', locked: false, jobId }); - json(res, 409, { error: 'merge_conflict', conflicts: result.conflicts }); - return; - } - - // Success — destroy worktree and update job status - try { destroyWorktree(ROOT, jobId); } catch { /* best effort */ } - // Update job status to merged - const s = getJobs(CACHE_DIR); // refresh - // Direct queue file update for status change - const allJobs = s.jobs.map(j => - j.id === jobId ? { ...j, status: 'merged', worktreePath: null, branchName: null } : j, - ); - const { writeFileSync: wfs } = await import('node:fs'); - wfs(resolve(CACHE_DIR, 'agent-queue.json'), JSON.stringify(allJobs, null, 2)); - - cachedData = null; - sse.broadcast({ type: 'merge-lock', locked: false, jobId }); - sse.broadcast({ type: 'job-update', jobId, status: 'merged' }); - json(res, 200, { success: true, filesChanged: result.filesChanged, commitSha: result.commitSha }); - return; - } - - // === FLEET ROUTES === - - if (url.pathname === '/api/fleet/status' && req.method === 'GET') { - const { jobs } = getJobs(CACHE_DIR); - const active = jobs.filter(j => j.status === 'running' || j.status === 'provisioning').length; - const queued = jobs.filter(j => j.status === 'queued').length; - const review = jobs.filter(j => j.status === 'review').length; - const worktrees = listWorktrees(ROOT).map(w => ({ - jobId: w.jobId, - path: w.path, - branch: w.branch, - diskMB: Math.round(getWorktreeDiskUsage(ROOT, w.jobId) / (1024 * 1024)), - })); - json(res, 200, { active, queued, review, maxConcurrent: getMaxConcurrent(), worktrees }); - return; - } - - if (url.pathname === '/api/fleet/config' && req.method === 'POST') { - if (!requireAuth(req, res)) return; - const body = await parseBody(req); - if (!body) { json(res, 400, { error: 'Invalid JSON' }); return; } - if (typeof body.maxConcurrent === 'number') { - if (body.maxConcurrent < 1 || body.maxConcurrent > 8) { - json(res, 400, { error: 'maxConcurrent must be between 1 and 8' }); return; - } - setMaxConcurrent(body.maxConcurrent); - } - // Save to user prefs for persistence - const prefsPath = resolve(CACHE_DIR, 'user-prefs.json'); - let prefs = {}; - try { prefs = JSON.parse(readFileSync(prefsPath, 'utf8')); } catch { /* new file */ } - if (body.maxConcurrent) prefs.maxConcurrent = body.maxConcurrent; - if (body.autopilot) prefs.autopilot = { ...prefs.autopilot, ...body.autopilot }; - _writeFileSync(prefsPath, JSON.stringify(prefs, null, 2)); - json(res, 200, { ok: true }); - return; - } - - if (url.pathname === '/api/worktrees' && req.method === 'GET') { - if (!requireAuth(req, res)) return; - const worktrees = listWorktrees(ROOT); - json(res, 200, { worktrees }); - return; - } - - // === BRIEFING ROUTE === - - if (url.pathname === '/api/briefing' && req.method === 'GET') { - // Use cached data if available, otherwise collect fresh - if (!cachedData) cachedData = await collectData(true); - const authenticated = isAuthorized(req); - const briefing = await generateBriefing(ROOT, CACHE_DIR, { - sessionData: cachedData.sessionData, - suggestions: cachedData.suggestions, - backlog: cachedData.backlog, - // Only pass agent jobs for authenticated callers - agentJobs: authenticated ? getJobs(CACHE_DIR) : null, - }); - if (!authenticated) { - // Strip all agent-job-derived fields from unauthenticated responses - const { agentResults: _a, deadJobs: _d, stats: _s, autoQueueSuggestions: _q, ...publicBriefing } = briefing; - json(res, 200, publicBriefing); - } else { - json(res, 200, briefing); - } - return; - } - - // === DASHBOARD HTML === - - if (url.pathname === '/' || url.pathname === '/index.html') { - const html = readFileSync(resolve(__dirname, 'dashboard.html'), 'utf8'); - res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); - res.end(html); - return; - } - - res.writeHead(404); - res.end('Not found'); - } catch (err) { - console.error(` [Server] ${req.method} ${url.pathname} error:`, err); - json(res, 500, { error: 'Internal server error' }); - } -}); - -// Startup recovery -async function startup() { - console.log('\n Project Map Dashboard'); - console.log(` http://${BIND_HOST}:${PORT}?token=${AUTH_TOKEN}`); - // Also write token to a file for programmatic access - try { - _writeFileSync(resolve(CACHE_DIR, 'token.txt'), AUTH_TOKEN, { mode: 0o600 }); - } catch { /* non-critical */ } - - // Recover stale sessions - try { - const recovery = recoverStaleSession(ROOT, CACHE_DIR); - if (recovery.recovered) console.log(' [Session] Recovered stale session'); - } catch (e) { console.error(' [Session] Recovery error:', e.message); } - - // Recover orphaned agent jobs (fleet-aware — also cleans stale worktrees) - try { - const orphans = recoverOrphans(CACHE_DIR, ROOT); - if (orphans.recovered > 0) console.log(` [Fleet] Recovered ${orphans.recovered} orphaned jobs`); - } catch (e) { console.error(' [Fleet] Orphan recovery error:', e.message); } - - // Load saved fleet config - try { - const prefsPath = resolve(CACHE_DIR, 'user-prefs.json'); - if (existsSync(prefsPath)) { - const prefs = JSON.parse(readFileSync(prefsPath, 'utf8')); - if (prefs.maxConcurrent) setMaxConcurrent(prefs.maxConcurrent); - } - } catch { /* use defaults */ } - - // Prune old results - try { - const pruned = pruneResults(CACHE_DIR); - if (pruned.pruned > 0) console.log(` [Agent] Pruned ${pruned.pruned} old results`); - } catch (e) { /* silent */ } - - // Agent health check - try { - const health = healthCheck(); - if (health.available) { - console.log(` [Agent] Claude CLI available (${health.version})`); - } else { - console.log(' [Agent] Claude CLI not available — agent jobs disabled'); - } - } catch { /* silent */ } - - // Auto-process agent queue every 30 seconds (fleet-aware) - processInterval = setInterval(async () => { - try { - const { jobs } = getJobs(CACHE_DIR); - const hasQueued = jobs.some(j => j.status === 'queued'); - const activeCount = getActiveCount(); - if (hasQueued && activeCount < getMaxConcurrent()) { - console.log(` [Fleet] Auto-processing queue (${activeCount}/${getMaxConcurrent()} active)...`); - startDiffPoller(); - const result = await processQueue(ROOT, CACHE_DIR, - (jobId, chunk) => { - sse.broadcast({ type: 'job-output', jobId, chunk }); - const hints = parseActivityHints(chunk); - for (const hint of hints) { - sse.broadcast({ type: 'job-activity', jobId, hint: hint.hint, file: hint.file }); - } - }, - (jobId, message) => { - sse.broadcast({ type: 'job-warning', jobId, message }); - }, - ); - if (result.launched > 0) { - sse.broadcast({ type: 'fleet-update', active: getActiveCount(), max: getMaxConcurrent() }); - } - const { jobs: currentJobs } = getJobs(CACHE_DIR); - if (!currentJobs.some(j => j.status === 'running')) stopDiffPoller(); - } - } catch { /* silent */ } - }, 30000); - - console.log(' File watcher active — dashboard auto-refreshes on changes'); - console.log(` Fleet: max ${getMaxConcurrent()} concurrent agents, auto-processes every 30s\n`); -} - -server.listen(PORT, BIND_HOST, startup); - -process.on('SIGINT', () => { - if (processInterval) clearInterval(processInterval); - stopDiffPoller(); - watcher.close(); - server.close(); - process.exit(0); -}); - -// Exports for testing — token only available in test environment -export function getAuthToken() { - if (process.env.NODE_ENV !== 'test') throw new Error('Token access restricted to test environment'); - return AUTH_TOKEN; -} -export { isAuthorized, server, BIND_HOST }; diff --git a/tools/project-map/tests/agent-diff.test.mjs b/tools/project-map/tests/agent-diff.test.mjs deleted file mode 100644 index a0bfd813..00000000 --- a/tools/project-map/tests/agent-diff.test.mjs +++ /dev/null @@ -1,104 +0,0 @@ -import { describe, it, beforeEach, afterEach } from 'node:test'; -import assert from 'node:assert/strict'; -import { mkdirSync, rmSync, writeFileSync } from 'node:fs'; -import { resolve, dirname } from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { execSync } from 'node:child_process'; -import { parseActivityHints, getJobDiff } from '../lib/agent-manager.mjs'; - -const __dirname = dirname(fileURLToPath(import.meta.url)); -const GIT_TEST_DIR = resolve(__dirname, '.test-git-diff'); - -describe('parseActivityHints', () => { - it('extracts Go file paths', () => { - const chunk = 'Reading Server/ws/handler.go for analysis'; - const hints = parseActivityHints(chunk); - assert.equal(hints.length, 1); - assert.equal(hints[0].file, 'Server/ws/handler.go'); - }); - - it('extracts TypeScript file paths', () => { - const chunk = 'Checking Client/tauri-client/src/stores/voice.ts'; - const hints = parseActivityHints(chunk); - assert.equal(hints.length, 1); - assert.equal(hints[0].file, 'Client/tauri-client/src/stores/voice.ts'); - }); - - it('extracts Rust file paths', () => { - const chunk = 'Found issue in src-tauri/src/commands.rs'; - const hints = parseActivityHints(chunk); - assert.equal(hints.length, 1); - assert.equal(hints[0].file, 'src-tauri/src/commands.rs'); - }); - - it('extracts multiple paths from one chunk', () => { - const chunk = 'Comparing Server/ws/handler.go with Server/ws/conn.go'; - const hints = parseActivityHints(chunk); - assert.equal(hints.length, 2); - }); - - it('returns empty array when no paths found', () => { - const hints = parseActivityHints('Just some regular text here'); - assert.deepEqual(hints, []); - }); - - it('handles null/undefined input', () => { - assert.deepEqual(parseActivityHints(null), []); - assert.deepEqual(parseActivityHints(undefined), []); - assert.deepEqual(parseActivityHints(''), []); - }); -}); - -describe('getJobDiff', () => { - beforeEach(() => { - rmSync(GIT_TEST_DIR, { recursive: true, force: true }); - mkdirSync(GIT_TEST_DIR, { recursive: true }); - execSync('git init', { cwd: GIT_TEST_DIR, stdio: 'pipe' }); - execSync('git config user.email "test@test.com"', { cwd: GIT_TEST_DIR, stdio: 'pipe' }); - execSync('git config user.name "Test"', { cwd: GIT_TEST_DIR, stdio: 'pipe' }); - writeFileSync(resolve(GIT_TEST_DIR, 'file.txt'), 'original\n'); - execSync('git add . && git commit -m "init"', { cwd: GIT_TEST_DIR, stdio: 'pipe' }); - }); - - afterEach(() => { - rmSync(GIT_TEST_DIR, { recursive: true, force: true }); - }); - - it('returns empty files when nothing changed', () => { - const headSha = execSync('git rev-parse HEAD', { cwd: GIT_TEST_DIR, encoding: 'utf8' }).trim(); - const result = getJobDiff(GIT_TEST_DIR, headSha, []); - assert.deepEqual(result.files, []); - assert.deepEqual(result.diffs, {}); - }); - - it('detects modified files with +/- counts', () => { - const headSha = execSync('git rev-parse HEAD', { cwd: GIT_TEST_DIR, encoding: 'utf8' }).trim(); - writeFileSync(resolve(GIT_TEST_DIR, 'file.txt'), 'modified\nline2\n'); - const result = getJobDiff(GIT_TEST_DIR, headSha, []); - assert.equal(result.files.length, 1); - assert.equal(result.files[0].path, 'file.txt'); - assert.equal(result.files[0].status, 'M'); - assert.ok(result.files[0].additions >= 1); - assert.ok(typeof result.diffs['file.txt'] === 'string'); - assert.ok(result.diffs['file.txt'].includes('modified')); - }); - - it('detects new files', () => { - const headSha = execSync('git rev-parse HEAD', { cwd: GIT_TEST_DIR, encoding: 'utf8' }).trim(); - writeFileSync(resolve(GIT_TEST_DIR, 'newfile.txt'), 'brand new\n'); - const result = getJobDiff(GIT_TEST_DIR, headSha, []); - const newFile = result.files.find(f => f.path === 'newfile.txt'); - assert.ok(newFile); - assert.equal(newFile.status, 'A'); - }); - - it('excludes pre-existing dirty files', () => { - const headSha = execSync('git rev-parse HEAD', { cwd: GIT_TEST_DIR, encoding: 'utf8' }).trim(); - writeFileSync(resolve(GIT_TEST_DIR, 'file.txt'), 'modified\n'); - writeFileSync(resolve(GIT_TEST_DIR, 'newfile.txt'), 'new\n'); - const result = getJobDiff(GIT_TEST_DIR, headSha, ['file.txt']); - assert.equal(result.files.length, 1); - assert.equal(result.files[0].path, 'newfile.txt'); - assert.ok(!result.diffs['file.txt']); - }); -}); diff --git a/tools/project-map/tests/agent-queue.test.mjs b/tools/project-map/tests/agent-queue.test.mjs deleted file mode 100644 index bf1b1845..00000000 --- a/tools/project-map/tests/agent-queue.test.mjs +++ /dev/null @@ -1,89 +0,0 @@ -/** - * Tests for agent-manager.mjs — verifies that a spawn failure - * does not wedge the queue (lock must be released). - * - * Uses setSpawnCommand() to inject a guaranteed-nonexistent binary, - * making the test deterministic regardless of whether Claude CLI is installed. - */ -import { describe, it, beforeEach, afterEach } from 'node:test'; -import assert from 'node:assert/strict'; -import { mkdirSync, rmSync } from 'node:fs'; -import { resolve, dirname } from 'node:path'; -import { fileURLToPath } from 'node:url'; - -import { - createJob, - processQueue, - getJobs, - isQueueLocked, - resetQueueLock, - setSpawnCommand, -} from '../lib/agent-manager.mjs'; - -const __dirname = dirname(fileURLToPath(import.meta.url)); -const TEST_CACHE = resolve(__dirname, '.test-cache-agent'); -const FAKE_ROOT = resolve(__dirname, '.test-root-agent'); - -// A binary name that will never exist on any platform -const NONEXISTENT_CMD = '__owncord_test_no_such_binary_12345__'; - -describe('agent queue lock recovery', () => { - beforeEach(() => { - mkdirSync(TEST_CACHE, { recursive: true }); - mkdirSync(FAKE_ROOT, { recursive: true }); - resetQueueLock(); - setSpawnCommand(NONEXISTENT_CMD); - }); - - afterEach(() => { - resetQueueLock(); - setSpawnCommand('claude'); - rmSync(TEST_CACHE, { recursive: true, force: true }); - rmSync(FAKE_ROOT, { recursive: true, force: true }); - }); - - it('releases lock after spawn error (command not found)', async () => { - createJob(TEST_CACHE, { - type: 'research', - target: 'api', - priority: 1, - }); - - assert.equal(isQueueLocked(), false, 'lock should be free before processing'); - - // processQueue spawns the nonexistent command which will ENOENT - const result = await processQueue(FAKE_ROOT, TEST_CACHE); - - assert.equal(result.processed, true, 'should have attempted processing'); - assert.equal(isQueueLocked(), false, 'lock MUST be released after spawn failure'); - }); - - it('allows subsequent jobs after a failed spawn', async () => { - createJob(TEST_CACHE, { type: 'research', target: 'ws', priority: 1 }); - - // First job will fail due to nonexistent command - await processQueue(FAKE_ROOT, TEST_CACHE); - assert.equal(isQueueLocked(), false, 'lock should be free after first failure'); - - // Create and process another job — should not be blocked - createJob(TEST_CACHE, { type: 'code-review', target: 'auth', priority: 1 }); - const result2 = await processQueue(FAKE_ROOT, TEST_CACHE); - - // It should attempt processing (not be stuck on 'locked') - assert.notEqual(result2.reason, 'locked', 'queue should not be wedged'); - }); - - it('marks job as dead after max retries', async () => { - createJob(TEST_CACHE, { type: 'research', target: 'db', priority: 1 }); - - // Exhaust retries (maxRetries defaults to 2) - await processQueue(FAKE_ROOT, TEST_CACHE); - await processQueue(FAKE_ROOT, TEST_CACHE); - - const { jobs } = getJobs(TEST_CACHE); - const job = jobs.find(j => j.target === 'db'); - assert.ok(job, 'job should still exist in queue'); - assert.equal(job.status, 'dead', 'job should be dead after max retries'); - assert.ok(job.error, 'job should have an error message'); - }); -}); diff --git a/tools/project-map/tests/backlog-parser.test.mjs b/tools/project-map/tests/backlog-parser.test.mjs deleted file mode 100644 index 471e4c06..00000000 --- a/tools/project-map/tests/backlog-parser.test.mjs +++ /dev/null @@ -1,80 +0,0 @@ -/** - * Tests for backlog-parser.mjs — verifies both task syntaxes are parsed - * and open/done counts match. - */ -import { describe, it } from 'node:test'; -import assert from 'node:assert/strict'; -import { readFileSync, existsSync } from 'node:fs'; -import { resolve, dirname } from 'node:path'; -import { fileURLToPath } from 'node:url'; - -const __dirname = dirname(fileURLToPath(import.meta.url)); -const ROOT = resolve(__dirname, '../../..'); - -// Direct regex test (same regex the parser uses) -const TASK_RE = /^- \[([ x])\] \*\*T-(\d+)(?::\*\*|\*\*:)\s*(.+)/; - -describe('backlog parser regex', () => { - it('matches colon-inside-bold format: **T-165:**', () => { - const line = '- [x] **T-165:** Fix BUG-046 — wrap voice switchActiveDevice — 2026-03-28'; - const m = line.match(TASK_RE); - assert.ok(m, 'should match'); - assert.equal(m[1], 'x'); - assert.equal(m[2], '165'); - assert.ok(m[3].startsWith('Fix BUG-046')); - }); - - it('matches colon-after-bold format: **T-033**:', () => { - const line = '- [x] **T-033**: Fix voice state broadcast silent DB failures — 2026-03-21'; - const m = line.match(TASK_RE); - assert.ok(m, 'should match'); - assert.equal(m[1], 'x'); - assert.equal(m[2], '033'); - assert.ok(m[3].startsWith('Fix voice state')); - }); - - it('matches open tasks (unchecked)', () => { - const line = '- [ ] **T-195:** User profile/password/session management endpoints'; - const m = line.match(TASK_RE); - assert.ok(m, 'should match'); - assert.equal(m[1], ' '); - assert.equal(m[2], '195'); - }); - - it('does not match non-task lines', () => { - assert.equal('## Some Section'.match(TASK_RE), null); - assert.equal('- Regular bullet point'.match(TASK_RE), null); - assert.equal('- [ ] No bold task id here'.match(TASK_RE), null); - }); -}); - -describe('backlog parser integration', () => { - it('parses actual Backlog.md and counts match', async () => { - const { parseBacklog } = await import('../lib/backlog-parser.mjs'); - const result = await parseBacklog(ROOT); - - // Read the file directly and count with both regexes - const backlogPath = resolve(ROOT, 'docs/brain/02-Tasks/Backlog.md'); - if (!existsSync(backlogPath)) { - // Skip if file doesn't exist in CI - return; - } - const content = readFileSync(backlogPath, 'utf8'); - const lines = content.split('\n'); - - let expectedOpen = 0; - let expectedDone = 0; - for (const line of lines) { - const m = line.match(TASK_RE); - if (m) { - if (m[1] === 'x') expectedDone++; - else expectedOpen++; - } - } - - assert.equal(result.openCount, expectedOpen, `open count mismatch: got ${result.openCount}, expected ${expectedOpen}`); - assert.equal(result.doneCount, expectedDone, `done count mismatch: got ${result.doneCount}, expected ${expectedDone}`); - assert.ok(result.tasks.length > 0, 'should find tasks'); - assert.equal(result.tasks.length, expectedOpen + expectedDone, 'total tasks should match'); - }); -}); diff --git a/tools/project-map/tests/briefing-and-normalization.test.mjs b/tools/project-map/tests/briefing-and-normalization.test.mjs deleted file mode 100644 index 97268b1b..00000000 --- a/tools/project-map/tests/briefing-and-normalization.test.mjs +++ /dev/null @@ -1,254 +0,0 @@ -/** - * Behavioral tests for: - * 1. Root-level Server/*.go files normalize to 'server-root' - * 2. Unauthenticated /api/briefing does not leak agent job data - */ -import { describe, it, before, after } from 'node:test'; -import assert from 'node:assert/strict'; -import { createServer } from 'node:http'; - -// --------------------------------------------------------------------------- -// 1. Root package normalization — behavioral tests calling the real functions -// --------------------------------------------------------------------------- - -describe('root package normalization', () => { - let resolveModule, fileToModule; - - before(async () => { - ({ resolveModule } = await import('../lib/git-scanner.mjs')); - ({ fileToModule } = await import('../lib/session-manager.mjs')); - }); - - const rootFiles = ['Server/main.go', 'Server/go.mod', 'Server/go.sum', 'Server/Makefile']; - const subDirFiles = [ - { path: 'Server/ws/handler.go', expected: 'ws' }, - { path: 'Server/db/models.go', expected: 'db' }, - { path: 'Server/auth/middleware.go', expected: 'auth' }, - ]; - - for (const filePath of rootFiles) { - it(`git-scanner: ${filePath} → server-root`, () => { - assert.equal(resolveModule(filePath), 'server-root'); - }); - - it(`session-manager: ${filePath} → server-root`, () => { - assert.equal(fileToModule(filePath), 'server-root'); - }); - } - - for (const { path, expected } of subDirFiles) { - it(`git-scanner: ${path} → ${expected}`, () => { - assert.equal(resolveModule(path), expected); - }); - - it(`session-manager: ${path} → ${expected}`, () => { - assert.equal(fileToModule(path), expected); - }); - } - - it('git-scanner and session-manager agree on all test paths', () => { - const all = [...rootFiles, ...subDirFiles.map(s => s.path)]; - for (const p of all) { - assert.equal(resolveModule(p), fileToModule(p), - `mismatch on ${p}: git-scanner=${resolveModule(p)}, session-manager=${fileToModule(p)}`); - } - }); -}); - -// --------------------------------------------------------------------------- -// 2. Briefing leak — real HTTP test against the server -// --------------------------------------------------------------------------- - -describe('/api/briefing unauthenticated leak prevention', () => { - let generateBriefing; - - before(async () => { - ({ generateBriefing } = await import('../lib/morning-briefing.mjs')); - }); - - it('generateBriefing with agentJobs=null produces no job data', async () => { - const briefing = await generateBriefing('.', '.', { - sessionData: null, - suggestions: null, - backlog: null, - agentJobs: null, - }); - assert.deepEqual(briefing.agentResults, [], 'agentResults should be empty'); - assert.deepEqual(briefing.deadJobs, [], 'deadJobs should be empty'); - assert.deepEqual(briefing.autoQueueSuggestions, [], 'autoQueueSuggestions should be empty when agentJobs is null'); - assert.equal(briefing.stats.completedJobs, 0); - assert.equal(briefing.stats.deadJobs, 0); - }); - - it('generateBriefing with agentJobs includes job data', async () => { - const fakeJobs = { - jobs: [ - { id: 'j-1', type: 'research', target: 'ws', status: 'completed', completedAt: new Date().toISOString() }, - { id: 'j-2', type: 'fix-debt', target: 'db', status: 'dead', error: 'timeout', retryCount: 3 }, - ], - }; - const briefing = await generateBriefing('.', '.', { - sessionData: null, - suggestions: null, - backlog: null, - agentJobs: fakeJobs, - }); - assert.ok(briefing.agentResults.length > 0, 'should have agent results'); - assert.ok(briefing.deadJobs.length > 0, 'should have dead jobs'); - assert.ok(briefing.agentResults[0].id === 'j-1', 'should contain job id'); - }); - - it('server strips job fields from unauthenticated briefing response', async () => { - // Read server source to verify the stripping logic - const { readFileSync } = await import('node:fs'); - const { resolve, dirname } = await import('node:path'); - const { fileURLToPath } = await import('node:url'); - const __dirname = dirname(fileURLToPath(import.meta.url)); - const source = readFileSync(resolve(__dirname, '../server.mjs'), 'utf8'); - - // The /api/briefing route must check auth and strip fields - const briefingRoute = source.slice( - source.indexOf("url.pathname === '/api/briefing'"), - source.indexOf("url.pathname === '/api/briefing'") + 800 - ); - assert.ok(briefingRoute.includes('isAuthorized(req)'), 'briefing route should check auth'); - assert.ok(briefingRoute.includes('agentJobs: authenticated'), 'should only pass agentJobs when authenticated'); - assert.ok(briefingRoute.includes('agentResults'), 'should strip agentResults'); - assert.ok(briefingRoute.includes('deadJobs'), 'should strip deadJobs'); - assert.ok(briefingRoute.includes('autoQueueSuggestions'), 'should strip autoQueueSuggestions'); - assert.ok(briefingRoute.includes('stats'), 'should strip stats'); - }); - - it('HTTP: unauthenticated briefing has no job fields', async () => { - // Spin up a minimal test server that mimics the briefing route logic - const { generateBriefing } = await import('../lib/morning-briefing.mjs'); - - const fakeJobs = { - jobs: [ - { id: 'j-1', type: 'research', target: 'ws', status: 'completed', completedAt: new Date().toISOString() }, - { id: 'j-2', type: 'fix-debt', target: 'db', status: 'dead', error: 'timeout', retryCount: 3 }, - ], - }; - - const TOKEN = 'test-secret-token'; - - const srv = createServer(async (req, res) => { - const authenticated = (req.headers['authorization'] || '') === `Bearer ${TOKEN}`; - const briefing = await generateBriefing('.', '.', { - sessionData: null, - suggestions: null, - backlog: null, - agentJobs: authenticated ? fakeJobs : null, - }); - let payload; - if (!authenticated) { - const { agentResults: _a, deadJobs: _d, stats: _s, autoQueueSuggestions: _q, ...pub } = briefing; - payload = pub; - } else { - payload = briefing; - } - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify(payload)); - }); - - await new Promise(r => srv.listen(0, '127.0.0.1', r)); - const port = srv.address().port; - - try { - // Unauthenticated request - const unauthRes = await fetch(`http://127.0.0.1:${port}/api/briefing`); - const unauthData = await unauthRes.json(); - - assert.equal(unauthData.agentResults, undefined, 'agentResults must not be present'); - assert.equal(unauthData.deadJobs, undefined, 'deadJobs must not be present'); - assert.equal(unauthData.stats, undefined, 'stats must not be present'); - assert.equal(unauthData.autoQueueSuggestions, undefined, 'autoQueueSuggestions must not be present'); - assert.ok(unauthData.greeting, 'greeting should still be present'); - assert.ok(unauthData.suggestedTasks !== undefined, 'suggestedTasks should still be present'); - - // Authenticated request — should have all fields - const authRes = await fetch(`http://127.0.0.1:${port}/api/briefing`, { - headers: { Authorization: `Bearer ${TOKEN}` }, - }); - const authData = await authRes.json(); - - assert.ok(Array.isArray(authData.agentResults), 'authenticated should have agentResults'); - assert.ok(Array.isArray(authData.deadJobs), 'authenticated should have deadJobs'); - assert.ok(authData.stats, 'authenticated should have stats'); - assert.ok(authData.agentResults.length > 0, 'authenticated should have job data'); - } finally { - srv.close(); - } - }); - - it('HTTP: unauthenticated /api/data strips job fields from embedded briefing', async () => { - const { generateBriefing } = await import('../lib/morning-briefing.mjs'); - - const fakeJobs = { - jobs: [ - { id: 'j-1', type: 'research', target: 'ws', status: 'completed', completedAt: new Date().toISOString() }, - { id: 'j-2', type: 'fix-debt', target: 'db', status: 'dead', error: 'timeout', retryCount: 3 }, - ], - }; - - const TOKEN = 'test-data-token'; - - // Simulate the /api/data route logic with an embedded briefing - const srv = createServer(async (req, res) => { - const authenticated = (req.headers['authorization'] || '') === `Bearer ${TOKEN}`; - - // Build a cachedData-like object with briefing containing job data - const briefing = await generateBriefing('.', '.', { - sessionData: null, - suggestions: null, - backlog: null, - agentJobs: fakeJobs, - }); - const cachedData = { modules: [], agentJobs: fakeJobs, briefing, timestamp: new Date().toISOString() }; - - let payload; - if (!authenticated) { - const { agentJobs: _stripped, briefing: fullBriefing, ...publicData } = cachedData; - if (fullBriefing) { - const { agentResults: _a, deadJobs: _d, stats: _s, autoQueueSuggestions: _q, ...publicBriefing } = fullBriefing; - publicData.briefing = publicBriefing; - } - payload = publicData; - } else { - payload = cachedData; - } - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify(payload)); - }); - - await new Promise(r => srv.listen(0, '127.0.0.1', r)); - const port = srv.address().port; - - try { - // Unauthenticated — briefing should be sanitized - const unauthRes = await fetch(`http://127.0.0.1:${port}/api/data`); - const unauthData = await unauthRes.json(); - - assert.equal(unauthData.agentJobs, undefined, 'agentJobs must not be present'); - assert.ok(unauthData.briefing, 'briefing should still exist'); - assert.equal(unauthData.briefing.agentResults, undefined, 'briefing.agentResults must not be present'); - assert.equal(unauthData.briefing.deadJobs, undefined, 'briefing.deadJobs must not be present'); - assert.equal(unauthData.briefing.stats, undefined, 'briefing.stats must not be present'); - assert.equal(unauthData.briefing.autoQueueSuggestions, undefined, 'briefing.autoQueueSuggestions must not be present'); - assert.ok(unauthData.briefing.greeting, 'briefing.greeting should remain'); - - // Authenticated — full data - const authRes = await fetch(`http://127.0.0.1:${port}/api/data`, { - headers: { Authorization: `Bearer ${TOKEN}` }, - }); - const authData = await authRes.json(); - - assert.ok(authData.agentJobs, 'authenticated should have agentJobs'); - assert.ok(authData.briefing.agentResults, 'authenticated briefing should have agentResults'); - assert.ok(authData.briefing.deadJobs, 'authenticated briefing should have deadJobs'); - assert.ok(authData.briefing.stats, 'authenticated briefing should have stats'); - } finally { - srv.close(); - } - }); -}); diff --git a/tools/project-map/tests/cache-mode.test.mjs b/tools/project-map/tests/cache-mode.test.mjs deleted file mode 100644 index 3cd644c1..00000000 --- a/tools/project-map/tests/cache-mode.test.mjs +++ /dev/null @@ -1,58 +0,0 @@ -/** - * Tests for server cache behavior — verifies that quick-mode cached data - * is not incorrectly reused for full-mode requests. - */ -import { describe, it } from 'node:test'; -import assert from 'node:assert/strict'; - -describe('server cache mode awareness', () => { - it('source code has mode-aware cache logic', async () => { - const { readFileSync } = await import('node:fs'); - const { resolve, dirname } = await import('node:path'); - const { fileURLToPath } = await import('node:url'); - - const __dirname = dirname(fileURLToPath(import.meta.url)); - const source = readFileSync(resolve(__dirname, '../server.mjs'), 'utf8'); - - // Should track cache mode separately - assert.ok(source.includes('cachedDataMode'), 'should have cachedDataMode variable'); - - // The /api/data handler should compare requested mode with cached mode - assert.ok(source.includes('requestedMode'), 'should compute requestedMode'); - - // Quick cache should not be valid for full requests - assert.ok( - source.includes('cachedDataMode === requestedMode'), - 'should compare cached mode with requested mode' - ); - }); - - it('cache logic prevents quick data from serving full requests', () => { - // Simulate the cache validation logic extracted from server.mjs - function isCacheValid(cachedData, cachedDataMode, requestedMode, hasRefresh) { - const cacheValid = cachedData && cachedDataMode === requestedMode && !hasRefresh; - const fullCacheForQuick = cachedData && cachedDataMode === 'full' && requestedMode === 'quick' && !hasRefresh; - return cacheValid || fullCacheForQuick; - } - - const data = { some: 'data' }; - - // Quick cache should serve quick requests - assert.ok(isCacheValid(data, 'quick', 'quick', false), 'quick cache serves quick request'); - - // Quick cache should NOT serve full requests - assert.ok(!isCacheValid(data, 'quick', 'full', false), 'quick cache must NOT serve full request'); - - // Full cache should serve full requests - assert.ok(isCacheValid(data, 'full', 'full', false), 'full cache serves full request'); - - // Full cache should serve quick requests (superset) - assert.ok(isCacheValid(data, 'full', 'quick', false), 'full cache serves quick request'); - - // No cached data - assert.ok(!isCacheValid(null, null, 'quick', false), 'no cache is not valid'); - - // Refresh flag forces re-fetch - assert.ok(!isCacheValid(data, 'quick', 'quick', true), 'refresh flag invalidates cache'); - }); -}); diff --git a/tools/project-map/tests/module-naming.test.mjs b/tools/project-map/tests/module-naming.test.mjs deleted file mode 100644 index 887151a6..00000000 --- a/tools/project-map/tests/module-naming.test.mjs +++ /dev/null @@ -1,129 +0,0 @@ -/** - * Tests for module name normalization — verifies that git-scanner, - * scanner, backlog-parser, and debt-scanner all produce the same - * canonical module keys so signals merge correctly. - */ -import { describe, it } from 'node:test'; -import assert from 'node:assert/strict'; - -// Import the resolveModule function from git-scanner by reading the source -// and extracting the function (git-scanner doesn't export it). -// Instead, we test the expected behavior by checking the patterns. -describe('module naming consistency', () => { - // These are the canonical module names used by scanner.mjs: - // Go packages: 'api', 'ws', 'db', 'auth', 'config', 'admin', etc. - // TS areas: 'lib', 'stores', 'components', 'pages', 'styles' - // Rust: 'tauri-rust' - - // git-scanner resolveModule should produce the SAME names (no prefixes). - // We test this by importing the git-scanner module and checking its output. - - it('git-scanner resolveModule produces unprefixed Go module names', async () => { - // Read the git-scanner source and check that resolveModule for Server paths - // no longer returns "go:" prefixed names - const { readFileSync } = await import('node:fs'); - const { resolve, dirname } = await import('node:path'); - const { fileURLToPath } = await import('node:url'); - - const __dirname = dirname(fileURLToPath(import.meta.url)); - const source = readFileSync(resolve(__dirname, '../lib/git-scanner.mjs'), 'utf8'); - - // Ensure "go:" prefix is NOT used in resolveModule - const resolveModuleFn = source.match(/function resolveModule\([\s\S]*?\n\}/); - assert.ok(resolveModuleFn, 'should find resolveModule function'); - - const fnBody = resolveModuleFn[0]; - assert.ok(!fnBody.includes('`go:'), 'resolveModule should not produce go: prefixed names'); - assert.ok(!fnBody.includes('`ts:'), 'resolveModule should not produce ts: prefixed names'); - assert.ok(!fnBody.includes("'go:"), 'resolveModule should not produce go: prefixed names (single quotes)'); - assert.ok(!fnBody.includes("'ts:"), 'resolveModule should not produce ts: prefixed names (single quotes)'); - }); - - it('session-manager fileToModule matches git-scanner convention', async () => { - const { readFileSync } = await import('node:fs'); - const { resolve, dirname } = await import('node:path'); - const { fileURLToPath } = await import('node:url'); - - const __dirname = dirname(fileURLToPath(import.meta.url)); - const sessionSource = readFileSync(resolve(__dirname, '../lib/session-manager.mjs'), 'utf8'); - const gitSource = readFileSync(resolve(__dirname, '../lib/git-scanner.mjs'), 'utf8'); - - // Both should use 'server-root' for Server/ root, not 'go:root' - assert.ok(sessionSource.includes("'server-root'"), 'session-manager should use server-root'); - assert.ok(gitSource.includes("'server-root'"), 'git-scanner should use server-root'); - - // Both should use 'client-config' not 'ts:config' - assert.ok(sessionSource.includes("'client-config'"), 'session-manager should use client-config'); - assert.ok(gitSource.includes("'client-config'"), 'git-scanner should use client-config'); - }); - - it('backlog and scanner module names have no type prefixes', async () => { - const { readFileSync } = await import('node:fs'); - const { resolve, dirname } = await import('node:path'); - const { fileURLToPath } = await import('node:url'); - - const __dirname = dirname(fileURLToPath(import.meta.url)); - const backlogSource = readFileSync(resolve(__dirname, '../lib/backlog-parser.mjs'), 'utf8'); - - // Backlog MODULE_KEYWORDS keys should be plain names - const keyMatch = backlogSource.match(/const MODULE_KEYWORDS = \{([\s\S]*?)\n\};/); - assert.ok(keyMatch, 'should find MODULE_KEYWORDS'); - - // Ensure no prefixed keys - assert.ok(!keyMatch[1].includes("'go:"), 'backlog keywords should not have go: prefix'); - assert.ok(!keyMatch[1].includes("'ts:"), 'backlog keywords should not have ts: prefix'); - }); - - it('suggestion engine merges modules from different sources under same key', async () => { - const { generateSuggestions } = await import('../lib/suggestion-engine.mjs'); - const { mkdirSync, rmSync } = await import('node:fs'); - const { resolve, dirname } = await import('node:path'); - const { fileURLToPath } = await import('node:url'); - - const __dirname = dirname(fileURLToPath(import.meta.url)); - const tmpCache = resolve(__dirname, '.test-cache-naming'); - mkdirSync(tmpCache, { recursive: true }); - - try { - // Simulate data from different sources all using 'components' (not 'ts:components') - const result = await generateSuggestions(tmpCache, { - priorities: [{ name: 'components', coverage: 40, type: 'typescript' }], - backlog: { - byPhase: { - bug: [{ id: 'T-001', description: 'UI button bug', modules: ['components'] }], - }, - }, - gitData: { - commitsByModule: { - components: { count: 15, lastCommit: new Date().toISOString() }, - }, - staleness: {}, - }, - debtData: { - summary: { - byModule: { - components: { markers: 3, largeFiles: 1, longFunctions: 0 }, - }, - }, - }, - }); - - // All signals should merge into a single 'components' entry - const compSuggestion = result.suggestions.find(s => s.module === 'components'); - assert.ok(compSuggestion, 'should have a components suggestion'); - - // It should have signals from coverage, bugs, git, AND debt - const signalTypes = new Set(compSuggestion.signals.map(s => s.signal)); - assert.ok(signalTypes.has('coverage-gap'), 'should have coverage signal'); - assert.ok(signalTypes.has('open-bug'), 'should have bug signal'); - assert.ok(signalTypes.has('high-churn'), 'should have churn signal'); - assert.ok(signalTypes.has('debt-markers'), 'should have debt signal'); - - // Verify NO 'ts:components' entry exists - const prefixed = result.suggestions.find(s => s.module === 'ts:components'); - assert.equal(prefixed, undefined, 'should NOT have ts:components — should be merged into components'); - } finally { - rmSync(tmpCache, { recursive: true, force: true }); - } - }); -}); diff --git a/tools/project-map/tests/server-auth.test.mjs b/tools/project-map/tests/server-auth.test.mjs deleted file mode 100644 index 036ddc20..00000000 --- a/tools/project-map/tests/server-auth.test.mjs +++ /dev/null @@ -1,73 +0,0 @@ -/** - * Tests for server.mjs — verifies that all mutating / job / session - * endpoints require auth and that the server binds to localhost. - */ -import { describe, it } from 'node:test'; -import assert from 'node:assert/strict'; - -describe('server security configuration', () => { - // Helper — read server source once per test (stateless, no port conflict) - async function readServerSource() { - const { readFileSync } = await import('node:fs'); - const { resolve, dirname } = await import('node:path'); - const { fileURLToPath } = await import('node:url'); - const __dirname = dirname(fileURLToPath(import.meta.url)); - return readFileSync(resolve(__dirname, '../server.mjs'), 'utf8'); - } - - it('binds to localhost by default', async () => { - const source = await readServerSource(); - assert.ok(source.includes("'127.0.0.1'"), 'default bind host should be 127.0.0.1'); - assert.ok(source.includes('BIND_HOST'), 'should use BIND_HOST variable'); - assert.ok(source.includes('server.listen(PORT, BIND_HOST'), 'server.listen should include host parameter'); - }); - - it('does not use wildcard CORS', async () => { - const source = await readServerSource(); - const corsLines = source.split('\n').filter(l => l.includes('Access-Control-Allow-Origin')); - for (const line of corsLines) { - assert.ok(!line.includes("'*'"), `CORS should not be wildcard: ${line.trim()}`); - } - }); - - it('generates an auth token on startup', async () => { - const source = await readServerSource(); - assert.ok(source.includes('randomBytes'), 'should use crypto.randomBytes for token generation'); - assert.ok(source.includes('AUTH_TOKEN'), 'should define AUTH_TOKEN'); - assert.ok(source.includes('Bearer'), 'should use Bearer token scheme'); - }); - - // ---- Protected route checks ---- - - const protectedRoutes = [ - { label: 'POST /api/jobs', pattern: "url.pathname === '/api/jobs' && req.method === 'POST'" }, - { label: 'DELETE /api/jobs/:id', pattern: "deleteMatch && req.method === 'DELETE'" }, - { label: 'GET /api/jobs/:id/result', pattern: "resultMatch && req.method === 'GET'" }, - { label: 'POST /api/jobs/process', pattern: "url.pathname === '/api/jobs/process'" }, - { label: 'GET /api/jobs', pattern: "url.pathname === '/api/jobs' && req.method === 'GET'" }, - { label: 'POST /api/worked-on', pattern: "url.pathname === '/api/worked-on'" }, - { label: 'POST /api/strategy', pattern: "url.pathname === '/api/strategy'" }, - { label: 'POST /api/session/start', pattern: "url.pathname === '/api/session/start'" }, - { label: 'POST /api/session/end', pattern: "url.pathname === '/api/session/end'" }, - ]; - - for (const { label, pattern } of protectedRoutes) { - it(`protects ${label} with requireAuth`, async () => { - const source = await readServerSource(); - const idx = source.indexOf(pattern); - assert.ok(idx !== -1, `should have handler for ${label}`); - const routeBlock = source.slice(idx, idx + 300); - assert.ok(routeBlock.includes('requireAuth'), `${label} should call requireAuth`); - }); - } - - it('strips agentJobs and briefing job fields from unauthenticated /api/data responses', async () => { - const source = await readServerSource(); - // The /api/data handler should check isAuthorized and strip agentJobs - assert.ok(source.includes('isAuthorized(req)'), '/api/data should check isAuthorized'); - assert.ok(source.includes('agentJobs: _stripped'), 'should destructure out agentJobs for public response'); - // Should also strip job-derived fields from the embedded briefing - assert.ok(source.includes('briefing: fullBriefing'), 'should destructure out briefing for sanitization'); - assert.ok(source.includes('publicBriefing'), 'should rebuild a public briefing without job fields'); - }); -});