- Port #11015 to handle sockets - Fix `AccessDenied` errors to properly return 404 in specific scenarios - Add optional `debug` flag
137 lines
4.4 KiB
TypeScript
137 lines
4.4 KiB
TypeScript
import type { CollectionConfig } from 'payload'
|
|
import type { Readable } from 'stream'
|
|
|
|
import type { CollectionCachingConfig, PluginOptions, StaticHandler } from './types.js'
|
|
|
|
import { createKey } from './utilities/createKey.js'
|
|
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
|
|
const streamToBuffer = async (readableStream: any) => {
|
|
const chunks = []
|
|
for await (const chunk of readableStream) {
|
|
chunks.push(typeof chunk === 'string' ? Buffer.from(chunk) : chunk)
|
|
}
|
|
return Buffer.concat(chunks)
|
|
}
|
|
|
|
export const getStaticHandler = ({ cachingOptions, collection, debug }: Args): StaticHandler => {
|
|
let maxAge = 86400 // 24 hours default
|
|
let collCacheConfig: CollectionCachingConfig | undefined
|
|
if (cachingOptions !== false) {
|
|
// Set custom maxAge for all collections
|
|
maxAge = cachingOptions?.maxAge || maxAge
|
|
collCacheConfig = cachingOptions?.collections?.[collection.slug] || {}
|
|
}
|
|
|
|
// Set maxAge using collection-specific override
|
|
maxAge = collCacheConfig?.maxAge || maxAge
|
|
|
|
const cachingEnabled =
|
|
cachingOptions !== false &&
|
|
!!process.env.PAYLOAD_CLOUD_CACHE_KEY &&
|
|
collCacheConfig?.enabled !== false
|
|
|
|
return async (req, { params }) => {
|
|
let key = ''
|
|
try {
|
|
const { identityID, storageClient } = await getStorageClient()
|
|
|
|
key = createKey({
|
|
collection: collection.slug,
|
|
filename: params.filename,
|
|
identityID,
|
|
})
|
|
|
|
const object = await storageClient.getObject({
|
|
Bucket: process.env.PAYLOAD_CLOUD_BUCKET,
|
|
Key: key,
|
|
})
|
|
|
|
if (!object.Body || !object.ContentType || !object.ETag) {
|
|
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, {
|
|
headers: new Headers({
|
|
'Content-Length': String(object.ContentLength),
|
|
'Content-Type': object.ContentType,
|
|
...(cachingEnabled && { 'Cache-Control': `public, max-age=${maxAge}` }),
|
|
ETag: object.ETag,
|
|
}),
|
|
status: 200,
|
|
})
|
|
} catch (err: unknown) {
|
|
// Handle each error explicitly
|
|
if (err instanceof Error) {
|
|
/**
|
|
* 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.name === 'AccessDenied') {
|
|
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' })
|
|
} 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({
|
|
collectionSlug: collection.slug,
|
|
err,
|
|
msg: `Error getting file from cloud storage: ${params.filename}`,
|
|
params,
|
|
requestedKey: key,
|
|
})
|
|
return new Response('Internal Server Error', { status: 500 })
|
|
}
|
|
}
|
|
}
|