@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
package/edge/policy.go ADDED
@@ -0,0 +1,150 @@
1
+ // Package edge rejects public requests before they reach PocketBase.
2
+ package edge
3
+
4
+ import (
5
+ "encoding/json"
6
+ "errors"
7
+ "io"
8
+ "net/netip"
9
+ "os"
10
+ "strings"
11
+ "time"
12
+
13
+ "github.com/spink-dev/pocketbase-extension/security"
14
+ )
15
+
16
+ type Policy struct {
17
+ Collections []string `json:"collections"`
18
+ BlockedCIDRs []string `json:"blockedCIDRs"`
19
+ BlockedPaths []string `json:"blockedPaths"`
20
+ TrustedProxies []string `json:"trustedProxies"`
21
+ Rate int `json:"rate"`
22
+ Burst int `json:"burst"`
23
+ ClientRate int `json:"clientRate"`
24
+ ClientBurst int `json:"clientBurst"`
25
+ MaxClients int `json:"maxClients"`
26
+ MaxConcurrent int `json:"maxConcurrent"`
27
+ MaxBodyBytes int64 `json:"maxBodyBytes"`
28
+ blocked, trusted []netip.Prefix
29
+ }
30
+
31
+ func (p *Policy) Validate() error {
32
+ if p.Rate < 1 || p.Rate > 100000 || p.Burst < 1 || p.Burst > 100000 || p.ClientRate < 1 || p.ClientRate > 10000 || p.ClientBurst < 1 || p.ClientBurst > 10000 || p.MaxClients < 1 || p.MaxClients > 100000 || p.MaxConcurrent < 1 || p.MaxConcurrent > 1024 || p.MaxBodyBytes < 1 || p.MaxBodyBytes > 32<<20 {
33
+ return errors.New("invalid edge limits")
34
+ }
35
+ if int64(p.MaxConcurrent)*p.MaxBodyBytes > 128<<20 {
36
+ return errors.New("body buffering budget exceeds 128 MiB")
37
+ }
38
+ if len(p.Collections) > 256 || len(p.BlockedPaths) > 256 {
39
+ return errors.New("too many rules")
40
+ }
41
+ for _, c := range p.Collections {
42
+ if c == "" || len(c) > 128 || c == "_superusers" || c == "pbc_3142635823" || strings.ContainsAny(c, "/%\\. \t\r\n") {
43
+ return errors.New("invalid public collection")
44
+ }
45
+ }
46
+ for _, path := range p.BlockedPaths {
47
+ if !strings.HasPrefix(path, "/") || len(path) > 1024 {
48
+ return errors.New("invalid blocked path")
49
+ }
50
+ }
51
+ var err error
52
+ if p.blocked, err = prefixes(p.BlockedCIDRs); err != nil {
53
+ return err
54
+ }
55
+ p.trusted, err = prefixes(p.TrustedProxies)
56
+ return err
57
+ }
58
+ func prefixes(values []string) ([]netip.Prefix, error) {
59
+ if len(values) > 256 {
60
+ return nil, errors.New("too many CIDRs")
61
+ }
62
+ result := make([]netip.Prefix, 0, len(values))
63
+ for _, v := range values {
64
+ p, e := netip.ParsePrefix(v)
65
+ if e != nil {
66
+ return nil, e
67
+ }
68
+ result = append(result, p)
69
+ }
70
+ return result, nil
71
+ }
72
+ func contains(list []netip.Prefix, ip netip.Addr) bool {
73
+ for _, p := range list {
74
+ if p.Contains(ip) {
75
+ return true
76
+ }
77
+ }
78
+ return false
79
+ }
80
+ func readJSON(path string, dest any, strict bool) error {
81
+ f, err := os.Open(path)
82
+ if err != nil {
83
+ return err
84
+ }
85
+ defer f.Close()
86
+ b, err := io.ReadAll(io.LimitReader(f, (1<<20)+1))
87
+ if err != nil {
88
+ return err
89
+ }
90
+ if len(b) > 1<<20 {
91
+ return errors.New("configuration too large")
92
+ }
93
+ d := json.NewDecoder(strings.NewReader(string(b)))
94
+ if strict {
95
+ d.DisallowUnknownFields()
96
+ }
97
+ if err = d.Decode(dest); err != nil {
98
+ return err
99
+ }
100
+ if d.Decode(new(any)) != io.EOF {
101
+ return errors.New("trailing configuration data")
102
+ }
103
+ return nil
104
+ }
105
+ func LoadPolicy(path string) (Policy, error) {
106
+ var p Policy
107
+ err := readJSON(path, &p, true)
108
+ if err == nil {
109
+ err = p.Validate()
110
+ }
111
+ return p, err
112
+ }
113
+
114
+ type Snapshot struct {
115
+ Version int `json:"version"`
116
+ Revision uint64 `json:"revision"`
117
+ Policy security.Config `json:"policy"`
118
+ Actions map[string]security.Action `json:"actions"`
119
+ }
120
+
121
+ func LoadSnapshot(path string) (Snapshot, error) {
122
+ var s Snapshot
123
+ info, err := os.Stat(path)
124
+ if err != nil {
125
+ return s, err
126
+ }
127
+ if time.Since(info.ModTime()) > 30*time.Second {
128
+ return s, errors.New("security checkpoint is stale")
129
+ }
130
+ if err := readJSON(path, &s, false); err != nil {
131
+ return s, err
132
+ }
133
+ if s.Version != 1 || s.Revision == 0 || len(s.Actions) > 6 {
134
+ return s, errors.New("invalid security checkpoint")
135
+ }
136
+ if err := s.Policy.Validate(); err != nil {
137
+ return s, err
138
+ }
139
+ for key, a := range s.Actions {
140
+ switch key {
141
+ case "auth", "reads", "writes", "files", "realtime", "other":
142
+ default:
143
+ return s, errors.New("invalid action family")
144
+ }
145
+ if key != a.Family || len(a.Reason) > 200 || a.ExpiresAt.IsZero() || a.ExpiresAt.After(time.Now().Add(15*time.Minute)) {
146
+ return s, errors.New("invalid action")
147
+ }
148
+ }
149
+ return s, nil
150
+ }
@@ -0,0 +1,13 @@
1
+ package features
2
+
3
+ import "github.com/pocketbase/pocketbase/core"
4
+
5
+ const SingletonRecordID = "singleton000001"
6
+
7
+ func NewSingletonCollection(name string, optId ...string) *core.Collection {
8
+ collection := core.NewBaseCollection(name, optId...)
9
+ field := collection.Fields.GetByName(core.FieldNameId).(*core.TextField)
10
+ field.Pattern = "^" + SingletonRecordID + "$"
11
+ field.AutogeneratePattern = SingletonRecordID
12
+ return collection
13
+ }
@@ -0,0 +1,46 @@
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 TestSingletonCollection(t *testing.T) {
11
+ app, err := tests.NewTestApp()
12
+ if err != nil {
13
+ t.Fatal(err)
14
+ }
15
+ defer app.Cleanup()
16
+ c := features.NewSingletonCollection("homepage")
17
+ c.Fields.Add(&core.TextField{Name: "title", Required: true})
18
+ if err := app.Save(c); err != nil {
19
+ t.Fatal(err)
20
+ }
21
+ invalid := core.NewRecord(c)
22
+ if err := app.Save(invalid); err == nil {
23
+ t.Fatal("created invalid placeholder")
24
+ }
25
+ first := core.NewRecord(c)
26
+ first.Set("title", "Home")
27
+ if err := app.Save(first); err != nil {
28
+ t.Fatal(err)
29
+ }
30
+ if first.Id != features.SingletonRecordID {
31
+ t.Fatal("wrong id")
32
+ }
33
+ second := core.NewRecord(c)
34
+ second.Set("title", "Second")
35
+ if err := app.Save(second); err == nil {
36
+ t.Fatal("accepted second record")
37
+ }
38
+ second.Id = "another00000001"
39
+ if err := app.Save(second); err == nil {
40
+ t.Fatal("accepted different id")
41
+ }
42
+ first.Set("title", "Updated")
43
+ if err := app.Save(first); err != nil {
44
+ t.Fatal(err)
45
+ }
46
+ }
@@ -0,0 +1,65 @@
1
+ package features_test
2
+
3
+ import (
4
+ "bytes"
5
+ "github.com/pocketbase/pocketbase/apis"
6
+ "github.com/pocketbase/pocketbase/core"
7
+ "github.com/pocketbase/pocketbase/tests"
8
+ "github.com/pocketbase/pocketbase/tools/filesystem"
9
+ "github.com/pocketbase/pocketbase/tools/types"
10
+ "github.com/spink-dev/pocketbase-extension/features"
11
+ "image"
12
+ "image/png"
13
+ "net/http/httptest"
14
+ "testing"
15
+ )
16
+
17
+ func TestFileDimensions(t *testing.T) {
18
+ app, err := tests.NewTestApp()
19
+ if err != nil {
20
+ t.Fatal(err)
21
+ }
22
+ defer app.Cleanup()
23
+ c := core.NewBaseCollection("dimensions_test")
24
+ c.ViewRule = types.Pointer("")
25
+ c.Fields.Add(&core.FileField{Name: "image", MaxSelect: 1, Protected: true})
26
+ if err := app.Save(c); err != nil {
27
+ t.Fatal(err)
28
+ }
29
+ var buffer bytes.Buffer
30
+ if err := png.Encode(&buffer, image.NewRGBA(image.Rect(0, 0, 20, 10))); err != nil {
31
+ t.Fatal(err)
32
+ }
33
+ file, err := filesystem.NewFileFromBytes(buffer.Bytes(), "image.png")
34
+ if err != nil {
35
+ t.Fatal(err)
36
+ }
37
+ record := core.NewRecord(c)
38
+ record.Set("image", file)
39
+ if err := app.Save(record); err != nil {
40
+ t.Fatal(err)
41
+ }
42
+ features.Register(app)
43
+ router, _ := apis.NewRouter(app)
44
+ handler, err := router.BuildMux()
45
+ if err != nil {
46
+ t.Fatal(err)
47
+ }
48
+ path := "/api/files/" + c.Id + "/" + record.Id + "/" + record.GetString("image")
49
+ for _, method := range []string{"GET", "HEAD"} {
50
+ w := httptest.NewRecorder()
51
+ handler.ServeHTTP(w, httptest.NewRequest(method, path+"?dimensions=1", nil))
52
+ if w.Code != 200 || w.Header().Get("X-Image-Width") != "20" || w.Header().Get("X-Image-Height") != "10" {
53
+ t.Fatalf("missing dimensions %d", w.Code)
54
+ }
55
+ }
56
+ c.ViewRule = nil
57
+ if err := app.Save(c); err != nil {
58
+ t.Fatal(err)
59
+ }
60
+ w := httptest.NewRecorder()
61
+ handler.ServeHTTP(w, httptest.NewRequest("GET", path+"?dimensions=1", nil))
62
+ if w.Code == 200 || w.Header().Get("X-Image-Width") != "" {
63
+ t.Fatal("metadata leaked without file access")
64
+ }
65
+ }
@@ -0,0 +1,178 @@
1
+ package features
2
+
3
+ import (
4
+ "bytes"
5
+ "encoding/json"
6
+ "github.com/pocketbase/pocketbase/core"
7
+ "github.com/pocketbase/pocketbase/tools/filesystem"
8
+ "io"
9
+ "mime/multipart"
10
+ "net/http"
11
+ "net/http/httptest"
12
+ "strings"
13
+ "sync"
14
+ )
15
+
16
+ const maxDuplicateBytes = 32 << 20
17
+
18
+ func registerDuplicate(app core.App) {
19
+ app.OnServe().BindFunc(func(se *core.ServeEvent) error {
20
+ var once sync.Once
21
+ var handler http.Handler
22
+ var buildErr error
23
+ se.Router.POST("/api/collections/{collection}/records/{id}/duplicate", func(e *core.RequestEvent) error {
24
+ once.Do(func() { handler, buildErr = se.Router.BuildMux() })
25
+ if buildErr != nil {
26
+ return buildErr
27
+ }
28
+ collection, err := e.App.FindCachedCollectionByNameOrId(e.Request.PathValue("collection"))
29
+ if err != nil || !collection.IsBase() {
30
+ return e.BadRequestError("Only base collections support duplication", nil)
31
+ }
32
+ path := "/api/collections/" + collection.Id + "/records/" + e.Request.PathValue("id")
33
+ view := e.Request.Clone(e.Request.Context())
34
+ u := *view.URL
35
+ view.URL = &u
36
+ view.Method = "GET"
37
+ view.URL.Path = path
38
+ view.URL.RawQuery = ""
39
+ view.Body = http.NoBody
40
+ view.ContentLength = 0
41
+ check := httptest.NewRecorder()
42
+ handler.ServeHTTP(check, view)
43
+ if check.Code == 403 || check.Code == 404 {
44
+ return e.NotFoundError("Source unavailable", nil)
45
+ }
46
+ if check.Code != 200 {
47
+ return e.Blob(check.Code, "application/json", check.Body.Bytes())
48
+ }
49
+ source, err := e.App.FindRecordById(collection, e.Request.PathValue("id"))
50
+ if err != nil {
51
+ return e.NotFoundError("Missing source", err)
52
+ }
53
+ info, err := e.RequestInfo()
54
+ if err != nil {
55
+ return err
56
+ }
57
+ allowed, err := e.App.CanAccessRecord(source, info, collection.ViewRule)
58
+ if err != nil || !allowed {
59
+ return e.NotFoundError("Source unavailable", nil)
60
+ }
61
+ overrides := map[string]any{}
62
+ if e.Request.ContentLength != 0 {
63
+ if err = e.BindBody(&overrides); err != nil {
64
+ return e.BadRequestError("Invalid overrides", err)
65
+ }
66
+ }
67
+ var buf bytes.Buffer
68
+ writer := multipart.NewWriter(&buf)
69
+ var fs *filesystem.System
70
+ defer func() {
71
+ if fs != nil {
72
+ fs.Close()
73
+ }
74
+ }()
75
+ for _, field := range collection.Fields {
76
+ name := field.GetName()
77
+ if name == core.FieldNameId || field.Type() == core.FieldTypeAutodate || field.GetHidden() && !info.HasSuperuserAuth() {
78
+ continue
79
+ }
80
+ value, overridden := overrides[name]
81
+ if f, ok := field.(*core.FileField); ok && !overridden {
82
+ names := source.GetStringSlice(name)
83
+ if len(names) == 0 {
84
+ continue
85
+ }
86
+ if f.Protected {
87
+ protected := info.Clone()
88
+ protected.Context = core.RequestInfoContextProtectedFile
89
+ ok, err := e.App.CanAccessRecord(source, protected, collection.ViewRule)
90
+ if err != nil || !ok {
91
+ return e.NotFoundError("Source attachment unavailable", nil)
92
+ }
93
+ }
94
+ if fs == nil {
95
+ fs, err = e.App.NewFilesystem()
96
+ if err != nil {
97
+ return err
98
+ }
99
+ fs.SetContext(e.Request.Context())
100
+ }
101
+ for _, filename := range names {
102
+ file, err := fs.GetReuploadableFile(source.BaseFilesPath()+"/"+filename, true)
103
+ if err != nil {
104
+ return err
105
+ }
106
+ part, err := writer.CreateFormFile(name, file.OriginalName)
107
+ if err != nil {
108
+ return err
109
+ }
110
+ r, err := file.Reader.Open()
111
+ if err != nil {
112
+ return err
113
+ }
114
+ _, err = io.Copy(part, io.LimitReader(r, int64(maxDuplicateBytes-buf.Len()+1)))
115
+ r.Close()
116
+ if err != nil {
117
+ return err
118
+ }
119
+ if buf.Len() > maxDuplicateBytes {
120
+ return e.Error(413, "Duplicate exceeds 32 MiB", nil)
121
+ }
122
+ }
123
+ continue
124
+ }
125
+ if !overridden {
126
+ value = source.GetRaw(name)
127
+ }
128
+ text, ok := value.(string)
129
+ if !ok {
130
+ raw, err := json.Marshal(value)
131
+ if err != nil {
132
+ return err
133
+ }
134
+ text = string(raw)
135
+ }
136
+ if len(text)+buf.Len() > maxDuplicateBytes {
137
+ return e.Error(413, "Duplicate exceeds 32 MiB", nil)
138
+ }
139
+ if err = writer.WriteField(name, text); err != nil {
140
+ return err
141
+ }
142
+ }
143
+ // Preserve modifier overrides such as relation additions for the native create pipeline.
144
+ for k, v := range overrides {
145
+ if collection.Fields.GetByName(k) != nil {
146
+ continue
147
+ }
148
+ if strings.ContainsAny(k, "+-") {
149
+ raw, _ := json.Marshal(v)
150
+ if len(raw)+buf.Len() > maxDuplicateBytes {
151
+ return e.Error(413, "Duplicate exceeds 32 MiB", nil)
152
+ }
153
+ if err = writer.WriteField(k, string(raw)); err != nil {
154
+ return err
155
+ }
156
+ }
157
+ }
158
+ if err = writer.Close(); err != nil {
159
+ return err
160
+ }
161
+ req := e.Request.Clone(e.Request.Context())
162
+ url := *req.URL
163
+ req.URL = &url
164
+ req.URL.Path = "/api/collections/" + collection.Id + "/records"
165
+ req.Header = req.Header.Clone()
166
+ req.Header.Set("Content-Type", writer.FormDataContentType())
167
+ req.Body = io.NopCloser(bytes.NewReader(buf.Bytes()))
168
+ req.ContentLength = int64(buf.Len())
169
+ req.GetBody = nil
170
+ req.Form = nil
171
+ req.PostForm = nil
172
+ req.MultipartForm = nil
173
+ handler.ServeHTTP(e.Response, req)
174
+ return nil
175
+ })
176
+ return se.Next()
177
+ })
178
+ }
@@ -0,0 +1,128 @@
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/filesystem"
9
+ "github.com/pocketbase/pocketbase/tools/types"
10
+ "github.com/spink-dev/pocketbase-extension/features"
11
+ "io"
12
+ "net/http/httptest"
13
+ "strings"
14
+ "testing"
15
+ )
16
+
17
+ func TestRecordDuplicate(t *testing.T) {
18
+ app, err := tests.NewTestApp()
19
+ if err != nil {
20
+ t.Fatal(err)
21
+ }
22
+ defer app.Cleanup()
23
+ c := core.NewBaseCollection("duplicate_test")
24
+ c.ViewRule = types.Pointer("")
25
+ c.CreateRule = types.Pointer(`title != "denied"`)
26
+ c.Fields.Add(&core.TextField{Name: "title"}, &core.FileField{Name: "attachment", MaxSelect: 2, Protected: true}, &core.TextField{Name: "secret", Hidden: true})
27
+ if err := app.Save(c); err != nil {
28
+ t.Fatal(err)
29
+ }
30
+ file, err := filesystem.NewFileFromBytes([]byte("attachment contents"), "sample.txt")
31
+ if err != nil {
32
+ t.Fatal(err)
33
+ }
34
+ source := core.NewRecord(c)
35
+ source.Set("title", "original")
36
+ source.Set("attachment", file)
37
+ source.Set("secret", "private")
38
+ if err := app.Save(source); err != nil {
39
+ t.Fatal(err)
40
+ }
41
+ features.Register(app)
42
+ r, _ := apis.NewRouter(app)
43
+ if err := app.OnServe().Trigger(&core.ServeEvent{App: app, Router: r}); err != nil {
44
+ t.Fatal(err)
45
+ }
46
+ r.BindFunc(func(e *core.RequestEvent) error {
47
+ err := e.Next()
48
+ if err != nil {
49
+ t.Logf("route error: %+v", err)
50
+ }
51
+ return err
52
+ })
53
+ handler, err := r.BuildMux()
54
+ if err != nil {
55
+ t.Fatal(err)
56
+ }
57
+ for _, tc := range []struct {
58
+ body string
59
+ status int
60
+ }{{`{"title":"copy"}`, 200}, {`{"title":"denied"}`, 400}} {
61
+ req := httptest.NewRequest("POST", "/api/collections/"+c.Id+"/records/"+source.Id+"/duplicate", strings.NewReader(tc.body))
62
+ req.Header.Set("Content-Type", "application/json")
63
+ w := httptest.NewRecorder()
64
+ handler.ServeHTTP(w, req)
65
+ if w.Code != tc.status {
66
+ t.Fatalf("expected %d got %d: %s", tc.status, w.Code, w.Body.String())
67
+ }
68
+ if tc.status == 200 {
69
+ var data struct {
70
+ Id string
71
+ Attachment []string
72
+ Secret string
73
+ }
74
+ if err := json.Unmarshal(w.Body.Bytes(), &data); err != nil {
75
+ t.Fatal(err)
76
+ }
77
+ if data.Id == source.Id || data.Secret != "" || len(data.Attachment) != 1 {
78
+ t.Fatalf("invalid duplicate response: %+v source=%v", data, source.GetStringSlice("attachment"))
79
+ }
80
+ copy, err := app.FindRecordById(c, data.Id)
81
+ if err != nil {
82
+ t.Fatal(err)
83
+ }
84
+ fsys, err := app.NewFilesystem()
85
+ if err != nil {
86
+ t.Fatal(err)
87
+ }
88
+ exists, err := fsys.Exists(copy.BaseFilesPath() + "/" + data.Attachment[0])
89
+ reader, readErr := fsys.GetReader(copy.BaseFilesPath() + "/" + data.Attachment[0])
90
+ if readErr != nil {
91
+ t.Fatal(readErr)
92
+ }
93
+ contents, _ := io.ReadAll(reader)
94
+ reader.Close()
95
+ if string(contents) != "attachment contents" {
96
+ t.Fatal("copied attachment contents differ")
97
+ }
98
+ fsys.Close()
99
+ if err != nil || !exists {
100
+ t.Fatal("missing copied file")
101
+ }
102
+ }
103
+ }
104
+ c.ViewRule = types.Pointer(`@request.context != "protectedFile"`)
105
+ if err := app.Save(c); err != nil {
106
+ t.Fatal(err)
107
+ }
108
+ protectedReq := httptest.NewRequest("POST", "/api/collections/"+c.Id+"/records/"+source.Id+"/duplicate", nil)
109
+ protectedWriter := httptest.NewRecorder()
110
+ handler.ServeHTTP(protectedWriter, protectedReq)
111
+ if protectedWriter.Code != 404 {
112
+ t.Fatalf("protected attachment rule bypass: %d", protectedWriter.Code)
113
+ }
114
+ c.ViewRule = nil
115
+ if err := app.Save(c); err != nil {
116
+ t.Fatal(err)
117
+ }
118
+ req := httptest.NewRequest("POST", "/api/collections/"+c.Id+"/records/"+source.Id+"/duplicate", nil)
119
+ w := httptest.NewRecorder()
120
+ handler.ServeHTTP(w, req)
121
+ if w.Code != 404 {
122
+ t.Fatalf("view rule bypass: %d", w.Code)
123
+ }
124
+ count, err := app.CountRecords(c.Id)
125
+ if err != nil || count != 2 {
126
+ t.Fatalf("unexpected record count %d %v", count, err)
127
+ }
128
+ }
@@ -0,0 +1,46 @@
1
+ package features
2
+
3
+ import (
4
+ "context"
5
+ "errors"
6
+ validation "github.com/pocketbase/ozzo-validation/v4"
7
+ "github.com/pocketbase/pocketbase/core"
8
+ "github.com/spf13/cast"
9
+ "regexp"
10
+ "strings"
11
+ )
12
+
13
+ const FieldTypeColor = "color"
14
+
15
+ var colorPattern = regexp.MustCompile(`^#(?:[0-9a-f]{3}|[0-9a-f]{4}|[0-9a-f]{6}|[0-9a-f]{8})$`)
16
+
17
+ type ColorField struct{ core.TextField }
18
+
19
+ func init() { core.Fields[FieldTypeColor] = func() core.Field { return &ColorField{} } }
20
+ func (f *ColorField) Type() string { return FieldTypeColor }
21
+ func (f *ColorField) PrepareValue(_ *core.Record, raw any) (any, error) {
22
+ return strings.ToLower(cast.ToString(raw)), nil
23
+ }
24
+ func (f *ColorField) ValidateValue(ctx context.Context, app core.App, r *core.Record) error {
25
+ if err := f.TextField.ValidateValue(ctx, app, r); err != nil {
26
+ return err
27
+ }
28
+ value := r.GetString(f.Name)
29
+ if value != "" && !colorPattern.MatchString(value) {
30
+ return validation.NewError("validation_color", "Must be a hex color (#RGB, #RGBA, #RRGGBB or #RRGGBBAA)")
31
+ }
32
+ return nil
33
+ }
34
+ func (f *ColorField) ValidateSettings(ctx context.Context, app core.App, c *core.Collection) error {
35
+ if f.PrimaryKey || f.AutogeneratePattern != "" {
36
+ return errors.New("color fields cannot be primary keys or autogenerated")
37
+ }
38
+ return f.TextField.ValidateSettings(ctx, app, c)
39
+ }
40
+
41
+ func (f *ColorField) FindSetter(key string) core.SetterFunc {
42
+ if key != f.Name {
43
+ return nil
44
+ }
45
+ return func(r *core.Record, raw any) { value, _ := f.PrepareValue(r, raw); r.SetRaw(f.Name, value) }
46
+ }
@@ -0,0 +1,39 @@
1
+ package features
2
+
3
+ import (
4
+ "context"
5
+ "errors"
6
+ validation "github.com/pocketbase/ozzo-validation/v4"
7
+ "github.com/pocketbase/pocketbase/core"
8
+ "github.com/spf13/cast"
9
+ "time"
10
+ )
11
+
12
+ const FieldTypeDateOnly = "dateOnly"
13
+
14
+ type DateOnlyField struct{ core.TextField }
15
+
16
+ func init() { core.Fields[FieldTypeDateOnly] = func() core.Field { return &DateOnlyField{} } }
17
+ func (f *DateOnlyField) Type() string { return FieldTypeDateOnly }
18
+ func (f *DateOnlyField) PrepareValue(_ *core.Record, raw any) (any, error) {
19
+ return cast.ToString(raw), nil
20
+ }
21
+ func (f *DateOnlyField) ValidateValue(ctx context.Context, app core.App, r *core.Record) error {
22
+ if err := f.TextField.ValidateValue(ctx, app, r); err != nil {
23
+ return err
24
+ }
25
+ value := r.GetString(f.Name)
26
+ if value != "" {
27
+ parsed, err := time.Parse(time.DateOnly, value)
28
+ if err != nil || parsed.Format(time.DateOnly) != value {
29
+ return validation.NewError("validation_date_only", "Must be a calendar date in YYYY-MM-DD format")
30
+ }
31
+ }
32
+ return nil
33
+ }
34
+ func (f *DateOnlyField) ValidateSettings(ctx context.Context, app core.App, c *core.Collection) error {
35
+ if f.PrimaryKey || f.AutogeneratePattern != "" {
36
+ return errors.New("calendar dates cannot be primary keys or autogenerated")
37
+ }
38
+ return f.TextField.ValidateSettings(ctx, app, c)
39
+ }