mirror of
https://github.com/J3vb/OwnCord.git
synced 2026-09-03 03:50:00 +03:00
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:
+2
-8
@@ -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 ; \
|
||||
|
||||
@@ -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/…",
|
||||
|
||||
@@ -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
@@ -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))
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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...)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||
);
|
||||
@@ -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
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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"]}`
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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,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.
|
||||
//
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user