@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
package/.dockerignore
ADDED
package/Dockerfile
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
FROM golang:1.27 AS build
|
|
2
|
+
WORKDIR /src
|
|
3
|
+
COPY go.mod go.sum ./
|
|
4
|
+
RUN go mod download
|
|
5
|
+
COPY . .
|
|
6
|
+
RUN CGO_ENABLED=0 go build -trimpath -o /pocketbase ./cmd/pocketbase && \
|
|
7
|
+
CGO_ENABLED=0 go build -trimpath -o /edge ./cmd/edge
|
|
8
|
+
RUN mkdir -p /data/security && chown -R 65532:65532 /data
|
|
9
|
+
|
|
10
|
+
FROM gcr.io/distroless/static-debian12:nonroot AS edge
|
|
11
|
+
COPY --from=build /edge /edge
|
|
12
|
+
EXPOSE 8080 8081
|
|
13
|
+
HEALTHCHECK --interval=10s --timeout=3s --start-period=20s CMD ["/edge", "-probe", "http://127.0.0.1:8081/edge/readyz"]
|
|
14
|
+
ENTRYPOINT ["/edge"]
|
|
15
|
+
|
|
16
|
+
FROM gcr.io/distroless/static-debian12:nonroot AS pocketbase
|
|
17
|
+
COPY --from=build /pocketbase /pocketbase
|
|
18
|
+
COPY --from=build /edge /edge
|
|
19
|
+
COPY --from=build --chown=65532:65532 /data /pb_data
|
|
20
|
+
COPY --from=build --chown=65532:65532 /data/security /state
|
|
21
|
+
COPY --chown=65532:65532 deploy/app /app
|
|
22
|
+
EXPOSE 8090
|
|
23
|
+
VOLUME ["/pb_data"]
|
|
24
|
+
HEALTHCHECK --interval=10s --timeout=3s --start-period=20s CMD ["/edge", "-probe", "http://127.0.0.1:8090/api/health"]
|
|
25
|
+
ENTRYPOINT ["/pocketbase"]
|
|
26
|
+
CMD ["serve","--http=0.0.0.0:8090","--dir=/pb_data","--dev=false","--migrationsDir=/app/migrations","--hooksDir=/app/hooks","--publicDir=/app/public","--automigrate=false"]
|
package/FEATURES.md
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# Feature inventory
|
|
2
|
+
|
|
3
|
+
All previously implemented work is represented here as add-on code or an explicit equivalent API. The dependency remains stock PocketBase; the old fork is not required to build or run this package.
|
|
4
|
+
|
|
5
|
+
| Earlier work | Package implementation / current interface |
|
|
6
|
+
|---|---|
|
|
7
|
+
| Admission budgets, incident detection, management, temporary blocks, checkpoints | `security/`; native Security tab with overview, incident review, policy controls and shared superuser session. |
|
|
8
|
+
| Local bounded simulation + database sandbox | `loadtest/`, `cmd/loadtest/`; same loopback safeguards, canaries, finite request/time limits. |
|
|
9
|
+
| Encrypted manual/scheduled local/S3 backups | `backups/`; upstream ZIP snapshot hooks stage privately before age encryption. Key is supplied through an environment variable, never the UI/database. |
|
|
10
|
+
| Backup upload/download/list/delete/restore | Native Backups list/download/delete/schedules remain. New encrypted upload under `/api/spink/backups/upload`; authenticated cancellation-aware restore, rollback on move/commit/restart errors; legacy ZIPs supported. |
|
|
11
|
+
| #1154 JSON Schema | `features.JSONField` wraps original JSON field and overrides its public factory. Schema metadata survives serialization as `type: json`; inline Draft 2020-12 editor, format checks, bounded cache/depth, no external loading. |
|
|
12
|
+
| #593 localized email | `mail/` hooks, request `?lang`, exact/parent fallback. Templates live in extension settings; UI is Settings → x47base extensions. Immutable request-scoped locale avoids cross-user leaks. `mail.WithLocale` supports Go callers. |
|
|
13
|
+
| #3798 error localization | `localization.Register` opt-in catalogue. |
|
|
14
|
+
| #159 singleton | `features.NewSingletonCollection`; original fixed-ID semantics. |
|
|
15
|
+
| #401 color, #7752 dateOnly | Public field factories and native field editor additions. |
|
|
16
|
+
| #6308 hasAny | Existing filter function syntax with permission/multi-relation tests. |
|
|
17
|
+
| #7793 generate value | Field editor button; `/api/adapter/collections/{collection}/fields/{field}/generate`, superuser-only. |
|
|
18
|
+
| #6667 duplicate record | `/api/collections/{collection}/records/{id}/duplicate`; checks source/protected files and internally forwards to stock create pipeline, preserving create rules/hooks. Base collections; 32MiB bounded clone, cloned files receive fresh native filenames. |
|
|
19
|
+
| #7504 environment settings | `PB_SETTINGS_JSON` applied only on initial settings validation; existing saved values win. |
|
|
20
|
+
| #4480 image dimensions | Authorized file hook with `?dimensions=1`, bounded image header read and width/height response headers. |
|
|
21
|
+
| #7642 static fallback | `web.StaticWithOptions`; import changes, same fallback/error behavior. |
|
|
22
|
+
| #7672 sidebar collapse | Native collection page toggle, independent desktop state. |
|
|
23
|
+
| #7770 app name header | Mount extension reads existing app-name setting. |
|
|
24
|
+
| #7556 OTP password seam | Opt-in `otp.Register(app, callback)` exposes `Request.Password` before stock OTP processing. Stock expiry/rate limits/single-use/MFA remain. It cannot add a field to the original core event; callback signature and timing differ from the fork. |
|
|
25
|
+
| #7774 JSVM ProgramSource | Optional extension-owned `jsvm` loader with existing compiled-program callback, tests and stock core bindings. Included in the CLI with JS migrations and configurable hooks; Go embedders use this package instead of stock `plugins/jsvm`. Do not register both. |
|
|
26
|
+
| Settings/collection watcher fix | `watcher.Register`, replacing pinned stock watcher hook IDs with coalesced pending settings/collection events. |
|
|
27
|
+
|
|
28
|
+
The earlier decisions to defer native WebAuthn (#6800) and a new nested storage model (#2491) remain unchanged. Automatic attack classification/blocking, distributed load generation and proven production DDoS capacity were not previously implemented and are not claimed here.
|
|
29
|
+
|
|
30
|
+
The experimental native UI integration and JSVM/watcher compatibility seams are version-pinned. Future PocketBase upgrades require this repository's checks and browser/restore verification; separation makes upgrades manageable, not automatically safe.
|
|
31
|
+
|
|
32
|
+
The Docker edge add-on (`edge/`, `cmd/edge`, `deploy/`) now rejects public traffic before upstream work: explicit collections, CIDR/path blocks, synchronized enforce-mode family actions, bounded budgets/clients/concurrency/bodies, and separate operator capacity. Policy synchronization is asynchronous; existing requests are not recalled. Custom migrations/hooks/assets ship in the image. Gateway counters are local at `/edge/status`. See deployment docs for route and availability limits.
|
package/LICENSE.md
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
Copyright (c) 2022 - present, Gani Georgiev
|
|
3
|
+
|
|
4
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of this software
|
|
5
|
+
and associated documentation files (the "Software"), to deal in the Software without restriction,
|
|
6
|
+
including without limitation the rights to use, copy, modify, merge, publish, distribute,
|
|
7
|
+
sublicense, and/or sell copies of the Software, and to permit persons to whom the Software
|
|
8
|
+
is furnished to do so, subject to the following conditions:
|
|
9
|
+
|
|
10
|
+
The above copyright notice and this permission notice shall be included in all copies or
|
|
11
|
+
substantial portions of the Software.
|
|
12
|
+
|
|
13
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING
|
|
14
|
+
BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
|
15
|
+
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
|
|
16
|
+
DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
17
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
package/MIGRATION.md
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# Integration and migration
|
|
2
|
+
|
|
3
|
+
## Go application
|
|
4
|
+
|
|
5
|
+
Use `require github.com/spink-dev/pocketbase-extension v0.0.0` and a **local** replace to `/Users/x47base/git/spink-dev_pocketbase-extension` until a release is published. Do not replace `github.com/pocketbase/pocketbase`.
|
|
6
|
+
|
|
7
|
+
```go
|
|
8
|
+
import (
|
|
9
|
+
"path/filepath"
|
|
10
|
+
"github.com/pocketbase/pocketbase"
|
|
11
|
+
extension "github.com/spink-dev/pocketbase-extension"
|
|
12
|
+
)
|
|
13
|
+
app := pocketbase.New()
|
|
14
|
+
config := extension.DefaultConfig()
|
|
15
|
+
statePath, err := filepath.Abs(filepath.Join(app.DataDir(), "security", "state.json"))
|
|
16
|
+
if err != nil { panic(err) }
|
|
17
|
+
config.StatePath = statePath
|
|
18
|
+
if err := extension.Register(app, config); err != nil { panic(err) }
|
|
19
|
+
if err := app.Start(); err != nil { panic(err) }
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Register before bootstrap, so field factories, first-run settings and watcher hooks are ready. Registration is process-wide for field/filter factories; do not register competing JSON factory overrides or both versions of the extension. For Go embedders, `jsvm`, `otp`, and `localization` are explicit opt-ins. The packaged CLI includes JSVM/migration commands and static serving; use `--hooksDir`, `--migrationsDir`, `--hooksWatch`, `--publicDir` and `--staticFallback` to configure them. See FEATURES.md for the changed Go import paths.
|
|
23
|
+
|
|
24
|
+
## Existing fork database
|
|
25
|
+
|
|
26
|
+
1. Stop its server. Preserve the fork binary and an offline database backup, plus any backup/settings encryption keys.
|
|
27
|
+
2. Work with an **offline copy** first. Run `go run ./cmd/import-fork --dir=/absolute/copy` before editing collections/settings through stock models. For encrypted native settings, also pass `--encryptionEnv=YOUR_EXISTING_SETTINGS_KEY_ENV` with the secret set in the environment.
|
|
28
|
+
3. Import copies native `backups.encrypted/encryptionEnv` and per-template `locales` into extension-owned `_params.spink_extension_v1`. It preserves existing extension settings and is idempotent. Missing backup keys fail validation instead of disabling encryption. JSON `schema` is preserved by the registered field wrapper.
|
|
29
|
+
4. Start the copied database using this package. Review Security, x47base extensions, field schemas, mail templates and a disposable backup/restore drill. Only then switch the live `--dir` during downtime.
|
|
30
|
+
|
|
31
|
+
The current development database stays at `/Users/x47base/git/pocketbase/adapter/pb_data`; no account reset or data move is needed. The older deleted temporary database cannot be recovered by package extraction.
|
|
32
|
+
|
|
33
|
+
## Backups
|
|
34
|
+
|
|
35
|
+
Configure a secret of 32–1024 bytes in a server environment variable, default `PB_BACKUP_ENCRYPTION_KEY`. Restart with that variable available, then enable encryption in **Settings → x47base extensions**. Only its variable name is stored. Keep old keys while their archives remain in retention. Changing a key does not re-encrypt old backups.
|
|
36
|
+
|
|
37
|
+
Normal/manual/scheduled backup creation uses the stock destination and consistency logic, with a private local staging directory and ciphertext-only final upload. Uploaded record files stored separately in S3 remain outside PocketBase database backups. Encrypted upload accepts `.zip.age`; normal ZIP upload remains native. Restore authenticates ciphertext before extracting or moving live files, checks cancellation and rolls back failed replacement. Like stock PocketBase, restore is a UNIX process restart operation.
|
|
38
|
+
|
|
39
|
+
The extension owns the final restore handler at hook priority9999. Register application authorization/audit/exclusion hooks at ordinary/default priority and call `e.Next()`. Do not install another competing restore finalizer.
|
|
40
|
+
|
|
41
|
+
## Upgrade and rollback
|
|
42
|
+
|
|
43
|
+
Change the upstream version in go.mod, run `go mod tidy`, `./scripts/check.sh`, package inspection and browser/restore tests. The admin loader versions cached scripts by release and verifies the stock loader seam; no generated upstream files are edited. CSP and existing authenticated routes are retained. A normal reload picks up this installation.
|
|
44
|
+
|
|
45
|
+
Do not run a plain stock executable against custom field metadata. Roll back with the preserved extension/fork binary and data backup if needed. This package has not been pushed or published; CI/container configuration has not run remotely.
|
|
46
|
+
|
|
47
|
+
## Docker edge deployment
|
|
48
|
+
|
|
49
|
+
New opt-in Compose deployment uses fresh named volumes and ports 8080/8081. It does not attach or migrate the existing host database. CLI adds `PB_SECURITY_STATE_PATH` (absolute), `PB_SECURITY_TRUSTED_PEERS`, `PB_SECURITY_MANAGEMENT_PEERS`, `PB_SECURITY_OPERATOR_PEERS` (comma-separated CIDRs; operator peers are runtime-only), and `PB_BACKUP_ENCRYPTION_KEY_FILE`. Existing persisted security policy retains precedence for peer lists; verify those after moving a database. Compose stores checkpoint state outside `pb_data` so native/encrypted restores can replace database files without moving a mounted state directory. Preserve and recover this volume separately. See deploy/README.md for deliberate public-route restrictions and migration packaging.
|
package/NOTICE.md
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
# Source provenance
|
|
2
|
+
|
|
3
|
+
PocketBase is copyright Gani Georgiev and contributors, licensed under MIT (see LICENSE.md). This extension includes adapted portions of the local PocketBase development fork, notably archive/restore helpers, watcher, custom fields and optional JSVM loader/bindings. The Go dependency is the unmodified upstream v0.40.3 module, not a vendored core fork.
|
|
4
|
+
|
|
5
|
+
The JSVM declarations describe stock core APIs, with trailing whitespace normalized. Extension-specific Go callbacks are documented in their package sources. Upstream issue review history remains in the original local fork's research/2026-09-10 directory; this package's FEATURES.md records delivery scope without claiming upstream acceptance or issue closure.
|
package/README.md
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# @x47base/pocketbase-addon
|
|
2
|
+
|
|
3
|
+
An independent add-on for **unmodified PocketBase v0.40.3**, including a native Security dashboard, encrypted backups and the development extensions listed in [FEATURES.md](FEATURES.md). Supports custom schemas, migrations, and hooks. Go 1.27+; Node 22+ only for npm/JavaScript tests. Co-developed with AI.
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
```sh
|
|
7
|
+
cd /Users/x47base/git/spink-dev_pocketbase-extension
|
|
8
|
+
# Keep the admin account/database already created in this session:
|
|
9
|
+
go run ./cmd/pocketbase serve --http=127.0.0.1:8090 --dir=/Users/x47base/git/pocketbase/adapter/pb_data
|
|
10
|
+
# For a fresh instance instead, use --dir=./pb_data and complete first-run setup.
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Admin: http://127.0.0.1:8090/_/ → **Security**. Backups: **Settings → Backups**. Encryption and localized mail: **Settings → x47base extensions**. Default security mode is observe; set `PB_SECURITY_MODE=enforce` only after reviewing limits. Stop any existing server on the same port first.
|
|
14
|
+
|
|
15
|
+
```sh
|
|
16
|
+
# From this folder, against your local instance:
|
|
17
|
+
go run ./cmd/loadtest -target http://127.0.0.1:8090/api/health -rate 200 -clients 8 -duration 40s -requests 8000
|
|
18
|
+
./scripts/check.sh
|
|
19
|
+
npm pack --dry-run
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
The Go module is `github.com/spink-dev/pocketbase-extension`; the npm package is `@spink-dev/pocketbase-extension`. Neither has been published. Install locally with `npm install /Users/x47base/git/spink-dev_pocketbase-extension`, then `npx pocketbase-extension serve --dir=/absolute/pb_data`. The npm launcher builds the Go binary; this is not a browser-only SDK or a plugin for an already-built stock executable.
|
|
23
|
+
|
|
24
|
+
See [integration and migration](MIGRATION.md), [feature inventory](FEATURES.md), [verification](VERIFY.md), and [deployment](deploy/README.md). The original development fork is historical; this repository is the extension source. Existing databases stay at their chosen `--dir` and are never moved automatically.
|
|
25
|
+
|
|
26
|
+
Docker deployment with a pre-database gateway, private admin listener and versioned custom migrations: [deployment guide](deploy/README.md). It creates a separate database. Start with `docker compose -f deploy/compose.yaml up --build -d --wait`; admin is on `http://127.0.0.1:8081/_/`.
|
package/adapter.go
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
package extension
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"github.com/pocketbase/pocketbase/core"
|
|
5
|
+
"github.com/spink-dev/pocketbase-extension/admin"
|
|
6
|
+
"github.com/spink-dev/pocketbase-extension/backups"
|
|
7
|
+
"github.com/spink-dev/pocketbase-extension/features"
|
|
8
|
+
"github.com/spink-dev/pocketbase-extension/mail"
|
|
9
|
+
"github.com/spink-dev/pocketbase-extension/security"
|
|
10
|
+
"github.com/spink-dev/pocketbase-extension/settings"
|
|
11
|
+
"github.com/spink-dev/pocketbase-extension/watcher"
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
type Config = security.Config
|
|
15
|
+
|
|
16
|
+
func DefaultConfig() Config { return security.DefaultConfig() }
|
|
17
|
+
func Register(app core.App, config Config) error {
|
|
18
|
+
if err := security.Register(app, config); err != nil {
|
|
19
|
+
return err
|
|
20
|
+
}
|
|
21
|
+
features.Register(app)
|
|
22
|
+
settings.Register(app)
|
|
23
|
+
mail.Register(app)
|
|
24
|
+
watcher.Register(app)
|
|
25
|
+
backups.RegisterUpload(app)
|
|
26
|
+
backups.Register(app, func(a core.App) (backups.Config, error) { c, err := settings.Load(a); return c.Backups, err })
|
|
27
|
+
if err := admin.Register(app); err != nil {
|
|
28
|
+
return err
|
|
29
|
+
}
|
|
30
|
+
return nil
|
|
31
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
package admin
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"crypto/sha256"
|
|
5
|
+
"fmt"
|
|
6
|
+
"io/fs"
|
|
7
|
+
"regexp"
|
|
8
|
+
"strings"
|
|
9
|
+
|
|
10
|
+
"github.com/pocketbase/pocketbase/core"
|
|
11
|
+
"github.com/pocketbase/pocketbase/ui"
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
// Register versions the upstream extension loader without rebuilding its dashboard.
|
|
15
|
+
func Register(app core.App) error {
|
|
16
|
+
if ui.DistDirFS == nil {
|
|
17
|
+
return nil
|
|
18
|
+
}
|
|
19
|
+
index, err := fs.ReadFile(ui.DistDirFS, "index.html")
|
|
20
|
+
if err != nil {
|
|
21
|
+
return err
|
|
22
|
+
}
|
|
23
|
+
pattern := regexp.MustCompile(`src="\./(assets/[^"?]+\.js)"`)
|
|
24
|
+
match := pattern.FindSubmatch(index)
|
|
25
|
+
if len(match) != 2 {
|
|
26
|
+
return fmt.Errorf("unsupported PocketBase admin entry point")
|
|
27
|
+
}
|
|
28
|
+
asset := string(match[1])
|
|
29
|
+
code, err := fs.ReadFile(ui.DistDirFS, asset)
|
|
30
|
+
if err != nil {
|
|
31
|
+
return err
|
|
32
|
+
}
|
|
33
|
+
if strings.Count(string(code), "/_/extensions.js") != 1 {
|
|
34
|
+
return fmt.Errorf("unsupported PocketBase extension loader")
|
|
35
|
+
}
|
|
36
|
+
version := fmt.Sprintf("%x", sha256.Sum256(append(code, []byte("spink-extension-0.1.0")...)))[:16]
|
|
37
|
+
versioned := strings.ReplaceAll(string(code), "/_/extensions.js", "/_/extensions.js?spink="+version)
|
|
38
|
+
html := pattern.ReplaceAllString(string(index), `src="./$1?spink=`+version+`"`)
|
|
39
|
+
app.OnServe().BindFunc(func(e *core.ServeEvent) error {
|
|
40
|
+
e.Router.BindFunc(func(re *core.RequestEvent) error {
|
|
41
|
+
if re.Request.Method != "GET" && re.Request.Method != "HEAD" {
|
|
42
|
+
return re.Next()
|
|
43
|
+
}
|
|
44
|
+
switch re.Request.URL.Path {
|
|
45
|
+
case "/_/", "/_/index.html":
|
|
46
|
+
if re.Response.Header().Get("Content-Security-Policy") == "" {
|
|
47
|
+
re.Response.Header().Set("Content-Security-Policy", "default-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' http://127.0.0.1:* https://tile.openstreetmap.org data: blob:; connect-src 'self' http://127.0.0.1:* https://nominatim.openstreetmap.org; script-src 'self' http://127.0.0.1:*; frame-ancestors 'none'")
|
|
48
|
+
}
|
|
49
|
+
re.Response.Header().Set("Cache-Control", "no-store")
|
|
50
|
+
return re.HTML(200, html)
|
|
51
|
+
case "/_/" + asset:
|
|
52
|
+
re.Response.Header().Set("Cache-Control", "no-store")
|
|
53
|
+
return re.Blob(200, "text/javascript", []byte(versioned))
|
|
54
|
+
}
|
|
55
|
+
return re.Next()
|
|
56
|
+
})
|
|
57
|
+
return e.Next()
|
|
58
|
+
})
|
|
59
|
+
return nil
|
|
60
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
package admin
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"github.com/pocketbase/pocketbase/apis"
|
|
5
|
+
"github.com/pocketbase/pocketbase/core"
|
|
6
|
+
"github.com/pocketbase/pocketbase/tests"
|
|
7
|
+
"net/http/httptest"
|
|
8
|
+
"strings"
|
|
9
|
+
"testing"
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
func TestAdminLoaderVersionsCachedExtensionURL(t *testing.T) {
|
|
13
|
+
a, err := tests.NewTestApp()
|
|
14
|
+
if err != nil {
|
|
15
|
+
t.Fatal(err)
|
|
16
|
+
}
|
|
17
|
+
defer a.Cleanup()
|
|
18
|
+
if err = Register(a); err != nil {
|
|
19
|
+
t.Fatal(err)
|
|
20
|
+
}
|
|
21
|
+
r, _ := apis.NewRouter(a)
|
|
22
|
+
if err = a.OnServe().Trigger(&core.ServeEvent{App: a, Router: r}); err != nil {
|
|
23
|
+
t.Fatal(err)
|
|
24
|
+
}
|
|
25
|
+
h, err := r.BuildMux()
|
|
26
|
+
if err != nil {
|
|
27
|
+
t.Fatal(err)
|
|
28
|
+
}
|
|
29
|
+
w := httptest.NewRecorder()
|
|
30
|
+
h.ServeHTTP(w, httptest.NewRequest("GET", "/_/", nil))
|
|
31
|
+
if !strings.Contains(w.Header().Get("Content-Security-Policy"), "frame-ancestors 'none'") {
|
|
32
|
+
t.Fatal("missing dashboard CSP")
|
|
33
|
+
}
|
|
34
|
+
if w.Code != 200 || !strings.Contains(w.Body.String(), "?spink=") || w.Header().Get("Cache-Control") != "no-store" {
|
|
35
|
+
t.Fatal("admin HTML did not version entry")
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
package backups
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"archive/zip"
|
|
5
|
+
"context"
|
|
6
|
+
"os"
|
|
7
|
+
"path/filepath"
|
|
8
|
+
"strings"
|
|
9
|
+
"testing"
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
func TestBackupEncryptionRoundTripAndTamper(t *testing.T) {
|
|
13
|
+
t.Setenv("PB_TEST_BACKUP_KEY", strings.Repeat("a", 32))
|
|
14
|
+
cfg := Config{Encrypted: true, EncryptionEnv: "PB_TEST_BACKUP_KEY"}
|
|
15
|
+
dir := t.TempDir()
|
|
16
|
+
source := filepath.Join(dir, "plain.zip")
|
|
17
|
+
f, err := os.Create(source)
|
|
18
|
+
if err != nil {
|
|
19
|
+
t.Fatal(err)
|
|
20
|
+
}
|
|
21
|
+
z := zip.NewWriter(f)
|
|
22
|
+
entry, err := z.Create("data.db")
|
|
23
|
+
if err != nil {
|
|
24
|
+
t.Fatal(err)
|
|
25
|
+
}
|
|
26
|
+
entry.Write([]byte("database fixture"))
|
|
27
|
+
z.Close()
|
|
28
|
+
f.Close()
|
|
29
|
+
encrypted, err := encryptBackup(context.Background(), cfg, source)
|
|
30
|
+
if err != nil {
|
|
31
|
+
t.Fatal(err)
|
|
32
|
+
}
|
|
33
|
+
raw, err := os.ReadFile(encrypted)
|
|
34
|
+
if err != nil {
|
|
35
|
+
t.Fatal(err)
|
|
36
|
+
}
|
|
37
|
+
if !strings.HasPrefix(string(raw), EncryptedBackupHeader) {
|
|
38
|
+
t.Fatal("not age format")
|
|
39
|
+
}
|
|
40
|
+
restored := filepath.Join(dir, "restored")
|
|
41
|
+
if err := extractBackup(context.Background(), cfg, encrypted, restored); err != nil {
|
|
42
|
+
t.Fatal(err)
|
|
43
|
+
}
|
|
44
|
+
data, err := os.ReadFile(filepath.Join(restored, "data.db"))
|
|
45
|
+
if err != nil || string(data) != "database fixture" {
|
|
46
|
+
t.Fatal("round trip changed data")
|
|
47
|
+
}
|
|
48
|
+
if err := extractBackup(context.Background(), cfg, source, filepath.Join(dir, "downgrade"), "backup.zip.age"); err == nil {
|
|
49
|
+
t.Fatal("encrypted backup accepted plaintext replacement")
|
|
50
|
+
}
|
|
51
|
+
legacy := filepath.Join(dir, "legacy")
|
|
52
|
+
if err := extractBackup(context.Background(), Config{}, source, legacy); err != nil {
|
|
53
|
+
t.Fatal(err)
|
|
54
|
+
}
|
|
55
|
+
t.Setenv("PB_TEST_BACKUP_KEY", strings.Repeat("b", 32))
|
|
56
|
+
if err := extractBackup(context.Background(), cfg, encrypted, filepath.Join(dir, "wrong-key")); err == nil {
|
|
57
|
+
t.Fatal("accepted wrong key")
|
|
58
|
+
}
|
|
59
|
+
t.Setenv("PB_TEST_BACKUP_KEY", strings.Repeat("a", 32))
|
|
60
|
+
raw[len(raw)-1] ^= 1
|
|
61
|
+
os.WriteFile(encrypted, raw, 0600)
|
|
62
|
+
target := filepath.Join(dir, "tampered")
|
|
63
|
+
if err := extractBackup(context.Background(), cfg, encrypted, target); err == nil {
|
|
64
|
+
t.Fatal("accepted corrupt stream")
|
|
65
|
+
}
|
|
66
|
+
if _, err := os.Stat(target); !os.IsNotExist(err) {
|
|
67
|
+
t.Fatal("extracted before authenticating")
|
|
68
|
+
}
|
|
69
|
+
leftovers, _ := filepath.Glob(filepath.Join(dir, "pb_decrypted_*"))
|
|
70
|
+
if len(leftovers) != 0 {
|
|
71
|
+
t.Fatal("leaked plaintext temporary file")
|
|
72
|
+
}
|
|
73
|
+
ctx, cancel := context.WithCancel(context.Background())
|
|
74
|
+
cancel()
|
|
75
|
+
if _, err := encryptBackup(ctx, cfg, source); err == nil {
|
|
76
|
+
t.Fatal("ignored cancellation")
|
|
77
|
+
}
|
|
78
|
+
t.Setenv("PB_TEST_BACKUP_KEY", "")
|
|
79
|
+
if _, err := encryptBackup(context.Background(), cfg, source); err == nil {
|
|
80
|
+
t.Fatal("missing key accepted")
|
|
81
|
+
}
|
|
82
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
package backups
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"bufio"
|
|
5
|
+
"context"
|
|
6
|
+
"errors"
|
|
7
|
+
"io"
|
|
8
|
+
"os"
|
|
9
|
+
"path/filepath"
|
|
10
|
+
"regexp"
|
|
11
|
+
"strings"
|
|
12
|
+
|
|
13
|
+
"filippo.io/age"
|
|
14
|
+
"github.com/spink-dev/pocketbase-extension/internal/archive"
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
const EncryptedBackupHeader = "age-encryption.org/v1\n"
|
|
18
|
+
|
|
19
|
+
var backupEnvPattern = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]{0,127}$`)
|
|
20
|
+
|
|
21
|
+
func (c Config) encryptionKey() (string, error) {
|
|
22
|
+
name := c.EncryptionEnv
|
|
23
|
+
if name == "" {
|
|
24
|
+
name = "PB_BACKUP_ENCRYPTION_KEY"
|
|
25
|
+
}
|
|
26
|
+
if !backupEnvPattern.MatchString(name) {
|
|
27
|
+
return "", errors.New("invalid backup encryption environment name")
|
|
28
|
+
}
|
|
29
|
+
key := os.Getenv(name)
|
|
30
|
+
if len(key) < 32 || len(key) > 1024 {
|
|
31
|
+
return "", errors.New("backup encryption key must contain 32 to 1024 bytes")
|
|
32
|
+
}
|
|
33
|
+
return key, nil
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
type backupContextReader struct {
|
|
37
|
+
ctx context.Context
|
|
38
|
+
r io.Reader
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
func (r backupContextReader) Read(p []byte) (int, error) {
|
|
42
|
+
if err := r.ctx.Err(); err != nil {
|
|
43
|
+
return 0, err
|
|
44
|
+
}
|
|
45
|
+
return r.r.Read(p)
|
|
46
|
+
}
|
|
47
|
+
func encryptBackup(ctx context.Context, c Config, source string) (string, error) {
|
|
48
|
+
key, err := c.encryptionKey()
|
|
49
|
+
if err != nil {
|
|
50
|
+
return "", err
|
|
51
|
+
}
|
|
52
|
+
input, err := os.Open(source)
|
|
53
|
+
if err != nil {
|
|
54
|
+
return "", err
|
|
55
|
+
}
|
|
56
|
+
defer input.Close()
|
|
57
|
+
output, err := os.CreateTemp(filepath.Dir(source), "pb_encrypted_*")
|
|
58
|
+
if err != nil {
|
|
59
|
+
return "", err
|
|
60
|
+
}
|
|
61
|
+
success := false
|
|
62
|
+
defer func() {
|
|
63
|
+
output.Close()
|
|
64
|
+
if !success {
|
|
65
|
+
os.Remove(output.Name())
|
|
66
|
+
}
|
|
67
|
+
}()
|
|
68
|
+
recipient, err := age.NewScryptRecipient(key)
|
|
69
|
+
if err != nil {
|
|
70
|
+
return "", errors.New("invalid backup encryption key")
|
|
71
|
+
}
|
|
72
|
+
recipient.SetWorkFactor(18)
|
|
73
|
+
writer, err := age.Encrypt(output, recipient)
|
|
74
|
+
if err != nil {
|
|
75
|
+
return "", err
|
|
76
|
+
}
|
|
77
|
+
if _, err = io.Copy(writer, backupContextReader{ctx, input}); err != nil {
|
|
78
|
+
return "", err
|
|
79
|
+
}
|
|
80
|
+
if err = writer.Close(); err != nil {
|
|
81
|
+
return "", err
|
|
82
|
+
}
|
|
83
|
+
if err = output.Close(); err != nil {
|
|
84
|
+
return "", err
|
|
85
|
+
}
|
|
86
|
+
success = true
|
|
87
|
+
return output.Name(), nil
|
|
88
|
+
}
|
|
89
|
+
func extractBackup(ctx context.Context, c Config, source, dest string, originalName ...string) error {
|
|
90
|
+
input, err := os.Open(source)
|
|
91
|
+
if err != nil {
|
|
92
|
+
return err
|
|
93
|
+
}
|
|
94
|
+
defer input.Close()
|
|
95
|
+
buffered := bufio.NewReader(input)
|
|
96
|
+
header, _ := buffered.Peek(len(EncryptedBackupHeader))
|
|
97
|
+
if string(header) != EncryptedBackupHeader {
|
|
98
|
+
if len(originalName) > 0 && strings.HasSuffix(originalName[0], ".age") {
|
|
99
|
+
return errors.New("encrypted backup header is missing or invalid")
|
|
100
|
+
}
|
|
101
|
+
return archive.ExtractContext(ctx, source, dest)
|
|
102
|
+
}
|
|
103
|
+
key, err := c.encryptionKey()
|
|
104
|
+
if err != nil {
|
|
105
|
+
return err
|
|
106
|
+
}
|
|
107
|
+
identity, err := age.NewScryptIdentity(key)
|
|
108
|
+
if err != nil {
|
|
109
|
+
return errors.New("invalid backup encryption key")
|
|
110
|
+
}
|
|
111
|
+
identity.SetMaxWorkFactor(18)
|
|
112
|
+
reader, err := age.Decrypt(backupContextReader{ctx, buffered}, identity)
|
|
113
|
+
if err != nil {
|
|
114
|
+
return errors.New("cannot decrypt backup with configured key")
|
|
115
|
+
}
|
|
116
|
+
output, err := os.CreateTemp(filepath.Dir(dest), "pb_decrypted_*")
|
|
117
|
+
if err != nil {
|
|
118
|
+
return err
|
|
119
|
+
}
|
|
120
|
+
defer os.Remove(output.Name())
|
|
121
|
+
defer output.Close()
|
|
122
|
+
if _, err = io.Copy(output, reader); err != nil {
|
|
123
|
+
return errors.New("backup authentication failed or restore was cancelled")
|
|
124
|
+
}
|
|
125
|
+
if err = output.Close(); err != nil {
|
|
126
|
+
return err
|
|
127
|
+
}
|
|
128
|
+
if err = ctx.Err(); err != nil {
|
|
129
|
+
return err
|
|
130
|
+
}
|
|
131
|
+
return archive.ExtractContext(ctx, output.Name(), dest)
|
|
132
|
+
}
|
|
133
|
+
func EncryptedBackupName(name string, enabled bool) string {
|
|
134
|
+
if enabled && !strings.HasSuffix(name, ".age") {
|
|
135
|
+
return name + ".age"
|
|
136
|
+
}
|
|
137
|
+
return name
|
|
138
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
package backups_test
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"github.com/spink-dev/pocketbase-extension/backups"
|
|
6
|
+
"os"
|
|
7
|
+
"path/filepath"
|
|
8
|
+
"strings"
|
|
9
|
+
"testing"
|
|
10
|
+
|
|
11
|
+
"github.com/pocketbase/pocketbase/core"
|
|
12
|
+
"github.com/pocketbase/pocketbase/tests"
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
func TestEncryptedBackupIntegration(t *testing.T) {
|
|
16
|
+
app, err := tests.NewTestApp()
|
|
17
|
+
if err != nil {
|
|
18
|
+
t.Fatal(err)
|
|
19
|
+
}
|
|
20
|
+
defer app.Cleanup()
|
|
21
|
+
t.Setenv("PB_BACKUP_TEST_KEY", strings.Repeat("x", 32))
|
|
22
|
+
backups.Register(app, func(core.App) (backups.Config, error) {
|
|
23
|
+
return backups.Config{Encrypted: true, EncryptionEnv: "PB_BACKUP_TEST_KEY"}, nil
|
|
24
|
+
})
|
|
25
|
+
if err := app.CreateBackup(context.Background(), "encrypted.zip"); err != nil {
|
|
26
|
+
t.Fatal(err)
|
|
27
|
+
}
|
|
28
|
+
path := filepath.Join(app.DataDir(), core.LocalBackupsDirName, "encrypted.zip.age")
|
|
29
|
+
raw, err := os.ReadFile(path)
|
|
30
|
+
if err != nil || !strings.HasPrefix(string(raw), backups.EncryptedBackupHeader) {
|
|
31
|
+
t.Fatal("backup was not encrypted")
|
|
32
|
+
}
|
|
33
|
+
t.Setenv("PB_BACKUP_TEST_KEY", "")
|
|
34
|
+
if err := app.CreateBackup(context.Background(), "missing.zip"); err == nil {
|
|
35
|
+
t.Fatal("missing key accepted")
|
|
36
|
+
}
|
|
37
|
+
if _, err := os.Stat(filepath.Join(app.DataDir(), core.LocalBackupsDirName, "missing.zip.age")); !os.IsNotExist(err) {
|
|
38
|
+
t.Fatal("uploaded without key")
|
|
39
|
+
}
|
|
40
|
+
before, err := app.CountRecords("users")
|
|
41
|
+
if err != nil {
|
|
42
|
+
t.Fatal(err)
|
|
43
|
+
}
|
|
44
|
+
if err := app.RestoreBackup(context.Background(), "encrypted.zip.age"); err == nil {
|
|
45
|
+
t.Fatal("restored without key")
|
|
46
|
+
}
|
|
47
|
+
after, err := app.CountRecords("users")
|
|
48
|
+
if err != nil || before != after {
|
|
49
|
+
t.Fatal("failed restore changed live database")
|
|
50
|
+
}
|
|
51
|
+
}
|