@open-mercato/storage-s3 0.6.6-develop.6513.1.12f0383844 → 0.6.6-develop.6516.1.86a8538145
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
|
@@ -99,18 +99,20 @@ class S3StorageDriver {
|
|
|
99
99
|
}
|
|
100
100
|
}
|
|
101
101
|
async toLocalPath(partitionCode, storagePath) {
|
|
102
|
-
const tmpDir = path.join(os.tmpdir(),
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
const filePath = path.join(tmpDir, fileName);
|
|
106
|
-
const { buffer } = await this.read(partitionCode, storagePath);
|
|
107
|
-
await fs.writeFile(filePath, buffer);
|
|
108
|
-
return {
|
|
109
|
-
filePath,
|
|
110
|
-
cleanup: async () => {
|
|
111
|
-
await fs.rm(tmpDir, { recursive: true, force: true });
|
|
112
|
-
}
|
|
102
|
+
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "s3-tmp-"));
|
|
103
|
+
const cleanup = async () => {
|
|
104
|
+
await fs.rm(tmpDir, { recursive: true, force: true });
|
|
113
105
|
};
|
|
106
|
+
try {
|
|
107
|
+
const fileName = path.basename(storagePath) || "download";
|
|
108
|
+
const filePath = path.join(tmpDir, fileName);
|
|
109
|
+
const { buffer } = await this.read(partitionCode, storagePath);
|
|
110
|
+
await fs.writeFile(filePath, buffer, { flag: "wx", mode: 384 });
|
|
111
|
+
return { filePath, cleanup };
|
|
112
|
+
} catch (error) {
|
|
113
|
+
await Promise.allSettled([cleanup()]);
|
|
114
|
+
throw error;
|
|
115
|
+
}
|
|
114
116
|
}
|
|
115
117
|
/**
|
|
116
118
|
* Put an object directly at a specific S3 key (for standalone usage).
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../src/modules/storage_s3/lib/s3-driver.ts"],
|
|
4
|
-
"sourcesContent": ["import { randomUUID } from 'crypto'\nimport os from 'os'\nimport path from 'path'\nimport { promises as fs } from 'fs'\nimport {\n S3Client,\n PutObjectCommand,\n GetObjectCommand,\n DeleteObjectCommand,\n ListObjectsV2Command,\n} from '@aws-sdk/client-s3'\nimport { getSignedUrl } from '@aws-sdk/s3-request-presigner'\nimport type { StorageDriver, StoreFilePayload, StoredFile, ReadFileResult } from '@open-mercato/core/modules/attachments/lib/drivers'\n\nexport type S3DriverConfig = {\n bucket: string\n region?: string\n endpoint?: string\n pathPrefix?: string\n forcePathStyle?: boolean\n /**\n * authMode:\n * - access_keys: use explicit credentials (direct values or env prefix)\n * - ambient: let AWS SDK resolve credentials from the default chain (STS/IRSA/instance profile/etc.)\n *\n * Backward compat: missing authMode behaves like access_keys when keys are provided; otherwise ambient.\n */\n authMode?: 'access_keys' | 'ambient'\n /** Resolve access key + secret from env vars: {PREFIX}_ACCESS_KEY_ID / {PREFIX}_SECRET_ACCESS_KEY */\n credentialsEnvPrefix?: string\n /** Direct credentials (used when passed from Integration Marketplace) */\n accessKeyId?: string\n secretAccessKey?: string\n sessionToken?: string\n}\n\nfunction sanitizeFileName(fileName: string): string {\n if (!fileName) return 'file'\n return fileName.replace(/[^a-zA-Z0-9._-]/g, '_')\n}\n\nfunction resolveOrgSegment(orgId: string | null | undefined): string {\n if (typeof orgId === 'string' && orgId.trim().length > 0) return `org_${orgId}`\n return 'org_shared'\n}\n\nfunction resolveTenantSegment(tenantId: string | null | undefined): string {\n if (typeof tenantId === 'string' && tenantId.trim().length > 0) return `tenant_${tenantId}`\n return 'tenant_shared'\n}\n\nasync function streamToBuffer(stream: NodeJS.ReadableStream): Promise<Buffer> {\n return new Promise((resolve, reject) => {\n const chunks: Buffer[] = []\n stream.on('data', (chunk: Buffer) => chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)))\n stream.on('end', () => resolve(Buffer.concat(chunks)))\n stream.on('error', reject)\n })\n}\n\nexport class S3StorageDriver implements StorageDriver {\n readonly key = 's3'\n private readonly client: S3Client\n private readonly bucket: string\n private readonly pathPrefix: string\n\n constructor(config: Record<string, unknown>) {\n const cfg = config as S3DriverConfig\n this.bucket = cfg.bucket\n this.pathPrefix = cfg.pathPrefix ?? ''\n\n const authMode = cfg.authMode\n const shouldUseAccessKeys =\n authMode === 'access_keys'\n || (\n authMode !== 'ambient'\n && ((cfg.accessKeyId && cfg.secretAccessKey) || Boolean(cfg.credentialsEnvPrefix))\n )\n\n let credentials: { accessKeyId: string; secretAccessKey: string; sessionToken?: string } | undefined\n if (shouldUseAccessKeys) {\n if (cfg.credentialsEnvPrefix) {\n const prefix = cfg.credentialsEnvPrefix\n const accessKeyId = process.env[`${prefix}_ACCESS_KEY_ID`]\n const secretAccessKey = process.env[`${prefix}_SECRET_ACCESS_KEY`]\n const sessionToken = process.env[`${prefix}_SESSION_TOKEN`]\n if (accessKeyId && secretAccessKey) {\n credentials = { accessKeyId, secretAccessKey, ...(sessionToken ? { sessionToken } : {}) }\n }\n } else if (cfg.accessKeyId && cfg.secretAccessKey) {\n credentials = {\n accessKeyId: cfg.accessKeyId,\n secretAccessKey: cfg.secretAccessKey,\n ...(cfg.sessionToken ? { sessionToken: cfg.sessionToken } : {}),\n }\n }\n }\n\n this.client = new S3Client({\n region: cfg.region ?? 'us-east-1',\n endpoint: cfg.endpoint,\n forcePathStyle: cfg.forcePathStyle ?? false,\n credentials,\n })\n }\n\n async store(payload: StoreFilePayload): Promise<StoredFile> {\n const orgSegment = resolveOrgSegment(payload.orgId ?? null)\n const tenantSegment = resolveTenantSegment(payload.tenantId ?? null)\n const safeName = sanitizeFileName(payload.fileName || 'file')\n const uniqueSuffix = randomUUID().replace(/-/g, '').slice(0, 12)\n const storedName = `${Date.now()}_${uniqueSuffix}_${safeName}`\n const key = `${this.pathPrefix}${payload.partitionCode}/${orgSegment}/${tenantSegment}/${storedName}`\n\n await this.client.send(\n new PutObjectCommand({\n Bucket: this.bucket,\n Key: key,\n Body: payload.buffer,\n ContentLength: payload.buffer.length,\n }),\n )\n\n return { storagePath: key }\n }\n\n async read(_partitionCode: string, storagePath: string): Promise<ReadFileResult> {\n const response = await this.client.send(\n new GetObjectCommand({ Bucket: this.bucket, Key: storagePath }),\n )\n if (!response.Body) {\n throw new Error(`S3 object body is empty for key: ${storagePath}`)\n }\n const buffer = await streamToBuffer(response.Body as NodeJS.ReadableStream)\n return { buffer, contentType: response.ContentType }\n }\n\n async delete(_partitionCode: string, storagePath: string): Promise<void> {\n try {\n await this.client.send(\n new DeleteObjectCommand({ Bucket: this.bucket, Key: storagePath }),\n )\n } catch {\n // best-effort removal\n }\n }\n\n async toLocalPath(\n partitionCode: string,\n storagePath: string,\n ): Promise<{ filePath: string; cleanup: () => Promise<void> }> {\n const tmpDir = path.join(os.tmpdir(),
|
|
5
|
-
"mappings": "AAAA,SAAS,kBAAkB;AAC3B,OAAO,QAAQ;AACf,OAAO,UAAU;AACjB,SAAS,YAAY,UAAU;AAC/B;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,OACK;AACP,SAAS,oBAAoB;AAyB7B,SAAS,iBAAiB,UAA0B;AAClD,MAAI,CAAC,SAAU,QAAO;AACtB,SAAO,SAAS,QAAQ,oBAAoB,GAAG;AACjD;AAEA,SAAS,kBAAkB,OAA0C;AACnE,MAAI,OAAO,UAAU,YAAY,MAAM,KAAK,EAAE,SAAS,EAAG,QAAO,OAAO,KAAK;AAC7E,SAAO;AACT;AAEA,SAAS,qBAAqB,UAA6C;AACzE,MAAI,OAAO,aAAa,YAAY,SAAS,KAAK,EAAE,SAAS,EAAG,QAAO,UAAU,QAAQ;AACzF,SAAO;AACT;AAEA,eAAe,eAAe,QAAgD;AAC5E,SAAO,IAAI,QAAQ,CAAC,SAAS,WAAW;AACtC,UAAM,SAAmB,CAAC;AAC1B,WAAO,GAAG,QAAQ,CAAC,UAAkB,OAAO,KAAK,OAAO,SAAS,KAAK,IAAI,QAAQ,OAAO,KAAK,KAAK,CAAC,CAAC;AACrG,WAAO,GAAG,OAAO,MAAM,QAAQ,OAAO,OAAO,MAAM,CAAC,CAAC;AACrD,WAAO,GAAG,SAAS,MAAM;AAAA,EAC3B,CAAC;AACH;AAEO,MAAM,gBAAyC;AAAA,EAMpD,YAAY,QAAiC;AAL7C,SAAS,MAAM;AAMb,UAAM,MAAM;AACZ,SAAK,SAAS,IAAI;AAClB,SAAK,aAAa,IAAI,cAAc;AAEpC,UAAM,WAAW,IAAI;AACrB,UAAM,sBACJ,aAAa,iBAEX,aAAa,cACR,IAAI,eAAe,IAAI,mBAAoB,QAAQ,IAAI,oBAAoB;AAGpF,QAAI;AACJ,QAAI,qBAAqB;AACvB,UAAI,IAAI,sBAAsB;AAC5B,cAAM,SAAS,IAAI;AACnB,cAAM,cAAc,QAAQ,IAAI,GAAG,MAAM,gBAAgB;AACzD,cAAM,kBAAkB,QAAQ,IAAI,GAAG,MAAM,oBAAoB;AACjE,cAAM,eAAe,QAAQ,IAAI,GAAG,MAAM,gBAAgB;AAC1D,YAAI,eAAe,iBAAiB;AAClC,wBAAc,EAAE,aAAa,iBAAiB,GAAI,eAAe,EAAE,aAAa,IAAI,CAAC,EAAG;AAAA,QAC1F;AAAA,MACF,WAAW,IAAI,eAAe,IAAI,iBAAiB;AACjD,sBAAc;AAAA,UACZ,aAAa,IAAI;AAAA,UACjB,iBAAiB,IAAI;AAAA,UACrB,GAAI,IAAI,eAAe,EAAE,cAAc,IAAI,aAAa,IAAI,CAAC;AAAA,QAC/D;AAAA,MACF;AAAA,IACF;AAEA,SAAK,SAAS,IAAI,SAAS;AAAA,MACzB,QAAQ,IAAI,UAAU;AAAA,MACtB,UAAU,IAAI;AAAA,MACd,gBAAgB,IAAI,kBAAkB;AAAA,MACtC;AAAA,IACF,CAAC;AAAA,EACH;AAAA,EAEA,MAAM,MAAM,SAAgD;AAC1D,UAAM,aAAa,kBAAkB,QAAQ,SAAS,IAAI;AAC1D,UAAM,gBAAgB,qBAAqB,QAAQ,YAAY,IAAI;AACnE,UAAM,WAAW,iBAAiB,QAAQ,YAAY,MAAM;AAC5D,UAAM,eAAe,WAAW,EAAE,QAAQ,MAAM,EAAE,EAAE,MAAM,GAAG,EAAE;AAC/D,UAAM,aAAa,GAAG,KAAK,IAAI,CAAC,IAAI,YAAY,IAAI,QAAQ;AAC5D,UAAM,MAAM,GAAG,KAAK,UAAU,GAAG,QAAQ,aAAa,IAAI,UAAU,IAAI,aAAa,IAAI,UAAU;AAEnG,UAAM,KAAK,OAAO;AAAA,MAChB,IAAI,iBAAiB;AAAA,QACnB,QAAQ,KAAK;AAAA,QACb,KAAK;AAAA,QACL,MAAM,QAAQ;AAAA,QACd,eAAe,QAAQ,OAAO;AAAA,MAChC,CAAC;AAAA,IACH;AAEA,WAAO,EAAE,aAAa,IAAI;AAAA,EAC5B;AAAA,EAEA,MAAM,KAAK,gBAAwB,aAA8C;AAC/E,UAAM,WAAW,MAAM,KAAK,OAAO;AAAA,MACjC,IAAI,iBAAiB,EAAE,QAAQ,KAAK,QAAQ,KAAK,YAAY,CAAC;AAAA,IAChE;AACA,QAAI,CAAC,SAAS,MAAM;AAClB,YAAM,IAAI,MAAM,oCAAoC,WAAW,EAAE;AAAA,IACnE;AACA,UAAM,SAAS,MAAM,eAAe,SAAS,IAA6B;AAC1E,WAAO,EAAE,QAAQ,aAAa,SAAS,YAAY;AAAA,EACrD;AAAA,EAEA,MAAM,OAAO,gBAAwB,aAAoC;AACvE,QAAI;AACF,YAAM,KAAK,OAAO;AAAA,QAChB,IAAI,oBAAoB,EAAE,QAAQ,KAAK,QAAQ,KAAK,YAAY,CAAC;AAAA,MACnE;AAAA,IACF,QAAQ;AAAA,IAER;AAAA,EACF;AAAA,EAEA,MAAM,YACJ,eACA,aAC6D;AAC7D,UAAM,SAAS,KAAK,KAAK,GAAG,OAAO,GAAG,
|
|
4
|
+
"sourcesContent": ["import { randomUUID } from 'crypto'\nimport os from 'os'\nimport path from 'path'\nimport { promises as fs } from 'fs'\nimport {\n S3Client,\n PutObjectCommand,\n GetObjectCommand,\n DeleteObjectCommand,\n ListObjectsV2Command,\n} from '@aws-sdk/client-s3'\nimport { getSignedUrl } from '@aws-sdk/s3-request-presigner'\nimport type { StorageDriver, StoreFilePayload, StoredFile, ReadFileResult } from '@open-mercato/core/modules/attachments/lib/drivers'\n\nexport type S3DriverConfig = {\n bucket: string\n region?: string\n endpoint?: string\n pathPrefix?: string\n forcePathStyle?: boolean\n /**\n * authMode:\n * - access_keys: use explicit credentials (direct values or env prefix)\n * - ambient: let AWS SDK resolve credentials from the default chain (STS/IRSA/instance profile/etc.)\n *\n * Backward compat: missing authMode behaves like access_keys when keys are provided; otherwise ambient.\n */\n authMode?: 'access_keys' | 'ambient'\n /** Resolve access key + secret from env vars: {PREFIX}_ACCESS_KEY_ID / {PREFIX}_SECRET_ACCESS_KEY */\n credentialsEnvPrefix?: string\n /** Direct credentials (used when passed from Integration Marketplace) */\n accessKeyId?: string\n secretAccessKey?: string\n sessionToken?: string\n}\n\nfunction sanitizeFileName(fileName: string): string {\n if (!fileName) return 'file'\n return fileName.replace(/[^a-zA-Z0-9._-]/g, '_')\n}\n\nfunction resolveOrgSegment(orgId: string | null | undefined): string {\n if (typeof orgId === 'string' && orgId.trim().length > 0) return `org_${orgId}`\n return 'org_shared'\n}\n\nfunction resolveTenantSegment(tenantId: string | null | undefined): string {\n if (typeof tenantId === 'string' && tenantId.trim().length > 0) return `tenant_${tenantId}`\n return 'tenant_shared'\n}\n\nasync function streamToBuffer(stream: NodeJS.ReadableStream): Promise<Buffer> {\n return new Promise((resolve, reject) => {\n const chunks: Buffer[] = []\n stream.on('data', (chunk: Buffer) => chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)))\n stream.on('end', () => resolve(Buffer.concat(chunks)))\n stream.on('error', reject)\n })\n}\n\nexport class S3StorageDriver implements StorageDriver {\n readonly key = 's3'\n private readonly client: S3Client\n private readonly bucket: string\n private readonly pathPrefix: string\n\n constructor(config: Record<string, unknown>) {\n const cfg = config as S3DriverConfig\n this.bucket = cfg.bucket\n this.pathPrefix = cfg.pathPrefix ?? ''\n\n const authMode = cfg.authMode\n const shouldUseAccessKeys =\n authMode === 'access_keys'\n || (\n authMode !== 'ambient'\n && ((cfg.accessKeyId && cfg.secretAccessKey) || Boolean(cfg.credentialsEnvPrefix))\n )\n\n let credentials: { accessKeyId: string; secretAccessKey: string; sessionToken?: string } | undefined\n if (shouldUseAccessKeys) {\n if (cfg.credentialsEnvPrefix) {\n const prefix = cfg.credentialsEnvPrefix\n const accessKeyId = process.env[`${prefix}_ACCESS_KEY_ID`]\n const secretAccessKey = process.env[`${prefix}_SECRET_ACCESS_KEY`]\n const sessionToken = process.env[`${prefix}_SESSION_TOKEN`]\n if (accessKeyId && secretAccessKey) {\n credentials = { accessKeyId, secretAccessKey, ...(sessionToken ? { sessionToken } : {}) }\n }\n } else if (cfg.accessKeyId && cfg.secretAccessKey) {\n credentials = {\n accessKeyId: cfg.accessKeyId,\n secretAccessKey: cfg.secretAccessKey,\n ...(cfg.sessionToken ? { sessionToken: cfg.sessionToken } : {}),\n }\n }\n }\n\n this.client = new S3Client({\n region: cfg.region ?? 'us-east-1',\n endpoint: cfg.endpoint,\n forcePathStyle: cfg.forcePathStyle ?? false,\n credentials,\n })\n }\n\n async store(payload: StoreFilePayload): Promise<StoredFile> {\n const orgSegment = resolveOrgSegment(payload.orgId ?? null)\n const tenantSegment = resolveTenantSegment(payload.tenantId ?? null)\n const safeName = sanitizeFileName(payload.fileName || 'file')\n const uniqueSuffix = randomUUID().replace(/-/g, '').slice(0, 12)\n const storedName = `${Date.now()}_${uniqueSuffix}_${safeName}`\n const key = `${this.pathPrefix}${payload.partitionCode}/${orgSegment}/${tenantSegment}/${storedName}`\n\n await this.client.send(\n new PutObjectCommand({\n Bucket: this.bucket,\n Key: key,\n Body: payload.buffer,\n ContentLength: payload.buffer.length,\n }),\n )\n\n return { storagePath: key }\n }\n\n async read(_partitionCode: string, storagePath: string): Promise<ReadFileResult> {\n const response = await this.client.send(\n new GetObjectCommand({ Bucket: this.bucket, Key: storagePath }),\n )\n if (!response.Body) {\n throw new Error(`S3 object body is empty for key: ${storagePath}`)\n }\n const buffer = await streamToBuffer(response.Body as NodeJS.ReadableStream)\n return { buffer, contentType: response.ContentType }\n }\n\n async delete(_partitionCode: string, storagePath: string): Promise<void> {\n try {\n await this.client.send(\n new DeleteObjectCommand({ Bucket: this.bucket, Key: storagePath }),\n )\n } catch {\n // best-effort removal\n }\n }\n\n async toLocalPath(\n partitionCode: string,\n storagePath: string,\n ): Promise<{ filePath: string; cleanup: () => Promise<void> }> {\n const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 's3-tmp-'))\n const cleanup = async () => {\n await fs.rm(tmpDir, { recursive: true, force: true })\n }\n\n try {\n const fileName = path.basename(storagePath) || 'download'\n const filePath = path.join(tmpDir, fileName)\n const { buffer } = await this.read(partitionCode, storagePath)\n await fs.writeFile(filePath, buffer, { flag: 'wx', mode: 0o600 })\n return { filePath, cleanup }\n } catch (error) {\n await Promise.allSettled([cleanup()])\n throw error\n }\n }\n\n /**\n * Put an object directly at a specific S3 key (for standalone usage).\n */\n async putObject(key: string, buffer: Buffer, contentType?: string): Promise<void> {\n await this.client.send(\n new PutObjectCommand({\n Bucket: this.bucket,\n Key: key,\n Body: buffer,\n ContentType: contentType,\n ContentLength: buffer.length,\n }),\n )\n }\n\n getBucket(): string {\n return this.bucket\n }\n\n async listObjects(\n prefix: string,\n maxKeys = 100,\n continuationToken?: string,\n ): Promise<{\n files: Array<{ key: string; size: number; lastModified: Date }>\n truncated: boolean\n nextContinuationToken?: string\n }> {\n const response = await this.client.send(\n new ListObjectsV2Command({\n Bucket: this.bucket,\n Prefix: prefix,\n MaxKeys: maxKeys,\n ContinuationToken: continuationToken,\n }),\n )\n return {\n files: (response.Contents ?? []).map((obj) => ({\n key: obj.Key ?? '',\n size: obj.Size ?? 0,\n lastModified: obj.LastModified ?? new Date(0),\n })),\n truncated: response.IsTruncated ?? false,\n nextContinuationToken: response.NextContinuationToken,\n }\n }\n\n /**\n * Generate a pre-signed URL for direct browser upload or download.\n */\n async getSignedUrl(\n storagePath: string,\n operation: 'upload' | 'download',\n expiresIn = 3600,\n contentType?: string,\n ): Promise<string> {\n if (operation === 'upload') {\n const command = new PutObjectCommand({\n Bucket: this.bucket,\n Key: storagePath,\n ContentType: contentType,\n })\n return getSignedUrl(this.client, command, { expiresIn })\n }\n const command = new GetObjectCommand({ Bucket: this.bucket, Key: storagePath })\n return getSignedUrl(this.client, command, { expiresIn })\n }\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": ["command"]
|
|
7
7
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@open-mercato/storage-s3",
|
|
3
|
-
"version": "0.6.6-develop.
|
|
3
|
+
"version": "0.6.6-develop.6516.1.86a8538145",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -69,14 +69,14 @@
|
|
|
69
69
|
"dependencies": {
|
|
70
70
|
"@aws-sdk/client-s3": "^3.1075.0",
|
|
71
71
|
"@aws-sdk/s3-request-presigner": "^3.1075.0",
|
|
72
|
-
"@open-mercato/core": "0.6.6-develop.
|
|
72
|
+
"@open-mercato/core": "0.6.6-develop.6516.1.86a8538145"
|
|
73
73
|
},
|
|
74
74
|
"peerDependencies": {
|
|
75
75
|
"@mikro-orm/postgresql": "^6.6.10",
|
|
76
|
-
"@open-mercato/shared": "0.6.6-develop.
|
|
76
|
+
"@open-mercato/shared": "0.6.6-develop.6516.1.86a8538145"
|
|
77
77
|
},
|
|
78
78
|
"devDependencies": {
|
|
79
|
-
"@open-mercato/shared": "0.6.6-develop.
|
|
79
|
+
"@open-mercato/shared": "0.6.6-develop.6516.1.86a8538145",
|
|
80
80
|
"@types/jest": "^30.0.0",
|
|
81
81
|
"esbuild": "^0.28.1",
|
|
82
82
|
"glob": "^13.0.6",
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
/** @jest-environment node */
|
|
2
|
+
import path from 'path'
|
|
2
3
|
import { Readable } from 'stream'
|
|
3
4
|
|
|
4
5
|
const mockSend = jest.fn()
|
|
@@ -17,12 +18,14 @@ jest.mock('@aws-sdk/s3-request-presigner', () => ({
|
|
|
17
18
|
}))
|
|
18
19
|
|
|
19
20
|
const mockMkdir = jest.fn().mockResolvedValue(undefined)
|
|
21
|
+
const mockMkdtemp = jest.fn().mockResolvedValue('/tmp/s3-tmp-secure')
|
|
20
22
|
const mockWriteFile = jest.fn().mockResolvedValue(undefined)
|
|
21
23
|
const mockRm = jest.fn().mockResolvedValue(undefined)
|
|
22
24
|
|
|
23
25
|
jest.mock('fs', () => ({
|
|
24
26
|
promises: {
|
|
25
27
|
mkdir: (...a: unknown[]) => mockMkdir(...a),
|
|
28
|
+
mkdtemp: (...a: unknown[]) => mockMkdtemp(...a),
|
|
26
29
|
writeFile: (...a: unknown[]) => mockWriteFile(...a),
|
|
27
30
|
rm: (...a: unknown[]) => mockRm(...a),
|
|
28
31
|
},
|
|
@@ -41,6 +44,12 @@ beforeEach(() => {
|
|
|
41
44
|
mockSend.mockReset()
|
|
42
45
|
mockGetSignedUrl.mockReset()
|
|
43
46
|
mockGetSignedUrl.mockResolvedValue('https://presigned.example.com/object?sig=abc')
|
|
47
|
+
mockMkdtemp.mockReset()
|
|
48
|
+
mockMkdtemp.mockResolvedValue('/tmp/s3-tmp-secure')
|
|
49
|
+
mockWriteFile.mockReset()
|
|
50
|
+
mockWriteFile.mockResolvedValue(undefined)
|
|
51
|
+
mockRm.mockReset()
|
|
52
|
+
mockRm.mockResolvedValue(undefined)
|
|
44
53
|
;(S3Client as jest.Mock).mockClear()
|
|
45
54
|
})
|
|
46
55
|
|
|
@@ -195,6 +204,55 @@ describe('S3StorageDriver', () => {
|
|
|
195
204
|
})
|
|
196
205
|
})
|
|
197
206
|
|
|
207
|
+
describe('toLocalPath()', () => {
|
|
208
|
+
it('creates an owner-only temporary file without following an existing path', async () => {
|
|
209
|
+
mockSend.mockResolvedValueOnce({ Body: makeStream(Buffer.from('sensitive')) })
|
|
210
|
+
const driver = new S3StorageDriver(BASE_CONFIG)
|
|
211
|
+
|
|
212
|
+
const result = await driver.toLocalPath('', 'docs/private.pdf')
|
|
213
|
+
|
|
214
|
+
expect(mockMkdtemp).toHaveBeenCalledWith(expect.stringMatching(/s3-tmp-$/))
|
|
215
|
+
expect(mockWriteFile).toHaveBeenCalledWith(
|
|
216
|
+
path.join('/tmp/s3-tmp-secure', 'private.pdf'),
|
|
217
|
+
Buffer.from('sensitive'),
|
|
218
|
+
{ flag: 'wx', mode: 0o600 },
|
|
219
|
+
)
|
|
220
|
+
expect(result.filePath).toBe(path.join('/tmp/s3-tmp-secure', 'private.pdf'))
|
|
221
|
+
})
|
|
222
|
+
|
|
223
|
+
it('removes the temporary directory when the S3 download fails', async () => {
|
|
224
|
+
mockSend.mockRejectedValueOnce(new Error('download failed'))
|
|
225
|
+
const driver = new S3StorageDriver(BASE_CONFIG)
|
|
226
|
+
|
|
227
|
+
await expect(driver.toLocalPath('', 'docs/private.pdf')).rejects.toThrow('download failed')
|
|
228
|
+
|
|
229
|
+
expect(mockRm).toHaveBeenCalledWith('/tmp/s3-tmp-secure', { recursive: true, force: true })
|
|
230
|
+
})
|
|
231
|
+
|
|
232
|
+
it('removes partial artifacts without masking a write failure', async () => {
|
|
233
|
+
mockSend.mockResolvedValueOnce({ Body: makeStream(Buffer.from('sensitive')) })
|
|
234
|
+
mockWriteFile.mockRejectedValueOnce(new Error('write failed'))
|
|
235
|
+
mockRm.mockRejectedValueOnce(new Error('cleanup failed'))
|
|
236
|
+
const driver = new S3StorageDriver(BASE_CONFIG)
|
|
237
|
+
|
|
238
|
+
await expect(driver.toLocalPath('', 'docs/private.pdf')).rejects.toThrow('write failed')
|
|
239
|
+
|
|
240
|
+
expect(mockRm).toHaveBeenCalledWith('/tmp/s3-tmp-secure', { recursive: true, force: true })
|
|
241
|
+
})
|
|
242
|
+
|
|
243
|
+
it('returns an idempotent cleanup callback', async () => {
|
|
244
|
+
mockSend.mockResolvedValueOnce({ Body: makeStream(Buffer.from('sensitive')) })
|
|
245
|
+
const driver = new S3StorageDriver(BASE_CONFIG)
|
|
246
|
+
const result = await driver.toLocalPath('', 'docs/private.pdf')
|
|
247
|
+
|
|
248
|
+
await result.cleanup()
|
|
249
|
+
await result.cleanup()
|
|
250
|
+
|
|
251
|
+
expect(mockRm).toHaveBeenNthCalledWith(1, '/tmp/s3-tmp-secure', { recursive: true, force: true })
|
|
252
|
+
expect(mockRm).toHaveBeenNthCalledWith(2, '/tmp/s3-tmp-secure', { recursive: true, force: true })
|
|
253
|
+
})
|
|
254
|
+
})
|
|
255
|
+
|
|
198
256
|
describe('getSignedUrl()', () => {
|
|
199
257
|
it('delegates to @aws-sdk/s3-request-presigner for download', async () => {
|
|
200
258
|
const driver = new S3StorageDriver(BASE_CONFIG)
|
|
@@ -149,17 +149,20 @@ export class S3StorageDriver implements StorageDriver {
|
|
|
149
149
|
partitionCode: string,
|
|
150
150
|
storagePath: string,
|
|
151
151
|
): Promise<{ filePath: string; cleanup: () => Promise<void> }> {
|
|
152
|
-
const tmpDir = path.join(os.tmpdir(),
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
filePath,
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
}
|
|
152
|
+
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 's3-tmp-'))
|
|
153
|
+
const cleanup = async () => {
|
|
154
|
+
await fs.rm(tmpDir, { recursive: true, force: true })
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
try {
|
|
158
|
+
const fileName = path.basename(storagePath) || 'download'
|
|
159
|
+
const filePath = path.join(tmpDir, fileName)
|
|
160
|
+
const { buffer } = await this.read(partitionCode, storagePath)
|
|
161
|
+
await fs.writeFile(filePath, buffer, { flag: 'wx', mode: 0o600 })
|
|
162
|
+
return { filePath, cleanup }
|
|
163
|
+
} catch (error) {
|
|
164
|
+
await Promise.allSettled([cleanup()])
|
|
165
|
+
throw error
|
|
163
166
|
}
|
|
164
167
|
}
|
|
165
168
|
|