@x47base/pocketbase-addon 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (117) hide show
  1. package/.dockerignore +9 -0
  2. package/Dockerfile +26 -0
  3. package/FEATURES.md +32 -0
  4. package/LICENSE.md +17 -0
  5. package/MIGRATION.md +49 -0
  6. package/NOTICE.md +5 -0
  7. package/README.md +26 -0
  8. package/adapter.go +31 -0
  9. package/admin/register.go +60 -0
  10. package/admin/register_test.go +37 -0
  11. package/backups/backup_encryption_test.go +82 -0
  12. package/backups/encryption.go +138 -0
  13. package/backups/integration_test.go +51 -0
  14. package/backups/register.go +120 -0
  15. package/backups/restore.go +119 -0
  16. package/backups/s3_test.go +52 -0
  17. package/backups/swap.go +53 -0
  18. package/backups/swap_test.go +75 -0
  19. package/backups/upload.go +52 -0
  20. package/bin/pocketbase-extension.mjs +25 -0
  21. package/cmd/edge/main.go +123 -0
  22. package/cmd/import-fork/main.go +37 -0
  23. package/cmd/loadtest/main.go +61 -0
  24. package/cmd/loadtest/sandbox.go +73 -0
  25. package/cmd/loadtest/sandbox_test.go +20 -0
  26. package/cmd/pocketbase/main.go +78 -0
  27. package/deploy/README.md +148 -0
  28. package/deploy/app/hooks/README.md +2 -0
  29. package/deploy/app/migrations/1789000000_notes.js +16 -0
  30. package/deploy/app/public/README.md +2 -0
  31. package/deploy/compose.secrets.yaml +8 -0
  32. package/deploy/compose.yaml +68 -0
  33. package/deploy/edge.json +13 -0
  34. package/edge/gateway.go +295 -0
  35. package/edge/gateway_test.go +296 -0
  36. package/edge/openapi.json +1 -0
  37. package/edge/policy.go +150 -0
  38. package/features/collection_singleton.go +13 -0
  39. package/features/collection_singleton_test.go +46 -0
  40. package/features/dimensions_test.go +65 -0
  41. package/features/duplicate.go +178 -0
  42. package/features/duplicate_test.go +128 -0
  43. package/features/field_color.go +46 -0
  44. package/features/field_date_only.go +39 -0
  45. package/features/field_json_schema.go +92 -0
  46. package/features/field_scalar_extensions_test.go +66 -0
  47. package/features/files.go +36 -0
  48. package/features/filter_has_any_test.go +81 -0
  49. package/features/generate_test.go +72 -0
  50. package/features/has_any_visibility_test.go +62 -0
  51. package/features/json.go +50 -0
  52. package/features/membership.go +64 -0
  53. package/features/register.go +45 -0
  54. package/features/schema_test.go +58 -0
  55. package/features/ui/main.js +133 -0
  56. package/features/ui/settings.js +31 -0
  57. package/go.mod +54 -0
  58. package/go.sum +159 -0
  59. package/internal/archive/create.go +91 -0
  60. package/internal/archive/create_test.go +125 -0
  61. package/internal/archive/extract.go +99 -0
  62. package/internal/archive/extract_test.go +88 -0
  63. package/jsvm/binds.go +1273 -0
  64. package/jsvm/binds_app_reset_test.go +314 -0
  65. package/jsvm/binds_test.go +1870 -0
  66. package/jsvm/form_data.go +149 -0
  67. package/jsvm/form_data_test.go +225 -0
  68. package/jsvm/internal/types/generated/embed.go +6 -0
  69. package/jsvm/internal/types/generated/types.d.ts +24820 -0
  70. package/jsvm/internal/types/types.go +1408 -0
  71. package/jsvm/jsvm.go +587 -0
  72. package/jsvm/mapper.go +67 -0
  73. package/jsvm/mapper_test.go +42 -0
  74. package/jsvm/pool.go +73 -0
  75. package/jsvm/program_source_test.go +24 -0
  76. package/loadtest/loadtest.go +202 -0
  77. package/loadtest/loadtest_test.go +84 -0
  78. package/localization/README.md +23 -0
  79. package/localization/catalogue.json +483 -0
  80. package/localization/localization.go +94 -0
  81. package/localization/localization_test.go +21 -0
  82. package/mail/register.go +80 -0
  83. package/mail/register_test.go +49 -0
  84. package/mail/resolve.go +91 -0
  85. package/migration/import.go +96 -0
  86. package/migration/import_test.go +58 -0
  87. package/otp/otp.go +56 -0
  88. package/otp/otp_test.go +56 -0
  89. package/package.json +51 -0
  90. package/scripts/check-edge.py +42 -0
  91. package/scripts/check.sh +11 -0
  92. package/scripts/sync-jsvm-types.sh +10 -0
  93. package/security/README.md +94 -0
  94. package/security/assurance_test.go +149 -0
  95. package/security/compatibility_test.go +128 -0
  96. package/security/config.go +78 -0
  97. package/security/dashboard_test.go +103 -0
  98. package/security/management.go +169 -0
  99. package/security/openapi.json +508 -0
  100. package/security/review.go +34 -0
  101. package/security/security.go +503 -0
  102. package/security/security_test.go +146 -0
  103. package/security/state.go +116 -0
  104. package/security/ui/dashboard.css +4 -0
  105. package/security/ui/dashboard.js +83 -0
  106. package/security/ui/main.js +15 -0
  107. package/security/ui/model.js +32 -0
  108. package/security/ui/model.test.mjs +25 -0
  109. package/security/ui/registration.test.mjs +10 -0
  110. package/settings/env_test.go +41 -0
  111. package/settings/openapi.json +193 -0
  112. package/settings/settings.go +155 -0
  113. package/settings/settings_test.go +31 -0
  114. package/watcher/watcher.go +192 -0
  115. package/watcher/watcher_test.go +200 -0
  116. package/web/static.go +99 -0
  117. package/web/static_test.go +48 -0
