Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

add support for proxying images and files always via keystone express server, no matter local or s3, and add security access control extension hook #9001

Open
wants to merge 4 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/pages/docs/config/config.md
Original file line number Diff line number Diff line change
Expand Up @@ -482,7 +482,7 @@ export default config({
region,
accessKeyId,
secretAccessKey,
proxied: { baseUrl: '/images-proxy' },
serverRoute: { path: '/images-proxy' },
signed: { expiry: 5000 }
endpoint: 'http://127.0.0.1:9000/',
forcePathStyle: true,
Expand Down
2 changes: 1 addition & 1 deletion docs/pages/docs/guides/images-and-files.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ The `storage` object defines how and where the assets are stored and accessed by
- The `type` of `field` the storage is being used for – `file` or `image`
- A function to generate the URL (`generateUrl`) Keystone returns in the GraphQL API – pointing to the location or the storage where the assets can be accessed
- The actual location where Keystone stores the assets – either a local `path` or the details of an `s3` bucket
- The location Keystone will serve the assets from – either a `serverRoute` for `local` or a `proxied` connection for `s3`. Both of these options add a route to the Keystone backend which the files can be accessed from
- The location Keystone will serve the assets from – the `serverRoute` for `local` or `s3`. It will add a route to the Keystone backend which the files can be accessed from. Please note that `generateUrl` should be carefully defined when using proxied mode.

## Defining `storage` in Keystone config

Expand Down
4 changes: 2 additions & 2 deletions packages/core/src/lib/assets/createFilesContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,8 @@ export function createFilesContext (config: KeystoneConfig): FilesContext {
adaptersMap.set(
storageKey,
storageConfig.kind === 'local'
? localFileAssetsAPI(storageConfig)
: s3FileAssetsAPI(storageConfig)
? localFileAssetsAPI(storageConfig,storageKey)
: s3FileAssetsAPI(storageConfig,storageKey)
)
}
}
Expand Down
4 changes: 2 additions & 2 deletions packages/core/src/lib/assets/createImagesContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,8 @@ export function createImagesContext (config: KeystoneConfig): ImagesContext {
imageAssetsAPIs.set(
storageKey,
storageConfig.kind === 'local'
? localImageAssetsAPI(storageConfig)
: s3ImageAssetsAPI(storageConfig)
? localImageAssetsAPI(storageConfig,storageKey)
: s3ImageAssetsAPI(storageConfig,storageKey)
)
}
}
Expand Down
27 changes: 23 additions & 4 deletions packages/core/src/lib/assets/local.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,17 @@ import { type StorageConfig } from '../../types'
import { type FileAdapter, type ImageAdapter } from './types'

export function localImageAssetsAPI (
storageConfig: StorageConfig & { kind: 'local' }
storageConfig: StorageConfig & { kind: 'local' }, storageKey: string=''
): ImageAdapter {
return {
async url (id, extension) {
return storageConfig.generateUrl(`/${id}.${extension}`)
const generateUrl = storageConfig.generateUrl ?? (url=>url);

if(storageConfig.serverRoute) {
return generateUrl(`${storageConfig.serverRoute.path}/${storageKey}/${id}.${extension}`);
}

return generateUrl(`/${id}.${extension}`)
},
async upload (buffer, id, extension) {
await fs.writeFile(path.join(storageConfig.storagePath, `${id}.${extension}`), buffer)
Expand All @@ -26,13 +32,23 @@ export function localImageAssetsAPI (
}
}
},
async download(filename, stream, headers) {
throw new Error("Not implemented yet")
}
}
}

