@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
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
|
+
}
|