signalk-backup 0.5.0 → 0.6.1

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 (32) hide show
  1. package/README.md +35 -27
  2. package/docs/screenshots/1_signalk-backup_dashboard.png +0 -0
  3. package/docs/screenshots/2_signalk-backup_backups.png +0 -0
  4. package/docs/screenshots/3_signalk-backup_browse.png +0 -0
  5. package/docs/screenshots/4_signalk-backup_database-exports.png +0 -0
  6. package/docs/screenshots/5_signalk-backup_cloud-sync.png +0 -0
  7. package/docs/screenshots/6_signalk-backup_settings.png +0 -0
  8. package/package.json +12 -2
  9. package/plugin/config/image-tag.d.ts +1 -1
  10. package/plugin/config/image-tag.d.ts.map +1 -1
  11. package/plugin/config/image-tag.js +6 -13
  12. package/plugin/config/image-tag.js.map +1 -1
  13. package/plugin/config/schema.js +1 -1
  14. package/plugin/config/schema.js.map +1 -1
  15. package/plugin/database-export/staging-routes.d.ts +19 -0
  16. package/plugin/database-export/staging-routes.d.ts.map +1 -0
  17. package/plugin/database-export/staging-routes.js +205 -0
  18. package/plugin/database-export/staging-routes.js.map +1 -0
  19. package/plugin/index.d.ts.map +1 -1
  20. package/plugin/index.js +32 -2
  21. package/plugin/index.js.map +1 -1
  22. package/plugin/restore-host-write.d.ts +26 -0
  23. package/plugin/restore-host-write.d.ts.map +1 -0
  24. package/plugin/restore-host-write.js +363 -0
  25. package/plugin/restore-host-write.js.map +1 -0
  26. package/public/assets/{index-BbZnGesz.js → index-BQ7OBjdz.js} +2 -2
  27. package/public/assets/{index-BbZnGesz.js.map → index-BQ7OBjdz.js.map} +1 -1
  28. package/public/assets/main-BJWA9MBT.js +11 -0
  29. package/public/assets/main-BJWA9MBT.js.map +1 -0
  30. package/public/index.html +1 -1
  31. package/public/assets/main-Bb6YhYnz.js +0 -11
  32. package/public/assets/main-Bb6YhYnz.js.map +0 -1
package/README.md CHANGED
@@ -1,16 +1,18 @@
1
1
  # signalk-backup
2
2
 
3
- Scheduled deduplicated backups of your SignalK config (and optionally history) to local storage and Google Drive.
3
+ Scheduled deduplicated backups of your SignalK config (and optionally history) to local storage, USB / NAS, or Google Drive — with a tree-browser that lets you restore individual files from any snapshot, anywhere your user can write.
4
4
 
