@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,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
|
+
}
|
package/backups/swap.go
ADDED
|
@@ -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)});
|
package/cmd/edge/main.go
ADDED
|
@@ -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
|
+
}
|