From a7de6b710a250c7f1c7af282d2e066bd1f56806c Mon Sep 17 00:00:00 2001 From: QA Runner Date: Fri, 17 Jul 2026 09:47:34 -0700 Subject: [PATCH] =?UTF-8?q?feat:=20pluggable=20storage=20adapter=20?= =?UTF-8?q?=E2=80=94=20R2,=20GCS,=20S3-region=20config?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Route all storage operations through a StorageAdapter interface. The default R2StorageAdapter preserves existing R2 behavior exactly (same env vars, same bucket default, same error handling); GCSStorageAdapter adds Google Cloud Storage via Application Default Credentials. setStorageAdapter() swaps backends without touching call sites. The S3 region is now configurable via R2_REGION (default "auto", unchanged). Path/filename helpers move to core/storagePaths.ts and are re-exported from lib/storage, so every existing import keeps working. Mechanical port of the storage adapter work from amal66/mike@main (commit b3166dd), translated to the upstream backend/ layout. Co-Authored-By: Claude Fable 5 --- backend/package-lock.json | 641 +++++++++++++++++- backend/package.json | 1 + backend/src/core/storagePaths.ts | 66 ++ backend/src/lib/__tests__/storage.test.ts | 149 ++++ backend/src/lib/storage.ts | 256 ++----- backend/src/lib/storage/__tests__/gcs.test.ts | 180 +++++ backend/src/lib/storage/adapter.ts | 54 ++ backend/src/lib/storage/gcs.ts | 149 ++++ backend/src/lib/storage/r2.ts | 149 ++++ backend/tsconfig.json | 2 +- 10 files changed, 1460 insertions(+), 187 deletions(-) create mode 100644 backend/src/core/storagePaths.ts create mode 100644 backend/src/lib/__tests__/storage.test.ts create mode 100644 backend/src/lib/storage/__tests__/gcs.test.ts create mode 100644 backend/src/lib/storage/adapter.ts create mode 100644 backend/src/lib/storage/gcs.ts create mode 100644 backend/src/lib/storage/r2.ts diff --git a/backend/package-lock.json b/backend/package-lock.json index c7597a9ca6..2f5483a7c2 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -7,10 +7,12 @@ "": { "name": "mike-backend", "version": "1.0.0", + "license": "AGPL-3.0-only", "dependencies": { "@anthropic-ai/sdk": "^0.90.0", "@aws-sdk/client-s3": "^3.787.0", "@aws-sdk/s3-request-presigner": "^3.787.0", + "@google-cloud/storage": "^7.19.0", "@google/genai": "^1.50.1", "@modelcontextprotocol/sdk": "^1.29.0", "@supabase/supabase-js": "^2.49.4", @@ -1417,6 +1419,150 @@ "node": ">=18" } }, + "node_modules/@google-cloud/paginator": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/@google-cloud/paginator/-/paginator-5.0.2.tgz", + "integrity": "sha512-DJS3s0OVH4zFDB1PzjxAsHqJT6sKVbRwwML0ZBP9PbU7Yebtu/7SWMRzvO2J3nUi9pRNITCfu4LJeooM2w4pjg==", + "license": "Apache-2.0", + "dependencies": { + "arrify": "^2.0.0", + "extend": "^3.0.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@google-cloud/projectify": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@google-cloud/projectify/-/projectify-4.0.0.tgz", + "integrity": "sha512-MmaX6HeSvyPbWGwFq7mXdo0uQZLGBYCwziiLIGq5JVX+/bdI3SAq6bP98trV5eTWfLuvsMcIC1YJOF2vfteLFA==", + "license": "Apache-2.0", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@google-cloud/promisify": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@google-cloud/promisify/-/promisify-4.0.0.tgz", + "integrity": "sha512-Orxzlfb9c67A15cq2JQEyVc7wEsmFBmHjZWZYQMUyJ1qivXyMwdyNOs9odi79hze+2zqdTtu1E19IM/FtqZ10g==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "node_modules/@google-cloud/storage": { + "version": "7.21.0", + "resolved": "https://registry.npmjs.org/@google-cloud/storage/-/storage-7.21.0.tgz", + "integrity": "sha512-l+IFTkd+6Y5LoAuXyYCKNAKtw/Ci+rAMqgdTB1jv4iZiLhw0rtq+0qjIRbBizXkNzEFmXiXUW0H7sZQQvk1ffA==", + "license": "Apache-2.0", + "dependencies": { + "@google-cloud/paginator": "^5.0.0", + "@google-cloud/projectify": "^4.0.0", + "@google-cloud/promisify": "<4.1.0", + "abort-controller": "^3.0.0", + "async-retry": "^1.3.3", + "duplexify": "^4.1.3", + "fast-xml-parser": "^5.3.4", + "gaxios": "^6.0.2", + "google-auth-library": "^9.6.3", + "html-entities": "^2.5.2", + "mime": "^3.0.0", + "p-limit": "^3.0.1", + "retry-request": "^7.0.0", + "teeny-request": "^9.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@google-cloud/storage/node_modules/gaxios": { + "version": "6.7.1", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-6.7.1.tgz", + "integrity": "sha512-LDODD4TMYx7XXdpwxAVRAIAuB0bzv0s+ywFonY46k126qzQHT9ygyoa9tncmOiQmmDrik65UYsEkv3lbfqQ3yQ==", + "license": "Apache-2.0", + "dependencies": { + "extend": "^3.0.2", + "https-proxy-agent": "^7.0.1", + "is-stream": "^2.0.0", + "node-fetch": "^2.6.9", + "uuid": "^9.0.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@google-cloud/storage/node_modules/gcp-metadata": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-6.1.1.tgz", + "integrity": "sha512-a4tiq7E0/5fTjxPAaH4jpjkSv/uCaU2p5KC6HVGrvl0cDjA8iBZv4vv1gyzlmK0ZUKqwpOyQMKzZQe3lTit77A==", + "license": "Apache-2.0", + "dependencies": { + "gaxios": "^6.1.1", + "google-logging-utils": "^0.0.2", + "json-bigint": "^1.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@google-cloud/storage/node_modules/google-auth-library": { + "version": "9.15.1", + "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-9.15.1.tgz", + "integrity": "sha512-Jb6Z0+nvECVz+2lzSMt9u98UsoakXxA2HGHMCxh+so3n90XgYWkq5dur19JAJV7ONiJY22yBTyJB1TSkvPq9Ng==", + "license": "Apache-2.0", + "dependencies": { + "base64-js": "^1.3.0", + "ecdsa-sig-formatter": "^1.0.11", + "gaxios": "^6.1.1", + "gcp-metadata": "^6.1.0", + "gtoken": "^7.0.0", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@google-cloud/storage/node_modules/google-logging-utils": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-0.0.2.tgz", + "integrity": "sha512-NEgUnEcBiP5HrPzufUkBzJOD/Sxsco3rLNo1F1TNf7ieU8ryUzBhqba8r756CjLX7rn3fHl6iLEwPYuqpoKgQQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "node_modules/@google-cloud/storage/node_modules/mime": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-3.0.0.tgz", + "integrity": "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/@google-cloud/storage/node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, "node_modules/@google/genai": { "version": "1.50.1", "resolved": "https://registry.npmjs.org/@google/genai/-/genai-1.50.1.tgz", @@ -2971,6 +3117,15 @@ "node": ">=20.0.0" } }, + "node_modules/@tootallnate/once": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.1.tgz", + "integrity": "sha512-HqmEUIGRJ5fSXchkVgR5F7qn48bDBzv0kWj/Kfu5e6uci4UlEeng4331LnBkWffb++Ei3FOVLxo8JJWMFBDMeQ==", + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, "node_modules/@types/body-parser": { "version": "1.19.6", "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", @@ -2982,6 +3137,12 @@ "@types/node": "*" } }, + "node_modules/@types/caseless": { + "version": "0.12.5", + "resolved": "https://registry.npmjs.org/@types/caseless/-/caseless-0.12.5.tgz", + "integrity": "sha512-hWtVTC2q7hc7xZ/RLbxapMvDMgUnDvKvMOpKal4DrMyfGBUfB1oKaZlIRr6mJL+If3bAP6sV/QneGzF6tJjZDg==", + "license": "MIT" + }, "node_modules/@types/connect": { "version": "3.4.38", "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", @@ -3075,6 +3236,18 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/request": { + "version": "2.48.13", + "resolved": "https://registry.npmjs.org/@types/request/-/request-2.48.13.tgz", + "integrity": "sha512-FGJ6udDNUCjd19pp0Q3iTiDkwhYup7J8hpMW9c4k53NrccQFFWKRho6hvtPPEhnXWKvukfwAlB6DbDz4yhH5Gg==", + "license": "MIT", + "dependencies": { + "@types/caseless": "*", + "@types/node": "*", + "@types/tough-cookie": "*", + "form-data": "^2.5.5" + } + }, "node_modules/@types/retry": { "version": "0.12.0", "resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.0.tgz", @@ -3114,6 +3287,12 @@ "@types/node": "*" } }, + "node_modules/@types/tough-cookie": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.5.tgz", + "integrity": "sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==", + "license": "MIT" + }, "node_modules/@types/ws": { "version": "8.18.1", "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", @@ -3132,6 +3311,18 @@ "node": ">=10.0.0" } }, + "node_modules/abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "license": "MIT", + "dependencies": { + "event-target-shim": "^5.0.0" + }, + "engines": { + "node": ">=6.5" + } + }, "node_modules/accepts": { "version": "1.3.8", "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", @@ -3214,12 +3405,36 @@ "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", "license": "MIT" }, + "node_modules/arrify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/arrify/-/arrify-2.0.1.tgz", + "integrity": "sha512-3duEwti880xqi4eAMN8AyR4a0ByT90zoYdLlevfrvU43vb0YZwZVfxOgxWrLXXXpyugL0hNZc9G6BiB5B3nUug==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/async": { "version": "3.2.6", "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", "license": "MIT" }, + "node_modules/async-retry": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/async-retry/-/async-retry-1.3.3.tgz", + "integrity": "sha512-wfr/jstw9xNi/0teMHrRW7dsz3Lt5ARhYNZ2ewpadnhaIp5mbALhOAP+EAdsC7t4Z6wqsDVv9+W6gm1Dk9mEyw==", + "license": "MIT", + "dependencies": { + "retry": "0.13.1" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, "node_modules/base64-js": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", @@ -3346,6 +3561,18 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/concat-stream": { "version": "1.6.2", "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-1.6.2.tgz", @@ -3461,6 +3688,15 @@ "node": ">=0.10.0" } }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -3608,6 +3844,32 @@ "node": ">= 0.4" } }, + "node_modules/duplexify": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/duplexify/-/duplexify-4.1.3.tgz", + "integrity": "sha512-M3BmBhwJRZsSx38lZyhE53Csddgzl5R7xGJNk7CVddZD6CcmwMCH8J+7AprIrQKH7TonKxaCjcv27Qmf+sQ+oA==", + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.4.1", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1", + "stream-shift": "^1.0.2" + } + }, + "node_modules/duplexify/node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/ecdsa-sig-formatter": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", @@ -3632,6 +3894,15 @@ "node": ">= 0.8" } }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, "node_modules/entities": { "version": "4.5.0", "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", @@ -3674,6 +3945,21 @@ "node": ">= 0.4" } }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/esbuild": { "version": "0.27.7", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", @@ -3731,6 +4017,15 @@ "node": ">= 0.6" } }, + "node_modules/event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/eventsource": { "version": "3.0.7", "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", @@ -3927,6 +4222,23 @@ "node": ">= 0.8" } }, + "node_modules/form-data": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-2.5.6.tgz", + "integrity": "sha512-Ogz/E85h9tlfJzpI6TuFpGcHZFhLrb9Gw8wq9v40CxSCPnv7ahKr6Xgtkn0KYCDQJ8DNn5VoMO8EXr9V5PadyA==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.4", + "mime-types": "^2.1.35", + "safe-buffer": "^5.2.1" + }, + "engines": { + "node": ">= 0.12" + } + }, "node_modules/formdata-polyfill": { "version": "4.0.10", "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", @@ -4097,6 +4409,55 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/gtoken": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/gtoken/-/gtoken-7.1.0.tgz", + "integrity": "sha512-pCcEwRi+TKpMlxAQObHDQ56KawURgyAf6jtIY046fJ5tIv3zDe/LEIubckAO8fj6JnAxLdmWkUfNyulQ2iKdEw==", + "license": "MIT", + "dependencies": { + "gaxios": "^6.0.0", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/gtoken/node_modules/gaxios": { + "version": "6.7.1", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-6.7.1.tgz", + "integrity": "sha512-LDODD4TMYx7XXdpwxAVRAIAuB0bzv0s+ywFonY46k126qzQHT9ygyoa9tncmOiQmmDrik65UYsEkv3lbfqQ3yQ==", + "license": "Apache-2.0", + "dependencies": { + "extend": "^3.0.2", + "https-proxy-agent": "^7.0.1", + "is-stream": "^2.0.0", + "node-fetch": "^2.6.9", + "uuid": "^9.0.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/gtoken/node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, "node_modules/has-symbols": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", @@ -4109,6 +4470,21 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/hash.js": { "version": "1.1.7", "resolved": "https://registry.npmjs.org/hash.js/-/hash.js-1.1.7.tgz", @@ -4120,9 +4496,9 @@ } }, "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", "license": "MIT", "dependencies": { "function-bind": "^1.1.2" @@ -4149,6 +4525,22 @@ "node": ">=16.9.0" } }, + "node_modules/html-entities": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/html-entities/-/html-entities-2.6.0.tgz", + "integrity": "sha512-kig+rMn/QOVRvr7c86gQ8lWXq+Hkv6CbAH1hLu+RG338StTpE8Z0b44SDVaqVu7HGKf27frdmUYEs9hTUX/cLQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/mdevils" + }, + { + "type": "patreon", + "url": "https://patreon.com/mdevils" + } + ], + "license": "MIT" + }, "node_modules/html-to-text": { "version": "9.0.5", "resolved": "https://registry.npmjs.org/html-to-text/-/html-to-text-9.0.5.tgz", @@ -4204,6 +4596,55 @@ "url": "https://opencollective.com/express" } }, + "node_modules/http-proxy-agent": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-5.0.0.tgz", + "integrity": "sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w==", + "license": "MIT", + "dependencies": { + "@tootallnate/once": "2", + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/http-proxy-agent/node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/http-proxy-agent/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/http-proxy-agent/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, "node_modules/https-proxy-agent": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", @@ -4297,6 +4738,18 @@ "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", "license": "MIT" }, + "node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/isarray": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", @@ -4691,6 +5144,21 @@ "integrity": "sha512-pkEqbDyl8ou5cpq+VsnQbe/WlEy5qS7xPzMS1U55OCG9KPvwFD46zDbxQIj3egJSFc3D+XhYOPUzz49zQAVy7A==", "license": "BSD-2-Clause" }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/p-retry": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/p-retry/-/p-retry-4.6.2.tgz", @@ -4991,6 +5459,20 @@ "node": ">= 4" } }, + "node_modules/retry-request": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/retry-request/-/retry-request-7.0.2.tgz", + "integrity": "sha512-dUOvLMJ0/JJYEn8NrpOaGNE7X3vpI5XlZS/u0ANjqtcZVKnIxP7IgCFwrKTxENw29emmwug53awKtaMm4i9g5w==", + "license": "MIT", + "dependencies": { + "@types/request": "^2.48.8", + "extend": "^3.0.2", + "teeny-request": "^9.0.0" + }, + "engines": { + "node": ">=14" + } + }, "node_modules/router": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", @@ -5259,6 +5741,21 @@ "node": ">= 0.8" } }, + "node_modules/stream-events": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/stream-events/-/stream-events-1.0.5.tgz", + "integrity": "sha512-E1GUzBSgvct8Jsb3v2X15pjzN1tYebtbLaMg+eBOUOAxgbLoSbT2NS91ckc5lJD1KfLjId+jXJRgo0qnV5Nerg==", + "license": "MIT", + "dependencies": { + "stubs": "^3.0.0" + } + }, + "node_modules/stream-shift": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/stream-shift/-/stream-shift-1.0.3.tgz", + "integrity": "sha512-76ORR0DO1o1hlKwTbi/DM3EXWGf3ZJYO8cXX5RJwnul2DEg2oyoZyjLNoQM8WsvZiFKCRfC1O0J7iCvie3RZmQ==", + "license": "MIT" + }, "node_modules/streamsearch": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", @@ -5294,6 +5791,96 @@ ], "license": "MIT" }, + "node_modules/stubs": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/stubs/-/stubs-3.0.0.tgz", + "integrity": "sha512-PdHt7hHUJKxvTCgbKX9C1V/ftOcjJQgz8BZwNfV5c4B6dcGqlpelTbJ999jBGZ2jYiPAwcX5dP6oBwVlBlUbxw==", + "license": "MIT" + }, + "node_modules/teeny-request": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/teeny-request/-/teeny-request-9.0.0.tgz", + "integrity": "sha512-resvxdc6Mgb7YEThw6G6bExlXKkv6+YbuzGg9xuXxSgxJF7Ozs+o8Y9+2R3sArdWdW8nOokoQb1yrpFB0pQK2g==", + "license": "Apache-2.0", + "dependencies": { + "http-proxy-agent": "^5.0.0", + "https-proxy-agent": "^5.0.0", + "node-fetch": "^2.6.9", + "stream-events": "^1.0.5", + "uuid": "^9.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/teeny-request/node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/teeny-request/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/teeny-request/node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/teeny-request/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/teeny-request/node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, "node_modules/tmp": { "version": "0.2.5", "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.5.tgz", @@ -5312,6 +5899,12 @@ "node": ">=0.6" } }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, "node_modules/ts-algebra": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz", @@ -5413,6 +6006,20 @@ "node": ">= 0.4.0" } }, + "node_modules/uuid": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "deprecated": "uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/vary": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", @@ -5431,6 +6038,22 @@ "node": ">= 8" } }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -5521,6 +6144,18 @@ "node": ">=0.4" } }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/zod": { "version": "3.25.76", "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", diff --git a/backend/package.json b/backend/package.json index 195a6acfcd..2b4858a9d4 100644 --- a/backend/package.json +++ b/backend/package.json @@ -11,6 +11,7 @@ "@anthropic-ai/sdk": "^0.90.0", "@aws-sdk/client-s3": "^3.787.0", "@aws-sdk/s3-request-presigner": "^3.787.0", + "@google-cloud/storage": "^7.19.0", "@google/genai": "^1.50.1", "@modelcontextprotocol/sdk": "^1.29.0", "@supabase/supabase-js": "^2.49.4", diff --git a/backend/src/core/storagePaths.ts b/backend/src/core/storagePaths.ts new file mode 100644 index 0000000000..e41cd1aeee --- /dev/null +++ b/backend/src/core/storagePaths.ts @@ -0,0 +1,66 @@ +export function normalizeDownloadFilename(name: string): string { + const trimmed = name.trim(); + const base = trimmed || "download"; + return base.replace(/[\x00-\x1F\x7F]/g, "_").replace(/[\\/]/g, "_"); +} + +export function sanitizeDispositionFilename(name: string): string { + return normalizeDownloadFilename(name) + .replace(/["\\]/g, "_") + .replace(/[^\x20-\x7E]/g, "_"); +} + +export function encodeRFC5987(str: string): string { + return encodeURIComponent(str).replace( + /['()*]/g, + (c) => "%" + c.charCodeAt(0).toString(16).toUpperCase(), + ); +} + +export function buildContentDisposition( + kind: "inline" | "attachment", + filename: string, +): string { + const normalized = normalizeDownloadFilename(filename); + return `${kind}; filename="${sanitizeDispositionFilename(normalized)}"; filename*=UTF-8''${encodeRFC5987(normalized)}`; +} + +export function storageKey( + userId: string, + docId: string, + filename: string, +): string { + return `documents/${userId}/${docId}/source${storageExtension(filename, ".bin")}`; +} + +export function pdfStorageKey( + userId: string, + docId: string, + stem: string, +): string { + return `documents/${userId}/${docId}/${stem}.pdf`; +} + +export function generatedDocKey( + userId: string, + docId: string, + filename: string, +): string { + return `generated/${userId}/${docId}/generated${storageExtension(filename, ".docx")}`; +} + +export function versionStorageKey( + userId: string, + docId: string, + versionSlug: string, + filename: string, +): string { + return `documents/${userId}/${docId}/versions/${versionSlug}${storageExtension(filename, ".bin")}`; +} + +function storageExtension(filename: string, fallback: string): string { + const lastDot = filename.lastIndexOf("."); + if (lastDot < 0) return fallback; + const ext = filename.slice(lastDot).toLowerCase(); + return /^\.[a-z0-9]{1,16}$/.test(ext) ? ext : fallback; +} diff --git a/backend/src/lib/__tests__/storage.test.ts b/backend/src/lib/__tests__/storage.test.ts new file mode 100644 index 0000000000..7a70eab5b6 --- /dev/null +++ b/backend/src/lib/__tests__/storage.test.ts @@ -0,0 +1,149 @@ +import { describe, it, expect } from "vitest"; + +import { + normalizeDownloadFilename, + sanitizeDispositionFilename, + encodeRFC5987, + buildContentDisposition, + storageKey, + pdfStorageKey, + generatedDocKey, + versionStorageKey, +} from "../storage"; + +describe("normalizeDownloadFilename", () => { + it("trims surrounding whitespace", () => { + expect(normalizeDownloadFilename(" file.pdf ")).toBe("file.pdf"); + }); + + it("falls back to 'download' for empty string", () => { + expect(normalizeDownloadFilename("")).toBe("download"); + expect(normalizeDownloadFilename(" ")).toBe("download"); + }); + + it("replaces control characters with underscore", () => { + expect(normalizeDownloadFilename("file\x00name.pdf")).toBe("file_name.pdf"); + expect(normalizeDownloadFilename("file\x1fname.pdf")).toBe("file_name.pdf"); + }); + + it("replaces forward and backward slashes with underscore", () => { + expect(normalizeDownloadFilename("dir/file.pdf")).toBe("dir_file.pdf"); + expect(normalizeDownloadFilename("dir\\file.pdf")).toBe("dir_file.pdf"); + }); + + it("preserves normal filenames unchanged", () => { + expect(normalizeDownloadFilename("Contract v2 (Final).pdf")).toBe( + "Contract v2 (Final).pdf", + ); + }); +}); + +describe("sanitizeDispositionFilename", () => { + it("strips double-quote characters", () => { + expect(sanitizeDispositionFilename('file"name.pdf')).toBe("file_name.pdf"); + }); + + it("strips backslash characters", () => { + expect(sanitizeDispositionFilename("file\\name.pdf")).toBe("file_name.pdf"); + }); + + it("strips non-ASCII characters", () => { + expect(sanitizeDispositionFilename("filéname.pdf")).toBe("fil_name.pdf"); + }); + + it("still applies normalizeDownloadFilename rules first", () => { + expect(sanitizeDispositionFilename(" ")).toBe("download"); + }); +}); + +describe("encodeRFC5987", () => { + it("encodes spaces as %20", () => { + expect(encodeRFC5987("hello world")).toBe("hello%20world"); + }); + + it("encodes single-quote as %27", () => { + expect(encodeRFC5987("it's")).toContain("%27"); + }); + + it("encodes ( and ) as %28 and %29", () => { + const result = encodeRFC5987("a(b)c"); + expect(result).toContain("%28"); + expect(result).toContain("%29"); + }); + + it("encodes * as %2A", () => { + expect(encodeRFC5987("a*b")).toContain("%2A"); + }); + + it("leaves safe ASCII characters unencoded", () => { + expect(encodeRFC5987("file.pdf")).toBe("file.pdf"); + }); +}); + +describe("buildContentDisposition", () => { + it("produces an attachment header with ASCII filename", () => { + const header = buildContentDisposition("attachment", "contract.pdf"); + expect(header).toMatch(/^attachment;/); + expect(header).toContain('filename="contract.pdf"'); + expect(header).toContain("filename*=UTF-8''contract.pdf"); + }); + + it("produces an inline header", () => { + const header = buildContentDisposition("inline", "preview.pdf"); + expect(header).toMatch(/^inline;/); + }); + + it("encodes unicode filename in filename* param", () => { + const header = buildContentDisposition("attachment", "Ünïcödé.pdf"); + expect(header).toContain("filename*=UTF-8''"); + expect(header).not.toContain("Ü"); + }); +}); + +describe("storageKey", () => { + it("includes userId, docId, and correct extension", () => { + const key = storageKey("user1", "doc1", "contract.pdf"); + expect(key).toBe("documents/user1/doc1/source.pdf"); + }); + + it("falls back to .bin for extensions longer than 16 chars", () => { + const key = storageKey("user1", "doc1", "file.toolongextension1234"); + expect(key).toBe("documents/user1/doc1/source.bin"); + }); + + it("falls back to .bin when no extension", () => { + const key = storageKey("user1", "doc1", "noextension"); + expect(key).toBe("documents/user1/doc1/source.bin"); + }); +}); + +describe("pdfStorageKey", () => { + it("places PDF in the correct path with stem", () => { + const key = pdfStorageKey("user1", "doc1", "contract"); + expect(key).toBe("documents/user1/doc1/contract.pdf"); + }); +}); + +describe("generatedDocKey", () => { + it("uses generated/ prefix and .docx extension for docx files", () => { + const key = generatedDocKey("user1", "doc1", "output.docx"); + expect(key).toBe("generated/user1/doc1/generated.docx"); + }); + + it("falls back to .docx for extensions longer than 16 chars", () => { + const key = generatedDocKey("user1", "doc1", "output.toolongextension1234"); + expect(key).toBe("generated/user1/doc1/generated.docx"); + }); +}); + +describe("versionStorageKey", () => { + it("includes userId, docId, versionSlug, and extension", () => { + const key = versionStorageKey("user1", "doc1", "v2", "contract.pdf"); + expect(key).toBe("documents/user1/doc1/versions/v2.pdf"); + }); + + it("falls back to .bin for unknown extensions", () => { + const key = versionStorageKey("user1", "doc1", "v2", "file"); + expect(key).toBe("documents/user1/doc1/versions/v2.bin"); + }); +}); diff --git a/backend/src/lib/storage.ts b/backend/src/lib/storage.ts index dccf9e43ea..5cfb147b09 100644 --- a/backend/src/lib/storage.ts +++ b/backend/src/lib/storage.ts @@ -1,60 +1,82 @@ /** - * Cloudflare R2 storage utilities for Mike document management. - * R2 is S3-compatible — uses @aws-sdk/client-s3. + * Storage public API. * - * Required env vars: - * R2_ENDPOINT_URL — https://.r2.cloudflarestorage.com - * R2_ACCESS_KEY_ID — R2 API token (Access Key ID) - * R2_SECRET_ACCESS_KEY — R2 API token (Secret Access Key) - * R2_BUCKET_NAME — bucket name (default: "mike") + * The default backend is Cloudflare R2 (S3-compatible). To swap it for + * Supabase Storage, local filesystem, AWS S3, or any other backend, implement + * StorageAdapter (lib/storage/adapter.ts) and call setStorageAdapter() early + * in your application bootstrap — before the first upload/download call: + * + * import { setStorageAdapter } from "./lib/storage"; + * import { SupabaseStorageAdapter } from "./my-adapters/supabase"; + * setStorageAdapter(new SupabaseStorageAdapter()); + * + * All existing callers of uploadFile / downloadFile / deleteFile / getSignedUrl + * / checkStorageReady continue to work unchanged — they delegate to whichever + * adapter is currently installed. + * + * Required env vars for the default R2 adapter: + * R2_ENDPOINT_URL — https://.r2.cloudflarestorage.com + * R2_ACCESS_KEY_ID — R2 API token (Access Key ID) + * R2_SECRET_ACCESS_KEY — R2 API token (Secret Access Key) + * R2_BUCKET_NAME — bucket name (default: "mike") */ -import { - S3Client, - PutObjectCommand, - DeleteObjectCommand, - ListObjectsV2Command, -} from "@aws-sdk/client-s3"; -import * as S3Commands from "@aws-sdk/client-s3"; -import { getSignedUrl as awsGetSignedUrl } from "@aws-sdk/s3-request-presigner"; +import type { StorageAdapter } from "./storage/adapter"; +import { R2StorageAdapter } from "./storage/r2"; + +// Re-export the interface and default implementation so callers can build +// alternative adapters without needing to know the internal file layout. +export type { StorageAdapter } from "./storage/adapter"; +export { R2StorageAdapter } from "./storage/r2"; +export { GCSStorageAdapter } from "./storage/gcs"; + +// Path / filename utilities are pure functions with no I/O; they live in +// core/ and are re-exported here for convenience. +export { + normalizeDownloadFilename, + sanitizeDispositionFilename, + encodeRFC5987, + buildContentDisposition, + storageKey, + pdfStorageKey, + generatedDocKey, + versionStorageKey, +} from "../core/storagePaths"; -const GetObjectCommand = (S3Commands as any).GetObjectCommand; +// --------------------------------------------------------------------------- +// Adapter singleton +// --------------------------------------------------------------------------- -let cachedClient: S3Client | undefined; +let _adapter: StorageAdapter | undefined; -function getClient(): S3Client { - if (!cachedClient) { - cachedClient = new S3Client({ - region: "auto", - endpoint: process.env.R2_ENDPOINT_URL!, - forcePathStyle: true, - credentials: { - accessKeyId: process.env.R2_ACCESS_KEY_ID!, - secretAccessKey: process.env.R2_SECRET_ACCESS_KEY!, - }, - }); +function getAdapter(): StorageAdapter { + if (!_adapter) { + _adapter = new R2StorageAdapter(); } - return cachedClient; + return _adapter; } -const BUCKET = process.env.R2_BUCKET_NAME ?? "mike"; +/** + * Replace the active storage adapter. + * + * Must be called before the first storage operation (upload, download, etc.). + * Subsequent calls replace the adapter for all future operations. + */ +export function setStorageAdapter(adapter: StorageAdapter): void { + _adapter = adapter; +} +// Evaluated once at module load — reflects whether the default R2 adapter is +// configured. If you install a different adapter via setStorageAdapter(), +// call adapter.enabled instead of reading this export. export const storageEnabled = Boolean( process.env.R2_ENDPOINT_URL && process.env.R2_ACCESS_KEY_ID && process.env.R2_SECRET_ACCESS_KEY, ); -function requireStorageConfig(): void { - if (!storageEnabled) { - throw new Error( - "R2_ENDPOINT_URL, R2_ACCESS_KEY_ID, and R2_SECRET_ACCESS_KEY must be set", - ); - } -} - // --------------------------------------------------------------------------- -// Upload +// Storage operations — delegate to the active adapter // --------------------------------------------------------------------------- export async function uploadFile( @@ -62,165 +84,33 @@ export async function uploadFile( content: ArrayBuffer, contentType: string, ): Promise { - requireStorageConfig(); - const client = getClient(); - await client.send( - new PutObjectCommand({ - Bucket: BUCKET, - Key: key, - Body: Buffer.from(content), - ContentType: contentType, - }), - ); + return getAdapter().upload(key, content, contentType); } -// --------------------------------------------------------------------------- -// Download -// --------------------------------------------------------------------------- - export async function downloadFile(key: string): Promise { - if (!storageEnabled) return null; - try { - const client = getClient(); - const response = (await client.send( - new GetObjectCommand({ Bucket: BUCKET, Key: key }), - )) as any; - if (!response.Body) return null; - const bytes = await response.Body.transformToByteArray(); - return bytes.buffer as ArrayBuffer; - } catch { - return null; - } + return getAdapter().download(key); } -export async function listFiles(prefix: string): Promise { - if (!storageEnabled) return []; - const client = getClient(); - const keys: string[] = []; - let ContinuationToken: string | undefined; - do { - const response = await client.send( - new ListObjectsV2Command({ - Bucket: BUCKET, - Prefix: prefix, - ContinuationToken, - }), - ); - for (const item of response.Contents ?? []) { - if (item.Key) keys.push(item.Key); - } - ContinuationToken = response.NextContinuationToken; - } while (ContinuationToken); - return keys; -} - -// --------------------------------------------------------------------------- -// Delete -// --------------------------------------------------------------------------- - export async function deleteFile(key: string): Promise { - if (!storageEnabled) return; - const client = getClient(); - await client.send(new DeleteObjectCommand({ Bucket: BUCKET, Key: key })); + return getAdapter().delete(key); } -// --------------------------------------------------------------------------- -// Signed URL (pre-signed for temporary direct access) -// --------------------------------------------------------------------------- - export async function getSignedUrl( key: string, expiresIn = 3600, downloadFilename?: string, ): Promise { - if (!storageEnabled) return null; - try { - const client = getClient(); - // Override the response Content-Disposition so the browser uses this - // filename on download, instead of the last path segment of the R2 key - // (which includes the document UUID). The `download` attribute on - // is ignored for cross-origin URLs, so we have to set it server-side. - const responseContentDisposition = downloadFilename - ? buildContentDisposition("attachment", downloadFilename) - : undefined; - const command = new GetObjectCommand({ - Bucket: BUCKET, - Key: key, - ResponseContentDisposition: responseContentDisposition, - }) as any; - return await awsGetSignedUrl(client, command, { expiresIn }); - } catch { - return null; - } -} - -export function normalizeDownloadFilename(name: string): string { - const trimmed = name.trim(); - const base = trimmed || "download"; - return base.replace(/[\x00-\x1F\x7F]/g, "_").replace(/[\\/]/g, "_"); -} - -export function sanitizeDispositionFilename(name: string): string { - return normalizeDownloadFilename(name) - .replace(/["\\]/g, "_") - .replace(/[^\x20-\x7E]/g, "_"); -} - -export function encodeRFC5987(str: string): string { - return encodeURIComponent(str).replace( - /['()*]/g, - (c) => "%" + c.charCodeAt(0).toString(16).toUpperCase(), - ); -} - -export function buildContentDisposition( - kind: "inline" | "attachment", - filename: string, -): string { - const normalized = normalizeDownloadFilename(filename); - return `${kind}; filename="${sanitizeDispositionFilename(normalized)}"; filename*=UTF-8''${encodeRFC5987(normalized)}`; -} - -// --------------------------------------------------------------------------- -// Storage key helpers -// --------------------------------------------------------------------------- - -export function storageKey( - userId: string, - docId: string, - filename: string, -): string { - return `documents/${userId}/${docId}/source${storageExtension(filename, ".bin")}`; + return getAdapter().getSignedUrl(key, expiresIn, downloadFilename); } -export function pdfStorageKey( - userId: string, - docId: string, - stem: string, -): string { - return `documents/${userId}/${docId}/${stem}.pdf`; -} - -export function generatedDocKey( - userId: string, - docId: string, - filename: string, -): string { - return `generated/${userId}/${docId}/generated${storageExtension(filename, ".docx")}`; -} - -export function versionStorageKey( - userId: string, - docId: string, - versionSlug: string, - filename: string, -): string { - return `documents/${userId}/${docId}/versions/${versionSlug}${storageExtension(filename, ".bin")}`; +export async function listFiles(prefix: string): Promise { + return getAdapter().list(prefix); } -function storageExtension(filename: string, fallback: string): string { - const lastDot = filename.lastIndexOf("."); - if (lastDot < 0) return fallback; - const ext = filename.slice(lastDot).toLowerCase(); - return /^\.[a-z0-9]{1,16}$/.test(ext) ? ext : fallback; +export async function checkStorageReady(): Promise<{ + ok: boolean; + latencyMs?: number; + error?: string; +}> { + return getAdapter().checkReady(); } diff --git a/backend/src/lib/storage/__tests__/gcs.test.ts b/backend/src/lib/storage/__tests__/gcs.test.ts new file mode 100644 index 0000000000..38222677d2 --- /dev/null +++ b/backend/src/lib/storage/__tests__/gcs.test.ts @@ -0,0 +1,180 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; + +// vi.hoisted runs before vi.mock factories, so variables are available. +const { mockFile, mockBucket } = vi.hoisted(() => { + const mockFile = { + save: vi.fn().mockResolvedValue(undefined), + download: vi.fn().mockResolvedValue([Buffer.from("hello world")]), + delete: vi.fn().mockResolvedValue(undefined), + getSignedUrl: vi.fn().mockResolvedValue(["https://storage.googleapis.com/signed-url"]), + }; + const mockBucket = { + file: vi.fn().mockReturnValue(mockFile), + exists: vi.fn().mockResolvedValue([true]), + }; + return { mockFile, mockBucket }; +}); + +vi.mock("@google-cloud/storage", () => { + function MockStorage() { + // @ts-expect-error — constructor body + this.bucket = () => mockBucket; + } + return { Storage: MockStorage }; +}); + +// Import after mocks are registered (hoisted, so this is fine). +import { GCSStorageAdapter } from "../gcs"; + +beforeEach(() => { + process.env.GCS_BUCKET_NAME = "test-bucket"; + process.env.GCS_PROJECT_ID = "test-project"; + process.env.GCS_SIGNED_URL_TTL = "3600"; + delete process.env.GOOGLE_APPLICATION_CREDENTIALS; + vi.clearAllMocks(); + // Restore default mock implementations after clearAllMocks. + mockFile.save.mockResolvedValue(undefined); + mockFile.download.mockResolvedValue([Buffer.from("hello world")]); + mockFile.delete.mockResolvedValue(undefined); + mockFile.getSignedUrl.mockResolvedValue(["https://storage.googleapis.com/signed-url"]); + mockBucket.file.mockReturnValue(mockFile); + mockBucket.exists.mockResolvedValue([true]); +}); + +afterEach(() => { + delete process.env.GCS_BUCKET_NAME; + delete process.env.GCS_PROJECT_ID; + delete process.env.GCS_SIGNED_URL_TTL; + delete process.env.GOOGLE_APPLICATION_CREDENTIALS; +}); + +// --------------------------------------------------------------------------- +// enabled flag +// --------------------------------------------------------------------------- + +describe("enabled", () => { + it("is true when GCS_PROJECT_ID is set", () => { + expect(new GCSStorageAdapter().enabled).toBe(true); + }); + + it("is true when GOOGLE_APPLICATION_CREDENTIALS is set (even without project id)", () => { + delete process.env.GCS_PROJECT_ID; + process.env.GOOGLE_APPLICATION_CREDENTIALS = "/creds/sa.json"; + expect(new GCSStorageAdapter().enabled).toBe(true); + }); + + it("is false when neither GCS_PROJECT_ID nor GOOGLE_APPLICATION_CREDENTIALS is set", () => { + delete process.env.GCS_PROJECT_ID; + delete process.env.GOOGLE_APPLICATION_CREDENTIALS; + expect(new GCSStorageAdapter().enabled).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// upload +// --------------------------------------------------------------------------- + +describe("upload", () => { + it("calls file.save with a Buffer and the correct content type", async () => { + const adapter = new GCSStorageAdapter(); + const content = new Uint8Array([1, 2, 3]).buffer; + await adapter.upload("docs/contract.pdf", content, "application/pdf"); + expect(mockFile.save).toHaveBeenCalledWith( + expect.any(Buffer), + expect.objectContaining({ metadata: { contentType: "application/pdf" } }), + ); + }); + + it("throws when not configured", async () => { + delete process.env.GCS_PROJECT_ID; + const adapter = new GCSStorageAdapter(); + await expect(adapter.upload("k", new ArrayBuffer(0), "text/plain")).rejects.toThrow( + /not configured/i, + ); + }); +}); + +// --------------------------------------------------------------------------- +// download +// --------------------------------------------------------------------------- + +describe("download", () => { + it("returns an ArrayBuffer when the file exists", async () => { + const adapter = new GCSStorageAdapter(); + const result = await adapter.download("docs/contract.pdf"); + expect(result).toBeInstanceOf(ArrayBuffer); + }); + + it("returns null when disabled", async () => { + delete process.env.GCS_PROJECT_ID; + expect(await new GCSStorageAdapter().download("any")).toBeNull(); + }); + + it("returns null when the SDK throws (e.g. file not found)", async () => { + mockFile.download.mockRejectedValueOnce(new Error("Not found")); + const result = await new GCSStorageAdapter().download("missing"); + expect(result).toBeNull(); + }); +}); + +// --------------------------------------------------------------------------- +// getSignedUrl +// --------------------------------------------------------------------------- + +describe("getSignedUrl", () => { + it("returns a signed URL string", async () => { + const url = await new GCSStorageAdapter().getSignedUrl("docs/contract.pdf", 900); + expect(typeof url).toBe("string"); + expect(url).toContain("googleapis.com"); + }); + + it("passes Content-Disposition when downloadFilename is provided", async () => { + await new GCSStorageAdapter().getSignedUrl("docs/contract.pdf", 900, "contract.pdf"); + expect(mockFile.getSignedUrl).toHaveBeenCalledWith( + expect.objectContaining({ responseDisposition: expect.stringContaining("contract.pdf") }), + ); + }); + + it("returns null when disabled", async () => { + delete process.env.GCS_PROJECT_ID; + expect(await new GCSStorageAdapter().getSignedUrl("any")).toBeNull(); + }); + + it("returns null when getSignedUrl throws", async () => { + mockFile.getSignedUrl.mockRejectedValueOnce(new Error("IAM error")); + expect(await new GCSStorageAdapter().getSignedUrl("key")).toBeNull(); + }); +}); + +// --------------------------------------------------------------------------- +// checkReady +// --------------------------------------------------------------------------- + +describe("checkReady", () => { + it("returns ok:true with latencyMs when bucket exists", async () => { + const result = await new GCSStorageAdapter().checkReady(); + expect(result.ok).toBe(true); + expect(result.latencyMs).toBeGreaterThanOrEqual(0); + }); + + it("returns ok:false when bucket does not exist", async () => { + mockBucket.exists.mockResolvedValueOnce([false]); + const result = await new GCSStorageAdapter().checkReady(); + expect(result.ok).toBe(false); + expect(result.error).toMatch(/does not exist/i); + }); + + it("returns ok:false with error string when SDK throws", async () => { + mockBucket.exists.mockRejectedValueOnce(new Error("Network error")); + const result = await new GCSStorageAdapter().checkReady(); + expect(result.ok).toBe(false); + expect(result.error).toMatch(/network error/i); + }); + + it("returns ok:false with error message when disabled", async () => { + delete process.env.GCS_PROJECT_ID; + const result = await new GCSStorageAdapter().checkReady(); + expect(result.ok).toBe(false); + expect(result.error).toMatch(/not configured/i); + }); +}); diff --git a/backend/src/lib/storage/adapter.ts b/backend/src/lib/storage/adapter.ts new file mode 100644 index 0000000000..b3ccf0a07c --- /dev/null +++ b/backend/src/lib/storage/adapter.ts @@ -0,0 +1,54 @@ +/** + * Contract every storage backend must implement. + * + * The default adapter is R2StorageAdapter (Cloudflare R2 / S3-compatible). + * Swap it at application startup via setStorageAdapter() from lib/storage.ts: + * + * import { setStorageAdapter } from "./lib/storage"; + * import { SupabaseStorageAdapter } from "./my-adapters/supabase"; + * setStorageAdapter(new SupabaseStorageAdapter()); + * + * Implementing the interface is sufficient for full compatibility — no other + * file needs to change. + */ +export interface StorageAdapter { + /** True when the adapter is fully configured and ready to use. */ + readonly enabled: boolean; + + /** Upload content at key with the given MIME type. */ + upload(key: string, content: ArrayBuffer, contentType: string): Promise; + + /** Download content at key, or null if absent or storage is disabled. */ + download(key: string): Promise; + + /** Delete the object at key. No-ops if storage is disabled. */ + delete(key: string): Promise; + + /** + * List all object keys under a prefix. Returns an empty array when storage + * is disabled. Implementations must paginate internally so every matching + * key is returned. + */ + list(prefix: string): Promise; + + /** + * Generate a pre-signed URL for temporary direct access to key. + * Returns null if storage is disabled or the operation fails. + * + * @param key Storage object key. + * @param expiresIn TTL in seconds (default 3600). + * @param downloadFilename Sets Content-Disposition on the response so + * browsers use this filename on download. + */ + getSignedUrl( + key: string, + expiresIn?: number, + downloadFilename?: string, + ): Promise; + + /** + * Health-check the storage backend. + * Returns ok:true with latency when reachable, ok:false with error otherwise. + */ + checkReady(): Promise<{ ok: boolean; latencyMs?: number; error?: string }>; +} diff --git a/backend/src/lib/storage/gcs.ts b/backend/src/lib/storage/gcs.ts new file mode 100644 index 0000000000..cc76a8adfc --- /dev/null +++ b/backend/src/lib/storage/gcs.ts @@ -0,0 +1,149 @@ +import { Storage } from "@google-cloud/storage"; +import { buildContentDisposition } from "../../core/storagePaths"; +import type { StorageAdapter } from "./adapter"; + +/** + * Google Cloud Storage implementation of StorageAdapter. + * + * Auth is resolved via Application Default Credentials (ADC) — the standard + * GCP credential chain that covers: + * - GOOGLE_APPLICATION_CREDENTIALS env var (path to a service account JSON key) + * - Workload Identity (GKE, Cloud Run, Compute Engine) + * - gcloud CLI credentials (local development: `gcloud auth application-default login`) + * + * Required env vars: + * GCS_BUCKET_NAME — bucket name (default: "mike") + * GCS_PROJECT_ID — GCP project id (optional when running on GCP with ADC) + * + * Signed URL generation uses ADC automatically on GCP runtimes that support + * service account impersonation (Cloud Run, GKE with Workload Identity). + * For local development with a key file, set GOOGLE_APPLICATION_CREDENTIALS. + * + * To use GCS instead of R2, call setStorageAdapter() at application startup: + * + * import { setStorageAdapter } from "./lib/storage"; + * import { GCSStorageAdapter } from "./lib/storage/gcs"; + * setStorageAdapter(new GCSStorageAdapter()); + */ +export class GCSStorageAdapter implements StorageAdapter { + readonly enabled: boolean; + private readonly bucket: string; + private _storage: Storage | undefined; + + constructor() { + // Enabled when a GCS project or ADC is available. + // We treat presence of GCS_PROJECT_ID or GOOGLE_APPLICATION_CREDENTIALS + // as the intent-to-use signal, similar to R2_ENDPOINT_URL for R2. + this.enabled = Boolean( + process.env.GCS_PROJECT_ID || process.env.GOOGLE_APPLICATION_CREDENTIALS, + ); + this.bucket = process.env.GCS_BUCKET_NAME ?? "mike"; + } + + private storage(): Storage { + if (!this._storage) { + this._storage = new Storage({ + ...(process.env.GCS_PROJECT_ID ? { projectId: process.env.GCS_PROJECT_ID } : {}), + }); + } + return this._storage; + } + + private requireEnabled(): void { + if (!this.enabled) { + throw new Error( + "GCS is not configured. Set GCS_PROJECT_ID or GOOGLE_APPLICATION_CREDENTIALS.", + ); + } + } + + async upload(key: string, content: ArrayBuffer, contentType: string): Promise { + this.requireEnabled(); + const file = this.storage().bucket(this.bucket).file(key); + await file.save(Buffer.from(content), { + metadata: { contentType }, + resumable: false, + }); + } + + async download(key: string): Promise { + if (!this.enabled) return null; + try { + const [contents] = await this.storage().bucket(this.bucket).file(key).download(); + return contents.buffer.slice( + contents.byteOffset, + contents.byteOffset + contents.byteLength, + ) as ArrayBuffer; + } catch { + return null; + } + } + + async delete(key: string): Promise { + if (!this.enabled) return; + try { + await this.storage().bucket(this.bucket).file(key).delete(); + } catch { + // Ignore not-found errors on delete (idempotent) + } + } + + async list(prefix: string): Promise { + if (!this.enabled) return []; + try { + const [files] = await this.storage() + .bucket(this.bucket) + .getFiles({ prefix }); + return files.map((file) => file.name); + } catch { + return []; + } + } + + async getSignedUrl( + key: string, + expiresIn = Number(process.env.GCS_SIGNED_URL_TTL) || 3600, + downloadFilename?: string, + ): Promise { + if (!this.enabled) return null; + try { + const file = this.storage().bucket(this.bucket).file(key); + const responseDisposition = downloadFilename + ? buildContentDisposition("attachment", downloadFilename) + : undefined; + + const [url] = await file.getSignedUrl({ + version: "v4", + action: "read", + expires: Date.now() + expiresIn * 1000, + ...(responseDisposition + ? { responseDisposition } + : {}), + }); + return url; + } catch { + return null; + } + } + + async checkReady(): Promise<{ ok: boolean; latencyMs?: number; error?: string }> { + if (!this.enabled) { + return { ok: false, error: "GCS is not configured" }; + } + const startedAt = Date.now(); + try { + const [exists] = await this.storage().bucket(this.bucket).exists(); + if (!exists) { + return { + ok: false, + latencyMs: Date.now() - startedAt, + error: `Bucket "${this.bucket}" does not exist`, + }; + } + return { ok: true, latencyMs: Date.now() - startedAt }; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + return { ok: false, latencyMs: Date.now() - startedAt, error: message }; + } + } +} diff --git a/backend/src/lib/storage/r2.ts b/backend/src/lib/storage/r2.ts new file mode 100644 index 0000000000..85300117f2 --- /dev/null +++ b/backend/src/lib/storage/r2.ts @@ -0,0 +1,149 @@ +import { + S3Client, + PutObjectCommand, + GetObjectCommand, + DeleteObjectCommand, + ListObjectsV2Command, + HeadBucketCommand, +} from "@aws-sdk/client-s3"; +import { getSignedUrl as awsGetSignedUrl } from "@aws-sdk/s3-request-presigner"; +import { buildContentDisposition } from "../../core/storagePaths"; +import type { StorageAdapter } from "./adapter"; + +/** + * Cloudflare R2 (S3-compatible) implementation of StorageAdapter. + * + * Required env vars: + * R2_ENDPOINT_URL — https://.r2.cloudflarestorage.com + * R2_ACCESS_KEY_ID — R2 API token (Access Key ID) + * R2_SECRET_ACCESS_KEY — R2 API token (Secret Access Key) + * R2_BUCKET_NAME — bucket name (default: "mike") + * + * The S3Client is lazily created and cached (singleton per adapter instance) + * to enable HTTP keep-alive connection reuse across requests. + */ +export class R2StorageAdapter implements StorageAdapter { + readonly enabled: boolean; + private readonly bucket: string; + private _client: S3Client | undefined; + + constructor() { + this.enabled = Boolean( + process.env.R2_ENDPOINT_URL && + process.env.R2_ACCESS_KEY_ID && + process.env.R2_SECRET_ACCESS_KEY, + ); + this.bucket = process.env.R2_BUCKET_NAME ?? "mike"; + } + + private client(): S3Client { + if (!this._client) { + this._client = new S3Client({ + region: process.env.R2_REGION ?? "auto", + endpoint: process.env.R2_ENDPOINT_URL!, + forcePathStyle: true, + credentials: { + accessKeyId: process.env.R2_ACCESS_KEY_ID!, + secretAccessKey: process.env.R2_SECRET_ACCESS_KEY!, + }, + }); + } + return this._client; + } + + private requireEnabled(): void { + if (!this.enabled) { + throw new Error( + "R2_ENDPOINT_URL, R2_ACCESS_KEY_ID, and R2_SECRET_ACCESS_KEY must be set", + ); + } + } + + async upload(key: string, content: ArrayBuffer, contentType: string): Promise { + this.requireEnabled(); + await this.client().send( + new PutObjectCommand({ + Bucket: this.bucket, + Key: key, + Body: Buffer.from(content), + ContentType: contentType, + }), + ); + } + + async download(key: string): Promise { + if (!this.enabled) return null; + try { + const response = await this.client().send( + new GetObjectCommand({ Bucket: this.bucket, Key: key }), + ); + if (!response.Body) return null; + const bytes = await response.Body.transformToByteArray(); + return bytes.buffer as ArrayBuffer; + } catch { + return null; + } + } + + async delete(key: string): Promise { + if (!this.enabled) return; + await this.client().send( + new DeleteObjectCommand({ Bucket: this.bucket, Key: key }), + ); + } + + async list(prefix: string): Promise { + if (!this.enabled) return []; + const keys: string[] = []; + let continuationToken: string | undefined; + do { + const response = await this.client().send( + new ListObjectsV2Command({ + Bucket: this.bucket, + Prefix: prefix, + ContinuationToken: continuationToken, + }), + ); + for (const item of response.Contents ?? []) { + if (item.Key) keys.push(item.Key); + } + continuationToken = response.NextContinuationToken; + } while (continuationToken); + return keys; + } + + async getSignedUrl( + key: string, + expiresIn = 3600, + downloadFilename?: string, + ): Promise { + if (!this.enabled) return null; + try { + const responseContentDisposition = downloadFilename + ? buildContentDisposition("attachment", downloadFilename) + : undefined; + const command = new GetObjectCommand({ + Bucket: this.bucket, + Key: key, + ResponseContentDisposition: responseContentDisposition, + }); + return await awsGetSignedUrl(this.client(), command, { expiresIn }); + } catch { + return null; + } + } + + async checkReady(): Promise<{ ok: boolean; latencyMs?: number; error?: string }> { + if (!this.enabled) { + return { ok: false, error: "storage is not configured" }; + } + const startedAt = Date.now(); + try { + await this.client().send(new HeadBucketCommand({ Bucket: this.bucket })); + return { ok: true, latencyMs: Date.now() - startedAt }; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + return { ok: false, latencyMs: Date.now() - startedAt, error: message }; + } + } +} diff --git a/backend/tsconfig.json b/backend/tsconfig.json index a4b3abf674..bc27281c08 100644 --- a/backend/tsconfig.json +++ b/backend/tsconfig.json @@ -16,5 +16,5 @@ } }, "include": ["src/**/*"], - "exclude": ["node_modules", "dist"] + "exclude": ["node_modules", "dist", "src/**/*.test.ts", "src/**/__tests__/**"] }