1
0
Fork 0

Adding upstream version 0.28.1.

Signed-off-by: Daniel Baumann <daniel@debian.org>
This commit is contained in:
Daniel Baumann 2025-05-22 10:57:38 +02:00
parent 88f1d47ab6
commit e28c88ef14
Signed by: daniel
GPG key ID: FBB4F0E80A80222F
933 changed files with 194711 additions and 0 deletions

80
core/validators/db.go Normal file
View file

@ -0,0 +1,80 @@
package validators
import (
"database/sql"
"errors"
"strings"
validation "github.com/go-ozzo/ozzo-validation/v4"
"github.com/pocketbase/dbx"
)
// UniqueId checks whether a field string id already exists in the specified table.
//
// Example:
//
// validation.Field(&form.RelId, validation.By(validators.UniqueId(form.app.DB(), "tbl_example"))
func UniqueId(db dbx.Builder, tableName string) validation.RuleFunc {
return func(value any) error {
v, _ := value.(string)
if v == "" {
return nil // nothing to check
}
var foundId string
err := db.
Select("id").
From(tableName).
Where(dbx.HashExp{"id": v}).
Limit(1).
Row(&foundId)
if (err != nil && !errors.Is(err, sql.ErrNoRows)) || foundId != "" {
return validation.NewError("validation_invalid_or_existing_id", "The model id is invalid or already exists.")
}
return nil
}
}
// NormalizeUniqueIndexError attempts to convert a
// "unique constraint failed" error into a validation.Errors.
//
// The provided err is returned as it is without changes if:
// - err is nil
// - err is already validation.Errors
// - err is not "unique constraint failed" error
func NormalizeUniqueIndexError(err error, tableOrAlias string, fieldNames []string) error {
if err == nil {
return err
}
if _, ok := err.(validation.Errors); ok {
return err
}
msg := strings.ToLower(err.Error())
// check for unique constraint failure
if strings.Contains(msg, "unique constraint failed") {
// note: extra space to unify multi-columns lookup
msg = strings.ReplaceAll(strings.TrimSpace(msg), ",", " ") + " "
normalizedErrs := validation.Errors{}
for _, name := range fieldNames {
// note: extra spaces to exclude table name with suffix matching the current one
// OR other fields starting with the current field name
if strings.Contains(msg, strings.ToLower(" "+tableOrAlias+"."+name+" ")) {
normalizedErrs[name] = validation.NewError("validation_not_unique", "Value must be unique")
}
}
if len(normalizedErrs) > 0 {
return normalizedErrs
}
}
return err
}

125
core/validators/db_test.go Normal file
View file

@ -0,0 +1,125 @@
package validators_test
import (
"errors"
"fmt"
"testing"
validation "github.com/go-ozzo/ozzo-validation/v4"
"github.com/pocketbase/pocketbase/core/validators"
"github.com/pocketbase/pocketbase/tests"
)
func TestUniqueId(t *testing.T) {
t.Parallel()
app, _ := tests.NewTestApp()
defer app.Cleanup()
scenarios := []struct {
id string
tableName string
expectError bool
}{
{"", "", false},
{"test", "", true},
{"wsmn24bux7wo113", "_collections", true},
{"test_unique_id", "unknown_table", true},
{"test_unique_id", "_collections", false},
}
for i, s := range scenarios {
t.Run(fmt.Sprintf("%d_%s_%s", i, s.id, s.tableName), func(t *testing.T) {
err := validators.UniqueId(app.DB(), s.tableName)(s.id)
hasErr := err != nil
if hasErr != s.expectError {
t.Fatalf("Expected hasErr to be %v, got %v (%v)", s.expectError, hasErr, err)
}
})
}
}
func TestNormalizeUniqueIndexError(t *testing.T) {
t.Parallel()
scenarios := []struct {
name string
err error
table string
names []string
expectedKeys []string
}{
{
"nil error (no changes)",
nil,
"test",
[]string{"a", "b"},
nil,
},
{
"non-unique index error (no changes)",
errors.New("abc"),
"test",
[]string{"a", "b"},
nil,
},
{
"validation error (no changes)",
validation.Errors{"c": errors.New("abc")},
"test",
[]string{"a", "b"},
[]string{"c"},
},
{
"unique index error but mismatched table name",
errors.New("UNIQUE constraint failed for fields test.a,test.b"),
"example",
[]string{"a", "b"},
nil,
},
{
"unique index error with table name suffix matching the specified one",
errors.New("UNIQUE constraint failed for fields test_suffix.a,test_suffix.b"),
"suffix",
[]string{"a", "b", "c"},
nil,
},
{
"unique index error but mismatched fields",
errors.New("UNIQUE constraint failed for fields test.a,test.b"),
"test",
[]string{"c", "d"},
nil,
},
{
"unique index error with matching table name and fields",
errors.New("UNIQUE constraint failed for fields test.a,test.b"),
"test",
[]string{"a", "b", "c"},
[]string{"a", "b"},
},
{
"unique index error with matching table name and field starting with the name of another non-unique field",
errors.New("UNIQUE constraint failed for fields test.a_2,test.c"),
"test",
[]string{"a", "a_2", "c"},
[]string{"a_2", "c"},
},
}
for _, s := range scenarios {
t.Run(s.name, func(t *testing.T) {
result := validators.NormalizeUniqueIndexError(s.err, s.table, s.names)
if len(s.expectedKeys) == 0 {
if result != s.err {
t.Fatalf("Expected no error change, got %v", result)
}
return
}
tests.TestValidationErrors(t, result, s.expectedKeys)
})
}
}

