@x47base/pocketbase-addon 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (117) hide show
  1. package/.dockerignore +9 -0
  2. package/Dockerfile +26 -0
  3. package/FEATURES.md +32 -0
  4. package/LICENSE.md +17 -0
  5. package/MIGRATION.md +49 -0
  6. package/NOTICE.md +5 -0
  7. package/README.md +26 -0
  8. package/adapter.go +31 -0
  9. package/admin/register.go +60 -0
  10. package/admin/register_test.go +37 -0
  11. package/backups/backup_encryption_test.go +82 -0
  12. package/backups/encryption.go +138 -0
  13. package/backups/integration_test.go +51 -0
  14. package/backups/register.go +120 -0
  15. package/backups/restore.go +119 -0
  16. package/backups/s3_test.go +52 -0
  17. package/backups/swap.go +53 -0
  18. package/backups/swap_test.go +75 -0
  19. package/backups/upload.go +52 -0
  20. package/bin/pocketbase-extension.mjs +25 -0
  21. package/cmd/edge/main.go +123 -0
  22. package/cmd/import-fork/main.go +37 -0
  23. package/cmd/loadtest/main.go +61 -0
  24. package/cmd/loadtest/sandbox.go +73 -0
  25. package/cmd/loadtest/sandbox_test.go +20 -0
  26. package/cmd/pocketbase/main.go +78 -0
  27. package/deploy/README.md +148 -0
  28. package/deploy/app/hooks/README.md +2 -0
  29. package/deploy/app/migrations/1789000000_notes.js +16 -0
  30. package/deploy/app/public/README.md +2 -0
  31. package/deploy/compose.secrets.yaml +8 -0
  32. package/deploy/compose.yaml +68 -0
  33. package/deploy/edge.json +13 -0
  34. package/edge/gateway.go +295 -0
  35. package/edge/gateway_test.go +296 -0
  36. package/edge/openapi.json +1 -0
  37. package/edge/policy.go +150 -0
  38. package/features/collection_singleton.go +13 -0
  39. package/features/collection_singleton_test.go +46 -0
  40. package/features/dimensions_test.go +65 -0
  41. package/features/duplicate.go +178 -0
  42. package/features/duplicate_test.go +128 -0
  43. package/features/field_color.go +46 -0
  44. package/features/field_date_only.go +39 -0
  45. package/features/field_json_schema.go +92 -0
  46. package/features/field_scalar_extensions_test.go +66 -0
  47. package/features/files.go +36 -0
  48. package/features/filter_has_any_test.go +81 -0
  49. package/features/generate_test.go +72 -0
  50. package/features/has_any_visibility_test.go +62 -0
  51. package/features/json.go +50 -0
  52. package/features/membership.go +64 -0
  53. package/features/register.go +45 -0
  54. package/features/schema_test.go +58 -0
  55. package/features/ui/main.js +133 -0
  56. package/features/ui/settings.js +31 -0
  57. package/go.mod +54 -0
  58. package/go.sum +159 -0
  59. package/internal/archive/create.go +91 -0
  60. package/internal/archive/create_test.go +125 -0
  61. package/internal/archive/extract.go +99 -0
  62. package/internal/archive/extract_test.go +88 -0
  63. package/jsvm/binds.go +1273 -0
  64. package/jsvm/binds_app_reset_test.go +314 -0
  65. package/jsvm/binds_test.go +1870 -0
  66. package/jsvm/form_data.go +149 -0
  67. package/jsvm/form_data_test.go +225 -0
  68. package/jsvm/internal/types/generated/embed.go +6 -0
  69. package/jsvm/internal/types/generated/types.d.ts +24820 -0
  70. package/jsvm/internal/types/types.go +1408 -0
  71. package/jsvm/jsvm.go +587 -0
  72. package/jsvm/mapper.go +67 -0
  73. package/jsvm/mapper_test.go +42 -0
  74. package/jsvm/pool.go +73 -0
  75. package/jsvm/program_source_test.go +24 -0
  76. package/loadtest/loadtest.go +202 -0
  77. package/loadtest/loadtest_test.go +84 -0
  78. package/localization/README.md +23 -0
  79. package/localization/catalogue.json +483 -0
  80. package/localization/localization.go +94 -0
  81. package/localization/localization_test.go +21 -0
  82. package/mail/register.go +80 -0
  83. package/mail/register_test.go +49 -0
  84. package/mail/resolve.go +91 -0
  85. package/migration/import.go +96 -0
  86. package/migration/import_test.go +58 -0
  87. package/otp/otp.go +56 -0
  88. package/otp/otp_test.go +56 -0
  89. package/package.json +51 -0
  90. package/scripts/check-edge.py +42 -0
  91. package/scripts/check.sh +11 -0
  92. package/scripts/sync-jsvm-types.sh +10 -0
  93. package/security/README.md +94 -0
  94. package/security/assurance_test.go +149 -0
  95. package/security/compatibility_test.go +128 -0
  96. package/security/config.go +78 -0
  97. package/security/dashboard_test.go +103 -0
  98. package/security/management.go +169 -0
  99. package/security/openapi.json +508 -0
  100. package/security/review.go +34 -0
  101. package/security/security.go +503 -0
  102. package/security/security_test.go +146 -0
  103. package/security/state.go +116 -0
  104. package/security/ui/dashboard.css +4 -0
  105. package/security/ui/dashboard.js +83 -0
  106. package/security/ui/main.js +15 -0
  107. package/security/ui/model.js +32 -0
  108. package/security/ui/model.test.mjs +25 -0
  109. package/security/ui/registration.test.mjs +10 -0
  110. package/settings/env_test.go +41 -0
  111. package/settings/openapi.json +193 -0
  112. package/settings/settings.go +155 -0
  113. package/settings/settings_test.go +31 -0
  114. package/watcher/watcher.go +192 -0
  115. package/watcher/watcher_test.go +200 -0
  116. package/web/static.go +99 -0
  117. package/web/static_test.go +48 -0
