@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.
- package/.dockerignore +9 -0
- package/Dockerfile +26 -0
- package/FEATURES.md +32 -0
- package/LICENSE.md +17 -0
- package/MIGRATION.md +49 -0
- package/NOTICE.md +5 -0
- package/README.md +26 -0
- package/adapter.go +31 -0
- package/admin/register.go +60 -0
- package/admin/register_test.go +37 -0
- package/backups/backup_encryption_test.go +82 -0
- package/backups/encryption.go +138 -0
- package/backups/integration_test.go +51 -0
- package/backups/register.go +120 -0
- package/backups/restore.go +119 -0
- package/backups/s3_test.go +52 -0
- package/backups/swap.go +53 -0
- package/backups/swap_test.go +75 -0
- package/backups/upload.go +52 -0
- package/bin/pocketbase-extension.mjs +25 -0
- package/cmd/edge/main.go +123 -0
- package/cmd/import-fork/main.go +37 -0
- package/cmd/loadtest/main.go +61 -0
- package/cmd/loadtest/sandbox.go +73 -0
- package/cmd/loadtest/sandbox_test.go +20 -0
- package/cmd/pocketbase/main.go +78 -0
- package/deploy/README.md +148 -0
- package/deploy/app/hooks/README.md +2 -0
- package/deploy/app/migrations/1789000000_notes.js +16 -0
- package/deploy/app/public/README.md +2 -0
- package/deploy/compose.secrets.yaml +8 -0
- package/deploy/compose.yaml +68 -0
- package/deploy/edge.json +13 -0
- package/edge/gateway.go +295 -0
- package/edge/gateway_test.go +296 -0
- package/edge/openapi.json +1 -0
- package/edge/policy.go +150 -0
- package/features/collection_singleton.go +13 -0
- package/features/collection_singleton_test.go +46 -0
- package/features/dimensions_test.go +65 -0
- package/features/duplicate.go +178 -0
- package/features/duplicate_test.go +128 -0
- package/features/field_color.go +46 -0
- package/features/field_date_only.go +39 -0
- package/features/field_json_schema.go +92 -0
- package/features/field_scalar_extensions_test.go +66 -0
- package/features/files.go +36 -0
- package/features/filter_has_any_test.go +81 -0
- package/features/generate_test.go +72 -0
- package/features/has_any_visibility_test.go +62 -0
- package/features/json.go +50 -0
- package/features/membership.go +64 -0
- package/features/register.go +45 -0
- package/features/schema_test.go +58 -0
- package/features/ui/main.js +133 -0
- package/features/ui/settings.js +31 -0
- package/go.mod +54 -0
- package/go.sum +159 -0
- package/internal/archive/create.go +91 -0
- package/internal/archive/create_test.go +125 -0
- package/internal/archive/extract.go +99 -0
- package/internal/archive/extract_test.go +88 -0
- package/jsvm/binds.go +1273 -0
- package/jsvm/binds_app_reset_test.go +314 -0
- package/jsvm/binds_test.go +1870 -0
- package/jsvm/form_data.go +149 -0
- package/jsvm/form_data_test.go +225 -0
- package/jsvm/internal/types/generated/embed.go +6 -0
- package/jsvm/internal/types/generated/types.d.ts +24820 -0
- package/jsvm/internal/types/types.go +1408 -0
- package/jsvm/jsvm.go +587 -0
- package/jsvm/mapper.go +67 -0
- package/jsvm/mapper_test.go +42 -0
- package/jsvm/pool.go +73 -0
- package/jsvm/program_source_test.go +24 -0
- package/loadtest/loadtest.go +202 -0
- package/loadtest/loadtest_test.go +84 -0
- package/localization/README.md +23 -0
- package/localization/catalogue.json +483 -0
- package/localization/localization.go +94 -0
- package/localization/localization_test.go +21 -0
- package/mail/register.go +80 -0
- package/mail/register_test.go +49 -0
- package/mail/resolve.go +91 -0
- package/migration/import.go +96 -0
- package/migration/import_test.go +58 -0
- package/otp/otp.go +56 -0
- package/otp/otp_test.go +56 -0
- package/package.json +51 -0
- package/scripts/check-edge.py +42 -0
- package/scripts/check.sh +11 -0
- package/scripts/sync-jsvm-types.sh +10 -0
- package/security/README.md +94 -0
- package/security/assurance_test.go +149 -0
- package/security/compatibility_test.go +128 -0
- package/security/config.go +78 -0
- package/security/dashboard_test.go +103 -0
- package/security/management.go +169 -0
- package/security/openapi.json +508 -0
- package/security/review.go +34 -0
- package/security/security.go +503 -0
- package/security/security_test.go +146 -0
- package/security/state.go +116 -0
- package/security/ui/dashboard.css +4 -0
- package/security/ui/dashboard.js +83 -0
- package/security/ui/main.js +15 -0
- package/security/ui/model.js +32 -0
- package/security/ui/model.test.mjs +25 -0
- package/security/ui/registration.test.mjs +10 -0
- package/settings/env_test.go +41 -0
- package/settings/openapi.json +193 -0
- package/settings/settings.go +155 -0
- package/settings/settings_test.go +31 -0
- package/watcher/watcher.go +192 -0
- package/watcher/watcher_test.go +200 -0
- package/web/static.go +99 -0
- 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
|
+
}
|
package/features/json.go
ADDED
|
@@ -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
|
+
}
|