85
core/validators/equal.go Normal file
View file

@ -0,0 +1,85 @@
package validators
import (
"reflect"
validation "github.com/go-ozzo/ozzo-validation/v4"
)
// Equal checks whether the validated value matches another one from the same type.
//
// It expects the compared values to be from the same type and works
// with booleans, numbers, strings and their pointer variants.
//
// If one of the value is pointer, the comparison is based on its
// underlying value (when possible to determine).
//
// Note that empty/zero values are also compared (this differ from other validation.RuleFunc).
//
// Example:
//
// validation.Field(&form.PasswordConfirm, validation.By(validators.Equal(form.Password)))
func Equal[T comparable](valueToCompare T) validation.RuleFunc {
return func(value any) error {
if compareValues(value, valueToCompare) {
return nil
}
return validation.NewError("validation_values_mismatch", "Values don't match.")
}
}
func compareValues(a, b any) bool {
if a == b {
return true
}
if checkIsNil(a) && checkIsNil(b) {
return true
}
var result bool
defer func() {
if err := recover(); err != nil {
result = false
}
}()
reflectA := reflect.ValueOf(a)
reflectB := reflect.ValueOf(b)
dereferencedA := dereference(reflectA)
dereferencedB := dereference(reflectB)
if dereferencedA.CanInterface() && dereferencedB.CanInterface() {
result = dereferencedA.Interface() == dereferencedB.Interface()
}
return result
}
// note https://github.com/golang/go/issues/51649
func checkIsNil(value any) bool {
if value == nil {
return true
}
var result bool
defer func() {
if err := recover(); err != nil {
result = false
}
}()
result = reflect.ValueOf(value).IsNil()
return result
}
func dereference(v reflect.Value) reflect.Value {
for v.Kind() == reflect.Pointer {
v = v.Elem()
}
return v
}

View file

@ -0,0 +1,62 @@
package validators_test
import (
"fmt"
"testing"
"github.com/pocketbase/pocketbase/core/validators"
)
func Equal(t *testing.T) {
t.Parallel()
strA := "abc"
strB := "abc"
strC := "123"
var strNilPtr *string
var strNilPtr2 *string
scenarios := []struct {
valA any
valB any
expectError bool
}{
{nil, nil, false},
{"", "", false},
{"", "456", true},
{"123", "", true},
{"123", "456", true},
{"123", "123", false},
{true, false, true},
{false, true, true},
{false, false, false},
{true, true, false},
{0, 0, false},
{0, 1, true},
{1, 2, true},
{1, 1, false},
{&strA, &strA, false},
{&strA, &strB, false},
{&strA, &strC, true},
{"abc", &strA, false},
{&strA, "abc", false},
{"abc", &strC, true},
{"test", 123, true},
{nil, 123, true},
{nil, strA, true},
{nil, &strA, true},
{nil, strNilPtr, false},
{strNilPtr, strNilPtr2, false},
}
for i, s := range scenarios {
t.Run(fmt.Sprintf("%d_%v_%v", i, s.valA, s.valB), func(t *testing.T) {
err := validators.Equal(s.valA)(s.valB)
hasErr := err != nil
if hasErr != s.expectError {
t.Fatalf("Expected hasErr to be %v, got %v (%v)", s.expectError, hasErr, err)
}
})
}
}

