[#943] exposed apis.EnrichRecord and apis.EnrichRecords

This commit is contained in:
Gani Georgiev 2022-11-17 14:17:10 +02:00
parent 6e9cf986c5
commit 39408f135b
16 changed files with 297 additions and 212 deletions

View File

@ -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. 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. 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)), _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._ so all feature requests will be first aligned with what we need for Presentator v3._

View File

@ -251,16 +251,10 @@ func (api *realtimeApi) canAccessRecord(client subscriptions.Client, record *mod
} }
// emulate request data // emulate request data
requestData := map[string]any{ requestData := &models.FilterRequestData{
"method": "GET", 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.AuthRecord, _ = client.Get(ContextAuthRecordKey).(*models.Record)
resolver := resolvers.NewRecordFieldResolver(api.app.Dao(), record.Collection(), requestData, true) resolver := resolvers.NewRecordFieldResolver(api.app.Dao(), record.Collection(), requestData, true)
expr, err := search.FilterData(*accessRule).BuildExpr(resolver) expr, err := search.FilterData(*accessRule).BuildExpr(resolver)

View File

@ -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 { 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 // allow always returning the email address of the authenticated account
e.Record.IgnoreEmailVisibility(true) e.Record.IgnoreEmailVisibility(true)
// expand record relations // expand record relations
expands := strings.Split(c.QueryParam(expandQueryParam), ",") expands := strings.Split(c.QueryParam(expandQueryParam), ",")
if len(expands) > 0 { if len(expands) > 0 {
requestData := exportRequestData(e.HttpContext) requestData := GetRequestData(e.HttpContext)
requestData["auth"] = e.Record.PublicExport() requestData.Admin = nil
requestData.AuthRecord = e.Record
failed := api.app.Dao().ExpandRecord( failed := api.app.Dao().ExpandRecord(
e.Record, e.Record,
expands, expands,
expandFetch(api.app.Dao(), admin != nil, requestData), expandFetch(api.app.Dao(), requestData),
) )
if len(failed) > 0 && api.app.IsDebug() { if len(failed) > 0 && api.app.IsDebug() {
log.Println("Failed to expand relations: ", failed) 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 { record, authData, submitErr := form.Submit(func(createForm *forms.RecordUpsert, authRecord *models.Record, authUser *auth.AuthUser) error {
return createForm.DrySubmit(func(txDao *daos.Dao) error { return createForm.DrySubmit(func(txDao *daos.Dao) error {
requestData := exportRequestData(c) requestData := GetRequestData(c)
requestData["data"] = form.CreateData requestData.Data = form.CreateData
createRuleFunc := func(q *dbx.SelectQuery) error { createRuleFunc := func(q *dbx.SelectQuery) error {
admin, _ := c.Get(ContextAdminKey).(*models.Admin) admin, _ := c.Get(ContextAdminKey).(*models.Admin)
@ -422,7 +421,7 @@ func (api *recordAuthApi) listExternalAuths(c echo.Context) error {
ExternalAuths: externalAuths, 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) return e.HttpContext.JSON(http.StatusOK, e.ExternalAuths)
}) })
} }

View File

@ -886,7 +886,7 @@ func TestRecordAuthListExternalsAuths(t *testing.T) {
}, },
ExpectedStatus: 200, ExpectedStatus: 200,
ExpectedContent: []string{`[]`}, ExpectedContent: []string{`[]`},
ExpectedEvents: map[string]int{"OnRecordListExternalAuths": 1}, ExpectedEvents: map[string]int{"OnRecordListExternalAuthsRequest": 1},
}, },
{ {
Name: "admin + existing user id and 2 external auths", Name: "admin + existing user id and 2 external auths",
@ -902,7 +902,7 @@ func TestRecordAuthListExternalsAuths(t *testing.T) {
`"recordId":"4q1xlclmfloku33"`, `"recordId":"4q1xlclmfloku33"`,
`"collectionId":"_pb_users_auth_"`, `"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", Name: "auth record + trying to list another user external auths",
@ -933,7 +933,7 @@ func TestRecordAuthListExternalsAuths(t *testing.T) {
}, },
ExpectedStatus: 200, ExpectedStatus: 200,
ExpectedContent: []string{`[]`}, ExpectedContent: []string{`[]`},
ExpectedEvents: map[string]int{"OnRecordListExternalAuths": 1}, ExpectedEvents: map[string]int{"OnRecordListExternalAuthsRequest": 1},
}, },
{ {
Name: "authorized as user - owner with 2 external auths", Name: "authorized as user - owner with 2 external auths",
@ -949,7 +949,7 @@ func TestRecordAuthListExternalsAuths(t *testing.T) {
`"recordId":"4q1xlclmfloku33"`, `"recordId":"4q1xlclmfloku33"`,
`"collectionId":"_pb_users_auth_"`, `"collectionId":"_pb_users_auth_"`,
}, },
ExpectedEvents: map[string]int{"OnRecordListExternalAuths": 1}, ExpectedEvents: map[string]int{"OnRecordListExternalAuthsRequest": 1},
}, },
} }

View File

@ -46,31 +46,30 @@ func (api *recordApi) list(c echo.Context) error {
return NewNotFoundError("", "Missing collection context.") 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 // forbid users and guests to query special filter/sort fields
if err := api.checkForForbiddenQueryFields(c); err != nil { if err := api.checkForForbiddenQueryFields(c); err != nil {
return err 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( fieldsResolver := resolvers.NewRecordFieldResolver(
api.app.Dao(), api.app.Dao(),
collection, collection,
requestData, requestData,
// hidden fields are searchable only by admins // hidden fields are searchable only by admins
admin != nil, requestData.Admin != nil,
) )
searchProvider := search.NewProvider(fieldsResolver). searchProvider := search.NewProvider(fieldsResolver).
Query(api.app.Dao().RecordQuery(collection)) 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)) searchProvider.AddFilter(search.FilterData(*collection.ListRule))
} }
@ -82,28 +81,6 @@ func (api *recordApi) list(c echo.Context) error {
records := models.NewRecordsFromNullStringMaps(collection, rawRecords) 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 result.Items = records
event := &core.RecordsListEvent{ 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 { 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) 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.") 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") recordId := c.PathParam("id")
if recordId == "" { if recordId == "" {
return NewNotFoundError("", nil) 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 { 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) resolver := resolvers.NewRecordFieldResolver(api.app.Dao(), collection, requestData, true)
expr, err := search.FilterData(*collection.ViewRule).BuildExpr(resolver) expr, err := search.FilterData(*collection.ViewRule).BuildExpr(resolver)
if err != nil { if err != nil {
@ -155,31 +135,16 @@ func (api *recordApi) view(c echo.Context) error {
return NewNotFoundError("", fetchErr) 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{ event := &core.RecordViewEvent{
HttpContext: c, HttpContext: c,
Record: record, Record: record,
} }
return api.app.OnRecordViewRequest().Trigger(event, func(e *core.RecordViewEvent) error { 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) 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.") return NewNotFoundError("", "Missing collection context.")
} }
admin, _ := c.Get(ContextAdminKey).(*models.Admin) requestData := GetRequestData(c)
if admin == nil && collection.CreateRule == nil {
if requestData.Admin == nil && collection.CreateRule == nil {
// only admins can access if the rule is nil // only admins can access if the rule is nil
return NewForbiddenError("Only admins can perform this action.", nil) return NewForbiddenError("Only admins can perform this action.", nil)
} }
requestData := exportRequestData(c) hasFullManageAccess := requestData.Admin != nil
hasFullManageAccess := admin != nil
// temporary save the record and check it against the create rule // 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 { createRuleFunc := func(q *dbx.SelectQuery) error {
if *collection.CreateRule == "" { if *collection.CreateRule == "" {
return nil // no create rule to resolve 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) return NewBadRequestError("Failed to create record.", err)
} }
// expand record relations if err := EnrichRecord(e.HttpContext, api.app.Dao(), e.Record); err != nil && api.app.IsDebug() {
failed := api.app.Dao().ExpandRecord( log.Println(err)
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)
}
} }
return e.HttpContext.JSON(http.StatusOK, e.Record) 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.") 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") recordId := c.PathParam("id")
if recordId == "" { if recordId == "" {
return NewNotFoundError("", nil) 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 { 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) resolver := resolvers.NewRecordFieldResolver(api.app.Dao(), collection, requestData, true)
expr, err := search.FilterData(*collection.UpdateRule).BuildExpr(resolver) expr, err := search.FilterData(*collection.UpdateRule).BuildExpr(resolver)
if err != nil { if err != nil {
@ -330,7 +278,7 @@ func (api *recordApi) update(c echo.Context) error {
} }
form := forms.NewRecordUpsert(api.app, record) 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 // load request
if err := form.LoadRequest(c.Request(), ""); err != nil { 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) return NewBadRequestError("Failed to update record.", err)
} }
// expand record relations if err := EnrichRecord(e.HttpContext, api.app.Dao(), e.Record); err != nil && api.app.IsDebug() {
failed := api.app.Dao().ExpandRecord( log.Println(err)
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)
}
} }
return e.HttpContext.JSON(http.StatusOK, e.Record) 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.") 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") recordId := c.PathParam("id")
if recordId == "" { if recordId == "" {
return NewNotFoundError("", nil) 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 { 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) resolver := resolvers.NewRecordFieldResolver(api.app.Dao(), collection, requestData, true)
expr, err := search.FilterData(*collection.DeleteRule).BuildExpr(resolver) expr, err := search.FilterData(*collection.DeleteRule).BuildExpr(resolver)
if err != nil { if err != nil {

View File

@ -2,6 +2,7 @@ package apis
import ( import (
"fmt" "fmt"
"strings"
"github.com/labstack/echo/v5" "github.com/labstack/echo/v5"
"github.com/pocketbase/dbx" "github.com/pocketbase/dbx"
@ -10,45 +11,77 @@ import (
"github.com/pocketbase/pocketbase/resolvers" "github.com/pocketbase/pocketbase/resolvers"
"github.com/pocketbase/pocketbase/tools/rest" "github.com/pocketbase/pocketbase/tools/rest"
"github.com/pocketbase/pocketbase/tools/search" "github.com/pocketbase/pocketbase/tools/search"
"github.com/spf13/cast"
) )
// exportRequestData exports a map with common request fields. const ContextRequestDataKey = "requestData"
//
// @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
echo.BindQueryParams(c, &queryParams) // GetRequestData exports common request data fields
// (query, body, logged auth state, etc.) from the provided context.
rest.BindBody(c, &bodyData) func GetRequestData(c echo.Context) *models.FilterRequestData {
// return cached to avoid reading the body multiple times
result["method"] = method if v := c.Get(ContextRequestDataKey); v != nil {
result["query"] = queryParams if data, ok := v.(*models.FilterRequestData); ok {
result["data"] = bodyData return data
result["auth"] = nil }
auth, _ := c.Get(ContextAuthRecordKey).(*models.Record)
if auth != nil {
result["auth"] = auth.PublicExport()
} }
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 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. // expandFetch is the records fetch function that is used to expand related records.
func expandFetch( func expandFetch(
dao *daos.Dao, dao *daos.Dao,
isAdmin bool, requestData *models.FilterRequestData,
requestData map[string]any,
) daos.ExpandFetchFunc { ) daos.ExpandFetchFunc {
return func(relCollection *models.Collection, relIds []string) ([]*models.Record, error) { return func(relCollection *models.Collection, relIds []string) ([]*models.Record, error) {
records, err := dao.FindRecordsByIds(relCollection.Id, relIds, func(q *dbx.SelectQuery) 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 return nil // admins can access everything
} }
@ -70,7 +103,7 @@ func expandFetch(
}) })
if err == nil && len(records) > 0 { if err == nil && len(records) > 0 {
autoIgnoreAuthRecordsEmailVisibility(dao, records, isAdmin, requestData) autoIgnoreAuthRecordsEmailVisibility(dao, records, requestData)
} }
return records, err return records, err
@ -84,14 +117,13 @@ func expandFetch(
func autoIgnoreAuthRecordsEmailVisibility( func autoIgnoreAuthRecordsEmailVisibility(
dao *daos.Dao, dao *daos.Dao,
records []*models.Record, records []*models.Record,
isAdmin bool, requestData *models.FilterRequestData,
requestData map[string]any,
) error { ) error {
if len(records) == 0 || !records[0].Collection().IsAuth() { if len(records) == 0 || !records[0].Collection().IsAuth() {
return nil // nothing to check return nil // nothing to check
} }
if isAdmin { if requestData.Admin != nil {
for _, rec := range records { for _, rec := range records {
rec.IgnoreEmailVisibility(true) rec.IgnoreEmailVisibility(true)
} }
@ -107,8 +139,8 @@ func autoIgnoreAuthRecordsEmailVisibility(
recordIds = append(recordIds, rec.Id) recordIds = append(recordIds, rec.Id)
} }
if auth, ok := requestData["auth"].(map[string]any); ok && mappedRecords[cast.ToString(auth["id"])] != nil { if requestData != nil && requestData.AuthRecord != nil && mappedRecords[requestData.AuthRecord.Id] != nil {
mappedRecords[cast.ToString(auth["id"])].IgnoreEmailVisibility(true) mappedRecords[requestData.AuthRecord.Id].IgnoreEmailVisibility(true)
} }
authOptions := collection.AuthOptions() authOptions := collection.AuthOptions()
@ -153,7 +185,7 @@ func autoIgnoreAuthRecordsEmailVisibility(
func hasAuthManageAccess( func hasAuthManageAccess(
dao *daos.Dao, dao *daos.Dao,
record *models.Record, record *models.Record,
requestData map[string]any, requestData *models.FilterRequestData,
) bool { ) bool {
if !record.Collection().IsAuth() { if !record.Collection().IsAuth() {
return false return false
@ -165,7 +197,7 @@ func hasAuthManageAccess(
return false // only for admins (manageRule can't be empty) 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 return false // no auth record
} }

101
apis/record_helpers_test.go Normal file
View File

@ -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)
}
}
}
}

View File

@ -274,10 +274,10 @@ type App interface {
// record data and token. // record data and token.
OnRecordAuthRequest() *hook.Hook[*RecordAuthEvent] 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. // 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 // OnRecordBeforeUnlinkExternalAuthRequest hook is triggered before each API record
// external auth unlink request (after models load and before the actual relation deletion). // external auth unlink request (after models load and before the actual relation deletion).

View File

@ -88,7 +88,7 @@ type BaseApp struct {
// user api event hooks // user api event hooks
onRecordAuthRequest *hook.Hook[*RecordAuthEvent] onRecordAuthRequest *hook.Hook[*RecordAuthEvent]
onRecordListExternalAuths *hook.Hook[*RecordListExternalAuthsEvent] onRecordListExternalAuthsRequest *hook.Hook[*RecordListExternalAuthsEvent]
onRecordBeforeUnlinkExternalAuthRequest *hook.Hook[*RecordUnlinkExternalAuthEvent] onRecordBeforeUnlinkExternalAuthRequest *hook.Hook[*RecordUnlinkExternalAuthEvent]
onRecordAfterUnlinkExternalAuthRequest *hook.Hook[*RecordUnlinkExternalAuthEvent] onRecordAfterUnlinkExternalAuthRequest *hook.Hook[*RecordUnlinkExternalAuthEvent]
@ -175,7 +175,7 @@ func NewBaseApp(dataDir string, encryptionEnv string, isDebug bool) *BaseApp {
// user API event hooks // user API event hooks
onRecordAuthRequest: &hook.Hook[*RecordAuthEvent]{}, onRecordAuthRequest: &hook.Hook[*RecordAuthEvent]{},
onRecordListExternalAuths: &hook.Hook[*RecordListExternalAuthsEvent]{}, onRecordListExternalAuthsRequest: &hook.Hook[*RecordListExternalAuthsEvent]{},
onRecordBeforeUnlinkExternalAuthRequest: &hook.Hook[*RecordUnlinkExternalAuthEvent]{}, onRecordBeforeUnlinkExternalAuthRequest: &hook.Hook[*RecordUnlinkExternalAuthEvent]{},
onRecordAfterUnlinkExternalAuthRequest: &hook.Hook[*RecordUnlinkExternalAuthEvent]{}, onRecordAfterUnlinkExternalAuthRequest: &hook.Hook[*RecordUnlinkExternalAuthEvent]{},
@ -574,8 +574,8 @@ func (app *BaseApp) OnRecordAuthRequest() *hook.Hook[*RecordAuthEvent] {
return app.onRecordAuthRequest return app.onRecordAuthRequest
} }
func (app *BaseApp) OnRecordListExternalAuths() *hook.Hook[*RecordListExternalAuthsEvent] { func (app *BaseApp) OnRecordListExternalAuthsRequest() *hook.Hook[*RecordListExternalAuthsEvent] {
return app.onRecordListExternalAuths return app.onRecordListExternalAuthsRequest
} }
func (app *BaseApp) OnRecordBeforeUnlinkExternalAuthRequest() *hook.Hook[*RecordUnlinkExternalAuthEvent] { func (app *BaseApp) OnRecordBeforeUnlinkExternalAuthRequest() *hook.Hook[*RecordUnlinkExternalAuthEvent] {

View File

@ -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) 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 { if app.onRecordListExternalAuthsRequest != app.OnRecordListExternalAuthsRequest() || app.OnRecordListExternalAuthsRequest() == nil {
t.Fatalf("Getter app.OnRecordListExternalAuths does not match or nil (%v vs %v)", app.OnRecordListExternalAuths(), app.onRecordListExternalAuths) 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 { if app.onRecordBeforeUnlinkExternalAuthRequest != app.OnRecordBeforeUnlinkExternalAuthRequest() || app.OnRecordBeforeUnlinkExternalAuthRequest() == nil {

View File

@ -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"`
}

View File

@ -52,10 +52,11 @@ type RecordFieldResolver struct {
baseCollection *models.Collection baseCollection *models.Collection
allowHiddenFields bool allowHiddenFields bool
allowedFields []string allowedFields []string
requestData map[string]any
loadedCollections []*models.Collection loadedCollections []*models.Collection
joins []join // we cannot use a map because the insertion order is not preserved joins []join // we cannot use a map because the insertion order is not preserved
exprs []dbx.Expression exprs []dbx.Expression
requestData *models.FilterRequestData
staticRequestData map[string]any
} }
// NewRecordFieldResolver creates and initializes a new `RecordFieldResolver`. // NewRecordFieldResolver creates and initializes a new `RecordFieldResolver`.
@ -66,10 +67,10 @@ type RecordFieldResolver struct {
func NewRecordFieldResolver( func NewRecordFieldResolver(
dao *daos.Dao, dao *daos.Dao,
baseCollection *models.Collection, baseCollection *models.Collection,
requestData map[string]any, requestData *models.FilterRequestData,
allowHiddenFields bool, allowHiddenFields bool,
) *RecordFieldResolver { ) *RecordFieldResolver {
return &RecordFieldResolver{ r := &RecordFieldResolver{
dao: dao, dao: dao,
baseCollection: baseCollection, baseCollection: baseCollection,
requestData: requestData, requestData: requestData,
@ -86,6 +87,22 @@ func NewRecordFieldResolver(
`^\@collection\.\w+\.\w+[\w\.]*$`, `^\@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. // 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) return "", nil, fmt.Errorf("Invalid @request data field path in %q.", fieldName)
} }
if r.requestData == nil {
return "NULL", nil, nil
}
// plain @request.* field // plain @request.* field
if !strings.HasPrefix(fieldName, "@request.auth.") || list.ExistInSlice(fieldName, plainRequestAuthFields) { if !strings.HasPrefix(fieldName, "@request.auth.") || list.ExistInSlice(fieldName, plainRequestAuthFields) {
return r.resolveStaticRequestField(props[1:]...) return r.resolveStaticRequestField(props[1:]...)
@ -173,28 +194,18 @@ func (r *RecordFieldResolver) Resolve(fieldName string) (resultName string, plac
// resolve the auth collection fields // resolve the auth collection fields
// --- // ---
rawAuthRecordId, _ := extractNestedMapVal(r.requestData, "auth", "id") if r.requestData == nil || r.requestData.AuthRecord == nil || r.requestData.AuthRecord.Collection() == nil {
authRecordId := cast.ToString(rawAuthRecordId)
if authRecordId == "" {
return "NULL", nil, nil return "NULL", nil, nil
} }
rawAuthCollectionId, _ := extractNestedMapVal(r.requestData, "auth", schema.FieldNameCollectionId) collection := r.requestData.AuthRecord.Collection()
authCollectionId := cast.ToString(rawAuthCollectionId) r.loadedCollections = append(r.loadedCollections, collection)
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)
}
currentCollectionName = collection.Name currentCollectionName = collection.Name
currentTableAlias = "__auth_" + inflector.Columnify(currentCollectionName) currentTableAlias = "__auth_" + inflector.Columnify(currentCollectionName)
authIdParamKey := "auth" + security.PseudorandomString(5) authIdParamKey := "auth" + security.PseudorandomString(5)
authIdParams := dbx.Params{authIdParamKey: authRecordId} authIdParams := dbx.Params{authIdParamKey: r.requestData.AuthRecord.Id}
// --- // ---
// join the auth collection // 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) { func (r *RecordFieldResolver) resolveStaticRequestField(path ...string) (resultName string, placeholderParams dbx.Params, err error) {
// ignore error because requestData is dynamic and some of the // ignore error because requestData is dynamic and some of the
// lookup keys may not be defined for the request // lookup keys may not be defined for the request
resultVal, _ := extractNestedMapVal(r.requestData, path...) resultVal, _ := extractNestedMapVal(r.staticRequestData, path...)
switch v := resultVal.(type) { switch v := resultVal.(type) {
case nil: 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) { func (r *RecordFieldResolver) loadCollection(collectionNameOrId string) (*models.Collection, error) {
// return already loaded // return already loaded
for _, collection := range r.loadedCollections { for _, collection := range r.loadedCollections {
if collection.Name == collectionNameOrId || collection.Id == collectionNameOrId { if collection.Id == collectionNameOrId || strings.EqualFold(collection.Name, collectionNameOrId) {
return collection, nil return collection, nil
} }
} }

View File

@ -5,6 +5,7 @@ import (
"regexp" "regexp"
"testing" "testing"
"github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/resolvers" "github.com/pocketbase/pocketbase/resolvers"
"github.com/pocketbase/pocketbase/tests" "github.com/pocketbase/pocketbase/tests"
"github.com/pocketbase/pocketbase/tools/list" "github.com/pocketbase/pocketbase/tools/list"
@ -19,8 +20,8 @@ func TestRecordFieldResolverUpdateQuery(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
requestData := map[string]any{ requestData := &models.FilterRequestData{
"auth": authRecord.PublicExport(), AuthRecord: authRecord,
} }
scenarios := []struct { scenarios := []struct {
@ -181,8 +182,8 @@ func TestRecordFieldResolverResolveSchemaFields(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
requestData := map[string]any{ requestData := &models.FilterRequestData{
"auth": authRecord.PublicExport(), AuthRecord: authRecord,
} }
r := resolvers.NewRecordFieldResolver(app.Dao(), collection, requestData, true) r := resolvers.NewRecordFieldResolver(app.Dao(), collection, requestData, true)
@ -262,16 +263,16 @@ func TestRecordFieldResolverResolveStaticRequestDataFields(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
requestData := map[string]any{ requestData := &models.FilterRequestData{
"method": "get", Method: "get",
"query": map[string]any{ Query: map[string]any{
"a": 123, "a": 123,
}, },
"data": map[string]any{ Data: map[string]any{
"b": 456, "b": 456,
"c": map[string]int{"sub": 1}, "c": map[string]int{"sub": 1},
}, },
"user": authRecord.PublicExport(), AuthRecord: authRecord,
} }
r := resolvers.NewRecordFieldResolver(app.Dao(), collection, requestData, true) r := resolvers.NewRecordFieldResolver(app.Dao(), collection, requestData, true)
@ -295,7 +296,11 @@ func TestRecordFieldResolverResolveStaticRequestDataFields(t *testing.T) {
{"@request.data.c", false, `"{\"sub\":1}"`}, {"@request.data.c", false, `"{\"sub\":1}"`},
{"@request.auth", true, ""}, {"@request.auth", true, ""},
{"@request.auth.id", false, `"4q1xlclmfloku33"`}, {"@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 { for i, s := range scenarios {
@ -315,7 +320,7 @@ func TestRecordFieldResolverResolveStaticRequestDataFields(t *testing.T) {
// --- // ---
if len(params) == 0 { if len(params) == 0 {
if name != "NULL" { 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 continue
} }
@ -323,7 +328,7 @@ func TestRecordFieldResolverResolveStaticRequestDataFields(t *testing.T) {
// existing key // existing key
// --- // ---
if len(params) != 1 { 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 continue
} }
@ -340,7 +345,7 @@ func TestRecordFieldResolverResolveStaticRequestDataFields(t *testing.T) {
encodedParamValue, _ := json.Marshal(paramValue) encodedParamValue, _ := json.Marshal(paramValue)
if string(encodedParamValue) != s.expectParamValue { 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))
} }
} }
} }

View File

@ -162,8 +162,8 @@ func NewTestApp(optTestDataDir ...string) (*TestApp, error) {
return nil return nil
}) })
t.OnRecordListExternalAuths().Add(func(e *core.RecordListExternalAuthsEvent) error { t.OnRecordListExternalAuthsRequest().Add(func(e *core.RecordListExternalAuthsEvent) error {
t.EventCalls["OnRecordListExternalAuths"]++ t.EventCalls["OnRecordListExternalAuthsRequest"]++
return nil return nil
}) })

Binary file not shown.

Binary file not shown.