@rebasepro/server 0.23.0 → 0.24.1-canary.g56af4a4
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/README.md +1 -1
- package/bin/rebase-server.js +4 -2
- package/dist/GCSStorageController-DivE9bAx.js +402 -0
- package/dist/GCSStorageController-DivE9bAx.js.map +1 -0
- package/dist/S3StorageController-D_vKs4kP.js +480 -0
- package/dist/S3StorageController-D_vKs4kP.js.map +1 -0
- package/dist/api/ast-schema-editor.d.ts +92 -1
- package/dist/api/errors.d.ts +9 -0
- package/dist/api/live-schema-routes.d.ts +38 -8
- package/dist/api/logs-routes.d.ts +39 -1
- package/dist/api/openapi-generator.d.ts +17 -0
- package/dist/api/rest/api-generator.d.ts +97 -11
- package/dist/api/rest/auth-collection-writes.d.ts +28 -21
- package/dist/api/rest/field-access-query.d.ts +28 -8
- package/dist/api/rest/field-ops.d.ts +19 -7
- package/dist/api/rest/query-body.d.ts +10 -0
- package/dist/api/rest/write-validation.d.ts +9 -7
- package/dist/api/schema-editor-routes.d.ts +10 -1
- package/dist/api/sql-export-routes.d.ts +8 -0
- package/dist/api/types.d.ts +28 -1
- package/dist/{ast-schema-editor-Mvr50v_S.js → ast-schema-editor-CSdL0iCk.js} +310 -12
- package/dist/ast-schema-editor-CSdL0iCk.js.map +1 -0
- package/dist/auth/access.d.ts +121 -0
- package/dist/auth/adapter-middleware.d.ts +2 -1
- package/dist/auth/address-ownership.d.ts +16 -1
- package/dist/auth/admin-roles-route.d.ts +4 -2
- package/dist/auth/admin-roles.d.ts +17 -20
- package/dist/auth/admin-user-ops.d.ts +24 -2
- package/dist/auth/admin-users-route.d.ts +2 -0
- package/dist/auth/api-keys/api-key-middleware.d.ts +56 -55
- package/dist/auth/api-keys/api-key-routes.d.ts +41 -11
- package/dist/auth/api-keys/api-key-store.d.ts +31 -8
- package/dist/auth/api-keys/api-key-types.d.ts +14 -16
- package/dist/auth/api-keys/http-operation.d.ts +19 -0
- package/dist/auth/api-keys/index.d.ts +11 -11
- package/dist/auth/api-keys/key-grant.d.ts +41 -0
- package/dist/auth/api-keys/legacy-permissions.d.ts +33 -0
- package/dist/auth/auth-hooks.d.ts +46 -7
- package/dist/auth/builtin-auth-adapter.d.ts +8 -0
- package/dist/auth/cookie-utils.d.ts +7 -0
- package/dist/auth/deliverable-address.d.ts +6 -0
- package/dist/auth/email-change-routes.d.ts +41 -0
- package/dist/auth/expired-token-sweep.d.ts +71 -0
- package/dist/auth/impersonation.d.ts +110 -0
- package/dist/auth/index.d.ts +4 -2
- package/dist/auth/interfaces.d.ts +120 -59
- package/dist/auth/jwt-crypto.d.ts +7 -0
- package/dist/auth/jwt-keys.d.ts +4 -0
- package/dist/auth/jwt.d.ts +54 -3
- package/dist/auth/magic-link-routes.d.ts +2 -6
- package/dist/auth/mfa-routes.d.ts +2 -9
- package/dist/auth/middleware.d.ts +17 -5
- package/dist/auth/otp-routes.d.ts +2 -6
- package/dist/auth/passwordless-signup.d.ts +27 -0
- package/dist/auth/platform-token.d.ts +122 -0
- package/dist/auth/rate-limiter.d.ts +54 -1
- package/dist/auth/registration-policy.d.ts +17 -0
- package/dist/auth/routes.d.ts +45 -0
- package/dist/auth/scope-routes.d.ts +22 -0
- package/dist/auth/session-routes.d.ts +11 -6
- package/dist/auth/token-revocation.d.ts +50 -1
- package/dist/auth/user-picking.d.ts +35 -0
- package/dist/auth/verify-credential.d.ts +28 -0
- package/dist/{auth-B-GIMpDG.js → auth-OPq_FeMs.js} +3115 -638
- package/dist/auth-OPq_FeMs.js.map +1 -0
- package/dist/auth_adapter-CIItCTc8.js +21 -0
- package/dist/auth_adapter-CIItCTc8.js.map +1 -0
- package/dist/backend-DTAOsLQc.js.map +1 -1
- package/dist/backup/backup-common.d.ts +24 -3
- package/dist/backup/backup-routes.d.ts +46 -5
- package/dist/backup/backup-schedule.d.ts +33 -0
- package/dist/backup/backup-storage.d.ts +16 -0
- package/dist/backup/index.d.ts +5 -1
- package/dist/backup-5d7XhLX3.js +581 -0
- package/dist/backup-5d7XhLX3.js.map +1 -0
- package/dist/boot/bundle.d.ts +19 -0
- package/dist/boot/driver.d.ts +3 -0
- package/dist/boot/env.d.ts +56 -4
- package/dist/boot/http-server.d.ts +22 -0
- package/dist/boot/options.d.ts +16 -1
- package/dist/boot/runtime-cors.d.ts +17 -0
- package/dist/boot/security-headers.d.ts +26 -0
- package/dist/boot/static-routing.d.ts +65 -0
- package/dist/collection_patch-BRu-BvDv.js +472 -0
- package/dist/collection_patch-BRu-BvDv.js.map +1 -0
- package/dist/{contract-routes-CbFjuBwa.js → contract-routes-02lASyJI.js} +5 -8
- package/dist/contract-routes-02lASyJI.js.map +1 -0
- package/dist/cron/cron-routes.d.ts +1 -1
- package/dist/cron/cron-scheduler.d.ts +94 -23
- package/dist/cron/cron-store.d.ts +31 -4
- package/dist/cron/define-cron.d.ts +6 -2
- package/dist/cron/index.d.ts +1 -1
- package/dist/{cron-loader-DfTj2Hbi.js → cron-loader-D7OmleAb.js} +11 -7
- package/dist/cron-loader-D7OmleAb.js.map +1 -0
- package/dist/{cron-routes-eE8nif_b.js → cron-routes-CBfiaqEL.js} +34 -8
- package/dist/cron-routes-CBfiaqEL.js.map +1 -0
- package/dist/{cron-scheduler-B0pLfAix.js → cron-scheduler-C2vbETMo.js} +349 -65
- package/dist/cron-scheduler-C2vbETMo.js.map +1 -0
- package/dist/cron-store-B2Ih0gCN.js +540 -0
- package/dist/cron-store-B2Ih0gCN.js.map +1 -0
- package/dist/{ddl-bootstrap-C6mo0Kmz.js → ddl-bootstrap-BaqMSa4Y.js} +2 -2
- package/dist/{ddl-bootstrap-C6mo0Kmz.js.map → ddl-bootstrap-BaqMSa4Y.js.map} +1 -1
- package/dist/email/index.d.ts +2 -2
- package/dist/email/smtp-email-service.d.ts +23 -0
- package/dist/email/templates.d.ts +22 -0
- package/dist/email/types.d.ts +44 -0
- package/dist/env.d.ts +1 -39
- package/dist/{errors-DWsX4yTd.js → errors-D6_y86c5.js} +102 -8
- package/dist/errors-D6_y86c5.js.map +1 -0
- package/dist/filter-dialect-REl-TjZr.js +1628 -0
- package/dist/filter-dialect-REl-TjZr.js.map +1 -0
- package/dist/{function-loader-xnbDAPfa.js → function-loader-BDKKiBmy.js} +5 -4
- package/dist/function-loader-BDKKiBmy.js.map +1 -0
- package/dist/{function-routes-Chet4-lB.js → function-routes-DWB7BBsi.js} +25 -13
- package/dist/function-routes-DWB7BBsi.js.map +1 -0
- package/dist/functions/context.d.ts +26 -6
- package/dist/functions/define-function.d.ts +7 -4
- package/dist/functions/guards.d.ts +22 -5
- package/dist/functions/index.d.ts +2 -2
- package/dist/functions/index.js +114 -37
- package/dist/functions/index.js.map +1 -1
- package/dist/functions/request-timeout.d.ts +7 -5
- package/dist/history/history-routes.d.ts +9 -0
- package/dist/history/retention-sweep.d.ts +81 -0
- package/dist/{history-recorder-B4MpJfJK.js → history-recorder-DHenr3lm.js} +4 -4
- package/dist/{history-recorder-B4MpJfJK.js.map → history-recorder-DHenr3lm.js.map} +1 -1
- package/dist/{history-store-BhxWOuz9.js → history-store-DPofGVDp.js} +2 -2
- package/dist/{history-store-BhxWOuz9.js.map → history-store-DPofGVDp.js.map} +1 -1
- package/dist/index.d.ts +14 -7
- package/dist/index.es.js +23363 -17552
- package/dist/index.es.js.map +1 -1
- package/dist/init/health.d.ts +17 -2
- package/dist/init/shutdown.d.ts +20 -0
- package/dist/init.d.ts +70 -6
- package/dist/internal-tables-L2t39RdT.js +136 -0
- package/dist/internal-tables-L2t39RdT.js.map +1 -0
- package/dist/jobs/job-queue.d.ts +8 -0
- package/dist/jobs/job-store.d.ts +24 -0
- package/dist/jobs/types.d.ts +25 -4
- package/dist/{jobs-CazMYhyy.js → jobs-BpD4Q0qR.js} +235 -25
- package/dist/jobs-BpD4Q0qR.js.map +1 -0
- package/dist/{jwt-DnQHNFCl.js → jwt-Bqcu7cGt.js} +207 -77
- package/dist/jwt-Bqcu7cGt.js.map +1 -0
- package/dist/{keys-CogCQpxG.js → keys-GAVZqbqx.js} +3 -17
- package/dist/{keys-CogCQpxG.js.map → keys-GAVZqbqx.js.map} +1 -1
- package/dist/load-failure-UvzZgGXT.js +48 -0
- package/dist/load-failure-UvzZgGXT.js.map +1 -0
- package/dist/{logger-DO2PZc4i.js → logger-D-S-hO5e.js} +26 -3
- package/dist/logger-D-S-hO5e.js.map +1 -0
- package/dist/{logs-routes-Bj4TYYUl.js → logs-routes-DAdv37GI.js} +48 -8
- package/dist/logs-routes-DAdv37GI.js.map +1 -0
- package/dist/mcp/consent-page.d.ts +1 -1
- package/dist/mcp/mcp-routes.d.ts +7 -0
- package/dist/mcp/mcp-tools.d.ts +23 -9
- package/dist/mcp/oauth-metadata.d.ts +21 -16
- package/dist/mcp/oauth-routes.d.ts +7 -1
- package/dist/metrics/index.d.ts +18 -0
- package/dist/metrics/pool-wait.d.ts +50 -0
- package/dist/offline-8Ks3yp9n-CadBKm2D.js +3305 -0
- package/dist/offline-8Ks3yp9n-CadBKm2D.js.map +1 -0
- package/dist/offline-store-D9ytNNaa-BCr2vsFB.js +1903 -0
- package/dist/offline-store-D9ytNNaa-BCr2vsFB.js.map +1 -0
- package/dist/{openapi-generator-O_O24MAT.js → openapi-generator-BqoUh4Ij.js} +455 -80
- package/dist/openapi-generator-BqoUh4Ij.js.map +1 -0
- package/dist/policy-PdzX-Opd.js +121 -0
- package/dist/policy-PdzX-Opd.js.map +1 -0
- package/dist/project_manifest-R6y_KHms.js +59 -0
- package/dist/project_manifest-R6y_KHms.js.map +1 -0
- package/dist/{proxy-Czngl3p9.js → proxy-qRlqeUmO.js} +2 -2
- package/dist/{proxy-Czngl3p9.js.map → proxy-qRlqeUmO.js.map} +1 -1
- package/dist/{query-parser-DGRVFNM3.js → query-parser-DK9eMkb9.js} +497 -52
- package/dist/query-parser-DK9eMkb9.js.map +1 -0
- package/dist/{request-timeout-C_4C2BeR.js → request-timeout-Dnl2_OBc.js} +17 -10
- package/dist/request-timeout-Dnl2_OBc.js.map +1 -0
- package/dist/resumable-upload-threshold-CKv-Ry0l.js +24 -0
- package/dist/resumable-upload-threshold-CKv-Ry0l.js.map +1 -0
- package/dist/schema-edit/apply-schema-change.d.ts +73 -3
- package/dist/schema-edit/local-source.d.ts +29 -0
- package/dist/schema-edit/project-root.d.ts +3 -2
- package/dist/schema-edit/remote-source.d.ts +20 -5
- package/dist/schema-edit/source-editor.d.ts +28 -0
- package/dist/schema-editor-routes-BFf3i8jm.js +136 -0
- package/dist/schema-editor-routes-BFf3i8jm.js.map +1 -0
- package/dist/serve-spa.d.ts +58 -0
- package/dist/services/outbound-post.d.ts +50 -0
- package/dist/services/outbound-url-guard.d.ts +34 -5
- package/dist/services/routed-realtime-service.d.ts +16 -0
- package/dist/services/webhook-service.d.ts +66 -14
- package/dist/soft-delete-params-47yBGs3_.js +668 -0
- package/dist/soft-delete-params-47yBGs3_.js.map +1 -0
- package/dist/sql-export-routes-BNNuGGLP.js +240 -0
- package/dist/sql-export-routes-BNNuGGLP.js.map +1 -0
- package/dist/{src-vkcwKXbT.js → src-CatHFUym.js} +439 -20
- package/dist/src-CatHFUym.js.map +1 -0
- package/dist/storage/GCSStorageController.d.ts +48 -2
- package/dist/storage/LocalStorageController.d.ts +69 -2
- package/dist/storage/S3StorageController.d.ts +39 -3
- package/dist/storage/image-transform.d.ts +5 -0
- package/dist/storage/index.d.ts +2 -2
- package/dist/storage/maintenance-routes.d.ts +23 -0
- package/dist/storage/maintenance.d.ts +126 -0
- package/dist/storage/property-limits.d.ts +41 -6
- package/dist/storage/rendition-cache.d.ts +9 -11
- package/dist/storage/request-keys.d.ts +15 -0
- package/dist/storage/request-principals.d.ts +30 -0
- package/dist/storage/requested-object.d.ts +74 -0
- package/dist/storage/routes.d.ts +52 -14
- package/dist/storage/source-variables.d.ts +10 -0
- package/dist/storage/streams.d.ts +42 -0
- package/dist/storage/tus-handler.d.ts +76 -7
- package/dist/storage/types.d.ts +101 -7
- package/dist/storage/upload-validation.d.ts +13 -0
- package/dist/storage-resumable-VO-cMpse-C_YX71pO.js +125 -0
- package/dist/storage-resumable-VO-cMpse-C_YX71pO.js.map +1 -0
- package/dist/strings.es-BeZWAodn.js +256 -0
- package/dist/strings.es-BeZWAodn.js.map +1 -0
- package/dist/upload-validation-Dd-fnsbA.js +172 -0
- package/dist/upload-validation-Dd-fnsbA.js.map +1 -0
- package/dist/utils/byte-size.d.ts +10 -0
- package/dist/utils/compression.d.ts +6 -0
- package/dist/utils/load-failure.d.ts +14 -0
- package/dist/utils/logger.d.ts +12 -0
- package/package.json +5 -5
- package/dist/GCSStorageController-CjrA4PMo.js +0 -242
- package/dist/GCSStorageController-CjrA4PMo.js.map +0 -1
- package/dist/S3StorageController-B6pKDNVj.js +0 -264
- package/dist/S3StorageController-B6pKDNVj.js.map +0 -1
- package/dist/admin-roles-vYdp_Pil.js +0 -36
- package/dist/admin-roles-vYdp_Pil.js.map +0 -1
- package/dist/admin_block-DxKLmdiv.js +0 -206
- package/dist/admin_block-DxKLmdiv.js.map +0 -1
- package/dist/ast-schema-editor-Mvr50v_S.js.map +0 -1
- package/dist/auth/api-keys/api-key-permission-guard.d.ts +0 -65
- package/dist/auth-B-GIMpDG.js.map +0 -1
- package/dist/backup-D7YR94N3.js +0 -253
- package/dist/backup-D7YR94N3.js.map +0 -1
- package/dist/contract-routes-CbFjuBwa.js.map +0 -1
- package/dist/cron-loader-DfTj2Hbi.js.map +0 -1
- package/dist/cron-routes-eE8nif_b.js.map +0 -1
- package/dist/cron-scheduler-B0pLfAix.js.map +0 -1
- package/dist/cron-store-TcoGz-xS.js +0 -305
- package/dist/cron-store-TcoGz-xS.js.map +0 -1
- package/dist/dynamic-import-X40pTZUQ.js +0 -23
- package/dist/dynamic-import-X40pTZUQ.js.map +0 -1
- package/dist/errors-DWsX4yTd.js.map +0 -1
- package/dist/function-loader-xnbDAPfa.js.map +0 -1
- package/dist/function-routes-Chet4-lB.js.map +0 -1
- package/dist/jobs-CazMYhyy.js.map +0 -1
- package/dist/jwt-DnQHNFCl.js.map +0 -1
- package/dist/logger-DO2PZc4i.js.map +0 -1
- package/dist/logs-routes-Bj4TYYUl.js.map +0 -1
- package/dist/openapi-generator-O_O24MAT.js.map +0 -1
- package/dist/query-parser-DGRVFNM3.js.map +0 -1
- package/dist/request-timeout-C_4C2BeR.js.map +0 -1
- package/dist/schema-editor-routes-C5-lh_jO.js +0 -87
- package/dist/schema-editor-routes-C5-lh_jO.js.map +0 -1
- package/dist/src-pmvW7BFx.js +0 -6688
- package/dist/src-pmvW7BFx.js.map +0 -1
- package/dist/src-vkcwKXbT.js.map +0 -1
- package/dist/types-BfKcm9do.js +0 -39
- package/dist/types-BfKcm9do.js.map +0 -1
|
@@ -0,0 +1,581 @@
|
|
|
1
|
+
import { createRequire as __rebaseCreateRequire } from "module";
|
|
2
|
+
import __rebaseProcess from "process";
|
|
3
|
+
globalThis.process ??= __rebaseProcess;
|
|
4
|
+
__rebaseCreateRequire(import.meta.url);
|
|
5
|
+
import { n as __exportAll } from "./rolldown-runtime-dW7B1o5h.js";
|
|
6
|
+
import "./src-CatHFUym.js";
|
|
7
|
+
import { a as parseEnvBoolean, r as isAlreadyExecutingSkip } from "./cron-scheduler-C2vbETMo.js";
|
|
8
|
+
import { r as logger } from "./logger-D-S-hO5e.js";
|
|
9
|
+
import { r as errorHandler, t as ApiError } from "./errors-D6_y86c5.js";
|
|
10
|
+
import { C as verifyPurposeToken, g as isJwtConfigured, v as signPurposeToken } from "./jwt-Bqcu7cGt.js";
|
|
11
|
+
import { t as S3StorageController } from "./S3StorageController-D_vKs4kP.js";
|
|
12
|
+
import { t as GCSStorageController } from "./GCSStorageController-DivE9bAx.js";
|
|
13
|
+
import fs from "fs";
|
|
14
|
+
import path from "path";
|
|
15
|
+
import { Hono } from "hono";
|
|
16
|
+
//#region src/backup/backup-common.ts
|
|
17
|
+
/**
|
|
18
|
+
* Storage-generic backup helpers used by the admin backup routes.
|
|
19
|
+
*
|
|
20
|
+
* These live in server (which owns {@link StorageController}) so the
|
|
21
|
+
* admin API can list backups without depending on the Postgres driver —
|
|
22
|
+
* `server` must not import from `server-postgres`. The `pg_dump`-side
|
|
23
|
+
* copies of the tiny pure parsers live in `@rebasepro/server-postgres`;
|
|
24
|
+
* keep the two in sync if the naming scheme ever changes.
|
|
25
|
+
*/
|
|
26
|
+
/** Parse a destination string (`s3://…`, `gs://…`, or a local path). */
|
|
27
|
+
function parseBackupDestination(out) {
|
|
28
|
+
const s3 = out.match(/^s3:\/\/([^/]+)\/?(.*)$/);
|
|
29
|
+
if (s3) return {
|
|
30
|
+
kind: "s3",
|
|
31
|
+
bucket: s3[1],
|
|
32
|
+
prefix: s3[2].replace(/\/+$/, "")
|
|
33
|
+
};
|
|
34
|
+
const gcs = out.match(/^gs:\/\/([^/]+)\/?(.*)$/);
|
|
35
|
+
if (gcs) return {
|
|
36
|
+
kind: "gcs",
|
|
37
|
+
bucket: gcs[1],
|
|
38
|
+
prefix: gcs[2].replace(/\/+$/, "")
|
|
39
|
+
};
|
|
40
|
+
return {
|
|
41
|
+
kind: "local",
|
|
42
|
+
path: out
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Recover the UTC creation time encoded in a `rebase-<db>-<ts>.dump` name.
|
|
47
|
+
* Returns `null` for names that don't match, so foreign objects are ignored.
|
|
48
|
+
*/
|
|
49
|
+
function parseBackupTimestamp(fileName) {
|
|
50
|
+
const match = (fileName.split("/").pop() ?? fileName).match(/-(\d{8})T(\d{6})Z\.dump$/);
|
|
51
|
+
if (!match) return null;
|
|
52
|
+
const [, ymd, hms] = match;
|
|
53
|
+
const iso = `${ymd.slice(0, 4)}-${ymd.slice(4, 6)}-${ymd.slice(6, 8)}T${hms.slice(0, 2)}:${hms.slice(2, 4)}:${hms.slice(4, 6)}Z`;
|
|
54
|
+
const date = new Date(iso);
|
|
55
|
+
return Number.isNaN(date.getTime()) ? null : date;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* A backup is two files: the `.dump`, and beside it a `.globals.sql` holding
|
|
59
|
+
* the cluster roles the dump's GRANTs and RLS policies name. Without that
|
|
60
|
+
* sidecar a restore into a fresh cluster stops at the first GRANT to a role
|
|
61
|
+
* that does not exist there. The CLI uploads, restores and prunes the two as
|
|
62
|
+
* a pair. This listing used to know only the `.dump`, so a backup downloaded
|
|
63
|
+
* from Studio arrived without its roles.
|
|
64
|
+
*
|
|
65
|
+
* The suffix mirrors `globalsFileForDump` in `@rebasepro/server-postgres`.
|
|
66
|
+
*/
|
|
67
|
+
var DUMP_SUFFIX = ".dump";
|
|
68
|
+
var GLOBALS_SUFFIX = ".globals.sql";
|
|
69
|
+
function globalsKeyFor(dumpKey) {
|
|
70
|
+
return dumpKey.slice(0, -5) + GLOBALS_SUFFIX;
|
|
71
|
+
}
|
|
72
|
+
/** The only files the download route will serve. */
|
|
73
|
+
function isBackupFile(key) {
|
|
74
|
+
return key.endsWith(DUMP_SUFFIX) || key.endsWith(GLOBALS_SUFFIX);
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* How many pages of an object-storage listing to read before giving up — at
|
|
78
|
+
* the providers' 1,000 keys a page, 100,000 objects, or 50,000 backups.
|
|
79
|
+
*/
|
|
80
|
+
var MAX_LIST_PAGES = 100;
|
|
81
|
+
/**
|
|
82
|
+
* Every key under a prefix, across pages.
|
|
83
|
+
*
|
|
84
|
+
* S3 and GCS answer a listing a page at a time, in ascending key order, and a
|
|
85
|
+
* backup's key is `rebase-<db>-<UTC timestamp>`. Reading one page therefore
|
|
86
|
+
* returned the OLDEST thousand objects: with retention unset and an hourly
|
|
87
|
+
* schedule, the newest backup the panel and `rebase db backups list` showed
|
|
88
|
+
* stopped advancing after about three weeks, which reads as "backups stopped".
|
|
89
|
+
*/
|
|
90
|
+
async function listAllObjectKeys(storage, prefix, bucket) {
|
|
91
|
+
const keys = [];
|
|
92
|
+
let pageToken;
|
|
93
|
+
for (let page = 0; page < MAX_LIST_PAGES; page++) {
|
|
94
|
+
const result = await storage.listObjects(prefix, {
|
|
95
|
+
bucket,
|
|
96
|
+
maxResults: 1e3,
|
|
97
|
+
pageToken
|
|
98
|
+
});
|
|
99
|
+
for (const item of result.items) keys.push(item.fullPath);
|
|
100
|
+
pageToken = result.nextPageToken;
|
|
101
|
+
if (!pageToken) return keys;
|
|
102
|
+
}
|
|
103
|
+
logger.warn(`[backups] Stopped listing ${bucket}/${prefix} after ${MAX_LIST_PAGES} pages (${keys.length} objects). Set BACKUP_RETENTION_DAYS so old backups are pruned, or give the backups a prefix of their own.`);
|
|
104
|
+
return keys;
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Whether an object key is one the backup routes may serve: a backup file, at
|
|
108
|
+
* the destination's prefix and nowhere else.
|
|
109
|
+
*
|
|
110
|
+
* The local branch has always been held to the backup directory; this branch
|
|
111
|
+
* checked only the suffix, so any `.dump` in the bucket — `uploads/x.dump`, a
|
|
112
|
+
* neighbouring `nightlyish/` prefix — was readable by naming it. A key is taken
|
|
113
|
+
* as written, so a scheme (`s3://other-bucket/…`, which S3StorageController
|
|
114
|
+
* would follow) or a `.`/`..`/empty segment (which a filesystem-backed or
|
|
115
|
+
* S3-compatible store may resolve) is refused rather than interpreted.
|
|
116
|
+
*/
|
|
117
|
+
function isObjectBackupKey(dest, key) {
|
|
118
|
+
if (!isBackupFile(key) || /^[a-z][a-z0-9+.-]*:\/\//i.test(key)) return false;
|
|
119
|
+
if (key.split("/").some((segment) => segment === "" || segment === "." || segment === "..")) return false;
|
|
120
|
+
return dest.prefix === "" || key.startsWith(`${dest.prefix}/`);
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* List the backups at a destination as {@link BackupInfo}, newest first.
|
|
124
|
+
* One entry per `.dump`, with its `.globals.sql` sidecar attached as
|
|
125
|
+
* `globalsKey` when there is one.
|
|
126
|
+
*/
|
|
127
|
+
async function listBackupObjects(dest, storage) {
|
|
128
|
+
if (dest.kind === "local") {
|
|
129
|
+
const stat = await fs.promises.stat(dest.path).catch(() => null);
|
|
130
|
+
if (!stat) return [];
|
|
131
|
+
const dir = stat.isDirectory() ? dest.path : path.dirname(dest.path);
|
|
132
|
+
const files = await fs.promises.readdir(dir);
|
|
133
|
+
const present = new Set(files);
|
|
134
|
+
const dumps = files.filter((f) => f.endsWith(DUMP_SUFFIX));
|
|
135
|
+
const stats = await mapBounded(dumps, LOCAL_STAT_CONCURRENCY, (f) => fs.promises.stat(path.join(dir, f)));
|
|
136
|
+
return dumps.map((f, i) => {
|
|
137
|
+
const full = path.join(dir, f);
|
|
138
|
+
const st = stats[i];
|
|
139
|
+
const createdAt = parseBackupTimestamp(f) ?? st.mtime;
|
|
140
|
+
const globals = globalsKeyFor(f);
|
|
141
|
+
return {
|
|
142
|
+
key: full,
|
|
143
|
+
name: f,
|
|
144
|
+
sizeBytes: st.size,
|
|
145
|
+
createdAt: createdAt.toISOString(),
|
|
146
|
+
destinationKind: "local",
|
|
147
|
+
...present.has(globals) ? { globalsKey: path.join(dir, globals) } : {}
|
|
148
|
+
};
|
|
149
|
+
}).sort(byNewest);
|
|
150
|
+
}
|
|
151
|
+
if (!storage) throw new Error(noControllerFor(dest));
|
|
152
|
+
const keys = await listAllObjectKeys(storage, dest.prefix ? `${dest.prefix}/` : "", dest.bucket);
|
|
153
|
+
const present = new Set(keys);
|
|
154
|
+
return keys.filter((key) => key.endsWith(DUMP_SUFFIX)).map((key) => {
|
|
155
|
+
const createdAt = parseBackupTimestamp(key);
|
|
156
|
+
const globals = globalsKeyFor(key);
|
|
157
|
+
return {
|
|
158
|
+
key,
|
|
159
|
+
name: key.split("/").pop() || key,
|
|
160
|
+
createdAt: createdAt ? createdAt.toISOString() : void 0,
|
|
161
|
+
destinationKind: dest.kind,
|
|
162
|
+
...present.has(globals) ? { globalsKey: globals } : {}
|
|
163
|
+
};
|
|
164
|
+
}).sort(byNewest);
|
|
165
|
+
}
|
|
166
|
+
/** Stats in flight at once while listing a local backup folder. */
|
|
167
|
+
var LOCAL_STAT_CONCURRENCY = 32;
|
|
168
|
+
/** `fn` over `items`, at most `limit` at a time; results in input order. */
|
|
169
|
+
async function mapBounded(items, limit, fn) {
|
|
170
|
+
const results = new Array(items.length);
|
|
171
|
+
let next = 0;
|
|
172
|
+
const worker = async () => {
|
|
173
|
+
while (next < items.length) {
|
|
174
|
+
const i = next++;
|
|
175
|
+
results[i] = await fn(items[i]);
|
|
176
|
+
}
|
|
177
|
+
};
|
|
178
|
+
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
|
|
179
|
+
return results;
|
|
180
|
+
}
|
|
181
|
+
function noControllerFor(dest) {
|
|
182
|
+
return `No storage controller for ${dest.kind === "s3" ? "s3" : "gs"}://${dest.bucket}${dest.prefix ? `/${dest.prefix}` : ""}, so its backups cannot be read.`;
|
|
183
|
+
}
|
|
184
|
+
function byNewest(a, b) {
|
|
185
|
+
const ta = a.createdAt ? Date.parse(a.createdAt) : 0;
|
|
186
|
+
return (b.createdAt ? Date.parse(b.createdAt) : 0) - ta;
|
|
187
|
+
}
|
|
188
|
+
/**
|
|
189
|
+
* The absolute path of a local backup file, or `null` for anything the download
|
|
190
|
+
* route must not serve: a path outside the backup directory, a file that is not
|
|
191
|
+
* a backup, or one that does not exist.
|
|
192
|
+
*/
|
|
193
|
+
function resolveLocalBackupFile(dest, key) {
|
|
194
|
+
const dir = (fs.existsSync(dest.path) ? fs.statSync(dest.path) : null)?.isDirectory() ? dest.path : path.dirname(dest.path);
|
|
195
|
+
const resolvedDir = path.resolve(dir);
|
|
196
|
+
const resolved = path.resolve(key);
|
|
197
|
+
if (!resolved.startsWith(resolvedDir + path.sep) || !isBackupFile(resolved)) return null;
|
|
198
|
+
return fs.existsSync(resolved) ? resolved : null;
|
|
199
|
+
}
|
|
200
|
+
/** Read a single backup's bytes. Guards against path traversal for local. */
|
|
201
|
+
async function readBackupBytes(dest, key, storage) {
|
|
202
|
+
if (dest.kind === "local") {
|
|
203
|
+
const resolved = resolveLocalBackupFile(dest, key);
|
|
204
|
+
if (!resolved) return null;
|
|
205
|
+
return {
|
|
206
|
+
bytes: new Uint8Array(fs.readFileSync(resolved)),
|
|
207
|
+
name: path.basename(resolved)
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
if (!storage) throw new Error(noControllerFor(dest));
|
|
211
|
+
if (!isObjectBackupKey(dest, key)) return null;
|
|
212
|
+
const file = await storage.getObject(key, dest.bucket);
|
|
213
|
+
if (!file) return null;
|
|
214
|
+
return {
|
|
215
|
+
bytes: new Uint8Array(await file.arrayBuffer()),
|
|
216
|
+
name: key.split("/").pop() || key
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
/**
|
|
220
|
+
* Whether `key` names a backup the download route would serve, without
|
|
221
|
+
* opening it: the same guards as {@link openBackupStream}, and the file or
|
|
222
|
+
* object is there. An object store without `statObject` is taken at its key —
|
|
223
|
+
* reading the object to find out would be the download itself.
|
|
224
|
+
*/
|
|
225
|
+
async function findBackup(dest, key, storage) {
|
|
226
|
+
if (dest.kind === "local") {
|
|
227
|
+
const resolved = resolveLocalBackupFile(dest, key);
|
|
228
|
+
return resolved ? { name: path.basename(resolved) } : null;
|
|
229
|
+
}
|
|
230
|
+
if (!storage) throw new Error(noControllerFor(dest));
|
|
231
|
+
if (!isObjectBackupKey(dest, key)) return null;
|
|
232
|
+
if (storage.statObject && !await storage.statObject(key, dest.bucket)) return null;
|
|
233
|
+
return { name: key.split("/").pop() || key };
|
|
234
|
+
}
|
|
235
|
+
/**
|
|
236
|
+
* A single backup, opened for streaming, with its size. Same guards as
|
|
237
|
+
* {@link readBackupBytes}.
|
|
238
|
+
*
|
|
239
|
+
* What the download route serves. A dump is the size of the database, and
|
|
240
|
+
* reading it whole put all of it on the API process's heap — twice for a local
|
|
241
|
+
* destination (`readFileSync`, then a copy) and on top of the controller's own
|
|
242
|
+
* buffer for object storage — and a file over 2 GiB never downloaded at all:
|
|
243
|
+
* `readFileSync` throws `ERR_FS_FILE_TOO_LARGE` there.
|
|
244
|
+
*
|
|
245
|
+
* A local file is read from disk as the response is written, and so is an
|
|
246
|
+
* object through a controller with `getObjectStream` (the built-in S3 and GCS
|
|
247
|
+
* ones). A custom controller without it still arrives through
|
|
248
|
+
* {@link StorageController.getObject}, a buffered `File` by contract;
|
|
249
|
+
* streaming that avoids the second copy.
|
|
250
|
+
*/
|
|
251
|
+
async function openBackupStream(dest, key, storage) {
|
|
252
|
+
if (dest.kind === "local") {
|
|
253
|
+
const resolved = resolveLocalBackupFile(dest, key);
|
|
254
|
+
if (!resolved) return null;
|
|
255
|
+
const { size } = await fs.promises.stat(resolved);
|
|
256
|
+
return {
|
|
257
|
+
stream: fileStream(resolved),
|
|
258
|
+
size,
|
|
259
|
+
name: path.basename(resolved)
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
if (!storage) throw new Error(noControllerFor(dest));
|
|
263
|
+
if (!isObjectBackupKey(dest, key)) return null;
|
|
264
|
+
const name = key.split("/").pop() || key;
|
|
265
|
+
if (storage.getObjectStream) {
|
|
266
|
+
const object = await storage.getObjectStream(key, { bucket: dest.bucket });
|
|
267
|
+
return object ? {
|
|
268
|
+
stream: object.stream,
|
|
269
|
+
size: object.size,
|
|
270
|
+
name
|
|
271
|
+
} : null;
|
|
272
|
+
}
|
|
273
|
+
const file = await storage.getObject(key, dest.bucket);
|
|
274
|
+
if (!file) return null;
|
|
275
|
+
return {
|
|
276
|
+
stream: file.stream(),
|
|
277
|
+
size: file.size,
|
|
278
|
+
name
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
/** A file on disk as a web stream, read a chunk at a time as the response is written. */
|
|
282
|
+
function fileStream(filePath) {
|
|
283
|
+
const file = fs.createReadStream(filePath);
|
|
284
|
+
const chunks = file[Symbol.asyncIterator]();
|
|
285
|
+
return new ReadableStream({
|
|
286
|
+
async pull(controller) {
|
|
287
|
+
const next = await chunks.next();
|
|
288
|
+
if (next.done) controller.close();
|
|
289
|
+
else controller.enqueue(next.value);
|
|
290
|
+
},
|
|
291
|
+
cancel() {
|
|
292
|
+
file.destroy();
|
|
293
|
+
}
|
|
294
|
+
});
|
|
295
|
+
}
|
|
296
|
+
//#endregion
|
|
297
|
+
//#region src/backup/backup-storage.ts
|
|
298
|
+
/**
|
|
299
|
+
* The storage controller that reads a backup bucket.
|
|
300
|
+
*
|
|
301
|
+
* A backup destination is its own bucket, named by `BACKUP_DESTINATION`, and
|
|
302
|
+
* the documented backup cron writes to it through a controller it builds from
|
|
303
|
+
* the `S3_*` variables (GCS: application default credentials) — as
|
|
304
|
+
* `rebase db backups list` does. The admin routes have to read it the same way.
|
|
305
|
+
* The app's file storage is not that controller: it is configured for the
|
|
306
|
+
* uploads bucket, it is a local directory in development, and in production
|
|
307
|
+
* without an uploads bucket there is none at all.
|
|
308
|
+
*/
|
|
309
|
+
/** The destination `BACKUP_DESTINATION` names, or `null` when it is unset. */
|
|
310
|
+
function backupDestinationFromEnv(env) {
|
|
311
|
+
const out = env.BACKUP_DESTINATION?.trim();
|
|
312
|
+
return out ? parseBackupDestination(out) : null;
|
|
313
|
+
}
|
|
314
|
+
/** The destination as `BACKUP_DESTINATION` spells it. */
|
|
315
|
+
function describeBackupDestination(dest) {
|
|
316
|
+
if (dest.kind === "local") return dest.path;
|
|
317
|
+
return `${dest.kind === "s3" ? "s3" : "gs"}://${dest.bucket}${dest.prefix ? `/${dest.prefix}` : ""}`;
|
|
318
|
+
}
|
|
319
|
+
/**
|
|
320
|
+
* A controller for an object-storage destination's bucket, built from the
|
|
321
|
+
* environment the way the backup cron and `rebase db backups list` build
|
|
322
|
+
* theirs. Throws when an S3 destination has no credentials to read it with.
|
|
323
|
+
*/
|
|
324
|
+
function backupStorageFromEnv(dest, env) {
|
|
325
|
+
if (dest.kind === "gcs") return new GCSStorageController({
|
|
326
|
+
type: "gcs",
|
|
327
|
+
bucket: dest.bucket
|
|
328
|
+
});
|
|
329
|
+
if (!env.S3_ACCESS_KEY_ID || !env.S3_SECRET_ACCESS_KEY) throw new Error("An S3 backup destination needs S3_ACCESS_KEY_ID and S3_SECRET_ACCESS_KEY in the environment — the same S3_* variables the backup cron uses.");
|
|
330
|
+
return new S3StorageController({
|
|
331
|
+
type: "s3",
|
|
332
|
+
bucket: dest.bucket,
|
|
333
|
+
region: env.S3_REGION || "auto",
|
|
334
|
+
accessKeyId: env.S3_ACCESS_KEY_ID,
|
|
335
|
+
secretAccessKey: env.S3_SECRET_ACCESS_KEY,
|
|
336
|
+
endpoint: env.S3_ENDPOINT,
|
|
337
|
+
forcePathStyle: parseEnvBoolean(env.S3_FORCE_PATH_STYLE)
|
|
338
|
+
});
|
|
339
|
+
}
|
|
340
|
+
//#endregion
|
|
341
|
+
//#region src/backup/backup-routes.ts
|
|
342
|
+
/** Why a destination could not be read, as the answer says it. */
|
|
343
|
+
function unreadable(dest, err) {
|
|
344
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
345
|
+
return ApiError.serviceUnavailable(`Cannot read the backups at ${describeBackupDestination(dest)}: ${reason}`);
|
|
346
|
+
}
|
|
347
|
+
/**
|
|
348
|
+
* The `purpose` of a download link's token. {@link verifyAccessToken} refuses
|
|
349
|
+
* any token with a purpose, and the storage, MFA and MCP verifiers each demand
|
|
350
|
+
* their own, so a link is never a session or any other credential, and none of
|
|
351
|
+
* those is a link.
|
|
352
|
+
*/
|
|
353
|
+
var BACKUP_DOWNLOAD_PURPOSE = "backup-download";
|
|
354
|
+
/** The token's audience: the one route that redeems it. */
|
|
355
|
+
var BACKUP_DOWNLOAD_AUDIENCE = "rebase:admin/backups/download";
|
|
356
|
+
/** What a link grants: one backup, at one destination. */
|
|
357
|
+
function linkScope(dest, key) {
|
|
358
|
+
return `backup:${describeBackupDestination(dest)}:${key}`;
|
|
359
|
+
}
|
|
360
|
+
/** Whether `token` is a live link for `key` at `dest`. Never throws: anything else is a no. */
|
|
361
|
+
async function linkGrants(token, dest, key) {
|
|
362
|
+
if (!isJwtConfigured()) return false;
|
|
363
|
+
const claims = await verifyPurposeToken(BACKUP_DOWNLOAD_PURPOSE, token).catch(() => null);
|
|
364
|
+
return claims?.aud === BACKUP_DOWNLOAD_AUDIENCE && claims.scope === linkScope(dest, key);
|
|
365
|
+
}
|
|
366
|
+
/** The configured destination, or the 400 every backup route answers without one. */
|
|
367
|
+
function destinationOf(config) {
|
|
368
|
+
const dest = config.getDestination();
|
|
369
|
+
if (!dest) throw ApiError.badRequest("Backups are not configured (set BACKUP_DESTINATION).");
|
|
370
|
+
return dest;
|
|
371
|
+
}
|
|
372
|
+
/** {@link BackupRoutesConfig.storageFor}, with its default, for a destination that has a controller. */
|
|
373
|
+
function storageResolver(config) {
|
|
374
|
+
const storageFor = config.storageFor ?? ((dest) => backupStorageFromEnv(dest, process.env));
|
|
375
|
+
/** The controller for `dest`, or none for a local one. A destination it cannot read is an error, never `[]`. */
|
|
376
|
+
return (dest) => {
|
|
377
|
+
if (dest.kind === "local") return void 0;
|
|
378
|
+
try {
|
|
379
|
+
return storageFor(dest);
|
|
380
|
+
} catch (err) {
|
|
381
|
+
throw unreadable(dest, err);
|
|
382
|
+
}
|
|
383
|
+
};
|
|
384
|
+
}
|
|
385
|
+
/** One backup's bytes as the response, streamed: a dump is the size of the database. See `openBackupStream`. */
|
|
386
|
+
async function serveBackup(c, dest, key, storage) {
|
|
387
|
+
const result = await openBackupStream(dest, key, storage);
|
|
388
|
+
if (!result) throw ApiError.notFound(`Backup not found: ${key}`);
|
|
389
|
+
c.header("Content-Type", "application/octet-stream");
|
|
390
|
+
c.header("Content-Length", String(result.size));
|
|
391
|
+
c.header("Content-Disposition", `attachment; filename="${result.name}"`);
|
|
392
|
+
c.header("Cache-Control", "no-store");
|
|
393
|
+
return c.body(result.stream);
|
|
394
|
+
}
|
|
395
|
+
/**
|
|
396
|
+
* `GET /download?key=…&token=…`: a download link spent in place of the bearer
|
|
397
|
+
* header, for the one backup it names.
|
|
398
|
+
*
|
|
399
|
+
* Mount it at the backups prefix AHEAD of every gate on the admin prefix: the
|
|
400
|
+
* auth adapter's own admin routers authenticate everything under `/admin`
|
|
401
|
+
* before the Backups gate is reached. A request with no `token` passes
|
|
402
|
+
* through untouched; one with a token is answered here — the backup, or a
|
|
403
|
+
* 401 — and never falls through to the header: a bad link is a bad link.
|
|
404
|
+
*
|
|
405
|
+
* A browser cannot put a header on a navigation, so before links the SDK
|
|
406
|
+
* fetched the dump and held it whole in the tab as a Blob. With a link the
|
|
407
|
+
* browser streams it to disk.
|
|
408
|
+
*/
|
|
409
|
+
function createBackupLinkRoutes(config) {
|
|
410
|
+
const router = new Hono();
|
|
411
|
+
router.onError(errorHandler);
|
|
412
|
+
const storageOf = storageResolver(config);
|
|
413
|
+
router.get("/download", async (c, next) => {
|
|
414
|
+
const token = c.req.query("token");
|
|
415
|
+
if (token === void 0) return next();
|
|
416
|
+
const dest = destinationOf(config);
|
|
417
|
+
const key = c.req.query("key") ?? "";
|
|
418
|
+
if (!await linkGrants(token, dest, key)) throw ApiError.unauthenticated(`This download link is not valid for this backup, or it has expired. Links last 60 seconds: ask for a new one.`);
|
|
419
|
+
return serveBackup(c, dest, key, storageOf(dest));
|
|
420
|
+
});
|
|
421
|
+
return router;
|
|
422
|
+
}
|
|
423
|
+
/**
|
|
424
|
+
* Admin REST routes for the Backups panel.
|
|
425
|
+
*
|
|
426
|
+
* Routes (mounted under `/admin/backups`, admin-guarded by the caller):
|
|
427
|
+
* GET / → list available backups, and the scheduled job's last run
|
|
428
|
+
* GET /download → download a backup's bytes (?key=…)
|
|
429
|
+
* POST /download-url → a 60-second link to one backup, `{ key }` → {@link BackupDownloadLink}
|
|
430
|
+
*
|
|
431
|
+
* The link is spent at `GET /download?key=…&token=…`, which
|
|
432
|
+
* {@link createBackupLinkRoutes} answers ahead of the gate.
|
|
433
|
+
*/
|
|
434
|
+
function createBackupRoutes(config) {
|
|
435
|
+
const router = new Hono();
|
|
436
|
+
router.onError(errorHandler);
|
|
437
|
+
const storageOf = storageResolver(config);
|
|
438
|
+
router.get("/", async (c) => {
|
|
439
|
+
const schedule = config.getSchedule ? await config.getSchedule() : null;
|
|
440
|
+
const dest = config.getDestination();
|
|
441
|
+
if (!dest) return c.json({
|
|
442
|
+
backups: [],
|
|
443
|
+
destinationKind: "local",
|
|
444
|
+
configured: false,
|
|
445
|
+
schedule
|
|
446
|
+
});
|
|
447
|
+
const backups = await listBackupObjects(dest, storageOf(dest)).catch((err) => {
|
|
448
|
+
throw unreadable(dest, err);
|
|
449
|
+
});
|
|
450
|
+
const localDiskOfThisProcess = dest.kind === "local" && config.scheduledElsewhere === true && schedule !== null;
|
|
451
|
+
return c.json({
|
|
452
|
+
backups,
|
|
453
|
+
destinationKind: dest.kind,
|
|
454
|
+
configured: true,
|
|
455
|
+
schedule,
|
|
456
|
+
...localDiskOfThisProcess ? { localDiskOfThisProcess } : {}
|
|
457
|
+
});
|
|
458
|
+
});
|
|
459
|
+
router.get("/download", async (c) => {
|
|
460
|
+
const dest = destinationOf(config);
|
|
461
|
+
const key = c.req.query("key");
|
|
462
|
+
if (!key) throw ApiError.badRequest("Missing 'key' query parameter.");
|
|
463
|
+
return serveBackup(c, dest, key, storageOf(dest));
|
|
464
|
+
});
|
|
465
|
+
router.post("/download-url", async (c) => {
|
|
466
|
+
const dest = destinationOf(config);
|
|
467
|
+
const body = await c.req.json().catch(() => null);
|
|
468
|
+
const key = typeof body === "object" && body !== null && "key" in body ? body.key : void 0;
|
|
469
|
+
if (typeof key !== "string" || !key) throw ApiError.badRequest("Send the backup's key: { \"key\": \"…\" }.");
|
|
470
|
+
if (!await findBackup(dest, key, storageOf(dest))) throw ApiError.notFound(`Backup not found: ${key}`);
|
|
471
|
+
if (!isJwtConfigured()) throw ApiError.serviceUnavailable("Download links are signed with the JWT secret, and this server has none (auth.jwtSecret). Download through GET /download with the Authorization header instead.");
|
|
472
|
+
const token = await signPurposeToken(BACKUP_DOWNLOAD_PURPOSE, {
|
|
473
|
+
aud: BACKUP_DOWNLOAD_AUDIENCE,
|
|
474
|
+
scope: linkScope(dest, key)
|
|
475
|
+
}, 60);
|
|
476
|
+
const download = new URL("download", c.req.url).pathname;
|
|
477
|
+
return c.json({
|
|
478
|
+
url: `${download}?key=${encodeURIComponent(key)}&token=${encodeURIComponent(token)}`,
|
|
479
|
+
token,
|
|
480
|
+
expiresIn: 60
|
|
481
|
+
});
|
|
482
|
+
});
|
|
483
|
+
return router;
|
|
484
|
+
}
|
|
485
|
+
//#endregion
|
|
486
|
+
//#region src/backup/backup-schedule.ts
|
|
487
|
+
/**
|
|
488
|
+
* The mark `createBackupCron` (in `@rebasepro/server-postgres`) puts on the
|
|
489
|
+
* definition it returns.
|
|
490
|
+
*
|
|
491
|
+
* A registry symbol, so it is the same value across two installed copies of
|
|
492
|
+
* either package, and neither package has to import the other: `server` must
|
|
493
|
+
* not depend on `server-postgres`. The job cannot be recognised any other way —
|
|
494
|
+
* its id is the cron file's name and its display name is configurable.
|
|
495
|
+
*/
|
|
496
|
+
var BACKUP_CRON_MARK = Symbol.for("rebase.backupCron");
|
|
497
|
+
/** Whether a cron definition is the scheduled backup. */
|
|
498
|
+
function isBackupCronDefinition(definition) {
|
|
499
|
+
return Reflect.get(definition, BACKUP_CRON_MARK) === true;
|
|
500
|
+
}
|
|
501
|
+
/**
|
|
502
|
+
* How many recent log entries to look through for the last scheduled run. A
|
|
503
|
+
* skip — a slot that found the previous run still going — is logged as a
|
|
504
|
+
* success with nothing done, and must not stand in for the run it skipped; nor
|
|
505
|
+
* may a run someone started by hand, which is not the schedule's.
|
|
506
|
+
*/
|
|
507
|
+
var RECENT_RUNS = 50;
|
|
508
|
+
/** A log entry as the panel reports a run. */
|
|
509
|
+
function outcomeOf(entry) {
|
|
510
|
+
return {
|
|
511
|
+
startedAt: entry.startedAt,
|
|
512
|
+
finishedAt: entry.finishedAt,
|
|
513
|
+
success: entry.success,
|
|
514
|
+
...entry.success || !entry.error ? {} : { error: entry.error },
|
|
515
|
+
manual: entry.manual === true
|
|
516
|
+
};
|
|
517
|
+
}
|
|
518
|
+
/**
|
|
519
|
+
* The scheduled backup job and its last run, or `null` when the deployment
|
|
520
|
+
* has none — registered or refused.
|
|
521
|
+
*/
|
|
522
|
+
async function readBackupSchedule(source) {
|
|
523
|
+
const [jobId] = source.jobIdsWhere(isBackupCronDefinition);
|
|
524
|
+
if (jobId === void 0) {
|
|
525
|
+
const [refused] = source.rejectedJobsWhere(isBackupCronDefinition);
|
|
526
|
+
return refused ? {
|
|
527
|
+
jobId: refused.id,
|
|
528
|
+
name: refused.name,
|
|
529
|
+
schedule: refused.schedule,
|
|
530
|
+
enabled: false,
|
|
531
|
+
refused: refused.reason
|
|
532
|
+
} : null;
|
|
533
|
+
}
|
|
534
|
+
const job = await source.fetchJob(jobId);
|
|
535
|
+
if (!job) return null;
|
|
536
|
+
const disabledInCode = !job.enabled && source.jobIdsWhere((definition) => isBackupCronDefinition(definition) && definition.enabled === false).includes(jobId);
|
|
537
|
+
let recent;
|
|
538
|
+
let historyError;
|
|
539
|
+
try {
|
|
540
|
+
recent = await source.getJobLogsFromDb(jobId, RECENT_RUNS);
|
|
541
|
+
} catch (err) {
|
|
542
|
+
logger.error(`[backups] Could not read the run history of the backup job "${jobId}"`, { error: err });
|
|
543
|
+
recent = [];
|
|
544
|
+
historyError = "The backup job's run history could not be read. The server log has the reason.";
|
|
545
|
+
}
|
|
546
|
+
const runs = recent.filter((entry) => !isAlreadyExecutingSkip(entry));
|
|
547
|
+
const lastScheduled = runs.find((entry) => entry.manual !== true);
|
|
548
|
+
const lastManual = runs.find((entry) => entry.manual === true);
|
|
549
|
+
const manualSince = lastManual && (!lastScheduled || lastManual.startedAt > lastScheduled.startedAt) ? lastManual : void 0;
|
|
550
|
+
return {
|
|
551
|
+
jobId,
|
|
552
|
+
name: job.name,
|
|
553
|
+
schedule: job.schedule,
|
|
554
|
+
enabled: job.enabled,
|
|
555
|
+
...disabledInCode ? { disabledInCode } : {},
|
|
556
|
+
...job.nextRunAt ? { nextRunAt: job.nextRunAt } : {},
|
|
557
|
+
...historyError ? { historyError } : {},
|
|
558
|
+
...lastScheduled ? { lastRun: outcomeOf(lastScheduled) } : {},
|
|
559
|
+
...manualSince ? { lastManualRun: outcomeOf(manualSince) } : {}
|
|
560
|
+
};
|
|
561
|
+
}
|
|
562
|
+
//#endregion
|
|
563
|
+
//#region src/backup/index.ts
|
|
564
|
+
var backup_exports = /* @__PURE__ */ __exportAll({
|
|
565
|
+
BACKUP_DOWNLOAD_LINK_TTL_SECONDS: () => 60,
|
|
566
|
+
backupDestinationFromEnv: () => backupDestinationFromEnv,
|
|
567
|
+
createBackupLinkRoutes: () => createBackupLinkRoutes,
|
|
568
|
+
createBackupRoutes: () => createBackupRoutes,
|
|
569
|
+
findBackup: () => findBackup,
|
|
570
|
+
listAllObjectKeys: () => listAllObjectKeys,
|
|
571
|
+
listBackupObjects: () => listBackupObjects,
|
|
572
|
+
openBackupStream: () => openBackupStream,
|
|
573
|
+
parseBackupDestination: () => parseBackupDestination,
|
|
574
|
+
parseBackupTimestamp: () => parseBackupTimestamp,
|
|
575
|
+
readBackupBytes: () => readBackupBytes,
|
|
576
|
+
readBackupSchedule: () => readBackupSchedule
|
|
577
|
+
});
|
|
578
|
+
//#endregion
|
|
579
|
+
export { listBackupObjects as a, readBackupBytes as c, backupStorageFromEnv as i, createBackupLinkRoutes as n, parseBackupDestination as o, createBackupRoutes as r, parseBackupTimestamp as s, backup_exports as t };
|
|
580
|
+
|
|
581
|
+
//# sourceMappingURL=backup-5d7XhLX3.js.map
|