From 5f1d6fc287f392e61dac9644596021d3f0ba3b0e Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 19 Jul 2026 16:33:58 +0000 Subject: [PATCH] refactor(server): remove the store abstraction layer (D3) Deletes Server/store (SQLiteStore, MemStore, the composed Store interface) and collapses to a single sqlc-backed db package, executing the prior audit's P4 "single data layer" direction (finding #6). SQLiteStore was a pure pass-through to *db.DB, so consumers now depend on narrow interfaces that *db.DB satisfies directly: - service.Store (service/datastore.go, renamed from store/store.go) - ws.EventStore (ws/eventstore.go) - plugin.PluginStore (plugin/pluginstore.go) The event- and plugin-KV methods that lived in the store's SQLite implementation move into the db package (db/event_queries.go, db/plugin_queries.go), keeping their raw-SQL form. Tests: the MemStore-based unit tests now run against a real in-memory SQLite db opened per-test with migrations applied, via package-local seed helpers. Fault-injection tests embed a real *db.DB and override the single method under test, preserving error-path coverage. Full server suite and sqlc-verify are green. Docs: audit finding #6 and A-2026-07-06 marked resolved; decisions D3 updated; architecture server.md / data-model.md diagrams and prose updated to the api -> service -> db layering. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01UA17KPvqGBX3XbXYnMf1rA --- Server/admin/api_test.go | 3 +- Server/api/channel_handler_test.go | 5 +- Server/api/coverage_push_test.go | 3 +- Server/api/dm_handler_test.go | 3 +- Server/api/invite_handler_test.go | 3 +- Server/api/plugins_handler.go | 5 +- Server/api/plugins_handler_test.go | 56 +- Server/api/profile_handler_test.go | 3 +- Server/api/router.go | 8 +- Server/api/upload_handler_test.go | 3 +- Server/db/event_queries.go | 163 ++++ Server/db/plugin_queries.go | 152 ++++ Server/main.go | 17 +- Server/plugin/dbstore_test.go | 24 + Server/plugin/host_storage.go | 4 +- Server/plugin/plugin_test.go | 8 +- Server/plugin/pluginstore.go | 25 + Server/plugin/registry.go | 4 +- Server/plugin/sandbox_wazero_test.go | 6 +- Server/service/block.go | 5 +- Server/service/channel.go | 5 +- .../{store/store.go => service/datastore.go} | 141 +-- Server/service/dm.go | 5 +- Server/service/invite.go | 5 +- Server/service/message.go | 5 +- Server/service/message_test.go | 185 ++-- Server/service/moderation.go | 5 +- Server/service/moderation_test.go | 36 +- Server/service/permission.go | 5 +- Server/service/permission_test.go | 74 +- Server/service/seed_test.go | 181 ++++ Server/service/service.go | 3 +- Server/service/user.go | 5 +- Server/service/user_test.go | 25 +- Server/service/voice.go | 5 +- Server/store/memstore.go | 824 ------------------ Server/store/memstore_events.go | 289 ------ Server/store/sqlite.go | 364 -------- Server/store/sqlite_events.go | 302 ------- Server/ws/coverage_boost_test.go | 3 +- Server/ws/event_persister.go | 7 +- Server/ws/event_persister_test.go | 24 +- Server/ws/event_pruner.go | 6 +- Server/ws/eventstore.go | 20 + Server/ws/handler_v2_channel_focus_test.go | 5 +- Server/ws/handlers_command_test.go | 5 +- Server/ws/handlers_test.go | 3 +- Server/ws/hub.go | 5 +- Server/ws/reconnect_db_test.go | 37 +- Server/ws/ws_integration_test.go | 3 +- docs/architecture/data-model.md | 31 +- docs/architecture/server.md | 48 +- docs/audit-2026-07-19.md | 14 +- docs/plans/audit-2026-07-19-decisions.md | 2 +- 54 files changed, 936 insertions(+), 2241 deletions(-) create mode 100644 Server/db/event_queries.go create mode 100644 Server/db/plugin_queries.go create mode 100644 Server/plugin/dbstore_test.go create mode 100644 Server/plugin/pluginstore.go rename Server/{store/store.go => service/datastore.go} (57%) create mode 100644 Server/service/seed_test.go delete mode 100644 Server/store/memstore.go delete mode 100644 Server/store/memstore_events.go delete mode 100644 Server/store/sqlite.go delete mode 100644 Server/store/sqlite_events.go create mode 100644 Server/ws/eventstore.go diff --git a/Server/admin/api_test.go b/Server/admin/api_test.go index 71558e11..a8293658 100644 --- a/Server/admin/api_test.go +++ b/Server/admin/api_test.go @@ -15,14 +15,13 @@ import ( "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) + st := database checker := permissions.NewChecker(st) return service.NewModerationService(st, service.NewPermissionService(st, checker)) } diff --git a/Server/api/channel_handler_test.go b/Server/api/channel_handler_test.go index 8e1a9d57..0c0b7a97 100644 --- a/Server/api/channel_handler_test.go +++ b/Server/api/channel_handler_test.go @@ -13,7 +13,6 @@ import ( "github.com/owncord/server/auth" "github.com/owncord/server/db" "github.com/owncord/server/service" - "github.com/owncord/server/store" ) // ─── schema for channel tests ───────────────────────────────────────────────── @@ -189,7 +188,7 @@ func newChannelTestDB(t *testing.T) *db.DB { func buildChannelRouter(database *db.DB) http.Handler { r := chi.NewRouter() limiter := auth.NewRateLimiter() - st := store.NewSQLiteStore(database) + st := database svc := service.New(st, limiter) api.MountChannelRoutes(r, database, svc, limiter, nil) return r @@ -610,7 +609,7 @@ func TestSearch_TrustedProxyRateLimitUsesForwardedIP(t *testing.T) { database := newChannelTestDB(t) r := chi.NewRouter() limiter := auth.NewRateLimiter() - svc := service.New(store.NewSQLiteStore(database), limiter) + svc := service.New(database, limiter) api.MountChannelRoutes(r, database, svc, limiter, []string{"127.0.0.0/8"}) token := chTestCreateToken(t, database, "proxysearch", 1) diff --git a/Server/api/coverage_push_test.go b/Server/api/coverage_push_test.go index 7e192fcc..23aaf4ab 100644 --- a/Server/api/coverage_push_test.go +++ b/Server/api/coverage_push_test.go @@ -20,7 +20,6 @@ import ( "github.com/owncord/server/api" "github.com/owncord/server/auth" "github.com/owncord/server/service" - "github.com/owncord/server/store" ) // ─── handleCreateInvite: malformed JSON body ──────────────────────────────── @@ -750,7 +749,7 @@ func buildCombinedRouter(t *testing.T) (http.Handler, *auth.RateLimiter, string) limiter := auth.NewRateLimiter() r := chi.NewRouter() - svc := service.New(store.NewSQLiteStore(database), limiter) + svc := service.New(database, limiter) api.MountAuthRoutes(r, database, limiter, nil, testTOTPKey) api.MountProfileRoutes(r, database, svc, limiter, nil, nil) api.MountInviteRoutes(r, database, svc) diff --git a/Server/api/dm_handler_test.go b/Server/api/dm_handler_test.go index 86721ea6..8610f5ee 100644 --- a/Server/api/dm_handler_test.go +++ b/Server/api/dm_handler_test.go @@ -14,7 +14,6 @@ import ( "github.com/owncord/server/auth" "github.com/owncord/server/db" "github.com/owncord/server/service" - "github.com/owncord/server/store" ) // ─── DM test schema ───────────────────────────────────────────────────────── @@ -174,7 +173,7 @@ func dmCreateToken(t *testing.T, database *db.DB, username string, roleID int) s func buildDMRouter(database *db.DB, broadcaster api.DMBroadcaster) http.Handler { r := chi.NewRouter() - svc := service.New(store.NewSQLiteStore(database), auth.NewRateLimiter()) + svc := service.New(database, auth.NewRateLimiter()) api.MountDMRoutes(r, database, svc, broadcaster) return r } diff --git a/Server/api/invite_handler_test.go b/Server/api/invite_handler_test.go index e9cc8a40..789de56d 100644 --- a/Server/api/invite_handler_test.go +++ b/Server/api/invite_handler_test.go @@ -11,13 +11,12 @@ import ( "github.com/owncord/server/auth" "github.com/owncord/server/db" "github.com/owncord/server/service" - "github.com/owncord/server/store" ) // buildInviteRouter returns a chi router with invite routes and auth middleware. func buildInviteRouter(database *db.DB, limiter *auth.RateLimiter) http.Handler { r := chi.NewRouter() - svc := service.New(store.NewSQLiteStore(database), limiter) + svc := service.New(database, limiter) api.MountAuthRoutes(r, database, limiter, nil, testTOTPKey) api.MountInviteRoutes(r, database, svc) return r diff --git a/Server/api/plugins_handler.go b/Server/api/plugins_handler.go index 2cf95b7c..a0660336 100644 --- a/Server/api/plugins_handler.go +++ b/Server/api/plugins_handler.go @@ -15,7 +15,6 @@ import ( "github.com/go-chi/chi/v5" "github.com/owncord/server/plugin" - "github.com/owncord/server/store" ) // maxPluginUploadBytes caps the multipart upload at 16 MiB to match the @@ -26,13 +25,13 @@ const maxPluginUploadBytes = 16 * 1024 * 1024 // PluginAdminHandler exposes plugin lifecycle operations to the admin panel. type PluginAdminHandler struct { registry *plugin.Registry - store store.PluginStore + store plugin.PluginStore } // NewPluginAdminHandler builds an http.Handler that the router can mount. // Pass a nil registry when plugin support is disabled — the handler then // reports an empty list and 503 on lifecycle calls. -func NewPluginAdminHandler(registry *plugin.Registry, st store.PluginStore) http.Handler { +func NewPluginAdminHandler(registry *plugin.Registry, st plugin.PluginStore) http.Handler { h := &PluginAdminHandler{registry: registry, store: st} r := chi.NewRouter() r.Get("/", h.list) diff --git a/Server/api/plugins_handler_test.go b/Server/api/plugins_handler_test.go index cad708d0..f1e24c2e 100644 --- a/Server/api/plugins_handler_test.go +++ b/Server/api/plugins_handler_test.go @@ -2,8 +2,8 @@ // // The handler is covered at the HTTP boundary so the fixtures do not depend // on the Wazero runtime. A nil Registry exercises the "plugin runtime -// disabled" branch; a real Registry wired against a MemStore exercises the -// happy path. +// disabled" branch; a real Registry wired against a real in-memory DB +// exercises the happy path. package api import ( @@ -18,10 +18,26 @@ import ( "strings" "testing" + "github.com/owncord/server/db" "github.com/owncord/server/plugin" - "github.com/owncord/server/store" ) +// openPluginTestDB opens an in-memory database with the full migration set so +// the plugins and plugin_kv tables exist. *db.DB satisfies the plugin.Config +// Store interface directly (D3 removed the store abstraction). +func openPluginTestDB(t *testing.T) *db.DB { + t.Helper() + database, err := db.Open(":memory:") + if err != nil { + t.Fatalf("db.Open: %v", err) + } + t.Cleanup(func() { _ = database.Close() }) + if err := db.Migrate(database); err != nil { + t.Fatalf("db.Migrate: %v", err) + } + return database +} + func TestPluginsHandlerListEmptyWhenRegistryNil(t *testing.T) { h := NewPluginAdminHandler(nil, nil) req := httptest.NewRequest("GET", "/", nil) @@ -91,7 +107,7 @@ func TestPluginsHandlerInstallRejectsNonZipMagic(t *testing.T) { func TestPluginsHandlerInstallHappyPath(t *testing.T) { reg := newTestPluginRegistry(t) - mem := store.NewMemStore() + mem := openPluginTestDB(t) // Wire the store into the handler so /list can show the new row. The // registry already writes via its own PluginStore. h := NewPluginAdminHandler(reg, mem) @@ -137,15 +153,15 @@ func TestPluginsHandlerLifecycleInvalidID(t *testing.T) { func TestIsZipContentType(t *testing.T) { cases := map[string]bool{ - "application/zip": true, - "application/zip; charset=binary": true, - "APPLICATION/ZIP": true, - "application/x-zip-compressed": true, - "application/octet-stream": true, - "text/plain": false, - "image/png": false, - "": false, - "application/json; charset=utf-8": false, + "application/zip": true, + "application/zip; charset=binary": true, + "APPLICATION/ZIP": true, + "application/x-zip-compressed": true, + "application/octet-stream": true, + "text/plain": false, + "image/png": false, + "": false, + "application/json; charset=utf-8": false, } for ct, want := range cases { if got := isZipContentType(ct); got != want { @@ -156,12 +172,12 @@ func TestIsZipContentType(t *testing.T) { func TestHasZipMagic(t *testing.T) { cases := map[string]bool{ - "PK\x03\x04rest": true, - "PK\x05\x06": true, - "PK\x07\x08rest": false, // spanned-archive signature; not accepted here - "not a zip": false, - "": false, - "PK": false, + "PK\x03\x04rest": true, + "PK\x05\x06": true, + "PK\x07\x08rest": false, // spanned-archive signature; not accepted here + "not a zip": false, + "": false, + "PK": false, } for body, want := range cases { if got := hasZipMagic([]byte(body)); got != want { @@ -175,7 +191,7 @@ func TestHasZipMagic(t *testing.T) { func newTestPluginRegistry(t *testing.T) *plugin.Registry { t.Helper() dir := t.TempDir() - mem := store.NewMemStore() + mem := openPluginTestDB(t) reg, err := plugin.NewRegistry(plugin.Config{ Directory: filepath.Join(dir, "plugins"), Store: mem, diff --git a/Server/api/profile_handler_test.go b/Server/api/profile_handler_test.go index e0a6d086..aeaebb00 100644 --- a/Server/api/profile_handler_test.go +++ b/Server/api/profile_handler_test.go @@ -14,14 +14,13 @@ import ( "github.com/owncord/server/auth" "github.com/owncord/server/db" "github.com/owncord/server/service" - "github.com/owncord/server/store" ) // buildProfileRouter returns a chi router with profile routes mounted. func buildProfileRouter(database *db.DB) http.Handler { r := chi.NewRouter() limiter := auth.NewRateLimiter() - svc := service.New(store.NewSQLiteStore(database), limiter) + svc := service.New(database, limiter) api.MountProfileRoutes(r, database, svc, limiter, nil, nil) return r } diff --git a/Server/api/router.go b/Server/api/router.go index a40ef231..6f26d3f2 100644 --- a/Server/api/router.go +++ b/Server/api/router.go @@ -19,7 +19,6 @@ import ( "github.com/owncord/server/plugin" "github.com/owncord/server/service" "github.com/owncord/server/storage" - dbstore "github.com/owncord/server/store" "github.com/owncord/server/telemetry" "github.com/owncord/server/updater" "github.com/owncord/server/ws" @@ -94,8 +93,9 @@ func NewRouter(cfg *config.Config, database *db.DB, ver string, logBuf *admin.Ri } // Service layer — centralizes business logic for REST and WS handlers. - st := dbstore.NewSQLiteStore(database) - svc := service.New(st, limiter) + // *db.DB satisfies service.Store directly (the store abstraction was + // removed in D3). + svc := service.New(database, limiter) // Auth routes: register, login, logout, me. MountAuthRoutes(r, database, limiter, cfg.Server.TrustedProxies, totpKey) @@ -247,7 +247,7 @@ func NewRouter(cfg *config.Config, database *db.DB, ver string, logBuf *admin.Ri // which case lifecycle calls return 503 and list returns []). r.Group(func(r chi.Router) { r.Use(admin.RequireAdminAuth(database)) - r.Mount("/api/v1/admin/plugins", NewPluginAdminHandler(pluginRegistry, st)) + r.Mount("/api/v1/admin/plugins", NewPluginAdminHandler(pluginRegistry, database)) }) }) diff --git a/Server/api/upload_handler_test.go b/Server/api/upload_handler_test.go index 95b1fc4a..20251174 100644 --- a/Server/api/upload_handler_test.go +++ b/Server/api/upload_handler_test.go @@ -23,7 +23,6 @@ import ( "github.com/owncord/server/permissions" "github.com/owncord/server/service" "github.com/owncord/server/storage" - "github.com/owncord/server/store" ) // testPermSvc wires a PermissionService around the test DB so @@ -31,7 +30,7 @@ import ( // exercise per-channel ACLs directly — they go through the live // permissions.Checker, which is the production path anyway. func testPermSvc(database *db.DB) *service.PermissionService { - return service.NewPermissionService(store.NewSQLiteStore(database), permissions.NewChecker(database)) + return service.NewPermissionService(database, permissions.NewChecker(database)) } // ─── schema for upload tests ───────────────────────────────────────────────── diff --git a/Server/db/event_queries.go b/Server/db/event_queries.go new file mode 100644 index 00000000..462f320f --- /dev/null +++ b/Server/db/event_queries.go @@ -0,0 +1,163 @@ +package db + +import ( + "context" + "database/sql" + "fmt" + "strings" + "time" +) + +// ── Event persistence (Phase B Step 7) ────────────────────────────────────── +// +// These methods back the cold-tier reconnect replay. They were previously in +// the store package's SQLiteStore; they moved here (D3) when that pass-through +// layer was removed. The SQL is unchanged. + +// PersistEvent appends a single event to the events table with the +// caller-supplied seq. The hub assigns seq before this is called so the row +// seq always matches the wrapped-payload seq, even if the persister drops +// some events under load. +func (d *DB) PersistEvent(ctx context.Context, seq int64, eventType string, channelID int64, payload []byte) error { + _, err := d.sqlDB.ExecContext(ctx, + `INSERT INTO events (seq, event_type, channel_id, payload) VALUES (?, ?, ?, ?)`, + seq, eventType, channelID, payload, + ) + if err != nil { + return fmt.Errorf("PersistEvent: %w", err) + } + return nil +} + +// GetEventsSince returns events with seq > afterSeq up to limit, ordered ASC. +func (d *DB) GetEventsSince(ctx context.Context, afterSeq int64, limit int) ([]PersistedEvent, error) { + rows, err := d.sqlDB.QueryContext(ctx, + `SELECT seq, event_type, channel_id, payload, created_at + FROM events + WHERE seq > ? + ORDER BY seq ASC + LIMIT ?`, + afterSeq, limit, + ) + if err != nil { + return nil, fmt.Errorf("GetEventsSince: %w", err) + } + defer func() { _ = rows.Close() }() + return scanEventRows(rows) +} + +// GetEventsSinceForChannels filters events to those whose channel_id is 0 +// (global broadcast) or in channelIDs. +func (d *DB) GetEventsSinceForChannels(ctx context.Context, afterSeq int64, channelIDs []int64, limit int) ([]PersistedEvent, error) { + // Build IN clause manually since database/sql does not expand slices. + if len(channelIDs) == 0 { + // Only global broadcasts. + rows, err := d.sqlDB.QueryContext(ctx, + `SELECT seq, event_type, channel_id, payload, created_at + FROM events + WHERE seq > ? AND channel_id = 0 + ORDER BY seq ASC + LIMIT ?`, + afterSeq, limit, + ) + if err != nil { + return nil, fmt.Errorf("GetEventsSinceForChannels (global only): %w", err) + } + defer func() { _ = rows.Close() }() + return scanEventRows(rows) + } + + placeholders := make([]string, len(channelIDs)) + args := make([]any, 0, len(channelIDs)+2) + args = append(args, afterSeq) + for i, cid := range channelIDs { + placeholders[i] = "?" + args = append(args, cid) + } + args = append(args, limit) + + query := fmt.Sprintf( //nolint:gosec // G201: placeholder interpolation, not user input + `SELECT seq, event_type, channel_id, payload, created_at + FROM events + WHERE seq > ? + AND (channel_id = 0 OR channel_id IN (%s)) + ORDER BY seq ASC + LIMIT ?`, + strings.Join(placeholders, ","), + ) + rows, err := d.sqlDB.QueryContext(ctx, query, args...) + if err != nil { + return nil, fmt.Errorf("GetEventsSinceForChannels: %w", err) + } + defer func() { _ = rows.Close() }() + return scanEventRows(rows) +} + +// GetMaxEventSeq returns the largest seq in the events table, or 0 if empty. +func (d *DB) GetMaxEventSeq(ctx context.Context) (int64, error) { + var maxSeq sql.NullInt64 + err := d.sqlDB.QueryRowContext(ctx, `SELECT MAX(seq) FROM events`).Scan(&maxSeq) + if err != nil { + return 0, fmt.Errorf("GetMaxEventSeq: %w", err) + } + if !maxSeq.Valid { + return 0, nil + } + return maxSeq.Int64, nil +} + +// PruneEventsOlderThan deletes events older than cutoff. Returns rows deleted. +func (d *DB) PruneEventsOlderThan(ctx context.Context, cutoff time.Time) (int64, error) { + res, err := d.sqlDB.ExecContext(ctx, + `DELETE FROM events WHERE created_at < ?`, + cutoff.UTC().Format("2006-01-02 15:04:05"), + ) + 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 +} + +// ── helpers ───────────────────────────────────────────────────────────────── + +type rowsScanner interface { + Next() bool + Scan(dest ...any) error + Err() error +} + +func scanEventRows(rows rowsScanner) ([]PersistedEvent, error) { + var out []PersistedEvent + for rows.Next() { + var e PersistedEvent + var createdAt string + if err := rows.Scan(&e.Seq, &e.EventType, &e.ChannelID, &e.Payload, &createdAt); err != nil { + return nil, fmt.Errorf("scanEventRows: %w", err) + } + e.CreatedAt = parseSQLiteTime(createdAt) + out = append(out, e) + } + if err := rows.Err(); err != nil { + return nil, err + } + return out, nil +} + +// parseSQLiteTime parses the several timestamp formats SQLite may return. +func parseSQLiteTime(s string) time.Time { + // SQLite CURRENT_TIMESTAMP returns "YYYY-MM-DD HH:MM:SS" in UTC. + for _, layout := range []string{ + "2006-01-02 15:04:05", + time.RFC3339, + time.RFC3339Nano, + } { + if t, err := time.Parse(layout, s); err == nil { + return t.UTC() + } + } + return time.Time{} +} diff --git a/Server/db/plugin_queries.go b/Server/db/plugin_queries.go new file mode 100644 index 00000000..bb61a0be --- /dev/null +++ b/Server/db/plugin_queries.go @@ -0,0 +1,152 @@ +package db + +import ( + "context" + "fmt" +) + +// ── Plugin persistence (Phase C Step 9) ───────────────────────────────────── +// +// Moved verbatim from the store package's SQLiteStore (D3). The SQL is +// unchanged. + +func (d *DB) InstallPlugin(ctx context.Context, name, version, manifestJSON string) (int64, error) { + res, err := d.sqlDB.ExecContext(ctx, + `INSERT INTO plugins (name, version, enabled, manifest_json) VALUES (?, ?, 0, ?) + ON CONFLICT(name) DO UPDATE SET version = excluded.version, manifest_json = excluded.manifest_json`, + name, version, manifestJSON, + ) + if err != nil { + return 0, fmt.Errorf("InstallPlugin: %w", err) + } + id, err := res.LastInsertId() + if err != nil || id == 0 { + // On conflict path LastInsertId may be 0; look up by name. + row := d.sqlDB.QueryRowContext(ctx, `SELECT id FROM plugins WHERE name = ?`, name) + if scanErr := row.Scan(&id); scanErr != nil { + return 0, fmt.Errorf("InstallPlugin lookup: %w", scanErr) + } + } + return id, nil +} + +func (d *DB) EnablePlugin(ctx context.Context, id int64) error { + _, err := d.sqlDB.ExecContext(ctx, `UPDATE plugins SET enabled = 1 WHERE id = ?`, id) + return err +} + +func (d *DB) DisablePlugin(ctx context.Context, id int64) error { + _, err := d.sqlDB.ExecContext(ctx, `UPDATE plugins SET enabled = 0 WHERE id = ?`, id) + return err +} + +func (d *DB) UninstallPlugin(ctx context.Context, id int64) error { + _, err := d.sqlDB.ExecContext(ctx, `DELETE FROM plugins WHERE id = ?`, id) + return err +} + +func (d *DB) GetPlugin(ctx context.Context, id int64) (*PluginRow, error) { + row := d.sqlDB.QueryRowContext(ctx, + `SELECT id, name, version, enabled, manifest_json, installed_at FROM plugins WHERE id = ?`, + id, + ) + return scanPluginRow(row) +} + +func (d *DB) GetPluginByName(ctx context.Context, name string) (*PluginRow, error) { + row := d.sqlDB.QueryRowContext(ctx, + `SELECT id, name, version, enabled, manifest_json, installed_at FROM plugins WHERE name = ?`, + name, + ) + return scanPluginRow(row) +} + +func (d *DB) ListPlugins(ctx context.Context) ([]PluginRow, error) { + rows, err := d.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 func() { _ = rows.Close() }() + var out []PluginRow + for rows.Next() { + var p PluginRow + var enabledInt int64 + var installedAt string + if err := rows.Scan(&p.ID, &p.Name, &p.Version, &enabledInt, &p.ManifestJSON, &installedAt); err != nil { + return nil, fmt.Errorf("ListPlugins scan: %w", err) + } + p.Enabled = enabledInt != 0 + p.InstalledAt = parseSQLiteTime(installedAt) + out = append(out, p) + } + return out, rows.Err() +} + +func (d *DB) PluginKVGet(ctx context.Context, pluginID int64, key string) ([]byte, error) { + row := d.sqlDB.QueryRowContext(ctx, + `SELECT value FROM plugin_kv WHERE plugin_id = ? AND key = ?`, + pluginID, key, + ) + var v []byte + if err := row.Scan(&v); err != nil { + return nil, err + } + return v, nil +} + +func (d *DB) PluginKVSet(ctx context.Context, pluginID int64, key string, value []byte) error { + _, err := d.sqlDB.ExecContext(ctx, + `INSERT INTO plugin_kv (plugin_id, key, value) VALUES (?, ?, ?) + ON CONFLICT(plugin_id, key) DO UPDATE SET value = excluded.value`, + pluginID, key, value, + ) + return err +} + +func (d *DB) PluginKVDelete(ctx context.Context, pluginID int64, key string) error { + _, err := d.sqlDB.ExecContext(ctx, + `DELETE FROM plugin_kv WHERE plugin_id = ? AND key = ?`, + pluginID, key, + ) + return err +} + +func (d *DB) PluginKVScan(ctx context.Context, pluginID int64, prefix string, limit int) (map[string][]byte, error) { + rows, err := d.sqlDB.QueryContext(ctx, + `SELECT key, value FROM plugin_kv WHERE plugin_id = ? AND key LIKE ? ORDER BY key LIMIT ?`, + pluginID, prefix+"%", limit, + ) + if err != nil { + return nil, fmt.Errorf("PluginKVScan: %w", err) + } + defer func() { _ = 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() +} + +// rowScanner is satisfied by *sql.Row and *sql.Rows. +type rowScanner interface { + Scan(dest ...any) error +} + +func scanPluginRow(row rowScanner) (*PluginRow, error) { + var p PluginRow + var enabledInt int64 + var installedAt string + if err := row.Scan(&p.ID, &p.Name, &p.Version, &enabledInt, &p.ManifestJSON, &installedAt); err != nil { + return nil, err + } + p.Enabled = enabledInt != 0 + p.InstalledAt = parseSQLiteTime(installedAt) + return &p, nil +} diff --git a/Server/main.go b/Server/main.go index e983d5c3..05f75400 100644 --- a/Server/main.go +++ b/Server/main.go @@ -25,7 +25,6 @@ import ( "github.com/owncord/server/db" "github.com/owncord/server/plugin" "github.com/owncord/server/storage" - "github.com/owncord/server/store" "github.com/owncord/server/telemetry" "github.com/owncord/server/ws" ) @@ -143,12 +142,6 @@ func run(log *slog.Logger, logBuf *admin.RingBuffer) error { } }() - // ── 5. Construct shared store wrapper ────────────────────────────────── - // Used by both the event persistence layer and the plugin runtime. Once - // the Phase A "store everywhere" refactor lands, NewRouter will accept - // store.Store directly and this wrapper goes away. - storeWrapper := store.NewSQLiteStore(database) - // ── 5a. Construct plugin runtime BEFORE the router so the router can // wire the live registry into the plugin admin handler. ──────────────── var pluginRegistry *plugin.Registry @@ -158,7 +151,7 @@ func run(log *slog.Logger, logBuf *admin.RingBuffer) error { MaxMemoryMB: cfg.Plugins.MaxMemoryMB, CPUBudgetMs: cfg.Plugins.CPUBudgetMs, HTTPAllowlist: cfg.Plugins.HTTPAllowlist, - Store: storeWrapper, + Store: database, }) if plugErr != nil { log.Warn("plugin runtime init failed; continuing without plugins", "error", plugErr) @@ -186,7 +179,7 @@ func run(log *slog.Logger, logBuf *admin.RingBuffer) error { // this, the events table accumulates rows whose payload seqs reset // to 1 after every restart, breaking the reconnect "events since // last_seq" contract. - if maxSeq, seedErr := storeWrapper.GetMaxEventSeq(bgCtx); seedErr != nil { + if maxSeq, seedErr := database.GetMaxEventSeq(bgCtx); seedErr != nil { log.Warn("event persistence: failed to read MAX(events.seq); starting hub seq from 0", "error", seedErr) } else if maxSeq > 0 { hub.SeedSeq(uint64(maxSeq)) @@ -194,18 +187,18 @@ func run(log *slog.Logger, logBuf *admin.RingBuffer) error { } persister := ws.NewEventPersister( - storeWrapper, + database, 4096, cfg.EventPersistence.BatchSize, time.Duration(cfg.EventPersistence.BatchFlushMs)*time.Millisecond, ) persister.Start(bgCtx) hub.SetEventPersister(persister) - hub.SetEventStore(storeWrapper) + hub.SetEventStore(database) retention := time.Duration(cfg.EventPersistence.RetentionHours) * time.Hour prunerInterval := time.Duration(cfg.EventPersistence.PrunerIntervalMinutes) * time.Minute - ws.StartEventPruner(bgCtx, storeWrapper, retention, prunerInterval) + ws.StartEventPruner(bgCtx, database, retention, prunerInterval) defer func() { stopCtx, stopCancel := context.WithTimeout(context.Background(), 5*time.Second) defer stopCancel() diff --git a/Server/plugin/dbstore_test.go b/Server/plugin/dbstore_test.go new file mode 100644 index 00000000..48331c69 --- /dev/null +++ b/Server/plugin/dbstore_test.go @@ -0,0 +1,24 @@ +package plugin + +import ( + "testing" + + "github.com/owncord/server/db" +) + +// openPluginTestDB opens an in-memory database with the full migration set +// applied (the plugins and plugin_kv tables live in migration 015). *db.DB +// satisfies the PluginStore interface directly — D3 removed the store +// abstraction and its MemStore fake, so plugin tests run against a real DB. +func openPluginTestDB(t *testing.T) *db.DB { + t.Helper() + database, err := db.Open(":memory:") + if err != nil { + t.Fatalf("db.Open: %v", err) + } + t.Cleanup(func() { _ = database.Close() }) + if err := db.Migrate(database); err != nil { + t.Fatalf("db.Migrate: %v", err) + } + return database +} diff --git a/Server/plugin/host_storage.go b/Server/plugin/host_storage.go index ccd15047..ae31831f 100644 --- a/Server/plugin/host_storage.go +++ b/Server/plugin/host_storage.go @@ -12,8 +12,8 @@ import ( ) const ( - maxPluginValueBytes = 64 * 1024 // 64 KB per value - maxPluginScanLimit = 1000 // hard cap on PluginKVScan results + maxPluginValueBytes = 64 * 1024 // 64 KB per value + maxPluginScanLimit = 1000 // hard cap on PluginKVScan results ) // StoragePut writes a single key/value pair on behalf of inst. diff --git a/Server/plugin/plugin_test.go b/Server/plugin/plugin_test.go index 518b8be7..4bd6bb47 100644 --- a/Server/plugin/plugin_test.go +++ b/Server/plugin/plugin_test.go @@ -15,8 +15,6 @@ import ( "os" "path/filepath" "testing" - - "github.com/owncord/server/store" ) func TestParseManifestRoundTrip(t *testing.T) { @@ -100,7 +98,7 @@ func TestRegistryInstallFromDisk(t *testing.T) { 0o644) _ = os.WriteFile(filepath.Join(pluginDir, "hello.wasm"), []byte("\x00asm\x01\x00\x00\x00"), 0o644) - mem := store.NewMemStore() + mem := openPluginTestDB(t) reg, err := NewRegistry(Config{Directory: dir, Store: mem}) if err != nil { t.Fatal(err) @@ -118,7 +116,7 @@ func TestRegistryInstallFromDisk(t *testing.T) { } func TestStorageGatedByCapability(t *testing.T) { - mem := store.NewMemStore() + mem := openPluginTestDB(t) reg, err := NewRegistry(Config{Store: mem}) if err != nil { t.Fatal(err) @@ -152,7 +150,7 @@ func TestStorageGatedByCapability(t *testing.T) { // 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() + mem := openPluginTestDB(t) reg, err := NewRegistry(Config{Directory: t.TempDir(), Store: mem}) if err != nil { t.Fatalf("NewRegistry: %v", err) diff --git a/Server/plugin/pluginstore.go b/Server/plugin/pluginstore.go new file mode 100644 index 00000000..b7418179 --- /dev/null +++ b/Server/plugin/pluginstore.go @@ -0,0 +1,25 @@ +package plugin + +import ( + "context" + + "github.com/owncord/server/db" +) + +// PluginStore manages installed plugins and per-plugin KV namespaces. +// *db.DB satisfies it (the methods moved into the db package when the store +// abstraction was removed in D3). +type PluginStore interface { + InstallPlugin(ctx context.Context, name, version, manifestJSON string) (int64, error) + EnablePlugin(ctx context.Context, id int64) error + DisablePlugin(ctx context.Context, id int64) error + UninstallPlugin(ctx context.Context, id int64) error + GetPlugin(ctx context.Context, id int64) (*db.PluginRow, error) + GetPluginByName(ctx context.Context, name string) (*db.PluginRow, error) + ListPlugins(ctx context.Context) ([]db.PluginRow, error) + + PluginKVGet(ctx context.Context, pluginID int64, key string) ([]byte, error) + PluginKVSet(ctx context.Context, pluginID int64, key string, value []byte) error + PluginKVDelete(ctx context.Context, pluginID int64, key string) error + PluginKVScan(ctx context.Context, pluginID int64, prefix string, limit int) (map[string][]byte, error) +} diff --git a/Server/plugin/registry.go b/Server/plugin/registry.go index 4ab01d43..5c798e8e 100644 --- a/Server/plugin/registry.go +++ b/Server/plugin/registry.go @@ -22,8 +22,6 @@ import ( "path/filepath" "strings" "sync" - - "github.com/owncord/server/store" ) // Config is the runtime configuration sourced from PluginsConfig. @@ -32,7 +30,7 @@ type Config struct { MaxMemoryMB int CPUBudgetMs int HTTPAllowlist []string - Store store.PluginStore + Store PluginStore } // Registry is the central plugin coordinator. diff --git a/Server/plugin/sandbox_wazero_test.go b/Server/plugin/sandbox_wazero_test.go index 5c91dbf3..af5077b2 100644 --- a/Server/plugin/sandbox_wazero_test.go +++ b/Server/plugin/sandbox_wazero_test.go @@ -25,8 +25,6 @@ import ( "path/filepath" "strings" "testing" - - "github.com/owncord/server/store" ) // addWASM is the bytes of a minimal (module (func (export "add") ... )). @@ -55,9 +53,9 @@ func writeTestPlugin(t *testing.T, root, name string, manifest string, wasmBytes } } -func newWazeroTestRegistry(t *testing.T, dir string) (*Registry, store.PluginStore) { +func newWazeroTestRegistry(t *testing.T, dir string) (*Registry, PluginStore) { t.Helper() - mem := store.NewMemStore() + mem := openPluginTestDB(t) reg, err := NewRegistry(Config{ Directory: dir, MaxMemoryMB: 16, diff --git a/Server/service/block.go b/Server/service/block.go index f60079f7..f57988d1 100644 --- a/Server/service/block.go +++ b/Server/service/block.go @@ -6,17 +6,16 @@ import ( "log/slog" "time" - "github.com/owncord/server/store" "github.com/owncord/server/telemetry" ) // BlockService handles user block/unblock operations. type BlockService struct { - st store.Store + st Store } // NewBlockService creates a BlockService. -func NewBlockService(st store.Store) *BlockService { +func NewBlockService(st Store) *BlockService { return &BlockService{st: st} } diff --git a/Server/service/channel.go b/Server/service/channel.go index f29e4cb4..04fe6692 100644 --- a/Server/service/channel.go +++ b/Server/service/channel.go @@ -8,19 +8,18 @@ import ( "github.com/owncord/server/db" "github.com/owncord/server/permissions" - "github.com/owncord/server/store" "github.com/owncord/server/telemetry" ) // ChannelService handles channel-related business logic including // listing, permission-filtered access, typing, presence, and read state. type ChannelService struct { - st store.Store + st Store perms *PermissionService } // NewChannelService creates a ChannelService. -func NewChannelService(st store.Store, perms *PermissionService) *ChannelService { +func NewChannelService(st Store, perms *PermissionService) *ChannelService { return &ChannelService{ st: st, perms: perms, diff --git a/Server/store/store.go b/Server/service/datastore.go similarity index 57% rename from Server/store/store.go rename to Server/service/datastore.go index 3d6c9df3..f5a8f203 100644 --- a/Server/store/store.go +++ b/Server/service/datastore.go @@ -1,47 +1,23 @@ -// Package store defines the database abstraction layer for OwnCord. -// The Store interface decouples services from the concrete database -// implementation, enabling SQLite (default) and future PostgreSQL support. -package store +package service import ( "context" - "database/sql" "time" "github.com/owncord/server/db" ) -// Store is the top-level interface combining all domain-specific stores. -// Services accept Store instead of *db.DB, enabling swappable backends. +// Store is the data-access surface the service layer depends on. It is the +// set of *db.DB methods the services call — no event/plugin/transaction +// methods, which belong to other layers. *db.DB satisfies this interface +// directly (D3 removed the former store package's pass-through wrapper), and +// tests inject fakes that embed a real in-memory *db.DB and override the one +// method they need to exercise an error path. +// +// permissions.NewChecker takes its own narrower interface (permissions.DB), +// which *db.DB and this Store both satisfy. type Store interface { - MessageStore - ChannelStore - UserStore - SessionStore - RoleStore - InviteStore - VoiceStore - DMStore - BlockStore - AttachmentStore - AdminStore - SettingsStore - EventStore - PluginStore - - // Close releases the underlying database connection. - Close() error - - // WithTx executes fn within a transaction. The transaction is committed - // if fn returns nil, rolled back otherwise. - WithTx(ctx context.Context, fn func(Store) error) error - - // Raw access for callers that need it (migration, backup, etc.). - SQLDb() *sql.DB -} - -// MessageStore handles message CRUD, reactions, search, and read state. -type MessageStore interface { + // ── Messages / reactions / read-state ── CreateMessage(channelID, userID int64, content string, replyTo *int64) (int64, error) GetMessage(id int64) (*db.Message, error) GetMessages(channelID, before int64, limit int) ([]db.MessageWithUser, error) @@ -60,10 +36,8 @@ type MessageStore interface { GetLatestMessageID(channelID int64) (int64, error) LinkAttachmentsToMessage(messageID, uploaderID int64, attachmentIDs []string) (int64, error) GetAttachmentsByMessageIDs(msgIDs []int64) (map[int64][]db.AttachmentInfo, error) -} -// ChannelStore handles channel CRUD and permission overrides. -type ChannelStore interface { + // ── Channels ── ListChannels() ([]db.Channel, error) GetChannel(id int64) (*db.Channel, error) CreateChannel(name, chanType, category, topic string, position int) (int64, error) @@ -74,10 +48,8 @@ type ChannelStore interface { GetChannelPermissions(channelID, roleID int64) (allow, deny int64, err error) GetAllChannelPermissionsForRole(roleID int64) (map[int64]db.ChannelOverride, error) GetChannelTypes(ids []int64) (map[int64]string, error) -} -// UserStore handles user lookup and profile operations. -type UserStore interface { + // ── Users ── GetUserByID(id int64) (*db.User, error) GetUserByUsername(username string) (*db.User, error) CreateUser(username, passwordHash string, roleID int) (int64, error) @@ -91,10 +63,8 @@ type UserStore interface { ResetAllUserStatuses() error DeleteAccount(ctx context.Context, userID int64) error ListMembers() ([]db.MemberSummary, error) -} -// SessionStore handles authentication session management. -type SessionStore interface { + // ── Sessions ── CreateSession(userID int64, tokenHash, device, ip string) (int64, error) GetSessionByTokenHash(tokenHash string) (*db.Session, error) GetSessionWithBanStatus(tokenHash string) (*db.SessionWithBanStatus, error) @@ -106,27 +76,21 @@ type SessionStore interface { ListUserSessions(userID int64) ([]db.Session, error) ForceLogoutUser(userID int64) error GetUserSessions(userID int64) ([]db.Session, error) -} -// RoleStore handles role lookups. -type RoleStore interface { + // ── Roles ── GetRoleByID(id int64) (*db.Role, error) GetRoleForUser(userID int64) (*db.Role, error) GetUserWithRole(userID int64) (*db.User, *db.Role, error) ListRoles() ([]*db.Role, error) -} -// InviteStore handles invite management. -type InviteStore interface { + // ── Invites ── CreateInvite(createdBy int64, maxUses int, expiresAt *time.Time) (string, error) GetInvite(code string) (*db.Invite, error) ListInvites() ([]*db.Invite, error) UseInviteAtomic(code string) error RevokeInvite(code string) error -} -// VoiceStore handles voice state management. -type VoiceStore interface { + // ── Voice ── JoinVoiceChannel(userID, channelID int64) error JoinVoiceChannelIfCapacity(userID, channelID int64, maxUsers int) error LeaveVoiceChannel(userID int64) error @@ -143,10 +107,8 @@ type VoiceStore interface { EnableCameraIfUnderLimit(userID, channelID int64, maxVideo int) (bool, error) UpdateVoiceScreenshare(userID int64, screenshare bool) error CountChannelVoiceUsers(channelID int64) (int, error) -} -// DMStore handles direct message channels. -type DMStore interface { + // ── Direct messages ── GetOrCreateDMChannel(user1ID, user2ID int64) (*db.Channel, bool, error) GetUserDMChannels(userID int64) ([]db.DMChannelInfo, error) OpenDM(userID, channelID int64) error @@ -154,27 +116,21 @@ type DMStore interface { IsDMParticipant(userID, channelID int64) (bool, error) GetDMParticipantIDs(channelID int64) ([]int64, error) GetDMRecipient(channelID, requestingUserID int64) (*db.User, error) -} -// BlockStore handles user blocks. -type BlockStore interface { + // ── Blocks ── BlockUser(blockerID, blockedID int64) error UnblockUser(blockerID, blockedID int64) error IsBlocked(blockerID, blockedID int64) (bool, error) IsEitherBlocked(userA, userB int64) (bool, error) ListBlockedUsers(blockerID int64) ([]int64, error) -} -// AttachmentStore handles file attachment metadata. -type AttachmentStore interface { + // ── Attachments ── CreateAttachment(id string, uploaderID int64, filename, storedAs, mimeType string, size int64, width, height *int) error GetAttachmentByID(id string) (*db.Attachment, error) GetAttachmentWithChannel(id string) (*db.AttachmentAccess, error) DeleteOrphanedAttachments(cutoff string) ([]string, error) -} -// AdminStore handles admin operations. -type AdminStore interface { + // ── Admin ── UserCount() (int64, error) GetServerStats() (*db.ServerStats, error) ListAllUsers(limit, offset int) ([]db.UserWithRole, error) @@ -188,62 +144,9 @@ type AdminStore interface { BackupTo(path string) error BackupToSafe(path, safeRoot string) error CountUsersWithoutTOTP() (int, error) -} -// SettingsStore handles server settings. -type SettingsStore interface { + // ── Settings ── GetSetting(key string) (string, error) SetSetting(key, value string) error GetAllSettings() (map[string]string, error) } - -// EventStore persists broadcast events for cold-replay during reconnection -// when the in-memory ring buffer no longer covers the client's last_seq. -// -// Phase B Step 7 — Event Persistence Layer. -type EventStore interface { - // PersistEvent appends an event with the hub-assigned seq. seq must be - // the same monotonic counter the wrapped payload exposes to clients so - // that cold-replay queries by seq return rows whose payload seq matches - // the row seq. channelID == 0 means the event was a global broadcast. - // Implementations should be tolerant of out-of-order seq insertion (e.g. - // they SHOULD NOT rely on AUTOINCREMENT semantics). - PersistEvent(ctx context.Context, seq int64, eventType string, channelID int64, payload []byte) error - - // GetEventsSince returns up to limit events with seq > afterSeq, ordered - // by seq ascending. Used as a fallback after the ring buffer misses. - GetEventsSince(ctx context.Context, afterSeq int64, limit int) ([]db.PersistedEvent, error) - - // GetEventsSinceForChannels returns up to limit events with seq > afterSeq - // whose channel_id is in channelIDs OR is 0 (global broadcasts), ordered by - // seq ascending. Mirrors EventRingBuffer.EventsSinceFiltered. - GetEventsSinceForChannels(ctx context.Context, afterSeq int64, channelIDs []int64, limit int) ([]db.PersistedEvent, error) - - // PruneEventsOlderThan deletes events with created_at < cutoff. Returns the - // number of deleted rows. Called periodically by the pruner goroutine. - PruneEventsOlderThan(ctx context.Context, cutoff time.Time) (int64, error) - - // GetMaxEventSeq returns the largest seq in the events table, or 0 if the - // table is empty. Used at startup to seed the hub's in-memory monotonic - // counter so wrapped-payload seqs stay aligned with row seqs across - // restarts. Returns 0 (without error) when the table is empty. - GetMaxEventSeq(ctx context.Context) (int64, error) -} - -// PluginStore manages installed plugins and per-plugin KV namespaces. -// -// Phase C Step 9 — Wazero Plugin Runtime. -type PluginStore interface { - InstallPlugin(ctx context.Context, name, version, manifestJSON string) (int64, error) - EnablePlugin(ctx context.Context, id int64) error - DisablePlugin(ctx context.Context, id int64) error - UninstallPlugin(ctx context.Context, id int64) error - GetPlugin(ctx context.Context, id int64) (*db.PluginRow, error) - GetPluginByName(ctx context.Context, name string) (*db.PluginRow, error) - ListPlugins(ctx context.Context) ([]db.PluginRow, error) - - PluginKVGet(ctx context.Context, pluginID int64, key string) ([]byte, error) - PluginKVSet(ctx context.Context, pluginID int64, key string, value []byte) error - PluginKVDelete(ctx context.Context, pluginID int64, key string) error - PluginKVScan(ctx context.Context, pluginID int64, prefix string, limit int) (map[string][]byte, error) -} diff --git a/Server/service/dm.go b/Server/service/dm.go index 0cb4ad75..89a7d993 100644 --- a/Server/service/dm.go +++ b/Server/service/dm.go @@ -7,17 +7,16 @@ import ( "time" "github.com/owncord/server/db" - "github.com/owncord/server/store" "github.com/owncord/server/telemetry" ) // DMService handles direct message channel operations. type DMService struct { - st store.Store + st Store } // NewDMService creates a DMService. -func NewDMService(st store.Store) *DMService { +func NewDMService(st Store) *DMService { return &DMService{st: st} } diff --git a/Server/service/invite.go b/Server/service/invite.go index bebbd4f2..88a2b87a 100644 --- a/Server/service/invite.go +++ b/Server/service/invite.go @@ -6,17 +6,16 @@ import ( "time" "github.com/owncord/server/db" - "github.com/owncord/server/store" "github.com/owncord/server/telemetry" ) // InviteService handles invite management. type InviteService struct { - st store.Store + st Store } // NewInviteService creates an InviteService. -func NewInviteService(st store.Store) *InviteService { +func NewInviteService(st Store) *InviteService { return &InviteService{st: st} } diff --git a/Server/service/message.go b/Server/service/message.go index 4a428e66..ea559326 100644 --- a/Server/service/message.go +++ b/Server/service/message.go @@ -12,7 +12,6 @@ import ( "github.com/owncord/server/auth" "github.com/owncord/server/db" "github.com/owncord/server/permissions" - "github.com/owncord/server/store" "github.com/owncord/server/telemetry" ) @@ -100,13 +99,13 @@ type ReactionResult struct { // MessageService handles message-related business logic including // send, edit, delete, reactions, pins, and search. type MessageService struct { - st store.Store + st Store perms *PermissionService limiter *auth.RateLimiter } // NewMessageService creates a MessageService. -func NewMessageService(st store.Store, perms *PermissionService, limiter *auth.RateLimiter) *MessageService { +func NewMessageService(st Store, perms *PermissionService, limiter *auth.RateLimiter) *MessageService { return &MessageService{ st: st, perms: perms, diff --git a/Server/service/message_test.go b/Server/service/message_test.go index 41a0020c..594d0faf 100644 --- a/Server/service/message_test.go +++ b/Server/service/message_test.go @@ -8,32 +8,33 @@ import ( "github.com/owncord/server/db" "github.com/owncord/server/permissions" - "github.com/owncord/server/store" ) -// newTestMessageService creates a MessageService with a MemStore pre-populated -// with one text channel, one user, and a member role that has basic permissions. -// The rate limiter is nil (disabled) to avoid flakiness in unit tests. -func newTestMessageService() (*MessageService, *store.MemStore) { - ms := store.NewMemStore() - ms.SeedRole(&db.Role{ +// newTestMessageService creates a MessageService against a real in-memory DB +// pre-populated with one text channel, one user, and a member role that has +// basic permissions. The rate limiter is nil (disabled) to avoid flakiness in +// unit tests. +func newTestMessageService(t *testing.T) (*MessageService, *db.DB) { + t.Helper() + database := newTestDB(t) + seedRole(t, database, &db.Role{ ID: permissions.MemberRoleID, Name: "member", Permissions: permissions.SendMessages | permissions.ReadMessages | permissions.AddReactions, Position: 1, }) - ms.SeedUserRole(1, permissions.MemberRoleID) - ms.SeedUser(&db.User{ID: 1, Username: "alice", Status: "online"}) - ms.SeedChannel(&db.Channel{ID: 10, Name: "general", Type: "text"}) + seedUser(t, database, &db.User{ID: 1, Username: "alice", Status: "online"}) + seedUserRole(t, database, 1, permissions.MemberRoleID) + seedChannel(t, database, &db.Channel{ID: 10, Name: "general", Type: "text"}) - checker := permissions.NewChecker(ms) - permSvc := NewPermissionService(ms, checker) - msgSvc := NewMessageService(ms, permSvc, nil) - return msgSvc, ms + checker := permissions.NewChecker(database) + permSvc := NewPermissionService(database, checker) + msgSvc := NewMessageService(database, permSvc, nil) + return msgSvc, database } func TestSendMessage_Valid(t *testing.T) { - svc, _ := newTestMessageService() + svc, _ := newTestMessageService(t) result, err := svc.SendMessage(context.Background(), SendMessageParams{ ChannelID: 10, @@ -60,25 +61,25 @@ func TestSendMessage_Valid(t *testing.T) { // 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{ + database := newTestDB(t) + seedRole(t, database, &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) + seedUser(t, database, &db.User{ID: 1, Username: "alice"}) + seedUser(t, database, &db.User{ID: 2, Username: "bob"}) + seedUserRole(t, database, 1, permissions.MemberRoleID) + seedUserRole(t, database, 2, permissions.MemberRoleID) + seedChannel(t, database, &db.Channel{ID: 50, Name: "dm-1-2", Type: "dm"}) + seedDMParticipant(t, database, 50, 1) + seedDMParticipant(t, database, 50, 2) + checker := permissions.NewChecker(database) + svc := NewMessageService(database, NewPermissionService(database, 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 + seedBlock(t, database, 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) } @@ -93,16 +94,16 @@ func TestCanPost_DMBlockEnforced(t *testing.T) { // 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{ + database := newTestDB(t) + seedRole(t, database, &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) + seedUser(t, database, &db.User{ID: 1, Username: "alice"}) + seedUserRole(t, database, 1, permissions.MemberRoleID) + seedChannel(t, database, &db.Channel{ID: 10, Name: "general", Type: "text"}) + checker := permissions.NewChecker(database) + svc := NewMessageService(database, NewPermissionService(database, checker), nil) if err := svc.CanPost(1, 10); !errors.Is(err, ErrForbidden) { t.Fatalf("missing SEND_MESSAGES must refuse: got %v", err) @@ -113,22 +114,22 @@ func TestCanPost_ChannelPermissionRequired(t *testing.T) { // postable only by users with MANAGE_MESSAGES, even when they hold // READ|SEND. A plain member is refused; a moderator with MANAGE_MESSAGES posts. func TestCanPost_AnnouncementRequiresManageMessages(t *testing.T) { - ms := store.NewMemStore() - ms.SeedRole(&db.Role{ + database := newTestDB(t) + seedRole(t, database, &db.Role{ ID: permissions.MemberRoleID, Name: "member", Permissions: permissions.ReadMessages | permissions.SendMessages, Position: 1, }) - ms.SeedRole(&db.Role{ + seedRole(t, database, &db.Role{ ID: permissions.ModeratorRoleID, Name: "moderator", Permissions: permissions.ReadMessages | permissions.SendMessages | permissions.ManageMessages, Position: 60, }) - ms.SeedUserRole(1, permissions.MemberRoleID) - ms.SeedUserRole(2, permissions.ModeratorRoleID) - ms.SeedUser(&db.User{ID: 1, Username: "alice"}) - ms.SeedUser(&db.User{ID: 2, Username: "mod"}) - ms.SeedChannel(&db.Channel{ID: 20, Name: "announcements", Type: "announcement"}) - checker := permissions.NewChecker(ms) - svc := NewMessageService(ms, NewPermissionService(ms, checker), nil) + seedUser(t, database, &db.User{ID: 1, Username: "alice"}) + seedUser(t, database, &db.User{ID: 2, Username: "mod"}) + seedUserRole(t, database, 1, permissions.MemberRoleID) + seedUserRole(t, database, 2, permissions.ModeratorRoleID) + seedChannel(t, database, &db.Channel{ID: 20, Name: "announcements", Type: "announcement"}) + checker := permissions.NewChecker(database) + svc := NewMessageService(database, NewPermissionService(database, checker), nil) // Member has READ|SEND but not MANAGE_MESSAGES → refused in an announcement channel. if err := svc.CanPost(1, 20); !errors.Is(err, ErrForbidden) { @@ -145,25 +146,25 @@ func TestCanPost_AnnouncementRequiresManageMessages(t *testing.T) { // 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{ + database := newTestDB(t) + seedRole(t, database, &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) + seedUser(t, database, &db.User{ID: 1, Username: "alice", Status: "online"}) + seedUser(t, database, &db.User{ID: 2, Username: "mallory", Status: "online"}) + seedUserRole(t, database, 1, permissions.MemberRoleID) + seedUserRole(t, database, 2, permissions.MemberRoleID) + seedChannel(t, database, &db.Channel{ID: 10, Name: "general", Type: "text"}) + checker := permissions.NewChecker(database) + svc := NewMessageService(database, NewPermissionService(database, checker), nil) - if err := ms.CreateAttachment("att-own", 1, "a.png", "s-a.png", "image/png", 10, nil, nil); err != nil { + if err := database.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 { + if err := database.CreateAttachment("att-foreign", 2, "b.png", "s-b.png", "image/png", 10, nil, nil); err != nil { t.Fatal(err) } @@ -179,11 +180,11 @@ func TestSendMessage_AttachmentOwnershipAtomic(t *testing.T) { t.Fatal("message should persist even when some attachments are skipped") } - own, _ := ms.GetAttachmentByID("att-own") + own, _ := database.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") + foreign, _ := database.GetAttachmentByID("att-foreign") if foreign.MessageID != nil { t.Error("another user's attachment must never be linked (IDOR guard)") } @@ -200,14 +201,14 @@ func TestSendMessage_AttachmentOwnershipAtomic(t *testing.T) { if retry.MessageID <= 0 { t.Fatal("retry should persist a message") } - own2, _ := ms.GetAttachmentByID("att-own") + own2, _ := database.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() + svc, _ := newTestMessageService(t) _, err := svc.SendMessage(context.Background(), SendMessageParams{ ChannelID: 10, @@ -225,7 +226,7 @@ func TestSendMessage_EmptyContent(t *testing.T) { } func TestSendMessage_ExceedsMaxLength(t *testing.T) { - svc, _ := newTestMessageService() + svc, _ := newTestMessageService(t) // maxMessageLen is 4000 runes; create content that exceeds it. longContent := strings.Repeat("a", 4001) @@ -246,7 +247,7 @@ func TestSendMessage_ExceedsMaxLength(t *testing.T) { } func TestSendMessage_ChannelNotFound(t *testing.T) { - svc, _ := newTestMessageService() + svc, _ := newTestMessageService(t) _, err := svc.SendMessage(context.Background(), SendMessageParams{ ChannelID: 999, @@ -263,7 +264,7 @@ func TestSendMessage_ChannelNotFound(t *testing.T) { } func TestSendMessage_InvalidChannelID(t *testing.T) { - svc, _ := newTestMessageService() + svc, _ := newTestMessageService(t) _, err := svc.SendMessage(context.Background(), SendMessageParams{ ChannelID: 0, @@ -280,21 +281,21 @@ func TestSendMessage_InvalidChannelID(t *testing.T) { } func TestSendMessage_NoPermission(t *testing.T) { - ms := store.NewMemStore() + database := newTestDB(t) // Role with ReadMessages only (no SendMessages). - ms.SeedRole(&db.Role{ + seedRole(t, database, &db.Role{ ID: permissions.MemberRoleID, Name: "member", Permissions: permissions.ReadMessages, Position: 1, }) - ms.SeedUserRole(1, permissions.MemberRoleID) - ms.SeedUser(&db.User{ID: 1, Username: "alice"}) - ms.SeedChannel(&db.Channel{ID: 10, Name: "readonly", Type: "text"}) + seedUser(t, database, &db.User{ID: 1, Username: "alice"}) + seedUserRole(t, database, 1, permissions.MemberRoleID) + seedChannel(t, database, &db.Channel{ID: 10, Name: "readonly", Type: "text"}) - checker := permissions.NewChecker(ms) - permSvc := NewPermissionService(ms, checker) - svc := NewMessageService(ms, permSvc, nil) + checker := permissions.NewChecker(database) + permSvc := NewPermissionService(database, checker) + svc := NewMessageService(database, permSvc, nil) _, err := svc.SendMessage(context.Background(), SendMessageParams{ ChannelID: 10, @@ -311,7 +312,7 @@ func TestSendMessage_NoPermission(t *testing.T) { } func TestEditMessage_OwnerCanEdit(t *testing.T) { - svc, _ := newTestMessageService() + svc, _ := newTestMessageService(t) // Send a message first. result, err := svc.SendMessage(context.Background(), SendMessageParams{ @@ -337,10 +338,10 @@ func TestEditMessage_OwnerCanEdit(t *testing.T) { } func TestEditMessage_NonOwnerFails(t *testing.T) { - svc, ms := newTestMessageService() + svc, database := newTestMessageService(t) // Add a second user. - ms.SeedUserRole(2, permissions.MemberRoleID) - ms.SeedUser(&db.User{ID: 2, Username: "bob"}) + seedUser(t, database, &db.User{ID: 2, Username: "bob"}) + seedUserRole(t, database, 2, permissions.MemberRoleID) // User 1 sends a message. result, err := svc.SendMessage(context.Background(), SendMessageParams{ @@ -364,7 +365,7 @@ func TestEditMessage_NonOwnerFails(t *testing.T) { } func TestEditMessage_EmptyContentFails(t *testing.T) { - svc, _ := newTestMessageService() + svc, _ := newTestMessageService(t) result, err := svc.SendMessage(context.Background(), SendMessageParams{ ChannelID: 10, @@ -386,7 +387,7 @@ func TestEditMessage_EmptyContentFails(t *testing.T) { } func TestDeleteMessage_OwnerCanDelete(t *testing.T) { - svc, _ := newTestMessageService() + svc, _ := newTestMessageService(t) result, err := svc.SendMessage(context.Background(), SendMessageParams{ ChannelID: 10, @@ -411,9 +412,9 @@ func TestDeleteMessage_OwnerCanDelete(t *testing.T) { } func TestDeleteMessage_NonOwnerWithoutModFails(t *testing.T) { - svc, ms := newTestMessageService() - ms.SeedUserRole(2, permissions.MemberRoleID) - ms.SeedUser(&db.User{ID: 2, Username: "bob"}) + svc, database := newTestMessageService(t) + seedUser(t, database, &db.User{ID: 2, Username: "bob"}) + seedUserRole(t, database, 2, permissions.MemberRoleID) // User 1 sends. result, err := svc.SendMessage(context.Background(), SendMessageParams{ @@ -437,30 +438,30 @@ func TestDeleteMessage_NonOwnerWithoutModFails(t *testing.T) { } func TestDeleteMessage_ModCanDeleteOthersMessage(t *testing.T) { - ms := store.NewMemStore() + database := newTestDB(t) // Mod role has ManageMessages + SendMessages + ReadMessages. modPerms := permissions.SendMessages | permissions.ReadMessages | permissions.ManageMessages - ms.SeedRole(&db.Role{ + seedRole(t, database, &db.Role{ ID: permissions.ModeratorRoleID, Name: "moderator", Permissions: modPerms, Position: 10, }) - ms.SeedRole(&db.Role{ + seedRole(t, database, &db.Role{ ID: permissions.MemberRoleID, Name: "member", Permissions: permissions.SendMessages | permissions.ReadMessages, Position: 1, }) - ms.SeedUserRole(1, permissions.MemberRoleID) - ms.SeedUserRole(2, permissions.ModeratorRoleID) - ms.SeedUser(&db.User{ID: 1, Username: "alice"}) - ms.SeedUser(&db.User{ID: 2, Username: "mod_bob"}) - ms.SeedChannel(&db.Channel{ID: 10, Name: "general", Type: "text"}) + seedUser(t, database, &db.User{ID: 1, Username: "alice"}) + seedUser(t, database, &db.User{ID: 2, Username: "mod_bob"}) + seedUserRole(t, database, 1, permissions.MemberRoleID) + seedUserRole(t, database, 2, permissions.ModeratorRoleID) + seedChannel(t, database, &db.Channel{ID: 10, Name: "general", Type: "text"}) - checker := permissions.NewChecker(ms) - permSvc := NewPermissionService(ms, checker) - svc := NewMessageService(ms, permSvc, nil) + checker := permissions.NewChecker(database) + permSvc := NewPermissionService(database, checker) + svc := NewMessageService(database, permSvc, nil) // User 1 sends a message. result, err := svc.SendMessage(context.Background(), SendMessageParams{ @@ -484,7 +485,7 @@ func TestDeleteMessage_ModCanDeleteOthersMessage(t *testing.T) { } func TestDeleteMessage_InvalidMessageID(t *testing.T) { - svc, _ := newTestMessageService() + svc, _ := newTestMessageService(t) _, err := svc.DeleteMessage(1, 0) if err == nil { @@ -496,7 +497,7 @@ func TestDeleteMessage_InvalidMessageID(t *testing.T) { } func TestSendMessage_HTMLSanitized(t *testing.T) { - svc, _ := newTestMessageService() + svc, _ := newTestMessageService(t) result, err := svc.SendMessage(context.Background(), SendMessageParams{ ChannelID: 10, diff --git a/Server/service/moderation.go b/Server/service/moderation.go index d5c692a6..28d0efe5 100644 --- a/Server/service/moderation.go +++ b/Server/service/moderation.go @@ -7,18 +7,17 @@ import ( "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 perms *PermissionService } // NewModerationService creates a ModerationService. -func NewModerationService(st store.Store, perms *PermissionService) *ModerationService { +func NewModerationService(st Store, perms *PermissionService) *ModerationService { return &ModerationService{st: st, perms: perms} } diff --git a/Server/service/moderation_test.go b/Server/service/moderation_test.go index 37b316a3..4b008e9c 100644 --- a/Server/service/moderation_test.go +++ b/Server/service/moderation_test.go @@ -8,27 +8,27 @@ import ( "github.com/owncord/server/db" "github.com/owncord/server/permissions" - "github.com/owncord/server/store" ) -// newTestModerationService seeds a MemStore with a role hierarchy: +// newTestModerationService seeds a real in-memory DB 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}) +func newTestModerationService(t *testing.T) (*ModerationService, *db.DB) { + t.Helper() + database := newTestDB(t) + seedRole(t, database, &db.Role{ID: 1, Name: "owner", Permissions: permissions.Administrator, Position: 100}) + seedRole(t, database, &db.Role{ID: 2, Name: "mod", Permissions: permissions.BanMembers, Position: 80}) + seedRole(t, database, &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"}) + seedUser(t, database, &db.User{ID: userID, Username: fmt.Sprintf("u%d", userID), Status: "offline"}) + seedUserRole(t, database, userID, roleID) } - checker := permissions.NewChecker(ms) - return NewModerationService(ms, NewPermissionService(ms, checker)), ms + checker := permissions.NewChecker(database) + return NewModerationService(database, NewPermissionService(database, checker)), database } func TestBanUser_RequiresBanPermission(t *testing.T) { - svc, _ := newTestModerationService() + svc, _ := newTestModerationService(t) // A member without BAN_MEMBERS is refused. if err := svc.BanUser(context.Background(), 3, 4, "nope", nil); !errors.Is(err, ErrForbidden) { @@ -42,7 +42,7 @@ func TestBanUser_RequiresBanPermission(t *testing.T) { } func TestBanUser_HierarchyEnforced(t *testing.T) { - svc, ms := newTestModerationService() + svc, database := newTestModerationService(t) // Equal rank: mod cannot ban mod. if err := svc.BanUser(context.Background(), 2, 5, "peer", nil); !errors.Is(err, ErrForbidden) { @@ -52,19 +52,19 @@ func TestBanUser_HierarchyEnforced(t *testing.T) { 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) + owner, _ := database.GetUserByID(1) if owner.Banned { t.Fatal("owner must not be banned") } } func TestBanUser_AuthorizedSucceeds(t *testing.T) { - svc, ms := newTestModerationService() + svc, database := newTestModerationService(t) if err := svc.BanUser(context.Background(), 2, 3, "spam", nil); err != nil { t.Fatalf("authorized ban: %v", err) } - target, _ := ms.GetUserByID(3) + target, _ := database.GetUserByID(3) if !target.Banned { t.Fatal("target should be banned") } @@ -83,7 +83,7 @@ func TestBanUser_AuthorizedSucceeds(t *testing.T) { } func TestUnbanUser_AuthorizationMatrix(t *testing.T) { - svc, ms := newTestModerationService() + svc, database := newTestModerationService(t) if err := svc.BanUser(context.Background(), 1, 3, "setup", nil); err != nil { t.Fatalf("setup ban: %v", err) @@ -101,7 +101,7 @@ func TestUnbanUser_AuthorizationMatrix(t *testing.T) { if err := svc.UnbanUser(context.Background(), 2, 3); err != nil { t.Fatalf("authorized unban: %v", err) } - target, _ := ms.GetUserByID(3) + target, _ := database.GetUserByID(3) if target.Banned { t.Fatal("target should be unbanned") } diff --git a/Server/service/permission.go b/Server/service/permission.go index 2671ba7d..ba1389b1 100644 --- a/Server/service/permission.go +++ b/Server/service/permission.go @@ -7,7 +7,6 @@ import ( "github.com/owncord/server/db" "github.com/owncord/server/permissions" - "github.com/owncord/server/store" "github.com/owncord/server/telemetry" ) @@ -27,7 +26,7 @@ const permCacheTTL = 30 * time.Second // at scale. The cache is populated lazily on first access and invalidated // on role or channel override changes. type PermissionService struct { - st store.Store + st Store checker *permissions.Checker mu sync.RWMutex @@ -35,7 +34,7 @@ type PermissionService struct { } // NewPermissionService creates a PermissionService backed by the given DB. -func NewPermissionService(st store.Store, checker *permissions.Checker) *PermissionService { +func NewPermissionService(st Store, checker *permissions.Checker) *PermissionService { return &PermissionService{ st: st, checker: checker, diff --git a/Server/service/permission_test.go b/Server/service/permission_test.go index b1e58c4c..c5898dfa 100644 --- a/Server/service/permission_test.go +++ b/Server/service/permission_test.go @@ -6,27 +6,27 @@ import ( "github.com/owncord/server/db" "github.com/owncord/server/permissions" - "github.com/owncord/server/store" ) -// newTestPermService creates a PermissionService backed by a MemStore +// newTestPermService creates a PermissionService backed by a real in-memory DB // pre-populated with a single role and user. -func newTestPermService() (*PermissionService, *store.MemStore) { - ms := store.NewMemStore() - ms.SeedRole(&db.Role{ +func newTestPermService(t *testing.T) (*PermissionService, *db.DB) { + t.Helper() + database := newTestDB(t) + seedRole(t, database, &db.Role{ ID: permissions.MemberRoleID, Name: "member", Permissions: permissions.SendMessages | permissions.ReadMessages | permissions.AddReactions, Position: 1, }) - ms.SeedUserRole(1, permissions.MemberRoleID) - checker := permissions.NewChecker(ms) - return NewPermissionService(ms, checker), ms + seedUserRole(t, database, 1, permissions.MemberRoleID) + checker := permissions.NewChecker(database) + return NewPermissionService(database, checker), database } func TestHasChannelPerm_Allowed(t *testing.T) { - svc, ms := newTestPermService() - ms.SeedChannel(&db.Channel{ID: 10, Name: "general", Type: "text"}) + svc, database := newTestPermService(t) + seedChannel(t, database, &db.Channel{ID: 10, Name: "general", Type: "text"}) // Member has SendMessages | ReadMessages; no overrides exist, so base role perms apply. if !svc.HasChannelPerm(1, 10, permissions.SendMessages) { @@ -38,8 +38,8 @@ func TestHasChannelPerm_Allowed(t *testing.T) { } func TestHasChannelPerm_Denied(t *testing.T) { - svc, ms := newTestPermService() - ms.SeedChannel(&db.Channel{ID: 10, Name: "general", Type: "text"}) + svc, database := newTestPermService(t) + seedChannel(t, database, &db.Channel{ID: 10, Name: "general", Type: "text"}) // ManageMessages is NOT in the member role. if svc.HasChannelPerm(1, 10, permissions.ManageMessages) { @@ -48,10 +48,10 @@ func TestHasChannelPerm_Denied(t *testing.T) { } func TestHasChannelPerm_OverrideDeny(t *testing.T) { - svc, ms := newTestPermService() - ms.SeedChannel(&db.Channel{ID: 10, Name: "readonly", Type: "text"}) + svc, database := newTestPermService(t) + seedChannel(t, database, &db.Channel{ID: 10, Name: "readonly", Type: "text"}) // Deny SendMessages for this channel. - ms.SeedChannelOverride(permissions.MemberRoleID, 10, 0, permissions.SendMessages) + seedChannelOverride(t, database, permissions.MemberRoleID, 10, 0, permissions.SendMessages) // Invalidate so next check re-populates cache. svc.InvalidateAll() @@ -65,10 +65,10 @@ func TestHasChannelPerm_OverrideDeny(t *testing.T) { } func TestHasChannelPerm_OverrideAllow(t *testing.T) { - svc, ms := newTestPermService() - ms.SeedChannel(&db.Channel{ID: 10, Name: "special", Type: "text"}) + svc, database := newTestPermService(t) + seedChannel(t, database, &db.Channel{ID: 10, Name: "special", Type: "text"}) // Allow ManageMessages (not in base role) via channel override. - ms.SeedChannelOverride(permissions.MemberRoleID, 10, permissions.ManageMessages, 0) + seedChannelOverride(t, database, permissions.MemberRoleID, 10, permissions.ManageMessages, 0) svc.InvalidateAll() if !svc.HasChannelPerm(1, 10, permissions.ManageMessages) { @@ -77,20 +77,20 @@ func TestHasChannelPerm_OverrideAllow(t *testing.T) { } func TestHasChannelPerm_AdminBypass(t *testing.T) { - ms := store.NewMemStore() - ms.SeedRole(&db.Role{ + database := newTestDB(t) + seedRole(t, database, &db.Role{ ID: permissions.AdminRoleID, Name: "admin", Permissions: permissions.Administrator, Position: 90, }) - ms.SeedUserRole(1, permissions.AdminRoleID) - checker := permissions.NewChecker(ms) - svc := NewPermissionService(ms, checker) + seedUserRole(t, database, 1, permissions.AdminRoleID) + checker := permissions.NewChecker(database) + svc := NewPermissionService(database, checker) - ms.SeedChannel(&db.Channel{ID: 10, Name: "locked", Type: "text"}) + seedChannel(t, database, &db.Channel{ID: 10, Name: "locked", Type: "text"}) // Deny everything via override; admin should still bypass. - ms.SeedChannelOverride(permissions.AdminRoleID, 10, 0, permissions.SendMessages|permissions.ReadMessages) + seedChannelOverride(t, database, permissions.AdminRoleID, 10, 0, permissions.SendMessages|permissions.ReadMessages) if !svc.HasChannelPerm(1, 10, permissions.SendMessages) { t.Fatal("admin should bypass all permission checks") @@ -101,14 +101,14 @@ func TestHasChannelPerm_AdminBypass(t *testing.T) { } func TestInvalidateUser_ClearsCacheForUser(t *testing.T) { - svc, ms := newTestPermService() - ms.SeedChannel(&db.Channel{ID: 10, Name: "general", Type: "text"}) + svc, database := newTestPermService(t) + seedChannel(t, database, &db.Channel{ID: 10, Name: "general", Type: "text"}) // Populate cache. svc.HasChannelPerm(1, 10, permissions.SendMessages) // Now add a deny override. - ms.SeedChannelOverride(permissions.MemberRoleID, 10, 0, permissions.SendMessages) + seedChannelOverride(t, database, permissions.MemberRoleID, 10, 0, permissions.SendMessages) // Without invalidation, cache still says allowed. if !svc.HasChannelPerm(1, 10, permissions.SendMessages) { @@ -123,17 +123,17 @@ func TestInvalidateUser_ClearsCacheForUser(t *testing.T) { } func TestInvalidateAll_ClearsEntireCache(t *testing.T) { - svc, ms := newTestPermService() + svc, database := newTestPermService(t) // Add a second user. - ms.SeedUserRole(2, permissions.MemberRoleID) - ms.SeedChannel(&db.Channel{ID: 10, Name: "general", Type: "text"}) + seedUserRole(t, database, 2, permissions.MemberRoleID) + seedChannel(t, database, &db.Channel{ID: 10, Name: "general", Type: "text"}) // Populate cache for both users. svc.HasChannelPerm(1, 10, permissions.SendMessages) svc.HasChannelPerm(2, 10, permissions.SendMessages) // Add deny override. - ms.SeedChannelOverride(permissions.MemberRoleID, 10, 0, permissions.SendMessages) + seedChannelOverride(t, database, permissions.MemberRoleID, 10, 0, permissions.SendMessages) // Both still cached as allowed. if !svc.HasChannelPerm(1, 10, permissions.SendMessages) { @@ -159,14 +159,14 @@ func TestPermCacheTTLExpiry(t *testing.T) { // in a unit test, so we verify the structural behavior: after manually // backdating the populatedAt field the cache should be stale and the // next check should re-populate from the store. - svc, ms := newTestPermService() - ms.SeedChannel(&db.Channel{ID: 10, Name: "general", Type: "text"}) + svc, database := newTestPermService(t) + seedChannel(t, database, &db.Channel{ID: 10, Name: "general", Type: "text"}) // Populate cache. svc.HasChannelPerm(1, 10, permissions.SendMessages) // Add deny override. - ms.SeedChannelOverride(permissions.MemberRoleID, 10, 0, permissions.SendMessages) + seedChannelOverride(t, database, permissions.MemberRoleID, 10, 0, permissions.SendMessages) // Manually expire the cache entry by backdating populatedAt. svc.mu.Lock() @@ -182,8 +182,8 @@ func TestPermCacheTTLExpiry(t *testing.T) { } func TestHasChannelPerm_UnknownUserReturnsFalse(t *testing.T) { - svc, ms := newTestPermService() - ms.SeedChannel(&db.Channel{ID: 10, Name: "general", Type: "text"}) + svc, database := newTestPermService(t) + seedChannel(t, database, &db.Channel{ID: 10, Name: "general", Type: "text"}) // User 999 has no role assigned. if svc.HasChannelPerm(999, 10, permissions.SendMessages) { diff --git a/Server/service/seed_test.go b/Server/service/seed_test.go new file mode 100644 index 00000000..547064e6 --- /dev/null +++ b/Server/service/seed_test.go @@ -0,0 +1,181 @@ +package service + +import ( + "strconv" + "testing" + + "github.com/owncord/server/db" +) + +// This file provides real in-memory SQLite test helpers for the service +// package. D3 removed the store abstraction (and its MemStore fake); service +// tests now run against a real *db.DB opened at ":memory:" with all migrations +// applied. The seed* helpers insert rows directly with explicit IDs so tests +// keep the fixed identifiers (user 1, channel 10, …) they relied on under the +// old fake. Migration 001 pre-seeds the four default roles, so seedRole upserts +// by id to let a test redefine a role's permission bits. + +// newTestDB opens an in-memory database with the full migration set applied and +// registers cleanup. Each test gets an isolated database. +func newTestDB(t *testing.T) *db.DB { + t.Helper() + database, err := db.Open(":memory:") + if err != nil { + t.Fatalf("db.Open: %v", err) + } + t.Cleanup(func() { _ = database.Close() }) + if err := db.Migrate(database); err != nil { + t.Fatalf("db.Migrate: %v", err) + } + return database +} + +// seedRole upserts a role by id (overriding a default role's bits when the id +// collides with one of the migration-seeded defaults). +func seedRole(t *testing.T, database *db.DB, r *db.Role) { + t.Helper() + _, err := database.Exec( + `INSERT INTO roles (id, name, color, permissions, position, is_default) + VALUES (?, ?, ?, ?, ?, 0) + ON CONFLICT(id) DO UPDATE SET + name=excluded.name, + color=excluded.color, + permissions=excluded.permissions, + position=excluded.position`, + r.ID, r.Name, r.Color, r.Permissions, r.Position, + ) + if err != nil { + t.Fatalf("seedRole(%d): %v", r.ID, err) + } +} + +// seedUserRole assigns a role to a user, creating a minimal user row when one +// does not already exist. It preserves any identity fields set by an earlier +// seedUser call and only writes role_id. +func seedUserRole(t *testing.T, database *db.DB, userID, roleID int64) { + t.Helper() + _, err := database.Exec( + `INSERT INTO users (id, username, password, role_id) + VALUES (?, ?, '', ?) + ON CONFLICT(id) DO UPDATE SET role_id=excluded.role_id`, + userID, seedUsername(userID), roleID, + ) + if err != nil { + t.Fatalf("seedUserRole(%d,%d): %v", userID, roleID, err) + } +} + +// seedUser upserts a user's identity fields by id without clobbering a role_id +// assigned by a prior seedUserRole call. +func seedUser(t *testing.T, database *db.DB, u *db.User) { + t.Helper() + username := u.Username + if username == "" { + username = seedUsername(u.ID) + } + status := u.Status + if status == "" { + status = "offline" + } + var banReason any + if u.BanReason != nil { + banReason = *u.BanReason + } + var avatar any + if u.Avatar != nil { + avatar = *u.Avatar + } + banned := 0 + if u.Banned { + banned = 1 + } + _, err := database.Exec( + `INSERT INTO users (id, username, password, avatar, status, banned, ban_reason) + VALUES (?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET + username=excluded.username, + password=excluded.password, + avatar=excluded.avatar, + status=excluded.status, + banned=excluded.banned, + ban_reason=excluded.ban_reason`, + u.ID, username, u.PasswordHash, avatar, status, banned, banReason, + ) + if err != nil { + t.Fatalf("seedUser(%d): %v", u.ID, err) + } +} + +// seedChannel upserts a channel by id. +func seedChannel(t *testing.T, database *db.DB, ch *db.Channel) { + t.Helper() + ctype := ch.Type + if ctype == "" { + ctype = "text" + } + _, err := database.Exec( + `INSERT INTO channels (id, name, type, category, topic, position) + VALUES (?, ?, ?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET + name=excluded.name, + type=excluded.type, + category=excluded.category, + topic=excluded.topic, + position=excluded.position`, + ch.ID, ch.Name, ctype, nullStr(ch.Category), nullStr(ch.Topic), ch.Position, + ) + if err != nil { + t.Fatalf("seedChannel(%d): %v", ch.ID, err) + } +} + +// seedChannelOverride sets a per-channel permission override for a role. +func seedChannelOverride(t *testing.T, database *db.DB, roleID, channelID, allow, deny int64) { + t.Helper() + _, err := database.Exec( + `INSERT INTO channel_overrides (channel_id, role_id, allow, deny) + VALUES (?, ?, ?, ?) + ON CONFLICT(channel_id, role_id) DO UPDATE SET + allow=excluded.allow, + deny=excluded.deny`, + channelID, roleID, allow, deny, + ) + if err != nil { + t.Fatalf("seedChannelOverride(role=%d,chan=%d): %v", roleID, channelID, err) + } +} + +// seedDMParticipant adds a user as a participant of a DM channel. +func seedDMParticipant(t *testing.T, database *db.DB, channelID, userID int64) { + t.Helper() + _, err := database.Exec( + `INSERT OR IGNORE INTO dm_participants (channel_id, user_id) VALUES (?, ?)`, + channelID, userID, + ) + if err != nil { + t.Fatalf("seedDMParticipant(chan=%d,user=%d): %v", channelID, userID, err) + } +} + +// seedBlock records that blockerID has blocked blockedID. +func seedBlock(t *testing.T, database *db.DB, blockerID, blockedID int64) { + t.Helper() + _, err := database.Exec( + `INSERT OR IGNORE INTO user_blocks (blocker_id, blocked_id) VALUES (?, ?)`, + blockerID, blockedID, + ) + if err != nil { + t.Fatalf("seedBlock(%d,%d): %v", blockerID, blockedID, err) + } +} + +func seedUsername(id int64) string { + return "seeduser" + strconv.FormatInt(id, 10) +} + +func nullStr(s string) any { + if s == "" { + return nil + } + return s +} diff --git a/Server/service/service.go b/Server/service/service.go index 113fa924..2799ec41 100644 --- a/Server/service/service.go +++ b/Server/service/service.go @@ -7,7 +7,6 @@ package service import ( "github.com/owncord/server/auth" "github.com/owncord/server/permissions" - "github.com/owncord/server/store" ) // Services bundles all domain services for dependency injection. @@ -25,7 +24,7 @@ type Services struct { } // New creates all domain services wired together. -func New(st store.Store, limiter *auth.RateLimiter) *Services { +func New(st Store, limiter *auth.RateLimiter) *Services { permChecker := permissions.NewChecker(st) permSvc := NewPermissionService(st, permChecker) return &Services{ diff --git a/Server/service/user.go b/Server/service/user.go index 4332556d..76948726 100644 --- a/Server/service/user.go +++ b/Server/service/user.go @@ -8,17 +8,16 @@ import ( "time" "github.com/owncord/server/db" - "github.com/owncord/server/store" "github.com/owncord/server/telemetry" ) // UserService handles user profile and session operations. type UserService struct { - st store.Store + st Store } // NewUserService creates a UserService. -func NewUserService(st store.Store) *UserService { +func NewUserService(st Store) *UserService { return &UserService{st: st} } diff --git a/Server/service/user_test.go b/Server/service/user_test.go index 34c7674f..13864de6 100644 --- a/Server/service/user_test.go +++ b/Server/service/user_test.go @@ -6,14 +6,15 @@ import ( "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. +// pwStore wraps a real *db.DB with controllable DeleteOtherSessions behavior +// and audit capture, so the committed-password partial-success contract (W2-2) +// is testable. Embedding *db.DB satisfies the service Store interface; the two +// overridden methods intercept the calls the contract turns on while every +// other call (UpdateUserPassword, GetUserByID) hits the real database. type pwStore struct { - *store.MemStore + *db.DB failRevokes int // number of DeleteOtherSessions calls that fail before succeeding revokeCalls int audits []string @@ -37,9 +38,9 @@ func (f *pwStore) LogAudit(_ int64, action, _ string, _ int64, _ string) error { // 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} + database := newTestDB(t) + seedUser(t, database, &db.User{ID: 7, Username: "pat", PasswordHash: "oldhash"}) + fs := &pwStore{DB: database, failRevokes: 99} svc := NewUserService(fs) res, err := svc.ChangePassword(7, "newhash", 1) @@ -49,7 +50,7 @@ func TestChangePassword_RevokeFailureIsPartialSuccess(t *testing.T) { if !res.RevokeFailed { t.Fatal("RevokeFailed should be set when revocation keeps failing") } - if u, _ := ms.GetUserByID(7); u.PasswordHash != "newhash" { + if u, _ := database.GetUserByID(7); u.PasswordHash != "newhash" { t.Fatal("password should be committed") } if !slices.Contains(fs.audits, "password_change") { @@ -60,9 +61,9 @@ func TestChangePassword_RevokeFailureIsPartialSuccess(t *testing.T) { // 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} + database := newTestDB(t) + seedUser(t, database, &db.User{ID: 7, Username: "pat", PasswordHash: "oldhash"}) + fs := &pwStore{DB: database, failRevokes: 1} svc := NewUserService(fs) res, err := svc.ChangePassword(7, "newhash", 1) diff --git a/Server/service/voice.go b/Server/service/voice.go index f8579579..c0a42866 100644 --- a/Server/service/voice.go +++ b/Server/service/voice.go @@ -9,18 +9,17 @@ import ( "github.com/owncord/server/db" "github.com/owncord/server/permissions" - "github.com/owncord/server/store" "github.com/owncord/server/telemetry" ) // VoiceService handles voice state business logic. type VoiceService struct { - st store.Store + st Store perm *PermissionService } // NewVoiceService creates a VoiceService. -func NewVoiceService(st store.Store, perm *PermissionService) *VoiceService { +func NewVoiceService(st Store, perm *PermissionService) *VoiceService { return &VoiceService{st: st, perm: perm} } diff --git a/Server/store/memstore.go b/Server/store/memstore.go deleted file mode 100644 index 1837c54a..00000000 --- a/Server/store/memstore.go +++ /dev/null @@ -1,824 +0,0 @@ -package store - -import ( - "context" - "database/sql" - "fmt" - "sync" - "time" - - "github.com/owncord/server/db" -) - -// compile-time interface check -var _ Store = (*MemStore)(nil) - -// MemStore is a lightweight in-memory Store implementation for testing. -// Only the methods needed by service-layer tests are implemented; -// everything else panics with a descriptive message. -type MemStore struct { - mu sync.Mutex - - // auto-increment counters - nextMsgID int64 - nextChannelID int64 - - channels map[int64]*db.Channel - messages map[int64]*db.Message - users map[int64]*db.User - roles map[int64]*db.Role - // userID -> roleID - userRoles map[int64]int64 - // roleID -> channelID -> override - channelOverrides map[int64]map[int64]db.ChannelOverride - // messageID -> userID -> emoji -> bool - reactions map[int64]map[int64]map[string]bool - // channelID -> set of participant userIDs - dmParticipants map[int64]map[int64]bool - // blockerID -> blockedID -> bool (bidirectional checked via IsEitherBlocked) - 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 - // without these fields keep working. - eventsOnce sync.Once - eventStore *memEventStore -} - -// NewMemStore creates an empty MemStore ready for use. -func NewMemStore() *MemStore { - return &MemStore{ - channels: make(map[int64]*db.Channel), - messages: make(map[int64]*db.Message), - users: make(map[int64]*db.User), - roles: make(map[int64]*db.Role), - userRoles: make(map[int64]int64), - 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), - } -} - -// ---------- helpers for test setup ---------- - -// SeedChannel inserts a channel directly (for test setup). -func (m *MemStore) SeedChannel(ch *db.Channel) { - m.mu.Lock() - defer m.mu.Unlock() - m.channels[ch.ID] = ch - if ch.ID >= m.nextChannelID { - m.nextChannelID = ch.ID + 1 - } -} - -// SeedUser inserts a user directly (for test setup). -func (m *MemStore) SeedUser(u *db.User) { - m.mu.Lock() - defer m.mu.Unlock() - m.users[u.ID] = u -} - -// SeedRole inserts a role directly (for test setup). -func (m *MemStore) SeedRole(r *db.Role) { - m.mu.Lock() - defer m.mu.Unlock() - m.roles[r.ID] = r -} - -// SeedUserRole assigns a role to a user (for test setup). -func (m *MemStore) SeedUserRole(userID, roleID int64) { - m.mu.Lock() - defer m.mu.Unlock() - m.userRoles[userID] = roleID -} - -// SeedChannelOverride sets a channel permission override for a role (for test setup). -func (m *MemStore) SeedChannelOverride(roleID, channelID int64, allow, deny int64) { - m.mu.Lock() - defer m.mu.Unlock() - if m.channelOverrides[roleID] == nil { - m.channelOverrides[roleID] = make(map[int64]db.ChannelOverride) - } - m.channelOverrides[roleID][channelID] = db.ChannelOverride{Allow: allow, Deny: deny} -} - -// SeedDMParticipant adds a user as a DM participant in a channel (for test setup). -func (m *MemStore) SeedDMParticipant(channelID, userID int64) { - m.mu.Lock() - defer m.mu.Unlock() - if m.dmParticipants[channelID] == nil { - m.dmParticipants[channelID] = make(map[int64]bool) - } - m.dmParticipants[channelID][userID] = true -} - -// SeedBlock records a block relationship (for test setup). -func (m *MemStore) SeedBlock(blockerID, blockedID int64) { - m.mu.Lock() - defer m.mu.Unlock() - if m.blocks[blockerID] == nil { - m.blocks[blockerID] = make(map[int64]bool) - } - m.blocks[blockerID][blockedID] = true -} - -// ---------- 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) } - -// ---------- MessageStore ---------- - -func (m *MemStore) CreateMessage(channelID, userID int64, content string, replyTo *int64) (int64, error) { - m.mu.Lock() - defer m.mu.Unlock() - m.nextMsgID++ - id := m.nextMsgID - m.messages[id] = &db.Message{ - ID: id, - ChannelID: channelID, - UserID: userID, - Content: content, - ReplyTo: replyTo, - Timestamp: time.Now().UTC().Format(time.RFC3339), - } - return id, nil -} - -func (m *MemStore) GetMessage(id int64) (*db.Message, error) { - m.mu.Lock() - defer m.mu.Unlock() - msg, ok := m.messages[id] - if !ok { - return nil, fmt.Errorf("message %d not found", id) - } - // Return a copy to avoid aliasing. - cp := *msg - return &cp, nil -} - -func (m *MemStore) GetMessages(_ int64, _ int64, _ int) ([]db.MessageWithUser, error) { - panic("memstore: not implemented: GetMessages") -} - -func (m *MemStore) GetMessagesForAPI(_ int64, _ int64, _ int, _ int64) ([]db.MessageAPIResponse, error) { - return []db.MessageAPIResponse{}, nil -} - -func (m *MemStore) EditMessage(id, userID int64, content string) error { - m.mu.Lock() - defer m.mu.Unlock() - msg, ok := m.messages[id] - if !ok { - return fmt.Errorf("message %d not found", id) - } - if msg.UserID != userID { - return fmt.Errorf("not message owner") - } - now := time.Now().UTC().Format(time.RFC3339) - msg.Content = content - msg.EditedAt = &now - return nil -} - -func (m *MemStore) DeleteMessage(id, userID int64, isMod bool) error { - m.mu.Lock() - defer m.mu.Unlock() - msg, ok := m.messages[id] - if !ok { - return fmt.Errorf("message %d not found", id) - } - if !isMod && msg.UserID != userID { - return fmt.Errorf("not message owner") - } - msg.Deleted = true - return nil -} - -func (m *MemStore) SearchMessages(_ string, _ *int64, _ int) ([]db.MessageSearchResult, error) { - return []db.MessageSearchResult{}, nil -} - -func (m *MemStore) SearchMessagesInChannels(_ string, _ []int64, _ int) ([]db.MessageSearchResult, error) { - return []db.MessageSearchResult{}, nil -} - -func (m *MemStore) GetPinnedMessages(_ int64, _ int64) ([]db.MessageAPIResponse, error) { - return []db.MessageAPIResponse{}, nil -} - -func (m *MemStore) SetMessagePinned(_ int64, _ bool) error { return nil } - -func (m *MemStore) AddReaction(messageID, userID int64, emoji string) error { - m.mu.Lock() - defer m.mu.Unlock() - if _, ok := m.messages[messageID]; !ok { - return fmt.Errorf("message %d not found", messageID) - } - if m.reactions[messageID] == nil { - m.reactions[messageID] = make(map[int64]map[string]bool) - } - if m.reactions[messageID][userID] == nil { - m.reactions[messageID][userID] = make(map[string]bool) - } - if m.reactions[messageID][userID][emoji] { - return fmt.Errorf("reaction already exists") - } - m.reactions[messageID][userID][emoji] = true - return nil -} - -func (m *MemStore) RemoveReaction(messageID, userID int64, emoji string) error { - m.mu.Lock() - defer m.mu.Unlock() - if m.reactions[messageID] == nil || m.reactions[messageID][userID] == nil || !m.reactions[messageID][userID][emoji] { - return fmt.Errorf("reaction not found") - } - delete(m.reactions[messageID][userID], emoji) - return nil -} - -func (m *MemStore) GetReactions(_ int64) ([]db.ReactionCount, error) { - return []db.ReactionCount{}, nil -} - -func (m *MemStore) UpdateReadState(userID, channelID, lastReadMessageID int64) error { - m.mu.Lock() - defer m.mu.Unlock() - if m.readStates[userID] == nil { - m.readStates[userID] = make(map[int64]int64) - } - m.readStates[userID][channelID] = lastReadMessageID - return nil -} - -func (m *MemStore) GetChannelUnreadCounts(_ int64) (map[int64]db.ChannelUnread, error) { - return map[int64]db.ChannelUnread{}, nil -} - -func (m *MemStore) GetLatestMessageID(channelID int64) (int64, error) { - m.mu.Lock() - defer m.mu.Unlock() - var latest int64 - for _, msg := range m.messages { - if msg.ChannelID == channelID && msg.ID > latest { - latest = msg.ID - } - } - return latest, 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) { - return map[int64][]db.AttachmentInfo{}, nil -} - -// ---------- ChannelStore ---------- - -func (m *MemStore) ListChannels() ([]db.Channel, error) { - m.mu.Lock() - defer m.mu.Unlock() - out := make([]db.Channel, 0, len(m.channels)) - for _, ch := range m.channels { - out = append(out, *ch) - } - return out, nil -} - -func (m *MemStore) GetChannel(id int64) (*db.Channel, error) { - m.mu.Lock() - defer m.mu.Unlock() - ch, ok := m.channels[id] - if !ok { - return nil, fmt.Errorf("channel %d not found", id) - } - cp := *ch - return &cp, nil -} - -func (m *MemStore) CreateChannel(name, chanType, category, topic string, position int) (int64, error) { - m.mu.Lock() - defer m.mu.Unlock() - m.nextChannelID++ - id := m.nextChannelID - m.channels[id] = &db.Channel{ - ID: id, - Name: name, - Type: chanType, - Category: category, - Topic: topic, - Position: position, - CreatedAt: time.Now().UTC().Format(time.RFC3339), - } - return id, nil -} - -func (m *MemStore) UpdateChannel(_ int64, _, _ string, _ int) error { - panic("memstore: not implemented: UpdateChannel") -} - -func (m *MemStore) DeleteChannel(_ int64) error { - panic("memstore: not implemented: DeleteChannel") -} - -func (m *MemStore) SetChannelSlowMode(_ int64, _ int) error { - panic("memstore: not implemented: SetChannelSlowMode") -} - -func (m *MemStore) SetChannelVoiceMaxUsers(_ int64, _ int) error { - panic("memstore: not implemented: SetChannelVoiceMaxUsers") -} - -func (m *MemStore) GetChannelPermissions(channelID, roleID int64) (int64, int64, error) { - m.mu.Lock() - defer m.mu.Unlock() - overrides, ok := m.channelOverrides[roleID] - if !ok { - return 0, 0, nil - } - o, ok := overrides[channelID] - if !ok { - return 0, 0, nil - } - return o.Allow, o.Deny, nil -} - -func (m *MemStore) GetAllChannelPermissionsForRole(roleID int64) (map[int64]db.ChannelOverride, error) { - m.mu.Lock() - defer m.mu.Unlock() - overrides, ok := m.channelOverrides[roleID] - if !ok { - return map[int64]db.ChannelOverride{}, nil - } - // Copy map. - out := make(map[int64]db.ChannelOverride, len(overrides)) - for k, v := range overrides { - out[k] = v - } - return out, nil -} - -func (m *MemStore) GetChannelTypes(_ []int64) (map[int64]string, error) { - panic("memstore: not implemented: GetChannelTypes") -} - -// ---------- UserStore ---------- - -func (m *MemStore) GetUserByID(id int64) (*db.User, error) { - m.mu.Lock() - defer m.mu.Unlock() - u, ok := m.users[id] - if !ok { - return nil, fmt.Errorf("user %d not found", id) - } - cp := *u - return &cp, nil -} - -func (m *MemStore) GetUserByUsername(_ string) (*db.User, error) { - panic("memstore: not implemented: GetUserByUsername") -} - -func (m *MemStore) CreateUser(_, _ string, _ int) (int64, error) { - panic("memstore: not implemented: CreateUser") -} - -func (m *MemStore) CreateOwnerIfEmpty(_, _ string, _ int) (int64, error) { - panic("memstore: not implemented: CreateOwnerIfEmpty") -} - -func (m *MemStore) CreateUserWithInvite(_, _ string, _ int, _ string) (int64, error) { - panic("memstore: not implemented: CreateUserWithInvite") -} - -func (m *MemStore) UpdateUserProfile(_ int64, _ string, _ *string) error { - panic("memstore: not implemented: UpdateUserProfile") -} - -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 { - m.mu.Lock() - defer m.mu.Unlock() - u, ok := m.users[id] - if !ok { - return fmt.Errorf("user %d not found", id) - } - u.Status = status - return nil -} - -func (m *MemStore) UpdateUserTOTPSecret(_ int64, _ *string) error { - panic("memstore: not implemented: UpdateUserTOTPSecret") -} - -func (m *MemStore) UpdateUserRole(_ int64, _ int64) error { - panic("memstore: not implemented: UpdateUserRole") -} - -func (m *MemStore) ResetAllUserStatuses() error { - panic("memstore: not implemented: ResetAllUserStatuses") -} - -func (m *MemStore) DeleteAccount(_ context.Context, _ int64) error { - panic("memstore: not implemented: DeleteAccount") -} - -func (m *MemStore) ListMembers() ([]db.MemberSummary, error) { - panic("memstore: not implemented: ListMembers") -} - -// ---------- SessionStore ---------- - -func (m *MemStore) CreateSession(_ int64, _, _, _ string) (int64, error) { - panic("memstore: not implemented: CreateSession") -} - -func (m *MemStore) GetSessionByTokenHash(_ string) (*db.Session, error) { - panic("memstore: not implemented: GetSessionByTokenHash") -} - -func (m *MemStore) GetSessionWithBanStatus(_ string) (*db.SessionWithBanStatus, error) { - panic("memstore: not implemented: GetSessionWithBanStatus") -} - -func (m *MemStore) DeleteSession(_ string) error { - panic("memstore: not implemented: DeleteSession") -} - -func (m *MemStore) DeleteOtherSessions(_ int64, _ int64) (int64, error) { - panic("memstore: not implemented: DeleteOtherSessions") -} - -func (m *MemStore) DeleteExpiredSessions() error { - panic("memstore: not implemented: DeleteExpiredSessions") -} - -func (m *MemStore) DeleteSessionByID(_ int64, _ int64) error { - panic("memstore: not implemented: DeleteSessionByID") -} - -func (m *MemStore) TouchSession(_ string) error { - panic("memstore: not implemented: TouchSession") -} - -func (m *MemStore) ListUserSessions(_ int64) ([]db.Session, error) { - panic("memstore: not implemented: ListUserSessions") -} - -func (m *MemStore) ForceLogoutUser(_ int64) error { - panic("memstore: not implemented: ForceLogoutUser") -} - -func (m *MemStore) GetUserSessions(_ int64) ([]db.Session, error) { - panic("memstore: not implemented: GetUserSessions") -} - -// ---------- RoleStore ---------- - -func (m *MemStore) GetRoleByID(id int64) (*db.Role, error) { - m.mu.Lock() - defer m.mu.Unlock() - r, ok := m.roles[id] - if !ok { - return nil, fmt.Errorf("role %d not found", id) - } - cp := *r - return &cp, nil -} - -func (m *MemStore) GetRoleForUser(userID int64) (*db.Role, error) { - m.mu.Lock() - defer m.mu.Unlock() - roleID, ok := m.userRoles[userID] - if !ok { - return nil, fmt.Errorf("no role for user %d", userID) - } - r, ok := m.roles[roleID] - if !ok { - return nil, fmt.Errorf("role %d not found", roleID) - } - cp := *r - return &cp, nil -} - -func (m *MemStore) GetUserWithRole(_ int64) (*db.User, *db.Role, error) { - panic("memstore: not implemented: GetUserWithRole") -} - -func (m *MemStore) ListRoles() ([]*db.Role, error) { - panic("memstore: not implemented: ListRoles") -} - -// ---------- InviteStore ---------- - -func (m *MemStore) CreateInvite(_ int64, _ int, _ *time.Time) (string, error) { - panic("memstore: not implemented: CreateInvite") -} - -func (m *MemStore) GetInvite(_ string) (*db.Invite, error) { - panic("memstore: not implemented: GetInvite") -} - -func (m *MemStore) ListInvites() ([]*db.Invite, error) { - panic("memstore: not implemented: ListInvites") -} - -func (m *MemStore) UseInviteAtomic(_ string) error { - panic("memstore: not implemented: UseInviteAtomic") -} - -func (m *MemStore) RevokeInvite(_ string) error { - panic("memstore: not implemented: RevokeInvite") -} - -// ---------- VoiceStore ---------- - -func (m *MemStore) JoinVoiceChannel(_ int64, _ int64) error { - panic("memstore: not implemented: JoinVoiceChannel") -} - -func (m *MemStore) JoinVoiceChannelIfCapacity(_ int64, _ int64, _ int) error { - panic("memstore: not implemented: JoinVoiceChannelIfCapacity") -} - -func (m *MemStore) LeaveVoiceChannel(_ int64) error { - panic("memstore: not implemented: LeaveVoiceChannel") -} - -func (m *MemStore) LeaveVoiceChannelIfMatch(_ int64, _ int64, _ string) (bool, error) { - panic("memstore: not implemented: LeaveVoiceChannelIfMatch") -} - -func (m *MemStore) GetVoiceState(_ int64) (*db.VoiceState, error) { - panic("memstore: not implemented: GetVoiceState") -} - -func (m *MemStore) GetChannelVoiceStates(_ int64) ([]db.VoiceState, error) { - panic("memstore: not implemented: GetChannelVoiceStates") -} - -func (m *MemStore) GetAllVoiceStates() ([]db.VoiceState, error) { - panic("memstore: not implemented: GetAllVoiceStates") -} - -func (m *MemStore) UpdateVoiceMute(_ int64, _ bool) error { - panic("memstore: not implemented: UpdateVoiceMute") -} - -func (m *MemStore) UpdateVoiceDeafen(_ int64, _ bool) error { - panic("memstore: not implemented: UpdateVoiceDeafen") -} - -func (m *MemStore) ClearVoiceState(_ int64) error { - panic("memstore: not implemented: ClearVoiceState") -} - -func (m *MemStore) ClearAllVoiceStates() error { - panic("memstore: not implemented: ClearAllVoiceStates") -} - -func (m *MemStore) CountActiveCameras(_ int64) (int, error) { - panic("memstore: not implemented: CountActiveCameras") -} - -func (m *MemStore) UpdateVoiceCamera(_ int64, _ bool) error { - panic("memstore: not implemented: UpdateVoiceCamera") -} - -func (m *MemStore) EnableCameraIfUnderLimit(_ int64, _ int64, _ int) (bool, error) { - panic("memstore: not implemented: EnableCameraIfUnderLimit") -} - -func (m *MemStore) UpdateVoiceScreenshare(_ int64, _ bool) error { - panic("memstore: not implemented: UpdateVoiceScreenshare") -} - -func (m *MemStore) CountChannelVoiceUsers(_ int64) (int, error) { - panic("memstore: not implemented: CountChannelVoiceUsers") -} - -// ---------- DMStore ---------- - -func (m *MemStore) GetOrCreateDMChannel(_ int64, _ int64) (*db.Channel, bool, error) { - panic("memstore: not implemented: GetOrCreateDMChannel") -} - -func (m *MemStore) GetUserDMChannels(_ int64) ([]db.DMChannelInfo, error) { - return []db.DMChannelInfo{}, nil -} - -func (m *MemStore) OpenDM(_, _ int64) error { return nil } -func (m *MemStore) CloseDM(_, _ int64) error { return nil } - -func (m *MemStore) IsDMParticipant(userID, channelID int64) (bool, error) { - m.mu.Lock() - defer m.mu.Unlock() - participants, ok := m.dmParticipants[channelID] - if !ok { - return false, nil - } - return participants[userID], nil -} - -func (m *MemStore) GetDMParticipantIDs(channelID int64) ([]int64, error) { - m.mu.Lock() - defer m.mu.Unlock() - participants, ok := m.dmParticipants[channelID] - if !ok { - return []int64{}, nil - } - ids := make([]int64, 0, len(participants)) - for id := range participants { - ids = append(ids, id) - } - return ids, nil -} - -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 -} - -// ---------- BlockStore ---------- - -func (m *MemStore) BlockUser(_, _ int64) error { return nil } -func (m *MemStore) UnblockUser(_, _ int64) error { return nil } - -func (m *MemStore) IsBlocked(blockerID, blockedID int64) (bool, error) { - m.mu.Lock() - defer m.mu.Unlock() - if m.blocks[blockerID] != nil && m.blocks[blockerID][blockedID] { - return true, nil - } - return false, nil -} - -func (m *MemStore) IsEitherBlocked(userA, userB int64) (bool, error) { - m.mu.Lock() - defer m.mu.Unlock() - if m.blocks[userA] != nil && m.blocks[userA][userB] { - return true, nil - } - if m.blocks[userB] != nil && m.blocks[userB][userA] { - return true, nil - } - return false, nil -} - -func (m *MemStore) ListBlockedUsers(_ int64) ([]int64, error) { - return []int64{}, nil -} - -// ---------- AttachmentStore ---------- - -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(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) { - panic("memstore: not implemented: GetAttachmentWithChannel") -} - -func (m *MemStore) DeleteOrphanedAttachments(_ string) ([]string, error) { - panic("memstore: not implemented: DeleteOrphanedAttachments") -} - -// ---------- AdminStore ---------- - -func (m *MemStore) UserCount() (int64, error) { - panic("memstore: not implemented: UserCount") -} - -func (m *MemStore) GetServerStats() (*db.ServerStats, error) { - panic("memstore: not implemented: GetServerStats") -} - -func (m *MemStore) ListAllUsers(_ int, _ int) ([]db.UserWithRole, error) { - panic("memstore: not implemented: ListAllUsers") -} - -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(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 { - return nil -} - -func (m *MemStore) GetAuditLog(_ int, _ int) ([]db.AuditEntry, error) { - panic("memstore: not implemented: GetAuditLog") -} - -func (m *MemStore) AdminCreateChannel(_, _, _, _ string, _ int) (int64, error) { - panic("memstore: not implemented: AdminCreateChannel") -} - -func (m *MemStore) AdminUpdateChannel(_ int64, _, _ string, _, _ int, _ bool) error { - panic("memstore: not implemented: AdminUpdateChannel") -} - -func (m *MemStore) AdminDeleteChannel(_ int64) error { - panic("memstore: not implemented: AdminDeleteChannel") -} - -func (m *MemStore) BackupTo(_ string) error { - panic("memstore: not implemented: BackupTo") -} - -func (m *MemStore) BackupToSafe(_, _ string) error { - panic("memstore: not implemented: BackupToSafe") -} - -func (m *MemStore) CountUsersWithoutTOTP() (int, error) { - panic("memstore: not implemented: CountUsersWithoutTOTP") -} - -// ---------- SettingsStore ---------- - -func (m *MemStore) GetSetting(_ string) (string, error) { - panic("memstore: not implemented: GetSetting") -} - -func (m *MemStore) SetSetting(_, _ string) error { - panic("memstore: not implemented: SetSetting") -} - -func (m *MemStore) GetAllSettings() (map[string]string, error) { - panic("memstore: not implemented: GetAllSettings") -} diff --git a/Server/store/memstore_events.go b/Server/store/memstore_events.go deleted file mode 100644 index 8a9905b0..00000000 --- a/Server/store/memstore_events.go +++ /dev/null @@ -1,289 +0,0 @@ -package store - -import ( - "context" - "fmt" - "sort" - "strings" - "sync" - "sync/atomic" - "time" - - "github.com/owncord/server/db" -) - -// memEventStore is an in-memory EventStore + PluginStore implementation -// embedded into MemStore via the field below. -type memEventStore struct { - mu sync.Mutex - // nextSeq tracks the high-water mark of caller-supplied event seqs so - // GetMaxEventSeq is O(1). PersistEvent updates it after each insert. - nextSeq atomic.Int64 - events []db.PersistedEvent - plugins map[int64]*db.PluginRow - nextPID int64 - pluginKV map[int64]map[string][]byte -} - -func newMemEventStore() *memEventStore { - return &memEventStore{ - plugins: make(map[int64]*db.PluginRow), - pluginKV: make(map[int64]map[string][]byte), - } -} - -// ---------- EventStore ---------- - -func (m *MemStore) ensureEvents() *memEventStore { - m.eventsOnce.Do(func() { - m.eventStore = newMemEventStore() - }) - return m.eventStore -} - -func (m *MemStore) PersistEvent(_ context.Context, seq int64, eventType string, channelID int64, payload []byte) error { - es := m.ensureEvents() - es.mu.Lock() - defer es.mu.Unlock() - cp := make([]byte, len(payload)) - copy(cp, payload) - es.events = append(es.events, db.PersistedEvent{ - Seq: seq, - EventType: eventType, - ChannelID: channelID, - Payload: cp, - CreatedAt: time.Now().UTC(), - }) - // Track the high water mark so GetMaxEventSeq is O(1). - if seq > es.nextSeq.Load() { - es.nextSeq.Store(seq) - } - return nil -} - -func (m *MemStore) GetEventsSince(_ context.Context, afterSeq int64, limit int) ([]db.PersistedEvent, error) { - es := m.ensureEvents() - es.mu.Lock() - defer es.mu.Unlock() - out := make([]db.PersistedEvent, 0) - for _, e := range es.events { - if e.Seq > afterSeq { - out = append(out, e) - if len(out) >= limit { - break - } - } - } - return out, nil -} - -func (m *MemStore) GetEventsSinceForChannels(_ context.Context, afterSeq int64, channelIDs []int64, limit int) ([]db.PersistedEvent, error) { - es := m.ensureEvents() - es.mu.Lock() - defer es.mu.Unlock() - allowed := make(map[int64]bool, len(channelIDs)) - for _, cid := range channelIDs { - allowed[cid] = true - } - out := make([]db.PersistedEvent, 0) - for _, e := range es.events { - if e.Seq <= afterSeq { - continue - } - if e.ChannelID == 0 || allowed[e.ChannelID] { - out = append(out, e) - if len(out) >= limit { - break - } - } - } - return out, nil -} - -func (m *MemStore) GetMaxEventSeq(_ context.Context) (int64, error) { - es := m.ensureEvents() - return es.nextSeq.Load(), nil -} - -func (m *MemStore) PruneEventsOlderThan(_ context.Context, cutoff time.Time) (int64, error) { - es := m.ensureEvents() - es.mu.Lock() - defer es.mu.Unlock() - kept := es.events[:0] - var deleted int64 - for _, e := range es.events { - if e.CreatedAt.Before(cutoff) { - deleted++ - continue - } - kept = append(kept, e) - } - es.events = kept - return deleted, nil -} - -// ---------- PluginStore ---------- - -func (m *MemStore) InstallPlugin(_ context.Context, name, version, manifestJSON string) (int64, error) { - es := m.ensureEvents() - es.mu.Lock() - defer es.mu.Unlock() - for _, p := range es.plugins { - if p.Name == name { - p.Version = version - p.ManifestJSON = manifestJSON - return p.ID, nil - } - } - es.nextPID++ - id := es.nextPID - es.plugins[id] = &db.PluginRow{ - ID: id, - Name: name, - Version: version, - Enabled: false, - ManifestJSON: manifestJSON, - InstalledAt: time.Now().UTC(), - } - return id, nil -} - -func (m *MemStore) EnablePlugin(_ context.Context, id int64) error { - es := m.ensureEvents() - es.mu.Lock() - defer es.mu.Unlock() - p, ok := es.plugins[id] - if !ok { - return fmt.Errorf("plugin %d not found", id) - } - p.Enabled = true - return nil -} - -func (m *MemStore) DisablePlugin(_ context.Context, id int64) error { - es := m.ensureEvents() - es.mu.Lock() - defer es.mu.Unlock() - p, ok := es.plugins[id] - if !ok { - return fmt.Errorf("plugin %d not found", id) - } - p.Enabled = false - return nil -} - -func (m *MemStore) UninstallPlugin(_ context.Context, id int64) error { - es := m.ensureEvents() - es.mu.Lock() - defer es.mu.Unlock() - delete(es.plugins, id) - delete(es.pluginKV, id) - return nil -} - -func (m *MemStore) GetPlugin(_ context.Context, id int64) (*db.PluginRow, error) { - es := m.ensureEvents() - es.mu.Lock() - defer es.mu.Unlock() - p, ok := es.plugins[id] - if !ok { - return nil, fmt.Errorf("plugin %d not found", id) - } - cp := *p - return &cp, nil -} - -func (m *MemStore) GetPluginByName(_ context.Context, name string) (*db.PluginRow, error) { - es := m.ensureEvents() - es.mu.Lock() - defer es.mu.Unlock() - for _, p := range es.plugins { - if p.Name == name { - cp := *p - return &cp, nil - } - } - return nil, fmt.Errorf("plugin %q not found", name) -} - -func (m *MemStore) ListPlugins(_ context.Context) ([]db.PluginRow, error) { - es := m.ensureEvents() - es.mu.Lock() - defer es.mu.Unlock() - out := make([]db.PluginRow, 0, len(es.plugins)) - for _, p := range es.plugins { - out = append(out, *p) - } - sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name }) - return out, nil -} - -func (m *MemStore) PluginKVGet(_ context.Context, pluginID int64, key string) ([]byte, error) { - es := m.ensureEvents() - es.mu.Lock() - defer es.mu.Unlock() - bucket, ok := es.pluginKV[pluginID] - if !ok { - return nil, fmt.Errorf("kv: plugin %d not found", pluginID) - } - v, ok := bucket[key] - if !ok { - return nil, fmt.Errorf("kv: key %q not found", key) - } - cp := make([]byte, len(v)) - copy(cp, v) - return cp, nil -} - -func (m *MemStore) PluginKVSet(_ context.Context, pluginID int64, key string, value []byte) error { - es := m.ensureEvents() - es.mu.Lock() - defer es.mu.Unlock() - bucket, ok := es.pluginKV[pluginID] - if !ok { - bucket = make(map[string][]byte) - es.pluginKV[pluginID] = bucket - } - cp := make([]byte, len(value)) - copy(cp, value) - bucket[key] = cp - return nil -} - -func (m *MemStore) PluginKVDelete(_ context.Context, pluginID int64, key string) error { - es := m.ensureEvents() - es.mu.Lock() - defer es.mu.Unlock() - if bucket, ok := es.pluginKV[pluginID]; ok { - delete(bucket, key) - } - return nil -} - -func (m *MemStore) PluginKVScan(_ context.Context, pluginID int64, prefix string, limit int) (map[string][]byte, error) { - es := m.ensureEvents() - es.mu.Lock() - defer es.mu.Unlock() - out := make(map[string][]byte) - bucket, ok := es.pluginKV[pluginID] - if !ok { - return out, nil - } - keys := make([]string, 0, len(bucket)) - for k := range bucket { - if strings.HasPrefix(k, prefix) { - keys = append(keys, k) - } - } - sort.Strings(keys) - if limit > 0 && len(keys) > limit { - keys = keys[:limit] - } - for _, k := range keys { - v := bucket[k] - cp := make([]byte, len(v)) - copy(cp, v) - out[k] = cp - } - return out, nil -} diff --git a/Server/store/sqlite.go b/Server/store/sqlite.go deleted file mode 100644 index 64f7aba5..00000000 --- a/Server/store/sqlite.go +++ /dev/null @@ -1,364 +0,0 @@ -package store - -import ( - "context" - "database/sql" - "time" - - "github.com/owncord/server/db" -) - -// SQLiteStore wraps *db.DB to implement the Store interface. -// It delegates all operations to the existing db package methods. -type SQLiteStore struct { - db *db.DB -} - -// NewSQLiteStore creates a SQLiteStore from an existing *db.DB. -func NewSQLiteStore(database *db.DB) *SQLiteStore { - return &SQLiteStore{db: database} -} - -// Open opens a SQLite database at path and returns a ready-to-use Store. -func Open(path string) (*SQLiteStore, error) { - database, err := db.Open(path) - if err != nil { - return nil, err - } - return &SQLiteStore{db: database}, nil -} - -// DB returns the underlying *db.DB for callers that need raw access. -func (s *SQLiteStore) DB() *db.DB { return s.db } - -// Close releases the underlying database connection. -func (s *SQLiteStore) Close() error { return s.db.Close() } - -// SQLDb returns the underlying *sql.DB. -func (s *SQLiteStore) SQLDb() *sql.DB { return s.db.SQLDb() } - -// WithTx executes fn within a transaction. For SQLite, all writes are -// serialized through a single connection (MaxOpenConns=1), so the transaction -// is started and committed on the same underlying connection that fn's -// store calls use. -// -// TODO: implement properly with a transaction-scoped Store wrapper when -// services need multi-statement transactions. -func (s *SQLiteStore) WithTx(ctx context.Context, fn func(Store) error) error { - // SQLite serializes all writes through one connection, so starting a - // transaction and calling fn(s) effectively wraps fn's DB calls in - // that transaction — provided MaxOpenConns remains 1. - tx, err := s.db.BeginTx(ctx, nil) - if err != nil { - return err - } - if txErr := fn(s); txErr != nil { - _ = tx.Rollback() - return txErr - } - return tx.Commit() -} - -// ── MessageStore ──────────────────────────────────────────────────────────── - -func (s *SQLiteStore) CreateMessage(channelID, userID int64, content string, replyTo *int64) (int64, error) { - return s.db.CreateMessage(channelID, userID, content, replyTo) -} -func (s *SQLiteStore) GetMessage(id int64) (*db.Message, error) { - return s.db.GetMessage(id) -} -func (s *SQLiteStore) GetMessages(channelID, before int64, limit int) ([]db.MessageWithUser, error) { - return s.db.GetMessages(channelID, before, limit) -} -func (s *SQLiteStore) GetMessagesForAPI(channelID, before int64, limit int, requestingUserID int64) ([]db.MessageAPIResponse, error) { - return s.db.GetMessagesForAPI(channelID, before, limit, requestingUserID) -} -func (s *SQLiteStore) EditMessage(id, userID int64, content string) error { - return s.db.EditMessage(id, userID, content) -} -func (s *SQLiteStore) DeleteMessage(id, userID int64, isMod bool) error { - return s.db.DeleteMessage(id, userID, isMod) -} -func (s *SQLiteStore) SearchMessages(query string, channelID *int64, limit int) ([]db.MessageSearchResult, error) { - return s.db.SearchMessages(query, channelID, limit) -} -func (s *SQLiteStore) SearchMessagesInChannels(query string, channelIDs []int64, limit int) ([]db.MessageSearchResult, error) { - return s.db.SearchMessagesInChannels(query, channelIDs, limit) -} -func (s *SQLiteStore) GetPinnedMessages(channelID int64, requestingUserID int64) ([]db.MessageAPIResponse, error) { - return s.db.GetPinnedMessages(channelID, requestingUserID) -} -func (s *SQLiteStore) SetMessagePinned(id int64, pinned bool) error { - return s.db.SetMessagePinned(id, pinned) -} -func (s *SQLiteStore) AddReaction(messageID, userID int64, emoji string) error { - return s.db.AddReaction(messageID, userID, emoji) -} -func (s *SQLiteStore) RemoveReaction(messageID, userID int64, emoji string) error { - return s.db.RemoveReaction(messageID, userID, emoji) -} -func (s *SQLiteStore) GetReactions(messageID int64) ([]db.ReactionCount, error) { - return s.db.GetReactions(messageID) -} -func (s *SQLiteStore) UpdateReadState(userID, channelID, lastReadMessageID int64) error { - return s.db.UpdateReadState(userID, channelID, lastReadMessageID) -} -func (s *SQLiteStore) GetChannelUnreadCounts(userID int64) (map[int64]db.ChannelUnread, error) { - return s.db.GetChannelUnreadCounts(userID) -} -func (s *SQLiteStore) GetLatestMessageID(channelID int64) (int64, error) { - return s.db.GetLatestMessageID(channelID) -} -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) -} - -// ── ChannelStore ──────────────────────────────────────────────────────────── - -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) -} -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) SetChannelVoiceMaxUsers(id int64, max int) error { - return s.db.SetChannelVoiceMaxUsers(id, max) -} -func (s *SQLiteStore) GetChannelPermissions(channelID, roleID int64) (int64, int64, error) { - return s.db.GetChannelPermissions(channelID, roleID) -} -func (s *SQLiteStore) GetAllChannelPermissionsForRole(roleID int64) (map[int64]db.ChannelOverride, error) { - return s.db.GetAllChannelPermissionsForRole(roleID) -} -func (s *SQLiteStore) GetChannelTypes(ids []int64) (map[int64]string, error) { - return s.db.GetChannelTypes(ids) -} - -// ── UserStore ─────────────────────────────────────────────────────────────── - -func (s *SQLiteStore) GetUserByID(id int64) (*db.User, error) { return s.db.GetUserByID(id) } -func (s *SQLiteStore) GetUserByUsername(username string) (*db.User, error) { - return s.db.GetUserByUsername(username) -} -func (s *SQLiteStore) CreateUser(username, passwordHash string, roleID int) (int64, error) { - return s.db.CreateUser(username, passwordHash, roleID) -} -func (s *SQLiteStore) CreateOwnerIfEmpty(username, passwordHash string, roleID int) (int64, error) { - return s.db.CreateOwnerIfEmpty(username, passwordHash, roleID) -} -func (s *SQLiteStore) CreateUserWithInvite(username, passwordHash string, roleID int, inviteCode string) (int64, error) { - return s.db.CreateUserWithInvite(username, passwordHash, roleID, inviteCode) -} -func (s *SQLiteStore) UpdateUserProfile(userID int64, username string, avatar *string) error { - return s.db.UpdateUserProfile(userID, username, avatar) -} -func (s *SQLiteStore) UpdateUserPassword(userID int64, hash string) error { - return s.db.UpdateUserPassword(userID, hash) -} -func (s *SQLiteStore) UpdateUserStatus(id int64, status string) error { - return s.db.UpdateUserStatus(id, status) -} -func (s *SQLiteStore) UpdateUserTOTPSecret(id int64, secret *string) error { - return s.db.UpdateUserTOTPSecret(id, secret) -} -func (s *SQLiteStore) UpdateUserRole(userID, roleID int64) error { - return s.db.UpdateUserRole(userID, roleID) -} -func (s *SQLiteStore) ResetAllUserStatuses() error { return s.db.ResetAllUserStatuses() } -func (s *SQLiteStore) DeleteAccount(ctx context.Context, userID int64) error { - return s.db.DeleteAccount(ctx, userID) -} -func (s *SQLiteStore) ListMembers() ([]db.MemberSummary, error) { return s.db.ListMembers() } - -// ── SessionStore ──────────────────────────────────────────────────────────── - -func (s *SQLiteStore) CreateSession(userID int64, tokenHash, device, ip string) (int64, error) { - return s.db.CreateSession(userID, tokenHash, device, ip) -} -func (s *SQLiteStore) GetSessionByTokenHash(tokenHash string) (*db.Session, error) { - return s.db.GetSessionByTokenHash(tokenHash) -} -func (s *SQLiteStore) GetSessionWithBanStatus(tokenHash string) (*db.SessionWithBanStatus, error) { - return s.db.GetSessionWithBanStatus(tokenHash) -} -func (s *SQLiteStore) DeleteSession(tokenHash string) error { return s.db.DeleteSession(tokenHash) } -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) 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) 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) GetUserWithRole(userID int64) (*db.User, *db.Role, error) { - return s.db.GetUserWithRole(userID) -} -func (s *SQLiteStore) ListRoles() ([]*db.Role, error) { return s.db.ListRoles() } - -// ── InviteStore ───────────────────────────────────────────────────────────── - -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) } - -// ── VoiceStore ────────────────────────────────────────────────────────────── - -func (s *SQLiteStore) JoinVoiceChannel(userID, channelID int64) error { - return s.db.JoinVoiceChannel(userID, channelID) -} -func (s *SQLiteStore) JoinVoiceChannelIfCapacity(userID, channelID int64, maxUsers int) error { - return s.db.JoinVoiceChannelIfCapacity(userID, channelID, maxUsers) -} -func (s *SQLiteStore) LeaveVoiceChannel(userID int64) error { return s.db.LeaveVoiceChannel(userID) } -func (s *SQLiteStore) LeaveVoiceChannelIfMatch(userID, expectedChannelID int64, expectedJoinedAt string) (bool, error) { - return s.db.LeaveVoiceChannelIfMatch(userID, expectedChannelID, expectedJoinedAt) -} -func (s *SQLiteStore) GetVoiceState(userID int64) (*db.VoiceState, error) { - return s.db.GetVoiceState(userID) -} -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) 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) EnableCameraIfUnderLimit(userID, channelID int64, maxVideo int) (bool, error) { - return s.db.EnableCameraIfUnderLimit(userID, channelID, maxVideo) -} -func (s *SQLiteStore) UpdateVoiceScreenshare(userID int64, ss bool) error { - return s.db.UpdateVoiceScreenshare(userID, ss) -} -func (s *SQLiteStore) CountChannelVoiceUsers(channelID int64) (int, error) { - return s.db.CountChannelVoiceUsers(channelID) -} - -// ── DMStore ───────────────────────────────────────────────────────────────── - -func (s *SQLiteStore) GetOrCreateDMChannel(user1ID, user2ID int64) (*db.Channel, bool, error) { - return s.db.GetOrCreateDMChannel(user1ID, user2ID) -} -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) 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) -} -func (s *SQLiteStore) GetDMParticipantIDs(channelID int64) ([]int64, error) { - return s.db.GetDMParticipantIDs(channelID) -} -func (s *SQLiteStore) GetDMRecipient(channelID, requestingUserID int64) (*db.User, error) { - return s.db.GetDMRecipient(channelID, requestingUserID) -} - -// ── BlockStore ────────────────────────────────────────────────────────────── - -func (s *SQLiteStore) BlockUser(blockerID, blockedID int64) error { - return s.db.BlockUser(blockerID, blockedID) -} -func (s *SQLiteStore) UnblockUser(blockerID, blockedID int64) error { - return s.db.UnblockUser(blockerID, blockedID) -} -func (s *SQLiteStore) IsBlocked(blockerID, blockedID int64) (bool, error) { - return s.db.IsBlocked(blockerID, blockedID) -} -func (s *SQLiteStore) IsEitherBlocked(userA, userB int64) (bool, error) { - return s.db.IsEitherBlocked(userA, userB) -} -func (s *SQLiteStore) ListBlockedUsers(blockerID int64) ([]int64, error) { - return s.db.ListBlockedUsers(blockerID) -} - -// ── AttachmentStore ───────────────────────────────────────────────────────── - -func (s *SQLiteStore) CreateAttachment(id string, uploaderID int64, filename, storedAs, mimeType string, size int64, width, height *int) error { - return s.db.CreateAttachment(id, uploaderID, filename, storedAs, mimeType, size, width, height) -} -func (s *SQLiteStore) GetAttachmentByID(id string) (*db.Attachment, error) { - return s.db.GetAttachmentByID(id) -} -func (s *SQLiteStore) GetAttachmentWithChannel(id string) (*db.AttachmentAccess, error) { - return s.db.GetAttachmentWithChannel(id) -} -func (s *SQLiteStore) DeleteOrphanedAttachments(cutoff string) ([]string, error) { - return s.db.DeleteOrphanedAttachments(cutoff) -} - -// ── AdminStore ────────────────────────────────────────────────────────────── - -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) -} -func (s *SQLiteStore) BanUser(id int64, reason string, expires *time.Time) error { - return s.db.BanUser(id, reason, expires) -} -func (s *SQLiteStore) UnbanUser(id int64) error { return s.db.UnbanUser(id) } -func (s *SQLiteStore) LogAudit(actorID int64, action, targetType string, targetID int64, detail string) error { - return s.db.LogAudit(actorID, action, targetType, targetID, detail) -} -func (s *SQLiteStore) GetAuditLog(limit, offset int) ([]db.AuditEntry, error) { - return s.db.GetAuditLog(limit, offset) -} -func (s *SQLiteStore) AdminCreateChannel(name, chanType, category, topic string, position int) (int64, error) { - return s.db.AdminCreateChannel(name, chanType, category, topic, position) -} -func (s *SQLiteStore) AdminUpdateChannel(id int64, name, topic string, slowMode, position int, archived bool) error { - return s.db.AdminUpdateChannel(id, name, topic, slowMode, position, archived) -} -func (s *SQLiteStore) AdminDeleteChannel(id int64) error { return s.db.AdminDeleteChannel(id) } -func (s *SQLiteStore) BackupTo(path string) error { return s.db.BackupTo(path) } -func (s *SQLiteStore) BackupToSafe(path, safeRoot string) error { - return s.db.BackupToSafe(path, safeRoot) -} -func (s *SQLiteStore) CountUsersWithoutTOTP() (int, error) { return s.db.CountUsersWithoutTOTP() } - -// ── SettingsStore ─────────────────────────────────────────────────────────── - -func (s *SQLiteStore) GetSetting(key string) (string, error) { return s.db.GetSetting(key) } -func (s *SQLiteStore) SetSetting(key, value string) error { return s.db.SetSetting(key, value) } -func (s *SQLiteStore) GetAllSettings() (map[string]string, error) { return s.db.GetAllSettings() } - -// Compile-time interface check. -var _ Store = (*SQLiteStore)(nil) diff --git a/Server/store/sqlite_events.go b/Server/store/sqlite_events.go deleted file mode 100644 index 39af874d..00000000 --- a/Server/store/sqlite_events.go +++ /dev/null @@ -1,302 +0,0 @@ -package store - -import ( - "context" - "database/sql" - "fmt" - "strings" - "time" - - "github.com/owncord/server/db" -) - -// ── EventStore (Phase B Step 7) ───────────────────────────────────────────── - -// PersistEvent appends a single event to the events table with the -// caller-supplied seq. The hub assigns seq before this is called so the row -// seq always matches the wrapped-payload seq, even if the persister drops -// some events under load. -func (s *SQLiteStore) PersistEvent(ctx context.Context, seq int64, eventType string, channelID int64, payload []byte) error { - _, err := s.db.SQLDb().ExecContext(ctx, - `INSERT INTO events (seq, event_type, channel_id, payload) VALUES (?, ?, ?, ?)`, - seq, eventType, channelID, payload, - ) - if err != nil { - return fmt.Errorf("PersistEvent: %w", err) - } - return nil -} - -// GetEventsSince returns events with seq > afterSeq up to limit, ordered ASC. -func (s *SQLiteStore) GetEventsSince(ctx context.Context, afterSeq int64, limit int) ([]db.PersistedEvent, error) { - rows, err := s.db.SQLDb().QueryContext(ctx, - `SELECT seq, event_type, channel_id, payload, created_at - FROM events - WHERE seq > ? - ORDER BY seq ASC - LIMIT ?`, - afterSeq, limit, - ) - if err != nil { - return nil, fmt.Errorf("GetEventsSince: %w", err) - } - defer func() { _ = rows.Close() }() - return scanEventRows(rows) -} - -// GetEventsSinceForChannels filters events to those whose channel_id is 0 -// (global broadcast) or in channelIDs. -func (s *SQLiteStore) GetEventsSinceForChannels(ctx context.Context, afterSeq int64, channelIDs []int64, limit int) ([]db.PersistedEvent, error) { - // Build IN clause manually since database/sql does not expand slices. - if len(channelIDs) == 0 { - // Only global broadcasts. - rows, err := s.db.SQLDb().QueryContext(ctx, - `SELECT seq, event_type, channel_id, payload, created_at - FROM events - WHERE seq > ? AND channel_id = 0 - ORDER BY seq ASC - LIMIT ?`, - afterSeq, limit, - ) - if err != nil { - return nil, fmt.Errorf("GetEventsSinceForChannels (global only): %w", err) - } - defer func() { _ = rows.Close() }() - return scanEventRows(rows) - } - - placeholders := make([]string, len(channelIDs)) - args := make([]any, 0, len(channelIDs)+2) - args = append(args, afterSeq) - for i, cid := range channelIDs { - placeholders[i] = "?" - args = append(args, cid) - } - args = append(args, limit) - - query := fmt.Sprintf( - `SELECT seq, event_type, channel_id, payload, created_at - FROM events - WHERE seq > ? - AND (channel_id = 0 OR channel_id IN (%s)) - ORDER BY seq ASC - LIMIT ?`, - strings.Join(placeholders, ","), - ) - rows, err := s.db.SQLDb().QueryContext(ctx, query, args...) - if err != nil { - return nil, fmt.Errorf("GetEventsSinceForChannels: %w", err) - } - defer func() { _ = rows.Close() }() - return scanEventRows(rows) -} - -// GetMaxEventSeq returns the largest seq in the events table, or 0 if empty. -func (s *SQLiteStore) GetMaxEventSeq(ctx context.Context) (int64, error) { - var maxSeq sql.NullInt64 - err := s.db.SQLDb().QueryRowContext(ctx, `SELECT MAX(seq) FROM events`).Scan(&maxSeq) - if err != nil { - return 0, fmt.Errorf("GetMaxEventSeq: %w", err) - } - if !maxSeq.Valid { - return 0, nil - } - return maxSeq.Int64, nil -} - -// PruneEventsOlderThan deletes events older than cutoff. Returns rows deleted. -func (s *SQLiteStore) PruneEventsOlderThan(ctx context.Context, cutoff time.Time) (int64, error) { - res, err := s.db.SQLDb().ExecContext(ctx, - `DELETE FROM events WHERE created_at < ?`, - cutoff.UTC().Format("2006-01-02 15:04:05"), - ) - 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 -} - -// ── PluginStore (Phase C Step 9) ──────────────────────────────────────────── - -func (s *SQLiteStore) InstallPlugin(ctx context.Context, name, version, manifestJSON string) (int64, error) { - res, err := s.db.SQLDb().ExecContext(ctx, - `INSERT INTO plugins (name, version, enabled, manifest_json) VALUES (?, ?, 0, ?) - ON CONFLICT(name) DO UPDATE SET version = excluded.version, manifest_json = excluded.manifest_json`, - name, version, manifestJSON, - ) - if err != nil { - return 0, fmt.Errorf("InstallPlugin: %w", err) - } - id, err := res.LastInsertId() - if err != nil || id == 0 { - // On conflict path LastInsertId may be 0; look up by name. - row := s.db.SQLDb().QueryRowContext(ctx, `SELECT id FROM plugins WHERE name = ?`, name) - if scanErr := row.Scan(&id); scanErr != nil { - return 0, fmt.Errorf("InstallPlugin lookup: %w", scanErr) - } - } - return id, nil -} - -func (s *SQLiteStore) EnablePlugin(ctx context.Context, id int64) error { - _, err := s.db.SQLDb().ExecContext(ctx, `UPDATE plugins SET enabled = 1 WHERE id = ?`, id) - return err -} - -func (s *SQLiteStore) DisablePlugin(ctx context.Context, id int64) error { - _, err := s.db.SQLDb().ExecContext(ctx, `UPDATE plugins SET enabled = 0 WHERE id = ?`, id) - return err -} - -func (s *SQLiteStore) UninstallPlugin(ctx context.Context, id int64) error { - _, err := s.db.SQLDb().ExecContext(ctx, `DELETE FROM plugins WHERE id = ?`, id) - return err -} - -func (s *SQLiteStore) GetPlugin(ctx context.Context, id int64) (*db.PluginRow, error) { - row := s.db.SQLDb().QueryRowContext(ctx, - `SELECT id, name, version, enabled, manifest_json, installed_at FROM plugins WHERE id = ?`, - id, - ) - return scanPluginRow(row) -} - -func (s *SQLiteStore) GetPluginByName(ctx context.Context, name string) (*db.PluginRow, error) { - row := s.db.SQLDb().QueryRowContext(ctx, - `SELECT id, name, version, enabled, manifest_json, installed_at FROM plugins WHERE name = ?`, - name, - ) - return scanPluginRow(row) -} - -func (s *SQLiteStore) ListPlugins(ctx context.Context) ([]db.PluginRow, error) { - rows, err := s.db.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 func() { _ = rows.Close() }() - var out []db.PluginRow - for rows.Next() { - var p db.PluginRow - var enabledInt int64 - var installedAt string - if err := rows.Scan(&p.ID, &p.Name, &p.Version, &enabledInt, &p.ManifestJSON, &installedAt); err != nil { - return nil, fmt.Errorf("ListPlugins scan: %w", err) - } - p.Enabled = enabledInt != 0 - p.InstalledAt = parseSQLiteTime(installedAt) - out = append(out, p) - } - return out, rows.Err() -} - -func (s *SQLiteStore) PluginKVGet(ctx context.Context, pluginID int64, key string) ([]byte, error) { - row := s.db.SQLDb().QueryRowContext(ctx, - `SELECT value FROM plugin_kv WHERE plugin_id = ? AND key = ?`, - pluginID, key, - ) - var v []byte - if err := row.Scan(&v); err != nil { - return nil, err - } - return v, nil -} - -func (s *SQLiteStore) PluginKVSet(ctx context.Context, pluginID int64, key string, value []byte) error { - _, err := s.db.SQLDb().ExecContext(ctx, - `INSERT INTO plugin_kv (plugin_id, key, value) VALUES (?, ?, ?) - ON CONFLICT(plugin_id, key) DO UPDATE SET value = excluded.value`, - pluginID, key, value, - ) - return err -} - -func (s *SQLiteStore) PluginKVDelete(ctx context.Context, pluginID int64, key string) error { - _, err := s.db.SQLDb().ExecContext(ctx, - `DELETE FROM plugin_kv WHERE plugin_id = ? AND key = ?`, - pluginID, key, - ) - return err -} - -func (s *SQLiteStore) PluginKVScan(ctx context.Context, pluginID int64, prefix string, limit int) (map[string][]byte, error) { - rows, err := s.db.SQLDb().QueryContext(ctx, - `SELECT key, value FROM plugin_kv WHERE plugin_id = ? AND key LIKE ? ORDER BY key LIMIT ?`, - pluginID, prefix+"%", limit, - ) - if err != nil { - return nil, fmt.Errorf("PluginKVScan: %w", err) - } - defer func() { _ = 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() -} - -// ── helpers ───────────────────────────────────────────────────────────────── - -type rowScanner interface { - Scan(dest ...any) error -} - -func scanPluginRow(row rowScanner) (*db.PluginRow, error) { - var p db.PluginRow - var enabledInt int64 - var installedAt string - if err := row.Scan(&p.ID, &p.Name, &p.Version, &enabledInt, &p.ManifestJSON, &installedAt); err != nil { - return nil, err - } - p.Enabled = enabledInt != 0 - p.InstalledAt = parseSQLiteTime(installedAt) - return &p, nil -} - -type rowsScanner interface { - Next() bool - Scan(dest ...any) error - Err() error -} - -func scanEventRows(rows rowsScanner) ([]db.PersistedEvent, error) { - var out []db.PersistedEvent - for rows.Next() { - var e db.PersistedEvent - var createdAt string - if err := rows.Scan(&e.Seq, &e.EventType, &e.ChannelID, &e.Payload, &createdAt); err != nil { - return nil, fmt.Errorf("scanEventRows: %w", err) - } - e.CreatedAt = parseSQLiteTime(createdAt) - out = append(out, e) - } - if err := rows.Err(); err != nil { - return nil, err - } - return out, nil -} - -func parseSQLiteTime(s string) time.Time { - // SQLite CURRENT_TIMESTAMP returns "YYYY-MM-DD HH:MM:SS" in UTC. - for _, layout := range []string{ - "2006-01-02 15:04:05", - time.RFC3339, - time.RFC3339Nano, - } { - if t, err := time.Parse(layout, s); err == nil { - return t.UTC() - } - } - return time.Time{} -} diff --git a/Server/ws/coverage_boost_test.go b/Server/ws/coverage_boost_test.go index 4d887594..12060914 100644 --- a/Server/ws/coverage_boost_test.go +++ b/Server/ws/coverage_boost_test.go @@ -15,7 +15,6 @@ import ( "github.com/owncord/server/config" "github.com/owncord/server/db" "github.com/owncord/server/service" - "github.com/owncord/server/store" "github.com/owncord/server/ws" ) @@ -79,7 +78,7 @@ func newCoverageHub(t *testing.T) (*ws.Hub, *db.DB) { t.Helper() database := openCoverageDB(t) limiter := auth.NewRateLimiter() - st := store.NewSQLiteStore(database) + st := database svc := service.New(st, limiter) hub := ws.NewHub(database, limiter, svc) diff --git a/Server/ws/event_persister.go b/Server/ws/event_persister.go index 3fecc361..90ea2dd3 100644 --- a/Server/ws/event_persister.go +++ b/Server/ws/event_persister.go @@ -16,7 +16,6 @@ import ( "sync/atomic" "time" - "github.com/owncord/server/store" "github.com/owncord/server/telemetry" ) @@ -32,7 +31,7 @@ type pendingEvent struct { // EventPersister batches broadcast events and writes them to an EventStore. type EventPersister struct { - store store.EventStore + store EventStore queue chan pendingEvent batchSize int flushEvery time.Duration @@ -58,9 +57,9 @@ type EventPersister struct { // queueSize sets the channel buffer; once full, Enqueue increments the // dropped counter without blocking. batchSize and flushEvery control the // flush triggers. -func NewEventPersister(s store.EventStore, queueSize, batchSize int, flushEvery time.Duration) *EventPersister { +func NewEventPersister(s EventStore, queueSize, batchSize int, flushEvery time.Duration) *EventPersister { if s == nil { - panic("ws: NewEventPersister requires a non-nil store.EventStore") + panic("ws: NewEventPersister requires a non-nil EventStore") } if queueSize <= 0 { queueSize = 1024 diff --git a/Server/ws/event_persister_test.go b/Server/ws/event_persister_test.go index 576a02a9..c105fb52 100644 --- a/Server/ws/event_persister_test.go +++ b/Server/ws/event_persister_test.go @@ -11,11 +11,27 @@ import ( "testing" "time" - "github.com/owncord/server/store" + "github.com/owncord/server/db" ) +// openPersisterTestDB opens an in-memory database with migrations applied for +// EventPersister tests. *db.DB satisfies the EventStore interface the persister +// depends on (D3 removed the store abstraction). +func openPersisterTestDB(t *testing.T) *db.DB { + t.Helper() + database, err := db.Open(":memory:") + if err != nil { + t.Fatalf("db.Open: %v", err) + } + t.Cleanup(func() { _ = database.Close() }) + if err := db.Migrate(database); err != nil { + t.Fatalf("db.Migrate: %v", err) + } + return database +} + func TestEventPersisterFlushesBatch(t *testing.T) { - mem := store.NewMemStore() + mem := openPersisterTestDB(t) p := NewEventPersister(mem, 1024, 4, 50*time.Millisecond) ctx := context.Background() p.Start(ctx) @@ -56,7 +72,7 @@ func TestEventPersisterFlushesBatch(t *testing.T) { } func TestEventPersisterDropsOnFullQueue(t *testing.T) { - mem := store.NewMemStore() + mem := openPersisterTestDB(t) // Tiny queue, very long flush interval — guarantees drops because the // flusher won't drain fast enough. p := NewEventPersister(mem, 2, 1024, time.Hour) @@ -75,7 +91,7 @@ func TestEventPersisterDropsOnFullQueue(t *testing.T) { } func TestEventPersisterStopDrains(t *testing.T) { - mem := store.NewMemStore() + mem := openPersisterTestDB(t) p := NewEventPersister(mem, 256, 100, time.Hour) p.Start(context.Background()) diff --git a/Server/ws/event_pruner.go b/Server/ws/event_pruner.go index f9b62574..4ae7cd58 100644 --- a/Server/ws/event_pruner.go +++ b/Server/ws/event_pruner.go @@ -10,8 +10,6 @@ import ( "context" "log/slog" "time" - - "github.com/owncord/server/store" ) // maxStartupDelay caps how long StartEventPruner waits before its first @@ -23,7 +21,7 @@ const maxStartupDelay = time.Minute // StartEventPruner launches a goroutine that wakes every interval and deletes // events older than retention. The goroutine exits when ctx is cancelled. -func StartEventPruner(ctx context.Context, s store.EventStore, retention, interval time.Duration) { +func StartEventPruner(ctx context.Context, s EventStore, retention, interval time.Duration) { if s == nil { return } @@ -63,7 +61,7 @@ func StartEventPruner(ctx context.Context, s store.EventStore, retention, interv }() } -func runPrune(ctx context.Context, s store.EventStore, retention time.Duration) { +func runPrune(ctx context.Context, s EventStore, retention time.Duration) { cutoff := time.Now().Add(-retention) deleted, err := s.PruneEventsOlderThan(ctx, cutoff) if err != nil { diff --git a/Server/ws/eventstore.go b/Server/ws/eventstore.go new file mode 100644 index 00000000..7085b95c --- /dev/null +++ b/Server/ws/eventstore.go @@ -0,0 +1,20 @@ +package ws + +import ( + "context" + "time" + + "github.com/owncord/server/db" +) + +// EventStore persists broadcast events for cold-tier replay during reconnection +// when the in-memory ring buffer no longer covers the client's last_seq. +// *db.DB satisfies it (the methods moved into the db package when the store +// abstraction was removed in D3). +type EventStore interface { + PersistEvent(ctx context.Context, seq int64, eventType string, channelID int64, payload []byte) error + GetEventsSince(ctx context.Context, afterSeq int64, limit int) ([]db.PersistedEvent, error) + GetEventsSinceForChannels(ctx context.Context, afterSeq int64, channelIDs []int64, limit int) ([]db.PersistedEvent, error) + PruneEventsOlderThan(ctx context.Context, cutoff time.Time) (int64, error) + GetMaxEventSeq(ctx context.Context) (int64, error) +} diff --git a/Server/ws/handler_v2_channel_focus_test.go b/Server/ws/handler_v2_channel_focus_test.go index 8f08560d..d44f1b2c 100644 --- a/Server/ws/handler_v2_channel_focus_test.go +++ b/Server/ws/handler_v2_channel_focus_test.go @@ -8,7 +8,6 @@ import ( "github.com/owncord/server/db" "github.com/owncord/server/permissions" "github.com/owncord/server/service" - "github.com/owncord/server/store" ) // newFocusTestDeps creates an in-memory DB with a user (role=Owner, id=1) and @@ -33,7 +32,7 @@ func newFocusTestDeps(t *testing.T) (PresenceDeps, int64, int64) { t.Fatalf("CreateChannel: %v", err) } - st := store.NewSQLiteStore(database) + st := database svc := service.New(st, auth.NewRateLimiter()) deps := PresenceDeps{Limiter: nil, ChannelSvc: svc.Channels} return deps, userID, chID @@ -111,7 +110,7 @@ func TestChannelFocusV2_NoPermission_ReturnsForbidden(t *testing.T) { t.Fatalf("INSERT channel_overrides: %v", err) } - st := store.NewSQLiteStore(database) + st := database svc := service.New(st, auth.NewRateLimiter()) deps := PresenceDeps{Limiter: nil, ChannelSvc: svc.Channels} diff --git a/Server/ws/handlers_command_test.go b/Server/ws/handlers_command_test.go index abde5e14..29734e44 100644 --- a/Server/ws/handlers_command_test.go +++ b/Server/ws/handlers_command_test.go @@ -9,7 +9,6 @@ import ( "testing" "github.com/owncord/server/plugin" - "github.com/owncord/server/store" "github.com/owncord/server/ws" ) @@ -55,14 +54,12 @@ func TestChatCommand_NoRegistry_ReturnsError(t *testing.T) { // plugin owning the command. func TestChatCommand_UnknownCommand_ReturnsError(t *testing.T) { hub, database := newTestHub(t) - _ = database send := make(chan []byte, 4) c := ws.NewTestClient(hub, 1, send) hub.Register(c) defer hub.Unregister(c) - mem := store.NewMemStore() - reg, err := plugin.NewRegistry(plugin.Config{Store: mem}) + reg, err := plugin.NewRegistry(plugin.Config{Store: database}) if err != nil { t.Fatalf("NewRegistry: %v", err) } diff --git a/Server/ws/handlers_test.go b/Server/ws/handlers_test.go index 2726cb0e..688a81c8 100644 --- a/Server/ws/handlers_test.go +++ b/Server/ws/handlers_test.go @@ -11,7 +11,6 @@ import ( "github.com/owncord/server/db" "github.com/owncord/server/permissions" "github.com/owncord/server/service" - "github.com/owncord/server/store" "github.com/owncord/server/ws" ) @@ -74,7 +73,7 @@ func newHandlerHub(t *testing.T) (*ws.Hub, *db.DB) { t.Helper() database := openHandlerDB(t) limiter := auth.NewRateLimiter() - st := store.NewSQLiteStore(database) + st := database svc := service.New(st, limiter) hub := ws.NewHub(database, limiter, svc) go hub.Run() diff --git a/Server/ws/hub.go b/Server/ws/hub.go index ba12e9ea..919f2f56 100644 --- a/Server/ws/hub.go +++ b/Server/ws/hub.go @@ -16,7 +16,6 @@ import ( "github.com/owncord/server/permissions" "github.com/owncord/server/plugin" "github.com/owncord/server/service" - "github.com/owncord/server/store" "github.com/owncord/server/syncutil" ) @@ -65,7 +64,7 @@ type Hub struct { // because main.go wires these after NewRouter has already started the // Run loop, which reads them on the broadcast/replay paths. eventPersister atomic.Pointer[EventPersister] - eventStore atomic.Pointer[store.EventStore] // read path for cold-tier replay + eventStore atomic.Pointer[EventStore] // read path for cold-tier replay // Phase C Step 9 — plugin wiring. pluginRegistry *plugin.Registry // slash-command dispatch; nil = no plugins; wire before Run @@ -773,7 +772,7 @@ func (h *Hub) SetEventPersister(p *EventPersister) { // reconnect replay path. Typically the same store backing SetEventPersister. // Pass nil to disable. Safe to call at any time, including after Run has // started. -func (h *Hub) SetEventStore(s store.EventStore) { +func (h *Hub) SetEventStore(s EventStore) { if s == nil { h.eventStore.Store(nil) return diff --git a/Server/ws/reconnect_db_test.go b/Server/ws/reconnect_db_test.go index e6661f99..5a3e48d0 100644 --- a/Server/ws/reconnect_db_test.go +++ b/Server/ws/reconnect_db_test.go @@ -19,17 +19,33 @@ import ( "nhooyr.io/websocket" "github.com/owncord/server/auth" - "github.com/owncord/server/store" + "github.com/owncord/server/db" "github.com/owncord/server/ws" ) +// openEventStoreDB opens an in-memory database with the full migration set so +// the events table exists. *db.DB satisfies the hub's EventStore interface +// (D3 removed the store abstraction and its MemStore fake). +func openEventStoreDB(t *testing.T) *db.DB { + t.Helper() + database, err := db.Open(":memory:") + if err != nil { + t.Fatalf("db.Open: %v", err) + } + t.Cleanup(func() { _ = database.Close() }) + if err := db.Migrate(database); err != nil { + t.Fatalf("db.Migrate: %v", err) + } + return database +} + // TestReconnect_BufferMiss_FallsBackToDBTier verifies that when a client // reconnects with a last_seq that is older than the ring buffer's oldest entry, // the hub falls back to the EventStore (DB tier) and sends the missed events. // // Setup: // - Ring buffer size = 1000; push seqs 501..1500 → oldestSeq = 501. -// - MemStore contains 100 global events at seqs 501..600. +// - The DB event store contains 100 global events at seqs 501..600. // - Client reconnects with last_seq = 500. // - Buffer: 500 <= 501 → returns nil. // - DB: returns seqs > 500 with channelID = 0 (global, no permission filter). @@ -43,8 +59,8 @@ func TestReconnect_BufferMiss_FallsBackToDBTier(t *testing.T) { // Create a user. role_id=1 intentionally does not exist in the test DB so // computeAllowedChannels returns an empty channel set — but events with - // channelID=0 (global) bypass the per-channel filter in MemStore and in - // EventsSinceFiltered, so they are always returned. + // channelID=0 (global) bypass the per-channel filter in the DB event store + // and in EventsSinceFiltered, so they are always returned. userID, err := database.CreateUser("reconnect-db-user", "hash", 1) if err != nil { t.Fatalf("CreateUser: %v", err) @@ -58,20 +74,21 @@ func TestReconnect_BufferMiss_FallsBackToDBTier(t *testing.T) { } // Pre-populate the event store with 100 global events (seqs 501..600). - // channelID=0 means "global broadcast" — MemStore.GetEventsSinceForChannels - // returns them regardless of the allowed-channel filter. - memStore := store.NewMemStore() + // channelID=0 means "global broadcast" — the DB event store's + // GetEventsSinceForChannels returns them regardless of the allowed-channel + // filter. + eventStore := openEventStoreDB(t) bgCtx := context.Background() for seq := int64(501); seq <= 600; seq++ { payload := []byte(fmt.Sprintf(`{"seq":%d,"type":"broadcast"}`, seq)) - if err := memStore.PersistEvent(bgCtx, seq, "broadcast", 0, payload); err != nil { + if err := eventStore.PersistEvent(bgCtx, seq, "broadcast", 0, payload); err != nil { t.Fatalf("PersistEvent seq=%d: %v", seq, err) } } - // Build hub, attach the MemStore as the cold-tier read path. + // Build hub, attach the DB event store as the cold-tier read path. hub := ws.NewHub(database, limiter, nil) - hub.SetEventStore(memStore) + hub.SetEventStore(eventStore) go hub.Run() defer hub.Stop() diff --git a/Server/ws/ws_integration_test.go b/Server/ws/ws_integration_test.go index 18cbd76b..ea850e87 100644 --- a/Server/ws/ws_integration_test.go +++ b/Server/ws/ws_integration_test.go @@ -17,7 +17,6 @@ import ( "github.com/owncord/server/auth" "github.com/owncord/server/service" - "github.com/owncord/server/store" "github.com/owncord/server/ws" ) @@ -998,7 +997,7 @@ func TestServeWS_writePump_MessageDelivered(t *testing.T) { func TestIntegration_MessageRoundTrip(t *testing.T) { database := openServeTestDB(t) limiter := auth.NewRateLimiter() - st := store.NewSQLiteStore(database) + st := database svc := service.New(st, limiter) hub := ws.NewHub(database, limiter, svc) go hub.Run() diff --git a/docs/architecture/data-model.md b/docs/architecture/data-model.md index ba2072eb..423a449b 100644 --- a/docs/architecture/data-model.md +++ b/docs/architecture/data-model.md @@ -105,21 +105,24 @@ erDiagram | Plugins | `plugins`, `plugin_kv` | 015. `plugin_kv` is per-plugin namespaced KV via composite PK `(plugin_id, key)`. | | Ops | `settings`, `audit_log`, `sounds` | `settings` is a generic KV read by admin and the WS hub (via `db.GetSetting`). Migration 003 rebuilds `audit_log` through a transient `audit_log_v6` rename — only `audit_log` exists at runtime. `sounds` is **dead schema** — the soundboard feature was removed but the table remains. | -### How the schema is accessed (three coexisting styles) +### How the schema is accessed -1. **Raw SQL in `Server/db`** — hand-written queries (`*_queries.go`, ~178 call - sites). This is what actually runs, used directly by `Server/api` handlers, - `Server/admin`, and the `ws.Hub`. -2. **`store.Store` interface** (`Server/store`) — a 14-domain composed interface - with `SQLiteStore` (delegates to `db.DB`) and `MemStore` (test double). - Consumed by the `Server/service` layer only. -3. **sqlc-generated `Server/db/dbgen`** (~3.5k LOC, from `Server/db/queries/sqlite/` - per `sqlc.yaml`) — **imported by nothing**; dead code kept verified by the - `sqlc-verify` CI job. +The `Server/db` package is the single data layer. Its methods live in +`*_queries.go` and mostly delegate to the sqlc-generated `Server/db/dbgen` code +(D2), so sqlc is the type-checked query layer rather than dead generated code; a +documented remainder of raw queries stays hand-written where sqlc can't express +them (variable-length `IN` lists, FTS, multi-statement transactions, +PRAGMA/VACUUM) — see [plans/sqlc-adoption.md](../plans/sqlc-adoption.md). -The coexistence of all three is the largest structural finding of the audit — -see [audit-2026-07-19.md §3](../audit-2026-07-19.md). +Consumers depend on narrow interfaces that `*db.DB` satisfies rather than on the +concrete type: `service.Store` (the service layer), `ws.EventStore` (cold-tier +replay), and `plugin.PluginStore` (plugin registry). D3 removed the former +`store` package (a pass-through `SQLiteStore` plus a `MemStore` test double); +tests now run against a real in-memory SQLite `db`. The residual style issue is +that many `Server/api` handlers and the `Server/admin` package still take a raw +`*db.DB` directly instead of going through the service layer — the remaining +consolidation work (audit A-2026-07-06). **Source of truth:** `Server/migrations/*.sql` (schema), `Server/db/migrate.go` -(runner), `Server/db/*_queries.go` (live queries), `Server/store/store.go` -(interface), `sqlc.yaml` + `Server/db/dbgen/` (dormant generated layer). +(runner), `Server/db/*_queries.go` (live queries), `Server/service/datastore.go` +(service interface), `sqlc.yaml` + `Server/db/dbgen/` (generated query layer). diff --git a/docs/architecture/server.md b/docs/architecture/server.md index d36d405f..a0abb77e 100644 --- a/docs/architecture/server.md +++ b/docs/architecture/server.md @@ -31,10 +31,9 @@ flowchart TB end subgraph data ["Data"] - STORE["store
Store interface,
SQLiteStore, MemStore"] - DB[("db
raw SQL queries,
migration runner, models")] - DBGEN["db/dbgen
sqlc-generated"] - MIG["migrations
001–015 embedded SQL"] + DB[("db
query methods (sqlc-backed),
migration runner, models")] + DBGEN["db/dbgen
sqlc-generated queries"] + MIG["migrations
001–016 embedded SQL"] end subgraph support ["Support"] @@ -48,7 +47,6 @@ flowchart TB MAIN --> CFG MAIN --> API MAIN --> DB - MAIN --> STORE API --> ADMIN API --> WS API --> SVC @@ -56,32 +54,28 @@ flowchart TB API --> STORAGE API --> UPD API --> PLUGIN - SVC --> STORE + SVC --> DB SVC --> PERM - STORE --> DB + DB --> DBGEN DB --> MIG WS --> SVC WS --> PLUGIN - %% Layering violations (dashed red): raw *db.DB used above the store seam + %% Residual layering seam (dashed): raw *db.DB used above the service layer API -.->|"handlers take *db.DB directly"| DB ADMIN -.->|"raw *db.DB"| DB - WS -.->|"inline SQL for settings"| DB - DBGEN -.-|"generated but imported by nothing"| DB - - classDef dead fill:none,stroke-dasharray: 5 5,opacity:0.6 - class DBGEN dead ``` -**What this shows.** The intended layering is -`api → service → store → db`, and the `service` layer does follow it. But three -dashed edges mark where the seam is bypassed: nearly every REST handler receives -both `svc` *and* a raw `*db.DB`; the `admin` package operates on `*db.DB` -almost exclusively; and the `ws.Hub` runs inline SQL against the `settings` -table. `db/dbgen` (sqlc output) is generated and CI-verified but referenced by -no code. In effect the server has three coexisting data-access styles — see -[data-model.md](data-model.md) and the audit for the consolidation -recommendation. +**What this shows.** The layering is `api → service → db` (D3 removed the former +`store` seam). The `service` layer depends on a narrow `service.Store` interface +that `*db.DB` satisfies; `ws` and `plugin` depend on their own small interfaces +(`ws.EventStore`, `plugin.PluginStore`) the same way. The `db` package's query +methods delegate to the sqlc-generated `db/dbgen` code (D2), so sqlc is now the +type-checked query layer rather than dead generated code. Two dashed edges mark +the residual seam: many REST handlers still receive a raw `*db.DB` alongside +`svc`, and the `admin` package operates on `*db.DB` almost exclusively — +consolidating those behind the service layer is the remaining work (audit +A-2026-07-06). See [data-model.md](data-model.md). `api.NewRouter` (`Server/api/router.go`) is the composition root: it constructs the rate limiter, TOTP key, storage, `service.New`, the `ws.Hub`, the LiveKit @@ -91,7 +85,7 @@ process-level wiring (config, TLS, DB, event persistence, HTTP server, shutdown). **Source of truth:** `Server/main.go`, `Server/api/router.go`, package import -graph (`go list -deps`), `Server/store/store.go`, `sqlc.yaml`. +graph (`go list -deps`), `Server/service/datastore.go`, `sqlc.yaml`. ## D3 — REST request lifecycle @@ -103,7 +97,7 @@ sequenceDiagram participant RT as Route mount
(Mount*Routes) participant H as Handler participant S as service.* - participant ST as store.Store + participant ST as service.Store
(*db.DB) participant DB as SQLite C->>MW: HTTPS request @@ -112,12 +106,12 @@ sequenceDiagram Note over RT: AuthMiddleware(database)
+ per-route rate limits
+ RequirePermission(...) where mounted RT->>H: authenticated request H->>S: domain call (svc.Messages, svc.Permissions, …) - S->>ST: Store interface method - ST->>DB: SQL (via db.DB) + S->>ST: narrow Store interface method + ST->>DB: SQL (db.DB query method → sqlc dbgen) DB-->>C: JSON response (errorResponse envelope on failure) rect rgba(200,120,120,0.15) - Note over H,DB: Deviation — auth routes: MountAuthRoutes(r, database, …)
bypasses service/store and queries *db.DB directly.
Admin REST (Server/admin) does the same behind
AdminIPRestrict + RequireAdminAuth. + Note over H,DB: Deviation — auth routes: MountAuthRoutes(r, database, …)
bypasses the service layer and queries *db.DB directly.
Admin REST (Server/admin) does the same behind
AdminIPRestrict + RequireAdminAuth. end ``` diff --git a/docs/audit-2026-07-19.md b/docs/audit-2026-07-19.md index c5a1ab49..8ee85dbc 100644 --- a/docs/audit-2026-07-19.md +++ b/docs/audit-2026-07-19.md @@ -19,7 +19,7 @@ accepted-risk note before the beta gate. MEDIUMs are folded into the backlog | A-2026-07-03 | HIGH | Reference specs (api.md / protocol.md / schema.md) frozen at 2026-04-02; systemic drift incl. whole undocumented subsystems (voice E2EE, plugins) | CLOSED 2026-07-19 — full refresh of api.md/protocol.md/schema.md landed (all §2 fix-spec items); keep-current-per-PR rule now applies | | A-2026-07-04 | HIGH | Client unit test suite "KNOWN RED" and non-blocking in CI; E2E never gated | OPEN — supersedes prior #11's scope | | A-2026-07-05 | MEDIUM | Dead sqlc layer: `Server/db/dbgen/` (~3.5k LOC) generated + CI-verified but imported by nothing | RESOLVED 2026-07-19 — `dbgen` wired into `db.DB`; 97 methods across all domains delegate to it (no longer dead). Remaining raw queries (variable IN / FTS / tx) tracked in [plans/sqlc-adoption.md](plans/sqlc-adoption.md) | -| A-2026-07-06 | MEDIUM | Three coexisting DB-access styles (raw `*db.DB` in api/admin/ws, `store.Store` under service, dead dbgen) | DECIDED 2026-07-19 — single data layer: sqlc-backed db pkg, remove store/ (D2+D3) (see [plans/audit-2026-07-19-decisions.md](plans/audit-2026-07-19-decisions.md)) | +| A-2026-07-06 | MEDIUM | Three coexisting DB-access styles (raw `*db.DB` in api/admin/ws, `store.Store` under service, dead dbgen) | RESOLVED 2026-07-19 — collapsed to a single sqlc-backed `db` package: dbgen wired in (D2) and the `store` seam deleted (D3). The service layer depends on a narrow `service.Store` interface `*db.DB` satisfies; ws/plugin similarly. Broadening service-only access above the remaining direct-`db` handlers is the residual layering work (A-2026-07-06 backlog item 12) | | A-2026-07-07 | MEDIUM | Channel-visibility logic duplicated across ~4 sites with "must mirror" comments | OPEN | | A-2026-07-08 | MEDIUM | Protocol constants on both sides claim generation from `docs/protocol-schema.json`, which does not exist in the repo | CLOSED 2026-07-19 — codegen implemented: `docs/protocol-schema.json` + `Server/scripts/genprotocol` + `make protocol-verify` CI gate | | A-2026-07-09 | MEDIUM | Dual V1+V2 WS dispatch (strangler-fig) still live; two parsers/registries to keep in sync | OPEN | @@ -50,7 +50,7 @@ audit's table; details stay in [audit-2026-04-07.md](audit-2026-04-07.md). | Prior # | Sev | Finding (one-line) | Re-verification (2026-07-19) | |---------|-----|--------------------|------------------------------| | 1–5 | CRITICAL | Plugin governance (timeout, storage isolation, ACL, event rate limit, HTTP exfiltration) | Unchanged since prior closure table; plugins still default-disabled (`plugins.enabled: false`), which is the standing mitigation | -| 6 | HIGH | `Server/store/` untested | **Still zero `_test.go` files** in `Server/store/`. The "SUPERSEDED — remove in P4" plan has not executed; the package remains the designated abstraction seam with no direct tests | +| 6 | HIGH | `Server/store/` untested | **RESOLVED 2026-07-19 (D3)** — the `store/` package is deleted rather than tested. `SQLiteStore` was a pure pass-through to `*db.DB`; its event/plugin methods moved into `db` (`event_queries.go`, `plugin_queries.go`). Consumers now depend on narrow interfaces `*db.DB` satisfies (`service.Store`, `ws.EventStore`, `plugin.PluginStore`), and the former `MemStore`-based unit tests run against a real in-memory SQLite `db` — so the code paths that were untested through the seam are now exercised directly | | 7 | HIGH | Client unit coverage | Suite is large (157 test files) but currently KNOWN RED and non-blocking — see A-2026-07-04 | | 9 | MEDIUM | auth_handler bypasses service layer | **Confirmed open** — `Server/api/router.go:101` passes `database *db.DB` to `MountAuthRoutes` while sibling mounts receive `svc` | | 10 | MEDIUM | Audit-trail write failures silently ignored | **Fixed 2026-07-19** at the two flagged backup-handler sites (errors now logged). Wider scope discovered: the `_ = LogAudit` pattern exists at 23 call sites across admin/api/ws/service — appears to be a deliberate best-effort convention; policy decision tracked in the decisions doc (D8 note) | @@ -110,10 +110,10 @@ a decision on A first. ## 3. Server architecture findings -Verdict: the intended layering (`api → service → store → db`) is sound and the -`service` layer respects it; test discipline is excellent (test LOC ≈ 1.6× -source; race + deadlock + mutation tooling). The findings are about the seams -that grew around that design. +Verdict: the intended layering (`api → service → db`, since D3 collapsed the +former `store` seam) is sound and the `service` layer respects it; test +discipline is excellent (test LOC ≈ 1.6× source; race + deadlock + mutation +tooling). The findings are about the seams that grew around that design. | ID | Sev | Area | Evidence | Finding | Recommendation | Effort | |----|-----|------|----------|---------|----------------|--------| @@ -164,7 +164,7 @@ Ranked by severity × effort; quick wins float within tier. S/M/L ≈ hours / da | 2 | Delete (or finally adopt) the dead `db/dbgen` sqlc layer; adjust the `sqlc-verify` CI job to match | A-2026-07-05 | MEDIUM | S | | 3 | Extract the single channel-visibility function replacing the 4 "must mirror" copies; add REST/WS agreement test | A-2026-07-07 | MEDIUM | M | | 4 | One-PR refresh of api.md / protocol.md / schema.md against §2, incl. E2EE + plugins + migrations 009–015; then enforce spec-updates-with-code via PR checklist | A-2026-07-03 | HIGH | M | -| 5 | Execute the store-layer decision from prior #6: remove `Server/store/` (P4 plan) or test it directly | prior #6 | HIGH | M | +| ~~5~~ | ~~Execute the store-layer decision from prior #6: remove `Server/store/` (P4 plan) or test it directly~~ **DONE 2026-07-19 (D3)** — `store/` removed; consumers on narrow `*db.DB` interfaces; tests on real in-memory SQLite | prior #6 | HIGH | M | | 6 | Client HTTP TOFU pinning (Rust proxy mirroring `ws_proxy.rs`) | A-2026-07-02 | HIGH | M | | 7 | Stop discarding `LogAudit` errors in `admin/handlers_backup.go`; fix the contradictory upload `Cache-Control` | prior #10, W3-4 | MEDIUM | S | | 8 | Remove the SolidJS beachhead + adapters; retire or rewrite `docs/client-architecture.md` | A-2026-07-12 | MEDIUM | S | diff --git a/docs/plans/audit-2026-07-19-decisions.md b/docs/plans/audit-2026-07-19-decisions.md index 53818afe..703d5832 100644 --- a/docs/plans/audit-2026-07-19-decisions.md +++ b/docs/plans/audit-2026-07-19-decisions.md @@ -16,7 +16,7 @@ here (and the audit's closure table) as items land. |---|----------------|----------|----------|--------| | D1 | `announcement` channel type (documented + offered by admin API, rejected by DB triggers) | A-2026-07-01 | **Implement end-to-end**: migration to allow the type, posting-permission semantics, admin support, client rendering, spec updates. Not a doc-strip — this becomes a real feature. | **Implemented 2026-07-19**: migration 016 allows `announcement`; posting requires MANAGE_MESSAGES (readable like text); admin already offered it; client renders a megaphone icon + unread counts; specs updated. | | D2 | Data-layer direction (raw SQL vs dead sqlc `db/dbgen` vs `store.Store`) | A-2026-07-05 / A-2026-07-06 | **Adopt sqlc for real**: wire `db.DB` method bodies to the generated `dbgen` queries so sqlc becomes the actual, type-checked query layer. The `sqlc-verify` CI job stays and starts earning its keep. | **Largely done 2026-07-19**: `dbgen.Queries` wired into `db.DB`; 97 methods across all domains delegate to sqlc (no longer dead code). ~43 raw calls remain by design (variable IN, FTS, multi-statement tx, PRAGMA/VACUUM) — tracked in [sqlc-adoption.md](sqlc-adoption.md). | -| D3 | Fate of `Server/store/` (untested abstraction seam) | prior audit #6 | **Remove `store/`**: execute the prior audit's P4 "single data layer" direction. Services call the (sqlc-backed) `db` package directly; tests use in-memory SQLite instead of `MemStore`. | Planned (sequence with/after D2) | +| D3 | Fate of `Server/store/` (untested abstraction seam) | prior audit #6 | **Remove `store/`** via interface segregation: delete `SQLiteStore` + `MemStore` + the `store` package; port the event/plugin methods into `db`; each consumer depends on a small interface `*db.DB` satisfies. | **Implemented 2026-07-19**: the `store/` package is deleted. Event and plugin methods moved into `db` (`event_queries.go`, `plugin_queries.go`); consumers depend on narrow interfaces `*db.DB` satisfies (`service.Store`, `ws.EventStore`, `plugin.PluginStore`). All service/ws/plugin/api tests now run against a real in-memory SQLite `db` with seed helpers; fault-injection tests embed a real `*db.DB` and override the one method under test. Full server suite + `sqlc-verify` green. | | D4 | Protocol constants sync (`message_types.go` / `protocolTypes.ts` claim a nonexistent `docs/protocol-schema.json`) | A-2026-07-08 | **Create real codegen**: commit an actual `protocol-schema.json` plus a generator that emits the Go and TS constant files (and, ideally, protocol.md's message table), making the "single source of truth" comment true. | **Implemented 2026-07-19**: `docs/protocol-schema.json` + `Server/scripts/genprotocol` + `make protocol-generate`/`protocol-verify` + CI gate. protocol.md table generation deferred to D7. | | D5 | Client HTTP TLS gap (`allowSelfSigned: true`, no TOFU pinning on the REST path) | A-2026-07-02 | **Next security work**: build the TOFU HTTP proxy in Rust (mirroring `ws_proxy.rs`) as the next security task — highest-priority security item. | **Implemented 2026-07-19** — `src-tauri/src/http_proxy.rs` (per-host loopback TCP→TLS tunnels, shared TOFU cert store + `cert-tofu` events) + `src/lib/httpProxy.ts`; REST/health/attachments routed through it; `acceptInvalidCerts`/`allowSelfSigned` and the `dangerous-settings` feature removed. See [http-tofu-proxy.md](http-tofu-proxy.md). | | D6 | Abandoned SolidJS beachhead + stale `docs/client-architecture.md` | A-2026-07-12 | **Delete it all**: remove `src/components/solid/`, `solidMount`/`solidAdapter`, `vite-plugin-solid`, and Solid test deps; retire `client-architecture.md` in favor of [docs/architecture/client.md](../architecture/client.md). | **Implemented 2026-07-19** — solid/ dir, solidMount/solidAdapter, setup-solid tests, vite-plugin-solid, jsx tsconfig settings, and solid-js/@solidjs deps all removed; client-architecture.md is now a pointer. |