96
core/validators/file.go Normal file
View file

@ -0,0 +1,96 @@
package validators
import (
"fmt"
"strings"
"github.com/gabriel-vasile/mimetype"
validation "github.com/go-ozzo/ozzo-validation/v4"
"github.com/pocketbase/pocketbase/tools/filesystem"
)
// UploadedFileSize checks whether the validated [*filesystem.File]
// size is no more than the provided maxBytes.
//
// Example:
//
// validation.Field(&form.File, validation.By(validators.UploadedFileSize(1000)))
func UploadedFileSize(maxBytes int64) validation.RuleFunc {
return func(value any) error {
v, ok := value.(*filesystem.File)
if !ok {
return ErrUnsupportedValueType
}
if v == nil {
return nil // nothing to validate
}
if v.Size > maxBytes {
return validation.NewError(
"validation_file_size_limit",
"Failed to upload {{.file}} - the maximum allowed file size is {{.maxSize}} bytes.",
).SetParams(map[string]any{
"file": v.OriginalName,
"maxSize": maxBytes,
})
}
return nil
}
}
// UploadedFileMimeType checks whether the validated [*filesystem.File]
// mimetype is within the provided allowed mime types.
//
// Example:
//
// validMimeTypes := []string{"test/plain","image/jpeg"}
// validation.Field(&form.File, validation.By(validators.UploadedFileMimeType(validMimeTypes)))
func UploadedFileMimeType(validTypes []string) validation.RuleFunc {
return func(value any) error {
v, ok := value.(*filesystem.File)
if !ok {
return ErrUnsupportedValueType
}
if v == nil {
return nil // nothing to validate
}
baseErr := validation.NewError(
"validation_invalid_mime_type",
fmt.Sprintf("Failed to upload %q due to unsupported file type.", v.OriginalName),
)
if len(validTypes) == 0 {
return baseErr
}
f, err := v.Reader.Open()
if err != nil {
return baseErr
}
defer f.Close()
filetype, err := mimetype.DetectReader(f)
if err != nil {
return baseErr
}
for _, t := range validTypes {
if filetype.Is(t) {
return nil // valid
}
}
return validation.NewError(
"validation_invalid_mime_type",
fmt.Sprintf(
"%q mime type must be one of: %s.",
v.Name,
strings.Join(validTypes, ", "),
),
)
}
}

View file

@ -0,0 +1,75 @@
package validators_test
import (
"fmt"
"strings"
"testing"
"github.com/pocketbase/pocketbase/core/validators"
"github.com/pocketbase/pocketbase/tools/filesystem"
)
func TestUploadedFileSize(t *testing.T) {
t.Parallel()
file, err := filesystem.NewFileFromBytes([]byte("test"), "test.txt")
if err != nil {
t.Fatal(err)
}
scenarios := []struct {
maxBytes int64
file *filesystem.File
expectError bool
}{
{0, nil, false},
{4, nil, false},
{3, file, true}, // all test files have "test" as content
{4, file, false},
{5, file, false},
}
for _, s := range scenarios {
t.Run(fmt.Sprintf("%d", s.maxBytes), func(t *testing.T) {
err := validators.UploadedFileSize(s.maxBytes)(s.file)
hasErr := err != nil
if hasErr != s.expectError {
t.Fatalf("Expected hasErr to be %v, got %v (%v)", s.expectError, hasErr, err)
}
})
}
}
func TestUploadedFileMimeType(t *testing.T) {
t.Parallel()
file, err := filesystem.NewFileFromBytes([]byte("test"), "test.png") // the extension shouldn't matter
if err != nil {
t.Fatal(err)
}
scenarios := []struct {
types []string
file *filesystem.File
expectError bool
}{
{nil, nil, false},
{[]string{"image/jpeg"}, nil, false},
{[]string{}, file, true},
{[]string{"image/jpeg"}, file, true},
// test files are detected as "text/plain; charset=utf-8" content type
{[]string{"image/jpeg", "text/plain; charset=utf-8"}, file, false},
}
for i, s := range scenarios {
t.Run(fmt.Sprintf("%d_%s", i, strings.Join(s.types, ";")), func(t *testing.T) {
err := validators.UploadedFileMimeType(s.types)(s.file)
hasErr := err != nil
if hasErr != s.expectError {
t.Fatalf("Expected hasErr to be %v, got %v (%v)", s.expectError, hasErr, err)
}
})
}
}

