Merge pull request #1182 from J3vb/fix/security-hardening-review

P1: security-hardening remediation (Postgres deletion + W1-1..W2-7)
This commit is contained in:
J3vb
2026-07-19 10:49:31 +02:00
committed by GitHub
136 changed files with 2143 additions and 14924 deletions
+2 -8
View File
@@ -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 ; \
+3 -2
View File
@@ -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/…",
+10 -10
View File
@@ -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)
+3 -2
View File
@@ -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))
+27 -27
View File
@@ -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()
+126 -46
View File
@@ -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)
+13 -13
View File
@@ -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"
+7 -7
View File
@@ -12,7 +12,7 @@ import (
func TestCreateChannel_TextUnderTextCategory_OK(t *testing.T) {
database := openAdminTestDB(t)
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, 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
+46 -64
View File
@@ -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 {
+1 -1
View File
@@ -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 {
+3 -3
View File
@@ -18,7 +18,7 @@ import (
// session has expired is rejected with 401.
func TestAdminAuthMiddleware_ExpiredSession(t *testing.T) {
database := openAdminTestDB(t)
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, 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)
+4 -1
View File
@@ -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
+7 -7
View File
@@ -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)
+19
View File
@@ -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 {
+12 -12
View File
@@ -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)
+11 -1
View File
@@ -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)
+4 -2
View File
@@ -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
+43
View File
@@ -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)
+3
View File
@@ -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
+10 -6
View File
@@ -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)
})
}
+38
View File
@@ -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"}
+62 -7
View File
@@ -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: <spoofed>, <real>`) 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.
+13 -1
View File
@@ -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)
}
+11 -3
View File
@@ -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 {
+10 -2
View File
@@ -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",
+6 -1
View File
@@ -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 {
+4 -3
View File
@@ -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.
+6 -2
View File
@@ -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
+8 -5
View File
@@ -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
+28 -27
View File
@@ -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.
+14 -5
View File
@@ -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...)
+47 -3
View File
@@ -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) {
+1 -1
View File
@@ -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 {
-307
View File
@@ -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
}
-169
View File
@@ -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
}
-86
View File
@@ -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
}
-381
View File
@@ -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
}
-34
View File
@@ -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,
}
}
-241
View File
@@ -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
}
-109
View File
@@ -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
}
-140
View File
@@ -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
}
-74
View File
@@ -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
}
-479
View File
@@ -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
}
-218
View File
@@ -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"`
}
-175
View File
@@ -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
}
-48
View File
@@ -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
}
-197
View File
@@ -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)
-78
View File
@@ -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
}
-165
View File
@@ -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
}
-221
View File
@@ -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
}
-219
View File
@@ -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
}
-371
View File
@@ -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
}
-55
View File
@@ -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;
@@ -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;
-18
View File
@@ -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;
-69
View File
@@ -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;
-64
View File
@@ -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;
-19
View File
@@ -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;
-23
View File
@@ -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;
-15
View File
@@ -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;
-79
View File
@@ -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;
-36
View File
@@ -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;
-9
View File
@@ -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;
-12
View File
@@ -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;
-29
View File
@@ -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;
-49
View File
@@ -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;
-51
View File
@@ -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;
-88
View File
@@ -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;
+2 -4
View File
@@ -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
+10 -9
View File
@@ -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=
+5 -24
View File
@@ -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)
@@ -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)
);
-17
View File
@@ -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
+6 -1
View File
@@ -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
+62 -47
View File
@@ -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
+66
View File
@@ -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")
+1 -1
View File
@@ -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
+67
View File
@@ -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")
}
}
+15
View File
@@ -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,
+107 -12
View File
@@ -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
+113
View File
@@ -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"]}`
+26 -3
View File
@@ -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 {
+119
View File
@@ -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()
+65 -8
View File
@@ -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)
}
+108
View File
@@ -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")
}
}
+1 -1
View File
@@ -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),
}
}
+29 -5
View File
@@ -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.
+81
View File
@@ -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)
}
}
-20
View File
@@ -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"
+74 -16
View File
@@ -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 {
-833
View File
@@ -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)
+35 -23
View File
@@ -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)
+1 -1
View File
@@ -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)
}
+1 -1
View File
@@ -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.
//
+55 -7
View File
@@ -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)
+1
View File
@@ -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,
+1 -2
View File
@@ -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),
@@ -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}
+40 -4
View File
@@ -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 {
+1
View File
@@ -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,

Some files were not shown because too many files have changed in this diff Show More