package apis import ( "errors" "fmt" "net/http" "strings" "github.com/pocketbase/dbx" "github.com/pocketbase/pocketbase/core" "github.com/pocketbase/pocketbase/forms" "github.com/pocketbase/pocketbase/tools/filesystem" "github.com/pocketbase/pocketbase/tools/router" "github.com/pocketbase/pocketbase/tools/search" ) // bindRecordCrudApi registers the record crud api endpoints and // the corresponding handlers. // // note: the rate limiter is "inlined" because some of the crud actions are also used in the batch APIs func bindRecordCrudApi(app core.App, rg *router.RouterGroup[*core.RequestEvent]) { subGroup := rg.Group("/collections/{collection}/records").Unbind(DefaultRateLimitMiddlewareId) subGroup.GET("", recordsList) subGroup.GET("/{id}", recordView) subGroup.POST("", recordCreate(nil)).Bind(dynamicCollectionBodyLimit("")) subGroup.PATCH("/{id}", recordUpdate(nil)).Bind(dynamicCollectionBodyLimit("")) subGroup.DELETE("/{id}", recordDelete(nil)) } func recordsList(e *core.RequestEvent) error { collection, err := e.App.FindCachedCollectionByNameOrId(e.Request.PathValue("collection")) if err != nil || collection == nil { return e.NotFoundError("Missing collection context.", err) } err = checkCollectionRateLimit(e, collection, "list") if err != nil { return err } requestInfo, err := e.RequestInfo() if err != nil { return firstApiError(err, e.BadRequestError("", err)) } if collection.ListRule == nil && !requestInfo.HasSuperuserAuth() { return e.ForbiddenError("Only superusers can perform this action.", nil) } // forbid users and guests to query special filter/sort fields err = checkForSuperuserOnlyRuleFields(requestInfo) if err != nil { return err } fieldsResolver := core.NewRecordFieldResolver( e.App, collection, requestInfo, // hidden fields are searchable only by superusers requestInfo.HasSuperuserAuth(), ) searchProvider := search.NewProvider(fieldsResolver). Query(e.App.RecordQuery(collection)) if !requestInfo.HasSuperuserAuth() && collection.ListRule != nil { searchProvider.AddFilter(search.FilterData(*collection.ListRule)) } records := []*core.Record{} result, err := searchProvider.ParseAndExec(e.Request.URL.Query().Encode(), &records) if err != nil { return firstApiError(err, e.BadRequestError("", err)) } event := new(core.RecordsListRequestEvent) event.RequestEvent = e event.Collection = collection event.Records = records event.Result = result return e.App.OnRecordsListRequest().Trigger(event, func(e *core.RecordsListRequestEvent) error { if err := EnrichRecords(e.RequestEvent, e.Records); err != nil { return firstApiError(err, e.InternalServerError("Failed to enrich records", err)) } return e.JSON(http.StatusOK, e.Result) }) } func recordView(e *core.RequestEvent) error { collection, err := e.App.FindCachedCollectionByNameOrId(e.Request.PathValue("collection")) if err != nil || collection == nil { return e.NotFoundError("Missing collection context.", err) } err = checkCollectionRateLimit(e, collection, "view") if err != nil { return err } recordId := e.Request.PathValue("id") if recordId == "" { return e.NotFoundError("", nil) } requestInfo, err := e.RequestInfo() if err != nil { return firstApiError(err, e.BadRequestError("", err)) } if collection.ViewRule == nil && !requestInfo.HasSuperuserAuth() { return e.ForbiddenError("Only superusers can perform this action.", nil) } ruleFunc := func(q *dbx.SelectQuery) error { if !requestInfo.HasSuperuserAuth() && collection.ViewRule != nil && *collection.ViewRule != "" { resolver := core.NewRecordFieldResolver(e.App, collection, requestInfo, true) expr, err := search.FilterData(*collection.ViewRule).BuildExpr(resolver) if err != nil { return err } resolver.UpdateQuery(q) q.AndWhere(expr) } return nil } record, fetchErr := e.App.FindRecordById(collection, recordId, ruleFunc) if fetchErr != nil || record == nil { return firstApiError(err, e.NotFoundError("", fetchErr)) } event := new(core.RecordRequestEvent) event.RequestEvent = e event.Collection = collection event.Record = record return e.App.OnRecordViewRequest().Trigger(event, func(e *core.RecordRequestEvent) error { if err := EnrichRecord(e.RequestEvent, e.Record); err != nil { return firstApiError(err, e.InternalServerError("Failed to enrich record", err)) } return e.JSON(http.StatusOK, e.Record) }) } func recordCreate(optFinalizer func() error) func(e *core.RequestEvent) error { return func(e *core.RequestEvent) error { collection, err := e.App.FindCachedCollectionByNameOrId(e.Request.PathValue("collection")) if err != nil || collection == nil { return e.NotFoundError("Missing collection context.", err) } if collection.IsView() { return e.BadRequestError("Unsupported collection type.", nil) } err = checkCollectionRateLimit(e, collection, "create") if err != nil { return err } requestInfo, err := e.RequestInfo() if err != nil { return firstApiError(err, e.BadRequestError("", err)) } hasSuperuserAuth := requestInfo.HasSuperuserAuth() canSkipRuleCheck := hasSuperuserAuth // special case for the first superuser creation // --- if !canSkipRuleCheck && collection.Name == core.CollectionNameSuperusers { total, totalErr := e.App.CountRecords(core.CollectionNameSuperusers) canSkipRuleCheck = totalErr == nil && total == 0 } // --- if !canSkipRuleCheck && collection.CreateRule == nil { return e.ForbiddenError("Only superusers can perform this action.", nil) } record := core.NewRecord(collection) data, err := recordDataFromRequest(e, record) if err != nil { return firstApiError(err, e.BadRequestError("Failed to read the submitted data.", err)) } // replace modifiers fields so that the resolved value is always // available when accessing requestInfo.Body requestInfo.Body = data form := forms.NewRecordUpsert(e.App, record) if hasSuperuserAuth { form.GrantSuperuserAccess() } form.Load(data) var isOptFinalizerCalled bool event := new(core.RecordRequestEvent) event.RequestEvent = e event.Collection = collection event.Record = record hookErr := e.App.OnRecordCreateRequest().Trigger(event, func(e *core.RecordRequestEvent) error { form.SetApp(e.App) form.SetRecord(e.Record) // temporary save the record and check it against the create and manage rules if !canSkipRuleCheck && e.Collection.CreateRule != nil { // temporary grant manager access level form.GrantManagerAccess() // manually unset the verified field to prevent manage API rule misuse in case the rule relies on it initialVerified := e.Record.Verified() if initialVerified { e.Record.SetVerified(false) } createRuleFunc := func(q *dbx.SelectQuery) error { if *e.Collection.CreateRule == "" { return nil // no create rule to resolve } resolver := core.NewRecordFieldResolver(e.App, e.Collection, requestInfo, true) expr, err := search.FilterData(*e.Collection.CreateRule).BuildExpr(resolver) if err != nil { return err } resolver.UpdateQuery(q) q.AndWhere(expr) return nil } testErr := form.DrySubmit(func(txApp core.App, drySavedRecord *core.Record) error { foundRecord, err := txApp.FindRecordById(drySavedRecord.Collection(), drySavedRecord.Id, createRuleFunc) if err != nil { return fmt.Errorf("DrySubmit create rule failure: %w", err) } // reset the form access level in case it satisfies the Manage API rule if !hasAuthManageAccess(txApp, requestInfo, foundRecord) { form.ResetAccess() } return nil }) if testErr != nil { return e.BadRequestError("Failed to create record.", testErr) } // restore initial verified state (it will be further validated on submit) if initialVerified != e.Record.Verified() { e.Record.SetVerified(initialVerified) } } err := form.Submit() if err != nil { return firstApiError(err, e.BadRequestError("Failed to create record.", err)) } err = EnrichRecord(e.RequestEvent, e.Record) if err != nil { return firstApiError(err, e.InternalServerError("Failed to enrich record", err)) } err = e.JSON(http.StatusOK, e.Record) if err != nil { return err } if optFinalizer != nil { isOptFinalizerCalled = true err = optFinalizer() if err != nil { return firstApiError(err, e.InternalServerError("", err)) } } return nil }) if hookErr != nil { return hookErr } // e.g. in case the regular hook chain was stopped and the finalizer cannot be executed as part of the last e.Next() task if !isOptFinalizerCalled && optFinalizer != nil { if err := optFinalizer(); err != nil { return firstApiError(err, e.InternalServerError("", err)) } } return nil } } func recordUpdate(optFinalizer func() error) func(e *core.RequestEvent) error { return func(e *core.RequestEvent) error { collection, err := e.App.FindCachedCollectionByNameOrId(e.Request.PathValue("collection")) if err != nil || collection == nil { return e.NotFoundError("Missing collection context.", err) } if collection.IsView() { return e.BadRequestError("Unsupported collection type.", nil) } err = checkCollectionRateLimit(e, collection, "update") if err != nil { return err } recordId := e.Request.PathValue("id") if recordId == "" { return e.NotFoundError("", nil) } requestInfo, err := e.RequestInfo() if err != nil { return firstApiError(err, e.BadRequestError("", err)) } hasSuperuserAuth := requestInfo.HasSuperuserAuth() if !hasSuperuserAuth && collection.UpdateRule == nil { return firstApiError(err, e.ForbiddenError("Only superusers can perform this action.", nil)) } // eager fetch the record so that the modifiers field values can be resolved record, err := e.App.FindRecordById(collection, recordId) if err != nil { return firstApiError(err, e.NotFoundError("", err)) } data, err := recordDataFromRequest(e, record) if err != nil { return firstApiError(err, e.BadRequestError("Failed to read the submitted data.", err)) } // replace modifiers fields so that the resolved value is always // available when accessing requestInfo.Body requestInfo.Body = data ruleFunc := func(q *dbx.SelectQuery) error { if !hasSuperuserAuth && collection.UpdateRule != nil && *collection.UpdateRule != "" { resolver := core.NewRecordFieldResolver(e.App, collection, requestInfo, true) expr, err := search.FilterData(*collection.UpdateRule).BuildExpr(resolver) if err != nil { return err } resolver.UpdateQuery(q) q.AndWhere(expr) } return nil } // refetch with access checks record, err = e.App.FindRecordById(collection, recordId, ruleFunc) if err != nil { return firstApiError(err, e.NotFoundError("", err)) } form := forms.NewRecordUpsert(e.App, record) if hasSuperuserAuth { form.GrantSuperuserAccess() } form.Load(data) var isOptFinalizerCalled bool event := new(core.RecordRequestEvent) event.RequestEvent = e event.Collection = collection event.Record = record hookErr := e.App.OnRecordUpdateRequest().Trigger(event, func(e *core.RecordRequestEvent) error { form.SetApp(e.App) form.SetRecord(e.Record) if !form.HasManageAccess() && hasAuthManageAccess(e.App, requestInfo, e.Record) { form.GrantManagerAccess() } err := form.Submit() if err != nil { return firstApiError(err, e.BadRequestError("Failed to update record.", err)) } err = EnrichRecord(e.RequestEvent, e.Record) if err != nil { return firstApiError(err, e.InternalServerError("Failed to enrich record", err)) } err = e.JSON(http.StatusOK, e.Record) if err != nil { return err } if optFinalizer != nil { isOptFinalizerCalled = true err = optFinalizer() if err != nil { return firstApiError(err, e.InternalServerError("", fmt.Errorf("update optFinalizer error: %w", err))) } } return nil }) if hookErr != nil { return hookErr } // e.g. in case the regular hook chain was stopped and the finalizer cannot be executed as part of the last e.Next() task if !isOptFinalizerCalled && optFinalizer != nil { if err := optFinalizer(); err != nil { return firstApiError(err, e.InternalServerError("", fmt.Errorf("update optFinalizer error: %w", err))) } } return nil } } func recordDelete(optFinalizer func() error) func(e *core.RequestEvent) error { return func(e *core.RequestEvent) error { collection, err := e.App.FindCachedCollectionByNameOrId(e.Request.PathValue("collection")) if err != nil || collection == nil { return e.NotFoundError("Missing collection context.", err) } if collection.IsView() { return e.BadRequestError("Unsupported collection type.", nil) } err = checkCollectionRateLimit(e, collection, "delete") if err != nil { return err } recordId := e.Request.PathValue("id") if recordId == "" { return e.NotFoundError("", nil) } requestInfo, err := e.RequestInfo() if err != nil { return firstApiError(err, e.BadRequestError("", err)) } if !requestInfo.HasSuperuserAuth() && collection.DeleteRule == nil { return e.ForbiddenError("Only superusers can perform this action.", nil) } ruleFunc := func(q *dbx.SelectQuery) error { if !requestInfo.HasSuperuserAuth() && collection.DeleteRule != nil && *collection.DeleteRule != "" { resolver := core.NewRecordFieldResolver(e.App, collection, requestInfo, true) expr, err := search.FilterData(*collection.DeleteRule).BuildExpr(resolver) if err != nil { return err } resolver.UpdateQuery(q) q.AndWhere(expr) } return nil } record, err := e.App.FindRecordById(collection, recordId, ruleFunc) if err != nil || record == nil { return e.NotFoundError("", err) } var isOptFinalizerCalled bool event := new(core.RecordRequestEvent) event.RequestEvent = e event.Collection = collection event.Record = record hookErr := e.App.OnRecordDeleteRequest().Trigger(event, func(e *core.RecordRequestEvent) error { if err := e.App.Delete(e.Record); err != nil { return firstApiError(err, e.BadRequestError("Failed to delete record. Make sure that the record is not part of a required relation reference.", err)) } err = e.NoContent(http.StatusNoContent) if err != nil { return err } if optFinalizer != nil { isOptFinalizerCalled = true err = optFinalizer() if err != nil { return firstApiError(err, e.InternalServerError("", fmt.Errorf("delete optFinalizer error: %w", err))) } } return nil }) if hookErr != nil { return hookErr } // e.g. in case the regular hook chain was stopped and the finalizer cannot be executed as part of the last e.Next() task if !isOptFinalizerCalled && optFinalizer != nil { if err := optFinalizer(); err != nil { return firstApiError(err, e.InternalServerError("", fmt.Errorf("delete optFinalizer error: %w", err))) } } return nil } } // ------------------------------------------------------------------- func recordDataFromRequest(e *core.RequestEvent, record *core.Record) (map[string]any, error) { info, err := e.RequestInfo() if err != nil { return nil, err } // resolve regular fields result := record.ReplaceModifiers(info.Body) // resolve uploaded files uploadedFiles, err := extractUploadedFiles(e.Request, record.Collection(), "") if err != nil { return nil, err } if len(uploadedFiles) > 0 { for k, v := range uploadedFiles { result[k] = v } result = record.ReplaceModifiers(result) } isAuth := record.Collection().IsAuth() // unset hidden fields for non-superusers if !info.HasSuperuserAuth() { for _, f := range record.Collection().Fields { if f.GetHidden() { // exception for the auth collection "password" field if isAuth && f.GetName() == core.FieldNamePassword { continue } delete(result, f.GetName()) } } } return result, nil } func extractUploadedFiles(request *http.Request, collection *core.Collection, prefix string) (map[string][]*filesystem.File, error) { contentType := request.Header.Get("content-type") if !strings.HasPrefix(contentType, "multipart/form-data") { return nil, nil // not multipart/form-data request } result := map[string][]*filesystem.File{} for _, field := range collection.Fields { if field.Type() != core.FieldTypeFile { continue } baseKey := field.GetName() keys := []string{ baseKey, // prepend and append modifiers "+" + baseKey, baseKey + "+", } for _, k := range keys { if prefix != "" { k = prefix + "." + k } files, err := FindUploadedFiles(request, k) if err != nil && !errors.Is(err, http.ErrMissingFile) { return nil, err } if len(files) > 0 { result[k] = files } } } return result, nil }