@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,120 @@
1
+ package backups
2
+
3
+ import (
4
+ "errors"
5
+ "fmt"
6
+ "github.com/pocketbase/pocketbase/core"
7
+ "github.com/pocketbase/pocketbase/tools/filesystem"
8
+ "github.com/pocketbase/pocketbase/tools/hook"
9
+ "os"
10
+ "path/filepath"
11
+ "strings"
12
+ "time"
13
+ )
14
+
15
+ type Config struct {
16
+ Encrypted bool `json:"encrypted"`
17
+ EncryptionEnv string `json:"encryptionEnv"`
18
+ }
19
+
20
+ func (c Config) Validate() error {
21
+ if c.EncryptionEnv != "" && !backupEnvPattern.MatchString(c.EncryptionEnv) {
22
+ return errors.New("invalid encryption environment name")
23
+ }
24
+ if c.Encrypted {
25
+ _, err := c.encryptionKey()
26
+ return err
27
+ }
28
+ return nil
29
+ }
30
+ func (c Config) KeyConfigured() bool { _, err := c.encryptionKey(); return err == nil }
31
+
32
+ type stagedApp struct {
33
+ core.App
34
+ dir string
35
+ }
36
+
37
+ func (a *stagedApp) NewBackupsFilesystem() (*filesystem.System, error) {
38
+ return filesystem.NewLocal(a.dir)
39
+ }
40
+ func Register(app core.App, config func(core.App) (Config, error)) {
41
+ app.OnBackupCreate().BindFunc(func(e *core.BackupEvent) error {
42
+ c, err := config(e.App)
43
+ if err != nil {
44
+ return err
45
+ }
46
+ if !c.Encrypted {
47
+ return e.Next()
48
+ }
49
+ if err = c.Validate(); err != nil {
50
+ return err
51
+ }
52
+ if e.Name == "" {
53
+ e.Name = "pb_backup_" + time.Now().UTC().Format("20060102_150405.000000000") + ".zip"
54
+ }
55
+ if filepath.Base(e.Name) != e.Name || strings.ContainsAny(e.Name, "/\\") {
56
+ return errors.New("invalid backup name")
57
+ }
58
+ final := EncryptedBackupName(e.Name, true)
59
+ realApp := e.App
60
+ realFS, err := realApp.NewBackupsFilesystem()
61
+ if err != nil {
62
+ return err
63
+ }
64
+ defer realFS.Close()
65
+ realFS.SetContext(e.Context)
66
+ exists, err := realFS.Exists(final)
67
+ if err != nil {
68
+ return err
69
+ }
70
+ if exists {
71
+ return errors.New("backup already exists")
72
+ }
73
+ tempRoot := filepath.Join(realApp.DataDir(), core.LocalTempDirName)
74
+ if err = os.MkdirAll(tempRoot, 0700); err != nil {
75
+ return err
76
+ }
77
+ if err = os.Chmod(tempRoot, 0700); err != nil {
78
+ return err
79
+ }
80
+ dir, err := os.MkdirTemp(tempRoot, "spink_backup_")
81
+ if err != nil {
82
+ return err
83
+ }
84
+ defer os.RemoveAll(dir)
85
+ e.App = &stagedApp{App: realApp, dir: dir}
86
+ err = e.Next()
87
+ e.App = realApp
88
+ if err != nil {
89
+ return err
90
+ }
91
+ encrypted, err := encryptBackup(e.Context, c, filepath.Join(dir, e.Name))
92
+ if err != nil {
93
+ return err
94
+ }
95
+ defer os.Remove(encrypted)
96
+ f, err := filesystem.NewFileFromPath(encrypted)
97
+ if err != nil {
98
+ return err
99
+ }
100
+ f.Name = final
101
+ f.OriginalName = final
102
+ realApp.Store().Set(core.StoreKeyActiveBackup, final)
103
+ if err = realFS.UploadFile(f, final); err != nil {
104
+ return fmt.Errorf("persist encrypted backup: %w", err)
105
+ }
106
+ e.Name = final
107
+ return nil
108
+ })
109
+ app.OnBackupRestore().Bind(&hook.Handler[*core.BackupEvent]{Id: "spink.restore.finalizer", Priority: 9999, Func: func(e *core.BackupEvent) error {
110
+ c, err := config(e.App)
111
+ if err != nil {
112
+ return err
113
+ }
114
+ if filepath.Base(e.Name) != e.Name || strings.ContainsAny(e.Name, "/\\") {
115
+ return errors.New("invalid backup name")
116
+ }
117
+ // Own the restore finalizer to authenticate before extraction and preserve cancellation.
118
+ return restore(e, c)
119
+ }})
120
+ }
@@ -0,0 +1,119 @@
1
+ package backups
2
+
3
+ import (
4
+ "errors"
5
+ "fmt"
6
+ "io"
7
+ "log/slog"
8
+ "os"
9
+ "path/filepath"
10
+ "runtime"
11
+
12
+ "github.com/pocketbase/pocketbase/core"
13
+ "github.com/pocketbase/pocketbase/tools/osutils"
14
+ "github.com/pocketbase/pocketbase/tools/security"
15
+ )
16
+
17
+ func restore(e *core.BackupEvent, config Config) error {
18
+ name := e.Name
19
+ if runtime.GOOS == "windows" {
20
+ return errors.New("restore is not supported on Windows")
21
+ }
22
+
23
+ // make sure that the special temp directory exists
24
+ // note: it needs to be inside the current pb_data to avoid "cross-device link" errors
25
+ localTempDir := filepath.Join(e.App.DataDir(), core.LocalTempDirName)
26
+ if err := os.MkdirAll(localTempDir, 0700); err != nil {
27
+ return fmt.Errorf("failed to create a temp dir: %w", err)
28
+ }
29
+
30
+ fsys, err := e.App.NewBackupsFilesystem()
31
+ if err != nil {
32
+ return err
33
+ }
34
+ defer fsys.Close()
35
+
36
+ fsys.SetContext(e.Context)
37
+
38
+ if ok, _ := fsys.Exists(name); !ok {
39
+ return fmt.Errorf("missing or invalid backup file %q to restore", name)
40
+ }
41
+
42
+ extractedDataDir := filepath.Join(localTempDir, "pb_restore_"+security.PseudorandomString(8))
43
+ defer os.RemoveAll(extractedDataDir)
44
+
45
+ // extract the zip
46
+ if e.App.Settings().Backups.S3.Enabled {
47
+ br, err := fsys.GetReader(name)
48
+ if err != nil {
49
+ return err
50
+ }
51
+ defer br.Close()
52
+
53
+ // create a temp zip file from the blob.Reader and try to extract it
54
+ tempZip, err := os.CreateTemp(localTempDir, "pb_restore_zip")
55
+ if err != nil {
56
+ return err
57
+ }
58
+ defer os.Remove(tempZip.Name())
59
+ defer tempZip.Close() // note: this technically shouldn't be necessary but it is here to workaround platforms discrepancies
60
+
61
+ _, err = io.Copy(tempZip, backupContextReader{e.Context, br})
62
+ if err != nil {
63
+ return err
64
+ }
65
+
66
+ err = extractBackup(e.Context, config, tempZip.Name(), extractedDataDir, name)
67
+ if err != nil {
68
+ return err
69
+ }
70
+
71
+ // remove the temp zip file since we no longer need it
72
+ // (this is in case the app restarts and the defer calls are not called)
73
+ _ = tempZip.Close()
74
+ err = os.Remove(tempZip.Name())
75
+ if err != nil {
76
+ e.App.Logger().Warn(
77
+ "[RestoreBackup] Failed to remove the temp zip backup file",
78
+ slog.String("file", tempZip.Name()),
79
+ slog.String("error", err.Error()),
80
+ )
81
+ }
82
+ } else {
83
+ // manually construct the local path to avoid creating a copy of the zip file
84
+ // since the blob reader currently doesn't implement ReaderAt
85
+ zipPath := filepath.Join(e.App.DataDir(), core.LocalBackupsDirName, filepath.Base(name))
86
+
87
+ err = extractBackup(e.Context, config, zipPath, extractedDataDir, name)
88
+ if err != nil {
89
+ return err
90
+ }
91
+ }
92
+
93
+ // ensure that at least a database file exists
94
+ extractedDB := filepath.Join(extractedDataDir, "data.db")
95
+ if _, err := os.Stat(extractedDB); err != nil {
96
+ return fmt.Errorf("data.db file is missing or invalid: %w", err)
97
+ }
98
+
99
+ oldTempDataDir := filepath.Join(localTempDir, "old_pb_data_"+security.PseudorandomString(8))
100
+
101
+ if err := e.Context.Err(); err != nil {
102
+ return err
103
+ }
104
+ revertDataDirChanges, err := replaceData(e, extractedDataDir, oldTempDataDir, osutils.MoveDirContent)
105
+ if err != nil {
106
+ return err
107
+ }
108
+
109
+ // restart the app
110
+ if err := e.App.Restart(); err != nil {
111
+ if revertErr := revertDataDirChanges(); revertErr != nil {
112
+ panic(revertErr)
113
+ }
114
+
115
+ return fmt.Errorf("failed to restart the app process: %w", err)
116
+ }
117
+
118
+ return nil
119
+ }
@@ -0,0 +1,52 @@
1
+ package backups
2
+
3
+ import (
4
+ "context"
5
+ "github.com/pocketbase/pocketbase/core"
6
+ "github.com/pocketbase/pocketbase/tests"
7
+ "io"
8
+ "net/http"
9
+ "net/http/httptest"
10
+ "strings"
11
+ "sync"
12
+ "testing"
13
+ )
14
+
15
+ func TestS3DestinationReceivesOnlyCiphertext(t *testing.T) {
16
+ a, err := tests.NewTestApp()
17
+ if err != nil {
18
+ t.Fatal(err)
19
+ }
20
+ defer a.Cleanup()
21
+ var mu sync.Mutex
22
+ var uploads [][]byte
23
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
24
+ switch r.Method {
25
+ case "HEAD":
26
+ w.WriteHeader(404)
27
+ case "PUT":
28
+ data, _ := io.ReadAll(r.Body)
29
+ mu.Lock()
30
+ uploads = append(uploads, data)
31
+ mu.Unlock()
32
+ w.Header().Set("ETag", `"fixture"`)
33
+ w.WriteHeader(200)
34
+ default:
35
+ w.WriteHeader(404)
36
+ }
37
+ }))
38
+ defer server.Close()
39
+ a.Settings().Backups.S3 = core.S3Config{Enabled: true, Bucket: "backups", Endpoint: server.URL, Region: "us-east-1", AccessKey: "fixture", Secret: "fixture", ForcePathStyle: true}
40
+ t.Setenv("PB_BACKUP_TEST_KEY", strings.Repeat("x", 32))
41
+ Register(a, func(core.App) (Config, error) {
42
+ return Config{Encrypted: true, EncryptionEnv: "PB_BACKUP_TEST_KEY"}, nil
43
+ })
44
+ if err = a.CreateBackup(context.Background(), "s3.zip"); err != nil {
45
+ t.Fatal(err)
46
+ }
47
+ mu.Lock()
48
+ defer mu.Unlock()
49
+ if len(uploads) != 1 || !strings.HasPrefix(string(uploads[0]), EncryptedBackupHeader) {
50
+ t.Fatalf("unexpected uploaded object count/format: %d", len(uploads))
51
+ }
52
+ }
@@ -0,0 +1,53 @@
1
+ package backups
2
+
3
+ import (
4
+ "fmt"
5
+ "github.com/pocketbase/pocketbase/core"
6
+ )
7
+
8
+ type moveFiles func(string, string, ...string) error
9
+
10
+ func replaceData(e *core.BackupEvent, extracted, old string, move moveFiles) (func() error, error) {
11
+ oldMoved, newMoved := false, false
12
+ rollback := func() error {
13
+ if newMoved {
14
+ if err := move(e.App.DataDir(), extracted, e.Exclude...); err != nil {
15
+ return fmt.Errorf("revert replacement: %w", err)
16
+ }
17
+ newMoved = false
18
+ }
19
+ if oldMoved {
20
+ if err := move(old, e.App.DataDir(), e.Exclude...); err != nil {
21
+ return fmt.Errorf("restore original data: %w", err)
22
+ }
23
+ oldMoved = false
24
+ }
25
+ return nil
26
+ }
27
+ err := e.App.RunInTransaction(func(tx core.App) error {
28
+ return tx.AuxRunInTransaction(func(tx core.App) error {
29
+ if err := e.Context.Err(); err != nil {
30
+ return err
31
+ }
32
+ if err := move(tx.DataDir(), old, e.Exclude...); err != nil {
33
+ return err
34
+ }
35
+ oldMoved = true
36
+ if err := e.Context.Err(); err != nil {
37
+ return err
38
+ }
39
+ if err := move(extracted, tx.DataDir(), e.Exclude...); err != nil {
40
+ return err
41
+ }
42
+ newMoved = true
43
+ return nil
44
+ })
45
+ })
46
+ if err != nil {
47
+ if rollbackErr := rollback(); rollbackErr != nil {
48
+ panic(fmt.Errorf("restore failed (%v) and rollback failed: %w", err, rollbackErr))
49
+ }
50
+ return nil, err
51
+ }
52
+ return rollback, nil
53
+ }
@@ -0,0 +1,75 @@
1
+ package backups
2
+
3
+ import (
4
+ "context"
5
+ "errors"
6
+ "github.com/pocketbase/pocketbase/core"
7
+ "github.com/pocketbase/pocketbase/tests"
8
+ "github.com/pocketbase/pocketbase/tools/osutils"
9
+ "os"
10
+ "path/filepath"
11
+ "testing"
12
+ )
13
+
14
+ type commitFailureApp struct{ core.App }
15
+
16
+ func (a commitFailureApp) RunInTransaction(fn func(core.App) error) error {
17
+ if err := a.App.RunInTransaction(fn); err != nil {
18
+ return err
19
+ }
20
+ return errors.New("injected commit failure")
21
+ }
22
+ func TestRestoreSwapRollsBackEveryFailure(t *testing.T) {
23
+ for _, mode := range []string{"second-move", "commit", "cancel"} {
24
+ t.Run(mode, func(t *testing.T) {
25
+ a, err := tests.NewTestApp()
26
+ if err != nil {
27
+ t.Fatal(err)
28
+ }
29
+ defer a.Cleanup()
30
+ marker := filepath.Join(a.DataDir(), "marker.txt")
31
+ os.WriteFile(marker, []byte("original"), 0600)
32
+ stage := t.TempDir()
33
+ old := t.TempDir()
34
+ os.WriteFile(filepath.Join(stage, "marker.txt"), []byte("new"), 0600)
35
+ var app core.App = a
36
+ if mode == "commit" {
37
+ app = commitFailureApp{a}
38
+ }
39
+ ctx, cancel := context.WithCancel(context.Background())
40
+ defer cancel()
41
+ if mode == "cancel" {
42
+ cancel()
43
+ }
44
+ e := &core.BackupEvent{App: app, Context: ctx, Exclude: []string{core.LocalTempDirName, core.LocalBackupsDirName, core.LocalNotifyDirName}}
45
+ calls := 0
46
+ move := func(from, to string, exclude ...string) error {
47
+ calls++
48
+ if mode == "second-move" && calls == 2 {
49
+ return errors.New("injected second move failure")
50
+ }
51
+ return osutils.MoveDirContent(from, to, exclude...)
52
+ }
53
+ if _, err = replaceData(e, stage, old, move); err == nil {
54
+ t.Fatal("accepted failure")
55
+ }
56
+ raw, err := os.ReadFile(marker)
57
+ if err != nil || string(raw) != "original" {
58
+ t.Fatalf("original data displaced: %s %v", raw, err)
59
+ }
60
+ })
61
+ }
62
+ }
63
+ func TestLaterRestoreHookStillExecutes(t *testing.T) {
64
+ a, err := tests.NewTestApp()
65
+ if err != nil {
66
+ t.Fatal(err)
67
+ }
68
+ defer a.Cleanup()
69
+ Register(a, func(core.App) (Config, error) { return Config{}, nil })
70
+ called := false
71
+ a.OnBackupRestore().BindFunc(func(e *core.BackupEvent) error { called = true; return errors.New("operator denied restore") })
72
+ if err = a.RestoreBackup(context.Background(), "missing.zip"); err == nil || !called {
73
+ t.Fatal("restore suppressed application hook")
74
+ }
75
+ }
@@ -0,0 +1,52 @@
1
+ package backups
2
+
3
+ import (
4
+ "github.com/pocketbase/pocketbase/apis"
5
+ "github.com/pocketbase/pocketbase/core"
6
+ "io"
7
+ "path/filepath"
8
+ "strings"
9
+ )
10
+
11
+ func RegisterUpload(app core.App) {
12
+ app.OnServe().BindFunc(func(e *core.ServeEvent) error {
13
+ e.Router.POST("/api/spink/backups/upload", func(r *core.RequestEvent) error {
14
+ files, err := r.FindUploadedFiles("file")
15
+ if err != nil || len(files) != 1 {
16
+ return r.BadRequestError("Select one encrypted backup", err)
17
+ }
18
+ f := files[0]
19
+ if filepath.Base(f.OriginalName) != f.OriginalName || !strings.HasSuffix(f.OriginalName, ".zip.age") {
20
+ return r.BadRequestError("Expected .zip.age archive", nil)
21
+ }
22
+ rd, err := f.Reader.Open()
23
+ if err != nil {
24
+ return err
25
+ }
26
+ header := make([]byte, len(EncryptedBackupHeader))
27
+ _, err = io.ReadFull(rd, header)
28
+ rd.Close()
29
+ if err != nil || string(header) != EncryptedBackupHeader {
30
+ return r.BadRequestError("Invalid encrypted archive header", nil)
31
+ }
32
+ fs, err := r.App.NewBackupsFilesystem()
33
+ if err != nil {
34
+ return err
35
+ }
36
+ defer fs.Close()
37
+ fs.SetContext(r.Request.Context())
38
+ exists, err := fs.Exists(f.OriginalName)
39
+ if err != nil {
40
+ return err
41
+ }
42
+ if exists {
43
+ return r.BadRequestError("Backup already exists", nil)
44
+ }
45
+ if err = fs.UploadFile(f, f.OriginalName); err != nil {
46
+ return err
47
+ }
48
+ return r.NoContent(204)
49
+ }).Bind(apis.RequireSuperuserAuth(), apis.BodyLimit(0))
50
+ return e.Next()
51
+ })
52
+ }
@@ -0,0 +1,25 @@
1
+ #!/usr/bin/env node
2
+ import {spawn,spawnSync} from 'node:child_process';
3
+ import {mkdtempSync,rmSync} from 'node:fs';
4
+ import {tmpdir} from 'node:os';
5
+ import {fileURLToPath} from 'node:url';
6
+ import {resolve,join} from 'node:path';
7
+ const root=fileURLToPath(new URL('..',import.meta.url));
8
+ const args=process.argv.slice(2);
9
+ let target='pocketbase';
10
+ if(args[0]==='loadtest'||args[0]==='import-fork')target=args.shift();
11
+ if(target==='pocketbase'&&args.length===0)args.push('serve');
12
+ let hasDir=false;
13
+ for(let i=0;i<args.length;i++){
14
+ if(args[i].startsWith('--dir=')){args[i]='--dir='+resolve(process.cwd(),args[i].slice(6));hasDir=true;}
15
+ else if(args[i]==='--dir'&&args[i+1]){args[++i]=resolve(process.cwd(),args[i]);hasDir=true;}
16
+ }
17
+ if(target==='pocketbase'&&!hasDir)args.push('--dir='+resolve(process.cwd(),'pb_data'));
18
+ const temp=mkdtempSync(join(tmpdir(),'spink-pocketbase-'));
19
+ const binary=join(temp,process.platform==='win32'?'pocketbase.exe':'pocketbase');
20
+ const result=spawnSync('go',['build','-o',binary,'./cmd/'+target],{cwd:root,stdio:'inherit',env:{...process.env,GOWORK:'off'}});
21
+ if(result.error||result.status!==0){rmSync(temp,{recursive:true,force:true});console.error('Building the extension requires Go 1.27+.',result.error?.message||'');process.exit(result.status||1);}
22
+ const child=spawn(binary,args,{cwd:process.cwd(),stdio:'inherit',env:process.env});
23
+ child.on('error',error=>{rmSync(temp,{recursive:true,force:true});console.error(error.message);process.exitCode=1;});
24
+ for(const signal of ['SIGINT','SIGTERM'])process.on(signal,()=>child.kill(signal));
25
+ child.on('exit',(code,signal)=>{rmSync(temp,{recursive:true,force:true});process.exitCode=code??(signal?1:0)});
@@ -0,0 +1,123 @@
1
+ package main
2
+
3
+ import (
4
+ "context"
5
+ "flag"
6
+ "log/slog"
7
+ "net"
8
+ "net/http"
9
+ "net/url"
10
+ "os"
11
+ "os/signal"
12
+ "syscall"
13
+ "time"
14
+
15
+ "github.com/spink-dev/pocketbase-extension/edge"
16
+ "golang.org/x/net/netutil"
17
+ )
18
+
19
+ func main() {
20
+ upstream := flag.String("upstream", "http://pocketbase:8090", "Private PocketBase origin")
21
+ listen := flag.String("listen", ":8080", "Public listener")
22
+ operator := flag.String("operator", ":8081", "Operator listener; publish on loopback only")
23
+ policyPath := flag.String("policy", "/etc/spink/edge.json", "Edge policy file")
24
+ checkpoint := flag.String("checkpoint", "/state/state.json", "Read-only security checkpoint")
25
+ probe := flag.String("probe", "", "Health URL to check and exit")
26
+ flag.Parse()
27
+ if *probe != "" {
28
+ c := http.Client{Timeout: 2 * time.Second}
29
+ r, e := c.Get(*probe)
30
+ if e != nil {
31
+ os.Exit(1)
32
+ }
33
+ r.Body.Close()
34
+ if r.StatusCode < 200 || r.StatusCode >= 300 {
35
+ os.Exit(1)
36
+ }
37
+ return
38
+ }
39
+ slog.SetDefault(slog.New(slog.NewJSONHandler(os.Stdout, nil)))
40
+ u, err := url.Parse(*upstream)
41
+ if err != nil || u.Host == "" || (u.Scheme != "http" && u.Scheme != "https") || u.User != nil || u.Path != "" || u.RawQuery != "" {
42
+ slog.Error("invalid upstream origin")
43
+ os.Exit(1)
44
+ }
45
+ p, err := edge.LoadPolicy(*policyPath)
46
+ if err != nil {
47
+ slog.Error("invalid edge policy", "error", err)
48
+ os.Exit(1)
49
+ }
50
+ g, err := edge.New(u, p)
51
+ if err != nil {
52
+ slog.Error("gateway startup", "error", err)
53
+ os.Exit(1)
54
+ }
55
+ ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
56
+ defer stop()
57
+ go func() {
58
+ ticker := time.NewTicker(time.Second)
59
+ defer ticker.Stop()
60
+ wasValid := false
61
+ for {
62
+ next, pe := edge.LoadPolicy(*policyPath)
63
+ snapshot, se := edge.LoadSnapshot(*checkpoint)
64
+ valid := pe == nil && se == nil
65
+ g.Apply(next, snapshot, valid)
66
+ if valid != wasValid {
67
+ slog.Info("edge policy readiness changed", "ready", valid)
68
+ wasValid = valid
69
+ }
70
+ select {
71
+ case <-ctx.Done():
72
+ return
73
+ case <-ticker.C:
74
+ }
75
+ }
76
+ }()
77
+ go func() {
78
+ c := http.Client{Timeout: 2 * time.Second}
79
+ ticker := time.NewTicker(2 * time.Second)
80
+ defer ticker.Stop()
81
+ for {
82
+ r, e := c.Get(u.String() + "/api/health")
83
+ healthy := e == nil && r.StatusCode == 200
84
+ if r != nil {
85
+ r.Body.Close()
86
+ }
87
+ g.SetHealthy(healthy)
88
+ select {
89
+ case <-ctx.Done():
90
+ return
91
+ case <-ticker.C:
92
+ }
93
+ }
94
+ }()
95
+ servers := []*http.Server{
96
+ {Addr: *listen, Handler: http.HandlerFunc(g.Public), ReadHeaderTimeout: 3 * time.Second, ReadTimeout: 10 * time.Second, WriteTimeout: 30 * time.Second, IdleTimeout: 15 * time.Second, MaxHeaderBytes: 16 << 10},
97
+ {Addr: *operator, Handler: http.HandlerFunc(g.Operator), ReadHeaderTimeout: 5 * time.Second, ReadTimeout: 120 * time.Second, WriteTimeout: 120 * time.Second, IdleTimeout: 30 * time.Second, MaxHeaderBytes: 32 << 10},
98
+ }
99
+ for i, s := range servers {
100
+ l, e := net.Listen("tcp", s.Addr)
101
+ if e != nil {
102
+ slog.Error("listen", "error", e)
103
+ os.Exit(1)
104
+ }
105
+ limit := 256
106
+ if i == 1 {
107
+ limit = 32
108
+ }
109
+ go func() {
110
+ if e := s.Serve(netutil.LimitListener(l, limit)); e != nil && e != http.ErrServerClosed {
111
+ slog.Error("serve", "error", e)
112
+ stop()
113
+ }
114
+ }()
115
+ }
116
+ slog.Info("gateway started", "public", *listen, "operator", *operator)
117
+ <-ctx.Done()
118
+ shutdown, cancel := context.WithTimeout(context.Background(), 10*time.Second)
119
+ defer cancel()
120
+ for _, s := range servers {
121
+ s.Shutdown(shutdown)
122
+ }
123
+ }
@@ -0,0 +1,37 @@
1
+ package main
2
+
3
+ import (
4
+ "flag"
5
+ "github.com/pocketbase/pocketbase/core"
6
+ _ "github.com/pocketbase/pocketbase/migrations"
7
+ extension "github.com/spink-dev/pocketbase-extension"
8
+ "github.com/spink-dev/pocketbase-extension/migration"
9
+ "log"
10
+ "path/filepath"
11
+ )
12
+
13
+ func main() {
14
+ dir := flag.String("dir", "", "Offline database copy to import")
15
+ encryptionEnv := flag.String("encryptionEnv", "", "Environment variable holding the native settings encryption key")
16
+ flag.Parse()
17
+ if *dir == "" {
18
+ log.Fatal("--dir is required; stop the server and use a backed-up copy")
19
+ }
20
+ abs, err := filepath.Abs(*dir)
21
+ if err != nil {
22
+ log.Fatal(err)
23
+ }
24
+ app := core.NewBaseApp(core.BaseAppConfig{DataDir: abs, EncryptionEnv: *encryptionEnv})
25
+ if err = extension.Register(app, extension.DefaultConfig()); err != nil {
26
+ log.Fatal(err)
27
+ }
28
+ if err = app.Bootstrap(); err != nil {
29
+ log.Fatal(err)
30
+ }
31
+ defer app.ResetBootstrapState()
32
+ changed, err := migration.Import(app)
33
+ if err != nil {
34
+ log.Fatal(err)
35
+ }
36
+ log.Printf("Fork settings import complete; changed=%v", changed)
37
+ }