From 1ad355c21ea059a6fc1ee109f7d6eb4e5fa893e7 Mon Sep 17 00:00:00 2001 From: SimoneMariaRomeo <180769497+SimoneMariaRomeo@users.noreply.github.com> Date: Mon, 11 May 2026 22:50:32 +0700 Subject: [PATCH 1/6] feat: add TypeScript source --- index.d.ts | 26 ++++++ index.js | 232 +++++++++++++++++++++++----------------------- index.ts | 184 ++++++++++++++++++++++++++++++++++++ package-lock.json | 36 ++++--- package.json | 9 +- tsconfig.json | 15 +++ 6 files changed, 373 insertions(+), 129 deletions(-) create mode 100644 index.d.ts create mode 100644 index.ts create mode 100644 tsconfig.json diff --git a/index.d.ts b/index.d.ts new file mode 100644 index 0000000..592bfd2 --- /dev/null +++ b/index.d.ts @@ -0,0 +1,26 @@ +import { type StorageOptions } from '@google-cloud/storage'; +interface OtherOptions { + bucketPrefix?: string; + directAccess?: boolean; +} +interface GCSAdapterOptions extends StorageOptions { + bucket?: string; + bucketPrefix?: string; + directAccess?: boolean; +} +type GCSAdapterArgs = [] | [GCSAdapterOptions] | [string, string | undefined, string | undefined, OtherOptions?]; +declare class GCSAdapter { + private readonly _bucket; + private readonly _bucketPrefix; + private readonly _directAccess; + private _gcsClient; + constructor(...args: GCSAdapterArgs); + createFile(filename: string, data: Buffer | string, contentType?: string): Promise; + deleteFile(filename: string): Promise; + getFileData(filename: string): Promise; + getFileLocation(config: { + mount: string; + applicationId: string; + }, filename: string): string; +} +export default GCSAdapter; diff --git a/index.js b/index.js index 88a0cce..77b0ade 100644 --- a/index.js +++ b/index.js @@ -1,44 +1,46 @@ -'use strict'; -// GCSAdapter -// Store Parse Files in Google Cloud Storage: https://cloud.google.com/storage -const { Storage } = require('@google-cloud/storage'); - +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +const storage_1 = require("@google-cloud/storage"); function requiredOrFromEnvironment(options, key, env) { - options[key] = options[key] || process.env[env]; - if (!options[key]) { - throw `GCSAdapter requires an ${key}`; - } - return options; + options[key] = options[key] || process.env[env]; + if (!options[key]) { + throw `GCSAdapter requires an ${key}`; + } + return options; } - -function fromEnvironmentOrDefault(options, key, env, defaultValue) { - options[key] = options[key] || process.env[env] || defaultValue; - return options; +function stringFromEnvironmentOrDefault(options, key, env, defaultValue) { + options[key] = options[key] || process.env[env] || defaultValue; + return options; +} +function booleanFromEnvironmentOrDefault(options, key, env, defaultValue) { + if (typeof options[key] !== 'boolean') { + options[key] = process.env[env] === 'true' || defaultValue; + } + return options; } - function optionsFromArguments(args) { - let options = {}; - let projectIdOrOptions = args[0]; - if (typeof projectIdOrOptions == 'string') { - options.projectId = projectIdOrOptions; - options.keyFilename = args[1]; - options.bucket = args[2]; - let otherOptions = args[3]; - if (otherOptions) { - options.bucketPrefix = otherOptions.bucketPrefix; - options.directAccess = otherOptions.directAccess; + let options = {}; + const projectIdOrOptions = args[0]; + if (typeof projectIdOrOptions === 'string') { + options.projectId = projectIdOrOptions; + options.keyFilename = args[1]; + options.bucket = args[2]; + const otherOptions = args[3]; + if (otherOptions) { + options.bucketPrefix = otherOptions.bucketPrefix; + options.directAccess = otherOptions.directAccess; + } + } + else { + options = { ...projectIdOrOptions }; } - } else { - options = Object.assign({}, projectIdOrOptions); - } - options = fromEnvironmentOrDefault(options, 'projectId', 'GCP_PROJECT_ID', undefined); - options = fromEnvironmentOrDefault(options, 'keyFilename', 'GCP_KEYFILE_PATH', undefined); - options = requiredOrFromEnvironment(options, 'bucket', 'GCS_BUCKET'); - options = fromEnvironmentOrDefault(options, 'bucketPrefix', 'GCS_BUCKET_PREFIX', ''); - options = fromEnvironmentOrDefault(options, 'directAccess', 'GCS_DIRECT_ACCESS', false); - return options; + options = stringFromEnvironmentOrDefault(options, 'projectId', 'GCP_PROJECT_ID'); + options = stringFromEnvironmentOrDefault(options, 'keyFilename', 'GCP_KEYFILE_PATH'); + options = requiredOrFromEnvironment(options, 'bucket', 'GCS_BUCKET'); + options = stringFromEnvironmentOrDefault(options, 'bucketPrefix', 'GCS_BUCKET_PREFIX', ''); + options = booleanFromEnvironmentOrDefault(options, 'directAccess', 'GCS_DIRECT_ACCESS', false); + return options; } - /* supported options @@ -48,89 +50,89 @@ supported options { bucketPrefix / 'GCS_BUCKET_PREFIX' defaults to '' directAccess / 'GCS_DIRECT_ACCESS' defaults to false */ -function GCSAdapter() { - let options = optionsFromArguments(arguments); - - this._bucket = options.bucket; - this._bucketPrefix = options.bucketPrefix; - this._directAccess = options.directAccess; - - this._gcsClient = new Storage(options); -} - -GCSAdapter.prototype.createFile = function (filename, data, contentType) { - let params = { - metadata: { - contentType: contentType || 'application/octet-stream' +class GCSAdapter { + constructor(...args) { + const options = optionsFromArguments(arguments); + this._bucket = options.bucket; + this._bucketPrefix = options.bucketPrefix; + this._directAccess = options.directAccess; + this._gcsClient = new storage_1.Storage(options); } - }; - - return new Promise((resolve, reject) => { - let file = this._gcsClient.bucket(this._bucket).file(this._bucketPrefix + filename); - // gcloud supports upload(file) not upload(bytes), so we need to stream. - var uploadStream = file.createWriteStream(params); - uploadStream.on('error', (err) => { - return reject(err); - }).on('finish', () => { - // Second call to set public read ACL after object is uploaded. - if (this._directAccess) { - file.makePublic((err, res) => { - if (err !== null) { - return reject(err); - } - resolve(); + createFile(filename, data, contentType) { + const params = { + metadata: { + contentType: contentType || 'application/octet-stream' + } + }; + return new Promise((resolve, reject) => { + const file = this._gcsClient.bucket(this._bucket).file(this._bucketPrefix + filename); + // gcloud supports upload(file) not upload(bytes), so we need to stream. + const uploadStream = file.createWriteStream(params); + uploadStream.on('error', (err) => { + reject(err); + }).on('finish', () => { + // Second call to set public read ACL after object is uploaded. + if (this._directAccess) { + file.makePublic((err) => { + if (err !== null) { + reject(err); + return; + } + resolve(); + }); + } + else { + resolve(); + } + }); + uploadStream.write(data); + uploadStream.end(); }); - } else { - resolve(); - } - }); - uploadStream.write(data); - uploadStream.end(); - }); -} - -GCSAdapter.prototype.deleteFile = function (filename) { - return new Promise((resolve, reject) => { - let file = this._gcsClient.bucket(this._bucket).file(this._bucketPrefix + filename); - file.delete((err, res) => { - if (err !== null) { - return reject(err); - } - resolve(res); - }); - }); -} - -// Search for and return a file if found by filename. -// Returns a promise that succeeds with the buffer result from GCS, or fails with an error. -GCSAdapter.prototype.getFileData = function (filename) { - return new Promise((resolve, reject) => { - let file = this._gcsClient.bucket(this._bucket).file(this._bucketPrefix + filename); - // Check for existence, since gcloud-node seemed to be caching the result - file.exists((err, exists) => { - if (exists) { - file.download((err, data) => { - if (err !== null) { - return reject(err); - } - return resolve(data); + } + deleteFile(filename) { + return new Promise((resolve, reject) => { + const file = this._gcsClient.bucket(this._bucket).file(this._bucketPrefix + filename); + file.delete((err, response) => { + if (err !== null) { + reject(err); + return; + } + resolve(response); + }); }); - } else { - reject(err); - } - }); - }); -} - -// Generates and returns the location of a file stored in GCS for the given request and filename. -// The location is the direct GCS link if the option is set, -// otherwise we serve the file through parse-server. -GCSAdapter.prototype.getFileLocation = function (config, filename) { - if (this._directAccess) { - return `https://storage.googleapis.com/${this._bucket}/${this._bucketPrefix + filename}`; - } - return (config.mount + '/files/' + config.applicationId + '/' + encodeURIComponent(filename)); + } + // Search for and return a file if found by filename. + // Returns a promise that succeeds with the buffer result from GCS, or fails with an error. + getFileData(filename) { + return new Promise((resolve, reject) => { + const file = this._gcsClient.bucket(this._bucket).file(this._bucketPrefix + filename); + // Check for existence, since gcloud-node seemed to be caching the result + file.exists((err, exists) => { + if (exists) { + file.download((downloadError, data) => { + if (downloadError !== null) { + reject(downloadError); + return; + } + resolve(data); + }); + } + else { + reject(err); + } + }); + }); + } + // Generates and returns the location of a file stored in GCS for the given request and filename. + // The location is the direct GCS link if the option is set, + // otherwise we serve the file through parse-server. + getFileLocation(config, filename) { + if (this._directAccess) { + return `https://storage.googleapis.com/${this._bucket}/${this._bucketPrefix + filename}`; + } + return `${config.mount}/files/${config.applicationId}/${encodeURIComponent(filename)}`; + } } - +exports.default = GCSAdapter; module.exports = GCSAdapter; module.exports.default = GCSAdapter; diff --git a/index.ts b/index.ts new file mode 100644 index 0000000..9d5a35c --- /dev/null +++ b/index.ts @@ -0,0 +1,184 @@ +import { Storage, type StorageOptions } from '@google-cloud/storage'; + +interface OtherOptions { + bucketPrefix?: string; + directAccess?: boolean; +} + +interface GCSAdapterOptions extends StorageOptions { + bucket?: string; + bucketPrefix?: string; + directAccess?: boolean; +} + +type GCSAdapterArgs = + | [] + | [GCSAdapterOptions] + | [string, string | undefined, string | undefined, OtherOptions?]; + +function requiredOrFromEnvironment( + options: GCSAdapterOptions, + key: 'bucket', + env: string +): GCSAdapterOptions { + options[key] = options[key] || process.env[env]; + if (!options[key]) { + throw `GCSAdapter requires an ${key}`; + } + return options; +} + +function stringFromEnvironmentOrDefault( + options: GCSAdapterOptions, + key: 'projectId' | 'keyFilename' | 'bucketPrefix', + env: string, + defaultValue?: string +): GCSAdapterOptions { + options[key] = options[key] || process.env[env] || defaultValue; + return options; +} + +function booleanFromEnvironmentOrDefault( + options: GCSAdapterOptions, + key: 'directAccess', + env: string, + defaultValue: boolean +): GCSAdapterOptions { + if (typeof options[key] !== 'boolean') { + options[key] = process.env[env] === 'true' || defaultValue; + } + return options; +} + +function optionsFromArguments(args: IArguments): GCSAdapterOptions { + let options: GCSAdapterOptions = {}; + const projectIdOrOptions = args[0] as string | GCSAdapterOptions | undefined; + + if (typeof projectIdOrOptions === 'string') { + options.projectId = projectIdOrOptions; + options.keyFilename = args[1] as string | undefined; + options.bucket = args[2] as string | undefined; + const otherOptions = args[3] as OtherOptions | undefined; + if (otherOptions) { + options.bucketPrefix = otherOptions.bucketPrefix; + options.directAccess = otherOptions.directAccess; + } + } else { + options = { ...projectIdOrOptions }; + } + + options = stringFromEnvironmentOrDefault(options, 'projectId', 'GCP_PROJECT_ID'); + options = stringFromEnvironmentOrDefault(options, 'keyFilename', 'GCP_KEYFILE_PATH'); + options = requiredOrFromEnvironment(options, 'bucket', 'GCS_BUCKET'); + options = stringFromEnvironmentOrDefault(options, 'bucketPrefix', 'GCS_BUCKET_PREFIX', ''); + options = booleanFromEnvironmentOrDefault(options, 'directAccess', 'GCS_DIRECT_ACCESS', false); + return options; +} + +/* +supported options + +*projectId / 'GCP_PROJECT_ID' +*keyFilename / 'GCP_KEYFILE_PATH' +*bucket / 'GCS_BUCKET' +{ bucketPrefix / 'GCS_BUCKET_PREFIX' defaults to '' +directAccess / 'GCS_DIRECT_ACCESS' defaults to false +*/ +class GCSAdapter { + private readonly _bucket: string; + private readonly _bucketPrefix: string; + private readonly _directAccess: boolean; + private _gcsClient: Storage; + + constructor(...args: GCSAdapterArgs) { + const options = optionsFromArguments(arguments); + + this._bucket = options.bucket as string; + this._bucketPrefix = options.bucketPrefix as string; + this._directAccess = options.directAccess as boolean; + + this._gcsClient = new Storage(options); + } + + createFile(filename: string, data: Buffer | string, contentType?: string): Promise { + const params = { + metadata: { + contentType: contentType || 'application/octet-stream' + } + }; + + return new Promise((resolve, reject) => { + const file = this._gcsClient.bucket(this._bucket).file(this._bucketPrefix + filename); + // gcloud supports upload(file) not upload(bytes), so we need to stream. + const uploadStream = file.createWriteStream(params); + uploadStream.on('error', (err) => { + reject(err); + }).on('finish', () => { + // Second call to set public read ACL after object is uploaded. + if (this._directAccess) { + file.makePublic((err) => { + if (err !== null) { + reject(err); + return; + } + resolve(); + }); + } else { + resolve(); + } + }); + uploadStream.write(data); + uploadStream.end(); + }); + } + + deleteFile(filename: string): Promise { + return new Promise((resolve, reject) => { + const file = this._gcsClient.bucket(this._bucket).file(this._bucketPrefix + filename); + file.delete((err, response) => { + if (err !== null) { + reject(err); + return; + } + resolve(response); + }); + }); + } + + // Search for and return a file if found by filename. + // Returns a promise that succeeds with the buffer result from GCS, or fails with an error. + getFileData(filename: string): Promise { + return new Promise((resolve, reject) => { + const file = this._gcsClient.bucket(this._bucket).file(this._bucketPrefix + filename); + // Check for existence, since gcloud-node seemed to be caching the result + file.exists((err: Error | null, exists: boolean) => { + if (exists) { + file.download((downloadError, data) => { + if (downloadError !== null) { + reject(downloadError); + return; + } + resolve(data); + }); + } else { + reject(err); + } + }); + }); + } + + // Generates and returns the location of a file stored in GCS for the given request and filename. + // The location is the direct GCS link if the option is set, + // otherwise we serve the file through parse-server. + getFileLocation(config: { mount: string; applicationId: string }, filename: string): string { + if (this._directAccess) { + return `https://storage.googleapis.com/${this._bucket}/${this._bucketPrefix + filename}`; + } + return `${config.mount}/files/${config.applicationId}/${encodeURIComponent(filename)}`; + } +} + +export default GCSAdapter; + +module.exports = GCSAdapter; +module.exports.default = GCSAdapter; diff --git a/package-lock.json b/package-lock.json index 3651800..ec81661 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,12 +18,14 @@ "@semantic-release/github": "11.0.1", "@semantic-release/npm": "12.0.1", "@semantic-release/release-notes-generator": "14.0.1", + "@types/node": "^25.6.2", "codecov": "^3.8.3", "istanbul": "^0.4.2", "jasmine": "^2.4.1", "nyc": "15.1.0", "parse-server-conformance-tests": "^1.0.0", - "semantic-release": "24.2.0" + "semantic-release": "24.2.0", + "typescript": "^6.0.3" } }, "node_modules/@ampproject/remapping": { @@ -67,7 +69,6 @@ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.18.2.tgz", "integrity": "sha512-A8pri1YJiC5UnkdrWcmfZTJTV85b4UXTAfImGmCfYmax4TR9Cw8sDS0MOk++Gp2mE/BefVJ5nwy5yzqNJbP/DQ==", "dev": true, - "peer": true, "dependencies": { "@ampproject/remapping": "^2.1.0", "@babel/code-frame": "^7.16.7", @@ -682,7 +683,6 @@ "resolved": "https://registry.npmjs.org/@octokit/core/-/core-6.1.2.tgz", "integrity": "sha512-hEb7Ma4cGJGEUNOAVmyfdB/3WirWMg5hDuNFVejGEDFqupeOysLc2sG6HJxY2etBp5YQu5Wtxwi020jS9xlUwg==", "dev": true, - "peer": true, "dependencies": { "@octokit/auth-token": "^5.0.0", "@octokit/graphql": "^8.0.0", @@ -1302,12 +1302,12 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "25.3.1", - "resolved": "https://registry.npmjs.org/@types/node/-/node-25.3.1.tgz", - "integrity": "sha512-hj9YIJimBCipHVfHKRMnvmHg+wfhKc0o4mTtXh9pKBjC8TLJzz0nzGmLi5UJsYAUgSvXFHgb0V2oY10DUFtImw==", + "version": "25.6.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.6.2.tgz", + "integrity": "sha512-sokuT28dxf9JT5Kady1fsXOvI4HVpjZa95NKT5y9PNTIrs2AsobR4GFAA90ZG8M+nxVRLysCXsVj6eGC7Vbrlw==", "license": "MIT", "dependencies": { - "undici-types": "~7.18.0" + "undici-types": "~7.19.0" } }, "node_modules/@types/normalize-package-data": { @@ -4234,7 +4234,6 @@ "resolved": "https://registry.npmjs.org/marked/-/marked-12.0.2.tgz", "integrity": "sha512-qXUm7e/YKFoqFPYPa3Ukg9xlI5cyAtGmyEIzMfW//m6kXwCy2Ps9DYf5ioijFKQ8qyuscrHoY04iJGctu2Kg0Q==", "dev": true, - "peer": true, "bin": { "marked": "bin/marked.js" }, @@ -8577,7 +8576,6 @@ "resolved": "https://registry.npmjs.org/semantic-release/-/semantic-release-24.2.0.tgz", "integrity": "sha512-fQfn6e/aYToRtVJYKqneFM1Rg3KP2gh3wSWtpYsLlz6uaPKlISrTzvYAFn+mYWo07F0X1Cz5ucU89AVE8X1mbg==", "dev": true, - "peer": true, "dependencies": { "@semantic-release/commit-analyzer": "^13.0.0-beta.1", "@semantic-release/error": "^4.0.0", @@ -9618,6 +9616,20 @@ "is-typedarray": "^1.0.0" } }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, "node_modules/uglify-js": { "version": "3.15.5", "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.15.5.tgz", @@ -9632,9 +9644,9 @@ } }, "node_modules/undici-types": { - "version": "7.18.2", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", - "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "version": "7.19.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.19.2.tgz", + "integrity": "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==", "license": "MIT" }, "node_modules/unicode-emoji-modifier-base": { diff --git a/package.json b/package.json index 412e65b..7a27a74 100644 --- a/package.json +++ b/package.json @@ -3,6 +3,7 @@ "version": "2.1.0", "description": "Google Cloud Storage adapter for parse-server", "main": "index.js", + "types": "index.d.ts", "keywords": [ "parse-server", "google", @@ -27,15 +28,19 @@ "@semantic-release/github": "11.0.1", "@semantic-release/npm": "12.0.1", "@semantic-release/release-notes-generator": "14.0.1", + "@types/node": "^25.6.2", "codecov": "^3.8.3", "istanbul": "^0.4.2", "jasmine": "^2.4.1", "nyc": "15.1.0", "parse-server-conformance-tests": "^1.0.0", - "semantic-release": "24.2.0" + "semantic-release": "24.2.0", + "typescript": "^6.0.3" }, "scripts": { + "build": "tsc", "test": "jasmine", - "coverage": "nyc jasmine" + "coverage": "nyc jasmine", + "prepack": "npm run build" } } diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..d977332 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "declaration": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "module": "CommonJS", + "noImplicitOverride": true, + "noImplicitReturns": true, + "strict": true, + "target": "ES2019" + }, + "include": [ + "index.ts" + ] +} From 106354e06aab1f1af67f6028a9e137aa7873be09 Mon Sep 17 00:00:00 2001 From: SimoneMariaRomeo <180769497+SimoneMariaRomeo@users.noreply.github.com> Date: Mon, 11 May 2026 22:51:27 +0700 Subject: [PATCH 2/6] test: build TypeScript before tests --- .gitignore | 1 + package.json | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/.gitignore b/.gitignore index e920c16..711f100 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,7 @@ lib-cov # Coverage directory used by tools like istanbul coverage +.nyc_output # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) .grunt diff --git a/package.json b/package.json index 7a27a74..8d5bc1e 100644 --- a/package.json +++ b/package.json @@ -39,8 +39,8 @@ }, "scripts": { "build": "tsc", - "test": "jasmine", - "coverage": "nyc jasmine", + "test": "npm run build && jasmine", + "coverage": "npm run build && nyc jasmine", "prepack": "npm run build" } } From 8d595d6f603bc4b8c6c0a1d3133797d6805b90c8 Mon Sep 17 00:00:00 2001 From: SimoneMariaRomeo <180769497+SimoneMariaRomeo@users.noreply.github.com> Date: Mon, 11 May 2026 22:52:09 +0700 Subject: [PATCH 3/6] test: cover TypeScript export behavior --- spec/test.spec.js | 47 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/spec/test.spec.js b/spec/test.spec.js index 20c9fe3..7f38a51 100644 --- a/spec/test.spec.js +++ b/spec/test.spec.js @@ -37,6 +37,53 @@ describe('GCSAdapter tests', () => { }).not.toThrow(); }); + it('should keep the default export for transpiled imports', () => { + expect(GCSAdapter.default).toBe(GCSAdapter); + }); + + describe('environment options', () => { + let directAccess; + + beforeEach(() => { + directAccess = process.env.GCS_DIRECT_ACCESS; + }); + + afterEach(() => { + if (directAccess === undefined) { + delete process.env.GCS_DIRECT_ACCESS; + } else { + process.env.GCS_DIRECT_ACCESS = directAccess; + } + }); + + it('should parse GCS_DIRECT_ACCESS as a boolean', () => { + process.env.GCS_DIRECT_ACCESS = 'false'; + let proxiedAdapter = new GCSAdapter({ + projectId: 'projectId', + keyFilename: 'keyFilename', + bucket: 'bucket' + }); + + expect(proxiedAdapter.getFileLocation({ + mount: '/parse', + applicationId: 'appId' + }, 'folder/file name.txt')).toBe('/parse/files/appId/folder%2Ffile%20name.txt'); + + process.env.GCS_DIRECT_ACCESS = 'true'; + let directAdapter = new GCSAdapter({ + projectId: 'projectId', + keyFilename: 'keyFilename', + bucket: 'bucket', + bucketPrefix: 'prefix/' + }); + + expect(directAdapter.getFileLocation({ + mount: '/parse', + applicationId: 'appId' + }, 'file.txt')).toBe('https://storage.googleapis.com/bucket/prefix/file.txt'); + }); + }); + describe('deleteFile', () => { let gcsAdapter; let mockStorage; From 5dc957565b93c130fbdab8228ed934953670b592 Mon Sep 17 00:00:00 2001 From: SimoneMariaRomeo <180769497+SimoneMariaRomeo@users.noreply.github.com> Date: Mon, 11 May 2026 23:00:01 +0700 Subject: [PATCH 4/6] fix: harden GCS adapter inputs --- index.d.ts | 1 + index.js | 67 +++++++++++++++++++++++++++++++++++-------- index.ts | 72 +++++++++++++++++++++++++++++++++++++++-------- spec/test.spec.js | 70 ++++++++++++++++++++++++++++++++++++++++++++- 4 files changed, 187 insertions(+), 23 deletions(-) diff --git a/index.d.ts b/index.d.ts index 592bfd2..d3a2e09 100644 --- a/index.d.ts +++ b/index.d.ts @@ -22,5 +22,6 @@ declare class GCSAdapter { mount: string; applicationId: string; }, filename: string): string; + private filePath; } export default GCSAdapter; diff --git a/index.js b/index.js index 77b0ade..d728a9d 100644 --- a/index.js +++ b/index.js @@ -18,6 +18,25 @@ function booleanFromEnvironmentOrDefault(options, key, env, defaultValue) { } return options; } +function contentTypeOrDefault(contentType) { + const resolvedContentType = contentType || 'application/octet-stream'; + if (/[\r\n]/.test(resolvedContentType)) { + throw new Error('GCSAdapter contentType cannot contain line breaks'); + } + return resolvedContentType; +} +function validateFilePath(filePath) { + if (!filePath || filePath.startsWith('/') || filePath.includes('\\')) { + throw new Error('GCSAdapter filename must be a relative Google Cloud Storage object name'); + } + if (filePath.split('/').some((part) => part === '.' || part === '..')) { + throw new Error('GCSAdapter filename cannot contain relative path segments'); + } + return filePath; +} +function encodeGCSObjectName(filePath) { + return filePath.split('/').map(encodeURIComponent).join('/'); +} function optionsFromArguments(args) { let options = {}; const projectIdOrOptions = args[0]; @@ -59,15 +78,23 @@ class GCSAdapter { this._gcsClient = new storage_1.Storage(options); } createFile(filename, data, contentType) { - const params = { - metadata: { - contentType: contentType || 'application/octet-stream' - } - }; + let filePath; + let resolvedContentType; + try { + filePath = this.filePath(filename); + resolvedContentType = contentTypeOrDefault(contentType); + } + catch (err) { + return Promise.reject(err); + } return new Promise((resolve, reject) => { - const file = this._gcsClient.bucket(this._bucket).file(this._bucketPrefix + filename); + const file = this._gcsClient.bucket(this._bucket).file(filePath); // gcloud supports upload(file) not upload(bytes), so we need to stream. - const uploadStream = file.createWriteStream(params); + const uploadStream = file.createWriteStream({ + metadata: { + contentType: resolvedContentType + } + }); uploadStream.on('error', (err) => { reject(err); }).on('finish', () => { @@ -90,8 +117,15 @@ class GCSAdapter { }); } deleteFile(filename) { + let filePath; + try { + filePath = this.filePath(filename); + } + catch (err) { + return Promise.reject(err); + } return new Promise((resolve, reject) => { - const file = this._gcsClient.bucket(this._bucket).file(this._bucketPrefix + filename); + const file = this._gcsClient.bucket(this._bucket).file(filePath); file.delete((err, response) => { if (err !== null) { reject(err); @@ -104,8 +138,15 @@ class GCSAdapter { // Search for and return a file if found by filename. // Returns a promise that succeeds with the buffer result from GCS, or fails with an error. getFileData(filename) { + let filePath; + try { + filePath = this.filePath(filename); + } + catch (err) { + return Promise.reject(err); + } return new Promise((resolve, reject) => { - const file = this._gcsClient.bucket(this._bucket).file(this._bucketPrefix + filename); + const file = this._gcsClient.bucket(this._bucket).file(filePath); // Check for existence, since gcloud-node seemed to be caching the result file.exists((err, exists) => { if (exists) { @@ -118,7 +159,7 @@ class GCSAdapter { }); } else { - reject(err); + reject(err || new Error(`File ${filename} does not exist.`)); } }); }); @@ -127,11 +168,15 @@ class GCSAdapter { // The location is the direct GCS link if the option is set, // otherwise we serve the file through parse-server. getFileLocation(config, filename) { + const filePath = this.filePath(filename); if (this._directAccess) { - return `https://storage.googleapis.com/${this._bucket}/${this._bucketPrefix + filename}`; + return `https://storage.googleapis.com/${this._bucket}/${encodeGCSObjectName(filePath)}`; } return `${config.mount}/files/${config.applicationId}/${encodeURIComponent(filename)}`; } + filePath(filename) { + return validateFilePath(this._bucketPrefix + filename); + } } exports.default = GCSAdapter; module.exports = GCSAdapter; diff --git a/index.ts b/index.ts index 9d5a35c..6ebfea0 100644 --- a/index.ts +++ b/index.ts @@ -50,6 +50,30 @@ function booleanFromEnvironmentOrDefault( return options; } +function contentTypeOrDefault(contentType?: string): string { + const resolvedContentType = contentType || 'application/octet-stream'; + if (/[\r\n]/.test(resolvedContentType)) { + throw new Error('GCSAdapter contentType cannot contain line breaks'); + } + return resolvedContentType; +} + +function validateFilePath(filePath: string): string { + if (!filePath || filePath.startsWith('/') || filePath.includes('\\')) { + throw new Error('GCSAdapter filename must be a relative Google Cloud Storage object name'); + } + + if (filePath.split('/').some((part) => part === '.' || part === '..')) { + throw new Error('GCSAdapter filename cannot contain relative path segments'); + } + + return filePath; +} + +function encodeGCSObjectName(filePath: string): string { + return filePath.split('/').map(encodeURIComponent).join('/'); +} + function optionsFromArguments(args: IArguments): GCSAdapterOptions { let options: GCSAdapterOptions = {}; const projectIdOrOptions = args[0] as string | GCSAdapterOptions | undefined; @@ -101,16 +125,23 @@ class GCSAdapter { } createFile(filename: string, data: Buffer | string, contentType?: string): Promise { - const params = { - metadata: { - contentType: contentType || 'application/octet-stream' - } - }; + let filePath: string; + let resolvedContentType: string; + try { + filePath = this.filePath(filename); + resolvedContentType = contentTypeOrDefault(contentType); + } catch (err) { + return Promise.reject(err); + } return new Promise((resolve, reject) => { - const file = this._gcsClient.bucket(this._bucket).file(this._bucketPrefix + filename); + const file = this._gcsClient.bucket(this._bucket).file(filePath); // gcloud supports upload(file) not upload(bytes), so we need to stream. - const uploadStream = file.createWriteStream(params); + const uploadStream = file.createWriteStream({ + metadata: { + contentType: resolvedContentType + } + }); uploadStream.on('error', (err) => { reject(err); }).on('finish', () => { @@ -133,8 +164,15 @@ class GCSAdapter { } deleteFile(filename: string): Promise { + let filePath: string; + try { + filePath = this.filePath(filename); + } catch (err) { + return Promise.reject(err); + } + return new Promise((resolve, reject) => { - const file = this._gcsClient.bucket(this._bucket).file(this._bucketPrefix + filename); + const file = this._gcsClient.bucket(this._bucket).file(filePath); file.delete((err, response) => { if (err !== null) { reject(err); @@ -148,8 +186,15 @@ class GCSAdapter { // Search for and return a file if found by filename. // Returns a promise that succeeds with the buffer result from GCS, or fails with an error. getFileData(filename: string): Promise { + let filePath: string; + try { + filePath = this.filePath(filename); + } catch (err) { + return Promise.reject(err); + } + return new Promise((resolve, reject) => { - const file = this._gcsClient.bucket(this._bucket).file(this._bucketPrefix + filename); + const file = this._gcsClient.bucket(this._bucket).file(filePath); // Check for existence, since gcloud-node seemed to be caching the result file.exists((err: Error | null, exists: boolean) => { if (exists) { @@ -161,7 +206,7 @@ class GCSAdapter { resolve(data); }); } else { - reject(err); + reject(err || new Error(`File ${filename} does not exist.`)); } }); }); @@ -171,11 +216,16 @@ class GCSAdapter { // The location is the direct GCS link if the option is set, // otherwise we serve the file through parse-server. getFileLocation(config: { mount: string; applicationId: string }, filename: string): string { + const filePath = this.filePath(filename); if (this._directAccess) { - return `https://storage.googleapis.com/${this._bucket}/${this._bucketPrefix + filename}`; + return `https://storage.googleapis.com/${this._bucket}/${encodeGCSObjectName(filePath)}`; } return `${config.mount}/files/${config.applicationId}/${encodeURIComponent(filename)}`; } + + private filePath(filename: string): string { + return validateFilePath(this._bucketPrefix + filename); + } } export default GCSAdapter; diff --git a/spec/test.spec.js b/spec/test.spec.js index 7f38a51..3e1cd28 100644 --- a/spec/test.spec.js +++ b/spec/test.spec.js @@ -80,7 +80,75 @@ describe('GCSAdapter tests', () => { expect(directAdapter.getFileLocation({ mount: '/parse', applicationId: 'appId' - }, 'file.txt')).toBe('https://storage.googleapis.com/bucket/prefix/file.txt'); + }, 'folder/file name.txt')).toBe('https://storage.googleapis.com/bucket/prefix/folder/file%20name.txt'); + }); + }); + + describe('input validation', () => { + let gcsAdapter; + + beforeEach(() => { + gcsAdapter = new GCSAdapter({ + projectId: 'projectId', + keyFilename: 'keyFilename', + bucket: 'bucket' + }); + }); + + it('should reject relative object paths', (done) => { + expect(() => { + gcsAdapter.getFileLocation({ + mount: '/parse', + applicationId: 'appId' + }, '../secret.txt'); + }).toThrowError('GCSAdapter filename cannot contain relative path segments'); + + gcsAdapter.deleteFile('folder/../secret.txt') + .then(() => { + fail('Promise should have rejected'); + done(); + }) + .catch((err) => { + expect(err.message).toBe('GCSAdapter filename cannot contain relative path segments'); + done(); + }); + }); + + it('should reject unsafe content types before uploading', (done) => { + gcsAdapter.createFile('safe.txt', 'data', 'text/plain\r\nx-bad: yes') + .then(() => { + fail('Promise should have rejected'); + done(); + }) + .catch((err) => { + expect(err.message).toBe('GCSAdapter contentType cannot contain line breaks'); + done(); + }); + }); + + it('should reject missing files with an Error when GCS returns no error', (done) => { + let mockExists = jasmine.createSpy('exists'); + let mockFile = { exists: mockExists }; + let mockBucket = jasmine.createSpyObj('bucket', ['file']); + mockBucket.file.and.returnValue(mockFile); + let mockStorage = jasmine.createSpyObj('storage', ['bucket']); + mockStorage.bucket.and.returnValue(mockBucket); + gcsAdapter._gcsClient = mockStorage; + + mockExists.and.callFake((callback) => { + callback(null, false); + }); + + gcsAdapter.getFileData('missing.txt') + .then(() => { + fail('Promise should have rejected'); + done(); + }) + .catch((err) => { + expect(err instanceof Error).toBe(true); + expect(err.message).toBe('File missing.txt does not exist.'); + done(); + }); }); }); From 9125d665cf457daf0bad30d67a8ea00fc010a3ec Mon Sep 17 00:00:00 2001 From: SimoneMariaRomeo <180769497+SimoneMariaRomeo@users.noreply.github.com> Date: Mon, 11 May 2026 23:01:14 +0700 Subject: [PATCH 5/6] fix: throw errors for invalid GCS options --- index.js | 2 +- index.ts | 2 +- spec/test.spec.js | 10 +++++----- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/index.js b/index.js index d728a9d..1836c0d 100644 --- a/index.js +++ b/index.js @@ -4,7 +4,7 @@ const storage_1 = require("@google-cloud/storage"); function requiredOrFromEnvironment(options, key, env) { options[key] = options[key] || process.env[env]; if (!options[key]) { - throw `GCSAdapter requires an ${key}`; + throw new Error(`GCSAdapter requires an ${key}`); } return options; } diff --git a/index.ts b/index.ts index 6ebfea0..d0e6022 100644 --- a/index.ts +++ b/index.ts @@ -23,7 +23,7 @@ function requiredOrFromEnvironment( ): GCSAdapterOptions { options[key] = options[key] || process.env[env]; if (!options[key]) { - throw `GCSAdapter requires an ${key}`; + throw new Error(`GCSAdapter requires an ${key}`); } return options; } diff --git a/spec/test.spec.js b/spec/test.spec.js index 3e1cd28..a86cea6 100644 --- a/spec/test.spec.js +++ b/spec/test.spec.js @@ -8,23 +8,23 @@ describe('GCSAdapter tests', () => { it('should throw when not initialized properly', () => { expect(() => { return new GCSAdapter(); - }).toThrow('GCSAdapter requires an bucket'); + }).toThrowError('GCSAdapter requires an bucket'); expect(() => { return new GCSAdapter('projectId'); - }).toThrow('GCSAdapter requires an bucket'); + }).toThrowError('GCSAdapter requires an bucket'); expect(() => { return new GCSAdapter('projectId', 'keyFilename'); - }).toThrow('GCSAdapter requires an bucket'); + }).toThrowError('GCSAdapter requires an bucket'); expect(() => { return new GCSAdapter({ projectId: 'projectId' }); - }).toThrow('GCSAdapter requires an bucket'); + }).toThrowError('GCSAdapter requires an bucket'); expect(() => { return new GCSAdapter({ projectId: 'projectId', keyFilename: 'keyFilename' }); - }).toThrow('GCSAdapter requires an bucket'); + }).toThrowError('GCSAdapter requires an bucket'); }); it('should not throw when initialized properly', () => { From 49c02274d21b9f16c37f33358d710679e10b5294 Mon Sep 17 00:00:00 2001 From: SimoneMariaRomeo <180769497+SimoneMariaRomeo@users.noreply.github.com> Date: Tue, 12 May 2026 21:01:05 +0700 Subject: [PATCH 6/6] Preserve explicit empty string options --- index.js | 4 +++- index.ts | 4 +++- spec/test.spec.js | 24 ++++++++++++++++++++++++ 3 files changed, 30 insertions(+), 2 deletions(-) diff --git a/index.js b/index.js index 1836c0d..e302ff0 100644 --- a/index.js +++ b/index.js @@ -9,7 +9,9 @@ function requiredOrFromEnvironment(options, key, env) { return options; } function stringFromEnvironmentOrDefault(options, key, env, defaultValue) { - options[key] = options[key] || process.env[env] || defaultValue; + if (options[key] === undefined) { + options[key] = process.env[env] !== undefined ? process.env[env] : defaultValue; + } return options; } function booleanFromEnvironmentOrDefault(options, key, env, defaultValue) { diff --git a/index.ts b/index.ts index d0e6022..a527777 100644 --- a/index.ts +++ b/index.ts @@ -34,7 +34,9 @@ function stringFromEnvironmentOrDefault( env: string, defaultValue?: string ): GCSAdapterOptions { - options[key] = options[key] || process.env[env] || defaultValue; + if (options[key] === undefined) { + options[key] = process.env[env] !== undefined ? process.env[env] : defaultValue; + } return options; } diff --git a/spec/test.spec.js b/spec/test.spec.js index a86cea6..0f0f4d5 100644 --- a/spec/test.spec.js +++ b/spec/test.spec.js @@ -43,9 +43,11 @@ describe('GCSAdapter tests', () => { describe('environment options', () => { let directAccess; + let bucketPrefix; beforeEach(() => { directAccess = process.env.GCS_DIRECT_ACCESS; + bucketPrefix = process.env.GCS_BUCKET_PREFIX; }); afterEach(() => { @@ -54,6 +56,12 @@ describe('GCSAdapter tests', () => { } else { process.env.GCS_DIRECT_ACCESS = directAccess; } + + if (bucketPrefix === undefined) { + delete process.env.GCS_BUCKET_PREFIX; + } else { + process.env.GCS_BUCKET_PREFIX = bucketPrefix; + } }); it('should parse GCS_DIRECT_ACCESS as a boolean', () => { @@ -82,6 +90,22 @@ describe('GCSAdapter tests', () => { applicationId: 'appId' }, 'folder/file name.txt')).toBe('https://storage.googleapis.com/bucket/prefix/folder/file%20name.txt'); }); + + it('should preserve an explicit empty bucketPrefix over the environment', () => { + process.env.GCS_BUCKET_PREFIX = 'env-prefix/'; + let adapter = new GCSAdapter({ + projectId: 'projectId', + keyFilename: 'keyFilename', + bucket: 'bucket', + bucketPrefix: '', + directAccess: true + }); + + expect(adapter.getFileLocation({ + mount: '/parse', + applicationId: 'appId' + }, 'folder/file name.txt')).toBe('https://storage.googleapis.com/bucket/folder/file%20name.txt'); + }); }); describe('input validation', () => {