package core import ( "context" "fmt" "regexp" "strconv" "strings" validation "github.com/go-ozzo/ozzo-validation/v4" "github.com/pocketbase/dbx" "github.com/pocketbase/pocketbase/core/validators" "github.com/pocketbase/pocketbase/tools/dbutils" "github.com/pocketbase/pocketbase/tools/list" "github.com/pocketbase/pocketbase/tools/search" "github.com/pocketbase/pocketbase/tools/types" ) var collectionNameRegex = regexp.MustCompile(`^\w+$`) func onCollectionValidate(e *CollectionEvent) error { var original *Collection if !e.Collection.IsNew() { original = &Collection{} if err := e.App.ModelQuery(original).Model(e.Collection.LastSavedPK(), original); err != nil { return fmt.Errorf("failed to fetch old collection state: %w", err) } } validator := newCollectionValidator( e.Context, e.App, e.Collection, original, ) return validator.run() } func newCollectionValidator(ctx context.Context, app App, new, original *Collection) *collectionValidator { validator := &collectionValidator{ ctx: ctx, app: app, new: new, original: original, } // load old/original collection if validator.original == nil { validator.original = NewCollection(validator.new.Type, "") } return validator } type collectionValidator struct { original *Collection new *Collection app App ctx context.Context } type optionsValidator interface { validate(cv *collectionValidator) error } func (validator *collectionValidator) run() error { if validator.original.IsNew() { validator.new.updateGeneratedIdIfExists(validator.app) } // generate fields from the query (overwriting any explicit user defined fields) if validator.new.IsView() { validator.new.Fields, _ = validator.app.CreateViewFields(validator.new.ViewQuery) } // validate base fields baseErr := validation.ValidateStruct(validator.new, validation.Field( &validator.new.Id, validation.Required, validation.When( validator.original.IsNew(), validation.Length(1, 100), validation.Match(DefaultIdRegex), validation.By(validators.UniqueId(validator.app.DB(), validator.new.TableName())), ).Else( validation.By(validators.Equal(validator.original.Id)), ), ), validation.Field( &validator.new.System, validation.By(validator.ensureNoSystemFlagChange), ), validation.Field( &validator.new.Type, validation.Required, validation.In( CollectionTypeBase, CollectionTypeAuth, CollectionTypeView, ), validation.By(validator.ensureNoTypeChange), ), validation.Field( &validator.new.Name, validation.Required, validation.Length(1, 255), validation.By(checkForVia), validation.Match(collectionNameRegex), validation.By(validator.ensureNoSystemNameChange), validation.By(validator.checkUniqueName), ), validation.Field( &validator.new.Fields, validation.By(validator.checkFieldDuplicates), validation.By(validator.checkMinFields), validation.When( !validator.new.IsView(), validation.By(validator.ensureNoSystemFieldsChange), validation.By(validator.ensureNoFieldsTypeChange), ), validation.When(validator.new.IsAuth(), validation.By(validator.checkReservedAuthKeys)), validation.By(validator.checkFieldValidators), ), validation.Field( &validator.new.ListRule, validation.By(validator.checkRule), validation.By(validator.ensureNoSystemRuleChange(validator.original.ListRule)), ), validation.Field( &validator.new.ViewRule, validation.By(validator.checkRule), validation.By(validator.ensureNoSystemRuleChange(validator.original.ViewRule)), ), validation.Field( &validator.new.CreateRule, validation.When(validator.new.IsView(), validation.Nil), validation.By(validator.checkRule), validation.By(validator.ensureNoSystemRuleChange(validator.original.CreateRule)), ), validation.Field( &validator.new.UpdateRule, validation.When(validator.new.IsView(), validation.Nil), validation.By(validator.checkRule), validation.By(validator.ensureNoSystemRuleChange(validator.original.UpdateRule)), ), validation.Field( &validator.new.DeleteRule, validation.When(validator.new.IsView(), validation.Nil), validation.By(validator.checkRule), validation.By(validator.ensureNoSystemRuleChange(validator.original.DeleteRule)), ), validation.Field(&validator.new.Indexes, validation.By(validator.checkIndexes)), ) optionsErr := validator.validateOptions() return validators.JoinValidationErrors(baseErr, optionsErr) } func (validator *collectionValidator) checkUniqueName(value any) error { v, _ := value.(string) // ensure unique collection name if !validator.app.IsCollectionNameUnique(v, validator.original.Id) { return validation.NewError("validation_collection_name_exists", "Collection name must be unique (case insensitive).") } // ensure that the collection name doesn't collide with the id of any collection dummyCollection := &Collection{} if validator.app.ModelQuery(dummyCollection).Model(v, dummyCollection) == nil { return validation.NewError("validation_collection_name_id_duplicate", "The name must not match an existing collection id.") } // ensure that there is no existing internal table with the provided name if validator.original.Name != v && // has changed validator.app.IsCollectionNameUnique(v) && // is not a collection (in case it was presaved) validator.app.HasTable(v) { return validation.NewError("validation_collection_name_invalid", "The name shouldn't match with an existing internal table.") } return nil } func (validator *collectionValidator) ensureNoSystemNameChange(value any) error { v, _ := value.(string) if !validator.original.IsNew() && validator.original.System && v != validator.original.Name { return validation.NewError("validation_collection_system_name_change", "System collection name cannot be changed.") } return nil } func (validator *collectionValidator) ensureNoSystemFlagChange(value any) error { v, _ := value.(bool) if !validator.original.IsNew() && v != validator.original.System { return validation.NewError("validation_collection_system_flag_change", "System collection state cannot be changed.") } return nil } func (validator *collectionValidator) ensureNoTypeChange(value any) error { v, _ := value.(string) if !validator.original.IsNew() && v != validator.original.Type { return validation.NewError("validation_collection_type_change", "Collection type cannot be changed.") } return nil } func (validator *collectionValidator) ensureNoFieldsTypeChange(value any) error { v, ok := value.(FieldsList) if !ok { return validators.ErrUnsupportedValueType } errs := validation.Errors{} for i, field := range v { oldField := validator.original.Fields.GetById(field.GetId()) if oldField != nil && oldField.Type() != field.Type() { errs[strconv.Itoa(i)] = validation.NewError( "validation_field_type_change", "Field type cannot be changed.", ) } } if len(errs) > 0 { return errs } return nil } func (validator *collectionValidator) checkFieldDuplicates(value any) error { fields, ok := value.(FieldsList) if !ok { return validators.ErrUnsupportedValueType } totalFields := len(fields) ids := make([]string, 0, totalFields) names := make([]string, 0, totalFields) for i, field := range fields { if list.ExistInSlice(field.GetId(), ids) { return validation.Errors{ strconv.Itoa(i): validation.Errors{ "id": validation.NewError( "validation_duplicated_field_id", fmt.Sprintf("Duplicated or invalid field id %q", field.GetId()), ), }, } } // field names are used as db columns and should be case insensitive nameLower := strings.ToLower(field.GetName()) if list.ExistInSlice(nameLower, names) { return validation.Errors{ strconv.Itoa(i): validation.Errors{ "name": validation.NewError( "validation_duplicated_field_name", "Duplicated or invalid field name {{.fieldName}}", ).SetParams(map[string]any{ "fieldName": field.GetName(), }), }, } } ids = append(ids, field.GetId()) names = append(names, nameLower) } return nil } func (validator *collectionValidator) checkFieldValidators(value any) error { fields, ok := value.(FieldsList) if !ok { return validators.ErrUnsupportedValueType } errs := validation.Errors{} for i, field := range fields { if err := field.ValidateSettings(validator.ctx, validator.app, validator.new); err != nil { errs[strconv.Itoa(i)] = err } } if len(errs) > 0 { return errs } return nil } func (cv *collectionValidator) checkViewQuery(value any) error { v, _ := value.(string) if v == "" { return nil // nothing to check } if _, err := cv.app.CreateViewFields(v); err != nil { return validation.NewError( "validation_invalid_view_query", fmt.Sprintf("Invalid query - %s", err.Error()), ) } return nil } var reservedAuthKeys = []string{"passwordConfirm", "oldPassword"} func (cv *collectionValidator) checkReservedAuthKeys(value any) error { fields, ok := value.(FieldsList) if !ok { return validators.ErrUnsupportedValueType } if !cv.new.IsAuth() { return nil // not an auth collection } errs := validation.Errors{} for i, field := range fields { if list.ExistInSlice(field.GetName(), reservedAuthKeys) { errs[strconv.Itoa(i)] = validation.Errors{ "name": validation.NewError( "validation_reserved_field_name", "The field name is reserved and cannot be used.", ), } } } if len(errs) > 0 { return errs } return nil } func (cv *collectionValidator) checkMinFields(value any) error { fields, ok := value.(FieldsList) if !ok { return validators.ErrUnsupportedValueType } if len(fields) == 0 { return validation.ErrRequired } // all collections must have an "id" PK field idField, _ := fields.GetByName(FieldNameId).(*TextField) if idField == nil || !idField.PrimaryKey { return validation.NewError("validation_missing_primary_key", `Missing or invalid "id" PK field.`) } switch cv.new.Type { case CollectionTypeAuth: passwordField, _ := fields.GetByName(FieldNamePassword).(*PasswordField) if passwordField == nil { return validation.NewError("validation_missing_password_field", `System "password" field is required.`) } if !passwordField.Hidden || !passwordField.System { return validation.Errors{FieldNamePassword: ErrMustBeSystemAndHidden} } tokenKeyField, _ := fields.GetByName(FieldNameTokenKey).(*TextField) if tokenKeyField == nil { return validation.NewError("validation_missing_tokenKey_field", `System "tokenKey" field is required.`) } if !tokenKeyField.Hidden || !tokenKeyField.System { return validation.Errors{FieldNameTokenKey: ErrMustBeSystemAndHidden} } emailField, _ := fields.GetByName(FieldNameEmail).(*EmailField) if emailField == nil { return validation.NewError("validation_missing_email_field", `System "email" field is required.`) } if !emailField.System { return validation.Errors{FieldNameEmail: ErrMustBeSystem} } emailVisibilityField, _ := fields.GetByName(FieldNameEmailVisibility).(*BoolField) if emailVisibilityField == nil { return validation.NewError("validation_missing_emailVisibility_field", `System "emailVisibility" field is required.`) } if !emailVisibilityField.System { return validation.Errors{FieldNameEmailVisibility: ErrMustBeSystem} } verifiedField, _ := fields.GetByName(FieldNameVerified).(*BoolField) if verifiedField == nil { return validation.NewError("validation_missing_verified_field", `System "verified" field is required.`) } if !verifiedField.System { return validation.Errors{FieldNameVerified: ErrMustBeSystem} } return nil } return nil } func (validator *collectionValidator) ensureNoSystemFieldsChange(value any) error { fields, ok := value.(FieldsList) if !ok { return validators.ErrUnsupportedValueType } if validator.original.IsNew() { return nil // not an update } for _, oldField := range validator.original.Fields { if !oldField.GetSystem() { continue } newField := fields.GetById(oldField.GetId()) if newField == nil || oldField.GetName() != newField.GetName() { return validation.NewError("validation_system_field_change", "System fields cannot be deleted or renamed.") } } return nil } func (cv *collectionValidator) checkFieldsForUniqueIndex(value any) error { names, ok := value.([]string) if !ok { return validators.ErrUnsupportedValueType } if len(names) == 0 { return nil // nothing to check } for _, name := range names { field := cv.new.Fields.GetByName(name) if field == nil { return validation.NewError("validation_missing_field", "Invalid or missing field {{.fieldName}}"). SetParams(map[string]any{"fieldName": name}) } if !dbutils.HasSingleColumnUniqueIndex(name, cv.new.Indexes) { return validation.NewError("validation_missing_unique_constraint", "The field {{.fieldName}} doesn't have a UNIQUE constraint."). SetParams(map[string]any{"fieldName": name}) } } return nil } // note: value could be either *string or string func (validator *collectionValidator) checkRule(value any) error { var vStr string v, ok := value.(*string) if ok { if v != nil { vStr = *v } } else { vStr, ok = value.(string) } if !ok { return validators.ErrUnsupportedValueType } if vStr == "" { return nil // nothing to check } r := NewRecordFieldResolver(validator.app, validator.new, nil, true) _, err := search.FilterData(vStr).BuildExpr(r) if err != nil { return validation.NewError("validation_invalid_rule", "Invalid rule. Raw error: "+err.Error()) } return nil } func (validator *collectionValidator) ensureNoSystemRuleChange(oldRule *string) validation.RuleFunc { return func(value any) error { if validator.original.IsNew() || !validator.original.System { return nil // not an update of a system collection } rule, ok := value.(*string) if !ok { return validators.ErrUnsupportedValueType } if (rule == nil && oldRule == nil) || (rule != nil && oldRule != nil && *rule == *oldRule) { return nil } return validation.NewError("validation_collection_system_rule_change", "System collection API rule cannot be changed.") } } func (cv *collectionValidator) checkIndexes(value any) error { indexes, _ := value.(types.JSONArray[string]) if cv.new.IsView() && len(indexes) > 0 { return validation.NewError( "validation_indexes_not_supported", "View collections don't support indexes.", ) } duplicatedNames := make(map[string]struct{}, len(indexes)) duplicatedDefinitions := make(map[string]struct{}, len(indexes)) for i, rawIndex := range indexes { parsed := dbutils.ParseIndex(rawIndex) // always set a table name because it is ignored anyway in order to keep it in sync with the collection name parsed.TableName = "validator" if !parsed.IsValid() { return validation.Errors{ strconv.Itoa(i): validation.NewError( "validation_invalid_index_expression", "Invalid CREATE INDEX expression.", ), } } if _, isDuplicated := duplicatedNames[strings.ToLower(parsed.IndexName)]; isDuplicated { return validation.Errors{ strconv.Itoa(i): validation.NewError( "validation_duplicated_index_name", "The index name already exists.", ), } } duplicatedNames[strings.ToLower(parsed.IndexName)] = struct{}{} // ensure that the index name is not used in another collection var usedTblName string _ = cv.app.DB().Select("tbl_name"). From("sqlite_master"). AndWhere(dbx.HashExp{"type": "index"}). AndWhere(dbx.NewExp("LOWER([[tbl_name]])!=LOWER({:oldName})", dbx.Params{"oldName": cv.original.Name})). AndWhere(dbx.NewExp("LOWER([[tbl_name]])!=LOWER({:newName})", dbx.Params{"newName": cv.new.Name})). AndWhere(dbx.NewExp("LOWER([[name]])=LOWER({:indexName})", dbx.Params{"indexName": parsed.IndexName})). Limit(1). Row(&usedTblName) if usedTblName != "" { return validation.Errors{ strconv.Itoa(i): validation.NewError( "validation_existing_index_name", "The index name is already used in {{.usedTableName}} collection.", ).SetParams(map[string]any{"usedTableName": usedTblName}), } } // reset non-important identifiers parsed.SchemaName = "validator" parsed.IndexName = "validator" parsedDef := parsed.Build() if _, isDuplicated := duplicatedDefinitions[parsedDef]; isDuplicated { return validation.Errors{ strconv.Itoa(i): validation.NewError( "validation_duplicated_index_definition", "The index definition already exists.", ), } } duplicatedDefinitions[parsedDef] = struct{}{} // note: we don't check the index table name because it is always // overwritten by the SyncRecordTableSchema to allow // easier partial modifications (eg. changing only the collection name). // if !strings.EqualFold(parsed.TableName, form.Name) { // return validation.Errors{ // strconv.Itoa(i): validation.NewError( // "validation_invalid_index_table", // fmt.Sprintf("The index table must be the same as the collection name."), // ), // } // } } // ensure that unique indexes on system fields are not changed or removed if !cv.original.IsNew() { OLD_INDEXES_LOOP: for _, oldIndex := range cv.original.Indexes { oldParsed := dbutils.ParseIndex(oldIndex) if !oldParsed.Unique { continue } // reset collate and sort since they are not important for the unique constraint for i := range oldParsed.Columns { oldParsed.Columns[i].Collate = "" oldParsed.Columns[i].Sort = "" } oldParsedStr := oldParsed.Build() for _, column := range oldParsed.Columns { for _, f := range cv.original.Fields { if !f.GetSystem() || !strings.EqualFold(column.Name, f.GetName()) { continue } var hasMatch bool for _, newIndex := range cv.new.Indexes { newParsed := dbutils.ParseIndex(newIndex) // exclude the non-important identifiers from the check newParsed.SchemaName = oldParsed.SchemaName newParsed.IndexName = oldParsed.IndexName newParsed.TableName = oldParsed.TableName // exclude partial constraints newParsed.Where = oldParsed.Where // reset collate and sort for i := range newParsed.Columns { newParsed.Columns[i].Collate = "" newParsed.Columns[i].Sort = "" } if oldParsedStr == newParsed.Build() { hasMatch = true break } } if !hasMatch { return validation.NewError( "validation_invalid_unique_system_field_index", "Unique index definition on system fields ({{.fieldName}}) is invalid or missing.", ).SetParams(map[string]any{"fieldName": f.GetName()}) } continue OLD_INDEXES_LOOP } } } } // check for required indexes // // note: this is in case the indexes were removed manually when creating/importing new auth collections // and technically it is not necessary because on app.Save() the missing indexes will be reinserted by the system collection hook if cv.new.IsAuth() { requiredNames := []string{FieldNameTokenKey, FieldNameEmail} for _, name := range requiredNames { if !dbutils.HasSingleColumnUniqueIndex(name, indexes) { return validation.NewError( "validation_missing_required_unique_index", `Missing required unique index for field "{{.fieldName}}".`, ).SetParams(map[string]any{"fieldName": name}) } } } return nil } func (validator *collectionValidator) validateOptions() error { switch validator.new.Type { case CollectionTypeAuth: return validator.new.collectionAuthOptions.validate(validator) case CollectionTypeView: return validator.new.collectionViewOptions.validate(validator) } return nil }