@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/jsvm/pool.go ADDED
@@ -0,0 +1,73 @@
1
+ package jsvm
2
+
3
+ import (
4
+ "sync"
5
+
6
+ "github.com/dop251/goja"
7
+ )
8
+
9
+ type poolItem struct {
10
+ mux sync.Mutex
11
+ busy bool
12
+ vm *goja.Runtime
13
+ }
14
+
15
+ type vmsPool struct {
16
+ mux sync.RWMutex
17
+ factory func() *goja.Runtime
18
+ items []*poolItem
19
+ }
20
+
21
+ // newPool creates a new pool with pre-warmed vms generated from the specified factory.
22
+ func newPool(size int, factory func() *goja.Runtime) *vmsPool {
23
+ pool := &vmsPool{
24
+ factory: factory,
25
+ items: make([]*poolItem, size),
26
+ }
27
+
28
+ for i := 0; i < size; i++ {
29
+ vm := pool.factory()
30
+ pool.items[i] = &poolItem{vm: vm}
31
+ }
32
+
33
+ return pool
34
+ }
35
+
36
+ // run executes "call" with a vm created from the pool
37
+ // (either from the buffer or a new one if all buffered vms are busy)
38
+ func (p *vmsPool) run(call func(vm *goja.Runtime) error) error {
39
+ p.mux.RLock()
40
+
41
+ // try to find a free item
42
+ var freeItem *poolItem
43
+ for _, item := range p.items {
44
+ item.mux.Lock()
45
+ if item.busy {
46
+ item.mux.Unlock()
47
+ continue
48
+ }
49
+ item.busy = true
50
+ item.mux.Unlock()
51
+ freeItem = item
52
+ break
53
+ }
54
+
55
+ p.mux.RUnlock()
56
+
57
+ // create a new one-off item if of all of the pool items are currently busy
58
+ //
59
+ // note: if turned out not efficient we may change this in the future
60
+ // by adding the created item in the pool with some timer for removal
61
+ if freeItem == nil {
62
+ return call(p.factory())
63
+ }
64
+
65
+ execErr := call(freeItem.vm)
66
+
67
+ // "free" the vm
68
+ freeItem.mux.Lock()
69
+ freeItem.busy = false
70
+ freeItem.mux.Unlock()
71
+
72
+ return execErr
73
+ }
@@ -0,0 +1,24 @@
1
+ package jsvm
2
+
3
+ import (
4
+ "errors"
5
+ "github.com/dop251/goja"
6
+ "testing"
7
+ )
8
+
9
+ func TestProgramSource(t *testing.T) {
10
+ count := 0
11
+ p := &plugin{config: Config{ProgramSource: func(name, source string) (*goja.Program, error) { count++; return goja.Compile(name, source, false) }}}
12
+ v, err := p.runScript(goja.New(), "test.js", "2+3")
13
+ if err != nil || v.ToInteger() != 5 || count != 1 {
14
+ t.Fatalf("%v %v %d", v, err, count)
15
+ }
16
+ p.config.ProgramSource = func(string, string) (*goja.Program, error) { return nil, errors.New("compile failed") }
17
+ if _, err = p.runScript(goja.New(), "x", ""); err == nil {
18
+ t.Fatal("missing compile error")
19
+ }
20
+ p.config.ProgramSource = func(string, string) (*goja.Program, error) { return nil, nil }
21
+ if _, err = p.runScript(goja.New(), "x", ""); err == nil {
22
+ t.Fatal("nil program accepted")
23
+ }
24
+ }
@@ -0,0 +1,202 @@
1
+ package loadtest
2
+
3
+ import (
4
+ "bufio"
5
+ "context"
6
+ "encoding/json"
7
+ "errors"
8
+ "fmt"
9
+ "io"
10
+ "net"
11
+ "net/http"
12
+ "net/netip"
13
+ "net/url"
14
+ "strings"
15
+ "sync"
16
+ "sync/atomic"
17
+ "time"
18
+ )
19
+
20
+ type Config struct {
21
+ NormalEvery int
22
+ Target string
23
+ Rate int
24
+ Clients int
25
+ Duration time.Duration
26
+ MaxRequests int
27
+ }
28
+ type Report struct {
29
+ CanarySent uint64 `json:"canarySent"`
30
+ CanarySucceeded uint64 `json:"canarySucceeded"`
31
+ Offered uint64 `json:"offered"`
32
+ Interrupted bool `json:"interrupted"`
33
+ Sent uint64 `json:"sent"`
34
+ Completed uint64 `json:"completed"`
35
+ Errors uint64 `json:"errors"`
36
+ RateLimited uint64 `json:"rateLimited"`
37
+ Unavailable uint64 `json:"unavailable"`
38
+ Dropped uint64 `json:"dropped"`
39
+ StatusClasses [6]uint64 `json:"statusClasses"`
40
+ LatencyBuckets [7]uint64 `json:"latencyBuckets"`
41
+ Elapsed time.Duration `json:"elapsedNanoseconds"`
42
+ }
43
+
44
+ func (c Config) validate() (*url.URL, error) {
45
+ u, err := url.Parse(c.Target)
46
+ if err != nil || u.Scheme != "http" || u.User != nil || u.Fragment != "" || u.RawQuery != "" {
47
+ return nil, errors.New("target must be an HTTP loopback URL without credentials, query, or fragment")
48
+ }
49
+ ip, err := netip.ParseAddr(u.Hostname())
50
+ if err != nil || !ip.IsLoopback() || ip.Zone() != "" {
51
+ return nil, errors.New("target must use a literal loopback IP")
52
+ }
53
+ if u.Path != "/api/health" && u.Path != "/api/realtime" && u.Path != "/api/collections/pb_sim_records/records" {
54
+ return nil, errors.New("supported read-only targets are health, realtime and the pb_sim_records fixture")
55
+ }
56
+ if c.NormalEvery < 0 || c.NormalEvery == 1 || c.NormalEvery > 100 {
57
+ return nil, errors.New("normalEvery must be zero or 2 to 100")
58
+ }
59
+ if c.Rate < 1 || c.Rate > 10000 || c.Clients < 1 || c.Clients > 512 || c.Duration < time.Millisecond || c.Duration > 10*time.Minute || c.MaxRequests < 1 || c.MaxRequests > 1000000 {
60
+ return nil, errors.New("limits: 1..10000 requests/s, 1..512 clients, up to 10 minutes and 1000000 requests")
61
+ }
62
+ return u, nil
63
+ }
64
+ func Validate(c Config) error { _, err := c.validate(); return err }
65
+
66
+ func Run(ctx context.Context, c Config) (Report, error) {
67
+ callerCtx := ctx
68
+ u, err := c.validate()
69
+ if err != nil {
70
+ return Report{}, err
71
+ }
72
+ dialer := &net.Dialer{Timeout: 2 * time.Second}
73
+ transport := &http.Transport{MaxConnsPerHost: c.Clients, MaxIdleConnsPerHost: c.Clients, MaxIdleConns: c.Clients, ResponseHeaderTimeout: 2 * time.Second, DisableCompression: true, DialContext: func(ctx context.Context, network, address string) (net.Conn, error) {
74
+ expected := u.Host
75
+ if u.Port() == "" {
76
+ expected = net.JoinHostPort(u.Hostname(), "80")
77
+ }
78
+ if address != expected {
79
+ return nil, fmt.Errorf("unexpected target address")
80
+ }
81
+ return dialer.DialContext(ctx, network, address)
82
+ }}
83
+ defer transport.CloseIdleConnections()
84
+ client := &http.Client{Transport: transport, Timeout: 3 * time.Second, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}
85
+ ctx, cancel := context.WithTimeout(ctx, c.Duration)
86
+ defer cancel()
87
+ var report Report
88
+ var mu sync.Mutex
89
+ var sent atomic.Uint64
90
+ jobs := make(chan int)
91
+ var wg sync.WaitGroup
92
+ start := time.Now()
93
+ for i := 0; i < c.Clients; i++ {
94
+ wg.Go(func() {
95
+ for sequence := range jobs {
96
+ destination := *u
97
+ canary := c.NormalEvery > 0 && sequence%c.NormalEvery == 0
98
+ if canary {
99
+ destination.Path = "/api/health"
100
+ }
101
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, destination.String(), nil)
102
+ if err != nil {
103
+ return
104
+ }
105
+ req.Header.Set("User-Agent", "PocketBase-local-loadtest")
106
+ begin := time.Now()
107
+ sent.Add(1)
108
+ res, err := client.Do(req)
109
+ status := 0
110
+ if err == nil {
111
+ status = res.StatusCode
112
+ if destination.Path == "/api/realtime" && status == 200 {
113
+ scanner := bufio.NewScanner(io.LimitReader(res.Body, 65537))
114
+ connected := false
115
+ eventName := ""
116
+ for scanner.Scan() {
117
+ line := scanner.Text()
118
+ if strings.HasPrefix(line, "event:") {
119
+ eventName = strings.TrimSpace(strings.TrimPrefix(line, "event:"))
120
+ }
121
+ if eventName == "PB_CONNECT" && strings.HasPrefix(line, "data:") {
122
+ var data struct {
123
+ ClientID string `json:"clientId"`
124
+ }
125
+ connected = json.Unmarshal([]byte(strings.TrimSpace(strings.TrimPrefix(line, "data:"))), &data) == nil && data.ClientID != ""
126
+ break
127
+ }
128
+ }
129
+ if !connected {
130
+ err = errors.New("missing realtime connection event")
131
+ }
132
+ } else {
133
+ var body []byte
134
+ body, err = io.ReadAll(io.LimitReader(res.Body, (1<<20)+1))
135
+ if len(body) > 1<<20 {
136
+ err = errors.New("response body exceeds 1 MiB")
137
+ }
138
+ if status >= 200 && status < 300 && !json.Valid(body) {
139
+ err = errors.New("invalid JSON response")
140
+ }
141
+ }
142
+ res.Body.Close()
143
+ }
144
+ elapsed := time.Since(begin)
145
+ mu.Lock()
146
+ if canary {
147
+ report.CanarySent++
148
+ if err == nil && status >= 200 && status < 300 {
149
+ report.CanarySucceeded++
150
+ }
151
+ }
152
+ if err != nil {
153
+ report.Errors++
154
+ } else {
155
+ report.Completed++
156
+ }
157
+ if status > 0 {
158
+ report.StatusClasses[min(status/100, 5)]++
159
+ }
160
+ if status == 429 {
161
+ report.RateLimited++
162
+ }
163
+ if status == 503 {
164
+ report.Unavailable++
165
+ }
166
+ bucket := 6
167
+ for i, limit := range []time.Duration{time.Millisecond, 5 * time.Millisecond, 10 * time.Millisecond, 50 * time.Millisecond, 100 * time.Millisecond, time.Second} {
168
+ if elapsed <= limit {
169
+ bucket = i
170
+ break
171
+ }
172
+ }
173
+ report.LatencyBuckets[bucket]++
174
+ mu.Unlock()
175
+ }
176
+ })
177
+ }
178
+ ticker := time.NewTicker(time.Second / time.Duration(c.Rate))
179
+ defer ticker.Stop()
180
+ scheduled := 0
181
+ loop:
182
+ for scheduled < c.MaxRequests {
183
+ select {
184
+ case <-ctx.Done():
185
+ break loop
186
+ case <-ticker.C:
187
+ select {
188
+ case jobs <- scheduled:
189
+ scheduled++
190
+ default:
191
+ report.Dropped++
192
+ }
193
+ }
194
+ }
195
+ close(jobs)
196
+ wg.Wait()
197
+ report.Sent = sent.Load()
198
+ report.Offered = report.Sent + report.Dropped
199
+ report.Interrupted = callerCtx.Err() != nil
200
+ report.Elapsed = time.Since(start)
201
+ return report, nil
202
+ }
@@ -0,0 +1,84 @@
1
+ package loadtest
2
+
3
+ import (
4
+ "context"
5
+ "fmt"
6
+ "net/http"
7
+ "net/http/httptest"
8
+ "strings"
9
+ "sync/atomic"
10
+ "testing"
11
+ "time"
12
+ )
13
+
14
+ func TestRejectExternalTargets(t *testing.T) {
15
+ for _, target := range []string{"http://example.com/api/health", "http://192.0.2.1/api/health", "http://localhost/api/health", "http://127.0.0.1/api/collections/users/records", "http://user:secret@127.0.0.1/api/health", "http://127.0.0.1/api/health?url=example.com"} {
16
+ _, err := Run(context.Background(), Config{Target: target, Rate: 1, Clients: 1, Duration: time.Second, MaxRequests: 1})
17
+ if err == nil {
18
+ t.Fatalf("accepted %s", target)
19
+ }
20
+ }
21
+ }
22
+ func TestBudgetAndNoRedirect(t *testing.T) {
23
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
24
+ http.Redirect(w, r, "http://192.0.2.1/", http.StatusFound)
25
+ }))
26
+ defer server.Close()
27
+ report, err := Run(context.Background(), Config{Target: server.URL + "/api/health", Rate: 100, Clients: 2, Duration: time.Second, MaxRequests: 5})
28
+ if err != nil {
29
+ t.Fatal(err)
30
+ }
31
+ if report.Sent != 5 || report.Completed != 5 || report.StatusClasses[3] != 5 {
32
+ t.Fatalf("unexpected report %+v", report)
33
+ }
34
+ }
35
+ func TestCancellation(t *testing.T) {
36
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { <-r.Context().Done() }))
37
+ defer server.Close()
38
+ ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
39
+ defer cancel()
40
+ start := time.Now()
41
+ report, err := Run(ctx, Config{Target: server.URL + "/api/realtime", Rate: 100, Clients: 2, Duration: time.Minute, MaxRequests: 100})
42
+ if err != nil {
43
+ t.Fatal(err)
44
+ }
45
+ if time.Since(start) > time.Second || report.Sent > 2 || report.Errors != report.Sent {
46
+ t.Fatalf("cancellation failed %+v", report)
47
+ }
48
+ }
49
+
50
+ func TestProtocolAndResponseBounds(t *testing.T) {
51
+ for _, tc := range []struct {
52
+ path, body string
53
+ valid bool
54
+ }{
55
+ {"/api/health", `{"code":200}`, true},
56
+ {"/api/health", `not json`, false},
57
+ {"/api/health", strings.Repeat(" ", 1<<20) + `{}`, false},
58
+ {"/api/realtime", "event: PB_CONNECT\ndata: {\"clientId\":\"fixture\"}\n\n", true},
59
+ {"/api/realtime", "event: PB_CONNECT\ndata: {}\n\n", false},
60
+ } {
61
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { fmt.Fprint(w, tc.body) }))
62
+ config := Config{Target: server.URL + tc.path, Rate: 100, Clients: 1, Duration: time.Second, MaxRequests: 1}
63
+ if err := Validate(config); err != nil {
64
+ t.Fatal(err)
65
+ }
66
+ report, err := Run(context.Background(), config)
67
+ server.Close()
68
+ if err != nil || (report.Errors == 0) != tc.valid || report.Sent != 1 {
69
+ t.Fatalf("unexpected protocol result %+v %v", report, err)
70
+ }
71
+ }
72
+ }
73
+
74
+ func TestDryValidationDoesNotContactTarget(t *testing.T) {
75
+ var calls atomic.Int32
76
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { calls.Add(1) }))
77
+ defer server.Close()
78
+ if err := Validate(Config{Target: server.URL + "/api/health", Rate: 1, Clients: 1, Duration: time.Second, MaxRequests: 1}); err != nil {
79
+ t.Fatal(err)
80
+ }
81
+ if calls.Load() != 0 {
82
+ t.Fatal("validation sent traffic")
83
+ }
84
+ }
@@ -0,0 +1,23 @@
1
+ # Validation messages
2
+
3
+ Opt-in error-code translations for a custom Go binary:
4
+
5
+ ```go
6
+ localization.Register(app, localization.Catalog{
7
+ "de": {"validation_required": "Pflichtfeld"},
8
+ "fr": {"validation_required": "Champ obligatoire"},
9
+ })
10
+ ```
11
+
12
+ Clients select a language with `?lang=de` (including SDK query options). Exact tags
13
+ are tried before progressively shorter language tags. Missing translations keep
14
+ PocketBase's English message. This changes only normalized public validation
15
+ messages, preserving status, error codes and parameters. It never translates raw
16
+ internal errors or mutates shared validation errors. Top-level API messages remain
17
+ unchanged. Translations are startup configuration; no new settings database or
18
+ admin translation application is introduced.
19
+
20
+ `catalogue.json` lists literal codes/messages found in PocketBase and its pinned
21
+ validation dependency. Regenerate with `the root repository’s `python3 scripts/localization/catalogue.py``.
22
+ Dynamic/custom plugin error codes are not enumerable and must be supplied by the
23
+ application. Catalogue messages are text, not HTML; clients must render them as text.