fix(payload-cloud): handle socket closures (#11113)

- Port #11015 to handle sockets
- Fix `AccessDenied` errors to properly return 404 in specific scenarios
- Add optional `debug` flag
This commit is contained in:
Elliot DeNolf
2025-02-11 12:50:46 -05:00
committed by GitHub
parent ae4a78b298
commit da77f99df4
4 changed files with 63 additions and 21 deletions

View File

@@ -69,6 +69,7 @@ export const payloadCloudPlugin =
getStaticHandler({
cachingOptions: pluginOptions?.uploadCaching,
collection,
debug: pluginOptions?.debug,
}),
],
},

View File

@@ -1,4 +1,5 @@
import type { CollectionConfig } from 'payload'
import type { Readable } from 'stream'
import type { CollectionCachingConfig, PluginOptions, StaticHandler } from './types.js'
@@ -8,6 +9,19 @@ import { getStorageClient } from './utilities/getStorageClient.js'
interface Args {
cachingOptions?: PluginOptions['uploadCaching']
collection: CollectionConfig
debug?: boolean
}
// Type guard for NodeJS.Readable streams
const isNodeReadableStream = (body: unknown): body is Readable => {
return (
typeof body === 'object' &&
body !== null &&
'pipe' in body &&
typeof (body as any).pipe === 'function' &&
'destroy' in body &&
typeof (body as any).destroy === 'function'
)
}
// Convert a stream into a promise that resolves with a Buffer
@@ -19,7 +33,7 @@ const streamToBuffer = async (readableStream: any) => {
return Buffer.concat(chunks)
}
export const getStaticHandler = ({ cachingOptions, collection }: Args): StaticHandler => {
export const getStaticHandler = ({ cachingOptions, collection, debug }: Args): StaticHandler => {
let maxAge = 86400 // 24 hours default
let collCacheConfig: CollectionCachingConfig | undefined
if (cachingOptions !== false) {
@@ -56,6 +70,19 @@ export const getStaticHandler = ({ cachingOptions, collection }: Args): StaticHa
return new Response(null, { status: 404, statusText: 'Not Found' })
}
// On error, manually destroy stream to close socket
if (object.Body && isNodeReadableStream(object.Body)) {
const stream = object.Body
stream.on('error', (err) => {
req.payload.logger.error({
err,
key,
msg: 'Error streaming S3 object, destroying stream',
})
stream.destroy()
})
}
const bodyBuffer = await streamToBuffer(object.Body)
return new Response(bodyBuffer, {
@@ -68,25 +95,32 @@ export const getStaticHandler = ({ cachingOptions, collection }: Args): StaticHa
status: 200,
})
} catch (err: unknown) {
// Handle each error explicitly
if (err instanceof Error) {
/**
* If object key does not found, the getObject function attempts a ListBucket operation.
* Because of permissions, this will throw very specific error that we can catch and handle.
* Note: If AccessDenied comes back, it typically means that the object key is not found.
* The AWS SDK throws this because it attempts an s3:ListBucket operation under the hood
* if it does not find the object key, which we have disallowed in our bucket policy.
*/
if (
err instanceof Error &&
err.name === 'AccessDenied' &&
err.message?.includes('s3:ListBucket') &&
'type' in err &&
err.type === 'S3ServiceException'
) {
req.payload.logger.error({
if (err.name === 'AccessDenied') {
req.payload.logger.warn({
awsErr: debug ? err : err.name,
collectionSlug: collection.slug,
err,
msg: `Requested file not found in cloud storage: ${params.filename}`,
params,
requestedKey: key,
})
return new Response(null, { status: 404, statusText: 'Not Found' })
} else if (err.name === 'NoSuchKey') {
req.payload.logger.warn({
awsErr: debug ? err : err.name,
collectionSlug: collection.slug,
msg: `Requested file not found in cloud storage: ${params.filename}`,
params,
requestedKey: key,
})
return new Response(null, { status: 404, statusText: 'Not Found' })
}
}
req.payload.logger.error({

View File

@@ -54,6 +54,13 @@ export interface PayloadCloudEmailOptions {
}
export interface PluginOptions {
/**
* Enable additional debug logging
*
* @default false
*/
debug?: boolean
/** Payload Cloud Email
* @default true
*/

View File

@@ -31,7 +31,7 @@
}
],
"paths": {
"@payload-config": ["./test/_community/config.ts"],
"@payload-config": ["./test/payload-cloud/config.ts"],
"@payloadcms/live-preview": ["./packages/live-preview/src"],
"@payloadcms/live-preview-react": ["./packages/live-preview-react/src/index.ts"],
"@payloadcms/live-preview-vue": ["./packages/live-preview-vue/src/index.ts"],