@@ -0,0 +1,92 @@
1
+ package features
2
+
3
+ import (
4
+ "crypto/sha256"
5
+ "encoding/json"
6
+ "errors"
7
+ "github.com/pocketbase/pocketbase/core"
8
+ "strings"
9
+ "sync"
10
+
11
+ "github.com/santhosh-tekuri/jsonschema/v6"
12
+ )
13
+
14
+ type schemaCache struct {
15
+ mu sync.Mutex
16
+ entries map[[32]byte]*jsonschema.Schema
17
+ }
18
+ type inlineSchemaLoader struct{}
19
+
20
+ func (inlineSchemaLoader) Load(string) (any, error) {
21
+ return nil, errors.New("external schema resources are disabled; use inline definitions")
22
+ }
23
+
24
+ func compileFieldSchema(app core.App, raw string) (*jsonschema.Schema, error) {
25
+ if len(raw) > 65536 {
26
+ return nil, errors.New("schema exceeds 64 KiB")
27
+ }
28
+ var value any
29
+ dec := json.NewDecoder(strings.NewReader(raw))
30
+ dec.UseNumber()
31
+ if err := dec.Decode(&value); err != nil {
32
+ return nil, errors.New("schema must be valid JSON")
33
+ }
34
+ if !json.Valid([]byte(raw)) {
35
+ return nil, errors.New("schema must be valid JSON")
36
+ }
37
+ if !boundedJSONDepth(value, 0) {
38
+ return nil, errors.New("schema nesting exceeds 64 levels")
39
+ }
40
+ app.Store().SetFunc("pb.jsonSchemaCache", func(old any) any {
41
+ if old != nil {
42
+ return old
43
+ }
44
+ return &schemaCache{entries: map[[32]byte]*jsonschema.Schema{}}
45
+ })
46
+ cache := app.Store().Get("pb.jsonSchemaCache").(*schemaCache)
47
+ key := sha256.Sum256([]byte(raw))
48
+ cache.mu.Lock()
49
+ defer cache.mu.Unlock()
50
+ if schema := cache.entries[key]; schema != nil {
51
+ return schema, nil
52
+ }
53
+ compiler := jsonschema.NewCompiler()
54
+ compiler.DefaultDraft(jsonschema.Draft2020)
55
+ compiler.UseLoader(inlineSchemaLoader{})
56
+ compiler.AssertFormat()
57
+ if err := compiler.AddResource("urn:pocketbase:field", value); err != nil {
58
+ return nil, err
59
+ }
60
+ schema, err := compiler.Compile("urn:pocketbase:field")
61
+ if err != nil {
62
+ return nil, err
63
+ }
64
+ if len(cache.entries) >= 64 {
65
+ for k := range cache.entries {
66
+ delete(cache.entries, k)
67
+ break
68
+ }
69
+ }
70
+ cache.entries[key] = schema
71
+ return schema, nil
72
+ }
73
+ func boundedJSONDepth(value any, depth int) bool {
74
+ if depth > 64 {
75
+ return false
76
+ }
77
+ switch v := value.(type) {
78
+ case map[string]any:
79
+ for _, item := range v {
80
+ if !boundedJSONDepth(item, depth+1) {
81
+ return false
82
+ }
83
+ }
84
+ case []any:
85
+ for _, item := range v {
86
+ if !boundedJSONDepth(item, depth+1) {
87
+ return false
88
+ }
89
+ }
90
+ }
91
+ return true
92
+ }
@@ -0,0 +1,66 @@
1
+ package features_test
2
+
3
+ import (
4
+ "context"
5
+ "github.com/pocketbase/pocketbase/core"
6
+ "github.com/pocketbase/pocketbase/tests"
7
+ "github.com/spink-dev/pocketbase-extension/features"
8
+ "testing"
9
+ )
10
+
11
+ func TestScalarExtensions(t *testing.T) {
12
+ app, _ := tests.NewTestApp()
13
+ defer app.Cleanup()
14
+ for _, tc := range []struct {
15
+ field core.Field
16
+ good, bad string
17
+ }{
18
+ {&features.ColorField{TextField: core.TextField{Name: "value"}}, "#AABBCCDD", "red"},
19
+ {&features.DateOnlyField{TextField: core.TextField{Name: "value"}}, "2024-02-29", "2025-02-29"},
20
+ } {
21
+ c := core.NewBaseCollection("extensions")
22
+ c.Fields.Add(tc.field)
23
+ r := core.NewRecord(c)
24
+ r.Set("value", tc.good)
25
+ if err := tc.field.ValidateValue(context.Background(), app, r); err != nil {
26
+ t.Fatal(err)
27
+ }
28
+ r.Set("value", tc.bad)
29
+ if err := tc.field.ValidateValue(context.Background(), app, r); err == nil {
30
+ t.Fatal("invalid value accepted")
31
+ }
32
+ }
33
+ }
34
+
35
+ func TestScalarExtensionsPersist(t *testing.T) {
36
+ app, err := tests.NewTestApp()
37
+ if err != nil {
38
+ t.Fatal(err)
39
+ }
40
+ defer app.Cleanup()
41
+ c := core.NewBaseCollection("scalar_extensions")
42
+ c.Fields.Add(&features.ColorField{TextField: core.TextField{Name: "color"}}, &features.DateOnlyField{TextField: core.TextField{Name: "day"}})
43
+ if err := app.Save(c); err != nil {
44
+ t.Fatal(err)
45
+ }
46
+ r := core.NewRecord(c)
47
+ r.Set("color", "#ABCDEF")
48
+ r.Set("day", "2024-02-29")
49
+ if err := app.Save(r); err != nil {
50
+ t.Fatal(err)
51
+ }
52
+ got, err := app.FindRecordById(c.Id, r.Id)
53
+ if err != nil {
54
+ t.Fatal(err)
55
+ }
56
+ if got.GetString("color") != "#abcdef" || got.GetString("day") != "2024-02-29" {
57
+ t.Fatal("value changed on persistence")
58
+ }
59
+ c, err = app.FindCollectionByNameOrId(c.Id)
60
+ if err != nil {
61
+ t.Fatal(err)
62
+ }
63
+ if c.Fields.GetByName("color").Type() != features.FieldTypeColor || c.Fields.GetByName("day").Type() != features.FieldTypeDateOnly {
64
+ t.Fatal("lost field types")
65
+ }
66
+ }
@@ -0,0 +1,36 @@
1
+ package features
2
+
3
+ import (
4
+ "github.com/pocketbase/pocketbase/core"
5
+ "image"
6
+ _ "image/gif"
7
+ _ "image/jpeg"
8
+ _ "image/png"
9
+ "io"
10
+ "strconv"
11
+ )
12
+
13
+ func registerDimensions(app core.App) {
14
+ app.OnFileDownloadRequest().BindFunc(func(e *core.FileDownloadRequestEvent) error {
15
+ if e.Request.URL.Query().Get("dimensions") != "1" {
16
+ return e.Next()
17
+ }
18
+ fs, err := e.App.NewFilesystem()
19
+ if err != nil {
20
+ return err
21
+ }
22
+ defer fs.Close()
23
+ fs.SetContext(e.Request.Context())
24
+ r, err := fs.GetReader(e.ServedPath)
25
+ if err == nil {
26
+ c, _, err := image.DecodeConfig(io.LimitReader(r, 256<<10))
27
+ r.Close()
28
+ if err == nil && c.Width > 0 && c.Height > 0 {
29
+ e.Response.Header().Set("X-Image-Width", strconv.Itoa(c.Width))
30
+ e.Response.Header().Set("X-Image-Height", strconv.Itoa(c.Height))
31
+ e.Response.Header().Add("Access-Control-Expose-Headers", "X-Image-Width, X-Image-Height")
32
+ }
33
+ }
34
+ return e.Next()
35
+ })
36
+ }
@@ -0,0 +1,81 @@
1
+ package features_test
2
+
3
+ import (
4
+ "github.com/pocketbase/pocketbase/core"
5
+ "github.com/pocketbase/pocketbase/tests"
6
+ _ "github.com/spink-dev/pocketbase-extension/features"
7
+ "testing"
8
+ )
9
+
10
+ func TestHasAnyRecordFilter(t *testing.T) {
11
+ app, err := tests.NewTestApp()
12
+ if err != nil {
13
+ t.Fatal(err)
14
+ }
15
+ defer app.Cleanup()
16
+ c := core.NewAuthCollection("has_any_auth")
17
+ c.Fields.Add(&core.SelectField{Name: "roles", MaxSelect: 3, Values: []string{"a", "ab", "b"}})
18
+ if err := app.Save(c); err != nil {
19
+ t.Fatal(err)
20
+ }
21
+ r := core.NewRecord(c)
22
+ r.SetEmail("private@example.com")
23
+ r.SetPassword("1234567890")
24
+ r.Set("roles", []string{"a", "b"})
25
+ if err := app.Save(r); err != nil {
26
+ t.Fatal(err)
27
+ }
28
+ info := &core.RequestInfo{Auth: r}
29
+ for _, tc := range []struct {
30
+ rule string
31
+ want bool
32
+ }{{`hasAny(@request.auth.roles,"b","other") = true`, true}, {`hasAny(@request.auth.roles,"ab") = true`, false}, {`hasAny(@request.auth.roles,"b") = false`, false}} {
33
+ allowed, err := app.CanAccessRecord(r, info, &tc.rule)
34
+ if err != nil {
35
+ t.Fatal(err)
36
+ }
37
+ if allowed != tc.want {
38
+ t.Fatalf("%s: %v", tc.rule, allowed)
39
+ }
40
+ }
41
+ }
42
+
43
+ func TestHasAnyNegativeMultiRelation(t *testing.T) {
44
+ app, err := tests.NewTestApp()
45
+ if err != nil {
46
+ t.Fatal(err)
47
+ }
48
+ defer app.Cleanup()
49
+ groups := core.NewBaseCollection("filter_groups")
50
+ groups.Fields.Add(&core.SelectField{Name: "roles", MaxSelect: 2, Values: []string{"normal", "banned"}})
51
+ if err := app.Save(groups); err != nil {
52
+ t.Fatal(err)
53
+ }
54
+ ids := []string{}
55
+ for _, role := range []string{"normal", "banned"} {
56
+ r := core.NewRecord(groups)
57
+ r.Set("roles", []string{role})
58
+ if err := app.Save(r); err != nil {
59
+ t.Fatal(err)
60
+ }
61
+ ids = append(ids, r.Id)
62
+ }
63
+ parents := core.NewBaseCollection("filter_parents")
64
+ parents.Fields.Add(&core.RelationField{Name: "groups", CollectionId: groups.Id, MaxSelect: 3})
65
+ if err := app.Save(parents); err != nil {
66
+ t.Fatal(err)
67
+ }
68
+ parent := core.NewRecord(parents)
69
+ parent.Set("groups", ids)
70
+ if err := app.Save(parent); err != nil {
71
+ t.Fatal(err)
72
+ }
73
+ rule := `hasAny(groups.roles,"banned") = false`
74
+ allowed, err := app.CanAccessRecord(parent, &core.RequestInfo{}, &rule)
75
+ if err != nil {
76
+ t.Fatal(err)
77
+ }
78
+ if allowed {
79
+ t.Fatal("negative membership accepted mixed relations")
80
+ }
81
+ }
@@ -0,0 +1,72 @@
1
+ package features_test
2
+
3
+ import (
4
+ "encoding/json"
5
+ "github.com/pocketbase/pocketbase/apis"
6
+ "github.com/pocketbase/pocketbase/core"
7
+ "github.com/pocketbase/pocketbase/tests"
8
+ "github.com/spink-dev/pocketbase-extension/features"
9
+ "net/http/httptest"
10
+ "regexp"
11
+ "testing"
12
+ )
13
+
14
+ func TestCollectionGenerateField(t *testing.T) {
15
+ app, err := tests.NewTestApp()
16
+ if err != nil {
17
+ t.Fatal(err)
18
+ }
19
+ defer app.Cleanup()
20
+ c := core.NewBaseCollection("generated_values")
21
+ c.Fields.Add(&core.TextField{Name: "code", AutogeneratePattern: "[A-Z]{8}"}, &core.TextField{Name: "plain"})
22
+ if err := app.Save(c); err != nil {
23
+ t.Fatal(err)
24
+ }
25
+ admin, err := app.FindFirstRecordByData(core.CollectionNameSuperusers, "email", "test@example.com")
26
+ if err != nil {
27
+ t.Fatal(err)
28
+ }
29
+ token, err := admin.NewAuthToken()
30
+ if err != nil {
31
+ t.Fatal(err)
32
+ }
33
+ features.Register(app)
34
+ r, err := apis.NewRouter(app)
35
+ if err != nil {
36
+ t.Fatal(err)
37
+ }
38
+ if err := app.OnServe().Trigger(&core.ServeEvent{App: app, Router: r}); err != nil {
39
+ t.Fatal(err)
40
+ }
41
+ handler, err := r.BuildMux()
42
+ if err != nil {
43
+ t.Fatal(err)
44
+ }
45
+ for _, tc := range []struct {
46
+ field, token string
47
+ status int
48
+ }{{"code", "", 401}, {"code", token, 200}, {"plain", token, 400}, {"id", token, 400}, {"missing", token, 400}} {
49
+ req := httptest.NewRequest("POST", "/api/adapter/collections/"+c.Id+"/fields/"+tc.field+"/generate", nil)
50
+ req.Header.Set("Authorization", tc.token)
51
+ w := httptest.NewRecorder()
52
+ handler.ServeHTTP(w, req)
53
+ if w.Code != tc.status {
54
+ t.Fatalf("%s: got %d want %d", tc.field, w.Code, tc.status)
55
+ }
56
+ if tc.status == 200 {
57
+ var result struct {
58
+ Value string `json:"value"`
59
+ }
60
+ if err := json.Unmarshal(w.Body.Bytes(), &result); err != nil {
61
+ t.Fatal(err)
62
+ }
63
+ if !regexp.MustCompile(`^[A-Z]{8}$`).MatchString(result.Value) {
64
+ t.Fatal("invalid generated value")
65
+ }
66
+ }
67
+ }
68
+ count, err := app.CountRecords(c.Id)
69
+ if err != nil || count != 0 {
70
+ t.Fatal("generation persisted a record", err)
71
+ }
72
+ }
@@ -0,0 +1,62 @@
1
+ package features_test
2
+
3
+ import (
4
+ "encoding/json"
5
+ "github.com/pocketbase/pocketbase/apis"
6
+ "github.com/pocketbase/pocketbase/core"
7
+ "github.com/pocketbase/pocketbase/tests"
8
+ "github.com/pocketbase/pocketbase/tools/types"
9
+ _ "github.com/spink-dev/pocketbase-extension/features"
10
+ "net/http/httptest"
11
+ "net/url"
12
+ "testing"
13
+ )
14
+
15
+ func TestHasAnyPreservesEmailVisibility(t *testing.T) {
16
+ app, err := tests.NewTestApp()
17
+ if err != nil {
18
+ t.Fatal(err)
19
+ }
20
+ defer app.Cleanup()
21
+ c := core.NewAuthCollection("filter_visibility")
22
+ c.ListRule = types.Pointer("")
23
+ if err := app.Save(c); err != nil {
24
+ t.Fatal(err)
25
+ }
26
+ r := core.NewRecord(c)
27
+ r.SetEmail("private@example.com")
28
+ r.SetPassword("1234567890")
29
+ if err := app.Save(r); err != nil {
30
+ t.Fatal(err)
31
+ }
32
+ router, _ := apis.NewRouter(app)
33
+ handler, err := router.BuildMux()
34
+ if err != nil {
35
+ t.Fatal(err)
36
+ }
37
+ for _, visible := range []bool{false, true} {
38
+ r.SetEmailVisibility(visible)
39
+ if err := app.Save(r); err != nil {
40
+ t.Fatal(err)
41
+ }
42
+ req := httptest.NewRequest("GET", "/api/collections/"+c.Id+"/records?filter="+url.QueryEscape(`hasAny(email,"private@example.com") = true`), nil)
43
+ w := httptest.NewRecorder()
44
+ handler.ServeHTTP(w, req)
45
+ if w.Code != 200 {
46
+ t.Fatalf("filter failed %d", w.Code)
47
+ }
48
+ var response struct {
49
+ TotalItems int `json:"totalItems"`
50
+ }
51
+ if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {
52
+ t.Fatal(err)
53
+ }
54
+ expected := 0
55
+ if visible {
56
+ expected = 1
57
+ }
58
+ if response.TotalItems != expected {
59
+ t.Fatal("email visibility bypass")
60
+ }
61
+ }
62
+ }
@@ -0,0 +1,50 @@
1
+ package features
2
+
3
+ import (
4
+ "context"
5
+ "encoding/json"
6
+ validation "github.com/pocketbase/ozzo-validation/v4"
7
+ "github.com/pocketbase/pocketbase/core"
8
+ "github.com/pocketbase/pocketbase/tools/types"
9
+ "strings"
10
+ )
11
+
12
+ type JSONField struct {
13
+ core.JSONField
14
+ Schema string `form:"schema" json:"schema,omitempty"`
15
+ }
16
+
17
+ func init() { core.Fields[core.FieldTypeJSON] = func() core.Field { return &JSONField{} } }
18
+ func (f *JSONField) ValidateSettings(ctx context.Context, app core.App, c *core.Collection) error {
19
+ if err := f.JSONField.ValidateSettings(ctx, app, c); err != nil {
20
+ return err
21
+ }
22
+ if f.Schema == "" {
23
+ return nil
24
+ }
25
+ _, err := compileFieldSchema(app, f.Schema)
26
+ return err
27
+ }
28
+ func (f *JSONField) ValidateValue(ctx context.Context, app core.App, r *core.Record) error {
29
+ if err := f.JSONField.ValidateValue(ctx, app, r); err != nil {
30
+ return err
31
+ }
32
+ if f.Schema == "" {
33
+ return nil
34
+ }
35
+ schema, err := compileFieldSchema(app, f.Schema)
36
+ if err != nil {
37
+ return validation.NewError("validation_json_schema", "Invalid field schema")
38
+ }
39
+ raw, _ := r.GetRaw(f.Name).(types.JSONRaw)
40
+ var value any
41
+ dec := json.NewDecoder(strings.NewReader(string(raw)))
42
+ dec.UseNumber()
43
+ if err := dec.Decode(&value); err != nil || !boundedJSONDepth(value, 0) {
44
+ return validation.NewError("validation_json_schema", "Invalid or excessively nested JSON value")
45
+ }
46
+ if err := schema.Validate(value); err != nil {
47
+ return validation.NewError("validation_json_schema", "Value does not match the field JSON schema")
48
+ }
49
+ return nil
50
+ }
@@ -0,0 +1,64 @@
1
+ package features
2
+
3
+ import (
4
+ "errors"
5
+ "github.com/ganigeorgiev/fexpr"
6
+ "github.com/pocketbase/dbx"
7
+ "github.com/pocketbase/pocketbase/tools/search"
8
+ "reflect"
9
+ "strings"
10
+ )
11
+
12
+ func init() { search.TokenFunctions["hasAny"] = hasAny }
13
+ func hasAny(resolve func(fexpr.Token) (*search.ResolverResult, error), args ...fexpr.Token) (*search.ResolverResult, error) {
14
+ if len(args) < 2 || len(args) > 33 {
15
+ return nil, errors.New("hasAny expects a value and 1 to 32 string constants")
16
+ }
17
+ if args[0].Type != fexpr.TokenIdentifier && args[0].Type != fexpr.TokenText {
18
+ return nil, errors.New("hasAny value must be a field or string")
19
+ }
20
+ value, err := resolve(args[0])
21
+ if err != nil {
22
+ return nil, err
23
+ }
24
+ params := dbx.Params{}
25
+ if err := concatUniqueParams(params, value.Params); err != nil {
26
+ return nil, err
27
+ }
28
+ identifiers := make([]string, 0, len(args)-1)
29
+ for _, arg := range args[1:] {
30
+ if arg.Type != fexpr.TokenText || len(arg.Literal) > 1024 {
31
+ return nil, errors.New("hasAny constants must be strings up to 1024 bytes")
32
+ }
33
+ r, err := resolve(arg)
34
+ if err != nil {
35
+ return nil, err
36
+ }
37
+ if err := concatUniqueParams(params, r.Params); err != nil {
38
+ return nil, err
39
+ }
40
+ identifiers = append(identifiers, r.Identifier)
41
+ }
42
+ buildExpression := func(v string) string {
43
+ return `EXISTS (SELECT 1 FROM json_each(CASE WHEN json_valid(` + v + `) THEN CASE WHEN json_type(` + v + `) = 'array' THEN ` + v + ` ELSE json_array(` + v + `) END ELSE json_array(` + v + `) END) AS pb_has_any WHERE pb_has_any.type = 'text' AND pb_has_any.value COLLATE BINARY IN (` + strings.Join(identifiers, ",") + `))`
44
+ }
45
+ result := &search.ResolverResult{Identifier: buildExpression(value.Identifier), Params: params, NullFallback: search.NullFallbackDisabled, AfterBuild: value.AfterBuild}
46
+ if value.MultiMatchSubQuery != nil {
47
+ result.MultiMatchSubQuery = value.MultiMatchSubQuery
48
+ result.MultiMatchSubQuery.ValueIdentifier = buildExpression(value.MultiMatchSubQuery.ValueIdentifier)
49
+ if err := concatUniqueParams(result.MultiMatchSubQuery.Params, params); err != nil {
50
+ return nil, err
51
+ }
52
+ }
53
+ return result, nil
54
+ }
55
+
56
+ func concatUniqueParams(dst, src dbx.Params) error {
57
+ for key, value := range src {
58
+ if old, ok := dst[key]; ok && !reflect.DeepEqual(old, value) {
59
+ return errors.New("duplicate filter parameter")
60
+ }
61
+ dst[key] = value
62
+ }
63
+ return nil
64
+ }
@@ -0,0 +1,45 @@
1
+ package features
2
+
3
+ import (
4
+ "embed"
5
+ "github.com/pocketbase/pocketbase/apis"
6
+ "github.com/pocketbase/pocketbase/core"
7
+ "github.com/pocketbase/pocketbase/tools/hook"
8
+ random "github.com/pocketbase/pocketbase/tools/security"
9
+ "io/fs"
10
+ )
11
+
12
+ //go:embed ui/*.js
13
+ var files embed.FS
14
+
15
+ func Register(app core.App) {
16
+ registerDimensions(app)
17
+ registerDuplicate(app)
18
+ app.OnServe().Bind(&hook.Handler[*core.ServeEvent]{Id: "adapter.features", Func: func(e *core.ServeEvent) error {
19
+ bundle, err := fs.Sub(files, "ui")
20
+ if err != nil {
21
+ return err
22
+ }
23
+ e.UIExtensions = append(e.UIExtensions, core.UIExtension{Name: "adapter-features", FS: bundle})
24
+ e.Router.POST("/api/adapter/collections/{collection}/fields/{field}/generate", generate).Bind(apis.RequireSuperuserAuth())
25
+ return e.Next()
26
+ }})
27
+ }
28
+ func generate(e *core.RequestEvent) error {
29
+ collection, err := e.App.FindCachedCollectionByNameOrId(e.Request.PathValue("collection"))
30
+ if err != nil {
31
+ return e.NotFoundError("Missing collection", err)
32
+ }
33
+ field, ok := collection.Fields.GetByName(e.Request.PathValue("field")).(*core.TextField)
34
+ if !ok || field.PrimaryKey || field.AutogeneratePattern == "" {
35
+ return e.BadRequestError("Field does not support generation", nil)
36
+ }
37
+ value, err := random.RandomStringByRegex(field.AutogeneratePattern)
38
+ if err != nil {
39
+ return e.BadRequestError("Unable to generate value", err)
40
+ }
41
+ if err := field.ValidatePlainValue(value); err != nil {
42
+ return e.BadRequestError("Generated value does not satisfy field constraints", err)
43
+ }
44
+ return e.JSON(200, map[string]string{"value": value})
45
+ }
@@ -0,0 +1,58 @@
1
+ package features_test
2
+
3
+ import (
4
+ "context"
5
+ "github.com/pocketbase/pocketbase/core"
6
+ "github.com/pocketbase/pocketbase/tests"
7
+ "github.com/spink-dev/pocketbase-extension/features"
8
+ "testing"
9
+ )
10
+
11
+ func TestJSONFieldSchema(t *testing.T) {
12
+ app, err := tests.NewTestApp()
13
+ if err != nil {
14
+ t.Fatal(err)
15
+ }
16
+ defer app.Cleanup()
17
+ for _, tc := range []struct{ schema, good, bad string }{
18
+ {`{"const":9007199254740993}`, `9007199254740993`, `9007199254740992`},
19
+ {`{"type":"object","properties":{"count":{"type":"integer","minimum":1}},"required":["count"],"additionalProperties":false}`, `{"count":2}`, `{"count":0}`},
20
+ {`{"$defs":{"day":{"type":"string","format":"date"}},"$ref":"#/$defs/day"}`, `"2024-02-29"`, `"2025-02-29"`},
21
+ } {
22
+ c := core.NewBaseCollection("schema_test")
23
+ f := &features.JSONField{JSONField: core.JSONField{Name: "payload"}, Schema: tc.schema}
24
+ c.Fields.Add(f)
25
+ if err := f.ValidateSettings(context.Background(), app, c); err != nil {
26
+ t.Fatal(err)
27
+ }
28
+ r := core.NewRecord(c)
29
+ r.Set("payload", tc.good)
30
+ if err := f.ValidateValue(context.Background(), app, r); err != nil {
31
+ t.Fatal(err)
32
+ }
33
+ r.Set("payload", tc.bad)
34
+ if err := f.ValidateValue(context.Background(), app, r); err == nil {
35
+ t.Fatal("accepted schema violation")
36
+ }
37
+ }
38
+ for _, raw := range []string{`{"$ref":"file:///etc/passwd"}`, `{"$ref":"http://127.0.0.1/schema"}`, `{"type":"unknown"}`, `{} {}`} {
39
+ f := &features.JSONField{JSONField: core.JSONField{Name: "payload"}, Schema: raw}
40
+ if err := f.ValidateSettings(context.Background(), app, core.NewBaseCollection("schema_test")); err == nil {
41
+ t.Fatalf("accepted invalid/external schema: %s", raw)
42
+ }
43
+ }
44
+ c := core.NewBaseCollection("persist_schema")
45
+ c.Fields.Add(&features.JSONField{JSONField: core.JSONField{Name: "payload"}, Schema: `{"type":"integer"}`})
46
+ if err := app.Save(c); err != nil {
47
+ t.Fatal(err)
48
+ }
49
+ r := core.NewRecord(c)
50
+ r.Set("payload", `"text"`)
51
+ if err := app.Save(r); err == nil {
52
+ t.Fatal("persisted invalid data")
53
+ }
54
+ r.Set("payload", 42)
55
+ if err := app.Save(r); err != nil {
56
+ t.Fatal(err)
57
+ }
58
+ }