@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,49 @@
|
|
|
1
|
+
package mail
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"github.com/pocketbase/pocketbase/core"
|
|
5
|
+
"github.com/pocketbase/pocketbase/mails"
|
|
6
|
+
"github.com/pocketbase/pocketbase/tests"
|
|
7
|
+
"github.com/spink-dev/pocketbase-extension/settings"
|
|
8
|
+
"strings"
|
|
9
|
+
"testing"
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
func TestLocaleFallbackAndRequestIsolation(t *testing.T) {
|
|
13
|
+
a, err := tests.NewTestApp()
|
|
14
|
+
if err != nil {
|
|
15
|
+
t.Fatal(err)
|
|
16
|
+
}
|
|
17
|
+
defer a.Cleanup()
|
|
18
|
+
Register(a)
|
|
19
|
+
r, err := a.FindFirstRecordByData("users", "email", "test@example.com")
|
|
20
|
+
if err != nil {
|
|
21
|
+
rs, e := a.FindRecordsByFilter("users", "", "", 1, 0)
|
|
22
|
+
if e != nil || len(rs) == 0 {
|
|
23
|
+
t.Fatal(err)
|
|
24
|
+
}
|
|
25
|
+
r = rs[0]
|
|
26
|
+
}
|
|
27
|
+
c, _ := settings.Load(a)
|
|
28
|
+
c.EmailLocales = map[string]map[string]map[string]settings.Template{r.Collection().Id: {"otp": {"fr": {Subject: "Bonjour {RECORD:email}", Body: "Code {OTP}"}}}}
|
|
29
|
+
if err = settings.Save(a, c); err != nil {
|
|
30
|
+
t.Fatal(err)
|
|
31
|
+
}
|
|
32
|
+
subjects := []string{}
|
|
33
|
+
a.OnMailerRecordOTPSend().BindFunc(func(e *core.MailerRecordEvent) error {
|
|
34
|
+
subjects = append(subjects, e.Message.Subject)
|
|
35
|
+
if len(subjects) == 1 && !strings.Contains(e.Message.HTML, "Code 123456") {
|
|
36
|
+
t.Fatal("OTP placeholder missing")
|
|
37
|
+
}
|
|
38
|
+
return nil
|
|
39
|
+
})
|
|
40
|
+
if err = mails.SendRecordOTP(WithLocale(a, "fr-ch"), r, "fixture", "123456"); err != nil {
|
|
41
|
+
t.Fatal(err)
|
|
42
|
+
}
|
|
43
|
+
if err = mails.SendRecordOTP(a, r, "fixture", "123456"); err != nil {
|
|
44
|
+
t.Fatal(err)
|
|
45
|
+
}
|
|
46
|
+
if len(subjects) != 2 || !strings.HasPrefix(subjects[0], "Bonjour") || strings.HasPrefix(subjects[1], "Bonjour") {
|
|
47
|
+
t.Fatalf("locale leaked: %v", subjects)
|
|
48
|
+
}
|
|
49
|
+
}
|
package/mail/resolve.go
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
package mail
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"bytes"
|
|
5
|
+
"github.com/pocketbase/pocketbase/core"
|
|
6
|
+
"github.com/pocketbase/pocketbase/mails/templates"
|
|
7
|
+
"html"
|
|
8
|
+
"html/template"
|
|
9
|
+
"slices"
|
|
10
|
+
texttemplate "text/template"
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
var nonescapeTypes = []string{core.FieldTypeNumber, core.FieldTypeBool, core.FieldTypeDate, core.FieldTypeAutodate}
|
|
14
|
+
|
|
15
|
+
func resolveTemplateContent(data any, content ...string) (string, error) {
|
|
16
|
+
if len(content) == 0 {
|
|
17
|
+
return "", nil
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
t := texttemplate.New("inline_template")
|
|
21
|
+
|
|
22
|
+
var parseErr error
|
|
23
|
+
for _, v := range content {
|
|
24
|
+
t, parseErr = t.Parse(v)
|
|
25
|
+
if parseErr != nil {
|
|
26
|
+
return "", parseErr
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
var wr bytes.Buffer
|
|
31
|
+
|
|
32
|
+
if executeErr := t.Execute(&wr, data); executeErr != nil {
|
|
33
|
+
return "", executeErr
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return wr.String(), nil
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
func resolveEmailTemplate(
|
|
40
|
+
app core.App,
|
|
41
|
+
authRecord *core.Record,
|
|
42
|
+
emailTemplate core.EmailTemplate,
|
|
43
|
+
placeholders map[string]any,
|
|
44
|
+
) (subject string, body string, err error) {
|
|
45
|
+
if placeholders == nil {
|
|
46
|
+
placeholders = map[string]any{}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// register default system placeholders
|
|
50
|
+
if _, ok := placeholders[core.EmailPlaceholderAppName]; !ok {
|
|
51
|
+
placeholders[core.EmailPlaceholderAppName] = app.Settings().Meta.AppName
|
|
52
|
+
}
|
|
53
|
+
if _, ok := placeholders[core.EmailPlaceholderAppURL]; !ok {
|
|
54
|
+
placeholders[core.EmailPlaceholderAppURL] = app.Settings().Meta.AppURL
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// register default auth record placeholders
|
|
58
|
+
for _, field := range authRecord.Collection().Fields {
|
|
59
|
+
if field.GetHidden() {
|
|
60
|
+
continue
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
fieldPlacehodler := "{RECORD:" + field.GetName() + "}"
|
|
64
|
+
if _, ok := placeholders[fieldPlacehodler]; !ok {
|
|
65
|
+
val := authRecord.GetString(field.GetName())
|
|
66
|
+
|
|
67
|
+
// note: the escaping is not strictly necessary but for just in case
|
|
68
|
+
// the user decide to store and render the email as plain html
|
|
69
|
+
if !slices.Contains(nonescapeTypes, field.Type()) {
|
|
70
|
+
val = html.EscapeString(val)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
placeholders[fieldPlacehodler] = val
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
subject, rawBody := emailTemplate.Resolve(placeholders)
|
|
78
|
+
|
|
79
|
+
params := struct {
|
|
80
|
+
HTMLContent template.HTML
|
|
81
|
+
}{
|
|
82
|
+
HTMLContent: template.HTML(rawBody),
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
body, err = resolveTemplateContent(params, templates.Layout, templates.HTMLBody)
|
|
86
|
+
if err != nil {
|
|
87
|
+
return "", "", err
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return subject, body, nil
|
|
91
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
// Package migration explicitly imports fork-only settings into extension-owned storage.
|
|
2
|
+
package migration
|
|
3
|
+
|
|
4
|
+
import (
|
|
5
|
+
"encoding/json"
|
|
6
|
+
"github.com/pocketbase/pocketbase/core"
|
|
7
|
+
"github.com/pocketbase/pocketbase/tools/security"
|
|
8
|
+
"github.com/spink-dev/pocketbase-extension/settings"
|
|
9
|
+
"os"
|
|
10
|
+
"reflect"
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
func Import(app core.App) (bool, error) {
|
|
14
|
+
c, err := settings.Load(app)
|
|
15
|
+
if err != nil {
|
|
16
|
+
return false, err
|
|
17
|
+
}
|
|
18
|
+
before, _ := json.Marshal(c)
|
|
19
|
+
p := new(core.Param)
|
|
20
|
+
if err = app.ModelQuery(p).Model("settings", p); err != nil {
|
|
21
|
+
return false, err
|
|
22
|
+
}
|
|
23
|
+
raw := []byte(p.Value)
|
|
24
|
+
var old struct {
|
|
25
|
+
Backups struct {
|
|
26
|
+
Encrypted bool
|
|
27
|
+
EncryptionEnv string
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
if err = json.Unmarshal(raw, &old); err != nil {
|
|
31
|
+
raw, err = security.Decrypt(string(p.Value), os.Getenv(app.EncryptionEnv()))
|
|
32
|
+
if err != nil {
|
|
33
|
+
return false, err
|
|
34
|
+
}
|
|
35
|
+
if err = json.Unmarshal(raw, &old); err != nil {
|
|
36
|
+
return false, err
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
if c.Revision == 1 {
|
|
40
|
+
c.Backups.Encrypted = old.Backups.Encrypted
|
|
41
|
+
c.Backups.EncryptionEnv = old.Backups.EncryptionEnv
|
|
42
|
+
}
|
|
43
|
+
var rows []struct {
|
|
44
|
+
Id string `db:"id"`
|
|
45
|
+
Options string `db:"options"`
|
|
46
|
+
}
|
|
47
|
+
if err = app.DB().NewQuery("SELECT id, options FROM _collections WHERE type='auth'").All(&rows); err != nil {
|
|
48
|
+
return false, err
|
|
49
|
+
}
|
|
50
|
+
for _, row := range rows {
|
|
51
|
+
var opts map[string]json.RawMessage
|
|
52
|
+
if err = json.Unmarshal([]byte(row.Options), &opts); err != nil {
|
|
53
|
+
return false, err
|
|
54
|
+
}
|
|
55
|
+
for key, kind := range map[string]string{"otp": "otp", "authAlert": "authAlert", "verificationTemplate": "verification", "resetPasswordTemplate": "passwordReset", "confirmEmailChangeTemplate": "emailChange"} {
|
|
56
|
+
data := opts[key]
|
|
57
|
+
if len(data) == 0 {
|
|
58
|
+
continue
|
|
59
|
+
}
|
|
60
|
+
if key == "otp" || key == "authAlert" {
|
|
61
|
+
var nested map[string]json.RawMessage
|
|
62
|
+
if err = json.Unmarshal(data, &nested); err != nil {
|
|
63
|
+
return false, err
|
|
64
|
+
}
|
|
65
|
+
data = nested["emailTemplate"]
|
|
66
|
+
}
|
|
67
|
+
if len(data) == 0 {
|
|
68
|
+
continue
|
|
69
|
+
}
|
|
70
|
+
var t struct{ Locales map[string]settings.Template }
|
|
71
|
+
if err = json.Unmarshal(data, &t); err != nil {
|
|
72
|
+
return false, err
|
|
73
|
+
}
|
|
74
|
+
if len(t.Locales) == 0 {
|
|
75
|
+
continue
|
|
76
|
+
}
|
|
77
|
+
if c.EmailLocales == nil {
|
|
78
|
+
c.EmailLocales = map[string]map[string]map[string]settings.Template{}
|
|
79
|
+
}
|
|
80
|
+
if c.EmailLocales[row.Id] == nil {
|
|
81
|
+
c.EmailLocales[row.Id] = map[string]map[string]settings.Template{}
|
|
82
|
+
}
|
|
83
|
+
if c.EmailLocales[row.Id][kind] == nil {
|
|
84
|
+
c.EmailLocales[row.Id][kind] = t.Locales
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
after, _ := json.Marshal(c)
|
|
89
|
+
if reflect.DeepEqual(before, after) {
|
|
90
|
+
return false, nil
|
|
91
|
+
}
|
|
92
|
+
if err = settings.Save(app, c); err != nil {
|
|
93
|
+
return false, err
|
|
94
|
+
}
|
|
95
|
+
return true, nil
|
|
96
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
package migration
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"encoding/json"
|
|
5
|
+
"github.com/pocketbase/dbx"
|
|
6
|
+
"github.com/pocketbase/pocketbase/core"
|
|
7
|
+
"github.com/pocketbase/pocketbase/tests"
|
|
8
|
+
"github.com/spink-dev/pocketbase-extension/settings"
|
|
9
|
+
"strings"
|
|
10
|
+
"testing"
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
func TestImportForkSettingsIsExplicitAndIdempotent(t *testing.T) {
|
|
14
|
+
a, err := tests.NewTestApp()
|
|
15
|
+
if err != nil {
|
|
16
|
+
t.Fatal(err)
|
|
17
|
+
}
|
|
18
|
+
defer a.Cleanup()
|
|
19
|
+
t.Setenv("SPINK_IMPORT_KEY", strings.Repeat("k", 32))
|
|
20
|
+
p := new(core.Param)
|
|
21
|
+
if err = a.ModelQuery(p).Model("settings", p); err != nil {
|
|
22
|
+
t.Fatal(err)
|
|
23
|
+
}
|
|
24
|
+
var data map[string]any
|
|
25
|
+
if err = json.Unmarshal(p.Value, &data); err != nil {
|
|
26
|
+
t.Fatal(err)
|
|
27
|
+
}
|
|
28
|
+
data["backups"].(map[string]any)["encrypted"] = true
|
|
29
|
+
data["backups"].(map[string]any)["encryptionEnv"] = "SPINK_IMPORT_KEY"
|
|
30
|
+
raw, _ := json.Marshal(data)
|
|
31
|
+
if _, err = a.DB().NewQuery("UPDATE _params SET value={:v} WHERE id='settings'").Bind(dbx.Params{"v": string(raw)}).Execute(); err != nil {
|
|
32
|
+
t.Fatal(err)
|
|
33
|
+
}
|
|
34
|
+
c, _ := a.FindCollectionByNameOrId("users")
|
|
35
|
+
var options string
|
|
36
|
+
if err = a.DB().NewQuery("SELECT options FROM _collections WHERE id={:id}").Bind(dbx.Params{"id": c.Id}).Row(&options); err != nil {
|
|
37
|
+
t.Fatal(err)
|
|
38
|
+
}
|
|
39
|
+
var opts map[string]any
|
|
40
|
+
json.Unmarshal([]byte(options), &opts)
|
|
41
|
+
opts["verificationTemplate"].(map[string]any)["locales"] = map[string]any{"fr": map[string]any{"subject": "Bonjour", "body": "Bienvenue"}}
|
|
42
|
+
raw, _ = json.Marshal(opts)
|
|
43
|
+
if _, err = a.DB().NewQuery("UPDATE _collections SET options={:v} WHERE id={:id}").Bind(dbx.Params{"v": string(raw), "id": c.Id}).Execute(); err != nil {
|
|
44
|
+
t.Fatal(err)
|
|
45
|
+
}
|
|
46
|
+
changed, err := Import(a)
|
|
47
|
+
if err != nil || !changed {
|
|
48
|
+
t.Fatalf("import: %v %v", changed, err)
|
|
49
|
+
}
|
|
50
|
+
got, err := settings.Load(a)
|
|
51
|
+
if err != nil || !got.Backups.Encrypted || got.EmailLocales[c.Id]["verification"]["fr"].Subject != "Bonjour" {
|
|
52
|
+
t.Fatal("lost imported configuration")
|
|
53
|
+
}
|
|
54
|
+
changed, err = Import(a)
|
|
55
|
+
if err != nil || changed {
|
|
56
|
+
t.Fatal("import is not idempotent")
|
|
57
|
+
}
|
|
58
|
+
}
|
package/otp/otp.go
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// Package otp exposes a pre-validation password callback while leaving stock OTP
|
|
2
|
+
// expiry, single-use, MFA and token issuance authoritative.
|
|
3
|
+
package otp
|
|
4
|
+
|
|
5
|
+
import (
|
|
6
|
+
"bytes"
|
|
7
|
+
"encoding/json"
|
|
8
|
+
"github.com/pocketbase/pocketbase/core"
|
|
9
|
+
"github.com/pocketbase/pocketbase/tools/router"
|
|
10
|
+
"io"
|
|
11
|
+
"strings"
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
type Request struct {
|
|
15
|
+
Event *core.RequestEvent
|
|
16
|
+
OTPID string
|
|
17
|
+
Password string
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
func Register(app core.App, callback func(*Request) error) {
|
|
21
|
+
app.OnServe().BindFunc(func(e *core.ServeEvent) error {
|
|
22
|
+
e.Router.BindFunc(func(r *core.RequestEvent) error {
|
|
23
|
+
if r.Request.Method != "POST" || !strings.HasSuffix(r.Request.URL.Path, "/auth-with-otp") {
|
|
24
|
+
return r.Next()
|
|
25
|
+
}
|
|
26
|
+
data, err := io.ReadAll(io.LimitReader(r.Request.Body, (1<<20)+1))
|
|
27
|
+
if err != nil {
|
|
28
|
+
return err
|
|
29
|
+
}
|
|
30
|
+
if len(data) > 1<<20 {
|
|
31
|
+
return r.BadRequestError("OTP request too large", nil)
|
|
32
|
+
}
|
|
33
|
+
var body map[string]any
|
|
34
|
+
if err = json.Unmarshal(data, &body); err != nil {
|
|
35
|
+
return r.BadRequestError("OTP extension requires JSON", err)
|
|
36
|
+
}
|
|
37
|
+
req := &Request{Event: r}
|
|
38
|
+
req.OTPID, _ = body["otpId"].(string)
|
|
39
|
+
req.Password, _ = body["password"].(string)
|
|
40
|
+
if err = callback(req); err != nil {
|
|
41
|
+
return err
|
|
42
|
+
}
|
|
43
|
+
body["password"] = req.Password
|
|
44
|
+
data, err = json.Marshal(body)
|
|
45
|
+
if err != nil {
|
|
46
|
+
return err
|
|
47
|
+
}
|
|
48
|
+
bodyReader := &router.RereadableReadCloser{ReadCloser: io.NopCloser(bytes.NewReader(data))}
|
|
49
|
+
defer bodyReader.Close()
|
|
50
|
+
r.Request.Body = bodyReader
|
|
51
|
+
r.Request.ContentLength = int64(len(data))
|
|
52
|
+
return r.Next()
|
|
53
|
+
})
|
|
54
|
+
return e.Next()
|
|
55
|
+
})
|
|
56
|
+
}
|
package/otp/otp_test.go
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
package otp
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"fmt"
|
|
5
|
+
"github.com/pocketbase/pocketbase/apis"
|
|
6
|
+
"github.com/pocketbase/pocketbase/core"
|
|
7
|
+
"github.com/pocketbase/pocketbase/tests"
|
|
8
|
+
"net/http/httptest"
|
|
9
|
+
"strings"
|
|
10
|
+
"testing"
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
func TestPasswordCallbackKeepsNativeSingleUse(t *testing.T) {
|
|
14
|
+
a, err := tests.NewTestApp()
|
|
15
|
+
if err != nil {
|
|
16
|
+
t.Fatal(err)
|
|
17
|
+
}
|
|
18
|
+
defer a.Cleanup()
|
|
19
|
+
c, _ := a.FindCollectionByNameOrId("users")
|
|
20
|
+
c.OTP.Enabled = true
|
|
21
|
+
c.MFA.Enabled = false
|
|
22
|
+
if err = a.Save(c); err != nil {
|
|
23
|
+
t.Fatal(err)
|
|
24
|
+
}
|
|
25
|
+
rs, err := a.FindRecordsByFilter(c.Id, "", "", 1, 0)
|
|
26
|
+
if err != nil || len(rs) == 0 {
|
|
27
|
+
t.Fatal(err)
|
|
28
|
+
}
|
|
29
|
+
o := core.NewOTP(a)
|
|
30
|
+
o.SetCollectionRef(c.Id)
|
|
31
|
+
o.SetRecordRef(rs[0].Id)
|
|
32
|
+
o.SetPassword("123456")
|
|
33
|
+
if err = a.Save(o); err != nil {
|
|
34
|
+
t.Fatal(err)
|
|
35
|
+
}
|
|
36
|
+
Register(a, func(r *Request) error {
|
|
37
|
+
if r.Password == "mapped" {
|
|
38
|
+
r.Password = "123456"
|
|
39
|
+
}
|
|
40
|
+
return nil
|
|
41
|
+
})
|
|
42
|
+
router, _ := apis.NewRouter(a)
|
|
43
|
+
if err = a.OnServe().Trigger(&core.ServeEvent{App: a, Router: router}); err != nil {
|
|
44
|
+
t.Fatal(err)
|
|
45
|
+
}
|
|
46
|
+
h, _ := router.BuildMux()
|
|
47
|
+
for i, want := range []int{200, 400} {
|
|
48
|
+
r := httptest.NewRequest("POST", "/api/collections/"+c.Id+"/auth-with-otp", strings.NewReader(fmt.Sprintf(`{"otpId":%q,"password":"mapped"}`, o.Id)))
|
|
49
|
+
r.Header.Set("Content-Type", "application/json")
|
|
50
|
+
w := httptest.NewRecorder()
|
|
51
|
+
h.ServeHTTP(w, r)
|
|
52
|
+
if w.Code != want {
|
|
53
|
+
t.Fatalf("attempt %d got %d: %s", i, w.Code, w.Body.String())
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@x47base/pocketbase-addon",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"scripts": {
|
|
6
|
+
"test": "node --test security/ui/*.test.mjs",
|
|
7
|
+
"check": "./scripts/check.sh"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"*.go",
|
|
11
|
+
"go.mod",
|
|
12
|
+
"go.sum",
|
|
13
|
+
"admin",
|
|
14
|
+
"backups",
|
|
15
|
+
"cmd",
|
|
16
|
+
"features",
|
|
17
|
+
"internal",
|
|
18
|
+
"jsvm",
|
|
19
|
+
"loadtest",
|
|
20
|
+
"localization",
|
|
21
|
+
"mail",
|
|
22
|
+
"migration",
|
|
23
|
+
"otp",
|
|
24
|
+
"security",
|
|
25
|
+
"settings",
|
|
26
|
+
"watcher",
|
|
27
|
+
"web",
|
|
28
|
+
"bin",
|
|
29
|
+
"scripts",
|
|
30
|
+
"README.md",
|
|
31
|
+
"MIGRATION.md",
|
|
32
|
+
"FEATURES.md",
|
|
33
|
+
"LICENSE.md",
|
|
34
|
+
"NOTICE.md",
|
|
35
|
+
"edge",
|
|
36
|
+
"Dockerfile",
|
|
37
|
+
".dockerignore",
|
|
38
|
+
"deploy"
|
|
39
|
+
],
|
|
40
|
+
"license": "MIT",
|
|
41
|
+
"description": "PocketBase add-on: security dashboard, encrypted backups and development extensions",
|
|
42
|
+
"bin": {
|
|
43
|
+
"pocketbase-extension": "bin/pocketbase-extension.mjs"
|
|
44
|
+
},
|
|
45
|
+
"engines": {
|
|
46
|
+
"node": ">=22"
|
|
47
|
+
},
|
|
48
|
+
"publishConfig": {
|
|
49
|
+
"access": "public"
|
|
50
|
+
}
|
|
51
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Finite loopback-only rejection smoke; no database writes or credentials."""
|
|
3
|
+
import argparse
|
|
4
|
+
import concurrent.futures
|
|
5
|
+
import json
|
|
6
|
+
import urllib.error
|
|
7
|
+
import urllib.parse
|
|
8
|
+
import urllib.request
|
|
9
|
+
|
|
10
|
+
parser = argparse.ArgumentParser()
|
|
11
|
+
parser.add_argument('--public', default='http://127.0.0.1:8080')
|
|
12
|
+
parser.add_argument('--operator', default='http://127.0.0.1:8081')
|
|
13
|
+
args = parser.parse_args()
|
|
14
|
+
for origin in (args.public, args.operator):
|
|
15
|
+
url = urllib.parse.urlsplit(origin)
|
|
16
|
+
if url.scheme != 'http' or url.hostname not in ('127.0.0.1', '::1') or url.username or url.password or url.path not in ('', '/') or url.query or url.fragment:
|
|
17
|
+
parser.error('Use a plain loopback HTTP origin')
|
|
18
|
+
class NoRedirect(urllib.request.HTTPRedirectHandler):
|
|
19
|
+
def redirect_request(self, *args, **kwargs):
|
|
20
|
+
return None
|
|
21
|
+
opener = urllib.request.build_opener(urllib.request.ProxyHandler({}), NoRedirect())
|
|
22
|
+
def get(url):
|
|
23
|
+
try:
|
|
24
|
+
with opener.open(url, timeout=5) as response:
|
|
25
|
+
return response.status, response.read(65536)
|
|
26
|
+
except urllib.error.HTTPError as error:
|
|
27
|
+
return error.code, error.read(65536)
|
|
28
|
+
code, body = get(args.operator + '/edge/status')
|
|
29
|
+
assert code == 200, 'operator status unavailable'
|
|
30
|
+
before = json.loads(body)
|
|
31
|
+
assert before['policyReady'] and before['upstreamHealthy'], 'gateway not ready'
|
|
32
|
+
with concurrent.futures.ThreadPoolExecutor(max_workers=8) as pool:
|
|
33
|
+
codes = list(pool.map(lambda _: get(args.public + '/_/')[0], range(200)))
|
|
34
|
+
assert all(code == 403 for code in codes), set(codes)
|
|
35
|
+
assert get(args.operator + '/edge/healthz')[0] == 204
|
|
36
|
+
assert get(args.operator + '/edge/readyz')[0] == 204
|
|
37
|
+
code, body = get(args.operator + '/edge/status')
|
|
38
|
+
after = json.loads(body)
|
|
39
|
+
assert after['rejected'] - before['rejected'] >= 200
|
|
40
|
+
print(json.dumps({'blockedRequests': len(codes), 'healthCanary': 'passed',
|
|
41
|
+
'publicAcceptedDelta': after['accepted'] - before['accepted'],
|
|
42
|
+
'note': 'Zero accepted delta expected on an otherwise idle instance.'}))
|
package/scripts/check.sh
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
#!/bin/sh
|
|
2
|
+
set -eu
|
|
3
|
+
export GOWORK=off
|
|
4
|
+
module=$(go list -m -f '{{.Version}} {{if .Replace}}REPLACED{{end}}' github.com/pocketbase/pocketbase)
|
|
5
|
+
case "$module" in *REPLACED*) echo 'Compatibility checks must use unmodified upstream PocketBase' >&2; exit 1;; esac
|
|
6
|
+
go test ./...
|
|
7
|
+
go test -race ./edge ./security ./features ./localization ./loadtest ./backups ./settings ./mail ./admin ./otp ./watcher
|
|
8
|
+
go vet ./...
|
|
9
|
+
node --test security/ui/*.test.mjs
|
|
10
|
+
go build -o /tmp/spink-pocketbase-check ./cmd/pocketbase
|
|
11
|
+
go build -o /tmp/spink-edge-check ./cmd/edge
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
#!/bin/sh
|
|
2
|
+
set -eu
|
|
3
|
+
export GOWORK=off
|
|
4
|
+
upstream_dir=$(go list -m -f '{{.Dir}}' github.com/pocketbase/pocketbase)
|
|
5
|
+
python3 - "$upstream_dir/plugins/jsvm/internal/types/generated/types.d.ts" <<'PY'
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
import sys
|
|
8
|
+
source = Path(sys.argv[1])
|
|
9
|
+
Path('jsvm/internal/types/generated/types.d.ts').write_text('\n'.join(line.rstrip() for line in source.read_text().splitlines()) + '\n')
|
|
10
|
+
PY
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# Request protection for custom PocketBase binaries
|
|
2
|
+
|
|
3
|
+
Register once before serving:
|
|
4
|
+
|
|
5
|
+
```go
|
|
6
|
+
config := security.DefaultConfig()
|
|
7
|
+
config.Mode = "observe" // disabled, observe, enforce
|
|
8
|
+
statePath, err := filepath.Abs(filepath.Join(app.DataDir(), "security", "state.json"))
|
|
9
|
+
if err != nil { return err }
|
|
10
|
+
config.StatePath = statePath
|
|
11
|
+
if err := security.Register(app, config); err != nil { return err }
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
The standard binary is unchanged. `cmd/pocketbase` registers this plugin and
|
|
15
|
+
accepts `PB_SECURITY_MODE`. `observe` reports decisions without rejecting requests;
|
|
16
|
+
`enforce` applies global and verified-identity token buckets, concurrent request
|
|
17
|
+
limits, separate write/file/realtime lanes, weighted batch size and per-client plus
|
|
18
|
+
aggregate subscription limits. Batch weights and subscription reservations release
|
|
19
|
+
on completion/failure/disconnect. Realtime messages receive a ten-second write
|
|
20
|
+
deadline where supported. Authenticated users behind the same NAT have independent
|
|
21
|
+
identity budgets; a global pre-authentication budget bounds total admission.
|
|
22
|
+
Unauthenticated clients share a source budget. Identity storage is capped and uses
|
|
23
|
+
process-local HMAC fingerprints; excess identities share an overflow bucket.
|
|
24
|
+
Forwarded addresses are considered only for configured immediate proxy peers.
|
|
25
|
+
|
|
26
|
+
Detection evaluates ten-second windows. Three consecutive high-rejection,
|
|
27
|
+
authentication-failure or combined slow/failing windows open a suspected incident.
|
|
28
|
+
Six clear windows resolve it. Legitimate load or client errors can trigger these
|
|
29
|
+
patterns. The detector never installs blocking actions automatically. History is
|
|
30
|
+
capped at 256 incidents. Missing traffic is not independent evidence of recovery;
|
|
31
|
+
these are heuristic state transitions, not validated attack classification.
|
|
32
|
+
|
|
33
|
+
## Management
|
|
34
|
+
|
|
35
|
+
Open `/_/#/security` through the Security tab after signing into the admin dashboard. It reuses the dashboard SDK session without copying tokens. Extension assets are public; APIs still require a superuser and an immediate peer in `ManagementPeers` (loopback by default). Logout/unmount cancels polling and discards old-session responses.
|
|
36
|
+
Four concurrent management requests have a separate admission lane.
|
|
37
|
+
|
|
38
|
+
| Route | Behavior |
|
|
39
|
+
| --- | --- |
|
|
40
|
+
| GET `/api/security/status` | Mode, counters, active lanes, actions, subscriptions and checkpoint health |
|
|
41
|
+
| GET `/api/security/incidents?limit=50&after=0` | Ascending ID cursor, maximum 100 items, `nextCursor: 0` at end |
|
|
42
|
+
| GET `/api/security/policy/snapshot` | Atomic policy and revision pair for the dashboard |
|
|
43
|
+
| GET `/api/security/policy` | Current configuration; numeric revision in ETag |
|
|
44
|
+
| PUT `/api/security/policy` | Validated replacement with matching numeric If-Match; stale updates return 409 |
|
|
45
|
+
| POST `/api/security/incidents/{id}/acknowledge` | Idempotent acknowledgement |
|
|
46
|
+
| POST `/api/security/actions` | Replace temporary block for one family |
|
|
47
|
+
| DELETE `/api/security/actions/{family}` | Cancel an existing block; missing action returns 404 |
|
|
48
|
+
|
|
49
|
+
Action body: `family`, `reason` (1–200 bytes), RFC3339 `expiresAt` in the next
|
|
50
|
+
15 minutes. Families: auth, reads, writes, files, realtime, other. Actions apply only
|
|
51
|
+
in enforce mode. Policy changes, acknowledgements, action changes/cancellation log
|
|
52
|
+
operator identity. No endpoint exports a raw request body, token or query.
|
|
53
|
+
|
|
54
|
+
Optional `StatePath` writes atomic private checkpoints every ten seconds, after
|
|
55
|
+
management mutations and on shutdown. Validated policy, revision, incidents and
|
|
56
|
+
unexpired actions survive restart; startup mode always wins. Corrupt/missing state
|
|
57
|
+
falls back to startup configuration, with corruption reported as `storageDegraded`.
|
|
58
|
+
Writes are best effort; an active policy can precede a failed disk write. This is
|
|
59
|
+
not a transactional audit journal. Request counters/identities are process-local.
|
|
60
|
+
|
|
61
|
+
## Boundaries
|
|
62
|
+
|
|
63
|
+
See [deployment and recovery guidance](../deploy/README.md). Edge
|
|
64
|
+
bandwidth exhaustion requires provider/network protection. No production capacity
|
|
65
|
+
profile, distributed DDoS proof, automatic incident-driven blocking, external
|
|
66
|
+
telemetry exporter or calibrated detector is supplied. Observe mode deliberately
|
|
67
|
+
permits traffic beyond configured limits; it does not claim an enforcement ceiling.
|
|
68
|
+
The original larger acceptance plan remains in the root repository’s `specs/001-security-expansion` with
|
|
69
|
+
unfulfilled release evidence explicitly open.
|
|
70
|
+
|
|
71
|
+
## Disposable database simulation
|
|
72
|
+
|
|
73
|
+
```sh
|
|
74
|
+
go run ./cmd/loadtest -sandbox -dry-run -rate 200 -clients 8 -duration 3s -requests 400 -normal-every 10
|
|
75
|
+
go run ./cmd/loadtest -sandbox -mode enforce -rate 200 -clients 8 -duration 3s -requests 400 -normal-every 10
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
The sandbox creates 5,000 records in a temporary SQLite database, serves read-only
|
|
79
|
+
`pb_sim_records` on an ephemeral loopback port, then stops and removes the fixture.
|
|
80
|
+
Every tenth scheduled request above is a health canary in the same total budget.
|
|
81
|
+
Canaries share the unauthenticated source budget; a rejected canary is a measured
|
|
82
|
+
availability failure, not counted as success. The sandbox binds admission middleware;
|
|
83
|
+
it does not exercise batch hooks or subscription operations.
|
|
84
|
+
|
|
85
|
+
Without `-sandbox`, accepted literal-loopback HTTP paths are `/api/health`,
|
|
86
|
+
`/api/realtime`, and `/api/collections/pb_sim_records/records`. No credentials,
|
|
87
|
+
writes, retries, redirects, proxies, external hosts or query parameters. Maximums:
|
|
88
|
+
512 clients, 10,000 requests/s, 10 minutes, one million sent requests. Interrupt
|
|
89
|
+
cancels in-flight requests. Successful JSON bodies and SSE connection data are
|
|
90
|
+
validated; bodies are bounded. JSON reports response classes, 429/503, errors,
|
|
91
|
+
dropped scheduling slots, offered/sent counts, canary success and latency buckets.
|
|
92
|
+
`offered = sent + dropped`; dropped slots indicate generator saturation. Duration
|
|
93
|
+
includes request drain/cancellation; normal duration expiry is not an interruption.
|
|
94
|
+
This is a finite local capacity tool, not a distributed attack engine.
|