29
core/validators/string.go Normal file
View file

@ -0,0 +1,29 @@
package validators
import (
"regexp"
validation "github.com/go-ozzo/ozzo-validation/v4"
)
// IsRegex checks whether the validated value is a valid regular expression pattern.
//
// Example:
//
// validation.Field(&form.Pattern, validation.By(validators.IsRegex))
func IsRegex(value any) error {
v, ok := value.(string)
if !ok {
return ErrUnsupportedValueType
}
if v == "" {
return nil // nothing to check
}
if _, err := regexp.Compile(v); err != nil {
return validation.NewError("validation_invalid_regex", err.Error())
}
return nil
}

View file

@ -0,0 +1,33 @@
package validators_test
import (
"fmt"
"testing"
"github.com/pocketbase/pocketbase/core/validators"
)
func TestIsRegex(t *testing.T) {
t.Parallel()
scenarios := []struct {
val string
expectError bool
}{
{"", false},
{`abc`, false},
{`\w+`, false},
{`\w*((abc+`, true},
}
for i, s := range scenarios {
t.Run(fmt.Sprintf("%d_%#v", i, s.val), func(t *testing.T) {
err := validators.IsRegex(s.val)
hasErr := err != nil
if hasErr != s.expectError {
t.Fatalf("Expected hasErr to be %v, got %v (%v)", s.expectError, hasErr, err)
}
})
}
}

View file

@ -0,0 +1,40 @@
// Package validators implements some common custom PocketBase validators.
package validators
import (
"errors"
"maps"
validation "github.com/go-ozzo/ozzo-validation/v4"
)
var ErrUnsupportedValueType = validation.NewError("validation_unsupported_value_type", "Invalid or unsupported value type.")
// JoinValidationErrors attempts to join the provided [validation.Errors] arguments.
//
// If only one of the arguments is [validation.Errors], it returns the first non-empty [validation.Errors].
//
// If both arguments are not [validation.Errors] then it returns a combined [errors.Join] error.
func JoinValidationErrors(errA, errB error) error {
vErrA, okA := errA.(validation.Errors)
vErrB, okB := errB.(validation.Errors)
// merge
if okA && okB {
result := maps.Clone(vErrA)
maps.Copy(result, vErrB)
if len(result) > 0 {
return result
}
}
if okA && len(vErrA) > 0 {
return vErrA
}
if okB && len(vErrB) > 0 {
return vErrB
}
return errors.Join(errA, errB)
}

View file

@ -0,0 +1,39 @@
package validators_test
import (
"errors"
"fmt"
"testing"
validation "github.com/go-ozzo/ozzo-validation/v4"
"github.com/pocketbase/pocketbase/core/validators"
)
func TestJoinValidationErrors(t *testing.T) {
scenarios := []struct {
errA error
errB error
expected string
}{
{nil, nil, "<nil>"},
{errors.New("abc"), nil, "abc"},
{nil, errors.New("abc"), "abc"},
{errors.New("abc"), errors.New("456"), "abc\n456"},
{validation.Errors{"test1": errors.New("test1_err")}, nil, "test1: test1_err."},
{nil, validation.Errors{"test2": errors.New("test2_err")}, "test2: test2_err."},
{validation.Errors{}, errors.New("456"), "\n456"},
{errors.New("456"), validation.Errors{}, "456\n"},
{validation.Errors{"test1": errors.New("test1_err")}, errors.New("456"), "test1: test1_err."},
{errors.New("456"), validation.Errors{"test2": errors.New("test2_err")}, "test2: test2_err."},
{validation.Errors{"test1": errors.New("test1_err")}, validation.Errors{"test2": errors.New("test2_err")}, "test1: test1_err; test2: test2_err."},
}
for i, s := range scenarios {
t.Run(fmt.Sprintf("%d_%#T_%T", i, s.errA, s.errB), func(t *testing.T) {
result := fmt.Sprintf("%v", validators.JoinValidationErrors(s.errA, s.errB))
if result != s.expected {
t.Fatalf("Expected\n%v\ngot\n%v", s.expected, result)
}
})
}
}