export function localFileAssetsAPI (storageConfig: StorageConfig & { kind: 'local' }): FileAdapter {
export function localFileAssetsAPI (storageConfig: StorageConfig & { kind: 'local' }, storageKey: string=''): FileAdapter {
return {
async url (filename) {
return storageConfig.generateUrl(`/${filename}`)
const generateUrl = storageConfig.generateUrl ?? (url=>url);


if(storageConfig.serverRoute) {
return generateUrl(`${storageConfig.serverRoute.path}/${storageKey}/${filename}`);
}

return generateUrl(`/${filename}`)
},
async upload (stream, filename) {
const writeStream = fs.createWriteStream(path.join(storageConfig.storagePath, filename))
Expand Down Expand Up @@ -66,5 +82,8 @@ export function localFileAssetsAPI (storageConfig: StorageConfig & { kind: 'loca
}
}
},
async download(filename, stream, headers) {
throw new Error("Not implemented yet")
}
}
}
60 changes: 46 additions & 14 deletions packages/core/src/lib/assets/s3.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,17 +4,23 @@ import { Upload } from '@aws-sdk/lib-storage'

import type { StorageConfig } from '../../types'
import type { FileAdapter, ImageAdapter } from './types'
import { Writable } from 'stream'

export function s3ImageAssetsAPI (storageConfig: StorageConfig & { kind: 's3' }): ImageAdapter {
export function s3ImageAssetsAPI(storageConfig: StorageConfig & { kind: 's3' }, storageKey: string=''): ImageAdapter {
const { generateUrl, s3, presign, s3Endpoint } = s3AssetsCommon(storageConfig)
return {
async url (id, extension) {
async url(id, extension) {
if(storageConfig.serverRoute) {
return generateUrl(`${storageConfig.serverRoute.path}/${storageKey}/${storageConfig.pathPrefix || ''}${id}.${extension}`);
}

if (!storageConfig.signed) {
return generateUrl(`${s3Endpoint}${storageConfig.pathPrefix || ''}${id}.${extension}`)
}

return generateUrl(await presign(`${id}.${extension}`))
},
async upload (buffer, id, extension) {
async upload(buffer, id, extension) {
const upload = new Upload({
client: s3,
params: {
Expand All @@ -25,33 +31,37 @@ export function s3ImageAssetsAPI (storageConfig: StorageConfig & { kind: 's3' })
png: 'image/png',
webp: 'image/webp',
gif: 'image/gif',
jpg: 'image/jpeg',
jpg: 'image/jpeg'
}[extension],
ACL: storageConfig.acl,
},
})
await upload.done()
},
async delete (id, extension) {
async delete(id, extension) {
await s3.deleteObject({
Bucket: storageConfig.bucketName,
Key: `${storageConfig.pathPrefix || ''}${id}.${extension}`,
})
},
download: downloadFn(storageConfig)
}
}

export function s3FileAssetsAPI (storageConfig: StorageConfig & { kind: 's3' }): FileAdapter {
export function s3FileAssetsAPI(storageConfig: StorageConfig & { kind: 's3' }, storageKey: string=''): FileAdapter {
const { generateUrl, s3, presign, s3Endpoint } = s3AssetsCommon(storageConfig)

return {
async url (filename) {
async url(filename) {
if(storageConfig.serverRoute) {
return generateUrl(`${storageConfig.serverRoute.path}/${storageKey}/${storageConfig.pathPrefix || ''}${filename}`);
}
if (!storageConfig.signed) {
return generateUrl(`${s3Endpoint}${storageConfig.pathPrefix || ''}${filename}`)
}
return generateUrl(await presign(filename))
},
async upload (stream, filename) {
async upload(stream, filename) {
let filesize = 0
stream.on('data', data => {
filesize += data.length
Expand All @@ -72,16 +82,17 @@ export function s3FileAssetsAPI (storageConfig: StorageConfig & { kind: 's3' }):

return { filename, filesize }
},
async delete (filename) {
async delete(filename) {
await s3.deleteObject({
Bucket: storageConfig.bucketName,
Key: (storageConfig.pathPrefix || '') + filename,
})
},
download: downloadFn(storageConfig)
}
}

export function getS3AssetsEndpoint (storageConfig: StorageConfig & { kind: 's3' }) {
export function getS3AssetsEndpoint(storageConfig: StorageConfig & { kind: 's3' }) {
let endpoint = storageConfig.endpoint
? new URL(storageConfig.endpoint)
: new URL(`https://s3.${storageConfig.region}.amazonaws.com`)
Expand All @@ -96,14 +107,14 @@ export function getS3AssetsEndpoint (storageConfig: StorageConfig & { kind: 's3'
return `${endpointString}/`
}

function s3AssetsCommon (storageConfig: StorageConfig & { kind: 's3' }) {
export function s3AssetsCommon(storageConfig: StorageConfig & { kind: 's3' }) {
const s3 = new S3({
credentials:
storageConfig.accessKeyId && storageConfig.secretAccessKey
? {
accessKeyId: storageConfig.accessKeyId,
secretAccessKey: storageConfig.secretAccessKey,
}
accessKeyId: storageConfig.accessKeyId,
secretAccessKey: storageConfig.secretAccessKey,
}
: undefined,
region: storageConfig.region,
endpoint: storageConfig.endpoint,
Expand All @@ -128,3 +139,24 @@ function s3AssetsCommon (storageConfig: StorageConfig & { kind: 's3' }) {
},
}
}

const downloadFn = (storageConfig: StorageConfig & { kind: 's3' }) => {
return async (filename: string, stream: Writable, headers: (key: string, val: string) => void) => {
const { s3 } = s3AssetsCommon(storageConfig)
console.log({headers,stream});
const command = new GetObjectCommand({
Bucket: storageConfig.bucketName,
Key: filename,
})

const s3Response = await s3.send(command)
if (!s3Response.Body) {
throw new Error('No response body')
}

headers('Content-Type', s3Response.ContentType ?? '')
headers('Content-Length', s3Response.ContentLength?.toString() ?? '')

await s3Response.Body.transformToWebStream().pipeTo(Writable.toWeb(stream))
};
}
6 changes: 4 additions & 2 deletions packages/core/src/lib/assets/types.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,16 @@
import type { Readable } from 'stream'
import type { Readable, Writable } from 'stream'
import type { ImageExtension, FileMetadata } from '../../types'

export type ImageAdapter = {
download(filename: string, stream: Writable, headers: (key: string, value: string) => void): void
upload(buffer: Buffer, id: string, extension: string): Promise<void>
delete(id: string, extension: ImageExtension): Promise<void>
url(id: string, extension: ImageExtension): Promise<string>
}

export type FileAdapter = {
upload(stream: Readable, filename: string): Promise<FileMetadata>
download(filename: string, stream: Writable, headers: (key: string, value: string) => void): void
upload(stream: Readable, filename: string): Promise<FileMetadata>
delete(id: string): Promise<void>
url(filename: string): Promise<string>
}
Loading