mirror of
https://github.com/pocketbase/pocketbase.git
synced 2024-11-24 17:07:00 +02:00
346 lines
10 KiB
Go
346 lines
10 KiB
Go
package apis
|
|
|
|
import (
|
|
"fmt"
|
|
"log"
|
|
"log/slog"
|
|
"net/http"
|
|
"strings"
|
|
|
|
"github.com/labstack/echo/v5"
|
|
"github.com/pocketbase/dbx"
|
|
"github.com/pocketbase/pocketbase/core"
|
|
"github.com/pocketbase/pocketbase/daos"
|
|
"github.com/pocketbase/pocketbase/models"
|
|
"github.com/pocketbase/pocketbase/resolvers"
|
|
"github.com/pocketbase/pocketbase/tokens"
|
|
"github.com/pocketbase/pocketbase/tools/inflector"
|
|
"github.com/pocketbase/pocketbase/tools/rest"
|
|
"github.com/pocketbase/pocketbase/tools/search"
|
|
)
|
|
|
|
const ContextRequestInfoKey = "requestInfo"
|
|
|
|
const expandQueryParam = "expand"
|
|
const fieldsQueryParam = "fields"
|
|
|
|
// Deprecated: Use RequestInfo instead.
|
|
func RequestData(c echo.Context) *models.RequestInfo {
|
|
log.Println("RequestData(c) is deprecated and will be removed in the future! You can replace it with RequestInfo(c).")
|
|
return RequestInfo(c)
|
|
}
|
|
|
|
// RequestInfo exports cached common request data fields
|
|
// (query, body, logged auth state, etc.) from the provided context.
|
|
func RequestInfo(c echo.Context) *models.RequestInfo {
|
|
// return cached to avoid copying the body multiple times
|
|
if v := c.Get(ContextRequestInfoKey); v != nil {
|
|
if data, ok := v.(*models.RequestInfo); ok {
|
|
// refresh auth state
|
|
data.AuthRecord, _ = c.Get(ContextAuthRecordKey).(*models.Record)
|
|
data.Admin, _ = c.Get(ContextAdminKey).(*models.Admin)
|
|
return data
|
|
}
|
|
}
|
|
|
|
result := &models.RequestInfo{
|
|
Context: models.RequestInfoContextDefault,
|
|
Method: c.Request().Method,
|
|
Query: map[string]any{},
|
|
Data: map[string]any{},
|
|
Headers: map[string]any{},
|
|
}
|
|
|
|
// extract the first value of all headers and normalizes the keys
|
|
// ("X-Token" is converted to "x_token")
|
|
for k, v := range c.Request().Header {
|
|
if len(v) > 0 {
|
|
result.Headers[inflector.Snakecase(k)] = v[0]
|
|
}
|
|
}
|
|
|
|
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(ContextRequestInfoKey, result)
|
|
|
|
return result
|
|
}
|
|
|
|
// RecordAuthResponse writes standardised json record auth response
|
|
// into the specified request context.
|
|
func RecordAuthResponse(
|
|
app core.App,
|
|
c echo.Context,
|
|
authRecord *models.Record,
|
|
meta any,
|
|
finalizers ...func(token string) error,
|
|
) error {
|
|
if !authRecord.Verified() && authRecord.Collection().AuthOptions().OnlyVerified {
|
|
return NewForbiddenError("Please verify your account first.", nil)
|
|
}
|
|
|
|
token, tokenErr := tokens.NewRecordAuthToken(app, authRecord)
|
|
if tokenErr != nil {
|
|
return NewBadRequestError("Failed to create auth token.", tokenErr)
|
|
}
|
|
|
|
event := new(core.RecordAuthEvent)
|
|
event.HttpContext = c
|
|
event.Collection = authRecord.Collection()
|
|
event.Record = authRecord
|
|
event.Token = token
|
|
event.Meta = meta
|
|
|
|
return app.OnRecordAuthRequest().Trigger(event, func(e *core.RecordAuthEvent) error {
|
|
if e.HttpContext.Response().Committed {
|
|
return nil
|
|
}
|
|
|
|
// 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 {
|
|
// create a copy of the cached request data and adjust it to the current auth record
|
|
requestInfo := *RequestInfo(e.HttpContext)
|
|
requestInfo.Admin = nil
|
|
requestInfo.AuthRecord = e.Record
|
|
failed := app.Dao().ExpandRecord(
|
|
e.Record,
|
|
expands,
|
|
expandFetch(app.Dao(), &requestInfo),
|
|
)
|
|
if len(failed) > 0 {
|
|
app.Logger().Debug("[RecordAuthResponse] Failed to expand relations", slog.Any("errors", failed))
|
|
}
|
|
}
|
|
|
|
result := map[string]any{
|
|
"token": e.Token,
|
|
"record": e.Record,
|
|
}
|
|
|
|
if e.Meta != nil {
|
|
result["meta"] = e.Meta
|
|
}
|
|
|
|
for _, f := range finalizers {
|
|
if err := f(e.Token); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
return e.HttpContext.JSON(http.StatusOK, 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 visible 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 visible 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 {
|
|
requestInfo := RequestInfo(c)
|
|
|
|
if err := autoIgnoreAuthRecordsEmailVisibility(dao, records, requestInfo); err != nil {
|
|
return fmt.Errorf("failed to resolve email visibility: %w", err)
|
|
}
|
|
|
|
expands := defaultExpands
|
|
if param := c.QueryParam(expandQueryParam); param != "" {
|
|
expands = append(expands, strings.Split(param, ",")...)
|
|
}
|
|
if len(expands) == 0 {
|
|
return nil // nothing to expand
|
|
}
|
|
|
|
errs := dao.ExpandRecords(records, expands, expandFetch(dao, requestInfo))
|
|
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,
|
|
requestInfo *models.RequestInfo,
|
|
) 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 requestInfo.Admin != nil {
|
|
return nil // admins can access everything
|
|
}
|
|
|
|
if relCollection.ViewRule == nil {
|
|
return fmt.Errorf("only admins can view collection %q records", relCollection.Name)
|
|
}
|
|
|
|
if *relCollection.ViewRule != "" {
|
|
resolver := resolvers.NewRecordFieldResolver(dao, relCollection, requestInfo, true)
|
|
expr, err := search.FilterData(*(relCollection.ViewRule)).BuildExpr(resolver)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
resolver.UpdateQuery(q)
|
|
q.AndWhere(expr)
|
|
}
|
|
|
|
return nil
|
|
})
|
|
|
|
if err == nil && len(records) > 0 {
|
|
autoIgnoreAuthRecordsEmailVisibility(dao, records, requestInfo)
|
|
}
|
|
|
|
return records, err
|
|
}
|
|
}
|
|
|
|
// autoIgnoreAuthRecordsEmailVisibility ignores the email visibility check for
|
|
// the provided record if the current auth model is admin, owner or a "manager".
|
|
//
|
|
// Note: Expects all records to be from the same auth collection!
|
|
func autoIgnoreAuthRecordsEmailVisibility(
|
|
dao *daos.Dao,
|
|
records []*models.Record,
|
|
requestInfo *models.RequestInfo,
|
|
) error {
|
|
if len(records) == 0 || !records[0].Collection().IsAuth() {
|
|
return nil // nothing to check
|
|
}
|
|
|
|
if requestInfo.Admin != nil {
|
|
for _, rec := range records {
|
|
rec.IgnoreEmailVisibility(true)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
collection := records[0].Collection()
|
|
|
|
mappedRecords := make(map[string]*models.Record, len(records))
|
|
recordIds := make([]any, len(records))
|
|
for i, rec := range records {
|
|
mappedRecords[rec.Id] = rec
|
|
recordIds[i] = rec.Id
|
|
}
|
|
|
|
if requestInfo != nil && requestInfo.AuthRecord != nil && mappedRecords[requestInfo.AuthRecord.Id] != nil {
|
|
mappedRecords[requestInfo.AuthRecord.Id].IgnoreEmailVisibility(true)
|
|
}
|
|
|
|
authOptions := collection.AuthOptions()
|
|
if authOptions.ManageRule == nil || *authOptions.ManageRule == "" {
|
|
return nil // no manage rule to check
|
|
}
|
|
|
|
// fetch the ids of the managed records
|
|
// ---
|
|
managedIds := []string{}
|
|
|
|
query := dao.RecordQuery(collection).
|
|
Select(dao.DB().QuoteSimpleColumnName(collection.Name) + ".id").
|
|
AndWhere(dbx.In(dao.DB().QuoteSimpleColumnName(collection.Name)+".id", recordIds...))
|
|
|
|
resolver := resolvers.NewRecordFieldResolver(dao, collection, requestInfo, true)
|
|
expr, err := search.FilterData(*authOptions.ManageRule).BuildExpr(resolver)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
resolver.UpdateQuery(query)
|
|
query.AndWhere(expr)
|
|
|
|
if err := query.Column(&managedIds); err != nil {
|
|
return err
|
|
}
|
|
// ---
|
|
|
|
// ignore the email visibility check for the managed records
|
|
for _, id := range managedIds {
|
|
if rec, ok := mappedRecords[id]; ok {
|
|
rec.IgnoreEmailVisibility(true)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// hasAuthManageAccess checks whether the client is allowed to have full
|
|
// [forms.RecordUpsert] auth management permissions
|
|
// (aka. allowing to change system auth fields without oldPassword).
|
|
func hasAuthManageAccess(
|
|
dao *daos.Dao,
|
|
record *models.Record,
|
|
requestInfo *models.RequestInfo,
|
|
) bool {
|
|
if !record.Collection().IsAuth() {
|
|
return false
|
|
}
|
|
|
|
manageRule := record.Collection().AuthOptions().ManageRule
|
|
|
|
if manageRule == nil || *manageRule == "" {
|
|
return false // only for admins (manageRule can't be empty)
|
|
}
|
|
|
|
if requestInfo == nil || requestInfo.AuthRecord == nil {
|
|
return false // no auth record
|
|
}
|
|
|
|
ruleFunc := func(q *dbx.SelectQuery) error {
|
|
resolver := resolvers.NewRecordFieldResolver(dao, record.Collection(), requestInfo, true)
|
|
expr, err := search.FilterData(*manageRule).BuildExpr(resolver)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
resolver.UpdateQuery(q)
|
|
q.AndWhere(expr)
|
|
return nil
|
|
}
|
|
|
|
_, findErr := dao.FindRecordById(record.Collection().Id, record.Id, ruleFunc)
|
|
|
|
return findErr == nil
|
|
}
|
|
|
|
var ruleQueryParams = []string{search.FilterQueryParam, search.SortQueryParam}
|
|
var adminOnlyRuleFields = []string{"@collection.", "@request."}
|
|
|
|
// @todo consider moving the rules check to the RecordFieldResolver.
|
|
//
|
|
// checkForAdminOnlyRuleFields loosely checks and returns an error if
|
|
// the provided RequestInfo contains rule fields that only the admin can use.
|
|
func checkForAdminOnlyRuleFields(requestInfo *models.RequestInfo) error {
|
|
if requestInfo.Admin != nil || len(requestInfo.Query) == 0 {
|
|
return nil // admin or nothing to check
|
|
}
|
|
|
|
for _, param := range ruleQueryParams {
|
|
v, _ := requestInfo.Query[param].(string)
|
|
if v == "" {
|
|
continue
|
|
}
|
|
|
|
for _, field := range adminOnlyRuleFields {
|
|
if strings.Contains(v, field) {
|
|
return NewForbiddenError("Only admins can filter by "+field, nil)
|
|
}
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|