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