5
- The plugin is a thin shell: it asks [signalk-container](https://github.com/dirkwa/signalk-container) to run the backup engine in a container ([signalk-backup-server](https://github.com/dirkwa/signalk-backup-server)) and presents a redirect to the engine's UI as a SignalK webapp. All backup work — Kopia snapshots, rclone Google Drive sync, scheduling, restore-with-rollback happens inside the container.
5
+ The plugin runs a React webapp inside the SignalK Admin UI and orchestrates a separate backup-engine container ([signalk-backup-server](https://github.com/dirkwa/signalk-backup-server)). All Kopia snapshot work happens in the container; the plugin handles the UI, the database-export tick, and host-side file writes for restores that target paths the container can't see.
6
6
 
7
7
  ## Features
8
8
 
9
9
  - **Content-addressable deduplication** — Kopia stores file blobs once across all snapshots; identical config files between hourly/daily/weekly retention tiers cost nothing
10
- - **Cloud sync to Google Drive** — optional, opt-in. Uses rclone with `drive.file` scope (the app only sees files it created)
11
10
  - **Scheduled backups** — hourly / daily / weekly / on-startup tiers, each with independent retention
12
- - **Restore with safety backup** — every restore creates a snapshot of the current state first, so a botched restore can be rolled back automatically
13
- - **Database export pipeline** — opt-in exporters for [signalk-questdb](https://github.com/dirkwa/signalk-questdb), [signalk-grafana](https://github.com/dirkwa/signalk-grafana), and [signalk-database](https://github.com/dirkwa/signalk-database) periodically write consistent snapshots (parquet, SQLite checkpoint, provisioning YAML) to a staging dir that kopia includes in every snapshot. Live DB files are excluded from filesystem-level backup to avoid torn-write hazards; the export side is how DB state actually gets backed up. Manual backups run pending exports synchronously first.
11
+ - **Cloud + local destinations** — Google Drive (rclone, `drive.file` scope, opt-in OAuth), USB / NAS via local-fs, or SMB share. Snapshots stay in the local Kopia repo even when cloud sync is configured
12
+ - **Selective restore** — browse any backup's file tree, download a single file or whole subdirectory, or restore in-place. The trailing slash on a custom path means "land inside as a directory", so `tmp/` plus a file source writes `tmp/<filename>` instead of overwriting `tmp` as a file
13
+ - **Restore-anywhere** — custom-path restores stream through the plugin and write under the SignalK user's permissions, so you can land restored bytes in `/tmp`, `/media/usb/...`, or your home directory — anywhere your user can write
14
+ - **Restore with safety backup** — every full restore creates a snapshot of the current state first; partial restores stash the existing target as a sibling before overwriting. Both flows can be rolled back automatically on failure
15
+ - **Database export pipeline** — opt-in exporters for [signalk-questdb](https://github.com/dirkwa/signalk-questdb), [signalk-grafana](https://github.com/dirkwa/signalk-grafana), and [signalk-database](https://github.com/dirkwa/signalk-database) periodically write consistent snapshots (parquet, SQLite checkpoint, provisioning YAML) to a staging dir that Kopia includes in every snapshot. Live DB files are excluded from filesystem backup to avoid torn-write hazards. The dedicated "Database exports" tab also extracts the export subtree from any historical backup without pulling the whole snapshot — a direct answer to "QuestDB shards bloat the full zip"
14
16
  - **First-run safe default** — installs with a daily local-only schedule already seeded; no surprise data egress
15
17
  - **Update detection** — checks ghcr.io for new container images via signalk-container's centralized update service
16
18
 
@@ -18,7 +20,7 @@ The plugin is a thin shell: it asks [signalk-container](https://github.com/dirkw
18
20
 
19
21
  - SignalK Server ≥ 2.24.0
20
22
  - Node 22 or newer (24 recommended)
21
- - [signalk-container](https://github.com/dirkwa/signalk-container) ≥ 0.1.6 (provides the container runtime)
23
+ - [signalk-container](https://github.com/dirkwa/signalk-container) ≥ 1.6.0 (provides the container runtime)
22
24
  - Podman or Docker on the host
23
25
 
24
26
  ## Install
@@ -28,38 +30,44 @@ The plugin is a thin shell: it asks [signalk-container](https://github.com/dirkw
28
30
  # (auto-enables on install — first-run schedules a daily local backup)
29
31
  ```
30
32
 
31
- The plugin pulls `ghcr.io/dirkwa/signalk-backup-server:latest` on first run. The container is small (~80 MB compressed). Subsequent updates are detected and surfaced in the signalk-container config panel.
33
+ The plugin pulls `ghcr.io/dirkwa/signalk-backup-server` at a server version it pins itself (see [src/config/image-tag.ts](src/config/image-tag.ts)). Plugin releases and server releases are decoupled you can pin or float either independently via the plugin's `imageTag` setting.
32
34
 
33
35
  ## How it works
34
36
 
35
- ```
36
- Your browser
37
- └── /plugins/signalk-backup/ redirect HTML
38
- └── plugin proxies /api/gui-url
39
- └── backup-server container UI
40
- (snapshots, restore wizard, cloud sync, settings)
37
+ ```text
38
+ Browser
39
+ └── /signalk-backup/ plugin webapp (React)
40
+ └── /plugins/signalk-backup/api/* ← plugin's reverse proxy
41
+ ├── /api/db-export/staging ← plugin-local (file lister)
42
+ ├── /api/restore-partial-host ← plugin-local (writes to host fs)
43
+ └── /api/backups/*, /api/cloud/*, … ← proxied to backup-server container
44
+ └── http://127.0.0.1:<port>/api/*
45
+ (Kopia snapshots, restore-with-rollback,
46
+ partial-restore state machine, cloud sync)
41
47
  ```
42
48
 
43
- When you visit `/plugins/signalk-backup/` in the SignalK Admin UI, the plugin's redirect HTML asks `/plugins/signalk-backup/api/gui-url` for the current backup-server location and bounces your browser there. This means the UI version is always in sync with the running container.
49
+ The plugin is most of the user experience:
44
50
 
45
- ## Configuration
51
+ - The **webapp** at `/signalk-backup/` is a React SPA bundled by Vite. It runs in the SignalK admin origin and reads/writes via `fetch('/plugins/signalk-backup/api/*')`. No CORS dance, no separate auth — it inherits SignalK's.
52
+ - The **plugin process** owns three things directly: the database-export scheduler tick, an HTTP route that lists / streams files from the staging tree (so the webapp can show "live" exports), and an HTTP route that performs host-side file writes for custom-path restores.
53
+ - Everything else — Kopia snapshots, restore-with-rollback, cloud sync, scheduling — runs in the [signalk-backup-server](https://github.com/dirkwa/signalk-backup-server) container. The plugin reverse-proxies `/api/*` through to it, and adds a small set of explicit overrides above the catch-all proxy.
46
54
 
47
- Most configuration lives in the **Backup Console** (open it from the redirect, or from the SignalK Admin UI plugin config: "Open Backup Console"). The Console handles:
55
+ The webapp has four tabs:
48
56
 
49
- - Backup schedule (hourly / daily / weekly / startup) and retention tiers
50
- - Backup excludes (paths to skip default: `node_modules/`, `charts*/`)
51
- - Repository password (Kopia encryption key)
52
- - Cloud sync mode (off / manual / daily / weekly)
53
- - Google Drive connection (one-click OAuth via `rclone authorize drive`)
54
- - Database export toggles (QuestDB, Grafana, signalk-database) and export interval
55
- - Snapshot browser, restore wizard
57
+ - **Dashboard** status, recent backups, scheduler next-run times
58
+ - **Backups** create, list, download, restore (full or selective), upload a ZIP, delete
59
+ - **Database exports** — live staging table (one row per exporter file currently on disk) plus a "pick a backup → browse the database-exports subtree" extractor for grabbing historical shards without pulling the whole snapshot
60
+ - **Cloud sync** provider chooser (Google Drive / local / SMB), credentials, sync mode, on-demand sync
61
+
62
+ ## Configuration
56
63
 
57
- The SignalK Admin UI plugin panel (small) handles only deployment-level settings:
64
+ Most settings live in the **webapp** under Settings. The plugin's SignalK Admin UI config panel is intentionally small and handles only deployment-level options (the schema in [src/config/schema.ts](src/config/schema.ts) is the source of truth — update this list if you bump it):
58
65
 
59
- - `managedContainer` — whether to manage the container yourself or point at an external backup-server
60
- - `imageTag` — pin a specific version or use a floating tag like `latest`
66
+ - `managedContainer` — let the plugin manage the backup-server container (default), or point at an external instance
67
+ - `imageTag` — pin a specific version or use `auto` (default resolves to the BACKUP_SERVER_VERSION constant)
61
68
  - `externalUrl` — only used when `managedContainer: false`
62
- - `logLevel` — forwarded to the container
69
+ - `databaseExport.{questdb, grafana, signalkDatabase}` — enable per-exporter (default off; users opt in once their DB plugin is producing data)
70
+ - `databaseExport.intervalMinutes` — how often the export tick runs (5–1440)
63
71
 
64
72
  ## License
65
73
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "signalk-backup",
3
- "version": "0.5.0",
3
+ "version": "0.6.1",
4
4
  "description": "Scheduled deduplicated backups of SignalK config to local storage and Google Drive (via Kopia + rclone)",
5
5
  "main": "plugin/index.js",
6
6
  "type": "module",
@@ -33,6 +33,14 @@
33
33
  "name": "Backup",
34
34
  "description": "Scheduled deduplicated backups to local storage and Google Drive"
35
35
  },
36
+ "screenshots": [
37
+ "./docs/screenshots/1_signalk-backup_dashboard.png",
38
+ "./docs/screenshots/2_signalk-backup_backups.png",
39
+ "./docs/screenshots/3_signalk-backup_browse.png",
40
+ "./docs/screenshots/4_signalk-backup_database-exports.png",
41
+ "./docs/screenshots/5_signalk-backup_cloud-sync.png",
42
+ "./docs/screenshots/6_signalk-backup_settings.png"
43
+ ],
36
44
  "requires": [
37
45
  "signalk-container"
38
46
  ]
@@ -49,7 +57,8 @@
49
57
  },
50
58
  "dependencies": {
51
59
  "@sinclair/typebox": "^0.34.0",
52
- "bonjour-service": "^1.3.0"
60
+ "bonjour-service": "^1.3.0",
61
+ "unzipper": "^0.12.3"
53
62
  },
54
63
  "peerDependencies": {
55
64
  "signalk-container": ">=1.6.0"
@@ -68,6 +77,7 @@
68
77
  "@types/react": "^19.2.14",
69
78
  "@types/react-dom": "^19.2.3",
70
79
  "@types/supertest": "^7.2.0",
80
+ "@types/unzipper": "^0.10.11",
71
81
  "@vitejs/plugin-react": "^6.0.1",
72
82
  "bootstrap": "^5.3.8",
73
83
  "eslint": "^10.3.0",
@@ -1,3 +1,3 @@
1
- export declare const PLUGIN_VERSION: string;
1
+ export declare const BACKUP_SERVER_VERSION = "0.5.0";
2
2
  export declare function resolveImageTag(tag: string): string;
3
3
  //# sourceMappingURL=image-tag.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"image-tag.d.ts","sourceRoot":"","sources":["../../src/config/image-tag.ts"],"names":[],"mappings":"AAIA,eAAO,MAAM,cAAc,QAOvB,CAAA;AAGJ,wBAAgB,eAAe,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAEnD"}
1
+ {"version":3,"file":"image-tag.d.ts","sourceRoot":"","sources":["../../src/config/image-tag.ts"],"names":[],"mappings":"AAIA,eAAO,MAAM,qBAAqB,UAAU,CAAA;AAE5C,wBAAgB,eAAe,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAEnD"}
@@ -1,16 +1,9 @@
1
- import { readFileSync } from 'node:fs';
2
- import { fileURLToPath } from 'node:url';
3
- // CI publishes signalk-backup-server:X.Y.Z in lockstep with this plugin's package.json version.
4
- export const PLUGIN_VERSION = (() => {
5
- const pkgPath = fileURLToPath(new URL('../../package.json', import.meta.url));
6
- const raw = JSON.parse(readFileSync(pkgPath, 'utf-8'));
7
- if (typeof raw.version !== 'string') {
8
- throw new Error('signalk-backup package.json has no version field');
9
- }
10
- return raw.version;
11
- })();
12
- // 'auto' resolves to PLUGIN_VERSION so the container tracks plugin upgrades; other tags pass through.
1
+ // The signalk-backup-server image version that "auto" resolves to.
2
+ // Bump this when a new signalk-backup-server release is published to ghcr.io.
3
+ // Independent of signalk-backup's own package.json version — the two repos
4
+ // release on independent cadences. See AGENTS.md "Gotchas" for rationale.
5
+ export const BACKUP_SERVER_VERSION = '0.5.0';
13
6
  export function resolveImageTag(tag) {
14
- return tag === 'auto' ? PLUGIN_VERSION : tag;
7
+ return tag === 'auto' ? BACKUP_SERVER_VERSION : tag;
15
8
  }
16
9
  //# sourceMappingURL=image-tag.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"image-tag.js","sourceRoot":"","sources":["../../src/config/image-tag.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,YAAY,EAAE,MAAM,SAAS,CAAA;AACtC,OAAO,EAAE,aAAa,EAAE,MAAM,UAAU,CAAA;AAExC,gGAAgG;AAChG,MAAM,CAAC,MAAM,cAAc,GAAG,CAAC,GAAG,EAAE;IAClC,MAAM,OAAO,GAAG,aAAa,CAAC,IAAI,GAAG,CAAC,oBAAoB,EAAE,MAAM,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,CAAA;IAC7E,MAAM,GAAG,GAAG,IAAI,CAAC,KAAK,CAAC,YAAY,CAAC,OAAO,EAAE,OAAO,CAAC,CAA0B,CAAA;IAC/E,IAAI,OAAO,GAAG,CAAC,OAAO,KAAK,QAAQ,EAAE,CAAC;QACpC,MAAM,IAAI,KAAK,CAAC,kDAAkD,CAAC,CAAA;IACrE,CAAC;IACD,OAAO,GAAG,CAAC,OAAO,CAAA;AACpB,CAAC,CAAC,EAAE,CAAA;AAEJ,sGAAsG;AACtG,MAAM,UAAU,eAAe,CAAC,GAAW;IACzC,OAAO,GAAG,KAAK,MAAM,CAAC,CAAC,CAAC,cAAc,CAAC,CAAC,CAAC,GAAG,CAAA;AAC9C,CAAC"}
1
+ {"version":3,"file":"image-tag.js","sourceRoot":"","sources":["../../src/config/image-tag.ts"],"names":[],"mappings":"AAAA,mEAAmE;AACnE,8EAA8E;AAC9E,2EAA2E;AAC3E,0EAA0E;AAC1E,MAAM,CAAC,MAAM,qBAAqB,GAAG,OAAO,CAAA;AAE5C,MAAM,UAAU,eAAe,CAAC,GAAW;IACzC,OAAO,GAAG,KAAK,MAAM,CAAC,CAAC,CAAC,qBAAqB,CAAC,CAAC,CAAC,GAAG,CAAA;AACrD,CAAC"}
@@ -19,7 +19,7 @@ export const ConfigSchema = Type.Object({
19
19
  imageTag: Type.String({
20
20
  default: 'auto',
21
21
  title: 'Container image tag',
22
- description: '"auto" (default) tracks the plugin version, so upgrading the plugin upgrades the container. ' +
22
+ description: '"auto" (default) tracks the signalk-backup-server version this plugin release was tested against. ' +
23
23
  'Pin to a specific version (e.g. "0.4.0") or use a floating tag (e.g. "latest") to override.'
24
24
  }),
25
25
  externalUrl: Type.String({
@@ -1 +1 @@
1
- {"version":3,"file":"schema.js","sourceRoot":"","sources":["../../src/config/schema.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,IAAI,EAAU,MAAM,mBAAmB,CAAA;AAEhD;;;;;;;;;GASG;AACH,MAAM,CAAC,MAAM,YAAY,GAAG,IAAI,CAAC,MAAM,CAAC;IACtC,gBAAgB,EAAE,IAAI,CAAC,OAAO,CAAC;QAC7B,OAAO,EAAE,IAAI;QACb,KAAK,EAAE,+CAA+C;QACtD,WAAW,EACT,0FAA0F;YAC1F,4EAA4E;KAC/E,CAAC;IACF,QAAQ,EAAE,IAAI,CAAC,MAAM,CAAC;QACpB,OAAO,EAAE,MAAM;QACf,KAAK,EAAE,qBAAqB;QAC5B,WAAW,EACT,8FAA8F;YAC9F,6FAA6F;KAChG,CAAC;IACF,WAAW,EAAE,IAAI,CAAC,MAAM,CAAC;QACvB,OAAO,EAAE,EAAE;QACX,KAAK,EAAE,4BAA4B;QACnC,WAAW,EACT,8EAA8E;YAC9E,0CAA0C;KAC7C,CAAC;IACF,oEAAoE;IACpE,mEAAmE;IACnE,cAAc,EAAE,IAAI,CAAC,MAAM,CACzB;QACE,OAAO,EAAE,IAAI,CAAC,OAAO,CAAC,EAAE,OAAO,EAAE,KAAK,EAAE,CAAC;QACzC,OAAO,EAAE,IAAI,CAAC,OAAO,CAAC,EAAE,OAAO,EAAE,KAAK,EAAE,CAAC;QACzC,eAAe,EAAE,IAAI,CAAC,OAAO,CAAC,EAAE,OAAO,EAAE,KAAK,EAAE,CAAC;QACjD,eAAe,EAAE,IAAI,CAAC,MAAM,CAAC,EAAE,OAAO,EAAE,EAAE,EAAE,OAAO,EAAE,CAAC,EAAE,OAAO,EAAE,IAAI,EAAE,CAAC;KACzE,EACD;QACE,OAAO,EAAE;YACP,OAAO,EAAE,KAAK;YACd,OAAO,EAAE,KAAK;YACd,eAAe,EAAE,KAAK;YACtB,eAAe,EAAE,EAAE;SACpB;KACF,CACF;CACF,CAAC,CAAA;AAIF;;;;;;;;;GASG;AACH,MAAM,CAAC,MAAM,eAAe,GAAW;IACrC,gBAAgB,EAAE,IAAI;IACtB,QAAQ,EAAE,MAAM;IAChB,WAAW,EAAE,EAAE;IACf,cAAc,EAAE;QACd,OAAO,EAAE,KAAK;QACd,OAAO,EAAE,KAAK;QACd,eAAe,EAAE,KAAK;QACtB,eAAe,EAAE,EAAE;KACpB;CACF,CAAA"}
1
+ {"version":3,"file":"schema.js","sourceRoot":"","sources":["../../src/config/schema.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,IAAI,EAAU,MAAM,mBAAmB,CAAA;AAEhD;;;;;;;;;GASG;AACH,MAAM,CAAC,MAAM,YAAY,GAAG,IAAI,CAAC,MAAM,CAAC;IACtC,gBAAgB,EAAE,IAAI,CAAC,OAAO,CAAC;QAC7B,OAAO,EAAE,IAAI;QACb,KAAK,EAAE,+CAA+C;QACtD,WAAW,EACT,0FAA0F;YAC1F,4EAA4E;KAC/E,CAAC;IACF,QAAQ,EAAE,IAAI,CAAC,MAAM,CAAC;QACpB,OAAO,EAAE,MAAM;QACf,KAAK,EAAE,qBAAqB;QAC5B,WAAW,EACT,oGAAoG;YACpG,6FAA6F;KAChG,CAAC;IACF,WAAW,EAAE,IAAI,CAAC,MAAM,CAAC;QACvB,OAAO,EAAE,EAAE;QACX,KAAK,EAAE,4BAA4B;QACnC,WAAW,EACT,8EAA8E;YAC9E,0CAA0C;KAC7C,CAAC;IACF,oEAAoE;IACpE,mEAAmE;IACnE,cAAc,EAAE,IAAI,CAAC,MAAM,CACzB;QACE,OAAO,EAAE,IAAI,CAAC,OAAO,CAAC,EAAE,OAAO,EAAE,KAAK,EAAE,CAAC;QACzC,OAAO,EAAE,IAAI,CAAC,OAAO,CAAC,EAAE,OAAO,EAAE,KAAK,EAAE,CAAC;QACzC,eAAe,EAAE,IAAI,CAAC,OAAO,CAAC,EAAE,OAAO,EAAE,KAAK,EAAE,CAAC;QACjD,eAAe,EAAE,IAAI,CAAC,MAAM,CAAC,EAAE,OAAO,EAAE,EAAE,EAAE,OAAO,EAAE,CAAC,EAAE,OAAO,EAAE,IAAI,EAAE,CAAC;KACzE,EACD;QACE,OAAO,EAAE;YACP,OAAO,EAAE,KAAK;YACd,OAAO,EAAE,KAAK;YACd,eAAe,EAAE,KAAK;YACtB,eAAe,EAAE,EAAE;SACpB;KACF,CACF;CACF,CAAC,CAAA;AAIF;;;;;;;;;GASG;AACH,MAAM,CAAC,MAAM,eAAe,GAAW;IACrC,gBAAgB,EAAE,IAAI;IACtB,QAAQ,EAAE,MAAM;IAChB,WAAW,EAAE,EAAE;IACf,cAAc,EAAE;QACd,OAAO,EAAE,KAAK;QACd,OAAO,EAAE,KAAK;QACd,eAAe,EAAE,KAAK;QACtB,eAAe,EAAE,EAAE;KACpB;CACF,CAAA"}
@@ -0,0 +1,19 @@
1
+ import type { IRouter } from 'express';
2
+ export interface StagingEntry {
3
+ path: string;
4
+ size: number;
5
+ mtime: string;
6
+ }
7
+ export interface StagingRoutesOptions {
8
+ getStagingRoot: () => string;
9
+ log?: (msg: string) => void;
10
+ }
11
+ export declare function registerStagingRoutes(router: IRouter, opts: StagingRoutesOptions): void;
12
+ export declare function listStagingFiles(stagingRoot: string): Promise<StagingEntry[]>;
13
+ export declare class StagingPathError extends Error {
14
+ readonly code: 'INVALID_FILE' | 'OUTSIDE_ROOT';
15
+ readonly status: number;
16
+ constructor(message: string, code: 'INVALID_FILE' | 'OUTSIDE_ROOT', status: number);
17
+ }
18
+ export declare function resolveStagingFile(stagingRoot: string, file: string): Promise<string>;
19
+ //# sourceMappingURL=staging-routes.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"staging-routes.d.ts","sourceRoot":"","sources":["../../src/database-export/staging-routes.ts"],"names":[],"mappings":"AAMA,OAAO,KAAK,EAAE,OAAO,EAAqB,MAAM,SAAS,CAAA;AAEzD,MAAM,WAAW,YAAY;IAC3B,IAAI,EAAE,MAAM,CAAA;IACZ,IAAI,EAAE,MAAM,CAAA;IACZ,KAAK,EAAE,MAAM,CAAA;CACd;AAED,MAAM,WAAW,oBAAoB;IACnC,cAAc,EAAE,MAAM,MAAM,CAAA;IAC5B,GAAG,CAAC,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,IAAI,CAAA;CAC5B;AAGD,wBAAgB,qBAAqB,CAAC,MAAM,EAAE,OAAO,EAAE,IAAI,EAAE,oBAAoB,GAAG,IAAI,CAyGvF;AAID,wBAAsB,gBAAgB,CAAC,WAAW,EAAE,MAAM,GAAG,OAAO,CAAC,YAAY,EAAE,CAAC,CAMnF;AA8BD,qBAAa,gBAAiB,SAAQ,KAAK;aAGvB,IAAI,EAAE,cAAc,GAAG,cAAc;aACrC,MAAM,EAAE,MAAM;gBAF9B,OAAO,EAAE,MAAM,EACC,IAAI,EAAE,cAAc,GAAG,cAAc,EACrC,MAAM,EAAE,MAAM;CAKjC;AAID,wBAAsB,kBAAkB,CAAC,WAAW,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CA+B3F"}
@@ -0,0 +1,205 @@
1
+ // stagingRoot is hard-pinned by the caller; the file query param is
2
+ // realpath-resolved against it so a manipulated request can't escape.
3
+ import { createReadStream } from 'node:fs';
4
+ import { lstat, readdir, realpath, stat } from 'node:fs/promises';
5
+ import path from 'node:path';
6
+ // Must register before /api/* proxy catch-all to avoid forwarding to backup-server.
7
+ export function registerStagingRoutes(router, opts) {
8
+ router.get('/api/db-export/staging', async (_req, res) => {
9
+ const root = opts.getStagingRoot();
10
+ try {
11
+ const entries = await listStagingFiles(root);
12
+ res.json({
13
+ success: true,
14
+ data: { stagingRoot: root, entries },
15
+ timestamp: new Date().toISOString()
16
+ });
17
+ }
18
+ catch (err) {
19
+ opts.log?.(`staging list failed: ${errMsg(err)}`);
20
+ if (isENOENT(err)) {
21
+ res.json({
22
+ success: true,
23
+ data: { stagingRoot: root, entries: [] },
24
+ timestamp: new Date().toISOString()
25
+ });
26
+ return;
27
+ }
28
+ res.status(500).json({
29
+ success: false,
30
+ error: { code: 'STAGING_LIST_FAILED', message: errMsg(err) },
31
+ timestamp: new Date().toISOString()
32
+ });
33
+ }
34
+ });
35
+ router.get('/api/db-export/staging/download', async (req, res) => {
36
+ const fileParam = typeof req.query.file === 'string' ? req.query.file : '';
37
+ if (!fileParam) {
38
+ res.status(400).json({
39
+ success: false,
40
+ error: {
41
+ code: 'INVALID_INPUT',
42
+ message: 'file query parameter is required'
43
+ },
44
+ timestamp: new Date().toISOString()
45
+ });
46
+ return;
47
+ }
48
+ const root = opts.getStagingRoot();
49
+ let absoluteFile;
50
+ try {
51
+ absoluteFile = await resolveStagingFile(root, fileParam);
52
+ }
53
+ catch (err) {
54
+ const status = err instanceof StagingPathError ? err.status : 500;
55
+ res.status(status).json({
56
+ success: false,
57
+ error: {
58
+ code: err instanceof StagingPathError ? err.code : 'INTERNAL',
59
+ message: errMsg(err)
60
+ },
61
+ timestamp: new Date().toISOString()
62
+ });
63
+ return;
64
+ }
65
+ // Stat is the final gate: if the resolved path is a directory we
66
+ // refuse — directory downloads go through the server's zip-subtree
67
+ // endpoint, not here.
68
+ let s;
69
+ try {
70
+ s = await stat(absoluteFile);
71
+ }
72
+ catch (err) {
73
+ const status = isENOENT(err) ? 404 : 500;
74
+ res.status(status).json({
75
+ success: false,
76
+ error: {
77
+ code: status === 404 ? 'FILE_NOT_FOUND' : 'STAT_FAILED',
78
+ message: errMsg(err)
79
+ },
80
+ timestamp: new Date().toISOString()
81
+ });
82
+ return;
83
+ }
84
+ if (!s.isFile()) {
85
+ res.status(400).json({
86
+ success: false,
87
+ error: {
88
+ code: 'NOT_A_FILE',
89
+ message: 'staging download only serves regular files; use the snapshot subtree endpoint for directories'
90
+ },
91
+ timestamp: new Date().toISOString()
92
+ });
93
+ return;
94
+ }
95
+ const safeName = path.basename(fileParam).replace(/[^A-Za-z0-9._-]/g, '_') || 'staging-file';
96
+ res.setHeader('Content-Type', 'application/octet-stream');
97
+ res.setHeader('Content-Disposition', `attachment; filename="${safeName}"`);
98
+ res.setHeader('Content-Length', String(s.size));
99
+ const stream = createReadStream(absoluteFile);
100
+ stream.on('error', (err) => {
101
+ opts.log?.(`staging stream error for ${absoluteFile}: ${errMsg(err)}`);
102
+ if (!res.headersSent) {
103
+ res.status(500).end();
104
+ }
105
+ else {
106
+ res.end();
107
+ }
108
+ });
109
+ stream.pipe(res);
110
+ });
111
+ }
112
+ // Recursively lists regular files under stagingRoot; throws ENOENT if
113
+ // root is missing (route handler maps that to an empty response).
114
+ export async function listStagingFiles(stagingRoot) {
115
+ const root = await realpath(stagingRoot);
116
+ const out = [];
117
+ await walk(root, root, out);
118
+ out.sort((a, b) => a.path.localeCompare(b.path));
119
+ return out;
120
+ }
121
+ async function walk(root, current, out) {
122
+ let entries;
123
+ try {
124
+ entries = await readdir(current, { withFileTypes: true });
125
+ }
126
+ catch (err) {
127
+ if (isENOENT(err))
128
+ return;
129
+ throw err;
130
+ }
131
+ for (const entry of entries) {
132
+ const child = path.join(current, entry.name);
133
+ if (entry.isDirectory()) {
134
+ await walk(root, child, out);
135
+ continue;
136
+ }
137
+ if (!entry.isFile())
138
+ continue;
139
+ // lstat (not stat) so a symlink swapped in between readdir and here
140
+ // doesn't get followed; re-verify isFile after the lstat in case the
141
+ // entry was replaced.
142
+ const s = await lstat(child);
143
+ if (!s.isFile())
144
+ continue;
145
+ out.push({
146
+ path: path.relative(root, child).split(path.sep).join('/'),
147
+ size: s.size,
148
+ mtime: s.mtime.toISOString()
149
+ });
150
+ }
151
+ }
152
+ export class StagingPathError extends Error {
153
+ code;
154
+ status;
155
+ constructor(message, code, status) {
156
+ super(message);
157
+ this.code = code;
158
+ this.status = status;
159
+ this.name = 'StagingPathError';
160
+ }
161
+ }
162
+ // Realpath both ends so a symlink-hop in the middle is resolved before
163
+ // we compare paths — otherwise a symlink could escape stagingRoot.
164
+ export async function resolveStagingFile(stagingRoot, file) {
165
+ if (file.includes('\0')) {
166
+ throw new StagingPathError('file must not contain NUL bytes', 'INVALID_FILE', 400);
167
+ }
168
+ if (path.isAbsolute(file)) {
169
+ throw new StagingPathError('file must be a relative path under the staging root', 'INVALID_FILE', 400);
170
+ }
171
+ // Normalize separators so callers can pass forward-slash paths on Windows too.
172
+ const normalized = file.split(/[/\\]/).filter((p) => p.length > 0);
173
+ if (normalized.some((p) => p === '..')) {
174
+ throw new StagingPathError('file must not contain ".." segments', 'INVALID_FILE', 400);
175
+ }
176
+ const root = await realpath(stagingRoot);
177
+ const joined = path.join(root, ...normalized);
178
+ // Walk up until we find an ancestor that exists; realpath that so a
179
+ // symlink hop in the middle of the path is resolved before the check.
180
+ const resolvedAncestor = await resolveExistingAncestor(joined);
181
+ const rel = path.relative(root, resolvedAncestor);
182
+ if (rel.startsWith('..') || path.isAbsolute(rel)) {
183
+ throw new StagingPathError(`file ${file} resolves outside the staging root`, 'OUTSIDE_ROOT', 403);
184
+ }
185
+ return joined;
186
+ }
187
+ async function resolveExistingAncestor(target) {
188
+ let current = target;
189
+ while (current !== path.dirname(current)) {
190
+ try {
191
+ return await realpath(current);
192
+ }
193
+ catch {
194
+ current = path.dirname(current);
195
+ }
196
+ }
197
+ return await realpath(current);
198
+ }
199
+ function isENOENT(err) {
200
+ return typeof err === 'object' && err !== null && err.code === 'ENOENT';
201
+ }
202
+ function errMsg(err) {
203
+ return err instanceof Error ? err.message : String(err);
204
+ }
205
+ //# sourceMappingURL=staging-routes.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"staging-routes.js","sourceRoot":"","sources":["../../src/database-export/staging-routes.ts"],"names":[],"mappings":"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"}
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,MAAM,EAAE,MAAM,qBAAqB,CAAA;AAK5C,OAAO,EACL,eAAe,EAKhB,MAAM,YAAY,CAAA;AA2GnB,MAAM,CAAC,OAAO,WAAW,GAAG,EAAE,eAAe,GAAG,MAAM,CA+mBrD"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,MAAM,EAAE,MAAM,qBAAqB,CAAA;AAK5C,OAAO,EACL,eAAe,EAKhB,MAAM,YAAY,CAAA;AA6GnB,MAAM,CAAC,OAAO,WAAW,GAAG,EAAE,eAAe,GAAG,MAAM,CA8oBrD"}
package/plugin/index.js CHANGED
@@ -6,6 +6,8 @@ import { discoverSmbHosts, shutdownSmbDiscovery } from './smb-discovery.js';
6
6
  import { ConfigSchema, SCHEMA_DEFAULTS } from './config/schema.js';
7
7
  import { resolveImageTag } from './config/image-tag.js';
8
8
  import { runAllExports } from './database-export/index.js';
9
+ import { registerStagingRoutes } from './database-export/staging-routes.js';
10
+ import { registerHostRestoreRoutes } from './restore-host-write.js';
9
11
  const BACKUP_IMAGE = 'ghcr.io/dirkwa/signalk-backup-server';
10
12
  const CONTAINER_NAME = 'signalk-backup-server';
11
13
  const PLUGIN_ID = 'signalk-backup';
@@ -246,13 +248,19 @@ export default function (app) {
246
248
  if (!containerImage) {
247
249
  containerImage = `${BACKUP_IMAGE}:${resolveImageTag(currentSettings?.imageTag ?? 'auto')}`;
248
250
  }
251
+ // pathMapping: translate backup-server container paths back to host paths for restore banners.
252
+ const managed = currentSettings?.managedContainer !== false;
253
+ const pathMapping = managed
254
+ ? { containerPath: SK_MOUNT, hostPath: resolveSignalkConfigRoot() }
255
+ : undefined;
249
256
  res.json({
250
257
  container: {
251
258
  state: containerState,
252
259
  image: containerImage,
253
- managed: currentSettings?.managedContainer !== false
260
+ managed
254
261
  },
255
- ready: client !== null
262
+ ready: client !== null,
263
+ ...(pathMapping ? { pathMapping } : {})
256
264
  });
257
265
  });
258
266
  // Update detection — delegated to signalk-container's centralized
@@ -408,6 +416,28 @@ export default function (app) {
408
416
  startDbExportTimer();
409
417
  res.json({ success: true, data: next, timestamp: new Date().toISOString() });
410
418
  });
419
+ // Live staging tree — files the plugin's own db-export tick wrote
420
+ // to <getDataDirPath()>/database-exports/<sourcePluginId>/. The
421
+ // root is hard-pinned here; the route handlers refuse anything
422
+ // that resolves outside it. Snapshotted shards (from older
423
+ // backups) go through the backup-server's /download-subtree.
424
+ registerStagingRoutes(router, {
425
+ getStagingRoot: () => path.join(app.getDataDirPath(), 'database-exports'),
426
+ log: (msg) => {
427
+ app.debug(msg);
428
+ }
429
+ });
430
+ // Host-side restore for "Custom path" targets — bypasses the
431
+ // backup-server container's filesystem so the user can land
432
+ // restored files anywhere the SignalK process can write.
433
+ // Original-location restores still go through the proxy to the
434
+ // server's restore-partial route.
435
+ registerHostRestoreRoutes(router, {
436
+ getUpstreamBase: () => containerAddress,
437
+ log: (msg) => {
438
+ app.debug(msg);
439
+ }
440
+ });
411
441
  // Discovery runs here, not in the container, so multicast doesn't
412
442
  // have to traverse the backup-server's network namespace.
413
443
  router.get('/api/cloud/smb/discover', async (_req, res) => {