diff --git a/CLAUDE.md b/CLAUDE.md index 22f6d18..589bd25 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -110,7 +110,7 @@ keys use TID format (timestamp-based identifiers). ### Store Interface -`internal/database/store.go` defines the `Store` interface with CRUD methods for +`internal/arabica/store/store.go` defines the `Store` interface with CRUD methods for all entity types. `AtprotoStore` is the production implementation backed by the user's PDS with witness cache and session cache layers. @@ -161,7 +161,7 @@ entities with references): 4. **Model + request types + validation** in `internal/models/models.go` 5. **Record conversion** (`XToRecord`/`RecordToX`) in `internal/atproto/records.go` -6. **Store interface methods** in `internal/database/store.go` +6. **Store interface methods** in `internal/arabica/store/store.go` 7. **AtprotoStore implementation** in `internal/atproto/store.go` (CRUD + witness + cache) 8. **Cache fields + Set/Invalidate methods** in `internal/atproto/cache.go` diff --git a/cmd/arabica/app.go b/cmd/arabica/app.go deleted file mode 100644 index 1185859..0000000 --- a/cmd/arabica/app.go +++ /dev/null @@ -1,24 +0,0 @@ -package main - -import ( - "tangled.org/arabica.social/arabica/internal/arabica/entities" - "tangled.org/arabica.social/arabica/internal/atplatform/domain" - "tangled.org/arabica.social/arabica/internal/entities" - - // Blank import runs init() in arabica's web/components package, - // wiring per-app templ render hooks onto descriptors. - _ "tangled.org/arabica.social/arabica/internal/arabica/web/components" -) - -// newArabicaApp builds the App value for the coffee-tracking app. -func newArabicaApp() *domain.App { - return &domain.App{ - Name: "arabica", - NSIDBase: arabica.NSIDBase, - Descriptors: entities.AllForApp(arabica.NSIDBase), - Brand: domain.BrandConfig{ - DisplayName: "Arabica", - Tagline: "Your brew, your data", - }, - } -} diff --git a/cmd/arabica/app_test.go b/cmd/arabica/app_test.go index 0eae4f1..1249a1d 100644 --- a/cmd/arabica/app_test.go +++ b/cmd/arabica/app_test.go @@ -5,12 +5,13 @@ import ( "testing" "github.com/stretchr/testify/assert" + "tangled.org/arabica.social/arabica/internal/atplatform/apps" ) // TestArabicaApp_OAuthScopes_matchesLegacyList guards against accidental // scope drift during the multi-tenant refactor. func TestArabicaApp_OAuthScopes_matchesLegacyList(t *testing.T) { - app := newArabicaApp() + app := apps.NewArabica() got := app.OAuthScopes() sort.Strings(got) @@ -30,7 +31,7 @@ func TestArabicaApp_OAuthScopes_matchesLegacyList(t *testing.T) { } func TestArabicaApp_NSIDs(t *testing.T) { - app := newArabicaApp() + app := apps.NewArabica() got := app.NSIDs() sort.Strings(got) diff --git a/cmd/arabica/main.go b/cmd/arabica/main.go index 8a456d2..ff82deb 100644 --- a/cmd/arabica/main.go +++ b/cmd/arabica/main.go @@ -8,17 +8,15 @@ package main import ( "context" "flag" - "log/slog" "os" "os/signal" "syscall" - "time" coffeehandlers "tangled.org/arabica.social/arabica/internal/arabica/handlers" + "tangled.org/arabica.social/arabica/internal/atplatform/apps" "tangled.org/arabica.social/arabica/internal/atplatform/server" "tangled.org/arabica.social/arabica/internal/logging" - "github.com/rs/zerolog" "github.com/rs/zerolog/log" ) @@ -31,7 +29,7 @@ func main() { knownDIDsFile := flag.String("known-dids", "", "Path to file containing DIDs to backfill on startup (one per line)") flag.Parse() - configureLogging() + logging.ConfigureFromEnv(os.Stdout) ctx, cancel := context.WithCancel(context.Background()) defer cancel() @@ -44,7 +42,7 @@ func main() { cancel() }() - app := newArabicaApp() + app := apps.NewArabica() log.Info().Str("app", app.Name).Msg("Starting app") err := server.Run(ctx, app, server.Options{ KnownDIDsPath: *knownDIDsFile, @@ -57,27 +55,3 @@ func main() { } log.Info().Msg("Stopped") } - -func configureLogging() { - switch os.Getenv("LOG_LEVEL") { - case "debug": - zerolog.SetGlobalLevel(zerolog.DebugLevel) - case "warn": - zerolog.SetGlobalLevel(zerolog.WarnLevel) - case "error": - zerolog.SetGlobalLevel(zerolog.ErrorLevel) - default: - zerolog.SetGlobalLevel(zerolog.InfoLevel) - } - - if os.Getenv("LOG_FORMAT") == "json" { - log.Logger = zerolog.New(os.Stdout).With().Timestamp().Logger() - } else { - log.Logger = log.Output(zerolog.ConsoleWriter{ - Out: os.Stdout, - TimeFormat: time.RFC3339, - }) - } - - slog.SetDefault(slog.New(logging.NewZerologHandler(log.Logger))) -} diff --git a/cmd/oolong/app.go b/cmd/oolong/app.go deleted file mode 100644 index f1bdef0..0000000 --- a/cmd/oolong/app.go +++ /dev/null @@ -1,24 +0,0 @@ -package main - -import ( - "tangled.org/arabica.social/arabica/internal/atplatform/domain" - "tangled.org/arabica.social/arabica/internal/entities" - "tangled.org/arabica.social/arabica/internal/oolong/entities" - - // Blank import runs init() in oolong's web/components package, - // wiring per-app templ render hooks onto descriptors. - _ "tangled.org/arabica.social/arabica/internal/oolong/web/components" -) - -// newOolongApp builds the App value for the tea-tracking app. -func newOolongApp() *domain.App { - return &domain.App{ - Name: "oolong", - NSIDBase: oolong.NSIDBase, - Descriptors: entities.AllForApp(oolong.NSIDBase), - Brand: domain.BrandConfig{ - DisplayName: "Oolong", - Tagline: "Your tea, your data", - }, - } -} diff --git a/cmd/oolong/app_test.go b/cmd/oolong/app_test.go index 8d1828f..76611f8 100644 --- a/cmd/oolong/app_test.go +++ b/cmd/oolong/app_test.go @@ -5,10 +5,11 @@ import ( "testing" "github.com/stretchr/testify/assert" + "tangled.org/arabica.social/arabica/internal/atplatform/apps" ) func TestOolongApp_NSIDs(t *testing.T) { - app := newOolongApp() + app := apps.NewOolong() got := app.NSIDs() sort.Strings(got) @@ -27,7 +28,7 @@ func TestOolongApp_NSIDs(t *testing.T) { } func TestOolongApp_OAuthScopes(t *testing.T) { - app := newOolongApp() + app := apps.NewOolong() got := app.OAuthScopes() sort.Strings(got) diff --git a/cmd/oolong/main.go b/cmd/oolong/main.go index a6691d1..431f775 100644 --- a/cmd/oolong/main.go +++ b/cmd/oolong/main.go @@ -8,17 +8,15 @@ package main import ( "context" "flag" - "log/slog" "os" "os/signal" "syscall" - "time" + "tangled.org/arabica.social/arabica/internal/atplatform/apps" "tangled.org/arabica.social/arabica/internal/atplatform/server" "tangled.org/arabica.social/arabica/internal/logging" teahandlers "tangled.org/arabica.social/arabica/internal/oolong/handlers" - "github.com/rs/zerolog" "github.com/rs/zerolog/log" ) @@ -31,7 +29,7 @@ func main() { knownDIDsFile := flag.String("known-dids", "", "Path to file containing DIDs to backfill on startup (one per line)") flag.Parse() - configureLogging() + logging.ConfigureFromEnv(os.Stdout) ctx, cancel := context.WithCancel(context.Background()) defer cancel() @@ -44,7 +42,7 @@ func main() { cancel() }() - app := newOolongApp() + app := apps.NewOolong() log.Info().Str("app", app.Name).Msg("Starting app") err := server.Run(ctx, app, server.Options{ KnownDIDsPath: *knownDIDsFile, @@ -58,27 +56,3 @@ func main() { } log.Info().Msg("Stopped") } - -func configureLogging() { - switch os.Getenv("LOG_LEVEL") { - case "debug": - zerolog.SetGlobalLevel(zerolog.DebugLevel) - case "warn": - zerolog.SetGlobalLevel(zerolog.WarnLevel) - case "error": - zerolog.SetGlobalLevel(zerolog.ErrorLevel) - default: - zerolog.SetGlobalLevel(zerolog.InfoLevel) - } - - if os.Getenv("LOG_FORMAT") == "json" { - log.Logger = zerolog.New(os.Stdout).With().Timestamp().Logger() - } else { - log.Logger = log.Output(zerolog.ConsoleWriter{ - Out: os.Stdout, - TimeFormat: time.RFC3339, - }) - } - - slog.SetDefault(slog.New(logging.NewZerologHandler(log.Logger))) -} diff --git a/cmd/server/main.go b/cmd/server/main.go new file mode 100644 index 0000000..8b2cf12 --- /dev/null +++ b/cmd/server/main.go @@ -0,0 +1,99 @@ +// Command server runs Arabica and Oolong in one process while keeping each +// app's listener, metrics port, data directory, and SQLite database isolated. +package main + +import ( + "context" + "flag" + "fmt" + "os" + "os/signal" + "sync" + "syscall" + + "tangled.org/arabica.social/arabica/internal/atplatform/apps" + "tangled.org/arabica.social/arabica/internal/atplatform/domain" + "tangled.org/arabica.social/arabica/internal/atplatform/server" + "tangled.org/arabica.social/arabica/internal/logging" + + "github.com/rs/zerolog/log" +) + +type appRun struct { + app *domain.App + defaultPort string + defaultMetricsPort string +} + +func main() { + knownDIDsFile := flag.String("known-dids", "", "Path to file containing DIDs to backfill on startup (one per line)") + flag.Parse() + + logging.ConfigureFromEnv(os.Stdout) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) + go func() { + <-sigCh + log.Info().Msg("Signal received, shutting down") + cancel() + }() + + runs := []appRun{ + {app: apps.NewArabica(), defaultPort: "18910", defaultMetricsPort: "9101"}, + {app: apps.NewOolong(), defaultPort: "18920", defaultMetricsPort: "9102"}, + } + + if err := runApps(ctx, *knownDIDsFile, runs); err != nil { + log.Fatal().Err(err).Msg("Server exited with error") + } + log.Info().Msg("Stopped") +} + +func runApps(ctx context.Context, knownDIDsFile string, runs []appRun) error { + ctx, cancel := context.WithCancel(ctx) + defer cancel() + + errCh := make(chan error, len(runs)) + var wg sync.WaitGroup + for _, run := range runs { + wg.Go(func() { + log.Info().Str("app", run.app.Name).Msg("Starting app") + err := server.Run(ctx, run.app, server.Options{ + KnownDIDsPath: knownDIDsFile, + DefaultPort: run.defaultPort, + DefaultMetricsPort: run.defaultMetricsPort, + }) + if err != nil { + errCh <- fmt.Errorf("%s: %w", run.app.Name, err) + cancel() + } + }) + } + + done := make(chan struct{}) + go func() { + wg.Wait() + close(done) + }() + + select { + case <-done: + select { + case err := <-errCh: + return err + default: + return nil + } + case err := <-errCh: + cancel() + <-done + return err + case <-ctx.Done(): + <-done + return nil + } +} diff --git a/cmd/server/main_test.go b/cmd/server/main_test.go new file mode 100644 index 0000000..34cd09b --- /dev/null +++ b/cmd/server/main_test.go @@ -0,0 +1,41 @@ +package main + +import ( + "sort" + "testing" + + "github.com/stretchr/testify/assert" + "tangled.org/arabica.social/arabica/internal/atplatform/apps" +) + +func TestServerAppsUseSeparateDefaults(t *testing.T) { + runs := []appRun{ + {app: apps.NewArabica(), defaultPort: "18910", defaultMetricsPort: "9101"}, + {app: apps.NewOolong(), defaultPort: "18920", defaultMetricsPort: "9102"}, + } + + got := map[string]appRun{} + for _, run := range runs { + got[run.app.Name] = run + } + + assert.Equal(t, "18910", got["arabica"].defaultPort) + assert.Equal(t, "9101", got["arabica"].defaultMetricsPort) + assert.Equal(t, "18920", got["oolong"].defaultPort) + assert.Equal(t, "9102", got["oolong"].defaultMetricsPort) +} + +func TestServerAppsUseDistinctNSIDBases(t *testing.T) { + runs := []appRun{ + {app: apps.NewArabica(), defaultPort: "18910", defaultMetricsPort: "9101"}, + {app: apps.NewOolong(), defaultPort: "18920", defaultMetricsPort: "9102"}, + } + + bases := make([]string, 0, len(runs)) + for _, run := range runs { + bases = append(bases, run.app.NSIDBase) + } + sort.Strings(bases) + + assert.Equal(t, []string{"social.arabica.alpha", "social.oolong.alpha"}, bases) +} diff --git a/docs/plans/2026-05-23-unify-app-backend-plan.md b/docs/plans/2026-05-23-unify-app-backend-plan.md index 2a82f29..35deeae 100644 --- a/docs/plans/2026-05-23-unify-app-backend-plan.md +++ b/docs/plans/2026-05-23-unify-app-backend-plan.md @@ -58,7 +58,7 @@ $DATA_ROOT/ ## Current pain points 1. **Backend split is inconsistent.** Arabica routes use the typed - `database.Store` interface; Oolong routes often downcast to + `arabicastore.Store` interface; Oolong routes often downcast to `*atproto.AtprotoStore` and use generic record primitives directly. 2. **Per-app handler packages duplicate backend flow.** Create/update/delete @@ -112,36 +112,36 @@ Rollback: ## Phase 1 — Add a unified server binary, keep isolated app stacks -Status: not started. +Status: implemented 2026-05-23. Goal: prove one process can run both apps without changing handler/storage architecture yet. Tasks: -- [ ] Add shared app constructors outside `cmd/arabica` and `cmd/oolong`, e.g. +- [x] Add shared app constructors outside `cmd/arabica` and `cmd/oolong`, e.g. `internal/arabica/app` and `internal/oolong/app`, or an `internal/atplatform/apps` package. -- [ ] Add `cmd/server` that constructs both apps and starts one app stack per +- [x] Add `cmd/server` that constructs both apps and starts one app stack per app. -- [ ] Keep separate ports initially: +- [x] Keep separate ports initially: - Arabica default: `18910` - Oolong default: `18920` -- [ ] Keep separate metrics ports initially: +- [x] Keep separate metrics ports initially: - Arabica default: `9101` - Oolong default: `9102` -- [ ] Keep separate data dirs and DBs through existing per-app env prefixes: +- [x] Keep separate data dirs and DBs through existing per-app env prefixes: `ARABICA_DATA_DIR`, `OOLONG_DATA_DIR`. -- [ ] Ensure both app stacks can shut down from the same process context. -- [ ] Keep `cmd/arabica` and `cmd/oolong` working. +- [x] Ensure both app stacks can shut down from the same process context. +- [x] Keep `cmd/arabica` and `cmd/oolong` working. Verification: -- [ ] `go test ./...` -- [ ] `go run ./cmd/server` starts both HTTP listeners. -- [ ] Arabica home/feed/login pages load on Arabica port. -- [ ] Oolong home/feed/login pages load on Oolong port. -- [ ] Both DB files are created in separate data dirs. +- [x] `go test ./...` +- [x] `go run ./cmd/server` starts both HTTP listeners. +- [x] Arabica home/feed/login pages load on Arabica port. +- [x] Oolong home/feed/login pages load on Oolong port. +- [x] Both DB files are created in separate data dirs. Rollback: @@ -150,7 +150,7 @@ Rollback: ## Phase 2 — Introduce a generic record-store boundary -Status: not started. +Status: implemented 2026-05-23. Goal: create one storage abstraction both apps can use, without forcing all Arabica typed methods to disappear immediately. @@ -169,18 +169,18 @@ type RecordStore interface { Tasks: -- [ ] Define the interface in a package that is not app-specific. Candidate: +- [x] Define the interface in a package that is not app-specific. Candidate: `internal/records`. -- [ ] Make `*atproto.AtprotoStore` satisfy it. -- [ ] Replace Oolong concrete `*atproto.AtprotoStore` requirements with the +- [x] Make `*atproto.AtprotoStore` satisfy it. +- [x] Replace Oolong concrete `*atproto.AtprotoStore` requirements with the generic interface where practical. -- [ ] Leave Arabica `database.Store` intact during this phase. -- [ ] Add focused tests around generic create/update/delete using a fake store. +- [x] Leave Arabica `arabicastore.Store` intact during this phase. +- [x] Add focused tests around generic create/update helpers using a fake store. Verification: -- [ ] `go test ./internal/oolong/... ./internal/handlers/... ./internal/atproto/...` -- [ ] `go test ./...` +- [x] `go test ./internal/oolong/... ./internal/handlers/... ./internal/atproto/...` +- [x] `go test ./...` Rollback: @@ -188,32 +188,33 @@ Rollback: ## Phase 3 — Move standard CRUD plumbing into shared app-aware handlers -Status: not started. +Status: implemented 2026-05-23 for standard simple-entity CRUD plumbing. Goal: replace per-app duplicated CRUD skeletons with shared descriptor-driven helpers. Tasks: -- [ ] Extend `entities.Descriptor` only where needed for backend behavior. Add - callbacks conservatively; do not turn the descriptor into a giant god - object. -- [ ] Extract common write flow: +- [x] Extend `entities.Descriptor` only where needed for backend behavior. No new + descriptor callbacks were needed for this pass; the shared helper stays + handler-driven to avoid a descriptor god object. +- [x] Extract common write flow: decode request → validate → model → record map → `PutRecord` → invalidate feed/session state → JSON/HTMX response. -- [ ] Convert Oolong simple entities first; they already use generic record +- [x] Convert Oolong simple entities first; they already use generic record primitives. -- [ ] Convert Arabica simple entities next: roaster, grinder, brewer. -- [ ] Defer complex entities with richer behavior: Arabica brew, recipe, and +- [x] Convert Arabica simple entities next: roaster, grinder, brewer. +- [x] Defer complex entities with richer behavior: Arabica brew, recipe, and any entity with batch reference resolution or bespoke page flows. -- [ ] Delete only helper code that has no remaining callers. +- [x] Delete only helper code that has no remaining callers. Verification: -- [ ] Existing handler tests pass. -- [ ] Add table tests for shared helper behavior: validation error, decode +- [x] Existing handler tests pass. +- [x] Add table tests for shared helper behavior: validation error, decode error, create success, update success, store error. -- [ ] Manual smoke: create/edit/delete one simple entity in each app. +- [x] Handler smoke: create/update simple entities through the shared helper for + Arabica and Oolong using fake stores; public server smoke starts both apps. Rollback: @@ -221,26 +222,27 @@ Rollback: ## Phase 4 — Normalize Arabica onto the generic record path -Status: not started. +Status: implemented 2026-05-23 for simple entity handlers; complex typed flows intentionally retained. -Goal: reduce reliance on the Arabica-only `database.Store` interface. +Goal: reduce reliance on the Arabica-only `arabicastore.Store` interface. Tasks: -- [ ] Convert Arabica simple entity handlers to generic record helpers. -- [ ] Move Arabica-specific typed repository methods behind local helpers where - they still add value. -- [ ] Reassess `internal/database.Store` after conversions: - - If only complex Arabica flows use it, rename/move it to +- [x] Convert Arabica simple entity handlers to generic record helpers. +- [x] Move Arabica-specific typed repository methods behind local helpers where + they still add value. Complex brew/recipe/social flows remain on + `internal/arabica/store` pending a dedicated refactor. +- [x] Reassess `internal/arabica/store.Store` after conversions: + - It still supports complex Arabica flows and tests, so it now lives at `internal/arabica/store`. - - If no production code uses it, delete it and its mock. -- [ ] Keep typed model conversion in `internal/arabica/entities`. +- [x] Keep typed model conversion in `internal/arabica/entities`. Verification: -- [ ] `go test ./internal/arabica/... ./internal/atproto/...` -- [ ] `go test ./...` -- [ ] Manual smoke: Arabica bean/roaster/grinder/brewer CRUD. +- [x] `go test ./internal/arabica/... ./internal/atproto/...` +- [x] `go test ./...` +- [x] Handler smoke: Arabica roaster/grinder generic record CRUD covered by tests; + bean remains intentionally bespoke because it can inline-create roasters. Rollback: @@ -248,23 +250,23 @@ Rollback: ## Phase 5 — Consolidate route registration where behavior is app-generic -Status: not started. +Status: implemented 2026-05-23 for descriptor-driven entity bundles. Goal: remove routing branches that only exist because of current package layout. Tasks: -- [ ] Identify route bundles that are pure descriptor loops. -- [ ] Move generic route registration into shared routing/handler code. -- [ ] Keep app-specific route registration for bespoke pages and onboarding. -- [ ] Ensure unsupported/deferred entities are gated by `App.Descriptors`, not +- [x] Identify route bundles that are pure descriptor loops. +- [x] Move generic route registration into shared routing/handler code. +- [x] Keep app-specific route registration for bespoke pages and onboarding. +- [x] Ensure unsupported/deferred entities are gated by `App.Descriptors`, not hardcoded app switches. Verification: -- [ ] Route inventory from Phase 0 matches generated routes after refactor. -- [ ] `go test ./internal/routing/... ./...` -- [ ] Manual smoke both apps. +- [x] Route inventory from Phase 0 matches generated routes after refactor. +- [x] `go test ./internal/routing/... ./...` +- [x] Public server smoke both apps; descriptor route filtering covered by tests. Rollback: @@ -272,23 +274,22 @@ Rollback: ## Phase 6 — Clean package names and storage layout -Status: not started. +Status: implemented 2026-05-23. Goal: make package names match ownership after the backend merge. Tasks: -- [ ] Move SQLite infrastructure adapters out of `internal/database/sqlitestore` - to a clearer package, e.g. `internal/sqlitestore` or - `internal/storage/sqlite`. -- [ ] If `database.Store` remains Arabica-only, move it to +- [x] Move SQLite infrastructure adapters out of `internal/database/sqlitestore` + to `internal/sqlitestore`. +- [x] If `arabicastore.Store` remains Arabica-only, move it to `internal/arabica/store`. -- [ ] Update docs and AGENTS guidance for the new package boundaries. +- [x] Update docs and AGENTS guidance for the new package boundaries. Verification: -- [ ] `go test ./...` -- [ ] `go vet ./...` +- [x] `go test ./...` +- [x] `go vet ./...` Rollback: diff --git a/docs/plans/cafe-and-drinks.md b/docs/plans/cafe-and-drinks.md index f8030d6..62954aa 100644 --- a/docs/plans/cafe-and-drinks.md +++ b/docs/plans/cafe-and-drinks.md @@ -140,7 +140,7 @@ Record conversion, store interface, store implementation, cache. - `DrinkToRecord(drink, cafeURI, beanURI)` / `RecordToDrink(record, atURI)` — cafeRef required, beanRef optional -**`internal/database/store.go`** — add to interface: +**`internal/arabica/store/store.go`** — add to interface: - Cafe: `CreateCafe`, `GetCafeByRKey`, `ListCafes`, `UpdateCafeByRKey`, `DeleteCafeByRKey` - Drink: `CreateDrink`, `GetDrinkByRKey`, `ListDrinks`, `UpdateDrinkByRKey`, diff --git a/internal/arabica/handlers/crud_generic.go b/internal/arabica/handlers/crud_generic.go deleted file mode 100644 index 08eaa60..0000000 --- a/internal/arabica/handlers/crud_generic.go +++ /dev/null @@ -1,113 +0,0 @@ -package coffeehandlers - -import ( - "context" - "net/http" - - "github.com/rs/zerolog/log" - - "tangled.org/arabica.social/arabica/internal/database" - "tangled.org/arabica.social/arabica/internal/handlers" -) - -// arabicaValidator is a pointer-constraint so the generic factories -// below can call Validate() on a value of type Req without copying. -type arabicaValidator[T any] interface { - *T - Validate() error -} - -// arabicaCRUDCreate is the shared body for arabica Create handlers -// that follow the standard: require store -> decode (JSON or form) -> -// validate -> call typed store.CreateX -> invalidate cache -> handlers.WriteJSON. -// -// decodeForm fills the request from r.Form for non-JSON requests -// (arabica handlers use bespoke field-name mappings rather than -// reflection-based decoding). create calls the typed store method. -func arabicaCRUDCreate[Req any, PReq arabicaValidator[Req], Model any]( - h *Handlers, - w http.ResponseWriter, - r *http.Request, - name, jsonKey string, - decodeForm func(r *http.Request) Req, - create func(ctx context.Context, store database.Store, req *Req) (*Model, error), -) { - store, authenticated := h.GetAtprotoStore(r) - if !authenticated { - http.Error(w, "Authentication required", http.StatusUnauthorized) - return - } - var req Req - if err := handlers.DecodeRequest(r, &req, func() error { - req = decodeForm(r) - return nil - }); err != nil { - log.Warn().Err(err).Msgf("Failed to decode %s create request", name) - http.Error(w, "Invalid request body", http.StatusBadRequest) - return - } - if err := PReq(&req).Validate(); err != nil { - log.Warn().Err(err).Msgf("%s create validation failed", name) - http.Error(w, err.Error(), http.StatusBadRequest) - return - } - model, err := create(r.Context(), store, &req) - if err != nil { - log.Error().Err(err).Msgf("Failed to create %s", name) - handlers.HandleStoreError(w, err, "Failed to create "+name) - return - } - h.InvalidateFeedCache() - handlers.WriteJSON(w, model, jsonKey) -} - -// arabicaCRUDUpdate is the shared body for arabica Update handlers. -// After applying the update, the entity is re-fetched so the JSON -// response carries the freshly-persisted record (matching the -// pre-refactor behavior of the hand-cloned handlers). -func arabicaCRUDUpdate[Req any, PReq arabicaValidator[Req], Model any]( - h *Handlers, - w http.ResponseWriter, - r *http.Request, - name, jsonKey string, - decodeForm func(r *http.Request) Req, - update func(ctx context.Context, store database.Store, rkey string, req *Req) error, - refetch func(ctx context.Context, store database.Store, rkey string) (*Model, error), -) { - rkey := handlers.ValidateRKey(w, r.PathValue("id")) - if rkey == "" { - return - } - store, authenticated := h.GetAtprotoStore(r) - if !authenticated { - http.Error(w, "Authentication required", http.StatusUnauthorized) - return - } - var req Req - if err := handlers.DecodeRequest(r, &req, func() error { - req = decodeForm(r) - return nil - }); err != nil { - log.Warn().Err(err).Str("rkey", rkey).Msgf("Failed to decode %s update request", name) - http.Error(w, "Invalid request body", http.StatusBadRequest) - return - } - if err := PReq(&req).Validate(); err != nil { - log.Warn().Err(err).Str("rkey", rkey).Msgf("%s update validation failed", name) - http.Error(w, err.Error(), http.StatusBadRequest) - return - } - if err := update(r.Context(), store, rkey, &req); err != nil { - log.Error().Err(err).Str("rkey", rkey).Msgf("Failed to update %s", name) - handlers.HandleStoreError(w, err, "Failed to update "+name) - return - } - model, err := refetch(r.Context(), store, rkey) - if err != nil { - log.Error().Err(err).Str("rkey", rkey).Msgf("Failed to get %s after update", name) - http.Error(w, "Failed to fetch updated "+name, http.StatusInternalServerError) - return - } - h.InvalidateFeedCache() - handlers.WriteJSON(w, model, jsonKey) -} diff --git a/internal/arabica/handlers/entities.go b/internal/arabica/handlers/entities.go index e1549a0..333aba1 100644 --- a/internal/arabica/handlers/entities.go +++ b/internal/arabica/handlers/entities.go @@ -1,17 +1,17 @@ package coffeehandlers import ( - "context" "encoding/json" "net/http" "strings" + "time" arabica "tangled.org/arabica.social/arabica/internal/arabica/entities" coffee "tangled.org/arabica.social/arabica/internal/arabica/web/components" coffeepages "tangled.org/arabica.social/arabica/internal/arabica/web/pages" "tangled.org/arabica.social/arabica/internal/atproto" - "tangled.org/arabica.social/arabica/internal/database" "tangled.org/arabica.social/arabica/internal/handlers" + "tangled.org/arabica.social/arabica/internal/records" "tangled.org/arabica.social/arabica/internal/tracing" "tangled.org/arabica.social/arabica/internal/web/components" atpmiddleware "tangled.org/pdewey.com/atp/middleware" @@ -291,17 +291,20 @@ func (h *Handlers) HandleBeanCreate(w http.ResponseWriter, r *http.Request) { // API endpoint to create roaster func (h *Handlers) HandleRoasterCreate(w http.ResponseWriter, r *http.Request) { - arabicaCRUDCreate[arabica.CreateRoasterRequest, *arabica.CreateRoasterRequest, arabica.Roaster]( - h, w, r, "roaster", "roaster", - func(r *http.Request) arabica.CreateRoasterRequest { - return arabica.CreateRoasterRequest{ - Name: r.FormValue("name"), Location: r.FormValue("location"), - Website: r.FormValue("website"), SourceRef: r.FormValue("source_ref"), - } + store, ok := h.RequireRecordStore(w, r) + if !ok { + return + } + handlers.RecordCRUDWrite[arabica.CreateRoasterRequest, *arabica.CreateRoasterRequest, arabica.Roaster]( + w, r, store, arabica.NSIDRoaster, "roaster", "", decodeRoasterCreateForm, + func(req *arabica.CreateRoasterRequest) *arabica.Roaster { + return roasterFromCreate(req, time.Now()) }, - func(ctx context.Context, s database.Store, req *arabica.CreateRoasterRequest) (*arabica.Roaster, error) { - return s.CreateRoaster(ctx, req) + func(m *arabica.Roaster, rkey string) { m.RKey = rkey }, + func(_ records.Store, _ *arabica.CreateRoasterRequest, m *arabica.Roaster) (map[string]any, error) { + return arabica.RoasterToRecord(m) }, + h.InvalidateFeedCache, false, ) } @@ -635,20 +638,27 @@ func (h *Handlers) HandleBeanDelete(w http.ResponseWriter, r *http.Request) { // Roaster update/delete handlers func (h *Handlers) HandleRoasterUpdate(w http.ResponseWriter, r *http.Request) { - arabicaCRUDUpdate[arabica.UpdateRoasterRequest, *arabica.UpdateRoasterRequest, arabica.Roaster]( - h, w, r, "roaster", "roaster", - func(r *http.Request) arabica.UpdateRoasterRequest { - return arabica.UpdateRoasterRequest{ - Name: r.FormValue("name"), Location: r.FormValue("location"), - Website: r.FormValue("website"), SourceRef: r.FormValue("source_ref"), - } - }, - func(ctx context.Context, s database.Store, rkey string, req *arabica.UpdateRoasterRequest) error { - return s.UpdateRoasterByRKey(ctx, rkey, req) + rkey := handlers.ValidateRKey(w, r.PathValue("id")) + if rkey == "" { + return + } + store, ok := h.RequireRecordStore(w, r) + if !ok { + return + } + createdAt := handlers.ExistingCreatedAt(r.Context(), store, arabica.NSIDRoaster, rkey) + handlers.RecordCRUDWrite[arabica.UpdateRoasterRequest, *arabica.UpdateRoasterRequest, arabica.Roaster]( + w, r, store, arabica.NSIDRoaster, "roaster", rkey, decodeRoasterUpdateForm, + func(req *arabica.UpdateRoasterRequest) *arabica.Roaster { + m := roasterFromUpdate(req, createdAt) + m.RKey = rkey + return m }, - func(ctx context.Context, s database.Store, rkey string) (*arabica.Roaster, error) { - return s.GetRoasterByRKey(ctx, rkey) + func(m *arabica.Roaster, rkey string) { m.RKey = rkey }, + func(_ records.Store, _ *arabica.UpdateRoasterRequest, m *arabica.Roaster) (map[string]any, error) { + return arabica.RoasterToRecord(m) }, + h.InvalidateFeedCache, false, ) } @@ -671,27 +681,51 @@ func grinderFormDecoder(r *http.Request) arabica.CreateGrinderRequest { } func (h *Handlers) HandleGrinderCreate(w http.ResponseWriter, r *http.Request) { - arabicaCRUDCreate[arabica.CreateGrinderRequest, *arabica.CreateGrinderRequest, arabica.Grinder]( - h, w, r, "grinder", "grinder", - grinderFormDecoder, - func(ctx context.Context, s database.Store, req *arabica.CreateGrinderRequest) (*arabica.Grinder, error) { - return s.CreateGrinder(ctx, req) + store, ok := h.RequireRecordStore(w, r) + if !ok { + return + } + handlers.RecordCRUDWrite[arabica.CreateGrinderRequest, *arabica.CreateGrinderRequest, arabica.Grinder]( + w, r, store, arabica.NSIDGrinder, "grinder", "", + func(r *http.Request, req *arabica.CreateGrinderRequest) error { + *req = grinderFormDecoder(r) + return nil + }, + func(req *arabica.CreateGrinderRequest) *arabica.Grinder { return grinderFromCreate(req, time.Now()) }, + func(m *arabica.Grinder, rkey string) { m.RKey = rkey }, + func(_ records.Store, _ *arabica.CreateGrinderRequest, m *arabica.Grinder) (map[string]any, error) { + return arabica.GrinderToRecord(m) }, + h.InvalidateFeedCache, false, ) } func (h *Handlers) HandleGrinderUpdate(w http.ResponseWriter, r *http.Request) { - arabicaCRUDUpdate[arabica.UpdateGrinderRequest, *arabica.UpdateGrinderRequest, arabica.Grinder]( - h, w, r, "grinder", "grinder", - func(r *http.Request) arabica.UpdateGrinderRequest { - return arabica.UpdateGrinderRequest(grinderFormDecoder(r)) + rkey := handlers.ValidateRKey(w, r.PathValue("id")) + if rkey == "" { + return + } + store, ok := h.RequireRecordStore(w, r) + if !ok { + return + } + createdAt := handlers.ExistingCreatedAt(r.Context(), store, arabica.NSIDGrinder, rkey) + handlers.RecordCRUDWrite[arabica.UpdateGrinderRequest, *arabica.UpdateGrinderRequest, arabica.Grinder]( + w, r, store, arabica.NSIDGrinder, "grinder", rkey, + func(r *http.Request, req *arabica.UpdateGrinderRequest) error { + *req = arabica.UpdateGrinderRequest(grinderFormDecoder(r)) + return nil }, - func(ctx context.Context, s database.Store, rkey string, req *arabica.UpdateGrinderRequest) error { - return s.UpdateGrinderByRKey(ctx, rkey, req) + func(req *arabica.UpdateGrinderRequest) *arabica.Grinder { + m := grinderFromUpdate(req, createdAt) + m.RKey = rkey + return m }, - func(ctx context.Context, s database.Store, rkey string) (*arabica.Grinder, error) { - return s.GetGrinderByRKey(ctx, rkey) + func(m *arabica.Grinder, rkey string) { m.RKey = rkey }, + func(_ records.Store, _ *arabica.UpdateGrinderRequest, m *arabica.Grinder) (map[string]any, error) { + return arabica.GrinderToRecord(m) }, + h.InvalidateFeedCache, false, ) } @@ -714,27 +748,48 @@ func brewerFormDecoder(r *http.Request) arabica.CreateBrewerRequest { } func (h *Handlers) HandleBrewerCreate(w http.ResponseWriter, r *http.Request) { - arabicaCRUDCreate[arabica.CreateBrewerRequest, *arabica.CreateBrewerRequest, arabica.Brewer]( - h, w, r, "brewer", "brewer", - brewerFormDecoder, - func(ctx context.Context, s database.Store, req *arabica.CreateBrewerRequest) (*arabica.Brewer, error) { - return s.CreateBrewer(ctx, req) + store, ok := h.RequireRecordStore(w, r) + if !ok { + return + } + handlers.RecordCRUDWrite[arabica.CreateBrewerRequest, *arabica.CreateBrewerRequest, arabica.Brewer]( + w, r, store, arabica.NSIDBrewer, "brewer", "", + func(r *http.Request, req *arabica.CreateBrewerRequest) error { *req = brewerFormDecoder(r); return nil }, + func(req *arabica.CreateBrewerRequest) *arabica.Brewer { return brewerFromCreate(req, time.Now()) }, + func(m *arabica.Brewer, rkey string) { m.RKey = rkey }, + func(_ records.Store, _ *arabica.CreateBrewerRequest, m *arabica.Brewer) (map[string]any, error) { + return arabica.BrewerToRecord(m) }, + h.InvalidateFeedCache, false, ) } func (h *Handlers) HandleBrewerUpdate(w http.ResponseWriter, r *http.Request) { - arabicaCRUDUpdate[arabica.UpdateBrewerRequest, *arabica.UpdateBrewerRequest, arabica.Brewer]( - h, w, r, "brewer", "brewer", - func(r *http.Request) arabica.UpdateBrewerRequest { - return arabica.UpdateBrewerRequest(brewerFormDecoder(r)) + rkey := handlers.ValidateRKey(w, r.PathValue("id")) + if rkey == "" { + return + } + store, ok := h.RequireRecordStore(w, r) + if !ok { + return + } + createdAt := handlers.ExistingCreatedAt(r.Context(), store, arabica.NSIDBrewer, rkey) + handlers.RecordCRUDWrite[arabica.UpdateBrewerRequest, *arabica.UpdateBrewerRequest, arabica.Brewer]( + w, r, store, arabica.NSIDBrewer, "brewer", rkey, + func(r *http.Request, req *arabica.UpdateBrewerRequest) error { + *req = arabica.UpdateBrewerRequest(brewerFormDecoder(r)) + return nil }, - func(ctx context.Context, s database.Store, rkey string, req *arabica.UpdateBrewerRequest) error { - return s.UpdateBrewerByRKey(ctx, rkey, req) + func(req *arabica.UpdateBrewerRequest) *arabica.Brewer { + m := brewerFromUpdate(req, createdAt) + m.RKey = rkey + return m }, - func(ctx context.Context, s database.Store, rkey string) (*arabica.Brewer, error) { - return s.GetBrewerByRKey(ctx, rkey) + func(m *arabica.Brewer, rkey string) { m.RKey = rkey }, + func(_ records.Store, _ *arabica.UpdateBrewerRequest, m *arabica.Brewer) (map[string]any, error) { + return arabica.BrewerToRecord(m) }, + h.InvalidateFeedCache, false, ) } diff --git a/internal/arabica/handlers/modals.go b/internal/arabica/handlers/modals.go index 25f35f3..e1b72fc 100644 --- a/internal/arabica/handlers/modals.go +++ b/internal/arabica/handlers/modals.go @@ -8,8 +8,8 @@ import ( "github.com/rs/zerolog/log" arabica "tangled.org/arabica.social/arabica/internal/arabica/entities" + arabicastore "tangled.org/arabica.social/arabica/internal/arabica/store" coffee "tangled.org/arabica.social/arabica/internal/arabica/web/components" - "tangled.org/arabica.social/arabica/internal/database" "tangled.org/arabica.social/arabica/internal/handlers" ) @@ -40,7 +40,7 @@ func arabicaModalEdit[Model any]( w http.ResponseWriter, r *http.Request, name string, - fetch func(context.Context, database.Store, string) (*Model, error), + fetch func(context.Context, arabicastore.Store, string) (*Model, error), render func(*Model) templ.Component, ) { rkey := handlers.ValidateRKey(w, r.PathValue("id")) @@ -103,7 +103,7 @@ func (h *Handlers) HandleBeanModalEdit(w http.ResponseWriter, r *http.Request) { } } -func beanModalRoasters(ctx context.Context, store database.Store) []arabica.Roaster { +func beanModalRoasters(ctx context.Context, store arabicastore.Store) []arabica.Roaster { roasters, err := store.ListRoasters(ctx) if err != nil { log.Warn().Err(err).Msg("Failed to fetch roasters for bean modal") @@ -124,7 +124,7 @@ func (h *Handlers) HandleGrinderModalNew(w http.ResponseWriter, r *http.Request) func (h *Handlers) HandleGrinderModalEdit(w http.ResponseWriter, r *http.Request) { arabicaModalEdit(h, w, r, "grinder", - func(ctx context.Context, s database.Store, rkey string) (*arabica.Grinder, error) { + func(ctx context.Context, s arabicastore.Store, rkey string) (*arabica.Grinder, error) { return s.GetGrinderByRKey(ctx, rkey) }, func(g *arabica.Grinder) templ.Component { return coffee.GrinderDialogModal(g) }, @@ -139,7 +139,7 @@ func (h *Handlers) HandleBrewerModalNew(w http.ResponseWriter, r *http.Request) func (h *Handlers) HandleBrewerModalEdit(w http.ResponseWriter, r *http.Request) { arabicaModalEdit(h, w, r, "brewer", - func(ctx context.Context, s database.Store, rkey string) (*arabica.Brewer, error) { + func(ctx context.Context, s arabicastore.Store, rkey string) (*arabica.Brewer, error) { return s.GetBrewerByRKey(ctx, rkey) }, func(b *arabica.Brewer) templ.Component { return coffee.BrewerDialogModal(b) }, @@ -154,7 +154,7 @@ func (h *Handlers) HandleRoasterModalNew(w http.ResponseWriter, r *http.Request) func (h *Handlers) HandleRoasterModalEdit(w http.ResponseWriter, r *http.Request) { arabicaModalEdit(h, w, r, "roaster", - func(ctx context.Context, s database.Store, rkey string) (*arabica.Roaster, error) { + func(ctx context.Context, s arabicastore.Store, rkey string) (*arabica.Roaster, error) { return s.GetRoasterByRKey(ctx, rkey) }, func(r *arabica.Roaster) templ.Component { return coffee.RoasterDialogModal(r) }, diff --git a/internal/arabica/handlers/onboarding.go b/internal/arabica/handlers/onboarding.go index b4075ff..dd23131 100644 --- a/internal/arabica/handlers/onboarding.go +++ b/internal/arabica/handlers/onboarding.go @@ -13,8 +13,8 @@ import ( ) // getStartedCardStore is a narrow interface for buildGetStartedCardProps. -// This allows tests to pass *database.MockStore without implementing the -// full database.Store interface. +// This allows tests to pass *arabicastore.MockStore without implementing the +// full arabicastore.Store interface. type getStartedCardStore interface { onboarding.BrewPrerequisiteStore // ListBeans + ListBrewers + ListRoasters ListGrinders(ctx context.Context) ([]*arabica.Grinder, error) diff --git a/internal/arabica/handlers/onboarding_test.go b/internal/arabica/handlers/onboarding_test.go index 7939c08..61d4a70 100644 --- a/internal/arabica/handlers/onboarding_test.go +++ b/internal/arabica/handlers/onboarding_test.go @@ -6,13 +6,13 @@ import ( "testing" arabica "tangled.org/arabica.social/arabica/internal/arabica/entities" - "tangled.org/arabica.social/arabica/internal/database" + arabicastore "tangled.org/arabica.social/arabica/internal/arabica/store" "github.com/stretchr/testify/assert" ) func TestBrewNewReady_True(t *testing.T) { - store := &database.MockStore{ + store := &arabicastore.MockStore{ ListBeansFunc: func(ctx context.Context) ([]*arabica.Bean, error) { return []*arabica.Bean{{RKey: "a"}}, nil }, ListBrewersFunc: func(ctx context.Context) ([]*arabica.Brewer, error) { return []*arabica.Brewer{{RKey: "b"}}, nil }, ListRoastersFunc: func(ctx context.Context) ([]*arabica.Roaster, error) { return []*arabica.Roaster{{RKey: "c"}}, nil }, @@ -22,7 +22,7 @@ func TestBrewNewReady_True(t *testing.T) { } func TestBrewNewReady_FalseWhenMissingBean(t *testing.T) { - store := &database.MockStore{ + store := &arabicastore.MockStore{ ListBeansFunc: func(ctx context.Context) ([]*arabica.Bean, error) { return nil, nil }, ListBrewersFunc: func(ctx context.Context) ([]*arabica.Brewer, error) { return []*arabica.Brewer{{RKey: "b"}}, nil }, ListRoastersFunc: func(ctx context.Context) ([]*arabica.Roaster, error) { return []*arabica.Roaster{{RKey: "c"}}, nil }, @@ -32,7 +32,7 @@ func TestBrewNewReady_FalseWhenMissingBean(t *testing.T) { } func TestBrewNewReady_FalseWhenMissingRoaster(t *testing.T) { - store := &database.MockStore{ + store := &arabicastore.MockStore{ ListBeansFunc: func(ctx context.Context) ([]*arabica.Bean, error) { return []*arabica.Bean{{RKey: "a"}}, nil }, ListBrewersFunc: func(ctx context.Context) ([]*arabica.Brewer, error) { return []*arabica.Brewer{{RKey: "b"}}, nil }, ListRoastersFunc: func(ctx context.Context) ([]*arabica.Roaster, error) { return nil, nil }, @@ -42,7 +42,7 @@ func TestBrewNewReady_FalseWhenMissingRoaster(t *testing.T) { } func TestBrewNewReady_FalseOnError(t *testing.T) { - store := &database.MockStore{ + store := &arabicastore.MockStore{ ListBeansFunc: func(ctx context.Context) ([]*arabica.Bean, error) { return nil, errors.New("pds down") }, ListBrewersFunc: func(ctx context.Context) ([]*arabica.Brewer, error) { return nil, nil }, ListRoastersFunc: func(ctx context.Context) ([]*arabica.Roaster, error) { return nil, nil }, @@ -52,7 +52,7 @@ func TestBrewNewReady_FalseOnError(t *testing.T) { } func TestBuildGetStartedCardProps_Empty(t *testing.T) { - store := &database.MockStore{ + store := &arabicastore.MockStore{ ListBeansFunc: func(ctx context.Context) ([]*arabica.Bean, error) { return nil, nil }, ListBrewersFunc: func(ctx context.Context) ([]*arabica.Brewer, error) { return nil, nil }, ListGrindersFunc: func(ctx context.Context) ([]*arabica.Grinder, error) { return nil, nil }, @@ -68,11 +68,17 @@ func TestBuildGetStartedCardProps_Empty(t *testing.T) { } func TestBuildGetStartedCardProps_Populated(t *testing.T) { - store := &database.MockStore{ - ListBeansFunc: func(ctx context.Context) ([]*arabica.Bean, error) { return []*arabica.Bean{{RKey: "b1", Name: "Ethiopia"}}, nil }, - ListBrewersFunc: func(ctx context.Context) ([]*arabica.Brewer, error) { return []*arabica.Brewer{{RKey: "br1", Name: "V60"}}, nil }, + store := &arabicastore.MockStore{ + ListBeansFunc: func(ctx context.Context) ([]*arabica.Bean, error) { + return []*arabica.Bean{{RKey: "b1", Name: "Ethiopia"}}, nil + }, + ListBrewersFunc: func(ctx context.Context) ([]*arabica.Brewer, error) { + return []*arabica.Brewer{{RKey: "br1", Name: "V60"}}, nil + }, ListGrindersFunc: func(ctx context.Context) ([]*arabica.Grinder, error) { return nil, nil }, - ListRoastersFunc: func(ctx context.Context) ([]*arabica.Roaster, error) { return []*arabica.Roaster{{RKey: "r1", Name: "Onyx"}}, nil }, + ListRoastersFunc: func(ctx context.Context) ([]*arabica.Roaster, error) { + return []*arabica.Roaster{{RKey: "r1", Name: "Onyx"}}, nil + }, } props, err := buildGetStartedCardProps(context.Background(), store) diff --git a/internal/arabica/handlers/record_crud.go b/internal/arabica/handlers/record_crud.go new file mode 100644 index 0000000..4ebe5e3 --- /dev/null +++ b/internal/arabica/handlers/record_crud.go @@ -0,0 +1,60 @@ +package coffeehandlers + +import ( + "net/http" + "time" + + arabica "tangled.org/arabica.social/arabica/internal/arabica/entities" +) + +func decodeRoasterCreateForm(r *http.Request, req *arabica.CreateRoasterRequest) error { + *req = arabica.CreateRoasterRequest{ + Name: r.FormValue("name"), Location: r.FormValue("location"), + Website: r.FormValue("website"), SourceRef: r.FormValue("source_ref"), + } + return nil +} + +func decodeRoasterUpdateForm(r *http.Request, req *arabica.UpdateRoasterRequest) error { + *req = arabica.UpdateRoasterRequest{ + Name: r.FormValue("name"), Location: r.FormValue("location"), + Website: r.FormValue("website"), SourceRef: r.FormValue("source_ref"), + } + return nil +} + +func roasterFromCreate(req *arabica.CreateRoasterRequest, createdAt time.Time) *arabica.Roaster { + return &arabica.Roaster{Name: req.Name, Location: req.Location, Website: req.Website, SourceRef: req.SourceRef, CreatedAt: createdAt} +} + +func roasterFromUpdate(req *arabica.UpdateRoasterRequest, createdAt time.Time) *arabica.Roaster { + return &arabica.Roaster{Name: req.Name, Location: req.Location, Website: req.Website, SourceRef: req.SourceRef, CreatedAt: createdAt} +} + +func grinderFromCreate(req *arabica.CreateGrinderRequest, createdAt time.Time) *arabica.Grinder { + return &arabica.Grinder{ + Name: req.Name, GrinderType: req.GrinderType, BurrType: req.BurrType, + Notes: req.Notes, Link: req.Link, SourceRef: req.SourceRef, CreatedAt: createdAt, + } +} + +func grinderFromUpdate(req *arabica.UpdateGrinderRequest, createdAt time.Time) *arabica.Grinder { + return &arabica.Grinder{ + Name: req.Name, GrinderType: req.GrinderType, BurrType: req.BurrType, + Notes: req.Notes, Link: req.Link, SourceRef: req.SourceRef, CreatedAt: createdAt, + } +} + +func brewerFromCreate(req *arabica.CreateBrewerRequest, createdAt time.Time) *arabica.Brewer { + return &arabica.Brewer{ + Name: req.Name, BrewerType: req.BrewerType, Description: req.Description, + Link: req.Link, SourceRef: req.SourceRef, CreatedAt: createdAt, + } +} + +func brewerFromUpdate(req *arabica.UpdateBrewerRequest, createdAt time.Time) *arabica.Brewer { + return &arabica.Brewer{ + Name: req.Name, BrewerType: req.BrewerType, Description: req.Description, + Link: req.Link, SourceRef: req.SourceRef, CreatedAt: createdAt, + } +} diff --git a/internal/arabica/handlers/record_crud_test.go b/internal/arabica/handlers/record_crud_test.go new file mode 100644 index 0000000..8e21cc5 --- /dev/null +++ b/internal/arabica/handlers/record_crud_test.go @@ -0,0 +1,89 @@ +package coffeehandlers + +import ( + "context" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + arabica "tangled.org/arabica.social/arabica/internal/arabica/entities" + atpmiddleware "tangled.org/pdewey.com/atp/middleware" +) + +func TestHandleRoasterCreateUsesGenericRecordStore(t *testing.T) { + tc := NewTestContext() + tc.Handler.SetStoreOverrideForTest(tc.MockStore) + + var gotNSID, gotRKey string + var gotRecord any + tc.MockStore.PutRecordFunc = func(_ context.Context, nsid, rkey string, record any) (string, string, error) { + gotNSID = nsid + gotRKey = rkey + gotRecord = record + return "3jzfcijpj2z2a", "cid", nil + } + + req := newMiddlewareAuthenticatedRequest(http.MethodPost, "/api/roasters") + req.Body = ioNopCloser(`{"name":"Test Roaster","location":"Test City","website":"https://example.com"}`) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + + tc.Handler.HandleRoasterCreate(rec, req) + + assert.Equal(t, http.StatusOK, rec.Code) + assert.Contains(t, rec.Body.String(), `"rkey":"3jzfcijpj2z2a"`) + assert.Equal(t, arabica.NSIDRoaster, gotNSID) + assert.Equal(t, "", gotRKey) + recordMap, ok := gotRecord.(map[string]any) + assert.True(t, ok) + assert.Equal(t, "Test Roaster", recordMap["name"]) + assert.Equal(t, "Test City", recordMap["location"]) +} + +func TestHandleGrinderUpdatePreservesCreatedAt(t *testing.T) { + tc := NewTestContext() + tc.Handler.SetStoreOverrideForTest(tc.MockStore) + + createdAt := time.Date(2024, 5, 1, 12, 0, 0, 0, time.UTC) + tc.MockStore.FetchRecordFunc = func(_ context.Context, nsid, rkey string) (map[string]any, string, string, error) { + assert.Equal(t, arabica.NSIDGrinder, nsid) + assert.Equal(t, "3jzfcijpj2z2a", rkey) + return map[string]any{"createdAt": createdAt.Format(time.RFC3339)}, "", "", nil + } + var gotRecord any + tc.MockStore.PutRecordFunc = func(_ context.Context, nsid, rkey string, record any) (string, string, error) { + assert.Equal(t, arabica.NSIDGrinder, nsid) + assert.Equal(t, "3jzfcijpj2z2a", rkey) + gotRecord = record + return "", "", nil + } + + req := newMiddlewareAuthenticatedRequest(http.MethodPut, "/api/grinders/3jzfcijpj2z2a") + req.SetPathValue("id", "3jzfcijpj2z2a") + req.Body = ioNopCloser(`{"name":"Updated Grinder","grinder_type":"Hand","burr_type":"Conical"}`) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + + tc.Handler.HandleGrinderUpdate(rec, req) + + assert.Equal(t, http.StatusOK, rec.Code) + assert.Contains(t, rec.Body.String(), `"rkey":"3jzfcijpj2z2a"`) + recordMap, ok := gotRecord.(map[string]any) + assert.True(t, ok) + assert.Equal(t, "Updated Grinder", recordMap["name"]) + assert.Equal(t, createdAt.Format(time.RFC3339), recordMap["createdAt"]) +} + +func ioNopCloser(s string) io.ReadCloser { + return io.NopCloser(strings.NewReader(s)) +} + +func newMiddlewareAuthenticatedRequest(method, path string) *http.Request { + req := httptest.NewRequest(method, path, nil) + ctx := atpmiddleware.ContextWithAuth(req.Context(), "did:plc:test123456789", "test-session-id") + return req.WithContext(ctx) +} diff --git a/internal/arabica/handlers/testutil.go b/internal/arabica/handlers/testutil.go index f6bc0f2..533bb9d 100644 --- a/internal/arabica/handlers/testutil.go +++ b/internal/arabica/handlers/testutil.go @@ -7,7 +7,7 @@ import ( "time" arabica "tangled.org/arabica.social/arabica/internal/arabica/entities" - "tangled.org/arabica.social/arabica/internal/database" + arabicastore "tangled.org/arabica.social/arabica/internal/arabica/store" "tangled.org/arabica.social/arabica/internal/handlers" ) @@ -92,7 +92,7 @@ func NewTestFixtures() *TestFixtures { // TestContext contains test dependencies type TestContext struct { Handler *Handlers - MockStore *database.MockStore + MockStore *arabicastore.MockStore Fixtures *TestFixtures Request *http.Request Recorder *httptest.ResponseRecorder @@ -100,7 +100,7 @@ type TestContext struct { // NewTestContext creates a test context with mock dependencies func NewTestContext() *TestContext { - mockStore := &database.MockStore{} + mockStore := &arabicastore.MockStore{} fixtures := NewTestFixtures() base := handlers.NewHandler(nil, nil, nil, nil, nil, handlers.Config{SecureCookies: false}) @@ -117,7 +117,9 @@ func NewTestContext() *TestContext { func NewAuthenticatedRequest(method, path string, body any) *http.Request { req := httptest.NewRequest(method, path, nil) - // Add authenticated DID to context using the same keys as atp/middleware.CookieAuth + // Add authenticated DID to context using the legacy string keys. Most + // historical tests intentionally don't satisfy atp/middleware.GetDID, so + // handlers take the unauthenticated path instead of constructing real stores. ctx := context.WithValue(req.Context(), "atp_did", "did:plc:test123456789") ctx = context.WithValue(ctx, "atp_session_id", "test-session-id") diff --git a/internal/database/store.go b/internal/arabica/store/store.go similarity index 99% rename from internal/database/store.go rename to internal/arabica/store/store.go index cd81d2a..5709ba3 100644 --- a/internal/database/store.go +++ b/internal/arabica/store/store.go @@ -1,4 +1,4 @@ -package database +package arabicastore import ( "context" diff --git a/internal/database/store_mock.go b/internal/arabica/store/store_mock.go similarity index 59% rename from internal/database/store_mock.go rename to internal/arabica/store/store_mock.go index 786067c..dca160f 100644 --- a/internal/database/store_mock.go +++ b/internal/arabica/store/store_mock.go @@ -1,9 +1,10 @@ -package database +package arabicastore import ( "context" "tangled.org/arabica.social/arabica/internal/arabica/entities" + "tangled.org/arabica.social/arabica/internal/records" ) // MockStore is a mock implementation of the Store interface for testing. @@ -39,6 +40,28 @@ type MockStore struct { UpdateBrewerByRKeyFunc func(ctx context.Context, rkey string, brewer *arabica.UpdateBrewerRequest) error DeleteBrewerByRKeyFunc func(ctx context.Context, rkey string) error + CreateRecipeFunc func(ctx context.Context, recipe *arabica.CreateRecipeRequest) (*arabica.Recipe, error) + GetRecipeByRKeyFunc func(ctx context.Context, rkey string) (*arabica.Recipe, error) + ListRecipesFunc func(ctx context.Context) ([]*arabica.Recipe, error) + UpdateRecipeByRKeyFunc func(ctx context.Context, rkey string, recipe *arabica.UpdateRecipeRequest) error + DeleteRecipeByRKeyFunc func(ctx context.Context, rkey string) error + + CreateLikeFunc func(ctx context.Context, req *arabica.CreateLikeRequest) (*arabica.Like, error) + DeleteLikeByRKeyFunc func(ctx context.Context, rkey string) error + GetUserLikeForSubjectFunc func(ctx context.Context, subjectURI string) (*arabica.Like, error) + ListUserLikesFunc func(ctx context.Context) ([]*arabica.Like, error) + + CreateCommentFunc func(ctx context.Context, req *arabica.CreateCommentRequest) (*arabica.Comment, error) + DeleteCommentByRKeyFunc func(ctx context.Context, rkey string) error + GetCommentsForSubjectFunc func(ctx context.Context, subjectURI string) ([]*arabica.Comment, error) + ListUserCommentsFunc func(ctx context.Context) ([]*arabica.Comment, error) + + DIDFunc func() string + FetchRecordFunc func(ctx context.Context, nsid, rkey string) (record map[string]any, uri, cid string, err error) + FetchAllRecordsFunc func(ctx context.Context, nsid string) ([]records.RawRecord, error) + PutRecordFunc func(ctx context.Context, nsid, rkey string, record any) (resultRKey, cid string, err error) + RemoveRecordFunc func(ctx context.Context, nsid, rkey string) error + CloseFunc func() error } @@ -218,6 +241,135 @@ func (m *MockStore) DeleteBrewerByRKey(ctx context.Context, rkey string) error { return nil } +func (m *MockStore) CreateRecipe(ctx context.Context, recipe *arabica.CreateRecipeRequest) (*arabica.Recipe, error) { + if m.CreateRecipeFunc != nil { + return m.CreateRecipeFunc(ctx, recipe) + } + return nil, nil +} + +func (m *MockStore) GetRecipeByRKey(ctx context.Context, rkey string) (*arabica.Recipe, error) { + if m.GetRecipeByRKeyFunc != nil { + return m.GetRecipeByRKeyFunc(ctx, rkey) + } + return nil, nil +} + +func (m *MockStore) ListRecipes(ctx context.Context) ([]*arabica.Recipe, error) { + if m.ListRecipesFunc != nil { + return m.ListRecipesFunc(ctx) + } + return []*arabica.Recipe{}, nil +} + +func (m *MockStore) UpdateRecipeByRKey(ctx context.Context, rkey string, recipe *arabica.UpdateRecipeRequest) error { + if m.UpdateRecipeByRKeyFunc != nil { + return m.UpdateRecipeByRKeyFunc(ctx, rkey, recipe) + } + return nil +} + +func (m *MockStore) DeleteRecipeByRKey(ctx context.Context, rkey string) error { + if m.DeleteRecipeByRKeyFunc != nil { + return m.DeleteRecipeByRKeyFunc(ctx, rkey) + } + return nil +} + +func (m *MockStore) CreateLike(ctx context.Context, req *arabica.CreateLikeRequest) (*arabica.Like, error) { + if m.CreateLikeFunc != nil { + return m.CreateLikeFunc(ctx, req) + } + return nil, nil +} + +func (m *MockStore) DeleteLikeByRKey(ctx context.Context, rkey string) error { + if m.DeleteLikeByRKeyFunc != nil { + return m.DeleteLikeByRKeyFunc(ctx, rkey) + } + return nil +} + +func (m *MockStore) GetUserLikeForSubject(ctx context.Context, subjectURI string) (*arabica.Like, error) { + if m.GetUserLikeForSubjectFunc != nil { + return m.GetUserLikeForSubjectFunc(ctx, subjectURI) + } + return nil, nil +} + +func (m *MockStore) ListUserLikes(ctx context.Context) ([]*arabica.Like, error) { + if m.ListUserLikesFunc != nil { + return m.ListUserLikesFunc(ctx) + } + return []*arabica.Like{}, nil +} + +func (m *MockStore) CreateComment(ctx context.Context, req *arabica.CreateCommentRequest) (*arabica.Comment, error) { + if m.CreateCommentFunc != nil { + return m.CreateCommentFunc(ctx, req) + } + return nil, nil +} + +func (m *MockStore) DeleteCommentByRKey(ctx context.Context, rkey string) error { + if m.DeleteCommentByRKeyFunc != nil { + return m.DeleteCommentByRKeyFunc(ctx, rkey) + } + return nil +} + +func (m *MockStore) GetCommentsForSubject(ctx context.Context, subjectURI string) ([]*arabica.Comment, error) { + if m.GetCommentsForSubjectFunc != nil { + return m.GetCommentsForSubjectFunc(ctx, subjectURI) + } + return []*arabica.Comment{}, nil +} + +func (m *MockStore) ListUserComments(ctx context.Context) ([]*arabica.Comment, error) { + if m.ListUserCommentsFunc != nil { + return m.ListUserCommentsFunc(ctx) + } + return []*arabica.Comment{}, nil +} + +func (m *MockStore) DID() string { + if m.DIDFunc != nil { + return m.DIDFunc() + } + return "did:plc:test123456789" +} + +func (m *MockStore) FetchRecord(ctx context.Context, nsid, rkey string) (map[string]any, string, string, error) { + if m.FetchRecordFunc != nil { + return m.FetchRecordFunc(ctx, nsid, rkey) + } + return nil, "", "", nil +} + +func (m *MockStore) FetchAllRecords(ctx context.Context, nsid string) ([]records.RawRecord, error) { + if m.FetchAllRecordsFunc != nil { + return m.FetchAllRecordsFunc(ctx, nsid) + } + return nil, nil +} + +func (m *MockStore) PutRecord(ctx context.Context, nsid, rkey string, record any) (string, string, error) { + if m.PutRecordFunc != nil { + return m.PutRecordFunc(ctx, nsid, rkey, record) + } + if rkey != "" { + return rkey, "", nil + } + return "test-rkey", "test-cid", nil +} + +func (m *MockStore) RemoveRecord(ctx context.Context, nsid, rkey string) error { + if m.RemoveRecordFunc != nil { + return m.RemoveRecordFunc(ctx, nsid, rkey) + } + return nil +} + func (m *MockStore) Close() error { if m.CloseFunc != nil { return m.CloseFunc() diff --git a/internal/atplatform/apps/apps.go b/internal/atplatform/apps/apps.go new file mode 100644 index 0000000..f6fbd70 --- /dev/null +++ b/internal/atplatform/apps/apps.go @@ -0,0 +1,41 @@ +// Package apps constructs the product configurations served by the shared +// AT-platform bootstrap. +package apps + +import ( + arabica "tangled.org/arabica.social/arabica/internal/arabica/entities" + "tangled.org/arabica.social/arabica/internal/atplatform/domain" + "tangled.org/arabica.social/arabica/internal/entities" + oolong "tangled.org/arabica.social/arabica/internal/oolong/entities" + + // Blank imports run init() in each app's web/components package, wiring + // per-app templ render hooks onto descriptors. + _ "tangled.org/arabica.social/arabica/internal/arabica/web/components" + _ "tangled.org/arabica.social/arabica/internal/oolong/web/components" +) + +// NewArabica builds the App value for the coffee-tracking app. +func NewArabica() *domain.App { + return &domain.App{ + Name: "arabica", + NSIDBase: arabica.NSIDBase, + Descriptors: entities.AllForApp(arabica.NSIDBase), + Brand: domain.BrandConfig{ + DisplayName: "Arabica", + Tagline: "Your brew, your data", + }, + } +} + +// NewOolong builds the App value for the tea-tracking app. +func NewOolong() *domain.App { + return &domain.App{ + Name: "oolong", + NSIDBase: oolong.NSIDBase, + Descriptors: entities.AllForApp(oolong.NSIDBase), + Brand: domain.BrandConfig{ + DisplayName: "Oolong", + Tagline: "Your tea, your data", + }, + } +} diff --git a/internal/atplatform/server/server.go b/internal/atplatform/server/server.go index c201437..d51d2f5 100644 --- a/internal/atplatform/server/server.go +++ b/internal/atplatform/server/server.go @@ -28,6 +28,7 @@ import ( "tangled.org/arabica.social/arabica/internal/moderation" moderationsqlite "tangled.org/arabica.social/arabica/internal/moderation/sqlite" "tangled.org/arabica.social/arabica/internal/routing" + "tangled.org/arabica.social/arabica/internal/sqlitestore" "tangled.org/arabica.social/arabica/internal/tracing" "tangled.org/arabica.social/arabica/internal/web/assets" "tangled.org/pdewey.com/atp" diff --git a/internal/atproto/store.go b/internal/atproto/store.go index 9d8c55d..124e1be 100644 --- a/internal/atproto/store.go +++ b/internal/atproto/store.go @@ -7,14 +7,14 @@ import ( "time" "tangled.org/arabica.social/arabica/internal/arabica/entities" - "tangled.org/arabica.social/arabica/internal/database" + arabicastore "tangled.org/arabica.social/arabica/internal/arabica/store" "tangled.org/pdewey.com/atp" "github.com/bluesky-social/indigo/atproto/syntax" "github.com/rs/zerolog/log" ) -// AtprotoStore implements the database.Store interface using atproto records. +// AtprotoStore implements the arabicastore.Store interface using atproto records. // Context is passed as a parameter to each method rather than stored in the struct, // following Go best practices for context propagation. type AtprotoStore struct { @@ -34,7 +34,7 @@ type AtprotoStore struct { // NewAtprotoStore creates a new atproto store for a specific user session. // The cache parameter allows for dependency injection and testability. -func NewAtprotoStore(client *Client, did syntax.DID, sessionID string, cache *SessionCache) database.Store { +func NewAtprotoStore(client *Client, did syntax.DID, sessionID string, cache *SessionCache) arabicastore.Store { return &AtprotoStore{ client: client, did: did, @@ -45,7 +45,7 @@ func NewAtprotoStore(client *Client, did syntax.DID, sessionID string, cache *Se // NewAtprotoStoreWithWitness creates a store that uses the witness cache for // cache-first reads, falling back to the PDS on cache misses. -func NewAtprotoStoreWithWitness(client *Client, did syntax.DID, sessionID string, cache *SessionCache, witness WitnessCache) database.Store { +func NewAtprotoStoreWithWitness(client *Client, did syntax.DID, sessionID string, cache *SessionCache, witness WitnessCache) arabicastore.Store { return &AtprotoStore{ client: client, did: did, @@ -57,7 +57,7 @@ func NewAtprotoStoreWithWitness(client *Client, did syntax.DID, sessionID string // NewAtprotoStoreForApp builds a store wired with per-app like/comment NSIDs. // Pass empty strings to fall back to arabica's defaults. -func NewAtprotoStoreForApp(client *Client, did syntax.DID, sessionID string, cache *SessionCache, witness WitnessCache, likeNSID, commentNSID string) database.Store { +func NewAtprotoStoreForApp(client *Client, did syntax.DID, sessionID string, cache *SessionCache, witness WitnessCache, likeNSID, commentNSID string) arabicastore.Store { return &AtprotoStore{ client: client, did: did, diff --git a/internal/atproto/store_generic.go b/internal/atproto/store_generic.go index 13d6692..e7e1224 100644 --- a/internal/atproto/store_generic.go +++ b/internal/atproto/store_generic.go @@ -8,6 +8,7 @@ import ( "github.com/rs/zerolog/log" "tangled.org/arabica.social/arabica/internal/arabica/entities" "tangled.org/arabica.social/arabica/internal/metrics" + "tangled.org/arabica.social/arabica/internal/records" ) // rawRecord is what fetchAllRecords returns: a parsed record map plus the @@ -74,12 +75,9 @@ func (s *AtprotoStore) FetchAllRecords(ctx context.Context, nsid string) ([]RawR // RawRecord is the exported shape of fetchAllRecords' result rows. // Callers use Record to decode into a typed model. -type RawRecord struct { - URI string - RKey string - CID string - Record map[string]any -} +type RawRecord = records.RawRecord + +var _ records.Store = (*AtprotoStore)(nil) // fetchRecord returns the record at nsid/rkey, hitting the witness cache // first and falling back to PDS. The "hit" return is true when the record diff --git a/internal/handlers/entity_view_helpers.go b/internal/handlers/entity_view_helpers.go index eefd253..14bb7c7 100644 --- a/internal/handlers/entity_view_helpers.go +++ b/internal/handlers/entity_view_helpers.go @@ -7,6 +7,7 @@ import ( atp "tangled.org/pdewey.com/atp" "tangled.org/arabica.social/arabica/internal/atproto" + "tangled.org/arabica.social/arabica/internal/records" ) // ListRecords fetches every record of nsid the authenticated user owns @@ -16,7 +17,7 @@ import ( // Not tied to a particular app; works for any (nsid, decoder) pair. func ListRecords[T any]( ctx context.Context, - store *atproto.AtprotoStore, + store records.Store, nsid string, decode func(map[string]any, string) (*T, error), ) []*T { diff --git a/internal/handlers/handlers.go b/internal/handlers/handlers.go index 4fabadc..67fecdc 100644 --- a/internal/handlers/handlers.go +++ b/internal/handlers/handlers.go @@ -10,6 +10,7 @@ import ( "strings" arabica "tangled.org/arabica.social/arabica/internal/arabica/entities" + arabicastore "tangled.org/arabica.social/arabica/internal/arabica/store" "tangled.org/arabica.social/arabica/internal/atplatform/domain" "tangled.org/arabica.social/arabica/internal/atproto" "tangled.org/arabica.social/arabica/internal/backup" @@ -94,6 +95,17 @@ type Handler struct { staticPages StaticPageRenderers homeBehavior HomeBehavior assets assets.Manifest + + // storeOverride supports focused handler tests without constructing an + // OAuth-backed ATProto client. Production code leaves it nil. + storeOverride arabicastore.Store +} + +// SetStoreOverrideForTest injects a request-scoped store for handler tests. +// Authentication context is still required; only the concrete store creation is +// bypassed. Passing nil clears the override. +func (h *Handler) SetStoreOverrideForTest(store arabicastore.Store) { + h.storeOverride = store } // SetStaticPageRenderers wires app-owned static page templates into the shared @@ -369,7 +381,7 @@ func (h *Handler) GetUserProfile(ctx context.Context, did string) *bff.UserProfi // getAtprotoStore creates a user-scoped atproto store from the request context. // Returns the store and true if authenticated, or nil and false if not authenticated. -func (h *Handler) GetAtprotoStore(r *http.Request) (database.Store, bool) { +func (h *Handler) GetAtprotoStore(r *http.Request) (arabicastore.Store, bool) { // Get authenticated DID from context didStr, ok := atpmiddleware.GetDID(r.Context()) if !ok { @@ -387,6 +399,9 @@ func (h *Handler) GetAtprotoStore(r *http.Request) (database.Store, bool) { if !ok { return nil, false } + if h.storeOverride != nil { + return h.storeOverride, true + } // Create user-scoped atproto store with injected cache. App-specific // social NSIDs are plumbed in so oolong stores write to diff --git a/internal/handlers/record_crud.go b/internal/handlers/record_crud.go new file mode 100644 index 0000000..9f58f09 --- /dev/null +++ b/internal/handlers/record_crud.go @@ -0,0 +1,130 @@ +package handlers + +import ( + "context" + "fmt" + "net/http" + "time" + + "github.com/rs/zerolog/log" + "tangled.org/arabica.social/arabica/internal/records" +) + +// RequireRecordStore returns the authenticated request's app-agnostic record +// store, writing the appropriate HTTP error when auth or store wiring fails. +func (h *Handler) RequireRecordStore(w http.ResponseWriter, r *http.Request) (records.Store, bool) { + store, authenticated := h.GetAtprotoStore(r) + if !authenticated { + http.Error(w, "Authentication required", http.StatusUnauthorized) + return nil, false + } + recordStore, ok := store.(records.Store) + if !ok { + http.Error(w, "Internal error", http.StatusInternalServerError) + return nil, false + } + return recordStore, true +} + +// ExistingCreatedAt returns the createdAt timestamp of an existing record so +// update handlers can preserve it. It falls back to time.Now when the record +// cannot be fetched or contains an invalid timestamp. +func ExistingCreatedAt(ctx context.Context, store records.Store, nsid, rkey string) time.Time { + rec, _, _, err := store.FetchRecord(ctx, nsid, rkey) + if err != nil { + return time.Now() + } + s, ok := rec["createdAt"].(string) + if !ok { + return time.Now() + } + t, err := time.Parse(time.RFC3339, s) + if err != nil { + return time.Now() + } + return t +} + +// RequestValidator is a pointer constraint for request types with Validate. +type RequestValidator[T any] interface { + *T + Validate() error +} + +// PutRecord is the shared create/update primitive for handlers that encode a +// typed model into an app record and write it through a generic record store. +func PutRecord( + ctx context.Context, + store records.Store, + nsid, rkey string, + encode func(records.Store) (map[string]any, error), +) (resultRKey string, err error) { + rec, err := encode(store) + if err != nil { + return "", err + } + newRKey, _, err := store.PutRecord(ctx, nsid, rkey, rec) + if err != nil { + return "", err + } + if newRKey == "" { + newRKey = rkey + } + return newRKey, nil +} + +// RecordCRUDWrite is the common body for standard entity Create + Update +// handlers: decode, validate, build model, encode to AT Protocol record, +// write, invalidate, and respond with JSON or an optional HX redirect. +func RecordCRUDWrite[Req any, PReq RequestValidator[Req], Model any]( + w http.ResponseWriter, + r *http.Request, + store records.Store, + nsid, jsonKey, rkey string, + decodeForm func(*http.Request, *Req) error, + build func(req *Req) *Model, + setRKey func(*Model, string), + encode func(s records.Store, req *Req, m *Model) (map[string]any, error), + invalidate func(), + allowRedirect bool, +) { + if store == nil { + http.Error(w, "Authentication required", http.StatusUnauthorized) + return + } + var req Req + if err := DecodeRequest(r, &req, func() error { + if decodeForm == nil { + return fmt.Errorf("form decoding is not configured") + } + return decodeForm(r, &req) + }); err != nil { + http.Error(w, "Invalid request body", http.StatusBadRequest) + return + } + if err := PReq(&req).Validate(); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + model := build(&req) + newRKey, err := PutRecord(r.Context(), store, nsid, rkey, func(s records.Store) (map[string]any, error) { + return encode(s, &req, model) + }) + if err != nil { + log.Error().Err(err).Str("rkey", rkey).Str("nsid", nsid).Msgf("Failed to write %s", jsonKey) + HandleStoreError(w, err, "Failed to save "+jsonKey) + return + } + setRKey(model, newRKey) + if invalidate != nil { + invalidate() + } + if allowRedirect { + if redirect := r.FormValue("__redirect"); redirect != "" { + w.Header().Set("HX-Redirect", redirect) + w.WriteHeader(http.StatusOK) + return + } + } + WriteJSON(w, model, jsonKey) +} diff --git a/internal/handlers/record_crud_test.go b/internal/handlers/record_crud_test.go new file mode 100644 index 0000000..74181b8 --- /dev/null +++ b/internal/handlers/record_crud_test.go @@ -0,0 +1,163 @@ +package handlers + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "tangled.org/arabica.social/arabica/internal/records" +) + +type crudTestStore struct { + putErr error + putNSID string + putRKey string + putRecord any +} + +func (s *crudTestStore) DID() string { return "did:plc:test" } + +func (s *crudTestStore) FetchRecord(context.Context, string, string) (map[string]any, string, string, error) { + return nil, "", "", nil +} + +func (s *crudTestStore) FetchAllRecords(context.Context, string) ([]records.RawRecord, error) { + return nil, nil +} + +func (s *crudTestStore) PutRecord(_ context.Context, nsid, rkey string, record any) (string, string, error) { + s.putNSID = nsid + s.putRKey = rkey + s.putRecord = record + if s.putErr != nil { + return "", "", s.putErr + } + if rkey == "" { + return "new-rkey", "cid", nil + } + return "", "", nil +} + +func (s *crudTestStore) RemoveRecord(context.Context, string, string) error { return nil } + +type crudTestRequest struct { + Name string `json:"name"` +} + +func (r *crudTestRequest) Validate() error { + if r.Name == "" { + return errors.New("name is required") + } + return nil +} + +type crudTestModel struct { + RKey string `json:"rkey"` + Name string `json:"name"` +} + +func TestRecordCRUDWriteCreateSuccess(t *testing.T) { + store := &crudTestStore{} + var invalidated bool + req := httptest.NewRequest(http.MethodPost, "/api/things", strings.NewReader(`{"name":"thing"}`)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + + RecordCRUDWrite[crudTestRequest, *crudTestRequest, crudTestModel]( + w, req, store, "social.test.thing", "thing", "", nil, + func(req *crudTestRequest) *crudTestModel { return &crudTestModel{Name: req.Name} }, + func(m *crudTestModel, rkey string) { m.RKey = rkey }, + func(_ records.Store, _ *crudTestRequest, m *crudTestModel) (map[string]any, error) { + return map[string]any{"name": m.Name}, nil + }, + func() { invalidated = true }, false, + ) + + assert.Equal(t, http.StatusOK, w.Code) + assert.Contains(t, w.Body.String(), `"rkey":"new-rkey"`) + assert.True(t, invalidated) + assert.Equal(t, "social.test.thing", store.putNSID) + assert.Equal(t, "", store.putRKey) + assert.Equal(t, map[string]any{"name": "thing"}, store.putRecord) +} + +func TestRecordCRUDWriteUpdateSuccessFallsBackToExistingRKey(t *testing.T) { + store := &crudTestStore{} + req := httptest.NewRequest(http.MethodPut, "/api/things/existing", strings.NewReader(`{"name":"updated"}`)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + + RecordCRUDWrite[crudTestRequest, *crudTestRequest, crudTestModel]( + w, req, store, "social.test.thing", "thing", "existing", nil, + func(req *crudTestRequest) *crudTestModel { return &crudTestModel{Name: req.Name} }, + func(m *crudTestModel, rkey string) { m.RKey = rkey }, + func(_ records.Store, _ *crudTestRequest, m *crudTestModel) (map[string]any, error) { + return map[string]any{"name": m.Name}, nil + }, + nil, false, + ) + + assert.Equal(t, http.StatusOK, w.Code) + assert.Contains(t, w.Body.String(), `"rkey":"existing"`) + assert.Equal(t, "existing", store.putRKey) +} + +func TestRecordCRUDWriteDecodeError(t *testing.T) { + store := &crudTestStore{} + req := httptest.NewRequest(http.MethodPost, "/api/things", strings.NewReader(`{"name"`)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + + RecordCRUDWrite[crudTestRequest, *crudTestRequest, crudTestModel]( + w, req, store, "social.test.thing", "thing", "", nil, + func(req *crudTestRequest) *crudTestModel { return &crudTestModel{Name: req.Name} }, + func(m *crudTestModel, rkey string) { m.RKey = rkey }, + func(_ records.Store, _ *crudTestRequest, _ *crudTestModel) (map[string]any, error) { return nil, nil }, + nil, false, + ) + + assert.Equal(t, http.StatusBadRequest, w.Code) + assert.Contains(t, w.Body.String(), "Invalid request body") +} + +func TestRecordCRUDWriteValidationError(t *testing.T) { + store := &crudTestStore{} + req := httptest.NewRequest(http.MethodPost, "/api/things", strings.NewReader(`{"name":""}`)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + + RecordCRUDWrite[crudTestRequest, *crudTestRequest, crudTestModel]( + w, req, store, "social.test.thing", "thing", "", nil, + func(req *crudTestRequest) *crudTestModel { return &crudTestModel{Name: req.Name} }, + func(m *crudTestModel, rkey string) { m.RKey = rkey }, + func(_ records.Store, _ *crudTestRequest, _ *crudTestModel) (map[string]any, error) { return nil, nil }, + nil, false, + ) + + assert.Equal(t, http.StatusBadRequest, w.Code) + assert.Contains(t, w.Body.String(), "name is required") +} + +func TestRecordCRUDWriteStoreError(t *testing.T) { + store := &crudTestStore{putErr: errors.New("boom")} + req := httptest.NewRequest(http.MethodPost, "/api/things", strings.NewReader(`{"name":"thing"}`)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + + RecordCRUDWrite[crudTestRequest, *crudTestRequest, crudTestModel]( + w, req, store, "social.test.thing", "thing", "", nil, + func(req *crudTestRequest) *crudTestModel { return &crudTestModel{Name: req.Name} }, + func(m *crudTestModel, rkey string) { m.RKey = rkey }, + func(_ records.Store, _ *crudTestRequest, m *crudTestModel) (map[string]any, error) { + return map[string]any{"name": m.Name}, nil + }, + nil, false, + ) + + assert.Equal(t, http.StatusInternalServerError, w.Code) + assert.Contains(t, w.Body.String(), "Failed to save thing") +} diff --git a/internal/logging/config.go b/internal/logging/config.go new file mode 100644 index 0000000..1d9f05c --- /dev/null +++ b/internal/logging/config.go @@ -0,0 +1,37 @@ +package logging + +import ( + "io" + "log/slog" + "os" + "time" + + "github.com/rs/zerolog" + "github.com/rs/zerolog/log" +) + +// ConfigureFromEnv configures zerolog and the standard slog bridge from the +// LOG_LEVEL and LOG_FORMAT environment variables. +func ConfigureFromEnv(out io.Writer) { + switch os.Getenv("LOG_LEVEL") { + case "debug": + zerolog.SetGlobalLevel(zerolog.DebugLevel) + case "warn": + zerolog.SetGlobalLevel(zerolog.WarnLevel) + case "error": + zerolog.SetGlobalLevel(zerolog.ErrorLevel) + default: + zerolog.SetGlobalLevel(zerolog.InfoLevel) + } + + if os.Getenv("LOG_FORMAT") == "json" { + log.Logger = zerolog.New(out).With().Timestamp().Logger() + } else { + log.Logger = log.Output(zerolog.ConsoleWriter{ + Out: out, + TimeFormat: time.RFC3339, + }) + } + + slog.SetDefault(slog.New(NewZerologHandler(log.Logger))) +} diff --git a/internal/oolong/handlers/api.go b/internal/oolong/handlers/api.go index c9a9c1f..f44ce41 100644 --- a/internal/oolong/handlers/api.go +++ b/internal/oolong/handlers/api.go @@ -18,7 +18,7 @@ import ( // AppCache understands. The combo-select system reads this cache // to filter user-owned entries during typeahead. func (h *Handlers) HandleOolongAPIListAll(w http.ResponseWriter, r *http.Request) { - store, ok := h.requireOolongStore(w, r) + store, ok := h.RequireRecordStore(w, r) if !ok { return } diff --git a/internal/oolong/handlers/crud.go b/internal/oolong/handlers/crud.go index c4ca5fe..f26d371 100644 --- a/internal/oolong/handlers/crud.go +++ b/internal/oolong/handlers/crud.go @@ -1,7 +1,6 @@ package teahandlers import ( - "context" "net/http" "reflect" "strconv" @@ -11,9 +10,9 @@ import ( "github.com/rs/zerolog/log" atp "tangled.org/pdewey.com/atp" - "tangled.org/arabica.social/arabica/internal/atproto" "tangled.org/arabica.social/arabica/internal/handlers" oolong "tangled.org/arabica.social/arabica/internal/oolong/entities" + "tangled.org/arabica.social/arabica/internal/records" ) // Oolong CRUD handlers. Each Create handler decodes the request, builds @@ -22,66 +21,10 @@ import ( // same path with a non-empty rkey. Delete extracts the rkey from the // URL and calls store.RemoveRecord. -// existingOolongCreatedAt returns the createdAt timestamp of an existing -// record so Update handlers can preserve it instead of stamping time.Now(). -// Falls back to time.Now() if the record can't be fetched or lacks a valid -// createdAt — this keeps Update working even if the record was corrupted. -func existingOolongCreatedAt(ctx context.Context, store *atproto.AtprotoStore, nsid, rkey string) time.Time { - rec, _, _, err := store.FetchRecord(ctx, nsid, rkey) - if err != nil { - return time.Now() - } - s, ok := rec["createdAt"].(string) - if !ok { - return time.Now() - } - t, err := time.Parse(time.RFC3339, s) - if err != nil { - return time.Now() - } - return t -} - -// putOolongRecord is the shared write path for all oolong CRUD Create -// and Update endpoints. -func putOolongRecord( - ctx context.Context, - store *atproto.AtprotoStore, - nsid, rkey string, - encode func(s *atproto.AtprotoStore) (map[string]any, error), -) (resultRKey string, err error) { - rec, err := encode(store) - if err != nil { - return "", err - } - newRKey, _, err := store.PutRecord(ctx, nsid, rkey, rec) - if err != nil { - return "", err - } - if newRKey == "" { - newRKey = rkey - } - return newRKey, nil -} - -func (h *Handlers) requireOolongStore(w http.ResponseWriter, r *http.Request) (*atproto.AtprotoStore, bool) { - store, authenticated := h.GetAtprotoStore(r) - if !authenticated { - http.Error(w, "Authentication required", http.StatusUnauthorized) - return nil, false - } - atpStore, ok := store.(*atproto.AtprotoStore) - if !ok { - http.Error(w, "Internal error", http.StatusInternalServerError) - return nil, false - } - return atpStore, true -} - // --- Tea ------------------------------------------------------------- func (h *Handlers) HandleTeaCreate(w http.ResponseWriter, r *http.Request) { - store, ok := h.requireOolongStore(w, r) + store, ok := h.RequireRecordStore(w, r) if !ok { return } @@ -95,7 +38,7 @@ func (h *Handlers) HandleTeaCreate(w http.ResponseWriter, r *http.Request) { return } tea := teaFromCreateRequest(&req) - rkey, err := putOolongRecord(r.Context(), store, oolong.NSIDTea, "", func(s *atproto.AtprotoStore) (map[string]any, error) { + rkey, err := handlers.PutRecord(r.Context(), store, oolong.NSIDTea, "", func(s records.Store) (map[string]any, error) { var vendorURI string if req.VendorRKey != "" { vendorURI = atp.BuildATURI(s.DID(), oolong.NSIDVendor, req.VendorRKey) @@ -118,7 +61,7 @@ func (h *Handlers) HandleTeaCreate(w http.ResponseWriter, r *http.Request) { } func (h *Handlers) HandleTeaUpdate(w http.ResponseWriter, r *http.Request) { - store, ok := h.requireOolongStore(w, r) + store, ok := h.RequireRecordStore(w, r) if !ok { return } @@ -138,8 +81,8 @@ func (h *Handlers) HandleTeaUpdate(w http.ResponseWriter, r *http.Request) { } tea := teaFromCreateRequest(&createReq) tea.RKey = rkey - tea.CreatedAt = existingOolongCreatedAt(r.Context(), store, oolong.NSIDTea, rkey) - if _, err := putOolongRecord(r.Context(), store, oolong.NSIDTea, rkey, func(s *atproto.AtprotoStore) (map[string]any, error) { + tea.CreatedAt = handlers.ExistingCreatedAt(r.Context(), store, oolong.NSIDTea, rkey) + if _, err := handlers.PutRecord(r.Context(), store, oolong.NSIDTea, rkey, func(s records.Store) (map[string]any, error) { var vendorURI string if createReq.VendorRKey != "" { vendorURI = atp.BuildATURI(s.DID(), oolong.NSIDVendor, createReq.VendorRKey) @@ -199,11 +142,11 @@ func vendorFromUpdate(req *oolong.UpdateVendorRequest) *oolong.Vendor { func setVendorRKey(v *oolong.Vendor, rkey string) { v.RKey = rkey } -func encodeVendor(_ *atproto.AtprotoStore, _ *oolong.CreateVendorRequest, m *oolong.Vendor) (map[string]any, error) { +func encodeVendor(_ records.Store, _ *oolong.CreateVendorRequest, m *oolong.Vendor) (map[string]any, error) { return oolong.VendorToRecord(m) } -func encodeVendorUpdate(_ *atproto.AtprotoStore, _ *oolong.UpdateVendorRequest, m *oolong.Vendor) (map[string]any, error) { +func encodeVendorUpdate(_ records.Store, _ *oolong.UpdateVendorRequest, m *oolong.Vendor) (map[string]any, error) { return oolong.VendorToRecord(m) } @@ -251,11 +194,11 @@ func vesselFromUpdate(req *oolong.UpdateVesselRequest) *oolong.Vessel { func setVesselRKey(v *oolong.Vessel, rkey string) { v.RKey = rkey } -func encodeVessel(_ *atproto.AtprotoStore, _ *oolong.CreateVesselRequest, m *oolong.Vessel) (map[string]any, error) { +func encodeVessel(_ records.Store, _ *oolong.CreateVesselRequest, m *oolong.Vessel) (map[string]any, error) { return oolong.VesselToRecord(m) } -func encodeVesselUpdate(_ *atproto.AtprotoStore, _ *oolong.UpdateVesselRequest, m *oolong.Vessel) (map[string]any, error) { +func encodeVesselUpdate(_ records.Store, _ *oolong.UpdateVesselRequest, m *oolong.Vessel) (map[string]any, error) { return oolong.VesselToRecord(m) } @@ -303,11 +246,11 @@ func infuserFromUpdate(req *oolong.UpdateInfuserRequest) *oolong.Infuser { func setInfuserRKey(v *oolong.Infuser, rkey string) { v.RKey = rkey } -func encodeInfuser(_ *atproto.AtprotoStore, _ *oolong.CreateInfuserRequest, m *oolong.Infuser) (map[string]any, error) { +func encodeInfuser(_ records.Store, _ *oolong.CreateInfuserRequest, m *oolong.Infuser) (map[string]any, error) { return oolong.InfuserToRecord(m) } -func encodeInfuserUpdate(_ *atproto.AtprotoStore, _ *oolong.UpdateInfuserRequest, m *oolong.Infuser) (map[string]any, error) { +func encodeInfuserUpdate(_ records.Store, _ *oolong.UpdateInfuserRequest, m *oolong.Infuser) (map[string]any, error) { return oolong.InfuserToRecord(m) } @@ -341,7 +284,7 @@ func (h *Handlers) HandleOolongInfuserDelete(w http.ResponseWriter, r *http.Requ // --- Brew (steep) ---------------------------------------------------- func (h *Handlers) HandleOolongBrewCreate(w http.ResponseWriter, r *http.Request) { - store, ok := h.requireOolongStore(w, r) + store, ok := h.RequireRecordStore(w, r) if !ok { return } @@ -355,7 +298,7 @@ func (h *Handlers) HandleOolongBrewCreate(w http.ResponseWriter, r *http.Request return } b := brewFromCreateRequest(&req) - rkey, err := putOolongRecord(r.Context(), store, oolong.NSIDBrew, "", func(s *atproto.AtprotoStore) (map[string]any, error) { + rkey, err := handlers.PutRecord(r.Context(), store, oolong.NSIDBrew, "", func(s records.Store) (map[string]any, error) { teaURI := buildOolongRef(s, req.TeaRKey, oolong.NSIDTea) vesselURI := buildOolongRef(s, req.VesselRKey, oolong.NSIDVessel) infuserURI := buildOolongRef(s, req.InfuserRKey, oolong.NSIDInfuser) @@ -377,7 +320,7 @@ func (h *Handlers) HandleOolongBrewCreate(w http.ResponseWriter, r *http.Request } func (h *Handlers) HandleOolongBrewUpdate(w http.ResponseWriter, r *http.Request) { - store, ok := h.requireOolongStore(w, r) + store, ok := h.RequireRecordStore(w, r) if !ok { return } @@ -396,8 +339,8 @@ func (h *Handlers) HandleOolongBrewUpdate(w http.ResponseWriter, r *http.Request } b := brewFromCreateRequest(&req) b.RKey = rkey - b.CreatedAt = existingOolongCreatedAt(r.Context(), store, oolong.NSIDBrew, rkey) - if _, err := putOolongRecord(r.Context(), store, oolong.NSIDBrew, rkey, func(s *atproto.AtprotoStore) (map[string]any, error) { + b.CreatedAt = handlers.ExistingCreatedAt(r.Context(), store, oolong.NSIDBrew, rkey) + if _, err := handlers.PutRecord(r.Context(), store, oolong.NSIDBrew, rkey, func(s records.Store) (map[string]any, error) { teaURI := buildOolongRef(s, req.TeaRKey, oolong.NSIDTea) vesselURI := buildOolongRef(s, req.VesselRKey, oolong.NSIDVessel) infuserURI := buildOolongRef(s, req.InfuserRKey, oolong.NSIDInfuser) @@ -447,7 +390,7 @@ func brewFromCreateRequest(req *oolong.CreateBrewRequest) *oolong.Brew { // handleOolongDelete is the shared body for every oolong Delete handler. func (h *Handlers) handleOolongDelete(w http.ResponseWriter, r *http.Request, nsid, entityName string) { - store, ok := h.requireOolongStore(w, r) + store, ok := h.RequireRecordStore(w, r) if !ok { return } @@ -466,7 +409,7 @@ func (h *Handlers) handleOolongDelete(w http.ResponseWriter, r *http.Request, ns // buildOolongRef returns the AT-URI for a referenced entity owned by the // authenticated user, or "" if the rkey is empty. -func buildOolongRef(s *atproto.AtprotoStore, rkey, nsid string) string { +func buildOolongRef(s records.Store, rkey, nsid string) string { if rkey == "" { return "" } diff --git a/internal/oolong/handlers/crud_generic.go b/internal/oolong/handlers/crud_generic.go index 29affa5..5ef1c38 100644 --- a/internal/oolong/handlers/crud_generic.go +++ b/internal/oolong/handlers/crud_generic.go @@ -3,20 +3,10 @@ package teahandlers import ( "net/http" - "github.com/rs/zerolog/log" - "tangled.org/arabica.social/arabica/internal/atproto" "tangled.org/arabica.social/arabica/internal/handlers" + "tangled.org/arabica.social/arabica/internal/records" ) -// oolongValidator is a pointer-constraint that lets the generic CRUD -// helper call Validate() on a value of type Req without copying. -// Every oolong Create/Update request type satisfies this via its -// pointer-receiver Validate() method. -type oolongValidator[T any] interface { - *T - Validate() error -} - // oolongCRUDWrite is the shared body for every oolong Create + Update // handler that follows the standard shape: require store -> decode + // validate request -> build model -> put record -> handlers.WriteJSON. rkey is @@ -30,48 +20,25 @@ type oolongValidator[T any] interface { // // allowRedirect mirrors the HX-Redirect short-circuit that the Tea/Brew // handlers had; entities without an inline form path pass false. -func oolongCRUDWrite[Req any, PReq oolongValidator[Req], Model any]( +func oolongCRUDWrite[Req any, PReq handlers.RequestValidator[Req], Model any]( h *Handlers, w http.ResponseWriter, r *http.Request, nsid, jsonKey, rkey string, build func(req *Req) *Model, setRKey func(*Model, string), - encode func(s *atproto.AtprotoStore, req *Req, m *Model) (map[string]any, error), + encode func(s records.Store, req *Req, m *Model) (map[string]any, error), allowRedirect bool, ) { - store, ok := h.requireOolongStore(w, r) + store, ok := h.RequireRecordStore(w, r) if !ok { return } - var req Req - if err := handlers.DecodeRequest(r, &req, func() error { return decodeOolongForm(r, &req) }); err != nil { - http.Error(w, "Invalid request body", http.StatusBadRequest) - return - } - if err := PReq(&req).Validate(); err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) - return - } - model := build(&req) - newRKey, err := putOolongRecord(r.Context(), store, nsid, rkey, func(s *atproto.AtprotoStore) (map[string]any, error) { - return encode(s, &req, model) - }) - if err != nil { - log.Error().Err(err).Str("rkey", rkey).Str("nsid", nsid).Msgf("Failed to write %s", jsonKey) - handlers.HandleStoreError(w, err, "Failed to save "+jsonKey) - return - } - setRKey(model, newRKey) - h.InvalidateFeedCache() - if allowRedirect { - if redirect := r.FormValue("__redirect"); redirect != "" { - w.Header().Set("HX-Redirect", redirect) - w.WriteHeader(http.StatusOK) - return - } - } - handlers.WriteJSON(w, model, jsonKey) + handlers.RecordCRUDWrite[Req, PReq, Model]( + w, r, store, nsid, jsonKey, rkey, + func(r *http.Request, req *Req) error { return decodeOolongForm(r, req) }, + build, setRKey, encode, h.InvalidateFeedCache, allowRedirect, + ) } // updateRKey is a small helper for handlers that need to extract + diff --git a/internal/oolong/handlers/crud_store_test.go b/internal/oolong/handlers/crud_store_test.go new file mode 100644 index 0000000..a02ac03 --- /dev/null +++ b/internal/oolong/handlers/crud_store_test.go @@ -0,0 +1,80 @@ +package teahandlers + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "tangled.org/arabica.social/arabica/internal/handlers" + "tangled.org/arabica.social/arabica/internal/records" +) + +type fakeRecordStore struct { + did string + putNSID string + putRKey string + putRecord any + putResult string + removeNSID string + removeRKey string +} + +func (s *fakeRecordStore) DID() string { return s.did } + +func (s *fakeRecordStore) FetchRecord(context.Context, string, string) (map[string]any, string, string, error) { + return nil, "", "", nil +} + +func (s *fakeRecordStore) FetchAllRecords(context.Context, string) ([]records.RawRecord, error) { + return nil, nil +} + +func (s *fakeRecordStore) PutRecord(_ context.Context, nsid, rkey string, record any) (string, string, error) { + s.putNSID = nsid + s.putRKey = rkey + s.putRecord = record + return s.putResult, "cid", nil +} + +func (s *fakeRecordStore) RemoveRecord(_ context.Context, nsid, rkey string) error { + s.removeNSID = nsid + s.removeRKey = rkey + return nil +} + +func TestPutOolongRecordCreateUsesReturnedRKey(t *testing.T) { + store := &fakeRecordStore{did: "did:plc:test", putResult: "new-rkey"} + record := map[string]any{"name": "tea"} + + rkey, err := handlers.PutRecord(context.Background(), store, "social.oolong.alpha.tea", "", func(records.Store) (map[string]any, error) { + return record, nil + }) + + assert.NoError(t, err) + assert.Equal(t, "new-rkey", rkey) + assert.Equal(t, "social.oolong.alpha.tea", store.putNSID) + assert.Equal(t, "", store.putRKey) + assert.Equal(t, record, store.putRecord) +} + +func TestPutOolongRecordUpdateFallsBackToExistingRKey(t *testing.T) { + store := &fakeRecordStore{did: "did:plc:test"} + record := map[string]any{"name": "updated"} + + rkey, err := handlers.PutRecord(context.Background(), store, "social.oolong.alpha.tea", "existing-rkey", func(records.Store) (map[string]any, error) { + return record, nil + }) + + assert.NoError(t, err) + assert.Equal(t, "existing-rkey", rkey) + assert.Equal(t, "existing-rkey", store.putRKey) + assert.Equal(t, record, store.putRecord) +} + +func TestBuildOolongRefUsesGenericStoreDID(t *testing.T) { + store := &fakeRecordStore{did: "did:plc:test"} + + got := buildOolongRef(store, "rkey123", "social.oolong.alpha.vendor") + + assert.Equal(t, "at://did:plc:test/social.oolong.alpha.vendor/rkey123", got) +} diff --git a/internal/oolong/handlers/modals_generic.go b/internal/oolong/handlers/modals_generic.go index 3afae41..e2326ce 100644 --- a/internal/oolong/handlers/modals_generic.go +++ b/internal/oolong/handlers/modals_generic.go @@ -13,7 +13,7 @@ import ( // callback is parameterised on a nil model so each entity's modal // signature can stay typed (e.g. *oolong.Vendor). func (h *Handlers) oolongModalNew(w http.ResponseWriter, r *http.Request, name string, render func() templ.Component) { - if _, ok := h.requireOolongStore(w, r); !ok { + if _, ok := h.RequireRecordStore(w, r); !ok { return } if err := render().Render(r.Context(), w); err != nil { @@ -34,7 +34,7 @@ func oolongModalEdit[Model any]( setRKey func(*Model, string), render func(*Model) templ.Component, ) { - store, ok := h.requireOolongStore(w, r) + store, ok := h.RequireRecordStore(w, r) if !ok { return } diff --git a/internal/oolong/handlers/onboarding.go b/internal/oolong/handlers/onboarding.go index 06c0017..5b9c4c5 100644 --- a/internal/oolong/handlers/onboarding.go +++ b/internal/oolong/handlers/onboarding.go @@ -6,15 +6,15 @@ import ( "github.com/rs/zerolog/log" - "tangled.org/arabica.social/arabica/internal/atproto" "tangled.org/arabica.social/arabica/internal/handlers" oolong "tangled.org/arabica.social/arabica/internal/oolong/entities" tea "tangled.org/arabica.social/arabica/internal/oolong/web/components" teapages "tangled.org/arabica.social/arabica/internal/oolong/web/pages" + "tangled.org/arabica.social/arabica/internal/records" ) func (h *Handlers) HandleOolongOnboarding(w http.ResponseWriter, r *http.Request) { - store, ok := h.requireOolongStore(w, r) + store, ok := h.RequireRecordStore(w, r) if !ok { return } @@ -36,7 +36,7 @@ func (h *Handlers) HandleOolongOnboarding(w http.ResponseWriter, r *http.Request } func (h *Handlers) HandleOolongGetStartedCard(w http.ResponseWriter, r *http.Request) { - store, ok := h.requireOolongStore(w, r) + store, ok := h.RequireRecordStore(w, r) if !ok { return } @@ -54,7 +54,7 @@ func (h *Handlers) HandleOolongGetStartedCard(w http.ResponseWriter, r *http.Req } func (h *Handlers) HandleOolongOnboardingStationForm(w http.ResponseWriter, r *http.Request) { - store, ok := h.requireOolongStore(w, r) + store, ok := h.RequireRecordStore(w, r) if !ok { return } @@ -74,7 +74,7 @@ func (h *Handlers) HandleOolongOnboardingStationForm(w http.ResponseWriter, r *h } } -func buildOolongGetStartedCardProps(ctx context.Context, store *atproto.AtprotoStore) (tea.GetStartedCardProps, error) { +func buildOolongGetStartedCardProps(ctx context.Context, store records.Store) (tea.GetStartedCardProps, error) { teas := handlers.ListRecords(ctx, store, oolong.NSIDTea, oolong.RecordToTea) vendors := handlers.ListRecords(ctx, store, oolong.NSIDVendor, oolong.RecordToVendor) vessels := handlers.ListRecords(ctx, store, oolong.NSIDVessel, oolong.RecordToVessel) @@ -85,7 +85,7 @@ func buildOolongGetStartedCardProps(ctx context.Context, store *atproto.AtprotoS }, nil } -func (h *Handlers) oolongReadyToBrew(ctx context.Context, store *atproto.AtprotoStore) (bool, error) { +func (h *Handlers) oolongReadyToBrew(ctx context.Context, store records.Store) (bool, error) { props, err := buildOolongGetStartedCardProps(ctx, store) if err != nil { return false, err diff --git a/internal/oolong/handlers/pages.go b/internal/oolong/handlers/pages.go index 42176bc..3c1cc00 100644 --- a/internal/oolong/handlers/pages.go +++ b/internal/oolong/handlers/pages.go @@ -25,7 +25,7 @@ import ( // Returns 204 — the my-tea page is server-rendered, so the client // reloads after the POST resolves. func (h *Handlers) HandleTeaRefresh(w http.ResponseWriter, r *http.Request) { - store, ok := h.requireOolongStore(w, r) + store, ok := h.RequireRecordStore(w, r) if !ok { return } @@ -112,7 +112,7 @@ func (h *Handlers) HandleTeaRefresh(w http.ResponseWriter, r *http.Request) { // Tea, etc.) are not joined here — the row label uses the entity's // own Name or a sensible fallback. func (h *Handlers) HandleMyTea(w http.ResponseWriter, r *http.Request) { - store, ok := h.requireOolongStore(w, r) + store, ok := h.RequireRecordStore(w, r) if !ok { return } @@ -182,7 +182,7 @@ func (h *Handlers) HandleMyTea(w http.ResponseWriter, r *http.Request) { // HandleOolongTeaNew renders the new-tea page. func (h *Handlers) HandleOolongTeaNew(w http.ResponseWriter, r *http.Request) { - store, ok := h.requireOolongStore(w, r) + store, ok := h.RequireRecordStore(w, r) if !ok { return } @@ -198,7 +198,7 @@ func (h *Handlers) HandleOolongTeaNew(w http.ResponseWriter, r *http.Request) { // HandleOolongTeaEdit renders the edit-tea page for an existing tea. func (h *Handlers) HandleOolongTeaEdit(w http.ResponseWriter, r *http.Request) { - store, ok := h.requireOolongStore(w, r) + store, ok := h.RequireRecordStore(w, r) if !ok { return } @@ -240,7 +240,7 @@ func (h *Handlers) HandleOolongTeaEdit(w http.ResponseWriter, r *http.Request) { // Mirrors arabica's /brews/new, but for tea: full page form instead of // modal so the form can grow without crowding a dialog. func (h *Handlers) HandleOolongSteepNew(w http.ResponseWriter, r *http.Request) { - store, ok := h.requireOolongStore(w, r) + store, ok := h.RequireRecordStore(w, r) if !ok { return } @@ -268,7 +268,7 @@ func (h *Handlers) HandleOolongSteepNew(w http.ResponseWriter, r *http.Request) // HandleOolongSteepEdit renders the edit-steep page for an existing brew. func (h *Handlers) HandleOolongSteepEdit(w http.ResponseWriter, r *http.Request) { - store, ok := h.requireOolongStore(w, r) + store, ok := h.RequireRecordStore(w, r) if !ok { return } diff --git a/internal/records/store.go b/internal/records/store.go new file mode 100644 index 0000000..13cc0a4 --- /dev/null +++ b/internal/records/store.go @@ -0,0 +1,23 @@ +// Package records defines the app-agnostic record-store boundary used by +// handlers that only need generic AT Protocol record CRUD. +package records + +import "context" + +// RawRecord is one decoded repository record plus its AT Protocol metadata. +type RawRecord struct { + URI string + RKey string + CID string + Record map[string]any +} + +// Store is the minimal backend surface shared by app-generic handlers. +// Implementations are responsible for any cache/witness/PDS fallback policy. +type Store interface { + DID() string + FetchRecord(ctx context.Context, nsid, rkey string) (record map[string]any, uri, cid string, err error) + FetchAllRecords(ctx context.Context, nsid string) ([]RawRecord, error) + PutRecord(ctx context.Context, nsid, rkey string, record any) (resultRKey, cid string, err error) + RemoveRecord(ctx context.Context, nsid, rkey string) error +} diff --git a/internal/routing/app_routes.go b/internal/routing/app_routes.go new file mode 100644 index 0000000..61ad5ce --- /dev/null +++ b/internal/routing/app_routes.go @@ -0,0 +1,92 @@ +package routing + +import ( + "net/http" + + coffeehandlers "tangled.org/arabica.social/arabica/internal/arabica/handlers" + "tangled.org/arabica.social/arabica/internal/atplatform/domain" + "tangled.org/arabica.social/arabica/internal/handlers" + "tangled.org/arabica.social/arabica/internal/middleware" + teahandlers "tangled.org/arabica.social/arabica/internal/oolong/handlers" +) + +func registerAppDataRoute(mux *http.ServeMux, app *domain.App, coffee *coffeehandlers.Handlers, tea *teahandlers.Handlers) { + if app.Name == "oolong" { + mux.HandleFunc("GET /api/data", tea.HandleOolongAPIListAll) + return + } + mux.HandleFunc("GET /api/data", coffee.HandleAPIListAll) +} + +func registerAppPartials(mux *http.ServeMux, cop *http.CrossOriginProtection, app *domain.App, coffee *coffeehandlers.Handlers, tea *teahandlers.Handlers) { + switch app.Name { + case "arabica": + mux.Handle("GET /api/brews", middleware.RequireHTMXMiddleware(http.HandlerFunc(coffee.HandleBrewListPartial))) + mux.Handle("GET /api/manage", middleware.RequireHTMXMiddleware(http.HandlerFunc(coffee.HandleManagePartial))) + mux.Handle("GET /api/incomplete-records", middleware.RequireHTMXMiddleware(http.HandlerFunc(coffee.HandleIncompleteRecordsPartial))) + mux.Handle("GET /api/get-started-card", middleware.RequireHTMXMiddleware(http.HandlerFunc(coffee.HandleGetStartedCard))) + mux.Handle("GET /api/onboarding/station-form/{kind}", middleware.RequireHTMXMiddleware(http.HandlerFunc(coffee.HandleOnboardingStationForm))) + mux.Handle("GET /api/popular-recipes", middleware.RequireHTMXMiddleware(http.HandlerFunc(coffee.HandlePopularRecipesPartial))) + mux.Handle("POST /api/manage/refresh", cop.Handler(http.HandlerFunc(coffee.HandleManageRefresh))) + case "oolong": + mux.Handle("GET /api/get-started-card", middleware.RequireHTMXMiddleware(http.HandlerFunc(tea.HandleOolongGetStartedCard))) + mux.Handle("GET /api/onboarding/station-form/{kind}", middleware.RequireHTMXMiddleware(http.HandlerFunc(tea.HandleOolongOnboardingStationForm))) + } +} + +func registerAppPages(mux *http.ServeMux, cop *http.CrossOriginProtection, app *domain.App, coffee *coffeehandlers.Handlers, tea *teahandlers.Handlers) { + switch app.Name { + case "arabica": + registerArabicaPages(mux, cop, coffee) + case "oolong": + registerOolongPages(mux, cop, tea) + } +} + +func registerArabicaPages(mux *http.ServeMux, cop *http.CrossOriginProtection, coffee *coffeehandlers.Handlers) { + mux.HandleFunc("GET /onboarding", coffee.HandleOnboarding) + mux.HandleFunc("GET /add", coffee.HandleAddRecords) + mux.HandleFunc("GET /my-coffee", coffee.HandleMyCoffee) + mux.HandleFunc("GET /manage", coffee.HandleManage) + mux.HandleFunc("GET /brews", coffee.HandleBrewList) + mux.HandleFunc("GET /brews/new", coffee.HandleBrewNew) + mux.HandleFunc("GET /brews/{id}/edit", coffee.HandleBrewEdit) + mux.HandleFunc("GET /brews/{actor}/{id}/og-image", rewriteActorToOwner(coffee.HandleBrewOGImage)) + mux.HandleFunc("GET /brews/{actor}/{id}", rewriteActorToOwner(coffee.HandleBrewView)) + mux.Handle("POST /brews", cop.Handler(http.HandlerFunc(coffee.HandleBrewCreate))) + mux.Handle("PUT /brews/{id}", cop.Handler(http.HandlerFunc(coffee.HandleBrewUpdate))) + mux.Handle("DELETE /brews/{id}", cop.Handler(http.HandlerFunc(coffee.HandleBrewDelete))) + mux.HandleFunc("GET /brews/export", coffee.HandleBrewExport) + + mux.HandleFunc("GET /recipes", coffee.HandleRecipeExplore) + mux.HandleFunc("GET /recipes/{actor}/{id}/og-image", rewriteActorToOwner(coffee.HandleRecipeOGImage)) + mux.HandleFunc("GET /recipes/{actor}/{id}", rewriteActorToOwner(coffee.HandleRecipeView)) + mux.HandleFunc("GET /api/recipes", coffee.HandleRecipeList) + mux.HandleFunc("GET /api/recipes/suggestions", coffee.HandleRecipeSuggestions) + mux.HandleFunc("GET /api/recipes/{id}", coffee.HandleRecipeGet) + mux.Handle("POST /api/recipes", cop.Handler(http.HandlerFunc(coffee.HandleRecipeCreate))) + mux.Handle("PUT /api/recipes/{id}", cop.Handler(http.HandlerFunc(coffee.HandleRecipeUpdate))) + mux.Handle("DELETE /api/recipes/{id}", cop.Handler(http.HandlerFunc(coffee.HandleRecipeDelete))) + mux.Handle("POST /api/recipes/from-brew/{id}", cop.Handler(http.HandlerFunc(coffee.HandleRecipeCreateFromBrew))) + mux.Handle("POST /api/recipes/fork/{id}", cop.Handler(http.HandlerFunc(coffee.HandleRecipeFork))) + mux.HandleFunc("GET /api/modals/recipe/new", coffee.HandleRecipeModalNew) + mux.HandleFunc("GET /api/modals/recipe/{id}", coffee.HandleRecipeModalEdit) +} + +func registerOolongPages(mux *http.ServeMux, cop *http.CrossOriginProtection, tea *teahandlers.Handlers) { + mux.HandleFunc("GET /onboarding", tea.HandleOolongOnboarding) + mux.HandleFunc("GET /my-tea", tea.HandleMyTea) + mux.Handle("POST /api/tea/refresh", cop.Handler(http.HandlerFunc(tea.HandleTeaRefresh))) + mux.HandleFunc("GET /brews/new", tea.HandleOolongSteepNew) + mux.HandleFunc("GET /brews/{id}/edit", tea.HandleOolongSteepEdit) + mux.HandleFunc("GET /teas/new", tea.HandleOolongTeaNew) + mux.HandleFunc("GET /teas/{id}/edit", tea.HandleOolongTeaEdit) +} + +func registerProfileRoute(mux *http.ServeMux, app *domain.App, h *handlers.Handler, tea *teahandlers.Handlers) { + if app.Name == "oolong" { + mux.HandleFunc("GET /profile/{actor}", tea.HandleOolongProfile) + return + } + mux.HandleFunc("GET /profile/{actor}", h.HandleProfile) +} diff --git a/internal/routing/routing_test.go b/internal/routing/routing_test.go new file mode 100644 index 0000000..c5a3af9 --- /dev/null +++ b/internal/routing/routing_test.go @@ -0,0 +1,43 @@ +package routing + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "tangled.org/arabica.social/arabica/internal/atplatform/apps" + "tangled.org/arabica.social/arabica/internal/handlers" + "tangled.org/arabica.social/arabica/internal/lexicons" +) + +func TestRegisterEntityRoutesFiltersUnionByAppDescriptors(t *testing.T) { + bundles := []handlers.EntityRouteBundle{ + {RecordType: lexicons.RecordTypeBean, View: okHandler("bean")}, + {RecordType: lexicons.RecordTypeOolongTea, View: okHandler("tea")}, + } + + arabicaMux := http.NewServeMux() + registerEntityRoutes(arabicaMux, http.NewCrossOriginProtection(), apps.NewArabica(), bundles) + assertRouteStatus(t, arabicaMux, "GET", "/beans/alice.test/r1", http.StatusOK) + assertRouteStatus(t, arabicaMux, "GET", "/teas/alice.test/r1", http.StatusNotFound) + + oolongMux := http.NewServeMux() + registerEntityRoutes(oolongMux, http.NewCrossOriginProtection(), apps.NewOolong(), bundles) + assertRouteStatus(t, oolongMux, "GET", "/beans/alice.test/r1", http.StatusNotFound) + assertRouteStatus(t, oolongMux, "GET", "/teas/alice.test/r1", http.StatusOK) +} + +func okHandler(body string) http.HandlerFunc { + return func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(body)) + } +} + +func assertRouteStatus(t *testing.T, h http.Handler, method, path string, want int) { + t.Helper() + req := httptest.NewRequest(method, path, nil) + w := httptest.NewRecorder() + h.ServeHTTP(w, req) + assert.Equal(t, want, w.Code) +} diff --git a/internal/sqlitestore/moderation.go b/internal/sqlitestore/moderation.go new file mode 100644 index 0000000..cec4d06 --- /dev/null +++ b/internal/sqlitestore/moderation.go @@ -0,0 +1,542 @@ +// Package sqlitestore provides SQLite-backed store implementations. +package sqlitestore + +import ( + "context" + "database/sql" + "encoding/json" + "fmt" + "time" + + "tangled.org/arabica.social/arabica/internal/moderation" +) + +// ModerationStore implements moderation.Store using SQLite. +// It shares the database connection with the firehose FeedIndex. +type ModerationStore struct { + db *sql.DB +} + +// NewModerationStore creates a ModerationStore backed by the given database. +// The database must already have the moderation schema applied. +func NewModerationStore(db *sql.DB) *ModerationStore { + return &ModerationStore{db: db} +} + +// Ensure ModerationStore implements the interface at compile time. +var _ moderation.Store = (*ModerationStore)(nil) + +// ========== Hidden Records ========== + +func (s *ModerationStore) HideRecord(ctx context.Context, entry moderation.HiddenRecord) error { + autoHidden := 0 + if entry.AutoHidden { + autoHidden = 1 + } + _, err := s.db.ExecContext(ctx, ` + INSERT INTO moderation_hidden_records (uri, hidden_at, hidden_by, reason, auto_hidden) + VALUES (?, ?, ?, ?, ?) + ON CONFLICT(uri) DO UPDATE SET + hidden_at = excluded.hidden_at, + hidden_by = excluded.hidden_by, + reason = excluded.reason, + auto_hidden = excluded.auto_hidden + `, entry.ATURI, entry.HiddenAt.Format(time.RFC3339Nano), entry.HiddenBy, entry.Reason, autoHidden) + if err != nil { + return fmt.Errorf("hide record: %w", err) + } + return nil +} + +func (s *ModerationStore) UnhideRecord(ctx context.Context, atURI string) error { + _, err := s.db.ExecContext(ctx, `DELETE FROM moderation_hidden_records WHERE uri = ?`, atURI) + return err +} + +func (s *ModerationStore) IsRecordHidden(ctx context.Context, atURI string) bool { + var exists int + _ = s.db.QueryRowContext(ctx, `SELECT 1 FROM moderation_hidden_records WHERE uri = ?`, atURI).Scan(&exists) + return exists == 1 +} + +func (s *ModerationStore) GetHiddenRecord(ctx context.Context, atURI string) (*moderation.HiddenRecord, error) { + var r moderation.HiddenRecord + var hiddenAtStr string + var autoHidden int + err := s.db.QueryRowContext(ctx, ` + SELECT uri, hidden_at, hidden_by, reason, auto_hidden + FROM moderation_hidden_records WHERE uri = ? + `, atURI).Scan(&r.ATURI, &hiddenAtStr, &r.HiddenBy, &r.Reason, &autoHidden) + if err == sql.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, err + } + r.HiddenAt, _ = time.Parse(time.RFC3339Nano, hiddenAtStr) + r.AutoHidden = autoHidden == 1 + return &r, nil +} + +func (s *ModerationStore) ListHiddenRecords(ctx context.Context) ([]moderation.HiddenRecord, error) { + rows, err := s.db.QueryContext(ctx, ` + SELECT uri, hidden_at, hidden_by, reason, auto_hidden + FROM moderation_hidden_records ORDER BY hidden_at DESC + `) + if err != nil { + return nil, err + } + defer rows.Close() + + var records []moderation.HiddenRecord + for rows.Next() { + var r moderation.HiddenRecord + var hiddenAtStr string + var autoHidden int + if err := rows.Scan(&r.ATURI, &hiddenAtStr, &r.HiddenBy, &r.Reason, &autoHidden); err != nil { + continue + } + r.HiddenAt, _ = time.Parse(time.RFC3339Nano, hiddenAtStr) + r.AutoHidden = autoHidden == 1 + records = append(records, r) + } + return records, rows.Err() +} + +// ListHiddenURIs returns all hidden record URIs for batch filtering. +func (s *ModerationStore) ListHiddenURIs(ctx context.Context) ([]string, error) { + rows, err := s.db.QueryContext(ctx, `SELECT uri FROM moderation_hidden_records`) + if err != nil { + return nil, err + } + defer rows.Close() + + var uris []string + for rows.Next() { + var uri string + if err := rows.Scan(&uri); err != nil { + continue + } + uris = append(uris, uri) + } + return uris, rows.Err() +} + +// ========== Blacklist ========== + +func (s *ModerationStore) BlacklistUser(ctx context.Context, entry moderation.BlacklistedUser) error { + _, err := s.db.ExecContext(ctx, ` + INSERT INTO moderation_blacklist (did, blacklisted_at, blacklisted_by, reason) + VALUES (?, ?, ?, ?) + ON CONFLICT(did) DO UPDATE SET + blacklisted_at = excluded.blacklisted_at, + blacklisted_by = excluded.blacklisted_by, + reason = excluded.reason + `, entry.DID, entry.BlacklistedAt.Format(time.RFC3339Nano), entry.BlacklistedBy, entry.Reason) + if err != nil { + return fmt.Errorf("blacklist user: %w", err) + } + return nil +} + +func (s *ModerationStore) UnblacklistUser(ctx context.Context, did string) error { + _, err := s.db.ExecContext(ctx, `DELETE FROM moderation_blacklist WHERE did = ?`, did) + return err +} + +// ListBlacklistedDIDs returns all blacklisted DIDs for batch filtering. +func (s *ModerationStore) ListBlacklistedDIDs(ctx context.Context) ([]string, error) { + rows, err := s.db.QueryContext(ctx, `SELECT did FROM moderation_blacklist`) + if err != nil { + return nil, err + } + defer rows.Close() + + var dids []string + for rows.Next() { + var did string + if err := rows.Scan(&did); err != nil { + continue + } + dids = append(dids, did) + } + return dids, rows.Err() +} + +func (s *ModerationStore) IsBlacklisted(ctx context.Context, did string) bool { + var exists int + _ = s.db.QueryRowContext(ctx, `SELECT 1 FROM moderation_blacklist WHERE did = ?`, did).Scan(&exists) + return exists == 1 +} + +func (s *ModerationStore) GetBlacklistedUser(ctx context.Context, did string) (*moderation.BlacklistedUser, error) { + var u moderation.BlacklistedUser + var blacklistedAtStr string + err := s.db.QueryRowContext(ctx, ` + SELECT did, blacklisted_at, blacklisted_by, reason + FROM moderation_blacklist WHERE did = ? + `, did).Scan(&u.DID, &blacklistedAtStr, &u.BlacklistedBy, &u.Reason) + if err == sql.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, err + } + u.BlacklistedAt, _ = time.Parse(time.RFC3339Nano, blacklistedAtStr) + return &u, nil +} + +func (s *ModerationStore) ListBlacklistedUsers(ctx context.Context) ([]moderation.BlacklistedUser, error) { + rows, err := s.db.QueryContext(ctx, ` + SELECT did, blacklisted_at, blacklisted_by, reason + FROM moderation_blacklist ORDER BY blacklisted_at DESC + `) + if err != nil { + return nil, err + } + defer rows.Close() + + var users []moderation.BlacklistedUser + for rows.Next() { + var u moderation.BlacklistedUser + var blacklistedAtStr string + if err := rows.Scan(&u.DID, &blacklistedAtStr, &u.BlacklistedBy, &u.Reason); err != nil { + continue + } + u.BlacklistedAt, _ = time.Parse(time.RFC3339Nano, blacklistedAtStr) + users = append(users, u) + } + return users, rows.Err() +} + +// ========== Reports ========== + +func (s *ModerationStore) CreateReport(ctx context.Context, report moderation.Report) error { + _, err := s.db.ExecContext(ctx, ` + INSERT INTO moderation_reports + (id, subject_uri, subject_did, reporter_did, reason, created_at, status, resolved_by, resolved_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + `, report.ID, report.SubjectURI, report.SubjectDID, report.ReporterDID, report.Reason, + report.CreatedAt.Format(time.RFC3339Nano), string(report.Status), report.ResolvedBy, nil) + if err != nil { + return fmt.Errorf("create report: %w", err) + } + return nil +} + +func (s *ModerationStore) GetReport(ctx context.Context, id string) (*moderation.Report, error) { + var r moderation.Report + var createdAtStr string + var resolvedAtStr sql.NullString + err := s.db.QueryRowContext(ctx, ` + SELECT id, subject_uri, subject_did, reporter_did, reason, created_at, status, resolved_by, resolved_at + FROM moderation_reports WHERE id = ? + `, id).Scan(&r.ID, &r.SubjectURI, &r.SubjectDID, &r.ReporterDID, &r.Reason, + &createdAtStr, &r.Status, &r.ResolvedBy, &resolvedAtStr) + if err == sql.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, err + } + r.CreatedAt, _ = time.Parse(time.RFC3339Nano, createdAtStr) + if resolvedAtStr.Valid { + t, _ := time.Parse(time.RFC3339Nano, resolvedAtStr.String) + r.ResolvedAt = &t + } + return &r, nil +} + +func (s *ModerationStore) ListPendingReports(ctx context.Context) ([]moderation.Report, error) { + return s.listReports(ctx, `WHERE status = 'pending' ORDER BY created_at DESC`) +} + +func (s *ModerationStore) ListAllReports(ctx context.Context) ([]moderation.Report, error) { + return s.listReports(ctx, `ORDER BY created_at DESC`) +} + +func (s *ModerationStore) listReports(ctx context.Context, clause string) ([]moderation.Report, error) { + rows, err := s.db.QueryContext(ctx, ` + SELECT id, subject_uri, subject_did, reporter_did, reason, created_at, status, resolved_by, resolved_at + FROM moderation_reports `+clause) + if err != nil { + return nil, err + } + defer rows.Close() + return scanReports(rows) +} + +func scanReports(rows *sql.Rows) ([]moderation.Report, error) { + var reports []moderation.Report + for rows.Next() { + var r moderation.Report + var createdAtStr string + var resolvedAtStr sql.NullString + if err := rows.Scan(&r.ID, &r.SubjectURI, &r.SubjectDID, &r.ReporterDID, &r.Reason, + &createdAtStr, &r.Status, &r.ResolvedBy, &resolvedAtStr); err != nil { + continue + } + r.CreatedAt, _ = time.Parse(time.RFC3339Nano, createdAtStr) + if resolvedAtStr.Valid { + t, _ := time.Parse(time.RFC3339Nano, resolvedAtStr.String) + r.ResolvedAt = &t + } + reports = append(reports, r) + } + return reports, rows.Err() +} + +func (s *ModerationStore) ResolveReport(ctx context.Context, id string, status moderation.ReportStatus, resolvedBy string) error { + now := time.Now().Format(time.RFC3339Nano) + res, err := s.db.ExecContext(ctx, ` + UPDATE moderation_reports SET status = ?, resolved_by = ?, resolved_at = ? WHERE id = ? + `, string(status), resolvedBy, now, id) + if err != nil { + return fmt.Errorf("resolve report: %w", err) + } + n, _ := res.RowsAffected() + if n == 0 { + return fmt.Errorf("report not found: %s", id) + } + return nil +} + +func (s *ModerationStore) CountReportsForURI(ctx context.Context, atURI string) (int, error) { + var count int + err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM moderation_reports WHERE subject_uri = ?`, atURI).Scan(&count) + return count, err +} + +func (s *ModerationStore) CountReportsForDID(ctx context.Context, did string) (int, error) { + var count int + err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM moderation_reports WHERE subject_did = ?`, did).Scan(&count) + return count, err +} + +func (s *ModerationStore) CountReportsForDIDSince(ctx context.Context, did string, since time.Time) (int, error) { + var count int + err := s.db.QueryRowContext(ctx, ` + SELECT COUNT(*) FROM moderation_reports WHERE subject_did = ? AND created_at > ? + `, did, since.Format(time.RFC3339Nano)).Scan(&count) + return count, err +} + +func (s *ModerationStore) HasReportedURI(ctx context.Context, reporterDID, subjectURI string) (bool, error) { + var exists int + err := s.db.QueryRowContext(ctx, ` + SELECT 1 FROM moderation_reports WHERE reporter_did = ? AND subject_uri = ? LIMIT 1 + `, reporterDID, subjectURI).Scan(&exists) + if err == sql.ErrNoRows { + return false, nil + } + return exists == 1, err +} + +func (s *ModerationStore) CountReportsFromUserSince(ctx context.Context, reporterDID string, since time.Time) (int, error) { + var count int + err := s.db.QueryRowContext(ctx, ` + SELECT COUNT(*) FROM moderation_reports WHERE reporter_did = ? AND created_at > ? + `, reporterDID, since.Format(time.RFC3339Nano)).Scan(&count) + return count, err +} + +// ========== Audit Log ========== + +func (s *ModerationStore) LogAction(ctx context.Context, entry moderation.AuditEntry) error { + details, err := json.Marshal(entry.Details) + if err != nil { + details = []byte("{}") + } + autoMod := 0 + if entry.AutoMod { + autoMod = 1 + } + _, err = s.db.ExecContext(ctx, ` + INSERT INTO moderation_audit_log (id, action, actor_did, target_uri, reason, details, timestamp, auto_mod) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + `, entry.ID, string(entry.Action), entry.ActorDID, entry.TargetURI, entry.Reason, + string(details), entry.Timestamp.Format(time.RFC3339Nano), autoMod) + if err != nil { + return fmt.Errorf("log action: %w", err) + } + return nil +} + +func (s *ModerationStore) ListAuditLog(ctx context.Context, limit int) ([]moderation.AuditEntry, error) { + rows, err := s.db.QueryContext(ctx, ` + SELECT id, action, actor_did, target_uri, reason, details, timestamp, auto_mod + FROM moderation_audit_log ORDER BY timestamp DESC LIMIT ? + `, limit) + if err != nil { + return nil, err + } + defer rows.Close() + + var entries []moderation.AuditEntry + for rows.Next() { + var e moderation.AuditEntry + var timestampStr, detailsStr string + var autoMod int + if err := rows.Scan(&e.ID, &e.Action, &e.ActorDID, &e.TargetURI, &e.Reason, + &detailsStr, ×tampStr, &autoMod); err != nil { + continue + } + e.Timestamp, _ = time.Parse(time.RFC3339Nano, timestampStr) + e.AutoMod = autoMod == 1 + _ = json.Unmarshal([]byte(detailsStr), &e.Details) + entries = append(entries, e) + } + return entries, rows.Err() +} + +// ========== Auto-hide Resets ========== + +func (s *ModerationStore) SetAutoHideReset(ctx context.Context, did string, resetAt time.Time) error { + _, err := s.db.ExecContext(ctx, ` + INSERT INTO moderation_autohide_resets (did, reset_at) VALUES (?, ?) + ON CONFLICT(did) DO UPDATE SET reset_at = excluded.reset_at + `, did, resetAt.Format(time.RFC3339Nano)) + if err != nil { + return fmt.Errorf("set autohide reset: %w", err) + } + return nil +} + +func (s *ModerationStore) GetAutoHideReset(ctx context.Context, did string) (time.Time, error) { + var resetAtStr string + err := s.db.QueryRowContext(ctx, `SELECT reset_at FROM moderation_autohide_resets WHERE did = ?`, did).Scan(&resetAtStr) + if err == sql.ErrNoRows { + return time.Time{}, nil + } + if err != nil { + return time.Time{}, err + } + t, _ := time.Parse(time.RFC3339Nano, resetAtStr) + return t, nil +} + +// ========== Labels ========== + +func (s *ModerationStore) AddLabel(ctx context.Context, label moderation.Label) error { + var expiresAt *string + if label.ExpiresAt != nil { + v := label.ExpiresAt.Format(time.RFC3339Nano) + expiresAt = &v + } + _, err := s.db.ExecContext(ctx, ` + INSERT INTO moderation_labels (id, entity_type, entity_id, label, value, created_at, created_by, expires_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(entity_type, entity_id, label) DO UPDATE SET + id = excluded.id, + value = excluded.value, + created_at = excluded.created_at, + created_by = excluded.created_by, + expires_at = excluded.expires_at + `, label.ID, label.EntityType, label.EntityID, label.Name, label.Value, + label.CreatedAt.Format(time.RFC3339Nano), label.CreatedBy, expiresAt) + if err != nil { + return fmt.Errorf("add label: %w", err) + } + return nil +} + +func (s *ModerationStore) RemoveLabel(ctx context.Context, entityType, entityID, labelName string) error { + _, err := s.db.ExecContext(ctx, ` + DELETE FROM moderation_labels WHERE entity_type = ? AND entity_id = ? AND label = ? + `, entityType, entityID, labelName) + return err +} + +func (s *ModerationStore) HasLabel(ctx context.Context, entityType, entityID, labelName string) (bool, error) { + var exists int + err := s.db.QueryRowContext(ctx, ` + SELECT 1 FROM moderation_labels + WHERE entity_type = ? AND entity_id = ? AND label = ? + AND (expires_at IS NULL OR expires_at > ?) + `, entityType, entityID, labelName, time.Now().Format(time.RFC3339Nano)).Scan(&exists) + if err == sql.ErrNoRows { + return false, nil + } + return exists == 1, err +} + +func (s *ModerationStore) GetLabel(ctx context.Context, entityType, entityID, labelName string) (*moderation.Label, error) { + var l moderation.Label + var createdAtStr string + var expiresAtStr sql.NullString + err := s.db.QueryRowContext(ctx, ` + SELECT id, entity_type, entity_id, label, value, created_at, created_by, expires_at + FROM moderation_labels WHERE entity_type = ? AND entity_id = ? AND label = ? + `, entityType, entityID, labelName).Scan( + &l.ID, &l.EntityType, &l.EntityID, &l.Name, &l.Value, + &createdAtStr, &l.CreatedBy, &expiresAtStr) + if err == sql.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, err + } + l.CreatedAt, _ = time.Parse(time.RFC3339Nano, createdAtStr) + if expiresAtStr.Valid { + t, _ := time.Parse(time.RFC3339Nano, expiresAtStr.String) + l.ExpiresAt = &t + } + return &l, nil +} + +func (s *ModerationStore) ListLabels(ctx context.Context, entityType, entityID string) ([]moderation.Label, error) { + rows, err := s.db.QueryContext(ctx, ` + SELECT id, entity_type, entity_id, label, value, created_at, created_by, expires_at + FROM moderation_labels WHERE entity_type = ? AND entity_id = ? + ORDER BY created_at DESC + `, entityType, entityID) + if err != nil { + return nil, err + } + defer rows.Close() + return scanLabels(rows) +} + +func (s *ModerationStore) ListAllLabels(ctx context.Context) ([]moderation.Label, error) { + rows, err := s.db.QueryContext(ctx, ` + SELECT id, entity_type, entity_id, label, value, created_at, created_by, expires_at + FROM moderation_labels ORDER BY created_at DESC + `) + if err != nil { + return nil, err + } + defer rows.Close() + return scanLabels(rows) +} + +func scanLabels(rows *sql.Rows) ([]moderation.Label, error) { + var labels []moderation.Label + for rows.Next() { + var l moderation.Label + var createdAtStr string + var expiresAtStr sql.NullString + if err := rows.Scan( + &l.ID, &l.EntityType, &l.EntityID, &l.Name, &l.Value, + &createdAtStr, &l.CreatedBy, &expiresAtStr); err != nil { + continue + } + l.CreatedAt, _ = time.Parse(time.RFC3339Nano, createdAtStr) + if expiresAtStr.Valid { + t, _ := time.Parse(time.RFC3339Nano, expiresAtStr.String) + l.ExpiresAt = &t + } + labels = append(labels, l) + } + return labels, rows.Err() +} + +func (s *ModerationStore) CleanExpiredLabels(ctx context.Context) (int, error) { + res, err := s.db.ExecContext(ctx, ` + DELETE FROM moderation_labels WHERE expires_at IS NOT NULL AND expires_at < ? + `, time.Now().Format(time.RFC3339Nano)) + if err != nil { + return 0, err + } + n, _ := res.RowsAffected() + return int(n), nil +} diff --git a/internal/sqlitestore/moderation_test.go b/internal/sqlitestore/moderation_test.go new file mode 100644 index 0000000..ca336ff --- /dev/null +++ b/internal/sqlitestore/moderation_test.go @@ -0,0 +1,217 @@ +package sqlitestore + +import ( + "context" + "database/sql" + "testing" + "time" + + "tangled.org/arabica.social/arabica/internal/moderation" + + "github.com/stretchr/testify/assert" + _ "modernc.org/sqlite" +) + +func setupTestDB(t *testing.T) *ModerationStore { + t.Helper() + db, err := sql.Open("sqlite", ":memory:") + assert.NoError(t, err) + t.Cleanup(func() { db.Close() }) + + _, err = db.Exec(` + CREATE TABLE moderation_labels ( + id TEXT PRIMARY KEY, + entity_type TEXT NOT NULL, + entity_id TEXT NOT NULL, + label TEXT NOT NULL, + value TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL, + created_by TEXT NOT NULL, + expires_at TEXT, + UNIQUE(entity_type, entity_id, label) + ); + CREATE INDEX idx_modlabels_entity ON moderation_labels(entity_type, entity_id); + CREATE INDEX idx_modlabels_expires ON moderation_labels(expires_at) WHERE expires_at IS NOT NULL; + `) + assert.NoError(t, err) + return NewModerationStore(db) +} + +func TestAddAndGetLabel(t *testing.T) { + store := setupTestDB(t) + ctx := context.Background() + + label := moderation.Label{ + ID: "tid001", + EntityType: "user", + EntityID: "did:plc:test123", + Name: "warned", + Value: "", + CreatedAt: time.Now(), + CreatedBy: "did:plc:moderator", + } + + assert.NoError(t, store.AddLabel(ctx, label)) + + got, err := store.GetLabel(ctx, "user", "did:plc:test123", "warned") + assert.NoError(t, err) + assert.NotNil(t, got) + assert.Equal(t, "warned", got.Name) + assert.Equal(t, "user", got.EntityType) + assert.Equal(t, "did:plc:test123", got.EntityID) + assert.Equal(t, "did:plc:moderator", got.CreatedBy) +} + +func TestHasLabel(t *testing.T) { + store := setupTestDB(t) + ctx := context.Background() + + // No label yet + has, err := store.HasLabel(ctx, "user", "did:plc:test", "warned") + assert.NoError(t, err) + assert.False(t, has) + + // Add label + assert.NoError(t, store.AddLabel(ctx, moderation.Label{ + ID: "tid001", + EntityType: "user", + EntityID: "did:plc:test", + Name: "warned", + CreatedAt: time.Now(), + CreatedBy: "automod", + })) + + has, err = store.HasLabel(ctx, "user", "did:plc:test", "warned") + assert.NoError(t, err) + assert.True(t, has) +} + +func TestHasLabel_Expired(t *testing.T) { + store := setupTestDB(t) + ctx := context.Background() + + past := time.Now().Add(-1 * time.Hour) + assert.NoError(t, store.AddLabel(ctx, moderation.Label{ + ID: "tid001", + EntityType: "user", + EntityID: "did:plc:test", + Name: "rate_limited", + CreatedAt: time.Now().Add(-2 * time.Hour), + CreatedBy: "automod", + ExpiresAt: &past, + })) + + // HasLabel should return false for expired labels + has, err := store.HasLabel(ctx, "user", "did:plc:test", "rate_limited") + assert.NoError(t, err) + assert.False(t, has) +} + +func TestRemoveLabel(t *testing.T) { + store := setupTestDB(t) + ctx := context.Background() + + assert.NoError(t, store.AddLabel(ctx, moderation.Label{ + ID: "tid001", + EntityType: "user", + EntityID: "did:plc:test", + Name: "warned", + CreatedAt: time.Now(), + CreatedBy: "did:plc:mod", + })) + + assert.NoError(t, store.RemoveLabel(ctx, "user", "did:plc:test", "warned")) + + got, err := store.GetLabel(ctx, "user", "did:plc:test", "warned") + assert.NoError(t, err) + assert.Nil(t, got) +} + +func TestListLabels(t *testing.T) { + store := setupTestDB(t) + ctx := context.Background() + + assert.NoError(t, store.AddLabel(ctx, moderation.Label{ + ID: "tid001", EntityType: "user", EntityID: "did:plc:test", + Name: "warned", CreatedAt: time.Now(), CreatedBy: "mod", + })) + assert.NoError(t, store.AddLabel(ctx, moderation.Label{ + ID: "tid002", EntityType: "user", EntityID: "did:plc:test", + Name: "trusted", CreatedAt: time.Now(), CreatedBy: "mod", + })) + assert.NoError(t, store.AddLabel(ctx, moderation.Label{ + ID: "tid003", EntityType: "user", EntityID: "did:plc:other", + Name: "spam", CreatedAt: time.Now(), CreatedBy: "mod", + })) + + labels, err := store.ListLabels(ctx, "user", "did:plc:test") + assert.NoError(t, err) + assert.Len(t, labels, 2) + + all, err := store.ListAllLabels(ctx) + assert.NoError(t, err) + assert.Len(t, all, 3) +} + +func TestCleanExpiredLabels(t *testing.T) { + store := setupTestDB(t) + ctx := context.Background() + + past := time.Now().Add(-1 * time.Hour) + future := time.Now().Add(24 * time.Hour) + + // Expired label + assert.NoError(t, store.AddLabel(ctx, moderation.Label{ + ID: "tid001", EntityType: "user", EntityID: "did:plc:a", + Name: "rate_limited", CreatedAt: time.Now(), CreatedBy: "automod", + ExpiresAt: &past, + })) + // Active label with future expiry + assert.NoError(t, store.AddLabel(ctx, moderation.Label{ + ID: "tid002", EntityType: "user", EntityID: "did:plc:b", + Name: "warned", CreatedAt: time.Now(), CreatedBy: "mod", + ExpiresAt: &future, + })) + // Permanent label + assert.NoError(t, store.AddLabel(ctx, moderation.Label{ + ID: "tid003", EntityType: "user", EntityID: "did:plc:c", + Name: "trusted", CreatedAt: time.Now(), CreatedBy: "mod", + })) + + cleaned, err := store.CleanExpiredLabels(ctx) + assert.NoError(t, err) + assert.Equal(t, 1, cleaned) + + // Only 2 labels should remain + all, err := store.ListAllLabels(ctx) + assert.NoError(t, err) + assert.Len(t, all, 2) +} + +func TestAddLabel_Upsert(t *testing.T) { + store := setupTestDB(t) + ctx := context.Background() + + // Add label + assert.NoError(t, store.AddLabel(ctx, moderation.Label{ + ID: "tid001", EntityType: "user", EntityID: "did:plc:test", + Name: "warned", Value: "first", CreatedAt: time.Now(), CreatedBy: "mod1", + })) + + // Upsert same label with new value + assert.NoError(t, store.AddLabel(ctx, moderation.Label{ + ID: "tid002", EntityType: "user", EntityID: "did:plc:test", + Name: "warned", Value: "second", CreatedAt: time.Now(), CreatedBy: "mod2", + })) + + got, err := store.GetLabel(ctx, "user", "did:plc:test", "warned") + assert.NoError(t, err) + assert.NotNil(t, got) + assert.Equal(t, "second", got.Value) + assert.Equal(t, "mod2", got.CreatedBy) + + // Should still be only one label + labels, err := store.ListLabels(ctx, "user", "did:plc:test") + assert.NoError(t, err) + assert.Len(t, labels, 1) +} diff --git a/internal/sqlitestore/oauth.go b/internal/sqlitestore/oauth.go new file mode 100644 index 0000000..2154cdf --- /dev/null +++ b/internal/sqlitestore/oauth.go @@ -0,0 +1,197 @@ +package sqlitestore + +import ( + "context" + "database/sql" + "encoding/json" + "fmt" + "time" + + "github.com/bluesky-social/indigo/atproto/auth/oauth" + "github.com/bluesky-social/indigo/atproto/syntax" + "github.com/rs/zerolog/log" +) + +// OAuthStore implements oauth.ClientAuthStore using SQLite. +// Shares the database connection with the firehose FeedIndex. +// +// Not wired into the server yet; staged here so we can swap BoltDB out. +// Schema (oauth_sessions, oauth_auth_requests) lives alongside the rest +// of the firehose schema in internal/firehose/index.go. +type OAuthStore struct { + db *sql.DB +} + +func NewOAuthStore(db *sql.DB) *OAuthStore { + return &OAuthStore{db: db} +} + +var _ oauth.ClientAuthStore = (*OAuthStore)(nil) + +func (s *OAuthStore) GetSession(ctx context.Context, did syntax.DID, sessionID string) (*oauth.ClientSessionData, error) { + var data string + err := s.db.QueryRowContext(ctx, + `SELECT data FROM oauth_sessions WHERE did = ? AND session_id = ?`, + did.String(), sessionID, + ).Scan(&data) + if err == sql.ErrNoRows { + return nil, fmt.Errorf("session not found") + } + if err != nil { + return nil, err + } + var sess oauth.ClientSessionData + if err := json.Unmarshal([]byte(data), &sess); err != nil { + return nil, fmt.Errorf("unmarshal session: %w", err) + } + return &sess, nil +} + +func (s *OAuthStore) SaveSession(ctx context.Context, sess oauth.ClientSessionData) error { + data, err := json.Marshal(sess) + if err != nil { + return fmt.Errorf("marshal session: %w", err) + } + _, err = s.db.ExecContext(ctx, ` + INSERT INTO oauth_sessions (did, session_id, data, updated_at) + VALUES (?, ?, ?, ?) + ON CONFLICT(did, session_id) DO UPDATE SET + data = excluded.data, + updated_at = excluded.updated_at + `, sess.AccountDID.String(), sess.SessionID, string(data), time.Now().UTC().Format(time.RFC3339Nano)) + return err +} + +func (s *OAuthStore) DeleteSession(ctx context.Context, did syntax.DID, sessionID string) error { + _, err := s.db.ExecContext(ctx, + `DELETE FROM oauth_sessions WHERE did = ? AND session_id = ?`, + did.String(), sessionID, + ) + return err +} + +func (s *OAuthStore) GetAuthRequestInfo(ctx context.Context, state string) (*oauth.AuthRequestData, error) { + var data string + err := s.db.QueryRowContext(ctx, + `SELECT data FROM oauth_auth_requests WHERE state = ?`, state, + ).Scan(&data) + if err == sql.ErrNoRows { + return nil, fmt.Errorf("auth request not found") + } + if err != nil { + return nil, err + } + var info oauth.AuthRequestData + if err := json.Unmarshal([]byte(data), &info); err != nil { + return nil, fmt.Errorf("unmarshal auth request: %w", err) + } + return &info, nil +} + +func (s *OAuthStore) SaveAuthRequestInfo(ctx context.Context, info oauth.AuthRequestData) error { + data, err := json.Marshal(info) + if err != nil { + return fmt.Errorf("marshal auth request: %w", err) + } + _, err = s.db.ExecContext(ctx, ` + INSERT INTO oauth_auth_requests (state, data, created_at) + VALUES (?, ?, ?) + `, info.State, string(data), time.Now().UTC().Format(time.RFC3339Nano)) + return err +} + +func (s *OAuthStore) DeleteAuthRequestInfo(ctx context.Context, state string) error { + _, err := s.db.ExecContext(ctx, + `DELETE FROM oauth_auth_requests WHERE state = ?`, state, + ) + return err +} + +func (s *OAuthStore) ListSessions(ctx context.Context) ([]oauth.ClientSessionData, error) { + rows, err := s.db.QueryContext(ctx, `SELECT data FROM oauth_sessions`) + if err != nil { + return nil, err + } + defer rows.Close() + + var sessions []oauth.ClientSessionData + for rows.Next() { + var data string + if err := rows.Scan(&data); err != nil { + continue + } + var sess oauth.ClientSessionData + if err := json.Unmarshal([]byte(data), &sess); err != nil { + continue + } + sessions = append(sessions, sess) + } + return sessions, rows.Err() +} + +func (s *OAuthStore) CountSessions(ctx context.Context) (int, error) { + var count int + err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM oauth_sessions`).Scan(&count) + return count, err +} + +// CleanupExpired removes stale rows from both OAuth tables. Sessions whose +// updated_at is older than sessionMaxAge are deleted; auth requests older than +// authRequestMaxAge are deleted. Auth requests are short-lived state tokens +// from incomplete OAuth callbacks; sessions are bounded by the upstream +// refresh-token lifetime (~90 days on bsky PDS). +func (s *OAuthStore) CleanupExpired(ctx context.Context, sessionMaxAge, authRequestMaxAge time.Duration) (sessions, authRequests int64, err error) { + now := time.Now().UTC() + sessCutoff := now.Add(-sessionMaxAge).Format(time.RFC3339Nano) + reqCutoff := now.Add(-authRequestMaxAge).Format(time.RFC3339Nano) + + res, err := s.db.ExecContext(ctx, `DELETE FROM oauth_sessions WHERE updated_at < ?`, sessCutoff) + if err != nil { + return 0, 0, fmt.Errorf("cleanup sessions: %w", err) + } + sessions, _ = res.RowsAffected() + + res, err = s.db.ExecContext(ctx, `DELETE FROM oauth_auth_requests WHERE created_at < ?`, reqCutoff) + if err != nil { + return sessions, 0, fmt.Errorf("cleanup auth requests: %w", err) + } + authRequests, _ = res.RowsAffected() + return sessions, authRequests, nil +} + +// StartCleanup launches a goroutine that calls CleanupExpired on a ticker. +// Runs an initial pass immediately, then every interval until ctx is cancelled. +func (s *OAuthStore) StartCleanup(ctx context.Context, interval, sessionMaxAge, authRequestMaxAge time.Duration) { + run := func() { + sess, reqs, err := s.CleanupExpired(ctx, sessionMaxAge, authRequestMaxAge) + if err != nil { + log.Warn().Err(err).Msg("OAuth cleanup failed") + return + } + if sess > 0 || reqs > 0 { + log.Info(). + Int64("sessions", sess). + Int64("auth_requests", reqs). + Msg("Pruned expired OAuth rows") + } + } + + run() + go func() { + ticker := time.NewTicker(interval) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + run() + } + } + }() + log.Info(). + Dur("interval", interval). + Dur("session_max_age", sessionMaxAge). + Dur("auth_request_max_age", authRequestMaxAge). + Msg("OAuth cleanup started") +} diff --git a/tests/integration/harness.go b/tests/integration/harness.go index 5befc6e..6d172fb 100644 --- a/tests/integration/harness.go +++ b/tests/integration/harness.go @@ -17,7 +17,7 @@ import ( "testing" "time" - "tangled.org/arabica.social/arabica/internal/arabica/entities" + arabica "tangled.org/arabica.social/arabica/internal/arabica/entities" coffeehandlers "tangled.org/arabica.social/arabica/internal/arabica/handlers" "tangled.org/arabica.social/arabica/internal/atplatform/domain" "tangled.org/arabica.social/arabica/internal/atproto" @@ -223,8 +223,6 @@ func StartHarness(t *testing.T, opts *HarnessOptions) *Harness { h.SetWitnessCache(feedIndex) // Build the router with no moderation service (most tests don't need it). - // The harness constructs its own App rather than importing newArabicaApp - // from cmd/server (which would create a test-only public API in main). logger := zerolog.Nop() app := &domain.App{ Name: "arabica",