@@ -0,0 +1,155 @@
1
+ package settings
2
+
3
+ import (
4
+ "database/sql"
5
+ _ "embed"
6
+ "encoding/json"
7
+ jsonv2 "encoding/json/v2"
8
+ "errors"
9
+ "fmt"
10
+ "os"
11
+ "regexp"
12
+ "strings"
13
+
14
+ "github.com/pocketbase/pocketbase/apis"
15
+ "github.com/pocketbase/pocketbase/core"
16
+ "github.com/pocketbase/pocketbase/tools/types"
17
+ "github.com/spink-dev/pocketbase-extension/backups"
18
+ )
19
+
20
+ //go:embed openapi.json
21
+ var apiContract []byte
22
+
23
+ const key = "spink_extension_v1"
24
+
25
+ type Template struct {
26
+ Subject string `json:"subject"`
27
+ Body string `json:"body"`
28
+ }
29
+ type Config struct {
30
+ Revision uint64 `json:"revision"`
31
+ Backups backups.Config `json:"backups"`
32
+ EmailLocales map[string]map[string]map[string]Template `json:"emailLocales"`
33
+ }
34
+
35
+ var localePattern = regexp.MustCompile(`^[a-z]{2,8}(?:-[a-z0-9]{1,8})*$`)
36
+ var ErrConflict = errors.New("extension settings changed; reload before saving")
37
+
38
+ func Load(app core.App) (Config, error) {
39
+ c := Config{Revision: 1, EmailLocales: map[string]map[string]map[string]Template{}}
40
+ p := new(core.Param)
41
+ if err := app.ModelQuery(p).Model(key, p); err != nil {
42
+ if errors.Is(err, sql.ErrNoRows) {
43
+ return c, nil
44
+ }
45
+ return c, err
46
+ }
47
+ err := json.Unmarshal(p.Value, &c)
48
+ return c, err
49
+ }
50
+ func Save(app core.App, c Config) error {
51
+ rawConfig, _ := json.Marshal(c)
52
+ if len(rawConfig) > 1<<20 {
53
+ return errors.New("extension configuration exceeds 1 MiB")
54
+ }
55
+ if err := c.Backups.Validate(); err != nil {
56
+ return err
57
+ }
58
+ if len(c.EmailLocales) > 100 {
59
+ return errors.New("too many localized collections")
60
+ }
61
+ for _, kinds := range c.EmailLocales {
62
+ if len(kinds) > 5 {
63
+ return errors.New("too many mail template types")
64
+ }
65
+ for kind, locales := range kinds {
66
+ switch kind {
67
+ case "otp", "verification", "passwordReset", "emailChange", "authAlert":
68
+ default:
69
+ return errors.New("invalid mail template type")
70
+ }
71
+ if len(locales) > 32 {
72
+ return errors.New("too many email locales")
73
+ }
74
+ for locale, t := range locales {
75
+ if !localePattern.MatchString(locale) || len(locale) > 35 || strings.ToLower(locale) != locale || strings.ContainsAny(locale, " /\\") {
76
+ return errors.New("invalid locale")
77
+ }
78
+ if strings.TrimSpace(t.Subject) == "" || strings.TrimSpace(t.Body) == "" || len(t.Subject) > 4096 || len(t.Body) > 262144 {
79
+ return errors.New("invalid localized template size")
80
+ }
81
+ }
82
+ }
83
+ }
84
+ return app.RunInTransaction(func(tx core.App) error {
85
+ old, err := Load(tx)
86
+ if err != nil {
87
+ return err
88
+ }
89
+ if c.Revision != old.Revision {
90
+ return ErrConflict
91
+ }
92
+ c.Revision++
93
+ p := new(core.Param)
94
+ err = tx.ModelQuery(p).Model(key, p)
95
+ if err != nil && !errors.Is(err, sql.ErrNoRows) {
96
+ return err
97
+ }
98
+ p.Id = key
99
+ raw, err := json.Marshal(c)
100
+ if err != nil {
101
+ return err
102
+ }
103
+ p.Value = types.JSONRaw(raw)
104
+ return tx.Save(p)
105
+ })
106
+ }
107
+ func Register(app core.App) {
108
+ // Applied before initial Settings validation; persisted settings never trigger this create hook.
109
+ app.OnModelValidate("_params").BindFunc(func(e *core.ModelEvent) error {
110
+ s, ok := e.Model.(*core.Settings)
111
+ if !ok || !s.IsNew() {
112
+ return e.Next()
113
+ }
114
+ raw := os.Getenv("PB_SETTINGS_JSON")
115
+ if raw == "" {
116
+ return e.Next()
117
+ }
118
+ if len(raw) > 1<<20 || !strings.HasPrefix(strings.TrimSpace(raw), "{") {
119
+ return errors.New("PB_SETTINGS_JSON must be an object of at most 1 MiB")
120
+ }
121
+ if err := jsonv2.Unmarshal([]byte(raw), s, jsonv2.RejectUnknownMembers(true)); err != nil {
122
+ return fmt.Errorf("invalid PB_SETTINGS_JSON: %w", err)
123
+ }
124
+ return e.Next()
125
+ })
126
+ app.OnServe().BindFunc(func(e *core.ServeEvent) error {
127
+ e.Router.GET("/api/spink/openapi.json", func(r *core.RequestEvent) error { return r.Blob(200, "application/json", apiContract) })
128
+ g := e.Router.Group("/api/spink/settings").Bind(apis.RequireSuperuserAuth())
129
+ g.GET("", func(r *core.RequestEvent) error {
130
+ c, err := Load(r.App)
131
+ if err != nil {
132
+ return err
133
+ }
134
+ return r.JSON(200, map[string]any{"config": c, "backupKeyConfigured": c.Backups.KeyConfigured()})
135
+ })
136
+ g.PUT("", func(r *core.RequestEvent) error {
137
+ var c Config
138
+ if err := r.BindBody(&c); err != nil {
139
+ return r.BadRequestError("Invalid settings", err)
140
+ }
141
+ if err := Save(r.App, c); err != nil {
142
+ if errors.Is(err, ErrConflict) {
143
+ return r.Error(409, err.Error(), nil)
144
+ }
145
+ return r.BadRequestError("Invalid extension settings", err)
146
+ }
147
+ c, err := Load(r.App)
148
+ if err != nil {
149
+ return err
150
+ }
151
+ return r.JSON(200, c)
152
+ })
153
+ return e.Next()
154
+ })
155
+ }
@@ -0,0 +1,31 @@
1
+ package settings
2
+
3
+ import (
4
+ "errors"
5
+ "github.com/pocketbase/pocketbase/tests"
6
+ "testing"
7
+ )
8
+
9
+ func TestSettingsPersistenceAndRevision(t *testing.T) {
10
+ a, err := tests.NewTestApp()
11
+ if err != nil {
12
+ t.Fatal(err)
13
+ }
14
+ defer a.Cleanup()
15
+ c, err := Load(a)
16
+ if err != nil {
17
+ t.Fatal(err)
18
+ }
19
+ old := c
20
+ c.EmailLocales = map[string]map[string]map[string]Template{"users": {"otp": {"fr": {Subject: "Bonjour", Body: "Votre code: {OTP}"}}}}
21
+ if err = Save(a, c); err != nil {
22
+ t.Fatal(err)
23
+ }
24
+ saved, err := Load(a)
25
+ if err != nil || saved.Revision != 2 || saved.EmailLocales["users"]["otp"]["fr"].Subject != "Bonjour" {
26
+ t.Fatalf("persistence: %+v %v", saved, err)
27
+ }
28
+ if !errors.Is(Save(a, old), ErrConflict) {
29
+ t.Fatal("accepted stale settings")
30
+ }
31
+ }
@@ -0,0 +1,192 @@
1
+ package watcher
2
+
3
+ import (
4
+ "fmt"
5
+ "github.com/pocketbase/pocketbase/core"
6
+ "os"
7
+ "path/filepath"
8
+ "strings"
9
+ "time"
10
+
11
+ "github.com/fatih/color"
12
+ "github.com/fsnotify/fsnotify"
13
+ "github.com/pocketbase/pocketbase/tools/hook"
14
+ "github.com/pocketbase/pocketbase/tools/routine"
15
+ "github.com/pocketbase/pocketbase/tools/security"
16
+ )
17
+
18
+ const systemHookIdNotifyWatcher = "__pbNotifyWatcherSystemHook__"
19
+
20
+ func Register(app core.App) {
21
+ var notifyWatcher *fsnotify.Watcher
22
+
23
+ instanceId := "@" + security.PseudorandomString(10)
24
+
25
+ localNotifyDirPath := filepath.Join(app.DataDir(), core.LocalNotifyDirName)
26
+ settingsFile := filepath.Join(localNotifyDirPath, "settings"+instanceId)
27
+ collectionsFile := filepath.Join(localNotifyDirPath, "collections"+instanceId)
28
+
29
+ // init
30
+ app.OnBootstrap().Bind(&hook.Handler[*core.BootstrapEvent]{
31
+ Id: systemHookIdNotifyWatcher,
32
+ Func: func(e *core.BootstrapEvent) error {
33
+ err := e.Next()
34
+ if err != nil {
35
+ return err
36
+ }
37
+
38
+ if notifyWatcher != nil {
39
+ _ = notifyWatcher.Close()
40
+ }
41
+
42
+ notifyWatcher, err = createNotifyDirWatcher(e.App, instanceId, localNotifyDirPath)
43
+ if err != nil {
44
+ e.App.Logger().Warn("Notify dir watcher failure.", "error", err)
45
+ }
46
+
47
+ return nil
48
+ },
49
+ Priority: -998,
50
+ })
51
+
52
+ // cleanup
53
+ app.OnTerminate().Bind(&hook.Handler[*core.TerminateEvent]{
54
+ Id: systemHookIdNotifyWatcher,
55
+ Func: func(e *core.TerminateEvent) error {
56
+ if notifyWatcher != nil {
57
+ _ = notifyWatcher.Close()
58
+ }
59
+ _ = os.Remove(settingsFile)
60
+ _ = os.Remove(collectionsFile)
61
+
62
+ return e.Next()
63
+ },
64
+ Priority: -998,
65
+ })
66
+
67
+ // ---------------------------------------------------------------
68
+
69
+ settingsNotify := func(e *core.ModelEvent) error {
70
+ err := e.Next()
71
+ if err != nil || e.Model.PK() != "settings" {
72
+ return err
73
+ }
74
+
75
+ if notifyWatcher != nil {
76
+ if err := os.WriteFile(settingsFile, []byte(time.Now().Format(time.RFC3339Nano)), 0644); err != nil {
77
+ e.App.Logger().Warn("Failed to write watcher file", "error", err, "file", settingsFile)
78
+ }
79
+ }
80
+
81
+ return nil
82
+ }
83
+ app.OnModelAfterCreateSuccess("_params").Bind(&hook.Handler[*core.ModelEvent]{
84
+ Id: systemHookIdNotifyWatcher,
85
+ Func: settingsNotify,
86
+ Priority: 999,
87
+ })
88
+ app.OnModelAfterUpdateSuccess("_params").Bind(&hook.Handler[*core.ModelEvent]{
89
+ Id: systemHookIdNotifyWatcher,
90
+ Func: settingsNotify,
91
+ Priority: 999,
92
+ })
93
+
94
+ // ---------------------------------------------------------------
95
+
96
+ collectionsNotify := func(e *core.CollectionEvent) error {
97
+ if err := e.Next(); err != nil {
98
+ return err
99
+ }
100
+
101
+ if notifyWatcher != nil {
102
+ if err := os.WriteFile(collectionsFile, []byte(time.Now().Format(time.RFC3339Nano)), 0644); err != nil {
103
+ e.App.Logger().Warn("Failed to write watcher file", "error", err, "file", collectionsFile)
104
+ }
105
+ }
106
+
107
+ return nil
108
+ }
109
+ app.OnCollectionAfterCreateSuccess().Bind(&hook.Handler[*core.CollectionEvent]{
110
+ Id: systemHookIdNotifyWatcher,
111
+ Func: collectionsNotify,
112
+ Priority: 999,
113
+ })
114
+ app.OnCollectionAfterUpdateSuccess().Bind(&hook.Handler[*core.CollectionEvent]{
115
+ Id: systemHookIdNotifyWatcher,
116
+ Func: collectionsNotify,
117
+ Priority: 999,
118
+ })
119
+ app.OnCollectionAfterDeleteSuccess().Bind(&hook.Handler[*core.CollectionEvent]{
120
+ Id: systemHookIdNotifyWatcher,
121
+ Func: collectionsNotify,
122
+ Priority: 999,
123
+ })
124
+ }
125
+
126
+ func createNotifyDirWatcher(app core.App, instanceId string, localNotifyDirPath string) (*fsnotify.Watcher, error) {
127
+ // create the notify dir (if not already)
128
+ err := os.MkdirAll(localNotifyDirPath, os.ModePerm)
129
+ if err != nil {
130
+ return nil, fmt.Errorf("failed to create a notify dir: %w", err)
131
+ }
132
+
133
+ watcher, err := fsnotify.NewWatcher()
134
+ if err != nil {
135
+ return nil, fmt.Errorf("failed to init notify dir watcher: %w", err)
136
+ }
137
+
138
+ err = watcher.Add(localNotifyDirPath)
139
+ if err != nil {
140
+ _ = watcher.Close()
141
+ return nil, fmt.Errorf("unable to watch notify dir: %w", err)
142
+ }
143
+
144
+ routine.FireAndForget(func() {
145
+ timer := time.NewTimer(time.Hour)
146
+ timer.Stop()
147
+ defer timer.Stop()
148
+ var pendingSettings, pendingCollections bool
149
+ for {
150
+ select {
151
+ case event, ok := <-watcher.Events:
152
+ if !ok {
153
+ return
154
+ }
155
+ if strings.HasSuffix(event.Name, instanceId) || event.Has(fsnotify.Remove) || !app.IsBootstrapped() {
156
+ continue
157
+ }
158
+ filename := filepath.Base(event.Name)
159
+ settings := strings.HasPrefix(filename, "settings@")
160
+ collections := strings.HasPrefix(filename, "collections@")
161
+ if !settings && !collections {
162
+ continue
163
+ }
164
+ pendingSettings = pendingSettings || settings
165
+ pendingCollections = pendingCollections || collections
166
+ timer.Reset(50 * time.Millisecond)
167
+ case <-timer.C:
168
+ if pendingSettings {
169
+ pendingSettings = false
170
+ if err := app.ReloadSettings(); err != nil {
171
+ app.Logger().Warn("Failed to reload app settings after notify", "error", err)
172
+ }
173
+ }
174
+ if pendingCollections {
175
+ pendingCollections = false
176
+ if err := app.ReloadCachedCollections(); err != nil {
177
+ app.Logger().Warn("Failed to reload cached collections after notify", "error", err)
178
+ }
179
+ }
180
+ case err, ok := <-watcher.Errors:
181
+ if !ok {
182
+ return
183
+ }
184
+ if app.IsDev() && err != nil {
185
+ color.Red("Notify dir watch error:\n%v", err)
186
+ }
187
+ }
188
+ }
189
+ })
190
+
191
+ return watcher, err
192
+ }
@@ -0,0 +1,200 @@
1
+ package watcher_test
2
+
3
+ import (
4
+ "context"
5
+ "database/sql"
6
+ _ "github.com/pocketbase/pocketbase/migrations"
7
+ "github.com/spink-dev/pocketbase-extension/watcher"
8
+ "os"
9
+ "testing"
10
+ "time"
11
+
12
+ "github.com/pocketbase/dbx"
13
+ "github.com/pocketbase/pocketbase/core"
14
+ "github.com/pocketbase/pocketbase/tools/store"
15
+ "golang.org/x/sync/semaphore"
16
+ )
17
+
18
+ func TestNotifyWatcher_SettingsUpdate(t *testing.T) {
19
+ t.Parallel()
20
+
21
+ testEvents := store.New[core.App, int](nil)
22
+
23
+ tmpDir, err := os.MkdirTemp("", "pb_notify_test*")
24
+ if err != nil {
25
+ t.Fatal(err)
26
+ }
27
+ t.Cleanup(func() { os.RemoveAll(tmpDir) })
28
+
29
+ app1 := core.NewBaseApp(core.BaseAppConfig{
30
+ DataDir: tmpDir,
31
+ })
32
+ t.Cleanup(func() { app1.OnTerminate().Trigger(&core.TerminateEvent{App: app1}); app1.ResetBootstrapState() })
33
+ watcher.Register(app1)
34
+ if err := app1.Bootstrap(); err != nil {
35
+ t.Fatal(err)
36
+ }
37
+
38
+ app2 := core.NewBaseApp(core.BaseAppConfig{
39
+ DataDir: tmpDir,
40
+ })
41
+ t.Cleanup(func() { app2.OnTerminate().Trigger(&core.TerminateEvent{App: app2}); app2.ResetBootstrapState() })
42
+ watcher.Register(app2)
43
+ if err := app2.Bootstrap(); err != nil {
44
+ t.Fatal(err)
45
+ }
46
+
47
+ timeout := time.After(3 * time.Second)
48
+ done := make(chan struct{})
49
+
50
+ app1.OnSettingsReload().BindFunc(func(e *core.SettingsReloadEvent) error {
51
+ testEvents.SetFunc(app1, func(old int) int {
52
+ return old + 1
53
+ })
54
+ return e.Next()
55
+ })
56
+
57
+ app2.OnSettingsReload().BindFunc(func(e *core.SettingsReloadEvent) error {
58
+ err := e.Next()
59
+ testEvents.SetFunc(app2, func(old int) int { return old + 1 })
60
+ done <- struct{}{}
61
+ return err
62
+ })
63
+
64
+ // updating app1 settings should trigger a reload in app2
65
+ app1.Settings().SuperuserIPs = []string{"127.0.0.1"}
66
+ if err := app1.Save(app1.Settings()); err != nil {
67
+ t.Fatal(err)
68
+ }
69
+
70
+ // wait for the event
71
+ select {
72
+ case <-timeout:
73
+ t.Fatal("app2 reload event timeout")
74
+ case <-done:
75
+ // ready
76
+ }
77
+
78
+ if app1Total := testEvents.Get(app1); app1Total != 1 {
79
+ t.Fatalf("Expected 1 app1 event, got %d", app1Total)
80
+ }
81
+
82
+ if app2Total := testEvents.Get(app2); app2Total != 1 {
83
+ t.Fatalf("Expected 1 app2 event, got %d", app2Total)
84
+ }
85
+
86
+ snapshot, err := app2.Settings().Clone()
87
+ if err != nil {
88
+ t.Fatal(err)
89
+ }
90
+ app2SuperuserIPs := snapshot.SuperuserIPs
91
+ if len(app2SuperuserIPs) != 1 || app2SuperuserIPs[0] != "127.0.0.1" {
92
+ t.Fatalf("Expected exactly 127.0.0.1 superuser IP in app2 settings event, got %v", app2SuperuserIPs)
93
+ }
94
+ }
95
+
96
+ func TestNotifyWatcher_CollectionsUpdate(t *testing.T) {
97
+ t.Parallel()
98
+
99
+ tmpDir, err := os.MkdirTemp("", "pb_notify_test*")
100
+ if err != nil {
101
+ t.Fatal(err)
102
+ }
103
+ t.Cleanup(func() { os.RemoveAll(tmpDir) })
104
+
105
+ app1 := core.NewBaseApp(core.BaseAppConfig{
106
+ DataDir: tmpDir,
107
+ })
108
+ t.Cleanup(func() { app1.OnTerminate().Trigger(&core.TerminateEvent{App: app1}); app1.ResetBootstrapState() })
109
+ watcher.Register(app1)
110
+ if err := app1.Bootstrap(); err != nil {
111
+ t.Fatal(err)
112
+ }
113
+
114
+ app2 := core.NewBaseApp(core.BaseAppConfig{
115
+ DataDir: tmpDir,
116
+ })
117
+ t.Cleanup(func() { app2.OnTerminate().Trigger(&core.TerminateEvent{App: app2}); app2.ResetBootstrapState() })
118
+ watcher.Register(app2)
119
+ if err := app2.Bootstrap(); err != nil {
120
+ t.Fatal(err)
121
+ }
122
+
123
+ testQueries := store.New[string, []string](nil)
124
+ app2.ConcurrentDB().(*dbx.DB).QueryLogFunc = func(ctx context.Context, t time.Duration, sql string, rows *sql.Rows, err error) {
125
+ testQueries.SetFunc("concurrent", func(old []string) []string {
126
+ return append(old, sql)
127
+ })
128
+ }
129
+ app2.ConcurrentDB().(*dbx.DB).ExecLogFunc = func(ctx context.Context, t time.Duration, sql string, result sql.Result, err error) {
130
+ testQueries.SetFunc("concurrent", func(old []string) []string {
131
+ return append(old, sql)
132
+ })
133
+ }
134
+ app2.NonconcurrentDB().(*dbx.DB).QueryLogFunc = func(ctx context.Context, t time.Duration, sql string, rows *sql.Rows, err error) {
135
+ testQueries.SetFunc("nonconcurrent", func(old []string) []string {
136
+ return append(old, sql)
137
+ })
138
+ }
139
+ app2.NonconcurrentDB().(*dbx.DB).ExecLogFunc = func(ctx context.Context, t time.Duration, sql string, result sql.Result, err error) {
140
+ testQueries.SetFunc("nonconcurrent", func(old []string) []string {
141
+ return append(old, sql)
142
+ })
143
+ }
144
+
145
+ ctx, cancelCtx := context.WithTimeout(context.Background(), 1*time.Second)
146
+ defer cancelCtx()
147
+
148
+ sem := semaphore.NewWeighted(1)
149
+ sem.Acquire(ctx, 1)
150
+
151
+ // currently there is no hook for the collections cache reload so we pool instead
152
+ done := make(chan bool, 1)
153
+ ticker := time.NewTicker(100 * time.Millisecond)
154
+ go func() {
155
+ for {
156
+ select {
157
+ case <-ticker.C:
158
+ if len(testQueries.Get("concurrent")) == 1 {
159
+ sem.Release(1)
160
+ return
161
+ }
162
+ case <-done:
163
+ return
164
+ }
165
+ }
166
+ }()
167
+
168
+ // create/update/delete app1 collections should trigger a reload in app2
169
+ dummyCollection := core.NewBaseCollection("test")
170
+ if err := app1.Save(dummyCollection); err != nil {
171
+ t.Fatal(err)
172
+ }
173
+ dummyCollection.Fields.Add(&core.TextField{Name: "test"})
174
+ if err := app1.Save(dummyCollection); err != nil {
175
+ t.Fatal(err)
176
+ }
177
+ if err := app1.Delete(dummyCollection); err != nil {
178
+ t.Fatal(err)
179
+ }
180
+
181
+ // block until released or timeouted
182
+ sem.Acquire(ctx, 1)
183
+ ticker.Stop()
184
+ done <- true
185
+
186
+ nonconcurrentQueries := testQueries.Get("nonconcurrent")
187
+ concurrentQueries := testQueries.Get("concurrent")
188
+
189
+ if len(nonconcurrentQueries) != 0 {
190
+ t.Fatalf("Expected 0 concurrent queries, got %d (%v)", len(nonconcurrentQueries), nonconcurrentQueries)
191
+ }
192
+ if len(concurrentQueries) != 1 {
193
+ t.Fatalf("Expected 1 concurrent query, got %d (%v)", len(concurrentQueries), concurrentQueries)
194
+ }
195
+
196
+ expectedQuery := "SELECT {{_collections}}.* FROM `_collections` ORDER BY `rowid` ASC"
197
+ if concurrentQueries[0] != expectedQuery {
198
+ t.Fatalf("Expected query\n%s\ngot\n%s", expectedQuery, concurrentQueries[0])
199
+ }
200
+ }
package/web/static.go ADDED
@@ -0,0 +1,99 @@
1
+ package web
2
+
3
+ import (
4
+ "errors"
5
+ "github.com/pocketbase/pocketbase/apis"
6
+ "github.com/pocketbase/pocketbase/core"
7
+ "github.com/pocketbase/pocketbase/tools/router"
8
+ "io/fs"
9
+ "net/http"
10
+ "path/filepath"
11
+ "strings"
12
+ )
13
+
14
+ type StaticOptions struct {
15
+ Fallback string
16
+ }
17
+
18
+ func StaticWithOptions(fsys fs.FS, options StaticOptions) func(*core.RequestEvent) error {
19
+ if options.Fallback != "" && (!fs.ValidPath(options.Fallback) || strings.Contains(options.Fallback, "\\")) {
20
+ panic("StaticWithOptions: fallback must be a relative filesystem path")
21
+ }
22
+ return staticHandler(fsys, options.Fallback, false)
23
+ }
24
+
25
+ func staticHandler(fsys fs.FS, fallback string, legacy bool) func(*core.RequestEvent) error {
26
+ if fsys == nil {
27
+ panic("Static: the provided fs.FS argument is nil")
28
+ }
29
+
30
+ return func(e *core.RequestEvent) error {
31
+ // disable the activity logger to avoid flooding with messages
32
+ //
33
+ // note: errors are still logged
34
+ if e.Get("skipSuccessActivityLog") == nil {
35
+ e.Set("skipSuccessActivityLog", true)
36
+ }
37
+
38
+ filename := e.Request.PathValue(apis.StaticWildcardParam)
39
+ filename = filepath.ToSlash(filepath.Clean(strings.TrimPrefix(filename, "/")))
40
+
41
+ // eagerly check for directory traversal
42
+ //
43
+ // note: this is just out of an abundance of caution because the fs.FS implementation could be non-std,
44
+ // but usually shouldn't be necessary since os.DirFS.Open is expected to fail if the filename starts with dots
45
+ if len(filename) > 2 && filename[0] == '.' && filename[1] == '.' && (filename[2] == '/' || filename[2] == '\\') {
46
+ if fallback != "" && filename != fallback {
47
+ return e.FileFS(fsys, fallback)
48
+ }
49
+ return router.ErrFileNotFound
50
+ }
51
+
52
+ fi, err := fs.Stat(fsys, filename)
53
+ if err != nil {
54
+ if fallback != "" && filename != fallback && (legacy || errors.Is(err, fs.ErrNotExist)) {
55
+ return e.FileFS(fsys, fallback)
56
+ }
57
+ if !legacy && !errors.Is(err, fs.ErrNotExist) {
58
+ return err
59
+ }
60
+ return router.ErrFileNotFound
61
+ }
62
+
63
+ if fi.IsDir() {
64
+ // redirect to a canonical dir url, aka. with trailing slash
65
+ if !strings.HasSuffix(e.Request.URL.Path, "/") {
66
+ return e.Redirect(http.StatusMovedPermanently, safeRedirectPath(e.Request.URL.Path+"/"))
67
+ }
68
+ } else {
69
+ urlPath := e.Request.URL.Path
70
+ if strings.HasSuffix(urlPath, "/") {
71
+ // redirect to a non-trailing slash file route
72
+ urlPath = strings.TrimRight(urlPath, "/")
73
+ if len(urlPath) > 0 {
74
+ return e.Redirect(http.StatusMovedPermanently, safeRedirectPath(urlPath))
75
+ }
76
+ } else if stripped, ok := strings.CutSuffix(urlPath, router.IndexPage); ok {
77
+ // redirect without the index.html
78
+ return e.Redirect(http.StatusMovedPermanently, safeRedirectPath(stripped))
79
+ }
80
+ }
81
+
82
+ fileErr := e.FileFS(fsys, filename)
83
+
84
+ if fileErr != nil && fallback != "" && filename != fallback && errors.Is(fileErr, router.ErrFileNotFound) {
85
+ return e.FileFS(fsys, fallback)
86
+ }
87
+
88
+ return fileErr
89
+ }
90
+ }
91
+
92
+ // safeRedirectPath normalizes the path string by replacing all beginning slashes
93
+ // (`\\`, `//`, `\/`) with a single forward slash to prevent open redirect attacks
94
+ func safeRedirectPath(path string) string {
95
+ if len(path) > 1 && (path[0] == '\\' || path[0] == '/') && (path[1] == '\\' || path[1] == '/') {
96
+ path = "/" + strings.TrimLeft(path, `/\`)
97
+ }
98
+ return path
99
+ }