diff --git a/.changeset/real-parents-warn.md b/.changeset/real-parents-warn.md new file mode 100644 index 00000000000..b7d291a2057 --- /dev/null +++ b/.changeset/real-parents-warn.md @@ -0,0 +1,5 @@ +--- +'@keystone-next/admin-ui': minor +--- + +Reflected next/image exports from admin-ui for use in other relevant keystone-next packages. diff --git a/.changeset/rude-parrots-train.md b/.changeset/rude-parrots-train.md new file mode 100644 index 00000000000..aef1bc2069e --- /dev/null +++ b/.changeset/rude-parrots-train.md @@ -0,0 +1,5 @@ +--- +'@keystone-next/app-basic': patch +--- + +Added example of image field support to basic keystone-next example. diff --git a/.changeset/shaggy-dots-appear.md b/.changeset/shaggy-dots-appear.md new file mode 100644 index 00000000000..093f875ba7c --- /dev/null +++ b/.changeset/shaggy-dots-appear.md @@ -0,0 +1,5 @@ +--- +'@keystone-next/fields': minor +--- + +Added new image field type. diff --git a/.changeset/stale-balloons-grin.md b/.changeset/stale-balloons-grin.md new file mode 100644 index 00000000000..2731b1e5ac1 --- /dev/null +++ b/.changeset/stale-balloons-grin.md @@ -0,0 +1,5 @@ +--- +'@keystone-next/types': minor +--- + +Added types for new images functionality in keystone. diff --git a/.changeset/tough-ravens-help.md b/.changeset/tough-ravens-help.md new file mode 100644 index 00000000000..a1052b50b9d --- /dev/null +++ b/.changeset/tough-ravens-help.md @@ -0,0 +1,5 @@ +--- +'@keystone-next/keystone': minor +--- + +Added create-image-context, logic for parsing, storing and retrieving image data in keystone core. diff --git a/examples-next/basic/.gitignore b/examples-next/basic/.gitignore new file mode 100644 index 00000000000..d298be107f2 --- /dev/null +++ b/examples-next/basic/.gitignore @@ -0,0 +1 @@ +public/ \ No newline at end of file diff --git a/examples-next/basic/keystone.ts b/examples-next/basic/keystone.ts index 58264c6bb29..adab06d2630 100644 --- a/examples-next/basic/keystone.ts +++ b/examples-next/basic/keystone.ts @@ -36,6 +36,7 @@ export default auth.withAuth( // path: '/admin', // isAccessAllowed, }, + images: { upload: 'local' }, lists, extendGraphqlSchema, session: withItemData( diff --git a/examples-next/basic/schema.ts b/examples-next/basic/schema.ts index 42327c162af..c90a33b141c 100644 --- a/examples-next/basic/schema.ts +++ b/examples-next/basic/schema.ts @@ -7,6 +7,7 @@ import { timestamp, select, virtual, + image, } from '@keystone-next/fields'; import { document } from '@keystone-next/fields-document'; // import { cloudinaryImage } from '@keystone-next/cloudinary'; @@ -34,7 +35,7 @@ export const lists = createSchema({ User: list({ ui: { listView: { - initialColumns: ['name', 'posts'], + initialColumns: ['name', 'posts', 'avatar'], }, }, fields: { @@ -42,13 +43,7 @@ export const lists = createSchema({ name: text({ isRequired: true }), /** Email is used to log into the system. */ email: text({ isRequired: true, isUnique: true }), - // avatar: cloudinaryImage({ - // cloudinary: { - // cloudName: '/* TODO */', - // apiKey: '/* TODO */', - // apiSecret: '/* TODO */', - // }, - // }), + avatar: image(), /** Used to log in. */ password: password(), /** Administrators have more access to various lists and fields. */ diff --git a/packages-next/admin-ui/image/package.json b/packages-next/admin-ui/image/package.json new file mode 100644 index 00000000000..f81006d13fa --- /dev/null +++ b/packages-next/admin-ui/image/package.json @@ -0,0 +1,4 @@ +{ + "main": "dist/admin-ui.cjs.js", + "module": "dist/admin-ui.esm.js" +} diff --git a/packages-next/admin-ui/package.json b/packages-next/admin-ui/package.json index a9b6538f77a..93d09b34d7d 100644 --- a/packages-next/admin-ui/package.json +++ b/packages-next/admin-ui/package.json @@ -52,7 +52,8 @@ "apollo.tsx", "context.tsx", "router.tsx", - "next-config.ts" + "next-config.ts", + "image.tsx" ] }, "repository": "https://github.com/keystonejs/keystone/tree/master/packages-next/admin-ui", diff --git a/packages-next/admin-ui/src/image.tsx b/packages-next/admin-ui/src/image.tsx new file mode 100644 index 00000000000..61342a3dff1 --- /dev/null +++ b/packages-next/admin-ui/src/image.tsx @@ -0,0 +1,2 @@ +export * from 'next/image'; +export { default } from 'next/image'; diff --git a/packages-next/admin-ui/src/system/generateAdminUI.ts b/packages-next/admin-ui/src/system/generateAdminUI.ts index 5ab94b65339..d197425b9c0 100644 --- a/packages-next/admin-ui/src/system/generateAdminUI.ts +++ b/packages-next/admin-ui/src/system/generateAdminUI.ts @@ -53,6 +53,14 @@ export const generateAdminUI = async ( // Nuke any existing files in our target directory await fs.remove(projectAdminPath); + if (config.images) { + const publicDirectory = Path.join(projectAdminPath, 'public'); + await fs.mkdir(publicDirectory, { recursive: true }); + const storagePath = Path.resolve(config.images.local?.storagePath ?? './public/images'); + await fs.mkdir(storagePath, { recursive: true }); + await fs.symlink(storagePath, Path.join(publicDirectory, 'images'), 'junction'); + } + // Write out the files configured by the user const userPages = config.ui?.getAdditionalFiles?.map(x => x(config)) ?? []; const userFilesToWrite = (await Promise.all(userPages)).flat(); diff --git a/packages-next/fields/package.json b/packages-next/fields/package.json index d0a06327ba3..ece9706aba5 100644 --- a/packages-next/fields/package.json +++ b/packages-next/fields/package.json @@ -7,6 +7,7 @@ "devDependencies": { "@keystone-next/server-side-graphql-client-legacy": "3.0.1", "@keystone-next/test-utils-legacy": "16.0.0", + "@types/bytes": "^3.1.0", "mime": "^2.5.2", "typescript": "^4.2.3" }, @@ -25,6 +26,7 @@ "@keystone-ui/icons": "^2.0.1", "@keystone-ui/loading": "^2.0.1", "@keystone-ui/modals": "^2.0.1", + "@keystone-ui/pill": "^2.0.1", "@keystone-ui/segmented-control": "^2.0.1", "@keystone-ui/toast": "^2.0.1", "@keystone-ui/tooltip": "^2.0.1", @@ -33,6 +35,8 @@ "@types/react": "^17.0.3", "apollo-errors": "^1.9.0", "bcryptjs": "^2.4.3", + "bytes": "^3.1.0", + "copy-to-clipboard": "^3.3.1", "cuid": "^2.1.8", "date-fns": "^2.20.2", "decimal.js": "^10.2.1", diff --git a/packages-next/fields/src/Implementation.ts b/packages-next/fields/src/Implementation.ts index ea21e94ca4d..9132f2c3f5a 100644 --- a/packages-next/fields/src/Implementation.ts +++ b/packages-next/fields/src/Implementation.ts @@ -150,8 +150,28 @@ class Field

{ return resolvedData[this.path]; } + /** + * @param {Object} data + * @param {Object} data.resolvedData The incoming item for the mutation with + * relationships and defaults already resolved + * @param {Object} data.existingItem If this is a updateX mutation, this will + * be the existing data in the database + * @param {Object} data.context The graphQL context object of the current + * request + * @param {Object} data.originalInput The raw incoming item from the mutation + * (no relationships or defaults resolved) + */ // eslint-disable-next-line @typescript-eslint/no-unused-vars - async validateInput(args: any) {} + async validateInput(data: { + resolvedData: Record; + existingItem?: Record; + context: KeystoneContext; + originalInput: any; + listKey: string; + fieldPath: P; + operation: 'create' | 'update'; + addFieldValidationError: (msg: string) => void; + }) {} async beforeChange() {} diff --git a/packages-next/fields/src/index.ts b/packages-next/fields/src/index.ts index c3756bf0a3e..c10d5c6e760 100644 --- a/packages-next/fields/src/index.ts +++ b/packages-next/fields/src/index.ts @@ -11,3 +11,4 @@ export { select } from './types/select'; export { virtual } from './types/virtual'; export { Implementation } from './Implementation'; export type { FieldConfigArgs, FieldExtraArgs } from './Implementation'; +export { image } from './types/image'; diff --git a/packages-next/fields/src/types/image/Implementation.ts b/packages-next/fields/src/types/image/Implementation.ts new file mode 100644 index 00000000000..f75cc99d5e6 --- /dev/null +++ b/packages-next/fields/src/types/image/Implementation.ts @@ -0,0 +1,216 @@ +import { PrismaFieldAdapter, PrismaListAdapter } from '@keystone-next/adapter-prisma-legacy'; +import { getImageRef, SUPPORTED_IMAGE_EXTENSIONS } from '@keystone-next/utils-legacy'; +import { ImageData, KeystoneContext, BaseKeystoneList } from '@keystone-next/types'; +import { Implementation } from '../../Implementation'; +import { handleImageData } from './handle-image-input'; + +export class ImageImplementation

extends Implementation

{ + get _supportsUnique() { + return false; + } + + gqlOutputFields() { + return [`${this.path}: ImageFieldOutput`]; + } + + getGqlAuxTypes() { + return [ + `enum ImageMode { + local + } + input ImageFieldInput { + upload: Upload + ref: String + } + enum ImageExtension { + ${SUPPORTED_IMAGE_EXTENSIONS.join('\n')} + } + type ImageFieldOutput { + mode: ImageMode! + id: ID! + filesize: Int! + width: Int! + height: Int! + extension: ImageExtension! + ref: String! + src: String! + }`, + ]; + } + + gqlAuxFieldResolvers() { + return { + ImageFieldOutput: { + src(data: ImageData, _args: any, context: KeystoneContext) { + if (!context.images) { + throw new Error('Image context is undefined'); + } + return context.images.getSrc(data.mode, data.id, data.extension); + }, + ref(data: ImageData, _args: any, context: KeystoneContext) { + if (!context.images) { + throw new Error('Image context is undefined'); + } + return getImageRef(data.mode, data.id, data.extension); + }, + }, + }; + } + // Called on `User.avatar` for example + gqlOutputFieldResolvers() { + return { [`${this.path}`]: (item: Record) => item[this.path] }; + } + + async resolveInput({ + resolvedData, + context, + }: { + resolvedData: Record; + context: KeystoneContext; + }) { + const data = resolvedData[this.path]; + if (data === null) { + return null; + } + if (data === undefined) { + return undefined; + } + const imageData = await handleImageData(data, context); + return imageData; + } + + gqlUpdateInputFields() { + return [`${this.path}: ImageFieldInput`]; + } + gqlCreateInputFields() { + return [`${this.path}: ImageFieldInput`]; + } + getBackingTypes() { + return { [this.path]: { optional: true, type: 'Record | null' } }; + } +} + +export class PrismaImageInterface

extends PrismaFieldAdapter

{ + constructor( + fieldName: string, + path: P, + field: ImageImplementation

, + listAdapter: PrismaListAdapter, + getListByKey: (arg: string) => BaseKeystoneList | undefined, + config = {} + ) { + super(fieldName, path, field, listAdapter, getListByKey, config); + // Error rather than ignoring invalid config + // We totally can index these values, it's just not trivial. See issue #1297 + if (this.config.isIndexed) { + throw new Error( + `The Image field type doesn't support indexes on Prisma. ` + + `Check the config for ${this.path} on the ${this.field.listKey} list` + ); + } + } + + getPrismaSchema() { + return [ + `${this.path}_filesize Int?`, + `${this.path}_extension String?`, + `${this.path}_width Int?`, + `${this.path}_height Int?`, + `${this.path}_mode String?`, + `${this.path}_id String?`, + ]; + } + + getQueryConditions() { + return {}; + } + + setupHooks({ + addPreSaveHook, + addPostReadHook, + }: { + addPreSaveHook: (hook: any) => void; + addPostReadHook: (hook: any) => void; + }) { + const field_path = this.path; + const filesize_field = `${this.path}_filesize`; + const extension_field = `${this.path}_extension`; + const width_field = `${this.path}_width`; + const height_field = `${this.path}_height`; + const mode_field = `${this.path}_mode`; + const id_field = `${this.path}_id`; + + addPreSaveHook( + (item: Record): Record => { + if (!Object.prototype.hasOwnProperty.call(item, field_path)) { + return item; + } + if (item[field_path as P] === null) { + // If the property exists on the field but is null or falsey + // all split fields are null + // delete the original field item + // return the item + const newItem = { + [filesize_field]: null, + [extension_field]: null, + [width_field]: null, + [height_field]: null, + [id_field]: null, + [mode_field]: null, + ...item, + }; + delete newItem[field_path]; + return newItem; + } else { + const { mode, filesize, extension, width, height, id } = item[field_path]; + + const newItem = { + [filesize_field]: filesize, + [extension_field]: extension, + [width_field]: width, + [height_field]: height, + [id_field]: id, + [mode_field]: mode, + ...item, + }; + + delete newItem[field_path]; + + return newItem; + } + } + ); + addPostReadHook( + (item: Record): Record => { + if ( + !item[filesize_field] || + !item[extension_field] || + !item[width_field] || + !item[height_field] || + !item[id_field] || + !item[mode_field] + ) { + item[field_path] = null; + return item; + } + item[field_path] = { + filesize: item[filesize_field], + extension: item[extension_field], + width: item[width_field], + height: item[height_field], + id: item[id_field], + mode: item[mode_field], + }; + + delete item[filesize_field]; + delete item[extension_field]; + delete item[width_field]; + delete item[height_field]; + delete item[id_field]; + delete item[mode_field]; + + return item; + } + ); + } +} diff --git a/packages-next/fields/src/types/image/handle-image-input.ts b/packages-next/fields/src/types/image/handle-image-input.ts new file mode 100644 index 00000000000..450805355a0 --- /dev/null +++ b/packages-next/fields/src/types/image/handle-image-input.ts @@ -0,0 +1,38 @@ +import { KeystoneContext } from '@keystone-next/types'; +import { FileUpload } from 'graphql-upload'; + +type ImageInput = { + upload?: Promise | null; + ref?: string | null; +}; + +type ValidatedImageInput = + | { + kind: 'upload'; + upload: Promise; + } + | { + kind: 'ref'; + ref: string; + }; + +function validateImageInput({ ref, upload }: ImageInput): ValidatedImageInput { + if (ref != null) { + if (upload) { + throw new Error('Only one of ref and upload can be passed to ImageFieldInput'); + } + return { kind: 'ref', ref }; + } + if (!upload) { + throw new Error('Either ref or upload must be passed to ImageFieldInput'); + } + return { kind: 'upload', upload }; +} + +export async function handleImageData(input: ImageInput, context: KeystoneContext) { + const data = validateImageInput(input); + if (data.kind === 'upload') { + return context.images!.getDataFromStream((await data.upload).createReadStream()); + } + return context.images!.getDataFromRef(data.ref); +} diff --git a/packages-next/fields/src/types/image/index.ts b/packages-next/fields/src/types/image/index.ts new file mode 100644 index 00000000000..eab740e817e --- /dev/null +++ b/packages-next/fields/src/types/image/index.ts @@ -0,0 +1,22 @@ +import type { FieldType, BaseGeneratedListTypes } from '@keystone-next/types'; +import { resolveView } from '../../resolve-view'; +import type { FieldConfig } from '../../interfaces'; +import { ImageImplementation, PrismaImageInterface } from './Implementation'; + +export type ImageFieldConfig< + TGeneratedListTypes extends BaseGeneratedListTypes +> = FieldConfig & { + isRequired?: boolean; +}; + +export const image = ( + config: ImageFieldConfig = {} +): FieldType => ({ + type: { + type: 'Image', + implementation: ImageImplementation, + adapter: PrismaImageInterface, + }, + config, + views: resolveView('image/views'), +}); diff --git a/packages-next/fields/src/types/image/views/Field.tsx b/packages-next/fields/src/types/image/views/Field.tsx new file mode 100644 index 00000000000..0e006209ef3 --- /dev/null +++ b/packages-next/fields/src/types/image/views/Field.tsx @@ -0,0 +1,404 @@ +/** @jsx jsx */ + +import { jsx, Stack, useTheme, Text } from '@keystone-ui/core'; +import { useToasts } from '@keystone-ui/toast'; +import { TextInput } from '@keystone-ui/fields'; +import { parseImageRef } from '@keystone-next/utils-legacy'; +import copy from 'copy-to-clipboard'; +import bytes from 'bytes'; +import { ReactNode, RefObject, useEffect, useMemo, useRef, useState } from 'react'; + +import { FieldContainer, FieldLabel } from '@keystone-ui/fields'; +import { Pill } from '@keystone-ui/pill'; +import { Button } from '@keystone-ui/button'; +import { FieldProps } from '@keystone-next/types'; +import NextImage from '@keystone-next/admin-ui/image'; + +import { ImageValue } from './index'; + +function useObjectURL(fileData: File | undefined) { + let [objectURL, setObjectURL] = useState(undefined); + useEffect(() => { + if (fileData) { + let url = URL.createObjectURL(fileData); + setObjectURL(url); + return () => { + URL.revokeObjectURL(url); + }; + } + }, [fileData]); + return objectURL; +} + +const RefView = ({ + onChange, + onCancel, + error, +}: { + onChange: (value: string) => void; + onCancel: () => void; + error?: string; +}) => { + return ( + + { + onChange(event.target.value); + }} + css={{ + width: '100%', + }} + /> + + {error ? ( + + {error} + + ) : null} + + ); +}; + +export function Field({ + autoFocus, + field, + value, + forceValidation, + onChange, +}: FieldProps) { + const inputRef = useRef(null); + + const errorMessage = createErrorMessage(value, forceValidation); + + const onUploadChange = ({ + currentTarget: { validity, files }, + }: React.SyntheticEvent) => { + const file = files?.[0]; + if (!file) return; // bail if the user cancels from the file browser + onChange?.({ + kind: 'upload', + data: { file, validity }, + previous: value, + }); + }; + + // Generate a random input key when the value changes, to ensure the file input is unmounted and + // remounted (this is the only way to reset its value and ensure onChange will fire again if + // the user selects the same file again) + const inputKey = useMemo(() => Math.random(), [value]); + + return ( + + {field.label} + {value.kind === 'ref' ? ( + { + onChange?.({ + kind: 'ref', + data: { ref }, + previous: value.previous, + }); + }} + error={forceValidation && errorMessage ? errorMessage : undefined} + onCancel={() => { + onChange?.(value.previous); + }} + /> + ) : ( + + )} + + + ); +} + +function ImgView({ + errorMessage, + value, + onChange, + field, + inputRef, +}: { + errorMessage?: string; + value: Exclude; + onChange?: (value: ImageValue) => void; + field: ReturnType; + inputRef: RefObject; +}) { + const { addToast } = useToasts(); + + const imagePathFromUpload = useObjectURL( + errorMessage === undefined && value.kind === 'upload' ? value.data.file : undefined + ); + const onSuccess = () => { + addToast({ title: 'Copied image ref to clipboard', tone: 'positive' }); + }; + const onFailure = () => { + addToast({ title: 'Failed to copy image ref to clipboard', tone: 'negative' }); + }; + + const copyRef = () => { + if (value.kind !== 'from-server') { + return; + } + + if (navigator) { + // use the new navigator.clipboard API if it exists + navigator.clipboard.writeText(value?.data.ref).then(onSuccess, onFailure); + return; + } else { + // Fallback to a library that leverages document.execCommand + // for browser versions that dont' support the navigator object. + // As document.execCommand + try { + copy(value?.data.ref); + } catch (e) { + addToast({ title: 'Faild to oopy to clipboard', tone: 'negative' }); + } + + return; + } + }; + return value.kind === 'from-server' || value.kind === 'upload' ? ( + + {errorMessage === undefined ? ( + value.kind === 'from-server' ? ( + + + + ) : ( + + {field.path} + + ) + ) : null} + {onChange && ( + + {value.kind === 'from-server' && ( + + + + + {`${value.data.id}.${value.data.extension}`} + + + + + {`${value.data.width} x ${value.data.height} (${bytes( + value.data.filesize + )})`} + + )} + + + {value.kind !== 'upload' ? ( + + ) : null} + {value.kind === 'from-server' && ( + + )} + {value.kind === 'upload' && ( + + )} + {errorMessage ? ( + + {errorMessage} + + ) : ( + value.kind === 'upload' && ( + + Save to upload this image + + ) + )} + + + )} + + ) : ( + + + + + {value.kind === 'remove' && value.previous && ( + + )} + {value.kind === 'remove' && + // NOTE -- UX decision is to not display this, I think it would only be relevant + // for deleting uploaded images (and we don't support that yet) + // + // Save to remove this image + // + null} + + + ); +} + +export function validateRef({ ref }: { ref: string }) { + if (!parseImageRef(ref)) { + return 'Invalid ref'; + } +} + +function createErrorMessage(value: ImageValue, forceValidation?: boolean) { + if (value.kind === 'upload') { + return validateImage(value.data); + } else if (value.kind === 'ref') { + return forceValidation ? validateRef(value.data) : undefined; + } +} + +export function validateImage({ + file, + validity, +}: { + file: File; + validity: ValidityState; +}): string | undefined { + if (!validity.valid) { + return 'Something went wrong, please reload and try again.'; + } + // check if the file is actually an image + if (!file.type.includes('image')) { + return 'Only image files are allowed. Please try again.'; + } +} + +// ============================== +// Styled Components +// ============================== + +const ImageWrapper = ({ children }: { children: ReactNode }) => { + const theme = useTheme(); + + return ( +

+ {children} +
+ ); +}; diff --git a/packages-next/fields/src/types/image/views/index.tsx b/packages-next/fields/src/types/image/views/index.tsx new file mode 100644 index 00000000000..7350b991c65 --- /dev/null +++ b/packages-next/fields/src/types/image/views/index.tsx @@ -0,0 +1,127 @@ +/* @jsx jsx */ + +import { jsx } from '@keystone-ui/core'; +import { + CardValueComponent, + CellComponent, + FieldController, + FieldControllerConfig, +} from '@keystone-next/types'; +import { FieldContainer, FieldLabel } from '@keystone-ui/fields'; +import { validateImage, validateRef } from './Field'; + +export { Field } from './Field'; + +export const Cell: CellComponent = ({ item, field }) => { + const data = item[field.path]; + if (!data) return null; + return ( +
+ {data.filename} +
+ ); +}; + +export const CardValue: CardValueComponent = ({ item, field }) => { + const data = item[field.path]; + return ( + + {field.label} + {data && {data.filename}} + + ); +}; + +type ImageData = { + src: string; + ref: string; + height: number; + width: number; + filesize: number; + extension: string; + id: string; +}; + +export type ImageValue = + | { kind: 'empty' } + | { + kind: 'ref'; + data: { + ref: string; + }; + previous: ImageValue; + } + | { + kind: 'from-server'; + data: ImageData; + } + | { + kind: 'upload'; + data: { + file: File; + validity: ValidityState; + }; + previous: ImageValue; + } + | { kind: 'remove'; previous?: Exclude }; + +type ImageController = FieldController; + +export const controller = (config: FieldControllerConfig): ImageController => { + return { + path: config.path, + label: config.label, + graphqlSelection: `${config.path} { + src + id + ref + extension + width + height + filesize + }`, + defaultValue: { kind: 'empty' }, + deserialize(item) { + const value = item[config.path]; + if (!value) return { kind: 'empty' }; + return { + kind: 'from-server', + data: { + src: value.src, + id: value.id, + extension: value.extension, + ref: value.ref, + width: value.width, + height: value.height, + filesize: value.filesize, + }, + }; + }, + validate(value): boolean { + if (value.kind === 'ref') { + return validateRef(value.data) === undefined; + } + return value.kind !== 'upload' || validateImage(value.data) === undefined; + }, + serialize(value) { + if (value.kind === 'upload') { + return { [config.path]: { upload: value.data.file } }; + } + if (value.kind === 'ref') { + return { [config.path]: { ref: value.data.ref } }; + } + if (value.kind === 'remove') { + return { [config.path]: null }; + } + return {}; + }, + }; +}; diff --git a/packages-next/fields/types/image/views/package.json b/packages-next/fields/types/image/views/package.json new file mode 100644 index 00000000000..0a2b32cebb5 --- /dev/null +++ b/packages-next/fields/types/image/views/package.json @@ -0,0 +1,4 @@ +{ + "main": "dist/fields.cjs.js", + "module": "dist/fields.esm.js" +} diff --git a/packages-next/keystone/package.json b/packages-next/keystone/package.json index 9546365c560..4995dabb66a 100644 --- a/packages-next/keystone/package.json +++ b/packages-next/keystone/package.json @@ -50,6 +50,7 @@ "@types/prompts": "^2.0.10", "@types/source-map-support": "^0.5.3", "@types/uid-safe": "^2.1.2", + "@types/uuid": "^8.3.0", "apollo-errors": "^1.9.0", "apollo-server-express": "^2.22.2", "apollo-server-micro": "^2.22.2", @@ -64,6 +65,8 @@ "graphql": "^15.5.0", "graphql-type-json": "^0.3.2", "graphql-upload": "^11.0.0", + "image-size": "^0.9.7", + "image-type": "^4.1.0", "meow": "^9.0.0", "next": "^10.0.9", "object-hash": "^2.1.1", @@ -78,7 +81,8 @@ "source-map-support": "^0.5.19", "stack-utils": "^2.0.3", "typescript": "^4.2.3", - "uid-safe": "^2.1.5" + "uid-safe": "^2.1.5", + "uuid": "^8.3.2" }, "devDependencies": { "fast-glob": "^3.2.5", diff --git a/packages-next/keystone/src/lib/createContext.ts b/packages-next/keystone/src/lib/createContext.ts index 8f21c52570b..a9fba5a49b4 100644 --- a/packages-next/keystone/src/lib/createContext.ts +++ b/packages-next/keystone/src/lib/createContext.ts @@ -5,6 +5,7 @@ import type { KeystoneContext, KeystoneGraphQLAPI, BaseKeystone, + ImagesContext, } from '@keystone-next/types'; import { itemAPIForList, getArgsFactory } from './itemAPI'; @@ -14,10 +15,12 @@ export function makeCreateContext({ graphQLSchema, internalSchema, keystone, + images, }: { graphQLSchema: GraphQLSchema; internalSchema: GraphQLSchema; keystone: BaseKeystone; + images: ImagesContext | undefined; }) { // We precompute these helpers here rather than every time createContext is called // because they require parsing the entire schema, which is potentially expensive. @@ -81,6 +84,7 @@ export function makeCreateContext({ // Note: This field lets us use the server-side-graphql-client library. // We may want to remove it once the updated itemAPI w/ query is available. gqlNames: (listKey: string) => keystone.lists[listKey].gqlNames, + images, }; const getArgsByList = schemaName === 'public' ? publicGetArgsByList : internalGetArgsByList; for (const [listKey, list] of Object.entries(keystone.lists)) { diff --git a/packages-next/keystone/src/lib/createImagesContext.ts b/packages-next/keystone/src/lib/createImagesContext.ts new file mode 100644 index 00000000000..33592c480c0 --- /dev/null +++ b/packages-next/keystone/src/lib/createImagesContext.ts @@ -0,0 +1,93 @@ +import path from 'path'; +import { + ImagesConfig as KeystoneImagesConfig, + ImagesContext, + ImageExtension, +} from '@keystone-next/types'; +import { v4 as uuid } from 'uuid'; +import fs from 'fs-extra'; +import fromBuffer from 'image-type'; +import imageSize from 'image-size'; + +import { parseImageRef } from '@keystone-next/utils-legacy'; + +const DEFAULT_BASE_URL = '/images'; +const DEFAULT_STORAGE_PATH = './public/images'; + +const getImageMetadataFromBuffer = async (buffer: Buffer) => { + const filesize = buffer.length; + const fileType = fromBuffer(buffer); + if (!fileType) { + throw new Error('File type not found'); + } + + if ( + fileType.ext !== 'jpg' && + fileType.ext !== 'png' && + fileType.ext !== 'webp' && + fileType.ext !== 'gif' + ) { + throw new Error(`${fileType.ext} is not a supported image type`); + } + + const extension: ImageExtension = fileType.ext; + + const { height, width } = imageSize(buffer); + + if (width === undefined || height === undefined) { + throw new Error('Height and width could not be found for image'); + } + return { width, height, filesize, extension }; +}; + +export function createImagesContext(config?: KeystoneImagesConfig): ImagesContext | undefined { + if (!config) { + return; + } + + const { baseUrl = DEFAULT_BASE_URL, storagePath = DEFAULT_STORAGE_PATH } = config.local || {}; + + fs.mkdirSync(storagePath, { recursive: true }); + + return { + getSrc: (mode, id, extension) => { + const filename = `${id}.${extension}`; + return `${baseUrl}/${filename}`; + }, + getDataFromRef: async ref => { + const imageRef = parseImageRef(ref); + if (!imageRef) { + throw new Error('Invalid image reference'); + } + const buffer = await fs.readFile( + path.join(storagePath, `${imageRef.id}.${imageRef.extension}`) + ); + const metadata = await getImageMetadataFromBuffer(buffer); + + return { + ...imageRef, + ...metadata, + }; + }, + getDataFromStream: async stream => { + const { upload: mode } = config; + const id = uuid(); + const chunks = []; + + for await (let chunk of stream) { + chunks.push(chunk); + } + + const buffer = Buffer.concat(chunks); + const metadata = await getImageMetadataFromBuffer(buffer); + + await fs.writeFile(path.join(storagePath, `${id}.${metadata.extension}`), buffer); + + return { + mode, + id, + ...metadata, + }; + }, + }; +} diff --git a/packages-next/keystone/src/lib/createSystem.ts b/packages-next/keystone/src/lib/createSystem.ts index dba9670d4c4..6b9a3244012 100644 --- a/packages-next/keystone/src/lib/createSystem.ts +++ b/packages-next/keystone/src/lib/createSystem.ts @@ -3,6 +3,7 @@ import type { KeystoneConfig } from '@keystone-next/types'; import { createGraphQLSchema } from './createGraphQLSchema'; import { makeCreateContext } from './createContext'; import { createKeystone } from './createKeystone'; +import { createImagesContext } from './createImagesContext'; export function createSystem(config: KeystoneConfig, prismaClient?: any) { const keystone = createKeystone(config, prismaClient); @@ -11,7 +12,12 @@ export function createSystem(config: KeystoneConfig, prismaClient?: any) { const internalSchema = createGraphQLSchema(config, keystone, 'internal'); - const createContext = makeCreateContext({ keystone, graphQLSchema, internalSchema }); + const createContext = makeCreateContext({ + keystone, + graphQLSchema, + internalSchema, + images: createImagesContext(config.images), + }); return { keystone, graphQLSchema, createContext }; } diff --git a/packages-next/types/src/config/index.ts b/packages-next/types/src/config/index.ts index d9e01130531..ae2f0f2f716 100644 --- a/packages-next/types/src/config/index.ts +++ b/packages-next/types/src/config/index.ts @@ -3,7 +3,7 @@ import { CorsOptions } from 'cors'; import type { GraphQLSchema } from 'graphql'; import type { Config } from 'apollo-server-express'; -import type { KeystoneContext } from '..'; +import type { ImageMode, KeystoneContext } from '..'; import { CreateContext } from '../core'; import type { BaseKeystone } from '../base'; @@ -39,6 +39,23 @@ export type KeystoneConfig = { /** Creates a file at `node_modules/.keystone/next/graphql-api` with `default` and `config` exports that can be re-exported in a Next API route */ generateNextGraphqlAPI?: boolean; }; + images?: ImagesConfig; +}; + +export type ImagesConfig = { + upload: ImageMode; + local?: { + /** + * The path local images are uploaded to. + * @default 'public/images' + */ + storagePath?: string; + /** + * The base of the URL local images will be served from, outside of keystone. + * @default '/images' + */ + baseUrl?: string; + }; }; // config.lists diff --git a/packages-next/types/src/context.ts b/packages-next/types/src/context.ts index 0d534ea28f8..e47207e065e 100644 --- a/packages-next/types/src/context.ts +++ b/packages-next/types/src/context.ts @@ -1,4 +1,5 @@ import { IncomingMessage } from 'http'; +import { Readable } from 'stream'; import { GraphQLSchema, ExecutionResult, DocumentNode } from 'graphql'; import { BaseKeystone } from './base'; import type { BaseGeneratedListTypes } from './utils'; @@ -17,6 +18,7 @@ export type KeystoneContext = { gqlNames: (listKey: string) => Record; // TODO: actual keys /** @deprecated */ keystone: BaseKeystone; + images: ImagesContext | undefined; } & AccessControlContext & Partial> & DatabaseAPIs; @@ -111,3 +113,22 @@ export type SessionContext = { export type DatabaseAPIs = { prisma?: any; }; + +export type ImageMode = 'local'; + +export type ImageExtension = 'jpg' | 'png' | 'webp' | 'gif'; + +export type ImageData = { + mode: ImageMode; + id: string; + extension: ImageExtension; + filesize: number; + width: number; + height: number; +}; + +export type ImagesContext = { + getSrc: (mode: ImageMode, id: string, extension: ImageExtension) => string; + getDataFromRef: (ref: string) => Promise; + getDataFromStream: (stream: Readable) => Promise; +}; diff --git a/packages/utils/package.json b/packages/utils/package.json index 4bd214fcdc6..46b01f6dfcc 100644 --- a/packages/utils/package.json +++ b/packages/utils/package.json @@ -13,6 +13,7 @@ }, "dependencies": { "@babel/runtime": "^7.13.10", + "@keystone-next/types": "^16.0.0", "p-lazy": "^3.1.0", "p-reflect": "^2.1.0", "semver": "^7.3.5" diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index f22ca9d4d09..8f76813c904 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -1,6 +1,7 @@ import pLazy from 'p-lazy'; import pReflect from 'p-reflect'; import semver from 'semver'; +import { ImageMode, ImageExtension } from '@keystone-next/types'; export const noop = (x: T): T => x; export const identity = noop; @@ -256,3 +257,25 @@ export const humanize = (str: string) => { .map(upcase) .join(' '); }; + +const REFREGEX = /^(local):([^:\n]+)\.(gif|jpg|png|webp)$/; + +export const getImageRef = (mode: ImageMode, id: string, extension: ImageExtension) => + `${mode}:${id}.${extension}`; + +export const SUPPORTED_IMAGE_EXTENSIONS = ['jpg', 'png', 'webp', 'gif']; + +export const parseImageRef = ( + ref: string +): { mode: ImageMode; id: string; extension: ImageExtension } | undefined => { + const match = ref.match(REFREGEX); + if (match) { + const [, mode, id, ext] = match; + return { + mode: mode as ImageMode, + id, + extension: ext as ImageExtension, + }; + } + return undefined; +}; diff --git a/yarn.lock b/yarn.lock index 2152429f9e1..10a5adb06bc 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2523,6 +2523,11 @@ "@types/connect" "*" "@types/node" "*" +"@types/bytes@^3.1.0": + version "3.1.0" + resolved "https://registry.yarnpkg.com/@types/bytes/-/bytes-3.1.0.tgz#835a3e4aea3b4d7604aca216a78de372bff3ecc3" + integrity sha512-5YG1AiIC8HPPXRvYAIa7ehK3YMAwd0DWiPCtpuL9sgKceWLyWsVtLRA+lT4NkoanDNF9slwQ66lPizWDpgRlWA== + "@types/classnames@^2.2.11": version "2.2.11" resolved "https://registry.yarnpkg.com/@types/classnames/-/classnames-2.2.11.tgz#2521cc86f69d15c5b90664e4829d84566052c1cf" @@ -3013,6 +3018,11 @@ resolved "https://registry.yarnpkg.com/@types/unist/-/unist-2.0.3.tgz#9c088679876f374eb5983f150d4787aa6fb32d7e" integrity sha512-FvUupuM3rlRsRtCN+fDudtmytGO6iHJuuRKS1Ss0pG5z8oX0diNEw94UEL7hgDbpN94rgaK5R7sWm6RrSkZuAQ== +"@types/uuid@^8.3.0": + version "8.3.0" + resolved "https://registry.yarnpkg.com/@types/uuid/-/uuid-8.3.0.tgz#215c231dff736d5ba92410e6d602050cce7e273f" + integrity sha512-eQ9qFW/fhfGJF8WKHGEHZEyVWfZxrT+6CLIJGBcZPfxUh/+BnEj+UCGYMlr9qZuX/2AltsvwrGqp0LhEW8D0zQ== + "@types/vfile-message@*": version "2.0.0" resolved "https://registry.yarnpkg.com/@types/vfile-message/-/vfile-message-2.0.0.tgz#690e46af0fdfc1f9faae00cd049cc888957927d5" @@ -4340,7 +4350,7 @@ bytes@3.0.0: resolved "https://registry.yarnpkg.com/bytes/-/bytes-3.0.0.tgz#d32815404d689699f85a4ea4fa8755dd13a96048" integrity sha1-0ygVQE1olpn4Wk6k+odV3ROpYEg= -bytes@3.1.0, bytes@^3.0.0: +bytes@3.1.0, bytes@^3.0.0, bytes@^3.1.0: version "3.1.0" resolved "https://registry.yarnpkg.com/bytes/-/bytes-3.1.0.tgz#f6cf7933a360e0588fa9fde85651cdc7f805d1f6" integrity sha512-zauLjrfCG+xvoyaqLoV8bLVXXNGC4JqlxFCutSDWA6fJrTo2ZuvLYTqZ7aHBLZSMOopbzwv8f+wZcVzfVTI2Dg== @@ -6514,6 +6524,11 @@ file-loader@^6.0.0: loader-utils "^2.0.0" schema-utils "^3.0.0" +file-type@^10.10.0: + version "10.11.0" + resolved "https://registry.yarnpkg.com/file-type/-/file-type-10.11.0.tgz#2961d09e4675b9fb9a3ee6b69e9cd23f43fd1890" + integrity sha512-uzk64HRpUZyTGZtVuvrjP0FYxzQrBf4rojot6J65YMEbwBLB0CWm0CLojVpwpmFmxcE/lkvYICgfcGozbBq6rw== + file-uri-to-path@1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz#553a7b8446ff6f684359c445f1e37a05dacc33dd" @@ -7507,6 +7522,20 @@ ignore@^5.1.4: resolved "https://registry.yarnpkg.com/ignore/-/ignore-5.1.8.tgz#f150a8b50a34289b33e22f5889abd4d8016f0e57" integrity sha512-BMpfD7PpiETpBl/A6S498BaIJ6Y/ABT93ETbby2fP00v4EbvPBXWEoaR1UBPKs3iR53pJY7EtZk5KACI57i1Uw== +image-size@^0.9.7: + version "0.9.7" + resolved "https://registry.yarnpkg.com/image-size/-/image-size-0.9.7.tgz#43b4ead4b1310d5ae310a559d52935a347e47c09" + integrity sha512-KRVgLNZkr00YGN0qn9MlIrmlxbRhsCcEb1Byq3WKGnIV4M48iD185cprRtaoK4t5iC+ym2Q5qlArxZ/V1yzDgA== + dependencies: + queue "6.0.2" + +image-type@^4.1.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/image-type/-/image-type-4.1.0.tgz#72a88d64ff5021371ed67b9a466442100be57cd1" + integrity sha512-CFJMJ8QK8lJvRlTCEgarL4ro6hfDQKif2HjSvYCdQZESaIPV4v9imrf7BQHK+sQeTeNeMpWciR9hyC/g8ybXEg== + dependencies: + file-type "^10.10.0" + immer@^7.0.0: version "7.0.15" resolved "https://registry.yarnpkg.com/immer/-/immer-7.0.15.tgz#dc3bc6db87401659d2e737c67a21b227c484a4ad" @@ -10935,6 +10964,13 @@ queue-microtask@^1.2.2: resolved "https://registry.yarnpkg.com/queue-microtask/-/queue-microtask-1.2.3.tgz#4929228bbc724dfac43e0efb058caf7b6cfb6243" integrity sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A== +queue@6.0.2: + version "6.0.2" + resolved "https://registry.yarnpkg.com/queue/-/queue-6.0.2.tgz#b91525283e2315c7553d2efa18d83e76432fed65" + integrity sha512-iHZWu+q3IdFZFX36ro/lKBkSvfkztY5Y7HMiPlOUjhupPcG2JMfst2KKEpu5XndviX/3UhFbRngUPNKtgvtZiA== + dependencies: + inherits "~2.0.3" + quick-lru@^1.0.0: version "1.1.0" resolved "https://registry.yarnpkg.com/quick-lru/-/quick-lru-1.1.0.tgz#4360b17c61136ad38078397ff11416e186dcfbb8" @@ -13653,7 +13689,7 @@ utils-merge@1.0.1: resolved "https://registry.yarnpkg.com/utils-merge/-/utils-merge-1.0.1.tgz#9f95710f50a267947b2ccc124741c1028427e713" integrity sha1-n5VxD1CiZ5R7LMwSR0HBAoQn5xM= -uuid@8.3.2, uuid@^8.0.0, uuid@^8.3.0: +uuid@8.3.2, uuid@^8.0.0, uuid@^8.3.0, uuid@^8.3.2: version "8.3.2" resolved "https://registry.yarnpkg.com/uuid/-/uuid-8.3.2.tgz#80d5b5ced271bb9af6c445f21a1a04c606cefbe2" integrity sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==