From 39408f135b8e492f549b716a0a9c0cd19b43f06f Mon Sep 17 00:00:00 2001 From: Gani Georgiev Date: Thu, 17 Nov 2022 14:17:10 +0200 Subject: [PATCH] [#943] exposed apis.EnrichRecord and apis.EnrichRecords --- README.md | 2 +- apis/realtime.go | 12 +- apis/record_auth.go | 15 +-- apis/record_auth_test.go | 8 +- apis/record_crud.go | 162 +++++++----------------- apis/record_helpers.go | 98 +++++++++----- apis/record_helpers_test.go | 101 +++++++++++++++ core/app.go | 4 +- core/base.go | 8 +- core/base_test.go | 4 +- models/filter_request_data.go | 11 ++ resolvers/record_field_resolver.go | 49 ++++--- resolvers/record_field_resolver_test.go | 31 +++-- tests/app.go | 4 +- tests/data/data.db | Bin 237568 -> 237568 bytes tests/data/logs.db | Bin 1028096 -> 1028096 bytes 16 files changed, 297 insertions(+), 212 deletions(-) create mode 100644 apis/record_helpers_test.go create mode 100644 models/filter_request_data.go diff --git a/README.md b/README.md index 83896b24..967e68f9 100644 --- a/README.md +++ b/README.md @@ -131,7 +131,7 @@ PocketBase has a [roadmap](https://github.com/orgs/pocketbase/projects/2) and I try to work on issues in a specific order and PRs often come in out of nowhere and skew all initial planning. Don't get upset if I close your PR, even if it is well executed and tested. This doesn't mean that it will never be merged. -Later we can always refer to it and/or take pieces of your implementation when the time to work on the issue come in (don't worry you'll be credited in the release notes). +Later we can always refer to it and/or take pieces of your implementation when the time comes to work on the issue (don't worry you'll be credited in the release notes). _Please also note that PocketBase was initially created to serve as a new backend for my other open source project - [Presentator](https://presentator.io) (see [#183](https://github.com/presentator/presentator/issues/183)), so all feature requests will be first aligned with what we need for Presentator v3._ diff --git a/apis/realtime.go b/apis/realtime.go index 646af821..a4c066a8 100644 --- a/apis/realtime.go +++ b/apis/realtime.go @@ -251,16 +251,10 @@ func (api *realtimeApi) canAccessRecord(client subscriptions.Client, record *mod } // emulate request data - requestData := map[string]any{ - "method": "GET", - "query": map[string]any{}, - "data": map[string]any{}, - "auth": nil, - } - authRecord, _ := client.Get(ContextAuthRecordKey).(*models.Record) - if authRecord != nil { - requestData["auth"] = authRecord.PublicExport() + requestData := &models.FilterRequestData{ + Method: "GET", } + requestData.AuthRecord, _ = client.Get(ContextAuthRecordKey).(*models.Record) resolver := resolvers.NewRecordFieldResolver(api.app.Dao(), record.Collection(), requestData, true) expr, err := search.FilterData(*accessRule).BuildExpr(resolver) diff --git a/apis/record_auth.go b/apis/record_auth.go index d895b774..4d051da2 100644 --- a/apis/record_auth.go +++ b/apis/record_auth.go @@ -65,20 +65,19 @@ func (api *recordAuthApi) authResponse(c echo.Context, authRecord *models.Record } return api.app.OnRecordAuthRequest().Trigger(event, func(e *core.RecordAuthEvent) error { - admin, _ := e.HttpContext.Get(ContextAdminKey).(*models.Admin) - // allow always returning the email address of the authenticated account e.Record.IgnoreEmailVisibility(true) // expand record relations expands := strings.Split(c.QueryParam(expandQueryParam), ",") if len(expands) > 0 { - requestData := exportRequestData(e.HttpContext) - requestData["auth"] = e.Record.PublicExport() + requestData := GetRequestData(e.HttpContext) + requestData.Admin = nil + requestData.AuthRecord = e.Record failed := api.app.Dao().ExpandRecord( e.Record, expands, - expandFetch(api.app.Dao(), admin != nil, requestData), + expandFetch(api.app.Dao(), requestData), ) if len(failed) > 0 && api.app.IsDebug() { log.Println("Failed to expand relations: ", failed) @@ -204,8 +203,8 @@ func (api *recordAuthApi) authWithOAuth2(c echo.Context) error { record, authData, submitErr := form.Submit(func(createForm *forms.RecordUpsert, authRecord *models.Record, authUser *auth.AuthUser) error { return createForm.DrySubmit(func(txDao *daos.Dao) error { - requestData := exportRequestData(c) - requestData["data"] = form.CreateData + requestData := GetRequestData(c) + requestData.Data = form.CreateData createRuleFunc := func(q *dbx.SelectQuery) error { admin, _ := c.Get(ContextAdminKey).(*models.Admin) @@ -422,7 +421,7 @@ func (api *recordAuthApi) listExternalAuths(c echo.Context) error { ExternalAuths: externalAuths, } - return api.app.OnRecordListExternalAuths().Trigger(event, func(e *core.RecordListExternalAuthsEvent) error { + return api.app.OnRecordListExternalAuthsRequest().Trigger(event, func(e *core.RecordListExternalAuthsEvent) error { return e.HttpContext.JSON(http.StatusOK, e.ExternalAuths) }) } diff --git a/apis/record_auth_test.go b/apis/record_auth_test.go index 3ca87cca..53aa822c 100644 --- a/apis/record_auth_test.go +++ b/apis/record_auth_test.go @@ -886,7 +886,7 @@ func TestRecordAuthListExternalsAuths(t *testing.T) { }, ExpectedStatus: 200, ExpectedContent: []string{`[]`}, - ExpectedEvents: map[string]int{"OnRecordListExternalAuths": 1}, + ExpectedEvents: map[string]int{"OnRecordListExternalAuthsRequest": 1}, }, { Name: "admin + existing user id and 2 external auths", @@ -902,7 +902,7 @@ func TestRecordAuthListExternalsAuths(t *testing.T) { `"recordId":"4q1xlclmfloku33"`, `"collectionId":"_pb_users_auth_"`, }, - ExpectedEvents: map[string]int{"OnRecordListExternalAuths": 1}, + ExpectedEvents: map[string]int{"OnRecordListExternalAuthsRequest": 1}, }, { Name: "auth record + trying to list another user external auths", @@ -933,7 +933,7 @@ func TestRecordAuthListExternalsAuths(t *testing.T) { }, ExpectedStatus: 200, ExpectedContent: []string{`[]`}, - ExpectedEvents: map[string]int{"OnRecordListExternalAuths": 1}, + ExpectedEvents: map[string]int{"OnRecordListExternalAuthsRequest": 1}, }, { Name: "authorized as user - owner with 2 external auths", @@ -949,7 +949,7 @@ func TestRecordAuthListExternalsAuths(t *testing.T) { `"recordId":"4q1xlclmfloku33"`, `"collectionId":"_pb_users_auth_"`, }, - ExpectedEvents: map[string]int{"OnRecordListExternalAuths": 1}, + ExpectedEvents: map[string]int{"OnRecordListExternalAuthsRequest": 1}, }, } diff --git a/apis/record_crud.go b/apis/record_crud.go index f2977e77..fb22fe29 100644 --- a/apis/record_crud.go +++ b/apis/record_crud.go @@ -46,31 +46,30 @@ func (api *recordApi) list(c echo.Context) error { return NewNotFoundError("", "Missing collection context.") } - admin, _ := c.Get(ContextAdminKey).(*models.Admin) - if admin == nil && collection.ListRule == nil { - // only admins can access if the rule is nil - return NewForbiddenError("Only admins can perform this action.", nil) - } - // forbid users and guests to query special filter/sort fields if err := api.checkForForbiddenQueryFields(c); err != nil { return err } - requestData := exportRequestData(c) + requestData := GetRequestData(c) + + if requestData.Admin == nil && collection.ListRule == nil { + // only admins can access if the rule is nil + return NewForbiddenError("Only admins can perform this action.", nil) + } fieldsResolver := resolvers.NewRecordFieldResolver( api.app.Dao(), collection, requestData, // hidden fields are searchable only by admins - admin != nil, + requestData.Admin != nil, ) searchProvider := search.NewProvider(fieldsResolver). Query(api.app.Dao().RecordQuery(collection)) - if admin == nil && collection.ListRule != nil { + if requestData.Admin == nil && collection.ListRule != nil { searchProvider.AddFilter(search.FilterData(*collection.ListRule)) } @@ -82,28 +81,6 @@ func (api *recordApi) list(c echo.Context) error { records := models.NewRecordsFromNullStringMaps(collection, rawRecords) - // expand records relations - expands := strings.Split(c.QueryParam(expandQueryParam), ",") - if len(expands) > 0 { - failed := api.app.Dao().ExpandRecords( - records, - expands, - expandFetch(api.app.Dao(), admin != nil, requestData), - ) - if len(failed) > 0 && api.app.IsDebug() { - log.Println("Failed to expand relations: ", failed) - } - } - - if collection.IsAuth() { - err := autoIgnoreAuthRecordsEmailVisibility( - api.app.Dao(), records, admin != nil, requestData, - ) - if err != nil && api.app.IsDebug() { - log.Println("IgnoreEmailVisibility failure:", err) - } - } - result.Items = records event := &core.RecordsListEvent{ @@ -114,6 +91,10 @@ func (api *recordApi) list(c echo.Context) error { } return api.app.OnRecordsListRequest().Trigger(event, func(e *core.RecordsListEvent) error { + if err := EnrichRecords(e.HttpContext, api.app.Dao(), e.Records); err != nil && api.app.IsDebug() { + log.Println(err) + } + return e.HttpContext.JSON(http.StatusOK, e.Result) }) } @@ -124,21 +105,20 @@ func (api *recordApi) view(c echo.Context) error { return NewNotFoundError("", "Missing collection context.") } - admin, _ := c.Get(ContextAdminKey).(*models.Admin) - if admin == nil && collection.ViewRule == nil { - // only admins can access if the rule is nil - return NewForbiddenError("Only admins can perform this action.", nil) - } - recordId := c.PathParam("id") if recordId == "" { return NewNotFoundError("", nil) } - requestData := exportRequestData(c) + requestData := GetRequestData(c) + + if requestData.Admin == nil && collection.ViewRule == nil { + // only admins can access if the rule is nil + return NewForbiddenError("Only admins can perform this action.", nil) + } ruleFunc := func(q *dbx.SelectQuery) error { - if admin == nil && collection.ViewRule != nil && *collection.ViewRule != "" { + if requestData.Admin == nil && collection.ViewRule != nil && *collection.ViewRule != "" { resolver := resolvers.NewRecordFieldResolver(api.app.Dao(), collection, requestData, true) expr, err := search.FilterData(*collection.ViewRule).BuildExpr(resolver) if err != nil { @@ -155,31 +135,16 @@ func (api *recordApi) view(c echo.Context) error { return NewNotFoundError("", fetchErr) } - // expand record relations - failed := api.app.Dao().ExpandRecord( - record, - strings.Split(c.QueryParam(expandQueryParam), ","), - expandFetch(api.app.Dao(), admin != nil, requestData), - ) - if len(failed) > 0 && api.app.IsDebug() { - log.Println("Failed to expand relations: ", failed) - } - - if collection.IsAuth() { - err := autoIgnoreAuthRecordsEmailVisibility( - api.app.Dao(), []*models.Record{record}, admin != nil, requestData, - ) - if err != nil && api.app.IsDebug() { - log.Println("IgnoreEmailVisibility failure:", err) - } - } - event := &core.RecordViewEvent{ HttpContext: c, Record: record, } return api.app.OnRecordViewRequest().Trigger(event, func(e *core.RecordViewEvent) error { + if err := EnrichRecord(e.HttpContext, api.app.Dao(), e.Record); err != nil && api.app.IsDebug() { + log.Println(err) + } + return e.HttpContext.JSON(http.StatusOK, e.Record) }) } @@ -190,18 +155,17 @@ func (api *recordApi) create(c echo.Context) error { return NewNotFoundError("", "Missing collection context.") } - admin, _ := c.Get(ContextAdminKey).(*models.Admin) - if admin == nil && collection.CreateRule == nil { + requestData := GetRequestData(c) + + if requestData.Admin == nil && collection.CreateRule == nil { // only admins can access if the rule is nil return NewForbiddenError("Only admins can perform this action.", nil) } - requestData := exportRequestData(c) - - hasFullManageAccess := admin != nil + hasFullManageAccess := requestData.Admin != nil // temporary save the record and check it against the create rule - if admin == nil && collection.CreateRule != nil { + if requestData.Admin == nil && collection.CreateRule != nil { createRuleFunc := func(q *dbx.SelectQuery) error { if *collection.CreateRule == "" { return nil // no create rule to resolve @@ -260,23 +224,8 @@ func (api *recordApi) create(c echo.Context) error { return NewBadRequestError("Failed to create record.", err) } - // expand record relations - failed := api.app.Dao().ExpandRecord( - e.Record, - strings.Split(e.HttpContext.QueryParam(expandQueryParam), ","), - expandFetch(api.app.Dao(), admin != nil, requestData), - ) - if len(failed) > 0 && api.app.IsDebug() { - log.Println("Failed to expand relations: ", failed) - } - - if collection.IsAuth() { - err := autoIgnoreAuthRecordsEmailVisibility( - api.app.Dao(), []*models.Record{e.Record}, admin != nil, requestData, - ) - if err != nil && api.app.IsDebug() { - log.Println("IgnoreEmailVisibility failure:", err) - } + if err := EnrichRecord(e.HttpContext, api.app.Dao(), e.Record); err != nil && api.app.IsDebug() { + log.Println(err) } return e.HttpContext.JSON(http.StatusOK, e.Record) @@ -297,21 +246,20 @@ func (api *recordApi) update(c echo.Context) error { return NewNotFoundError("", "Missing collection context.") } - admin, _ := c.Get(ContextAdminKey).(*models.Admin) - if admin == nil && collection.UpdateRule == nil { - // only admins can access if the rule is nil - return NewForbiddenError("Only admins can perform this action.", nil) - } - recordId := c.PathParam("id") if recordId == "" { return NewNotFoundError("", nil) } - requestData := exportRequestData(c) + requestData := GetRequestData(c) + + if requestData.Admin == nil && collection.UpdateRule == nil { + // only admins can access if the rule is nil + return NewForbiddenError("Only admins can perform this action.", nil) + } ruleFunc := func(q *dbx.SelectQuery) error { - if admin == nil && collection.UpdateRule != nil && *collection.UpdateRule != "" { + if requestData.Admin == nil && collection.UpdateRule != nil && *collection.UpdateRule != "" { resolver := resolvers.NewRecordFieldResolver(api.app.Dao(), collection, requestData, true) expr, err := search.FilterData(*collection.UpdateRule).BuildExpr(resolver) if err != nil { @@ -330,7 +278,7 @@ func (api *recordApi) update(c echo.Context) error { } form := forms.NewRecordUpsert(api.app, record) - form.SetFullManageAccess(admin != nil || hasAuthManageAccess(api.app.Dao(), record, requestData)) + form.SetFullManageAccess(requestData.Admin != nil || hasAuthManageAccess(api.app.Dao(), record, requestData)) // load request if err := form.LoadRequest(c.Request(), ""); err != nil { @@ -350,23 +298,8 @@ func (api *recordApi) update(c echo.Context) error { return NewBadRequestError("Failed to update record.", err) } - // expand record relations - failed := api.app.Dao().ExpandRecord( - e.Record, - strings.Split(e.HttpContext.QueryParam(expandQueryParam), ","), - expandFetch(api.app.Dao(), admin != nil, requestData), - ) - if len(failed) > 0 && api.app.IsDebug() { - log.Println("Failed to expand relations: ", failed) - } - - if collection.IsAuth() { - err := autoIgnoreAuthRecordsEmailVisibility( - api.app.Dao(), []*models.Record{e.Record}, admin != nil, requestData, - ) - if err != nil && api.app.IsDebug() { - log.Println("IgnoreEmailVisibility failure:", err) - } + if err := EnrichRecord(e.HttpContext, api.app.Dao(), e.Record); err != nil && api.app.IsDebug() { + log.Println(err) } return e.HttpContext.JSON(http.StatusOK, e.Record) @@ -387,21 +320,20 @@ func (api *recordApi) delete(c echo.Context) error { return NewNotFoundError("", "Missing collection context.") } - admin, _ := c.Get(ContextAdminKey).(*models.Admin) - if admin == nil && collection.DeleteRule == nil { - // only admins can access if the rule is nil - return NewForbiddenError("Only admins can perform this action.", nil) - } - recordId := c.PathParam("id") if recordId == "" { return NewNotFoundError("", nil) } - requestData := exportRequestData(c) + requestData := GetRequestData(c) + + if requestData.Admin == nil && collection.DeleteRule == nil { + // only admins can access if the rule is nil + return NewForbiddenError("Only admins can perform this action.", nil) + } ruleFunc := func(q *dbx.SelectQuery) error { - if admin == nil && collection.DeleteRule != nil && *collection.DeleteRule != "" { + if requestData.Admin == nil && collection.DeleteRule != nil && *collection.DeleteRule != "" { resolver := resolvers.NewRecordFieldResolver(api.app.Dao(), collection, requestData, true) expr, err := search.FilterData(*collection.DeleteRule).BuildExpr(resolver) if err != nil { diff --git a/apis/record_helpers.go b/apis/record_helpers.go index c2b8c98c..2beddcff 100644 --- a/apis/record_helpers.go +++ b/apis/record_helpers.go @@ -2,6 +2,7 @@ package apis import ( "fmt" + "strings" "github.com/labstack/echo/v5" "github.com/pocketbase/dbx" @@ -10,45 +11,77 @@ import ( "github.com/pocketbase/pocketbase/resolvers" "github.com/pocketbase/pocketbase/tools/rest" "github.com/pocketbase/pocketbase/tools/search" - "github.com/spf13/cast" ) -// exportRequestData exports a map with common request fields. -// -// @todo consider changing the map to a typed struct after v0.8 and the -// IN operator support. -func exportRequestData(c echo.Context) map[string]any { - result := map[string]any{} - queryParams := map[string]any{} - bodyData := map[string]any{} - method := c.Request().Method +const ContextRequestDataKey = "requestData" - echo.BindQueryParams(c, &queryParams) - - rest.BindBody(c, &bodyData) - - result["method"] = method - result["query"] = queryParams - result["data"] = bodyData - result["auth"] = nil - - auth, _ := c.Get(ContextAuthRecordKey).(*models.Record) - if auth != nil { - result["auth"] = auth.PublicExport() +// GetRequestData exports common request data fields +// (query, body, logged auth state, etc.) from the provided context. +func GetRequestData(c echo.Context) *models.FilterRequestData { + // return cached to avoid reading the body multiple times + if v := c.Get(ContextRequestDataKey); v != nil { + if data, ok := v.(*models.FilterRequestData); ok { + return data + } } + result := &models.FilterRequestData{ + Method: c.Request().Method, + Query: map[string]any{}, + Data: map[string]any{}, + } + + result.AuthRecord, _ = c.Get(ContextAuthRecordKey).(*models.Record) + result.Admin, _ = c.Get(ContextAdminKey).(*models.Admin) + echo.BindQueryParams(c, &result.Query) + rest.BindBody(c, &result.Data) + + c.Set(ContextRequestDataKey, result) + return result } +// EnrichRecord parses the request context and enrich the provided record: +// - expands relations (if defaultExpands and/or ?expand query param is set) +// - ensures that the emails of the auth record and its expanded auth relations +// are visibe only for the current logged admin, record owner or record with manage access +func EnrichRecord(c echo.Context, dao *daos.Dao, record *models.Record, defaultExpands ...string) error { + return EnrichRecords(c, dao, []*models.Record{record}, defaultExpands...) +} + +// EnrichRecords parses the request context and enriches the provided records: +// - expands relations (if defaultExpands and/or ?expand query param is set) +// - ensures that the emails of the auth records and their expanded auth relations +// are visibe only for the current logged admin, record owner or record with manage access +func EnrichRecords(c echo.Context, dao *daos.Dao, records []*models.Record, defaultExpands ...string) error { + requestData := GetRequestData(c) + + if err := autoIgnoreAuthRecordsEmailVisibility(dao, records, requestData); err != nil { + return fmt.Errorf("Failed to resolve email visibility: %v", err) + } + + expands := defaultExpands + expands = append(expands, strings.Split(c.QueryParam(expandQueryParam), ",")...) + if len(expands) == 0 { + return nil // nothing to expand + } + + errs := dao.ExpandRecords(records, expands, expandFetch(dao, requestData)) + if len(errs) > 0 { + return fmt.Errorf("Failed to expand: %v", errs) + } + + return nil +} + // expandFetch is the records fetch function that is used to expand related records. func expandFetch( dao *daos.Dao, - isAdmin bool, - requestData map[string]any, + requestData *models.FilterRequestData, ) daos.ExpandFetchFunc { return func(relCollection *models.Collection, relIds []string) ([]*models.Record, error) { records, err := dao.FindRecordsByIds(relCollection.Id, relIds, func(q *dbx.SelectQuery) error { - if isAdmin { + if requestData.Admin != nil { return nil // admins can access everything } @@ -70,7 +103,7 @@ func expandFetch( }) if err == nil && len(records) > 0 { - autoIgnoreAuthRecordsEmailVisibility(dao, records, isAdmin, requestData) + autoIgnoreAuthRecordsEmailVisibility(dao, records, requestData) } return records, err @@ -84,14 +117,13 @@ func expandFetch( func autoIgnoreAuthRecordsEmailVisibility( dao *daos.Dao, records []*models.Record, - isAdmin bool, - requestData map[string]any, + requestData *models.FilterRequestData, ) error { if len(records) == 0 || !records[0].Collection().IsAuth() { return nil // nothing to check } - if isAdmin { + if requestData.Admin != nil { for _, rec := range records { rec.IgnoreEmailVisibility(true) } @@ -107,8 +139,8 @@ func autoIgnoreAuthRecordsEmailVisibility( recordIds = append(recordIds, rec.Id) } - if auth, ok := requestData["auth"].(map[string]any); ok && mappedRecords[cast.ToString(auth["id"])] != nil { - mappedRecords[cast.ToString(auth["id"])].IgnoreEmailVisibility(true) + if requestData != nil && requestData.AuthRecord != nil && mappedRecords[requestData.AuthRecord.Id] != nil { + mappedRecords[requestData.AuthRecord.Id].IgnoreEmailVisibility(true) } authOptions := collection.AuthOptions() @@ -153,7 +185,7 @@ func autoIgnoreAuthRecordsEmailVisibility( func hasAuthManageAccess( dao *daos.Dao, record *models.Record, - requestData map[string]any, + requestData *models.FilterRequestData, ) bool { if !record.Collection().IsAuth() { return false @@ -165,7 +197,7 @@ func hasAuthManageAccess( return false // only for admins (manageRule can't be empty) } - if auth, ok := requestData["auth"].(map[string]any); !ok || cast.ToString(auth["id"]) == "" { + if requestData == nil || requestData.AuthRecord == nil { return false // no auth record } diff --git a/apis/record_helpers_test.go b/apis/record_helpers_test.go new file mode 100644 index 00000000..9aa5a30b --- /dev/null +++ b/apis/record_helpers_test.go @@ -0,0 +1,101 @@ +package apis_test + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/labstack/echo/v5" + "github.com/pocketbase/pocketbase/apis" + "github.com/pocketbase/pocketbase/models" + "github.com/pocketbase/pocketbase/tests" +) + +func TestGetRequestData(t *testing.T) { + e := echo.New() + req := httptest.NewRequest(http.MethodPost, "/?test=123", strings.NewReader(`{"test":456}`)) + req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON) + rec := httptest.NewRecorder() + c := e.NewContext(req, rec) + + dummyRecord := &models.Record{} + dummyRecord.Id = "id1" + c.Set(apis.ContextAuthRecordKey, dummyRecord) + + dummyAdmin := &models.Admin{} + dummyAdmin.Id = "id2" + c.Set(apis.ContextAdminKey, dummyAdmin) + + result := apis.GetRequestData(c) + + if result == nil { + t.Fatal("Expected *models.FilterRequestData instance, got nil") + } + + if result.Method != http.MethodPost { + t.Fatalf("Expected Method %v, got %v", http.MethodPost, result.Method) + } + + rawQuery, _ := json.Marshal(result.Query) + expectedQuery := `{"test":"123"}` + if v := string(rawQuery); v != expectedQuery { + t.Fatalf("Expected Query %v, got %v", expectedQuery, v) + } + + rawData, _ := json.Marshal(result.Data) + expectedData := `{"test":456}` + if v := string(rawData); v != expectedData { + t.Fatalf("Expected Data %v, got %v", expectedData, v) + } + + if result.AuthRecord == nil || result.AuthRecord.Id != dummyRecord.Id { + t.Fatalf("Expected AuthRecord %v, got %v", dummyRecord, result.AuthRecord) + } + + if result.Admin == nil || result.Admin.Id != dummyAdmin.Id { + t.Fatalf("Expected Admin %v, got %v", dummyAdmin, result.Admin) + } +} + +func TestEnrichRecords(t *testing.T) { + e := echo.New() + req := httptest.NewRequest(http.MethodGet, "/?expand=rel_many", nil) + req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON) + rec := httptest.NewRecorder() + c := e.NewContext(req, rec) + + dummyAdmin := &models.Admin{} + dummyAdmin.Id = "test_id" + c.Set(apis.ContextAdminKey, dummyAdmin) + + app, _ := tests.NewTestApp() + defer app.Cleanup() + + records, err := app.Dao().FindRecordsByIds("demo1", []string{"al1h9ijdeojtsjy", "84nmscqy84lsi1t"}) + if err != nil { + t.Fatal(err) + } + + apis.EnrichRecords(c, app.Dao(), records, "rel_one") + + for _, record := range records { + expand := record.Expand() + if len(expand) == 0 { + t.Fatalf("Expected non-empty expand, got nil for record %v", record) + } + + if len(record.GetStringSlice("rel_one")) != 0 { + if _, ok := expand["rel_one"]; !ok { + t.Fatalf("Expected rel_one to be expanded for record %v, got \n%v", record, expand) + } + } + + if len(record.GetStringSlice("rel_many")) != 0 { + if _, ok := expand["rel_many"]; !ok { + t.Fatalf("Expected rel_many to be expanded for record %v, got \n%v", record, expand) + } + } + } +} diff --git a/core/app.go b/core/app.go index 75dde9ed..f51c4ec9 100644 --- a/core/app.go +++ b/core/app.go @@ -274,10 +274,10 @@ type App interface { // record data and token. OnRecordAuthRequest() *hook.Hook[*RecordAuthEvent] - // OnRecordListExternalAuths hook is triggered on each API record external auths list request. + // OnRecordListExternalAuthsRequest hook is triggered on each API record external auths list request. // // Could be used to validate or modify the response before returning it to the client. - OnRecordListExternalAuths() *hook.Hook[*RecordListExternalAuthsEvent] + OnRecordListExternalAuthsRequest() *hook.Hook[*RecordListExternalAuthsEvent] // OnRecordBeforeUnlinkExternalAuthRequest hook is triggered before each API record // external auth unlink request (after models load and before the actual relation deletion). diff --git a/core/base.go b/core/base.go index 45b257c8..208279a4 100644 --- a/core/base.go +++ b/core/base.go @@ -88,7 +88,7 @@ type BaseApp struct { // user api event hooks onRecordAuthRequest *hook.Hook[*RecordAuthEvent] - onRecordListExternalAuths *hook.Hook[*RecordListExternalAuthsEvent] + onRecordListExternalAuthsRequest *hook.Hook[*RecordListExternalAuthsEvent] onRecordBeforeUnlinkExternalAuthRequest *hook.Hook[*RecordUnlinkExternalAuthEvent] onRecordAfterUnlinkExternalAuthRequest *hook.Hook[*RecordUnlinkExternalAuthEvent] @@ -175,7 +175,7 @@ func NewBaseApp(dataDir string, encryptionEnv string, isDebug bool) *BaseApp { // user API event hooks onRecordAuthRequest: &hook.Hook[*RecordAuthEvent]{}, - onRecordListExternalAuths: &hook.Hook[*RecordListExternalAuthsEvent]{}, + onRecordListExternalAuthsRequest: &hook.Hook[*RecordListExternalAuthsEvent]{}, onRecordBeforeUnlinkExternalAuthRequest: &hook.Hook[*RecordUnlinkExternalAuthEvent]{}, onRecordAfterUnlinkExternalAuthRequest: &hook.Hook[*RecordUnlinkExternalAuthEvent]{}, @@ -574,8 +574,8 @@ func (app *BaseApp) OnRecordAuthRequest() *hook.Hook[*RecordAuthEvent] { return app.onRecordAuthRequest } -func (app *BaseApp) OnRecordListExternalAuths() *hook.Hook[*RecordListExternalAuthsEvent] { - return app.onRecordListExternalAuths +func (app *BaseApp) OnRecordListExternalAuthsRequest() *hook.Hook[*RecordListExternalAuthsEvent] { + return app.onRecordListExternalAuthsRequest } func (app *BaseApp) OnRecordBeforeUnlinkExternalAuthRequest() *hook.Hook[*RecordUnlinkExternalAuthEvent] { diff --git a/core/base_test.go b/core/base_test.go index dfa04261..81ef9117 100644 --- a/core/base_test.go +++ b/core/base_test.go @@ -319,8 +319,8 @@ func TestBaseAppGetters(t *testing.T) { t.Fatalf("Getter app.OnRecordAuthRequest does not match or nil (%v vs %v)", app.OnRecordAuthRequest(), app.onRecordAuthRequest) } - if app.onRecordListExternalAuths != app.OnRecordListExternalAuths() || app.OnRecordListExternalAuths() == nil { - t.Fatalf("Getter app.OnRecordListExternalAuths does not match or nil (%v vs %v)", app.OnRecordListExternalAuths(), app.onRecordListExternalAuths) + if app.onRecordListExternalAuthsRequest != app.OnRecordListExternalAuthsRequest() || app.OnRecordListExternalAuthsRequest() == nil { + t.Fatalf("Getter app.OnRecordListExternalAuthsRequest does not match or nil (%v vs %v)", app.OnRecordListExternalAuthsRequest(), app.onRecordListExternalAuthsRequest) } if app.onRecordBeforeUnlinkExternalAuthRequest != app.OnRecordBeforeUnlinkExternalAuthRequest() || app.OnRecordBeforeUnlinkExternalAuthRequest() == nil { diff --git a/models/filter_request_data.go b/models/filter_request_data.go new file mode 100644 index 00000000..fa6adc2a --- /dev/null +++ b/models/filter_request_data.go @@ -0,0 +1,11 @@ +package models + +// FilterRequestData defines a HTTP request data struct, usually used +// as part of the `@request.*` filter resolver. +type FilterRequestData struct { + Method string `json:"method"` + Query map[string]any `json:"query"` + Data map[string]any `json:"data"` + AuthRecord *Record `json:"authRecord"` + Admin *Admin `json:"admin"` +} diff --git a/resolvers/record_field_resolver.go b/resolvers/record_field_resolver.go index 1bc15880..4276094d 100644 --- a/resolvers/record_field_resolver.go +++ b/resolvers/record_field_resolver.go @@ -52,10 +52,11 @@ type RecordFieldResolver struct { baseCollection *models.Collection allowHiddenFields bool allowedFields []string - requestData map[string]any loadedCollections []*models.Collection joins []join // we cannot use a map because the insertion order is not preserved exprs []dbx.Expression + requestData *models.FilterRequestData + staticRequestData map[string]any } // NewRecordFieldResolver creates and initializes a new `RecordFieldResolver`. @@ -66,10 +67,10 @@ type RecordFieldResolver struct { func NewRecordFieldResolver( dao *daos.Dao, baseCollection *models.Collection, - requestData map[string]any, + requestData *models.FilterRequestData, allowHiddenFields bool, ) *RecordFieldResolver { - return &RecordFieldResolver{ + r := &RecordFieldResolver{ dao: dao, baseCollection: baseCollection, requestData: requestData, @@ -86,6 +87,22 @@ func NewRecordFieldResolver( `^\@collection\.\w+\.\w+[\w\.]*$`, }, } + + // @todo remove after IN operator and multi-match filter enhancements + r.staticRequestData = map[string]any{} + if r.requestData != nil { + r.staticRequestData["method"] = r.requestData.Method + r.staticRequestData["query"] = r.requestData.Query + r.staticRequestData["data"] = r.requestData.Data + r.staticRequestData["auth"] = nil + if r.requestData.AuthRecord != nil { + r.requestData.AuthRecord.IgnoreEmailVisibility(true) + r.staticRequestData["auth"] = r.requestData.AuthRecord.PublicExport() + r.requestData.AuthRecord.IgnoreEmailVisibility(false) + } + } + + return r } // UpdateQuery implements `search.FieldResolver` interface. @@ -159,6 +176,10 @@ func (r *RecordFieldResolver) Resolve(fieldName string) (resultName string, plac return "", nil, fmt.Errorf("Invalid @request data field path in %q.", fieldName) } + if r.requestData == nil { + return "NULL", nil, nil + } + // plain @request.* field if !strings.HasPrefix(fieldName, "@request.auth.") || list.ExistInSlice(fieldName, plainRequestAuthFields) { return r.resolveStaticRequestField(props[1:]...) @@ -173,28 +194,18 @@ func (r *RecordFieldResolver) Resolve(fieldName string) (resultName string, plac // resolve the auth collection fields // --- - rawAuthRecordId, _ := extractNestedMapVal(r.requestData, "auth", "id") - authRecordId := cast.ToString(rawAuthRecordId) - if authRecordId == "" { + if r.requestData == nil || r.requestData.AuthRecord == nil || r.requestData.AuthRecord.Collection() == nil { return "NULL", nil, nil } - rawAuthCollectionId, _ := extractNestedMapVal(r.requestData, "auth", schema.FieldNameCollectionId) - authCollectionId := cast.ToString(rawAuthCollectionId) - if authCollectionId == "" { - return "NULL", nil, nil - } - - collection, err := r.loadCollection(authCollectionId) - if err != nil { - return "", nil, fmt.Errorf("Failed to load collection %q from field path %q.", currentCollectionName, fieldName) - } + collection := r.requestData.AuthRecord.Collection() + r.loadedCollections = append(r.loadedCollections, collection) currentCollectionName = collection.Name currentTableAlias = "__auth_" + inflector.Columnify(currentCollectionName) authIdParamKey := "auth" + security.PseudorandomString(5) - authIdParams := dbx.Params{authIdParamKey: authRecordId} + authIdParams := dbx.Params{authIdParamKey: r.requestData.AuthRecord.Id} // --- // join the auth collection @@ -331,7 +342,7 @@ func (r *RecordFieldResolver) Resolve(fieldName string) (resultName string, plac func (r *RecordFieldResolver) resolveStaticRequestField(path ...string) (resultName string, placeholderParams dbx.Params, err error) { // ignore error because requestData is dynamic and some of the // lookup keys may not be defined for the request - resultVal, _ := extractNestedMapVal(r.requestData, path...) + resultVal, _ := extractNestedMapVal(r.staticRequestData, path...) switch v := resultVal.(type) { case nil: @@ -387,7 +398,7 @@ func extractNestedMapVal(m map[string]any, keys ...string) (result any, err erro func (r *RecordFieldResolver) loadCollection(collectionNameOrId string) (*models.Collection, error) { // return already loaded for _, collection := range r.loadedCollections { - if collection.Name == collectionNameOrId || collection.Id == collectionNameOrId { + if collection.Id == collectionNameOrId || strings.EqualFold(collection.Name, collectionNameOrId) { return collection, nil } } diff --git a/resolvers/record_field_resolver_test.go b/resolvers/record_field_resolver_test.go index 30467cde..7d63f6de 100644 --- a/resolvers/record_field_resolver_test.go +++ b/resolvers/record_field_resolver_test.go @@ -5,6 +5,7 @@ import ( "regexp" "testing" + "github.com/pocketbase/pocketbase/models" "github.com/pocketbase/pocketbase/resolvers" "github.com/pocketbase/pocketbase/tests" "github.com/pocketbase/pocketbase/tools/list" @@ -19,8 +20,8 @@ func TestRecordFieldResolverUpdateQuery(t *testing.T) { t.Fatal(err) } - requestData := map[string]any{ - "auth": authRecord.PublicExport(), + requestData := &models.FilterRequestData{ + AuthRecord: authRecord, } scenarios := []struct { @@ -181,8 +182,8 @@ func TestRecordFieldResolverResolveSchemaFields(t *testing.T) { t.Fatal(err) } - requestData := map[string]any{ - "auth": authRecord.PublicExport(), + requestData := &models.FilterRequestData{ + AuthRecord: authRecord, } r := resolvers.NewRecordFieldResolver(app.Dao(), collection, requestData, true) @@ -262,16 +263,16 @@ func TestRecordFieldResolverResolveStaticRequestDataFields(t *testing.T) { t.Fatal(err) } - requestData := map[string]any{ - "method": "get", - "query": map[string]any{ + requestData := &models.FilterRequestData{ + Method: "get", + Query: map[string]any{ "a": 123, }, - "data": map[string]any{ + Data: map[string]any{ "b": 456, "c": map[string]int{"sub": 1}, }, - "user": authRecord.PublicExport(), + AuthRecord: authRecord, } r := resolvers.NewRecordFieldResolver(app.Dao(), collection, requestData, true) @@ -295,7 +296,11 @@ func TestRecordFieldResolverResolveStaticRequestDataFields(t *testing.T) { {"@request.data.c", false, `"{\"sub\":1}"`}, {"@request.auth", true, ""}, {"@request.auth.id", false, `"4q1xlclmfloku33"`}, - {"@request.auth.file", false, `"[]"`}, + {"@request.auth.email", false, `"test@example.com"`}, + {"@request.auth.username", false, `"users75657"`}, + {"@request.auth.verified", false, `false`}, + {"@request.auth.emailVisibility", false, `false`}, + {"@request.auth.missing", false, `NULL`}, } for i, s := range scenarios { @@ -315,7 +320,7 @@ func TestRecordFieldResolverResolveStaticRequestDataFields(t *testing.T) { // --- if len(params) == 0 { if name != "NULL" { - t.Errorf("(%d) Expected 0 placeholder parameters, got %v", i, params) + t.Errorf("(%d) Expected 0 placeholder parameters for %v, got %v", i, name, params) } continue } @@ -323,7 +328,7 @@ func TestRecordFieldResolverResolveStaticRequestDataFields(t *testing.T) { // existing key // --- if len(params) != 1 { - t.Errorf("(%d) Expected 1 placeholder parameter, got %v", i, params) + t.Errorf("(%d) Expected 1 placeholder parameter for %v, got %v", i, name, params) continue } @@ -340,7 +345,7 @@ func TestRecordFieldResolverResolveStaticRequestDataFields(t *testing.T) { encodedParamValue, _ := json.Marshal(paramValue) if string(encodedParamValue) != s.expectParamValue { - t.Errorf("(%d) Expected params %v, got %v", i, s.expectParamValue, string(encodedParamValue)) + t.Errorf("(%d) Expected params %v for %v, got %v", i, s.expectParamValue, name, string(encodedParamValue)) } } } diff --git a/tests/app.go b/tests/app.go index 4a93d0ea..936f4cfc 100644 --- a/tests/app.go +++ b/tests/app.go @@ -162,8 +162,8 @@ func NewTestApp(optTestDataDir ...string) (*TestApp, error) { return nil }) - t.OnRecordListExternalAuths().Add(func(e *core.RecordListExternalAuthsEvent) error { - t.EventCalls["OnRecordListExternalAuths"]++ + t.OnRecordListExternalAuthsRequest().Add(func(e *core.RecordListExternalAuthsEvent) error { + t.EventCalls["OnRecordListExternalAuthsRequest"]++ return nil }) diff --git a/tests/data/data.db b/tests/data/data.db index 012e067d387e4ac0a96af43af3484359ace6ffec..0e56937a23873fa906405dacc1e2e066c4a8a356 100644 GIT binary patch delta 150 zcmZoTz}IkqZv*QBR;D)$Om8*|3Or)sm?g-_t}VM+cysaM7#>d@rQFQqqWt3gv=YbC zk_;uQYNgb?#H5_m6eX*)#GK;PS{Fv-F}|43p2V9}zGuF)<)9 bGdeReIx{yeGBP%U|F4JtuK~CJuL1$^`cxOw diff --git a/tests/data/logs.db b/tests/data/logs.db index ed29281cdfc167ca8701412cd96fe881add6df9b..1fcdb16f8e17c3526129abf9c1a1bf41b6ec6309 100644 GIT binary patch delta 5616 zcmeHLTZkLi86HV1udI<~d`aS@8+Wy9H?gy^JXg)g*y)-;Od!Tfn=~eUS>0B*)n!*& zNjq(kcm3dpLK}DJ9w*-W{SvT?s2AeW;wGt+<|T&GhD}2ajY~-%%+i{cLLuj9MjD;5 z1b)avAC}?p|NnQS??2~1^B?B_FWi0M!rd3%?64@8pMKGz{Gxy3_<^$jsfAt4kLHYr9nx8s&4EalqLIr#)9dpXg(bOWz* z69mx#mwv2a>Cvb-92h zj1PG+Z?)!KYVhI!T~yE61}AcSR7&$wYETga5e3YhZR=7`|NaX0a=|S~a?>+HxUmHR zc;FTeHLw?A;i3>r2Zxnla3~2*A11Tv9xh*h1K?@!jMpca17zR^yB~C#Pf20op|F$< z2E@=X%d6WT{kDCv}F{M{xxGBE?Z#^m*zkouZ$juG4^S zqyI>I>38T`)Cbgk`XBT-bsaq_(>q$(=>4&~Y`Or*w;|=LwaksM#lfsRLcT>1mc>CO zucZAUK9-4xLnR-11%f}#iE-axs4xyim%rqp1S9fFv8_RnPUgD)+w=aFyF_R;%T5^{hR_hv;~TSt#L& z5?7#vhe*5$2gt*3%Z=w<+tZ_WmcCnx}4VKlrATc zgbRfmdkSuhBIv)+f1p1C%+DMLXo`M7e@Xuu%IZ_;?O(R2ef_S=-vUS6WvA_cH98W> z<*U1VJ`hgD)76dsSyRzo?`SI8?(Z~p0{u->Hw<;%P}dA~)lgRqby-u($la0wFB2Ap-vm>l=0h1L%r&xK7iN24T8EsZ-ePQ)~@y&RQ0L!W|u7LxvZ;l zZ|IJzOxZfE^((3xo|A@&QvG?uL`i~??$IUt1{N5~X&*0`XZcgFmByL`Hy8lV+b8ivuEzrkUx*hHnGiK*&@ zgw+CCq!*h;u1(in>!!`_g5TFJaO!5aFRXCb;An%xzPa|ruJ7NZ>`og&z31L^rAWWi2A99Ry9+kP zdSKt8u`!0%li=1x`V{EhXIZLu0Z`{g^fIzwDxeMAxDOfd9HejTP(`r96wE#DKB^wO zw^?tx=qzlrej)S;P!v%*M4hlTDMw4Fw?S+8V delta 5359 zcmeHLeP|o!8P`dcKlDoP>yS8hNQV_KZP&Sx?{tz*@|Iu-YuVZow<{#H`@^SiDf<4F z(jv>j@wYAeY`^xJZe8g-@W%d&-rSn^Z|t(L5pa(dY5T3$5)ZDp}kK`yW57?e0BYIe+N%>0X|XrIc0v^Au=t}a@LOem+vN)M&If1KJ zsq4bVyN^C+a}LsWi_7kO$x2(Trx^Ma%b2b7pu+E#hE3A3qOZi1ijF{%kNG(lUi@>% zZyK`Ppxtg^I7hRr%qy}sY$CE>+%;pXor`*c&RoG>a^^iL{Cv*vD^d0t8@ZgdaLzrm zm3c+hg^f+Kb$OCue=*KylIgG;Ki|`K7;pbl|2q=Q)@K{a4kxqEgjtKoE6T?)fc@!R z^9?0gcOk;~9bPsP=Xp$B)xIsxcSqH_Ul@og;bm2lc!N6e*16f}>BWnsbwvKjW2xAFmq7mW5Te)(APp9+b{6VvakHz8{v)gBONBG#qub6iZ zosGx*p;UCw8J4~$`+TWXJoTI}o#q3+zQMky_)x^>HD}^xPs+zE{K%9yd(Z2P64!OP(q3*a^+?-TWVIegh@sNfGc*tj!5 zTQ~2As2-}UQnqq?pGmwU;bmo#+}5V#vJt00=}j_$JeSOcnTJWPaBb_6&r4ca&SK{r zFYXONxD zgyDuTG;~ooHw0_fhh|LJ@ix^}4g~C}pg$1yG5JWsnFZ@dbp6^$DN~BMJZvx?@_Gsm zuqKjed&m*-i#6S(6c$l@A8Mib!Tl z^u=g6UW%rYbj+0i%OaU7`~A_PEnnn3fl$y{0)ogo_^`*8FF73Hkj)XbgC&t{TDo9d zB;A2TEMIW3JX;F7ygslfl1;B7P!&n_>xBmkA{i@|Rc{6`FS1ShI+znl)vjEOfLW1j z+EKxbNH%SVU|J-b_A@Xgl4RM1cdt7zDY8uq1WbrzQ*DDQ_2i=q8nlf*KzGn>^b7Pe zw1Iwv-au6}iAGTYB~TEJkkVK~|3?2nf2$1*-_Q!@hGFgA3%s#Rif2P}+i<*1r`2k1 zgLTQSNft|XRkACRU6!mM*(K3ZRWL63i;}HMc0sc9lAV*RXI26;lAV_9lxV3%Fe%xI zx~=X!@i7hhHM)sv=ykM&CeSO$k6uQDXaJogg`Y$Z(8uVH=zS80Vf3}y(8v!cVR!`A z{>p#JBtG#cyf+$(8QwJLy0vu())ly>K&-%35!PP^ z-l`2HzE26~60r7B@~*K(qoKZm@BY-(QwHl$*Q=GcTd<~fvD#f#yDMsUS?vmHcS-Gz z*WL2YKSi*pK3rA13u<>>?ary)S+zT(cBgl{gAAD3?K5Cf?M|rOMx;%jYTy*0VM)NY7lhLezIW(6WH6V35U%5L6;^rrcH12gD&l$OFQV&>fM{X zH63(m58r$@nz65bR+rZJ)MX-nJ3I7N4g8boEn@%-bo{IR=WVaH#`V_PA#;zuX8p;3 z^(|vIeB?5_HNkFCo9veMgOk*yP2;w9Q>VW~F297|`+~tNSUy_EBNr$$zH?98K5^|s zEg3p8TFB6EUi+~1<*il<<}@%zlq2w||Ihai;d@&rKQFCu