mirror of
https://github.com/pocketbase/pocketbase.git
synced 2024-12-03 19:26:50 +02:00
600 lines
17 KiB
Go
600 lines
17 KiB
Go
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
|
|
}
|