@underpostnet/cyberia 3.3.73 → 3.3.77
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/.env.example +17 -5
- package/.github/workflows/coverall.cyberia.ci.yml +10 -0
- package/.github/workflows/docker-image.cyberia-client.ci.yml +1 -1
- package/.github/workflows/docker-image.cyberia-client.dev.ci.yml +1 -1
- package/.github/workflows/docker-image.cyberia-server.ci.yml +1 -1
- package/.github/workflows/docker-image.cyberia-server.dev.ci.yml +1 -1
- package/.github/workflows/docker-image.engine-cyberia.ci.yml +1 -1
- package/.github/workflows/docker-image.engine-cyberia.dev.ci.yml +1 -1
- package/.github/workflows/engine-cyberia.ci.yml +1 -1
- package/.github/workflows/publish.cyberia.ci.yml +44 -0
- package/AGENTS.md +61 -12
- package/CHANGELOG.md +159 -161
- package/CLI-HELP.md +20 -13
- package/Dockerfile +1 -1
- package/Dockerfile.dev +1 -1
- package/Dockerfile.test +2 -2
- package/README.md +28 -28
- package/bin/build.js +96 -21
- package/bin/cyberia.js +1247 -1373
- package/bin/index.js +1247 -1373
- package/compose.env +17 -5
- package/conf.js +7 -4
- package/deploy/cyberia-client/deploy.sh +10 -11
- package/deploy/cyberia-client/package.sh +0 -1
- package/deploy/cyberia-client/state.sh +3 -2
- package/deploy/cyberia-server/deploy.sh +10 -11
- package/deploy/cyberia-server/package.sh +0 -1
- package/deploy/cyberia-server/state.sh +3 -2
- package/deploy/dd-cyberia/deploy.sh +5 -5
- package/deploy/dd-cyberia/init.sh +15 -14
- package/deploy/dd-cyberia/package.sh +0 -1
- package/deploy/dd-cyberia/state.sh +2 -2
- package/deploy/dd-cyberia/sync-deploy.sh +139 -75
- package/deploy/lib/config.sh +29 -0
- package/deploy/lib/github-actions-logging.sh +92 -76
- package/deploy/lib/host.sh +28 -19
- package/deploy/lib/state.sh +4 -1
- package/deploy/pwa-microservices-template/deploy.sh +10 -8
- package/deploy/release/deploy.sh +0 -2
- package/deployment.yaml +1 -1
- package/docker-compose.yml +23 -17
- package/docs/coverage/cyberia/api/cyberia-instance/cyberia-fallback-capture.js.html +1591 -0
- package/docs/coverage/cyberia/api/cyberia-instance/index.html +116 -0
- package/docs/coverage/cyberia/api/cyberia-server-defaults/cyberia-server-defaults.js.html +4138 -0
- package/docs/coverage/cyberia/api/cyberia-server-defaults/index.html +116 -0
- package/docs/coverage/cyberia/api/object-layer/index.html +116 -0
- package/docs/coverage/cyberia/api/object-layer/object-layer.model.js.html +1393 -0
- package/docs/coverage/cyberia/base.css +362 -0
- package/docs/coverage/cyberia/block-navigation.js +82 -0
- package/docs/coverage/cyberia/favicon.png +0 -0
- package/docs/coverage/cyberia/index.html +161 -0
- package/docs/coverage/cyberia/prettify.css +101 -0
- package/docs/coverage/cyberia/prettify.js +937 -0
- package/docs/coverage/cyberia/projects/cyberia/atlas-sprite-sheet-generator.js.html +1315 -0
- package/docs/coverage/cyberia/projects/cyberia/index.html +176 -0
- package/docs/coverage/cyberia/projects/cyberia/instance-backup.js.html +715 -0
- package/docs/coverage/cyberia/projects/cyberia/instance-data.js.html +2182 -0
- package/docs/coverage/cyberia/projects/cyberia/shape-generator.js.html +3265 -0
- package/docs/coverage/cyberia/projects/cyberia/stat-balance.js.html +328 -0
- package/docs/coverage/cyberia/sort-arrow-sprite.png +0 -0
- package/docs/coverage/cyberia/sorter.js +205 -0
- package/hardhat/package-lock.json +2 -2
- package/hardhat/package.json +1 -1
- package/manifests/cronjobs/dd-cron/dd-cron-backup.yaml +1 -1
- package/manifests/cronjobs/dd-cron/dd-cron-vultr.yaml +1 -1
- package/manifests/deployment/dd-cyberia-development/deployment.yaml +1 -1
- package/manifests/deployment/dd-default-development/deployment.yaml +2 -2
- package/manifests/deployment/mongo-express/deployment.yaml +46 -27
- package/manifests/deployment/mongo-express/kustomization.yaml +9 -0
- package/manifests/deployment/mongo-express/mongo-express-nodeport.yaml +18 -0
- package/manifests/deployment/mongo-express/service.yaml +16 -0
- package/manifests/deployment/mongo-express-no-auth/disable-mongodb-auth.yaml +16 -0
- package/manifests/deployment/mongo-express-no-auth/kustomization.yaml +16 -0
- package/package.json +19 -17
- package/scripts/test-monitor.sh +3 -3
- package/src/api/atlas-sprite-sheet/atlas-sprite-sheet.controller.js +14 -9
- package/src/api/atlas-sprite-sheet/atlas-sprite-sheet.model.js +36 -5
- package/src/api/atlas-sprite-sheet/atlas-sprite-sheet.router.js +5 -0
- package/src/api/atlas-sprite-sheet/atlas-sprite-sheet.service.js +88 -184
- package/src/api/cyberia-action/cyberia-action.router.js +2 -2
- package/src/api/cyberia-action/cyberia-action.service.js +3 -4
- package/src/api/cyberia-audio/cyberia-audio.controller.js +6 -0
- package/src/api/cyberia-audio/cyberia-audio.model.js +61 -0
- package/src/api/cyberia-audio/cyberia-audio.router.js +21 -0
- package/src/api/cyberia-audio/cyberia-audio.service.js +220 -0
- package/src/api/cyberia-client-hints/cyberia-client-hints.controller.js +1 -3
- package/src/api/cyberia-client-hints/cyberia-client-hints.model.js +10 -19
- package/src/api/cyberia-client-hints/cyberia-client-hints.router.js +12 -34
- package/src/api/cyberia-client-hints/cyberia-client-hints.service.js +15 -32
- package/src/api/cyberia-dialogue/cyberia-dialogue.model.js +1 -1
- package/src/api/cyberia-dialogue/cyberia-dialogue.router.js +1 -1
- package/src/api/cyberia-entity/cyberia-entity.model.js +2 -0
- package/src/api/cyberia-entity/cyberia-entity.service.js +1 -1
- package/src/api/cyberia-entity-type-default/cyberia-entity-type-default.controller.js +8 -2
- package/src/api/cyberia-entity-type-default/cyberia-entity-type-default.model.js +31 -20
- package/src/api/cyberia-entity-type-default/cyberia-entity-type-default.router.js +16 -1
- package/src/api/cyberia-entity-type-default/cyberia-entity-type-default.service.js +466 -3
- package/src/api/cyberia-instance/cyberia-fallback-capture.js +87 -51
- package/src/api/cyberia-instance/cyberia-fallback-world.js +21 -40
- package/src/api/cyberia-instance/cyberia-instance-boot.service.js +5 -5
- package/src/api/cyberia-instance/cyberia-instance-items.js +98 -0
- package/src/api/cyberia-instance/cyberia-instance-map.service.js +34 -141
- package/src/api/cyberia-instance/cyberia-instance.controller.js +1 -4
- package/src/api/cyberia-instance/cyberia-instance.model.js +0 -14
- package/src/api/cyberia-instance/cyberia-instance.router.js +3 -16
- package/src/api/cyberia-instance/cyberia-instance.service.js +3 -43
- package/src/api/cyberia-instance/cyberia-world-generator.js +8 -13
- package/src/api/cyberia-instance-conf/cyberia-instance-conf.model.js +49 -75
- package/src/api/cyberia-instance-conf/cyberia-instance-conf.service.js +74 -1
- package/src/api/cyberia-map/cyberia-map.service.js +6 -2
- package/src/api/cyberia-map-audio-conf/cyberia-map-audio-conf.controller.js +31 -0
- package/src/api/cyberia-map-audio-conf/cyberia-map-audio-conf.model.js +56 -0
- package/src/api/cyberia-map-audio-conf/cyberia-map-audio-conf.router.js +33 -0
- package/src/api/cyberia-map-audio-conf/cyberia-map-audio-conf.service.js +189 -0
- package/src/api/cyberia-quest/cyberia-quest.router.js +3 -3
- package/src/api/cyberia-quest/cyberia-quest.service.js +3 -3
- package/src/api/cyberia-quest-progress/cyberia-quest-progress.model.js +1 -1
- package/src/api/cyberia-server-defaults/cyberia-server-defaults.js +403 -188
- package/src/api/cyberia-skill/cyberia-skill.model.js +6 -6
- package/src/api/file/file.ref.js +153 -0
- package/src/api/file/file.ref.json +11 -2
- package/src/api/object-layer/object-layer.model.js +47 -12
- package/src/api/object-layer/object-layer.router.js +10 -3
- package/src/api/object-layer/object-layer.service.js +113 -120
- package/src/cli/app.js +12 -1
- package/src/cli/client.js +2 -2
- package/src/cli/cluster.js +80 -18
- package/src/cli/deploy.js +215 -34
- package/src/cli/docker-compose.js +1 -1
- package/src/cli/fs-selection.js +115 -0
- package/src/cli/fs.js +158 -342
- package/src/cli/index.js +22 -10
- package/src/cli/release.js +3 -1
- package/src/cli/repository.js +35 -9
- package/src/cli/run.js +80 -199
- package/src/cli/test.js +4 -0
- package/src/cli/wireguard.js +32 -31
- package/src/client/components/core/Badge.js +0 -1
- package/src/client/components/core/CssCore.js +8 -1
- package/src/client/components/core/Docs.js +77 -100
- package/src/client/components/core/Modal.js +77 -23
- package/src/client/components/core/ToolTip.js +15 -0
- package/src/client/components/cryptokoyn/AppShellCryptokoyn.js +0 -2
- package/src/client/components/cyberia/ActionEngineCyberia.js +12 -36
- package/src/client/components/cyberia/EntityEngineCyberia.js +492 -121
- package/src/client/components/cyberia/InstanceEngineCyberia.js +129 -160
- package/src/client/components/cyberia/InstanceSelectionView.js +7 -1
- package/src/client/components/cyberia/MapEngineCyberia.js +175 -69
- package/src/client/components/cyberia/ObjectLayerEngine.js +1011 -97
- package/src/client/components/cyberia/ObjectLayerEngineModal.js +81 -155
- package/src/client/components/cyberia/ObjectLayerEngineViewer.js +113 -98
- package/src/client/components/cyberia/SharedDefaultsCyberia.js +215 -56
- package/src/client/components/cyberia-portal/AppShellCyberiaPortal.js +0 -34
- package/src/client/components/cyberia-portal/CssCyberiaPortal.js +1 -1
- package/src/client/components/cyberia-portal/MainBodyCyberiaPortal.js +712 -76
- package/src/client/components/cyberia-portal/RouterCyberiaPortal.js +0 -4
- package/src/client/components/cyberia-portal/TranslateCyberiaPortal.js +0 -4
- package/src/client/components/default/AppShellDefault.js +1 -4
- package/src/client/components/itemledger/AppShellItemledger.js +0 -2
- package/src/client/components/underpost/AppShellUnderpost.js +1 -1
- package/src/client/public/cyberia-docs/ARCHITECTURE.md +15 -15
- package/src/client/public/cyberia-docs/CYBERIA-CLI.md +280 -24
- package/src/client/public/cyberia-docs/CYBERIA-CLIENT.md +51 -29
- package/src/client/public/cyberia-docs/CYBERIA-SAGA.md +52 -53
- package/src/client/public/cyberia-docs/CYBERIA-SERVER.md +16 -11
- package/src/client/public/cyberia-docs/CYBERIA.md +28 -28
- package/src/client/public/cyberia-docs/ENTITY-PROFILE.md +11 -5
- package/src/client/public/cyberia-docs/ROADMAP.md +1 -1
- package/src/client/public/cyberia-docs/STATS-PROGRESSION.md +217 -0
- package/src/client/public/cyberia-docs/WHITE-PAPER.md +20 -12
- package/src/client/services/cyberia-audio/cyberia-audio.service.js +99 -0
- package/src/client/services/cyberia-entity-type-default/cyberia-entity-type-default.service.js +38 -0
- package/src/client/services/cyberia-instance/cyberia-instance.management.js +5 -4
- package/src/client/services/cyberia-instance/cyberia-instance.service.js +0 -40
- package/src/client/services/cyberia-map/cyberia-map.management.js +5 -4
- package/src/client/services/cyberia-map-audio-conf/cyberia-map-audio-conf.service.js +138 -0
- package/src/client/services/object-layer/object-layer.management.js +6 -10
- package/src/client/services/object-layer/object-layer.service.js +3 -1
- package/src/client/ssr/views/Cyberia404.js +3 -3
- package/src/client-builder/client-build-docs.js +52 -52
- package/src/client-builder/client-build.js +67 -44
- package/src/client-builder/client-bundle.js +347 -0
- package/src/db/mongo/MongoExpress.js +187 -0
- package/src/grpc/cyberia/grpc-server.js +5 -7
- package/src/index.js +1 -1
- package/src/projects/cyberia/atlas-sprite-sheet-generator.js +143 -146
- package/src/projects/cyberia/atlas-sprite-sheet-store.js +391 -0
- package/src/projects/cyberia/besu-genesis-generator.js +5 -14
- package/src/projects/cyberia/boot-contract-fixtures.js +32 -0
- package/src/projects/cyberia/catalog-cyberia.js +3 -2
- package/src/projects/cyberia/gemini-client.js +3 -3
- package/src/projects/cyberia/generate-saga.js +4 -11
- package/src/projects/cyberia/hot-reload-trigger.js +17 -28
- package/src/projects/cyberia/instance-backup.js +210 -0
- package/src/projects/cyberia/instance-data.js +177 -257
- package/src/projects/cyberia/map-preview-generator.js +32 -68
- package/src/projects/cyberia/object-layer.js +152 -319
- package/src/projects/cyberia/seed-audio.js +139 -0
- package/src/projects/cyberia/semantic-layer-generator-skin.js +2 -8
- package/src/projects/cyberia/semantic-layer-generator.js +2 -18
- package/src/projects/cyberia/stat-balance.js +81 -0
- package/src/projects/cyberia/stat-commands.js +23 -0
- package/src/projects/cyberia/stat-contract-generator.js +101 -0
- package/src/runtime/cyberia-client/Dockerfile +8 -4
- package/src/runtime/cyberia-client/Dockerfile.dev +5 -2
- package/src/runtime/cyberia-server/Dockerfile +9 -2
- package/src/runtime/cyberia-server/Dockerfile.dev +8 -1
- package/src/runtime/engine-cyberia/Dockerfile +1 -1
- package/src/runtime/engine-cyberia/Dockerfile.dev +1 -1
- package/src/runtime/engine-cyberia/Dockerfile.test +2 -2
- package/src/runtime/engine-cyberia/compose.env +17 -5
- package/src/runtime/engine-cyberia/docker-compose.yml +23 -17
- package/src/server/build/coverage.js +113 -42
- package/src/server/build/package.js +55 -12
- package/src/server/build/testing.js +59 -9
- package/src/server/network/middlewares.js +8 -2
- package/src/server/network/underpost-gateway.js +113 -14
- package/src/server/ops/logger.js +70 -35
- package/src/server/runtime/conf.js +40 -9
- package/src/server/runtime/start.js +11 -2
- package/src/server/storage/data-query.js +16 -0
- package/src/server/storage/downloader.js +62 -14
- package/src/server/storage/zip.js +153 -0
- package/test/integration/app/cyberia/atlas-sprite-sheet-store.test.js +402 -0
- package/test/integration/app/cyberia/atlas-sprite-sheet.test.js +86 -0
- package/test/integration/app/cyberia/cyberia-cli-plain-reads.test.js +34 -0
- package/test/integration/app/cyberia/cyberia-entity-type-default.test.js +842 -0
- package/test/integration/app/cyberia/cyberia-instance-conf-coerce.test.js +53 -0
- package/test/integration/app/cyberia/cyberia-instance-conf-defaults.test.js +16 -22
- package/test/integration/app/cyberia/cyberia-instance-items.test.js +90 -0
- package/test/integration/app/cyberia/cyberia-load.test.js +6 -7
- package/test/integration/app/cyberia/cyberia-map-audio-conf.test.js +256 -0
- package/test/integration/app/cyberia/cyberia-stats.test.js +150 -0
- package/test/integration/app/cyberia/fallback-world-capture.test.js +87 -2
- package/test/integration/app/cyberia/object-layer-item-selection.test.js +56 -0
- package/test/integration/app/cyberia/object-layer-natural-key.test.js +32 -0
- package/test/integration/app/cyberia/seed-audio.test.js +275 -0
- package/test/integration/infra/2-network/wireguard-cli.test.js +33 -9
- package/test/integration/infra/2-network/wireguard-edge.test.js +58 -18
- package/test/integration/infra/3-cluster/mongo-express-deploy.test.js +156 -0
- package/test/integration/infra/4-ingress/api-cross-origin.test.js +79 -0
- package/test/integration/infra/4-ingress/underpost-ingress.test.js +1 -1
- package/test/unit/catalog.test.js +13 -2
- package/test/unit/client-build-docs.test.js +97 -70
- package/test/unit/client-bundle.test.js +313 -0
- package/test/unit/conf-resolution.test.js +58 -7
- package/test/unit/coverage-artifact.test.js +120 -29
- package/test/unit/cyberia/instance-backup.test.js +183 -0
- package/test/unit/cyberia/instance-data.test.js +314 -0
- package/test/unit/cyberia/instance-object-layer-items.test.js +46 -0
- package/test/unit/cyberia/publish-workflow.test.js +22 -0
- package/test/unit/cyberia/stat-balance.test.js +69 -0
- package/test/unit/deploy-legacy-gateway-sweep.test.js +63 -0
- package/test/unit/deploy-log-table.test.js +76 -5
- package/test/unit/downloader-integrity.test.js +98 -0
- package/test/unit/file-reference-registry.test.js +141 -0
- package/test/unit/fs-storage-paths.test.js +596 -0
- package/test/unit/gateway-pod-fetch.test.js +95 -0
- package/test/unit/letsencrypt-issuer.test.js +101 -0
- package/test/unit/logger-redaction.test.js +29 -0
- package/test/unit/package.test.js +70 -16
- package/test/unit/prepare-host.test.js +84 -1
- package/test/unit/release-bump.test.js +3 -1
- package/test/unit/start-options.test.js +12 -0
- package/test/unit/test-tiers.test.js +27 -0
- package/test/unit/zip-archive.test.js +142 -0
- package/vitest.config.js +11 -3
- package/src/api/cyberia-instance/cyberia-fallback-default-items.js +0 -63
- package/src/client/components/cyberia/FallbackWorldEngineCyberia.js +0 -368
|
@@ -957,6 +957,104 @@ const placeInstanceStaticAssets = ({ instances, options, label }) => {
|
|
|
957
957
|
return assertStaticAssets({ records, hostRoot, label });
|
|
958
958
|
};
|
|
959
959
|
|
|
960
|
+
/**
|
|
961
|
+
* @method parseRawHttpResponse
|
|
962
|
+
* @description Splits a raw HTTP/1.x response into its status code and body. The body
|
|
963
|
+
* is everything after the first blank line, byte for byte, so it can be hashed against
|
|
964
|
+
* the document on disk.
|
|
965
|
+
* @param {string} raw - Response as read off the socket.
|
|
966
|
+
* @returns {{status: string, body: string}} Status code (empty when no status line) and body.
|
|
967
|
+
* @memberof UnderpostGateway
|
|
968
|
+
*/
|
|
969
|
+
const parseRawHttpResponse = (raw = '') => {
|
|
970
|
+
const status = /^HTTP\/[0-9.]+\s+([0-9]{3})/.exec(raw)?.[1] || '';
|
|
971
|
+
if (!status) return { status: '', body: '' };
|
|
972
|
+
const separator = raw.search(/\r?\n\r?\n/);
|
|
973
|
+
if (separator < 0) return { status, body: '' };
|
|
974
|
+
return { status, body: raw.slice(separator).replace(/^\r?\n\r?\n/, '') };
|
|
975
|
+
};
|
|
976
|
+
|
|
977
|
+
/**
|
|
978
|
+
* @method gatewayPodFetch
|
|
979
|
+
* @description One request against the gateway from inside its own pod, returning the
|
|
980
|
+
* status and the body even when the status is an error.
|
|
981
|
+
*
|
|
982
|
+
* Written on the socket with `nc` rather than fetched with `wget`: the image ships
|
|
983
|
+
* busybox, whose `wget` discards the body of any non-2xx response and exits — so a
|
|
984
|
+
* fallback document, which is by definition delivered with an upstream-failure status,
|
|
985
|
+
* could never be read through it. The probe that did so failed every attempt on a
|
|
986
|
+
* correct edge and looked like a hang. HTTP/1.0 keeps the reply unchunked, so the body
|
|
987
|
+
* is the document verbatim. Where `nc` is absent the `wget` pair still yields the status.
|
|
988
|
+
* @param {object} params
|
|
989
|
+
* @param {string} params.namespace - Namespace the gateway runs in.
|
|
990
|
+
* @param {string} params.host - Host header to present.
|
|
991
|
+
* @param {string} [params.path] - Request path.
|
|
992
|
+
* @returns {{status: string, body: string}} What the gateway answered.
|
|
993
|
+
* @memberof UnderpostGateway
|
|
994
|
+
*/
|
|
995
|
+
const gatewayPodFetch = ({ namespace, host, path = '/' }) => {
|
|
996
|
+
const exec = (script) =>
|
|
997
|
+
`${
|
|
998
|
+
shellExec(`kubectl exec -n ${namespace} deploy/${UNDERPOST_GATEWAY.name} -- sh -c "${script}"`, {
|
|
999
|
+
stdout: true,
|
|
1000
|
+
silent: true,
|
|
1001
|
+
silentOnError: true,
|
|
1002
|
+
disableLog: true,
|
|
1003
|
+
}) ?? ''
|
|
1004
|
+
}`;
|
|
1005
|
+
const request = path || '/';
|
|
1006
|
+
const raw = exec(
|
|
1007
|
+
`printf 'GET ${request} HTTP/1.0\\r\\nHost: ${host}\\r\\n\\r\\n' | nc -w 10 127.0.0.1 ${UNDERPOST_GATEWAY.port} 2>/dev/null || true`,
|
|
1008
|
+
);
|
|
1009
|
+
const parsed = parseRawHttpResponse(raw);
|
|
1010
|
+
if (parsed.status) return parsed;
|
|
1011
|
+
const url = `http://127.0.0.1${request}`;
|
|
1012
|
+
const headers = exec(`wget -S -O /dev/null -T 10 --header 'Host: ${host}' ${url} 2>&1 || true`);
|
|
1013
|
+
const status = [...headers.matchAll(/HTTP\/[0-9.]+\s+([0-9]{3})/g)].pop()?.[1] || '';
|
|
1014
|
+
const body = exec(`wget -q -O - -T 10 --header 'Host: ${host}' ${url} 2>/dev/null || true`);
|
|
1015
|
+
return { status, body };
|
|
1016
|
+
};
|
|
1017
|
+
|
|
1018
|
+
/**
|
|
1019
|
+
* @method gatewayFetch
|
|
1020
|
+
* @description One request against the gateway, returning the status and the body
|
|
1021
|
+
* even when the status is an error — the shape every fallback probe asserts on.
|
|
1022
|
+
*
|
|
1023
|
+
* Sent from this host with `curl` to the gateway Service first: the sync runs on the
|
|
1024
|
+
* node that holds the gateway's volume, so the ClusterIP is reachable, and curl keeps
|
|
1025
|
+
* the body of an error response. Only when that yields no status line does it fall back
|
|
1026
|
+
* to {@link UnderpostGateway.gatewayPodFetch}, which needs nothing on the host at all.
|
|
1027
|
+
* @param {object} params
|
|
1028
|
+
* @param {string} params.namespace - Namespace the gateway runs in.
|
|
1029
|
+
* @param {string} params.host - Host header to present.
|
|
1030
|
+
* @param {string} [params.path] - Request path.
|
|
1031
|
+
* @returns {{status: string, body: string, via: string}} What the gateway answered, and which way.
|
|
1032
|
+
* @memberof UnderpostGateway
|
|
1033
|
+
*/
|
|
1034
|
+
const gatewayFetch = ({ namespace, host, path = '/' }) => {
|
|
1035
|
+
const clusterIp = `${
|
|
1036
|
+
shellExec(`kubectl get svc ${UNDERPOST_GATEWAY.serviceName} -n ${namespace} -o jsonpath='{.spec.clusterIP}'`, {
|
|
1037
|
+
stdout: true,
|
|
1038
|
+
silent: true,
|
|
1039
|
+
silentOnError: true,
|
|
1040
|
+
disableLog: true,
|
|
1041
|
+
}) ?? ''
|
|
1042
|
+
}`.trim();
|
|
1043
|
+
if (/^[0-9a-f.:]+$/i.test(clusterIp)) {
|
|
1044
|
+
const raw = `${
|
|
1045
|
+
shellExec(
|
|
1046
|
+
`curl -sS -i --noproxy '*' --max-time 10 -H 'Host: ${host}' http://${clusterIp}:${UNDERPOST_GATEWAY.port}${
|
|
1047
|
+
path || '/'
|
|
1048
|
+
} 2>/dev/null || true`,
|
|
1049
|
+
{ stdout: true, silent: true, silentOnError: true, disableLog: true },
|
|
1050
|
+
) ?? ''
|
|
1051
|
+
}`;
|
|
1052
|
+
const parsed = parseRawHttpResponse(raw);
|
|
1053
|
+
if (parsed.status) return { ...parsed, via: 'host' };
|
|
1054
|
+
}
|
|
1055
|
+
return { ...gatewayPodFetch({ namespace, host, path }), via: 'pod' };
|
|
1056
|
+
};
|
|
1057
|
+
|
|
960
1058
|
/**
|
|
961
1059
|
* @method gatewayFallbackProbeRunner
|
|
962
1060
|
* @description Proves the edge answers each configured fallback with the exact
|
|
@@ -1018,26 +1116,24 @@ const gatewayFallbackProbeRunner = async ({ checks, options, label, gatewayStatu
|
|
|
1018
1116
|
silent: true,
|
|
1019
1117
|
silentOnError: true,
|
|
1020
1118
|
}).trim();
|
|
1021
|
-
} else {
|
|
1022
|
-
const request = `http://127.0.0.1${check.path || '/'}`;
|
|
1023
|
-
body = shellExec(
|
|
1024
|
-
`kubectl exec -n ${namespace} deploy/${UNDERPOST_GATEWAY.name} -- sh -c ` +
|
|
1025
|
-
`"wget -q -O - -T 10 --header 'Host: ${check.host}' ${request} 2>/dev/null || true"`,
|
|
1026
|
-
{ stdout: true, silent: true, silentOnError: true },
|
|
1027
|
-
);
|
|
1028
|
-
const headers = shellExec(
|
|
1029
|
-
`kubectl exec -n ${namespace} deploy/${UNDERPOST_GATEWAY.name} -- sh -c ` +
|
|
1030
|
-
`"wget -S -O /dev/null -T 10 --header 'Host: ${check.host}' ${request} 2>&1 || true"`,
|
|
1031
|
-
{ stdout: true, silent: true, silentOnError: true },
|
|
1032
|
-
);
|
|
1033
|
-
status = [...headers.matchAll(/HTTP\/[0-9.]+\s+([0-9]{3})/g)].pop()?.[1] || '';
|
|
1034
|
-
}
|
|
1119
|
+
} else ({ status, body } = gatewayFetch({ namespace, host: check.host, path: check.path }));
|
|
1035
1120
|
actualHash = crypto
|
|
1036
1121
|
.createHash('sha256')
|
|
1037
1122
|
.update(body || '')
|
|
1038
1123
|
.digest('hex');
|
|
1039
1124
|
passed = /^50[234]$/.test(status) && !!expectedHash && actualHash === expectedHash;
|
|
1040
1125
|
if (passed) break;
|
|
1126
|
+
// The first miss is reported as it happens: a probe that polls for minutes with
|
|
1127
|
+
// nothing on the console reads as a hang, and what it saw is the whole diagnosis.
|
|
1128
|
+
if (attempts === 1)
|
|
1129
|
+
logger.warn(`[${label}] Fallback probe not yet satisfied; polling`, {
|
|
1130
|
+
host: check.host,
|
|
1131
|
+
path: check.path || '/',
|
|
1132
|
+
status: status || '(none)',
|
|
1133
|
+
bodyBytes: Buffer.byteLength(body || ''),
|
|
1134
|
+
bodyMatchesConfiguredAsset: actualHash === expectedHash,
|
|
1135
|
+
expectedAsset: expectedHash ? check.assetPath : '(missing on disk)',
|
|
1136
|
+
});
|
|
1041
1137
|
if (attempts < 30) await timer(2000);
|
|
1042
1138
|
}
|
|
1043
1139
|
const result = {
|
|
@@ -1059,6 +1155,8 @@ export {
|
|
|
1059
1155
|
UNDERPOST_GATEWAY,
|
|
1060
1156
|
assertStaticAssets,
|
|
1061
1157
|
gatewayFallbackProbeRunner,
|
|
1158
|
+
gatewayFetch,
|
|
1159
|
+
gatewayPodFetch,
|
|
1062
1160
|
gatewayStaticAssetExists,
|
|
1063
1161
|
hostInstanceRegistryPathFactory,
|
|
1064
1162
|
hostServerConfFactory,
|
|
@@ -1071,6 +1169,7 @@ export {
|
|
|
1071
1169
|
kubernetesUpstreamFactory,
|
|
1072
1170
|
underpostGatewayManifestsFactory,
|
|
1073
1171
|
nginxConfFactory,
|
|
1172
|
+
parseRawHttpResponse,
|
|
1074
1173
|
seedDefaultStatusPage,
|
|
1075
1174
|
staticLocationFactory,
|
|
1076
1175
|
staticPathSegmentFactory,
|
package/src/server/ops/logger.js
CHANGED
|
@@ -48,6 +48,13 @@ const SENSITIVE_WORDS = new Set([
|
|
|
48
48
|
'token',
|
|
49
49
|
]);
|
|
50
50
|
const SENSITIVE_COMPOUNDS = ['apikey', 'privatekey', 'accesskey', 'sessionid', 'mysqlpwd', 'mariadbpwd'];
|
|
51
|
+
/**
|
|
52
|
+
* A value adjacent to a sensitive name, separated by nothing but whitespace, is only treated as
|
|
53
|
+
* the secret when it is shaped like one: long enough, and carrying a digit, a capital, or a
|
|
54
|
+
* separator. Prose reads the same way — `token verification failed` — and redacting the next
|
|
55
|
+
* word of every sentence would cost more than it protects.
|
|
56
|
+
*/
|
|
57
|
+
const CREDENTIAL_SHAPED = /^(?=.{12,}$)(?=.*[0-9A-Z_-])[A-Za-z0-9_-]+$/;
|
|
51
58
|
const sensitiveField = (key) => {
|
|
52
59
|
const words = `${key || ''}`
|
|
53
60
|
.replace(/([a-z0-9])([A-Z])/g, '$1_$2')
|
|
@@ -61,43 +68,71 @@ const sensitiveField = (key) => {
|
|
|
61
68
|
);
|
|
62
69
|
};
|
|
63
70
|
|
|
71
|
+
/**
|
|
72
|
+
* Redacts a value that follows a sensitive name with nothing but whitespace between them.
|
|
73
|
+
*
|
|
74
|
+
* Service errors report credentials this way — `Invalid api_key 4778…` — so the pair reaches a
|
|
75
|
+
* log through a message rather than through a command or a structured field, which is what every
|
|
76
|
+
* other rule matches on. The candidate is looked ahead rather than consumed: a plain match would
|
|
77
|
+
* take `Invalid api_key` as its own name/value pair and advance past the real one behind it.
|
|
78
|
+
* @param {string} text - Text to scan.
|
|
79
|
+
* @returns {string} The text with adjacent credentials redacted.
|
|
80
|
+
* @memberof Logger
|
|
81
|
+
*/
|
|
82
|
+
const redactAdjacentCredentials = (text) => {
|
|
83
|
+
const pairs = new RegExp(`(${CONFIG_NAME_SOURCE})([ \\t]+)(?=([A-Za-z0-9_-]+)(?![\\w-]))`, 'g');
|
|
84
|
+
let out = '';
|
|
85
|
+
let cursor = 0;
|
|
86
|
+
for (const match of text.matchAll(pairs)) {
|
|
87
|
+
const [, key, separator, candidate] = match;
|
|
88
|
+
const valueStart = match.index + key.length + separator.length;
|
|
89
|
+
if (valueStart < cursor) continue;
|
|
90
|
+
if (!sensitiveField(key) || !CREDENTIAL_SHAPED.test(candidate)) continue;
|
|
91
|
+
out += text.slice(cursor, valueStart) + REDACTED;
|
|
92
|
+
cursor = valueStart + candidate.length;
|
|
93
|
+
}
|
|
94
|
+
return cursor === 0 ? text : out + text.slice(cursor);
|
|
95
|
+
};
|
|
96
|
+
|
|
64
97
|
const redactSensitiveText = (value = '') =>
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
98
|
+
redactAdjacentCredentials(
|
|
99
|
+
`${value ?? ''}`
|
|
100
|
+
.replace(/-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z0-9 ]*PRIVATE KEY-----/g, REDACTED)
|
|
101
|
+
.replace(
|
|
102
|
+
/\b(?:gh[pousr]_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,}|glpat-[A-Za-z0-9_-]{20,}|xox[baprs]-[A-Za-z0-9-]{10,}|eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+)\b/g,
|
|
103
|
+
REDACTED,
|
|
104
|
+
)
|
|
105
|
+
.replace(/([a-z][a-z0-9+.-]*\\?:(?:\\?\/){2})(?:(?:\\.)|[^/\\\s@])+(\\?@)/gi, '$1***$2')
|
|
106
|
+
.replace(/\b(x-access-token\\?:)[^\s"']+?(?=\\?@github\.com\b)/gi, '$1***')
|
|
107
|
+
.replace(
|
|
108
|
+
/((?:"|')?(?:authorization|proxy-authorization)(?:"|')?\s*:\s*)(?:"[^"]*"|'[^']*'|(?:bearer|basic)\s+\S+|\S+)/gi,
|
|
109
|
+
`$1"${REDACTED}"`,
|
|
110
|
+
)
|
|
111
|
+
.replace(
|
|
112
|
+
new RegExp(
|
|
113
|
+
`(\\b(?:host|app|state|secret)\\s+set\\s+)(${CONFIG_NAME_SOURCE})(\\s+)(${SENSITIVE_VALUE_SOURCE})`,
|
|
114
|
+
'gi',
|
|
115
|
+
),
|
|
116
|
+
(match, prefix, key, separator) => (sensitiveField(key) ? `${prefix}${key}${separator}${REDACTED}` : match),
|
|
117
|
+
)
|
|
118
|
+
.replace(
|
|
119
|
+
new RegExp(`(\\s--?)(${CONFIG_NAME_SOURCE})(=|\\s+)(${SENSITIVE_VALUE_SOURCE})`, 'gi'),
|
|
120
|
+
(match, prefix, key, separator) => (sensitiveField(key) ? `${prefix}${key}${separator}${REDACTED}` : match),
|
|
121
|
+
)
|
|
122
|
+
.replace(
|
|
123
|
+
new RegExp(`(^|\\s)(-p)(\\s*)(${SENSITIVE_VALUE_SOURCE})`, 'gi'),
|
|
124
|
+
(match, prefix, flag, separator, secret) =>
|
|
125
|
+
secret.startsWith('-') ? match : `${prefix}${flag}${separator}${REDACTED}`,
|
|
126
|
+
)
|
|
127
|
+
.replace(
|
|
128
|
+
new RegExp(`\\b(${CONFIG_NAME_SOURCE})(\\s*=\\s*)(${SENSITIVE_VALUE_SOURCE})`, 'gi'),
|
|
129
|
+
(match, key, separator) => (sensitiveField(key) ? `${key}${separator}${REDACTED}` : match),
|
|
130
|
+
)
|
|
131
|
+
.replace(
|
|
132
|
+
new RegExp(`((?:"|')?)(${CONFIG_NAME_SOURCE})((?:"|')?\\s*:\\s*)(${SENSITIVE_VALUE_SOURCE})`, 'gi'),
|
|
133
|
+
(match, quote, key, separator) => (sensitiveField(key) ? `${quote}${key}${separator}"${REDACTED}"` : match),
|
|
81
134
|
),
|
|
82
|
-
|
|
83
|
-
)
|
|
84
|
-
.replace(
|
|
85
|
-
new RegExp(`(\\s--?)(${CONFIG_NAME_SOURCE})(=|\\s+)(${SENSITIVE_VALUE_SOURCE})`, 'gi'),
|
|
86
|
-
(match, prefix, key, separator) => (sensitiveField(key) ? `${prefix}${key}${separator}${REDACTED}` : match),
|
|
87
|
-
)
|
|
88
|
-
.replace(
|
|
89
|
-
new RegExp(`(^|\\s)(-p)(\\s*)(${SENSITIVE_VALUE_SOURCE})`, 'gi'),
|
|
90
|
-
(match, prefix, flag, separator, secret) =>
|
|
91
|
-
secret.startsWith('-') ? match : `${prefix}${flag}${separator}${REDACTED}`,
|
|
92
|
-
)
|
|
93
|
-
.replace(
|
|
94
|
-
new RegExp(`\\b(${CONFIG_NAME_SOURCE})(\\s*=\\s*)(${SENSITIVE_VALUE_SOURCE})`, 'gi'),
|
|
95
|
-
(match, key, separator) => (sensitiveField(key) ? `${key}${separator}${REDACTED}` : match),
|
|
96
|
-
)
|
|
97
|
-
.replace(
|
|
98
|
-
new RegExp(`((?:"|')?)(${CONFIG_NAME_SOURCE})((?:"|')?\\s*:\\s*)(${SENSITIVE_VALUE_SOURCE})`, 'gi'),
|
|
99
|
-
(match, quote, key, separator) => (sensitiveField(key) ? `${quote}${key}${separator}"${REDACTED}"` : match),
|
|
100
|
-
);
|
|
135
|
+
);
|
|
101
136
|
|
|
102
137
|
const serializeLogValue = (value) => {
|
|
103
138
|
const seen = new WeakSet();
|
|
@@ -2986,8 +2986,10 @@ const syncPrivateConf = (deployId, extraPaths = []) => {
|
|
|
2986
2986
|
if (fs.existsSync(replicaSrcDir))
|
|
2987
2987
|
for (const entry of fs.readdirSync(replicaSrcDir))
|
|
2988
2988
|
if (entry.match(deployId)) fs.copySync(`${replicaSrcDir}/${entry}`, `${privateRepoPath}/replica/${entry}`);
|
|
2989
|
-
|
|
2990
|
-
|
|
2989
|
+
for (const extraPath of extraPaths) {
|
|
2990
|
+
fs.removeSync(`${privateRepoPath}/${extraPath}`);
|
|
2991
|
+
fs.copySync(`./engine-private/${extraPath}`, `${privateRepoPath}/${extraPath}`);
|
|
2992
|
+
}
|
|
2991
2993
|
|
|
2992
2994
|
shellExec(
|
|
2993
2995
|
`cd ${privateRepoPath}` +
|
|
@@ -2999,14 +3001,41 @@ const syncPrivateConf = (deployId, extraPaths = []) => {
|
|
|
2999
3001
|
};
|
|
3000
3002
|
|
|
3001
3003
|
/**
|
|
3002
|
-
*
|
|
3003
|
-
*
|
|
3004
|
-
*
|
|
3005
|
-
*
|
|
3006
|
-
*
|
|
3004
|
+
* Mirrors a deploy's public template sources from the engine working tree to the source
|
|
3005
|
+
* repo they came from. The engine ignores every one of these paths, so an edit made there
|
|
3006
|
+
* never reaches a diff; the source repo is where it is reviewed and committed. Each path
|
|
3007
|
+
* the engine holds is replaced wholesale there (removed, then copied) so the source repo's
|
|
3008
|
+
* `git status` reads as exactly the engine tree's state, deletions included. The engine
|
|
3009
|
+
* copies stay in place: this is a copy, not a move. A path the engine tree does not hold
|
|
3010
|
+
* is left untouched in the source repo rather than deleted.
|
|
3011
|
+
*
|
|
3012
|
+
* @method syncDeployIdSourcesBack
|
|
3013
|
+
* @param {Array<[string, string]>} [sourceMoves=[]] - Public `[src, dest]` pairs.
|
|
3014
|
+
* @returns {string[]} The source paths that were mirrored.
|
|
3015
|
+
* @memberof ServerConfBuilder
|
|
3016
|
+
*/
|
|
3017
|
+
const syncDeployIdSourcesBack = (sourceMoves = []) => {
|
|
3018
|
+
const mirrored = [];
|
|
3019
|
+
for (const [src, dest] of sourceMoves) {
|
|
3020
|
+
if (!fs.existsSync(dest)) continue;
|
|
3021
|
+
fs.removeSync(src);
|
|
3022
|
+
fs.copySync(dest, src);
|
|
3023
|
+
mirrored.push(src);
|
|
3024
|
+
}
|
|
3025
|
+
return mirrored;
|
|
3026
|
+
};
|
|
3027
|
+
|
|
3028
|
+
/**
|
|
3029
|
+
* Brings a deploy's public template sources into line ahead of the build copy step. The
|
|
3030
|
+
* engine working tree is the working copy and the source repo its git view, so a path the
|
|
3031
|
+
* engine already holds is mirrored out to the repo ({@link syncDeployIdSourcesBack}) and
|
|
3032
|
+
* only a path the engine lacks is copied in. Nothing is moved: the repo's working tree
|
|
3033
|
+
* keeps every file, so a build never leaves it reading as wholesale deletions, and an
|
|
3034
|
+
* engine-side edit is never overwritten by the repo's copy. Safe to rerun: each step is
|
|
3035
|
+
* guarded by `existsSync`.
|
|
3007
3036
|
*
|
|
3008
3037
|
* @method syncDeployIdSources
|
|
3009
|
-
* @param {Array<[string, string]>} [sourceMoves=[]] - Public `[src, dest]`
|
|
3038
|
+
* @param {Array<[string, string]>} [sourceMoves=[]] - Public `[src, dest]` pairs.
|
|
3010
3039
|
* @returns {boolean} `true` when any sources were declared, else `false`.
|
|
3011
3040
|
* @memberof ServerConfBuilder
|
|
3012
3041
|
*/
|
|
@@ -3014,7 +3043,8 @@ const syncDeployIdSources = (sourceMoves = []) => {
|
|
|
3014
3043
|
if (!sourceMoves.length) return false;
|
|
3015
3044
|
for (const dir of ['src/api', 'src/client/components', 'src/client/public', 'src/client/services'])
|
|
3016
3045
|
fs.mkdirSync(dir, { recursive: true });
|
|
3017
|
-
|
|
3046
|
+
syncDeployIdSourcesBack(sourceMoves);
|
|
3047
|
+
for (const [src, dest] of sourceMoves) if (!fs.existsSync(dest) && fs.existsSync(src)) fs.copySync(src, dest);
|
|
3018
3048
|
return true;
|
|
3019
3049
|
};
|
|
3020
3050
|
|
|
@@ -3446,6 +3476,7 @@ export {
|
|
|
3446
3476
|
waitForPort,
|
|
3447
3477
|
syncPrivateConf,
|
|
3448
3478
|
syncDeployIdSources,
|
|
3479
|
+
syncDeployIdSourcesBack,
|
|
3449
3480
|
gitOriginRepositoryName,
|
|
3450
3481
|
ensureTemplateCheckout,
|
|
3451
3482
|
pruneTemplateWorkTree,
|
|
@@ -333,10 +333,19 @@ class UnderpostStartUp {
|
|
|
333
333
|
const result = await awaitDeployMonitor(true);
|
|
334
334
|
if (result === true) {
|
|
335
335
|
// Withdraw every domain's local traces once the deployment is serving. Three calls
|
|
336
|
-
// rather than one cross-domain sweep: each domain owns what it put on disk.
|
|
336
|
+
// rather than one cross-domain sweep: each domain owns what it put on disk. Only the
|
|
337
|
+
// host domain is forced: its `--force` removes the ephemeral `engine-private` clone,
|
|
338
|
+
// which must not outlive the boot inside a container. For the other two, `--force`
|
|
339
|
+
// reaches into the cluster (the app Secret, secret purges) and is never wanted here.
|
|
337
340
|
if (env === 'production' && Underpost.state.isInsideContainer())
|
|
338
341
|
for (const domain of [Underpost.secret, Underpost.host, Underpost.app])
|
|
339
|
-
domain.clean({
|
|
342
|
+
domain.clean({
|
|
343
|
+
env,
|
|
344
|
+
namespace: 'default',
|
|
345
|
+
args: {},
|
|
346
|
+
dryRun: false,
|
|
347
|
+
force: domain === Underpost.host,
|
|
348
|
+
});
|
|
340
349
|
setTimeout(() => {
|
|
341
350
|
setRuntimeStatus(deployId, env, RUNTIME_STATUS.RUNNING);
|
|
342
351
|
setStartContainerStatus(deployId, env);
|
|
@@ -15,6 +15,22 @@
|
|
|
15
15
|
* @memberof DataQuery
|
|
16
16
|
*/
|
|
17
17
|
class DataQuery {
|
|
18
|
+
/**
|
|
19
|
+
* Filter that addresses one document by its natural key.
|
|
20
|
+
*
|
|
21
|
+
* The natural key is the value people navigate by: an object layer item id, an
|
|
22
|
+
* instance code. A key shaped like a document id matches either field, so a
|
|
23
|
+
* document id still resolves.
|
|
24
|
+
*
|
|
25
|
+
* @param {string} field - Path of the natural key, e.g. 'data.item.id'.
|
|
26
|
+
* @param {string} key - The key taken from the request.
|
|
27
|
+
* @memberof DataQuery
|
|
28
|
+
* @returns {Object} A Mongoose filter selecting at most one document.
|
|
29
|
+
*/
|
|
30
|
+
static naturalKeyFilter(field, key) {
|
|
31
|
+
return /^[0-9a-f]{24}$/i.test(String(key)) ? { $or: [{ [field]: key }, { _id: key }] } : { [field]: key };
|
|
32
|
+
}
|
|
33
|
+
|
|
18
34
|
/**
|
|
19
35
|
* Parse request query parameters into Mongoose query options
|
|
20
36
|
* @param {Object} params - The request query parameters (req.query)
|
|
@@ -31,31 +31,79 @@ class Downloader {
|
|
|
31
31
|
* @memberof Downloader
|
|
32
32
|
*/
|
|
33
33
|
static downloadFile(url, fullPath, options = { method: 'get', responseType: 'stream' }) {
|
|
34
|
-
|
|
34
|
+
/**
|
|
35
|
+
* Renders an error response body, whatever transport shape it arrived in, capped for a log
|
|
36
|
+
* line. A stream request carries even its errors as a stream, so the body has to be read
|
|
37
|
+
* before it can be reported.
|
|
38
|
+
*/
|
|
39
|
+
const responseBody = async (data) => {
|
|
40
|
+
if (!data) return undefined;
|
|
41
|
+
try {
|
|
42
|
+
if (Buffer.isBuffer(data)) return data.toString('utf8').slice(0, 500);
|
|
43
|
+
if (typeof data === 'string') return data.slice(0, 500);
|
|
44
|
+
if (typeof data.on === 'function') {
|
|
45
|
+
let text = '';
|
|
46
|
+
for await (const chunk of data) {
|
|
47
|
+
text += chunk;
|
|
48
|
+
if (text.length > 500) break;
|
|
49
|
+
}
|
|
50
|
+
return text.slice(0, 500) || undefined;
|
|
51
|
+
}
|
|
52
|
+
return JSON.stringify(data).slice(0, 500);
|
|
53
|
+
} catch {
|
|
54
|
+
return undefined;
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
return new Promise((resolve, reject) => {
|
|
59
|
+
let settled = false;
|
|
60
|
+
const fail = (error) => {
|
|
61
|
+
if (settled) return;
|
|
62
|
+
settled = true;
|
|
63
|
+
logger.error('Error downloading the file', { fullPath, error: error?.message });
|
|
64
|
+
// A partial file is worse than none: the caller cannot tell it apart from a whole one.
|
|
65
|
+
if (fs.existsSync(fullPath)) fs.unlinkSync(fullPath);
|
|
66
|
+
return reject(error);
|
|
67
|
+
};
|
|
68
|
+
|
|
35
69
|
axios({
|
|
36
70
|
url,
|
|
37
71
|
...options,
|
|
38
72
|
})
|
|
39
73
|
.then((response) => {
|
|
40
|
-
// Create a write stream to save the file to the specified path
|
|
41
74
|
const writer = fs.createWriteStream(fullPath);
|
|
42
|
-
response.
|
|
75
|
+
const expectedBytes = Number(response.headers?.['content-length'] ?? NaN);
|
|
76
|
+
|
|
77
|
+
// A response stream that ends early still ends, so the writer emits `finish` and the
|
|
78
|
+
// download reports success over a truncated file. The declared length is what proves
|
|
79
|
+
// the transfer whole — without this check a short part reached the caller intact-looking
|
|
80
|
+
// and only surfaced much later, as a corrupt archive assembled from it.
|
|
43
81
|
writer.on('finish', () => {
|
|
44
|
-
|
|
82
|
+
if (settled) return;
|
|
83
|
+
const receivedBytes = writer.bytesWritten;
|
|
84
|
+
if (Number.isFinite(expectedBytes) && receivedBytes !== expectedBytes)
|
|
85
|
+
return fail(new Error(`Truncated download: expected ${expectedBytes} bytes, received ${receivedBytes}`));
|
|
86
|
+
settled = true;
|
|
87
|
+
logger.info('Download complete', { fullPath, bytes: receivedBytes });
|
|
45
88
|
return resolve(fullPath);
|
|
46
89
|
});
|
|
47
|
-
writer.on('error',
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
if (fs.existsSync(fullPath)) fs.unlinkSync(fullPath);
|
|
51
|
-
return reject(error);
|
|
52
|
-
});
|
|
90
|
+
writer.on('error', fail);
|
|
91
|
+
response.data.on('error', fail);
|
|
92
|
+
response.data.pipe(writer);
|
|
53
93
|
})
|
|
54
|
-
.catch((error) => {
|
|
55
|
-
|
|
94
|
+
.catch(async (error) => {
|
|
95
|
+
// The status alone names nothing. A storage service explains a refusal in the body —
|
|
96
|
+
// which delivery type it looked under, which id it could not find — and without it a
|
|
97
|
+
// caller sees `status code 400` and has to reproduce the request by hand to learn why.
|
|
98
|
+
logger.error('Error in the request', {
|
|
99
|
+
fullPath,
|
|
100
|
+
error: error?.message,
|
|
101
|
+
status: error?.response?.status,
|
|
102
|
+
body: await responseBody(error?.response?.data),
|
|
103
|
+
});
|
|
56
104
|
return reject(error);
|
|
57
|
-
})
|
|
58
|
-
);
|
|
105
|
+
});
|
|
106
|
+
});
|
|
59
107
|
}
|
|
60
108
|
}
|
|
61
109
|
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ZIP archive helpers built on JSZip, shared by the file-storage CLI and the client builder.
|
|
3
|
+
* @module src/server/storage/zip.js
|
|
4
|
+
* @namespace Zip
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import JSZip from 'jszip';
|
|
8
|
+
import * as dir from 'path';
|
|
9
|
+
import fs from 'fs-extra';
|
|
10
|
+
|
|
11
|
+
const ZIP_LOCAL_FILE_HEADER = Buffer.from([0x50, 0x4b, 0x03, 0x04]);
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Reports whether a buffer starts with the ZIP local file header signature.
|
|
15
|
+
* @function isZipBuffer
|
|
16
|
+
* @param {Buffer} buffer - Candidate archive bytes.
|
|
17
|
+
* @returns {boolean} True when the buffer looks like a ZIP archive.
|
|
18
|
+
* @memberof Zip
|
|
19
|
+
*/
|
|
20
|
+
const isZipBuffer = (buffer) => Buffer.isBuffer(buffer) && buffer.subarray(0, 4).equals(ZIP_LOCAL_FILE_HEADER);
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Loads a ZIP archive from a buffer or a local path.
|
|
24
|
+
* @function loadZip
|
|
25
|
+
* @param {Buffer|string} source - Archive bytes or a path to the archive.
|
|
26
|
+
* @returns {Promise<JSZip>} The loaded archive.
|
|
27
|
+
* @memberof Zip
|
|
28
|
+
*/
|
|
29
|
+
const loadZip = async (source) => JSZip.loadAsync(Buffer.isBuffer(source) ? source : fs.readFileSync(source));
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Resolves an archive entry by exact name, then by trailing path segment, then by
|
|
33
|
+
* falling back to the only entry of a single-file archive.
|
|
34
|
+
* @function findZipEntry
|
|
35
|
+
* @param {JSZip} zip - The loaded archive.
|
|
36
|
+
* @param {string} entryName - Entry name or basename to look up.
|
|
37
|
+
* @returns {object|null} The matching JSZip entry, or null when nothing matches.
|
|
38
|
+
* @memberof Zip
|
|
39
|
+
*/
|
|
40
|
+
const findZipEntry = (zip, entryName) => {
|
|
41
|
+
const files = Object.values(zip.files).filter((entry) => !entry.dir);
|
|
42
|
+
return (
|
|
43
|
+
files.find((entry) => entry.name === entryName) ??
|
|
44
|
+
files.find((entry) => entry.name.endsWith(`/${entryName}`)) ??
|
|
45
|
+
(files.length === 1 ? files[0] : null)
|
|
46
|
+
);
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Reads a single entry out of an archive.
|
|
51
|
+
* @function readZipEntry
|
|
52
|
+
* @param {Buffer|string} source - Archive bytes or a path to the archive.
|
|
53
|
+
* @param {string} entryName - Entry name or basename to read.
|
|
54
|
+
* @returns {Promise<Buffer|null>} The entry contents, or null when the entry is absent.
|
|
55
|
+
* @memberof Zip
|
|
56
|
+
*/
|
|
57
|
+
const readZipEntry = async (source, entryName) => {
|
|
58
|
+
const entry = findZipEntry(await loadZip(source), entryName);
|
|
59
|
+
return entry ? await entry.async('nodebuffer') : null;
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Builds an archive buffer from local files.
|
|
64
|
+
* @function zipFromLocalFiles
|
|
65
|
+
* @param {Array<{localPath: string, entryName: string}>} entries - Files to archive.
|
|
66
|
+
* @returns {Promise<Buffer>} The generated archive bytes.
|
|
67
|
+
* @memberof Zip
|
|
68
|
+
*/
|
|
69
|
+
const zipFromLocalFiles = async (entries) => {
|
|
70
|
+
const zip = new JSZip();
|
|
71
|
+
for (const { localPath, entryName } of entries) {
|
|
72
|
+
const stats = fs.statSync(localPath);
|
|
73
|
+
zip.file(entryName, fs.readFileSync(localPath), {
|
|
74
|
+
date: stats.mtime,
|
|
75
|
+
unixPermissions: stats.mode & 0o777,
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
return await zip.generateAsync({
|
|
79
|
+
type: 'nodebuffer',
|
|
80
|
+
platform: 'UNIX',
|
|
81
|
+
compression: 'DEFLATE',
|
|
82
|
+
compressionOptions: { level: 6 },
|
|
83
|
+
});
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Resolves an entry name against an output directory, rejecting archive paths that
|
|
88
|
+
* escape it (zip slip).
|
|
89
|
+
* @function resolveEntryTarget
|
|
90
|
+
* @param {string} outputPath - Extraction root.
|
|
91
|
+
* @param {string} entryName - Entry name from the archive.
|
|
92
|
+
* @returns {string} The absolute target path.
|
|
93
|
+
* @throws {Error} When the entry resolves outside the extraction root.
|
|
94
|
+
* @memberof Zip
|
|
95
|
+
*/
|
|
96
|
+
const resolveEntryTarget = (outputPath, entryName) => {
|
|
97
|
+
const root = dir.resolve(outputPath);
|
|
98
|
+
const target = dir.resolve(root, entryName);
|
|
99
|
+
if (target !== root && !target.startsWith(root + dir.sep)) {
|
|
100
|
+
throw new Error(`Blocked zip entry outside extraction directory: ${entryName}`);
|
|
101
|
+
}
|
|
102
|
+
return target;
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Extracts every entry of an archive into a directory.
|
|
107
|
+
* @function extractZipTo
|
|
108
|
+
* @param {Buffer|string} source - Archive bytes or a path to the archive.
|
|
109
|
+
* @param {string} outputPath - Extraction root; created when missing.
|
|
110
|
+
* @returns {Promise<string[]>} The written file paths.
|
|
111
|
+
* @memberof Zip
|
|
112
|
+
*/
|
|
113
|
+
const extractZipTo = async (source, outputPath) => {
|
|
114
|
+
const zip = await loadZip(source);
|
|
115
|
+
fs.mkdirSync(outputPath, { recursive: true });
|
|
116
|
+
|
|
117
|
+
const writtenPaths = [];
|
|
118
|
+
for (const entry of Object.values(zip.files)) {
|
|
119
|
+
const target = resolveEntryTarget(outputPath, entry.name);
|
|
120
|
+
if (entry.dir) {
|
|
121
|
+
fs.mkdirSync(target, { recursive: true });
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
fs.mkdirSync(dir.dirname(target), { recursive: true });
|
|
125
|
+
fs.writeFileSync(target, await entry.async('nodebuffer'));
|
|
126
|
+
if (entry.unixPermissions) fs.chmodSync(target, entry.unixPermissions & 0o777);
|
|
127
|
+
writtenPaths.push(target);
|
|
128
|
+
}
|
|
129
|
+
return writtenPaths;
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Extracts a single archive entry into a directory, flattening its archive path.
|
|
134
|
+
* @function extractZipEntryTo
|
|
135
|
+
* @param {Buffer|string} source - Archive bytes or a path to the archive.
|
|
136
|
+
* @param {string} entryName - Entry name or basename to extract.
|
|
137
|
+
* @param {string} targetDir - Destination directory; created when missing.
|
|
138
|
+
* @returns {Promise<string>} The written file path.
|
|
139
|
+
* @throws {Error} When the entry is not present in the archive.
|
|
140
|
+
* @memberof Zip
|
|
141
|
+
*/
|
|
142
|
+
const extractZipEntryTo = async (source, entryName, targetDir) => {
|
|
143
|
+
const entry = findZipEntry(await loadZip(source), entryName);
|
|
144
|
+
if (!entry) throw new Error(`Zip entry not found: ${entryName}`);
|
|
145
|
+
|
|
146
|
+
const target = resolveEntryTarget(targetDir, dir.basename(entryName));
|
|
147
|
+
fs.mkdirSync(dir.dirname(target), { recursive: true });
|
|
148
|
+
fs.writeFileSync(target, await entry.async('nodebuffer'));
|
|
149
|
+
if (entry.unixPermissions) fs.chmodSync(target, entry.unixPermissions & 0o777);
|
|
150
|
+
return target;
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
export { isZipBuffer, loadZip, findZipEntry, readZipEntry, zipFromLocalFiles, extractZipTo, extractZipEntryTo };
|