From 11b234e2fcf6f0127033f84beeff268b6de6436e Mon Sep 17 00:00:00 2001 From: Danny Choudhury Date: Mon, 5 Aug 2024 18:08:48 +0700 Subject: [PATCH 01/91] Create uploads package with extension --- packages/uploads/README.md | 7 + packages/uploads/build.mts | 32 +++ packages/uploads/package.json | 42 +++ packages/uploads/prisma/index.js | 2 + packages/uploads/prisma/package.json | 4 + packages/uploads/src/prismaExtension.ts | 347 +++++++++++++++++++++++ packages/uploads/tsconfig.json | 13 + packages/uploads/tsconfig.types-cjs.json | 7 + yarn.lock | 26 ++ 9 files changed, 480 insertions(+) create mode 100644 packages/uploads/README.md create mode 100644 packages/uploads/build.mts create mode 100644 packages/uploads/package.json create mode 100644 packages/uploads/prisma/index.js create mode 100644 packages/uploads/prisma/package.json create mode 100644 packages/uploads/src/prismaExtension.ts create mode 100644 packages/uploads/tsconfig.json create mode 100644 packages/uploads/tsconfig.types-cjs.json diff --git a/packages/uploads/README.md b/packages/uploads/README.md new file mode 100644 index 000000000000..94d6e30debf7 --- /dev/null +++ b/packages/uploads/README.md @@ -0,0 +1,7 @@ +# Uploads + +This package houses + +- Prisma extension +- Form uploader +- diff --git a/packages/uploads/build.mts b/packages/uploads/build.mts new file mode 100644 index 000000000000..d1ec3b003bfb --- /dev/null +++ b/packages/uploads/build.mts @@ -0,0 +1,32 @@ +import { writeFileSync } from 'node:fs' + +import { build, defaultBuildOptions } from '@redwoodjs/framework-tools' +import { generateCjsTypes } from '@redwoodjs/framework-tools/cjsTypes' + +// CJS build +await build({ + buildOptions: { + ...defaultBuildOptions, + outdir: 'dist/cjs', + packages: 'external', + }, +}) + +// ESM build +await build({ + buildOptions: { + ...defaultBuildOptions, + format: 'esm', + packages: 'external', + }, +}) + +// Place a package.json file with `type: commonjs` in the dist/cjs folder so that +// all .js files are treated as CommonJS files. +writeFileSync('dist/cjs/package.json', JSON.stringify({ type: 'commonjs' })) + +// Place a package.json file with `type: module` in the dist folder so that +// all .js files are treated as ES Module files. +writeFileSync('dist/package.json', JSON.stringify({ type: 'module' })) + +await generateCjsTypes() diff --git a/packages/uploads/package.json b/packages/uploads/package.json new file mode 100644 index 000000000000..23f8ed72a863 --- /dev/null +++ b/packages/uploads/package.json @@ -0,0 +1,42 @@ +{ + "name": "@redwoodjs/uploads", + "type": "module", + "version": "7.0.0", + "repository": { + "type": "git", + "url": "git+https://github.com/redwoodjs/redwood.git", + "directory": "packages/uploads" + }, + "license": "MIT", + "exports": { + "./prisma": { + "require": "./dist/cjs/prismaExtension.js", + "import": "./dist/prismaExtension.js" + } + }, + "files": [ + "dist", + "prisma" + ], + "scripts": { + "build": "tsx ./build.mts && run build:types", + "build:pack": "yarn pack -o redwoodjs-uploads.tgz", + "build:types": "tsc --build --verbose", + "build:types-cjs": "tsc --build --verbose tsconfig.types-cjs.json" + }, + "dependencies": { + "@redwoodjs/project-config": "workspace:*", + "fs-extra": "11.2.0", + "mime-types": "2.1.35", + "ulid": "2.3.0" + }, + "devDependencies": { + "@redwoodjs/framework-tools": "workspace:*", + "@types/fs-extra": "11.0.4", + "@types/mime-types": "2.1.4", + "esbuild": "0.23.0", + "tsx": "4.16.2", + "typescript": "5.5.4" + }, + "gitHead": "3905ed045508b861b495f8d5630d76c7a157d8f1" +} diff --git a/packages/uploads/prisma/index.js b/packages/uploads/prisma/index.js new file mode 100644 index 000000000000..f21ea7b2fd3d --- /dev/null +++ b/packages/uploads/prisma/index.js @@ -0,0 +1,2 @@ +/* eslint-env es6, commonjs */ +module.exports = require('../dist/cjs/prismaExtension.js') diff --git a/packages/uploads/prisma/package.json b/packages/uploads/prisma/package.json new file mode 100644 index 000000000000..56705d7ff21b --- /dev/null +++ b/packages/uploads/prisma/package.json @@ -0,0 +1,4 @@ +{ + "main": "./index.js", + "types": "../dist/cjs/prisma/index.d.ts" +} diff --git a/packages/uploads/src/prismaExtension.ts b/packages/uploads/src/prismaExtension.ts new file mode 100644 index 000000000000..782edb9901fc --- /dev/null +++ b/packages/uploads/src/prismaExtension.ts @@ -0,0 +1,347 @@ +import fs from 'node:fs/promises' +import path from 'node:path' + +import { PrismaClient } from '@prisma/client' +import type * as runtime from '@prisma/client/runtime/library' +import { ulid } from 'ulid' + +import { getPaths } from '@redwoodjs/project-config' + +type FilterOutDollarPrefixed = T extends `$${string}` ? never : T +// Filter out $on, $connect, etc. +type ModelNames = FilterOutDollarPrefixed + +export type UploadConfigForModel = { + fields: [string] | string + savePath?: ((args: unknown) => string) | string + fileName?: (args: unknown) => string + onFileSaved?: (filePath: string) => void | Promise +} + +export type UploadsConfig = { + [key in ModelNames]?: UploadConfigForModel +} + +type TUSServerConfig = { + tusUploadDirectory: string +} + +type QueryExtends = { + [key in ModelNames]?: { + update: any + create: any + delete: any + } +} + +type ResultExtends = { + [key in ModelNames]?: { + withDataUri: { + needs: any + compute: (record: any) => () => Promise + } + withPublicUrl: { + needs: any + compute: (record: any) => () => Promise + } + } +} + +type ExtendsType = runtime.ExtensionArgs + +export const createUploadsExtension = ( + config: UploadsConfig, + tusConfig?: TUSServerConfig, +) => { + // @MARK typing these with ExtendsType['query'] and ExtendsType['result'] + // will create an error when we instiate the PrismaClient 🤷 + // but without these types prisma won''t show types for the new methods + const prismaInstance = new PrismaClient() + + async function deleteUploadsFromDiskForArgs({ + model, + args, + fields, + }: { + model: string + args: runtime.JsArgs // @TODO: type this better this is actually a where + fields: string[] + }) { + const record = await prismaInstance[model].findFirstOrThrow(args) + + // Delete the file from the file system + fields.forEach(async (field) => { + const filePath = record[field] + await fs.unlink(filePath) + }) + } + // This gives us typesafety when we write the extension, + // but we override it on return, because TS complains on instantiation of the PrismaClient + const queryExtends: ExtendsType['query'] = {} + const resultExtends: ExtendsType['result'] = {} + + for (const modelName in config) { + const modelConfig = config[modelName] as UploadConfigForModel + const uploadFields = Array.isArray(modelConfig.fields) + ? modelConfig.fields + : [modelConfig.fields] + + queryExtends[modelName] = { + // @TODO: in update we'll need to delete the old file, if the field is being updated + // THis will depend on whether we have a table for uploads or not + async update({ query, model, args }) { + await deleteUploadsFromDiskForArgs({ + model, + args: { + // The update args contains data, which we don't need to supply to delete + where: args.where, + }, + fields: uploadFields, + }) + + const uploadArgs = await saveUploads( + uploadFields, + args, + modelConfig, + tusConfig, + ) + + return query(uploadArgs) + }, + async create({ query, args }) { + const uploadArgs = await saveUploads( + uploadFields, + args, + modelConfig, + tusConfig, + ) + + return query(uploadArgs) + }, + async delete({ model, query, args }) { + await deleteUploadsFromDiskForArgs({ + model, + args, + fields: uploadFields, + }) + + return query(args) + }, + // findMany({ query, args, operation }) {} + } + + resultExtends[modelName] = { + withDataUri: { + needs: { avatar: true }, // specify the field name here, so it doesn't appear as a function if avatar isn't requested + compute(contact) { + return async () => { + // @TODO: edge cases + // 1. If readfile fails - file not found, etc. + // 2. If not a path, relative or absolute, throw error + const base64Content = await fs.readFile(contact.avatar, 'base64url') + return { + ...contact, + avatar: base64Content, + } + } + }, + }, + withPublicUrl: { + needs: { avatar: true }, // specify the field name here, so it doesn't appear as a function if avatar isn't requested + compute(contact) { + return async () => { + // @TODO: Test cases + // 1. If the avatar is a base64 string, we should return it as is, but warn + // 2. If absolute path, but not public, throw error + // 3. If relative path, but not public, throw error + const webPublicPath = contact.avatar.replace('web/public', '') + return { + ...contact, + avatar: webPublicPath, + } + } + }, + }, + } + } + + return { + name: 'redwood-upload-prisma-plugin', + query: queryExtends as QueryExtends, + result: resultExtends as ResultExtends, + } +} + +/** + * Returns new args to use in create or update. + * + * Pass this to the query function! + */ +async function saveUploads( + uploadFields: string[], + args: runtime.JsArgs & { + data?: { + [key: string]: runtime.JsInputValue + } + }, + modelConfig: UploadConfigForModel, + tusConfig?: TUSServerConfig, +) { + const fieldsToUpdate: { + [key: string]: string + } = {} + + if (!args.data) { + throw new Error('No data in prisma query') + } + + // For each upload property, we need to: + // 1. save the file to the file system (path or name from config) + // 2. replace the value of the field + for await (const field of uploadFields) { + const uploadUrlOrDataUrl = args.data[field] as string + + if (!uploadUrlOrDataUrl) { + continue + } + + const fileName = + modelConfig.fileName && typeof modelConfig.fileName === 'function' + ? modelConfig.fileName(args) + : ulid() + + const saveDir = + typeof modelConfig.savePath === 'function' + ? modelConfig.savePath(args) + : modelConfig.savePath || 'web/public/uploads' + + const savedFilePath = await saveUploadToFile( + uploadUrlOrDataUrl, + { + fileName, + saveDir, + }, + tusConfig, + ) + + fieldsToUpdate[field] = savedFilePath + + // Call the onFileSaved callback + // Having it here means it'll always trigger whether create/update + if (modelConfig.onFileSaved) { + await modelConfig.onFileSaved(savedFilePath) + } + } + + // Can't spread according to TS + const newData = Object.assign(args.data, fieldsToUpdate) + + return { + ...args, + data: newData, + } +} + +async function saveUploadToFile( + uploadUrlOrDataUrl: string, + { fileName, saveDir }: { saveDir: string; fileName: string }, + tusConfig?: TUSServerConfig, +) { + let outputPath: string | null = null + + if (isBase65(uploadUrlOrDataUrl)) { + outputPath = await saveBase65File(uploadUrlOrDataUrl, { + saveDir, + fileName, + }) + } else if (uploadUrlOrDataUrl.startsWith('http')) { + if (!tusConfig) { + throw new Error('TusConfig not supplied.') + } + + outputPath = await saveTusUpload(uploadUrlOrDataUrl, { + tusConfig, + saveDir, + fileName, + }) + } // @TODO: add support for form uploads? + + if (!outputPath) { + throw new Error('Unsupported upload URL') + } + + // @MARK: we can create a new record on the uploads table here + + return outputPath +} + +// @MARK: if we block the TUS GET, we don't really need to move it +// We send the TUS upload URL as the value of the field +async function saveTusUpload( + uploadUrl: string, + { + tusConfig, + saveDir, + fileName, + }: { + tusConfig: TUSServerConfig + saveDir: string + fileName: string + }, +) { + const tusId = uploadUrl.split('/').slice(-1).pop() + + if (!tusId) { + throw new Error('Could not extract upload ID from URL') + } + + if (!tusConfig.tusUploadDirectory) { + throw new Error( + 'You have to configure the TUS Upload Directory in the prisma extension. It is required for TUS uploads', + ) + } + + // Optional Step.... + const metaFile = path.join( + path.isAbsolute(tusConfig.tusUploadDirectory) + ? tusConfig.tusUploadDirectory + : // @MARK: if the directory supplied isn't relative + path.join(getPaths().base, tusConfig.tusUploadDirectory), + `${tusId}.json`, + ) + // Can't await import, because JSON file. + const tusMeta = require(metaFile) + + const fileExtension = tusMeta.metadata.filetype.split('/')[1] + + const savedFilePath = path.join(saveDir, `${fileName}.${fileExtension}`) + + // @MARK: we can also move... + await fs.copyFile( + path.join(tusConfig.tusUploadDirectory, tusId), + savedFilePath, + ) + + return savedFilePath +} + +function isBase65(uploadUrlOrDataUrl: string) { + // Check if the uploadUrlOrDataUrl is a valid base64 string + const base64Regex = /^data:(.*?);base64,/ + return base64Regex.test(uploadUrlOrDataUrl) +} + +async function saveBase65File( + dataUrlString: string, + { saveDir, fileName }: { saveDir: string; fileName: string }, +) { + // @TODO, use mime-types package to get the extension here. + const [dataType, fileContent] = dataUrlString.split(',') + // format is data:image/png;base64,.... + const fileExtension = dataType.split('/')[1].split(';')[0] + const filePath = path.join(saveDir, `${fileName}.${fileExtension}`) + + await fs.writeFile(filePath, Buffer.from(fileContent, 'base64')) + + return filePath +} diff --git a/packages/uploads/tsconfig.json b/packages/uploads/tsconfig.json new file mode 100644 index 000000000000..a8c9c1419bf6 --- /dev/null +++ b/packages/uploads/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "../../tsconfig.compilerOption.json", + "compilerOptions": { + "moduleResolution": "NodeNext", + "module": "NodeNext", + "baseUrl": ".", + "rootDir": "src", + "outDir": "dist" + }, + "include": ["src"], + "references": [ + ] +} diff --git a/packages/uploads/tsconfig.types-cjs.json b/packages/uploads/tsconfig.types-cjs.json new file mode 100644 index 000000000000..6bbdc61737c4 --- /dev/null +++ b/packages/uploads/tsconfig.types-cjs.json @@ -0,0 +1,7 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "dist/cjs", + "tsBuildInfoFile": "./tsconfig.types-cjs.tsbuildinfo" + } +} diff --git a/yarn.lock b/yarn.lock index cd78288431a9..781f7bd104e9 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8670,6 +8670,23 @@ __metadata: languageName: unknown linkType: soft +"@redwoodjs/uploads@workspace:packages/uploads": + version: 0.0.0-use.local + resolution: "@redwoodjs/uploads@workspace:packages/uploads" + dependencies: + "@redwoodjs/framework-tools": "workspace:*" + "@redwoodjs/project-config": "workspace:*" + "@types/fs-extra": "npm:11.0.4" + "@types/mime-types": "npm:2.1.4" + esbuild: "npm:0.23.0" + fs-extra: "npm:11.2.0" + mime-types: "npm:2.1.35" + tsx: "npm:4.16.2" + typescript: "npm:5.5.4" + ulid: "npm:2.3.0" + languageName: unknown + linkType: soft + "@redwoodjs/vite@workspace:packages/vite": version: 0.0.0-use.local resolution: "@redwoodjs/vite@workspace:packages/vite" @@ -28860,6 +28877,15 @@ __metadata: languageName: node linkType: hard +"ulid@npm:2.3.0": + version: 2.3.0 + resolution: "ulid@npm:2.3.0" + bin: + ulid: ./bin/cli.js + checksum: 10c0/070d237502781085e59cf3d8ece752ff96cd3a0990cf1c1be57273f4550597daeb72e9a7db8e5a320de31102509bb3321d280b54bfc44e98025e4628a9629773 + languageName: node + linkType: hard + "unbox-primitive@npm:^1.0.2": version: 1.0.2 resolution: "unbox-primitive@npm:1.0.2" From 2d748b2e836fb969538ed8d800d1420df1563f10 Mon Sep 17 00:00:00 2001 From: Danny Choudhury Date: Mon, 5 Aug 2024 19:15:40 +0700 Subject: [PATCH 02/91] Fix types after generating local prisma client for testing --- packages/uploads/attw.ts | 31 +++++++++++++++++++ packages/uploads/package.json | 8 ++++- .../src/__tests__/prismaExtension.test.ts | 0 packages/uploads/src/__tests__/schema.prisma | 19 ++++++++++++ packages/uploads/src/prismaExtension.ts | 8 +++-- packages/uploads/vitest.config.mts | 11 +++++++ yarn.lock | 2 ++ 7 files changed, 76 insertions(+), 3 deletions(-) create mode 100644 packages/uploads/attw.ts create mode 100644 packages/uploads/src/__tests__/prismaExtension.test.ts create mode 100644 packages/uploads/src/__tests__/schema.prisma create mode 100644 packages/uploads/vitest.config.mts diff --git a/packages/uploads/attw.ts b/packages/uploads/attw.ts new file mode 100644 index 000000000000..a377f7b5f320 --- /dev/null +++ b/packages/uploads/attw.ts @@ -0,0 +1,31 @@ +import { $ } from 'zx' + +interface Problem { + kind: string + entrypoint?: string + resolutionKind?: string +} + +await $({ nothrow: true })`yarn attw -P -f json > .attw.json` +const output = await $`cat .attw.json` +await $`rm .attw.json` + +const json = JSON.parse(output.stdout) + +if (!json.analysis.problems || json.analysis.problems.length === 0) { + console.log('No errors found') + process.exit(0) +} + +if ( + json.analysis.problems.every( + (problem: Problem) => problem.resolutionKind === 'node10', + ) +) { + console.log("Only found node10 problems, which we don't care about") + process.exit(0) +} + +console.log('Errors found') +console.log(json.analysis.problems) +process.exit(1) diff --git a/packages/uploads/package.json b/packages/uploads/package.json index 23f8ed72a863..c84b141b1220 100644 --- a/packages/uploads/package.json +++ b/packages/uploads/package.json @@ -2,6 +2,7 @@ "name": "@redwoodjs/uploads", "type": "module", "version": "7.0.0", + "types": "dist/prismaExtension.d.ts", "repository": { "type": "git", "url": "git+https://github.com/redwoodjs/redwood.git", @@ -22,7 +23,9 @@ "build": "tsx ./build.mts && run build:types", "build:pack": "yarn pack -o redwoodjs-uploads.tgz", "build:types": "tsc --build --verbose", - "build:types-cjs": "tsc --build --verbose tsconfig.types-cjs.json" + "build:types-cjs": "tsc --build --verbose tsconfig.types-cjs.json", + "check:attw": "tsx attw.ts", + "check:package": "concurrently npm:check:attw yarn publint" }, "dependencies": { "@redwoodjs/project-config": "workspace:*", @@ -31,10 +34,13 @@ "ulid": "2.3.0" }, "devDependencies": { + "@arethetypeswrong/cli": "0.15.3", + "@prisma/client": "5.17.0", "@redwoodjs/framework-tools": "workspace:*", "@types/fs-extra": "11.0.4", "@types/mime-types": "2.1.4", "esbuild": "0.23.0", + "publint": "0.2.9", "tsx": "4.16.2", "typescript": "5.5.4" }, diff --git a/packages/uploads/src/__tests__/prismaExtension.test.ts b/packages/uploads/src/__tests__/prismaExtension.test.ts new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/packages/uploads/src/__tests__/schema.prisma b/packages/uploads/src/__tests__/schema.prisma new file mode 100644 index 000000000000..f2a46e6fd5b4 --- /dev/null +++ b/packages/uploads/src/__tests__/schema.prisma @@ -0,0 +1,19 @@ +datasource db { + provider = "sqlite" + url = "file:never_used.db" +} + +generator client { + provider = "prisma-client-js" +} + +model Dummy { + id Int @id @default(autoincrement()) + uploadField String +} + +model Dumbo { + id Int @id @default(autoincrement()) + firstUpload String + secondUpload String +} diff --git a/packages/uploads/src/prismaExtension.ts b/packages/uploads/src/prismaExtension.ts index 782edb9901fc..9a5a729d6a8f 100644 --- a/packages/uploads/src/prismaExtension.ts +++ b/packages/uploads/src/prismaExtension.ts @@ -67,7 +67,10 @@ export const createUploadsExtension = ( args: runtime.JsArgs // @TODO: type this better this is actually a where fields: string[] }) { - const record = await prismaInstance[model].findFirstOrThrow(args) + const record = + // in the project its OK + // @ts-expect-error can't get prisma to stop complaining here + await prismaInstance[model as ModelNames].findFirstOrThrow(args) // Delete the file from the file system fields.forEach(async (field) => { @@ -81,7 +84,8 @@ export const createUploadsExtension = ( const resultExtends: ExtendsType['result'] = {} for (const modelName in config) { - const modelConfig = config[modelName] as UploadConfigForModel + // Guaranteed to have modelConfig, we're looping over config 🙄 + const modelConfig = config[modelName as ModelNames] as UploadConfigForModel const uploadFields = Array.isArray(modelConfig.fields) ? modelConfig.fields : [modelConfig.fields] diff --git a/packages/uploads/vitest.config.mts b/packages/uploads/vitest.config.mts new file mode 100644 index 000000000000..580e8462c027 --- /dev/null +++ b/packages/uploads/vitest.config.mts @@ -0,0 +1,11 @@ +import { defineConfig, configDefaults } from 'vitest/config' + +export default defineConfig({ + test: { + exclude: [...configDefaults.exclude, '**/fixtures'], + deps: { + interopDefault: false, + } + }, + +}) diff --git a/yarn.lock b/yarn.lock index 781f7bd104e9..089f93563eba 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8674,6 +8674,7 @@ __metadata: version: 0.0.0-use.local resolution: "@redwoodjs/uploads@workspace:packages/uploads" dependencies: + "@arethetypeswrong/cli": "npm:0.15.3" "@redwoodjs/framework-tools": "workspace:*" "@redwoodjs/project-config": "workspace:*" "@types/fs-extra": "npm:11.0.4" @@ -8681,6 +8682,7 @@ __metadata: esbuild: "npm:0.23.0" fs-extra: "npm:11.2.0" mime-types: "npm:2.1.35" + publint: "npm:0.2.9" tsx: "npm:4.16.2" typescript: "npm:5.5.4" ulid: "npm:2.3.0" From a32a3c82befce56791becc79484a68f1a3624d7c Mon Sep 17 00:00:00 2001 From: Danny Choudhury Date: Mon, 5 Aug 2024 22:52:07 +0700 Subject: [PATCH 03/91] Add gitignore --- packages/uploads/.gitignore | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 packages/uploads/.gitignore diff --git a/packages/uploads/.gitignore b/packages/uploads/.gitignore new file mode 100644 index 000000000000..0affa40abc3a --- /dev/null +++ b/packages/uploads/.gitignore @@ -0,0 +1,3 @@ +src/__tests__/migrations/* +src/__tests__/for_unit_test.db* + From e08016d102c5a5261f68e3a6e8de96d811d1111d Mon Sep 17 00:00:00 2001 From: Danny Choudhury Date: Mon, 5 Aug 2024 22:53:38 +0700 Subject: [PATCH 04/91] Rename test schema --- .../src/__tests__/{schema.prisma => unit-test-schema.prisma} | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename packages/uploads/src/__tests__/{schema.prisma => unit-test-schema.prisma} (87%) diff --git a/packages/uploads/src/__tests__/schema.prisma b/packages/uploads/src/__tests__/unit-test-schema.prisma similarity index 87% rename from packages/uploads/src/__tests__/schema.prisma rename to packages/uploads/src/__tests__/unit-test-schema.prisma index f2a46e6fd5b4..2040c947ba95 100644 --- a/packages/uploads/src/__tests__/schema.prisma +++ b/packages/uploads/src/__tests__/unit-test-schema.prisma @@ -1,6 +1,6 @@ datasource db { provider = "sqlite" - url = "file:never_used.db" + url = "file:for_unit_test.db" } generator client { From 56f008a314ccbd28289dbf7b5b6415aa9112714f Mon Sep 17 00:00:00 2001 From: Danny Choudhury Date: Mon, 5 Aug 2024 22:53:55 +0700 Subject: [PATCH 05/91] Add initial tests --- packages/uploads/package.json | 8 +- packages/uploads/src/__tests__/fileMocks.js | 2 + .../src/__tests__/getFileExtension.test.ts | 21 +++++ .../src/__tests__/prismaExtension.test.ts | 90 +++++++++++++++++++ packages/uploads/src/prismaExtension.ts | 22 +++-- yarn.lock | 3 + 6 files changed, 137 insertions(+), 9 deletions(-) create mode 100644 packages/uploads/src/__tests__/fileMocks.js create mode 100644 packages/uploads/src/__tests__/getFileExtension.test.ts diff --git a/packages/uploads/package.json b/packages/uploads/package.json index c84b141b1220..c5573df58e37 100644 --- a/packages/uploads/package.json +++ b/packages/uploads/package.json @@ -25,7 +25,9 @@ "build:types": "tsc --build --verbose", "build:types-cjs": "tsc --build --verbose tsconfig.types-cjs.json", "check:attw": "tsx attw.ts", - "check:package": "concurrently npm:check:attw yarn publint" + "check:package": "concurrently npm:check:attw yarn publint", + "test": "vitest run", + "test:watch": "vitest watch" }, "dependencies": { "@redwoodjs/project-config": "workspace:*", @@ -39,10 +41,12 @@ "@redwoodjs/framework-tools": "workspace:*", "@types/fs-extra": "11.0.4", "@types/mime-types": "2.1.4", + "concurrently": "8.2.2", "esbuild": "0.23.0", "publint": "0.2.9", "tsx": "4.16.2", - "typescript": "5.5.4" + "typescript": "5.5.4", + "vitest": "2.0.4" }, "gitHead": "3905ed045508b861b495f8d5630d76c7a157d8f1" } diff --git a/packages/uploads/src/__tests__/fileMocks.js b/packages/uploads/src/__tests__/fileMocks.js new file mode 100644 index 000000000000..469745009c0b --- /dev/null +++ b/packages/uploads/src/__tests__/fileMocks.js @@ -0,0 +1,2 @@ +export const dataUrlPng = + 'data:image/png;base64,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d78TDUS6RADRSfCKAAIIIIAAAggggAACCCCAAAIIIIBAmguUlpbaI9iwYYOcc845smXLFtlrr72kU6dONjioCzXPySefLLfffrtdrvN69+6tLzZpc+kBAwZIQUGBfU8AcRcMLwgggAACCCCAAAIIIIAAAggggAACCNQUAQ0ctmzZUsaNGyebN2+WBQsWyBVXXGEPb+TIkfLRRx/Z6VtuuUWKiork22+/lR07dsgDDzxg58+cOVMefPBBO52xdWthwE6V87/8vAJp1bpJOXOTDQEEEEAAAQQQQAABBBBAAAEEEEAAAQSSEdCRl2fNmmUDgW3atIm6CQ3+ufTmm2/KsGHD7NvRo0fLGWec4RbZV22a3K5dO1m7dq2cf/758uqrrwYt1zcaVHzooYdszcXFixcLg6iEETEDAQQQQAABBBBAAAEEEEAAAQQQQACB6iOgwT79Fy1pf4YZGRlhi7WZcmhatGiRt61bb701dLF9f/PNN9sAotZi1AAmAcSITMxEAAEEEEAAAQQQQAABBBBAAAEEEECgegjoICjXXntt1MJECh5qv4e5ublh68ybN8+bpzUcIyWt7ah9Ji5cuFDmzp1LADESEvMQQAABBBBAAAEEEEAAAQQQQAABBBCoLgK9evWSs846KyXFWbp0qd3OvvvuG3N72n+iBhCXLVsmDKISk4qFCCCAAAIIIIAAAggggAACCCCAAAII1ByBDh062INZsmSJaNPnaGnVqlV2keYngBhNifkIIIAAAggggAACCCCAAAIIIIAAAgjUMIHu3bt7R/TVV1950/4JraWoAUZN3bp1I4Dox2EaAQQQQAABBBBAAAEEEEAAAQQQQACBmizQsWNHcc2X77///oiH+uCDD9r52o+i9pNIDcSITMxEAAEEEEAAAQQQQAABBBBAAAEEEECg5glkZWXJvffeaw/srbfekquvvlp0tGVNeXl5cscdd8gTTzxh399+++3SuHFjAohWg/8QQAABBBBAAAEEEEAAAQQQQAABBBCoJQIXXXSRDBs2zB7tM888I02aNJF99tlHGjZs6AUXjz32WLnppptsHmog1pIPBoeJAAIIIIAAAggggAACCCCAAAIIIJBeAjk5ObbA2dnZCRe8Tp06UdfJyMiQUaNGyZNPPilt27a1+VauXGlfdfTlP//5zzJhwgTJzc218zK2bi2MPtxKhN3k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gAACCCCAAAK1RIAAYi050RwmAggggAACCCCAAAIIIIAAAggggAACyQgQQExGjXUQQAABBBBAAAEEEEAAAQQQQAABBBCoJQIEEGvJieYwEUAAAQQQQAABBBBAAAEEEEAAAQQQSEaAAGIyaqyDAAIIIIAAAggggAACCCCAAAIIIIBALREggFhLTjSHiQACCCCAAAIIIIAAAggggAACCCCAQDICBBCTUWMdBBBAAAEEEEAAAQQQQAABBBBAAAEEaokAAcRacqI5TAQQQAABBBBAAAEEEEAAAQQQQAABBJIRIICYjBrrIIAAAggggAACCCCAAAIIIIAAAgggUEsECCDWkhPNYSKAAAIIIIAAAggggAACCCCAAAIIIJCMAAHEZNRYBwEEEEAAAQQQQAABBBBAAAEEEEAAgVoiQACxlpxoDhMBBBBAAAEEEEAAAQQQQAABBBBAAIFkBAggJqPGOggggAACCCCAAAIIIIAAAggggAACCNQSAQKIteREc5gIIIAAAggggAACCCCAAAIIIIAAAggkI0AAMRk11kEAAQQQQAABBBBAAAEEEEAAAQQQQKCWCBBArCUnmsNEAAEEEEAAAQQQQAABBBBAAAEEEEAgGQECiMmosQ4CCCCAAAIIIIAAAggggAACCCCAAAK1RIAAYi050RwmAggggAACCCCAAAIIIIAAAggggAACyQgQQExGjXUQQAABBBBAAAEEEEAAAQQQQAABBBCoJQIEEGvJieYwEUAAAQQQQAABBBBAAAEEEEAAAQQQSEaAAGIyaqyDAAIIIIAAAggggAACCCCAAAIIIIBALREggFhLTjSHiQACCCCAAAIIIIAAAggggAACCCCAQDICBBCTUWMdBBBAAAEEEEAAAQQQQAABBBBAAAEEaokAAcRacqI5TAQQQAABBBBAAAEEEEAAAQQQQAABBJIRyE5mpXRZp6ioSP7+97/b4vbr10+OPfbYPVb0GTNmyGeffSY//vijbN68WTp37iw9evSQs88+Wxo0aLBHyrV69Wr55JNPZPLkybZMRx55pAwaNMiWK16Bli5dKq+99poEAgE55phjpH///vFWibq8Op2nqIX0LdBjf+utt+T777+X7du3y0EHHSTHH3+8HHHEEb5c4ZOlpaUydepU+eKLL2TmzJnSqVMnOeqoo2Tw4MFxPwPJ7jO0FD/88IO89957dvbB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EEAAAQQQQAABBBBAAAEEEEAAgVoiQACxlpxoDhMBBBBAAAEEEEAAAQQQQAABBBBAAIFkBAggJqPGOggggAACCCCAAAIIIIAAAggggAACCNQSgeyaeJylpaUydepU+eKLL2TmzJnSqVMnOeqoo2Tw4MHSoEGDuIf82muvyZIlS6Ru3bpy0003xc0fK8PEiRNtGTTPzTffLDk5ObGy79FlgUBARo8eLdOnT5d58+ZJ27ZtpWfPnnLRRRfFdVu9erV88sknMnnyZNm8ebMceeSRMmjQIOnRo0fMY6rIPkM3/Mwzz8imTZvs7BtuuEHq168fmoX3COxxgZEjR8ratWvDypGVlSX777+//Zvp2rVr2PKqnjFhwgT5+uuvpXPnznL22WdXePfRjjt0w23atJFLL700dDbvKyDwwgsvyKpVq+Too4+WI444IuEt6XfqBx98IN98843MnTtX9H23bt2kT58+MnToUMnMDH8WWVhYKI899pjd1xVXXCEtWrRIeL+sUHMEnn/+edHfCX379pUhQ4ZEPDD9fI0fP94uO/3006V79+4R873//vsyZ84cadmypVx++eXy008/yf+zdybw2lVj/18pGhGVoiKiEkKZkl4SypAMyRT+ppAxRJpFMhVSMssYGTJThswyE0KGzDJGMob7f32X91rvda977fvse5/zPOc85/zW53POvffaa9rfvfcarnWta+21117pb3/7WzrjjDPSbW9722a8peBJ3/Lcc89NX/nKV9IPf/jDtM022+Rvae+9905bb711ZxG76s8+7Ubsh8YMPO4NbnCDdN3rXrf5HcfwOhYBERhOoOs7bKX4uMc9bs5xVyue/ERABERgtRC45JK/j2b5u+hXF5vMZ+m6f/7zn6P73Oc+I4M38Wed0dFPf/rTzsJbx25kHduxeJ2Be1644Q1vWNIz4VzPWKs/mHVkR7e+9a1LWSM/EySOzjrrrM5CffKTnxxd+cpXbsZ93vOe1xlvPnnWib7zne8cy//Xv/51HUTnIrAkCNz4xjcee1fjt+bHd73rXUcXXnjhopbXOrC5nPe4xz0WpBx97pv7v9WtbrUg+SmR/yNw85vfPD/LZz3rWf/n2fPIBMmjac+ONu7DH/7wRGq/+93vynv+9a9/feK6PFYWgbvd7W75fTAhdueNP/zhDy/vzNOe9rTOcN6veshDHpLDnHDC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VERABERABERABERgyRDYc88908EHH5zMyHuyzXnSrrvu2iyb7ZCbdtttt+Y1eYqACIiACIiACIiACIjASiYgDcQl9PSxhYONia222moJlUpFEQEREAEREIE1nwCb/7z//e/PG6fEu8HGJVqJbKJz9NFHx0s6FgEREAEREIE1hgC2iRlL8req7RSvMVBUUBEQgQUlIBuIC4pTiYmACIiACIiACKwJBLAVxQBrww03XBOKqzKKgAiIgAiIgAiIgAiIwKIS0BLmRcWvzEVABERABERABBaDQN+dvBejbMpTBERABERABERABERABJYaAS1hXmpPROURAREQAREQAREQAREQAREQAREQAREQAREQgSVEQALEJfQwVBQREAEREAEREAEREAEREAEREAEREAEREAERWGoEJEBcak9E5REBERABERABERABERABERABERABERABERCBJURAAsQl9DBUFBEQAREQAREQAREQAREQAREQAREQAREQARFYagQkQFxqT0TlEQEREAEREAEREAEREAEREAEREAEREAEREIElREACxCX0MFQUERABERABERABERABERABERABERABERABEVhqBCRAXGpPROURAREQAREQAREQAREQAREQAREQAREQAREQgSVEQALEJfQwVBQREAEREAEREAEREAEREAEREAEREAEREAERWGoEJEBcak9E5REBERABERABERABERABERABERABERABERCBJURAAsQl9DBUFBEQAREQAREQAREQAREQAREQAREQAREQARFYagQkQFxqT0TlEQEREAEREAEREAEREAEREAEREAEREAEREIElREACxCX0MFQUERABERABERABERABERABERABERABERABEVhqBCRAXGpPROURAREQAREQAREQAREQAREQAREQAREQAREQgSVEQALEJfQwVBQREAEREAEREAEREAEREAEREAEREAEREAERWGoEJEBcak9E5REBERABERABERABERABERABERABERABERCBJURAAsQl9DBUFBEQAREQAREQAREQAREQAREQAREQAREQARFYagQkQFxqT0TlEQEREAEREAEREAEREAEREAEREAEREAEREIElREACxCX0MFQUERABERABERABERABERABERABERABERABEVhqBNYZUqBfX/THIdEURwREQAREQAREQAREQAREQAREQAREQAREQAREYA0jMEiAuPkWG69ht6niioAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIDCGgJcxDqCmOCIiACIiACIiACIiACIiACIiACIiACIiACKwQAhIgrpAHrdsUAREQAREQAREQAREQAREQAREQAREQAREQgSEEJEAcQk1xREAEREAEREAEREAEREAEREAEREAEREAERGCFEJAAcYU8aN2mCIiACIiACIiACIiACIiACIiACIiACIiACAwhIAHiEGqKIwIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIrhIAEiCvkQes2RUAEREAEREAEREAEREAEREAEREAEREAERGAIAQkQh1BTHBEQAREQAREQAREQAREQAREQAREQAREQARFYIQQkQFwhD1q3KQIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAJDCEiAOISa4oiACIiACIiACIiACIiACIiACIiACIiACIjACiEgAeIKedC6TREQAREQAREQAREQAREQAREQAREQAREQAREYQkACxCHUFEcEREAEREAEREAEREAEREAEREAEREAEREAEVggBCRBXyIPWbYqACIiACIiACIiACIiACIiACIiACIiACIjAEAISIA6hpjgiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIisEIISIC4Qh60blMEREAEREAEREAEREAEREAEREAEREAEREAEhhCQAHEINcURAREQAREQAREQAREQAREQAREQAREQAREQgRVCQALEFfKgdZsiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiMISABIhDqCmOCIiACIiACIiACIiACIiACIiACIiACIiACKwQAhIgrpAHrdsUAREQAREQAREQAREQAREQAREQAREQAREQgSEEJEAcQk1xREAEREAEREAEREAEREAEREAEREAEREAERGCFEJAAcYU8aN2mCIiACIiACIiACIiACIiACIiACIiACIiACAwhIAHiEGqKIwIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIrhIAEiCvkQes2RUAEREAEREAEREAEREAEREAEREAEREAERGAIAQkQh1BTHBEQAREQAREQAREQAREQAREQAREQAREQARFYIQTWWY73+Z///Cd9/vOfT5/5zGfSl7/85XSd61wn7b777mmPPfZIG2644dRb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wrzmPgKVXAREQAREQAREYFUSsOWfyWwcZg1DW6Kcd46+7LLLskarLYdKZiuq7I7tuwSvyvIobREQAREQAREQAREQARFY0wiss6YVWOUVAREQAREQAREQgVkI7Lnnnunggw9OZiMx2eY8adddd21GP+aYY9Juu+3WvCZPERABERABERABERABEVjJBKSBuISfPrYXsfu01VZbLeFSqmgiIAIiIAIisPQJnHjiien9739/qjUMzWB7QivRNqFItknX0r8RlVAEREAEREAEGgSwTczYkb9Vbae4kb28REAEVgAB2UBcAQ9ZtygCIiACIiACIjBOAFtRDLA23HDD8Qs6EwEREAEREAEREAEREAERmCCgJcwTSOQhAiIgAiIgAiKw3An03cl7uXPQ/YmACIiACIiACIiACIhAHwJawtyHksKIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIwAolIAHiCn3wum0REAEREAEREAEREAEREAEREAEREAEREAER6ENAAsQ+lBRGBERABERABERABERABERABERABERABERABFYoAQkQV+iD122LgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIQB8CEiD2oaQwIiACIiACIiACIiACIiACIiACIiACIiACIrBCCUiAuEIfvG5bBERABERABERABERABERABERABERABERABPoQkACxDyWFEQEREAEREAEREAEREAEREAEREAEREAEREIEVSkACxBX64HXbIiACIiACIiACIiACIiACIiACIiACIiACItCHgASIfSgpjAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAisUAISIK7QB6/bFgEREAEREAEREAEREAEREAEREAEREAEREIE+BCRA7ENJYURABERABERABERABERABERABERABERABERghRKQAHGFPnjdtgiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAj0ISABYh9KCiMCIiACIiACIiACIiACIiACIiACIiACIiACK5SABIgr9MHrtkVABERABERABERABERABERABERABERABESgDwEJEPtQUhgREAEREAEREAEREAEREAEREAEREAEREAERWKEEJEBcoQ9ety0CIiACIiACIiACIiACIiACIiACIiACIiACfQhIgNiHksKIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIwAolIAHiCn3wum0REAEREAEREAEREAEREAEREAEREAEREAER6ENgnT6B6jC/vuiPtZfORUAEREAEREAEREAEREAEREAEREAEREAEREAEliGBQQLEzbfYeBmi0C2JgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAjUBLSEuSaicxEQAREQAREQAREQAREQAREQAREQAREQAREQgUJAAsSCQgciIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAI1AQkQayI6FwEREAEREAEREAEREAEREAEREAEREAEREAERKAQkQCwodCACIiACIiACIiACIiACIiACIiACIiACIiACIlATkACxJqJzERABERABERABERABERABERABERABERABERCBQkACxIJCByIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAjUBCRBrIjoXAREQAREQAREQAREQAREQAREQAREQAREQAREoBCRALCh0IAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiUBOQALEmonMREAEREAEREAEREAEREAEREAEREAEREAEREIFCQALEgkIHIiACIiACIiACIiACIiACIiACIiACIiACIiACNQEJEGsiOhcBERABERABERABERABERABERABERABERABESgEJEAsKHQgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiJQE5AAsSaicxEQAREQAREQAREQAREQAREQAREQAREQAREQgUJAAsSCQgciIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAI1AQkQayI6FwEREAEREAEREAEREAEREAEREAEREAEREAERKAQkQCwodCACIiACIiACIiACIiACIiACIiACIiACIiACIlATkACxJqJzERABERABERABERABERABERABERABERABERCBQkACxIJCByIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAjUBCRBrIjoXAREQAREQAREQAREQAREQAREQAREQAREQAREoBCRALCh0IAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiUBOQALEmonMREAEREAEREAEREAEREAEREAEREAEREAEREIFCQALEgkIHIiACIiACIiACIiACIiACIiACIiACIiACIiACNQEJEGsiOhcBERABERABERABERABERABERABERABERABESgE1ilHy+jgP//5T/r85z+fPvOZz6Qvf/nL6TrXuU7afffd0x577JE23HDDqXd62WWXpbe97W3pG9/4RvrRj36Urn71q6cb3ehG6X73u1+68pWvPDVun4sf+9iZ4n6vAABAAElEQVTHcpkI++QnPzld/vKX7xNtUcKMRqP07ne/O33xi19MF1xwQbrGNa6RWTzwgQ+ck+Nf/vKX9MlPfjJ9+tOfTt///vfTzjvvnJ/Bbrvtli53uely63PPPTd99KMfTd/+9rfTuuuum3baaad0r3vdK22zzTYzcXjlK1+ZLr744hzn8Y9/fNpggw1miq/AIrAqCLzuda9Lv/nNbyaSXnvttdO2226bdtxxx7T99ttPXF/dHh/5yEfSV7/61XS9610vf3/zzb/rvut0qXMf/OAH1946nweBN7zhDelXv/pV+p//+Z+06667zpwSbeoHP/jB9LWvfS1997vfTZzvsMMO6SY3uUnaZ599mnX63//+9/SSl7wk5/Wwhz0sbbbZZjPnqwjLl8BPfvKTdOaZZ+b+wS9/+cu09dZb53dq//33T5tuuumSuvHXvOY16Xe/+13ae++9041vfONctu9973tpr732Sn/729/SGWeckW5729suqTKrMCIgAkuLQBz/zVWyxz3ucXOOs+ZKQ9dFQAREYJURuOSSv49m+bvoVxebXGnpun/+85+j+9znPiMDNvF3/etff/TTn/60s/AmtBqZsHEiHmld7WpXG73//e/vjNv3wg1veMOSvgnn+kZb7eF++MMfjm5961uXskaeJkgcnXXWWZ1l+vnPfz6K9xnjmiBw9I9//KMZ969//evooQ99aDNP0njBC17QjNfyfOc73zmWzq9//etWMPmJwGonYAPQsXczfh9+fNe73nV04YUXrvayxQytA5vLeY973CN6Dz7uc9/c/61udavBeShim8DNb37z/Cyf9axntQNM8TVB8mjas6Ou//CHPzyRgglcynv+9a9/feK6PFYmAfpoXrd4fRd/baJ29OxnP7sJx4SOIxuEj770pS81r68qT+/PvOhFLypZnHDCCeX9Puigg4q/DkRABESgReCJT3xiqTNindc6vuiii1pJyE8EREAElgSBZaWBaESzNoQJt6w+TskG4VnrDU1CtNG+853v5HM0E7faaqscxv+dd9556d73vnf605/+lL3Q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' diff --git a/packages/uploads/src/__tests__/getFileExtension.test.ts b/packages/uploads/src/__tests__/getFileExtension.test.ts new file mode 100644 index 000000000000..85629d8b4429 --- /dev/null +++ b/packages/uploads/src/__tests__/getFileExtension.test.ts @@ -0,0 +1,21 @@ +import { describe, it, expect } from 'vitest' + +import { getFileExtension } from '../prismaExtension' + +describe('getFileExtension', () => { + it('should return the correct file extension for a given data type', () => { + const dataType = 'data:image/png;base64' + const extension = getFileExtension(dataType) + expect(extension).toBe('png') + }) + + it('handles svgs', () => { + const dataType = 'data:image/svg+xml;base64' + expect(getFileExtension(dataType)).toBe('svg') + }) + + it('handles gif', () => { + const dataType = 'data:image/gif;base64' + expect(getFileExtension(dataType)).toBe('gif') + }) +}) diff --git a/packages/uploads/src/__tests__/prismaExtension.test.ts b/packages/uploads/src/__tests__/prismaExtension.test.ts index e69de29bb2d1..eb9b51527a28 100644 --- a/packages/uploads/src/__tests__/prismaExtension.test.ts +++ b/packages/uploads/src/__tests__/prismaExtension.test.ts @@ -0,0 +1,90 @@ +import fs from 'node:fs/promises' + +import { PrismaClient } from '@prisma/client' +import { describe, it, vi, expect } from 'vitest' + +import { createUploadsExtension } from '../prismaExtension' + +import { dataUrlPng } from './fileMocks' + +vi.mock('node:fs/promises', () => ({ + default: { + writeFile: vi.fn(), + }, +})) + +describe('Uploads Prisma Extension', () => { + const dummyUploadConfig = { + fields: 'uploadField', + savePath: '/bazinga', + onFileSaved: vi.fn(), + } + + const dumboUploadConfig = { + fields: ['firstUpload', 'secondUpload'], + savePath: '/dumbo', + onFileSaved: vi.fn(), + } + const prismaClient = new PrismaClient().$extends( + createUploadsExtension({ + dummy: dummyUploadConfig, + dumbo: dumboUploadConfig, + }), + ) + + describe('Query extensions', () => { + it('will create a file with base64 encoded png', async () => { + const dum1 = await prismaClient.dummy.create({ + data: { + uploadField: dataUrlPng, + }, + }) + + expect(dummyUploadConfig.onFileSaved).toHaveBeenCalled() + expect(dum1.uploadField).toMatch(/bazinga\/.*\.png/) + expect(fs.writeFile).toHaveBeenCalledWith( + expect.stringMatching(/bazinga\/.*\.png/), + expect.anything(), // no need to check content here, makes test slow + ) + }) + + it('handles multiple upload fields', async () => { + const dumbo = await prismaClient.dumbo.create({ + data: { + firstUpload: dataUrlPng, + secondUpload: dataUrlPng, + }, + }) + + expect(dumbo.firstUpload).toMatch(/dumbo\/.*\.png/) + expect(dumbo.secondUpload).toMatch(/dumbo\/.*\.png/) + + expect(dumboUploadConfig.onFileSaved).toHaveBeenCalledTimes(2) + }) + + // @TODO implement these tests + it('handles empty, null, or undefined fields', async () => {}) + + // it('handles updates, and removes old files', async () => {}) + + // it('handles deletes, and removes files', async () => {}) + + // it('supports custom file name functions', async () => {}) + + // it('supports custom save path functions', async () => {}) + + // it('will move file to new location with TUS uploads', async () => {}) + + // it('will remove old file when updating with TUS uploads', async () => {}) + + // it('will remove file when deleting with TUS uploads', async () => {}) + }) + + describe('Result extensions', () => { + it('will return a data URL for the file', async () => {}) + // @TODO implement + // it('will return a public URL for the file', async () => {}) + // it('if file is not found, will throw an error', async () => {}) + // it('if saved file is not a path, will throw an error', async () => {}) + }) +}) diff --git a/packages/uploads/src/prismaExtension.ts b/packages/uploads/src/prismaExtension.ts index 9a5a729d6a8f..4a876c3b4b0c 100644 --- a/packages/uploads/src/prismaExtension.ts +++ b/packages/uploads/src/prismaExtension.ts @@ -3,6 +3,7 @@ import path from 'node:path' import { PrismaClient } from '@prisma/client' import type * as runtime from '@prisma/client/runtime/library' +import mime from 'mime-types' import { ulid } from 'ulid' import { getPaths } from '@redwoodjs/project-config' @@ -12,7 +13,7 @@ type FilterOutDollarPrefixed = T extends `$${string}` ? never : T type ModelNames = FilterOutDollarPrefixed export type UploadConfigForModel = { - fields: [string] | string + fields: string[] | string savePath?: ((args: unknown) => string) | string fileName?: (args: unknown) => string onFileSaved?: (filePath: string) => void | Promise @@ -38,11 +39,11 @@ type ResultExtends = { [key in ModelNames]?: { withDataUri: { needs: any - compute: (record: any) => () => Promise + compute: (record: T) => () => Promise } withPublicUrl: { needs: any - compute: (record: any) => () => Promise + compute: (record: T) => () => Promise } } } @@ -80,6 +81,7 @@ export const createUploadsExtension = ( } // This gives us typesafety when we write the extension, // but we override it on return, because TS complains on instantiation of the PrismaClient + // its important that the resultsExtends const queryExtends: ExtendsType['query'] = {} const resultExtends: ExtendsType['result'] = {} @@ -91,8 +93,6 @@ export const createUploadsExtension = ( : [modelConfig.fields] queryExtends[modelName] = { - // @TODO: in update we'll need to delete the old file, if the field is being updated - // THis will depend on whether we have a table for uploads or not async update({ query, model, args }) { await deleteUploadsFromDiskForArgs({ model, @@ -339,13 +339,21 @@ async function saveBase65File( dataUrlString: string, { saveDir, fileName }: { saveDir: string; fileName: string }, ) { - // @TODO, use mime-types package to get the extension here. const [dataType, fileContent] = dataUrlString.split(',') // format is data:image/png;base64,.... - const fileExtension = dataType.split('/')[1].split(';')[0] + const fileExtension = getFileExtension(dataType) const filePath = path.join(saveDir, `${fileName}.${fileExtension}`) await fs.writeFile(filePath, Buffer.from(fileContent, 'base64')) return filePath } + +export function getFileExtension(dataType: string): string { + const mimeType = dataType.split(':')[1].split(';')[0] + const extension = mime.extension(mimeType) + if (!extension) { + throw new Error(`Unsupported file type: ${mimeType}`) + } + return extension +} diff --git a/yarn.lock b/yarn.lock index 089f93563eba..29a23a4bccd4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8675,10 +8675,12 @@ __metadata: resolution: "@redwoodjs/uploads@workspace:packages/uploads" dependencies: "@arethetypeswrong/cli": "npm:0.15.3" + "@prisma/client": "npm:5.17.0" "@redwoodjs/framework-tools": "workspace:*" "@redwoodjs/project-config": "workspace:*" "@types/fs-extra": "npm:11.0.4" "@types/mime-types": "npm:2.1.4" + concurrently: "npm:8.2.2" esbuild: "npm:0.23.0" fs-extra: "npm:11.2.0" mime-types: "npm:2.1.35" @@ -8686,6 +8688,7 @@ __metadata: tsx: "npm:4.16.2" typescript: "npm:5.5.4" ulid: "npm:2.3.0" + vitest: "npm:2.0.4" languageName: unknown linkType: soft From 7133768f9b579ea2b2eb3dfe2ef43da89bed3d45 Mon Sep 17 00:00:00 2001 From: Danny Choudhury Date: Mon, 5 Aug 2024 22:58:38 +0700 Subject: [PATCH 06/91] Update gitignore again --- packages/uploads/.gitignore | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/uploads/.gitignore b/packages/uploads/.gitignore index 0affa40abc3a..e5c46c18f78e 100644 --- a/packages/uploads/.gitignore +++ b/packages/uploads/.gitignore @@ -1,3 +1,3 @@ src/__tests__/migrations/* src/__tests__/for_unit_test.db* - +.attw.json From 7d157aeee1d56703b42e9fb89c7e23404ebac164 Mon Sep 17 00:00:00 2001 From: Danny Choudhury Date: Mon, 5 Aug 2024 22:58:44 +0700 Subject: [PATCH 07/91] Add test for empty values --- .../uploads/src/__tests__/prismaExtension.test.ts | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/packages/uploads/src/__tests__/prismaExtension.test.ts b/packages/uploads/src/__tests__/prismaExtension.test.ts index eb9b51527a28..17555cfd1797 100644 --- a/packages/uploads/src/__tests__/prismaExtension.test.ts +++ b/packages/uploads/src/__tests__/prismaExtension.test.ts @@ -62,9 +62,17 @@ describe('Uploads Prisma Extension', () => { expect(dumboUploadConfig.onFileSaved).toHaveBeenCalledTimes(2) }) - // @TODO implement these tests - it('handles empty, null, or undefined fields', async () => {}) + it('handles empty fields', async () => { + const emptyUploadFieldPromise = prismaClient.dummy.create({ + data: { + uploadField: '', + }, + }) + await expect(emptyUploadFieldPromise).resolves.not.toThrow() + }) + + // @TODO implement these tests // it('handles updates, and removes old files', async () => {}) // it('handles deletes, and removes files', async () => {}) From 40f0011b1c0a5af6a0d902a77dc2522542d6ee1f Mon Sep 17 00:00:00 2001 From: Danny Choudhury Date: Mon, 5 Aug 2024 23:00:56 +0700 Subject: [PATCH 08/91] Add prisma setup script --- packages/uploads/package.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/uploads/package.json b/packages/uploads/package.json index c5573df58e37..e704d7c6b933 100644 --- a/packages/uploads/package.json +++ b/packages/uploads/package.json @@ -26,7 +26,8 @@ "build:types-cjs": "tsc --build --verbose tsconfig.types-cjs.json", "check:attw": "tsx attw.ts", "check:package": "concurrently npm:check:attw yarn publint", - "test": "vitest run", + "setup:test": "npx prisma migrate reset -f --schema src/__tests__/unit-test-schema.prisma", + "test": "yarn run setup:test && vitest run", "test:watch": "vitest watch" }, "dependencies": { From 59f83ccbb996937652e64ddbdb315742c982ea72 Mon Sep 17 00:00:00 2001 From: Danny Choudhury Date: Mon, 5 Aug 2024 23:12:01 +0700 Subject: [PATCH 09/91] Update tsconfig --- packages/uploads/tsconfig.json | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/uploads/tsconfig.json b/packages/uploads/tsconfig.json index a8c9c1419bf6..43d163492556 100644 --- a/packages/uploads/tsconfig.json +++ b/packages/uploads/tsconfig.json @@ -9,5 +9,11 @@ }, "include": ["src"], "references": [ + { + "path": "../project-config" + }, + { + "path": "../framework-tools" + }, ] } From 2c28d76bdb54977f578ab2ed59f076516f41e4ad Mon Sep 17 00:00:00 2001 From: Danny Choudhury Date: Mon, 5 Aug 2024 23:13:45 +0700 Subject: [PATCH 10/91] Update readme --- packages/uploads/README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/uploads/README.md b/packages/uploads/README.md index 94d6e30debf7..b6b7c36ce457 100644 --- a/packages/uploads/README.md +++ b/packages/uploads/README.md @@ -2,6 +2,6 @@ This package houses -- Prisma extension -- Form uploader -- +- Prisma extension for handling uploads +- Base64 file picker uploader (?) +- TUS uploader (?) From bdbdccf25a4da382ae23b2c68a0b866a916b5169 Mon Sep 17 00:00:00 2001 From: Danny Choudhury Date: Mon, 5 Aug 2024 23:15:23 +0700 Subject: [PATCH 11/91] Add setup:test to watch command too --- packages/uploads/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/uploads/package.json b/packages/uploads/package.json index e704d7c6b933..f0b8cff5e6b3 100644 --- a/packages/uploads/package.json +++ b/packages/uploads/package.json @@ -28,7 +28,7 @@ "check:package": "concurrently npm:check:attw yarn publint", "setup:test": "npx prisma migrate reset -f --schema src/__tests__/unit-test-schema.prisma", "test": "yarn run setup:test && vitest run", - "test:watch": "vitest watch" + "test:watch": "yarn run setup:test && vitest watch" }, "dependencies": { "@redwoodjs/project-config": "workspace:*", From 9ab71a7205a5f95b6bc9c7535fd6025f5eac0d0d Mon Sep 17 00:00:00 2001 From: Danny Choudhury Date: Tue, 6 Aug 2024 13:24:01 +0700 Subject: [PATCH 12/91] Change types again --- packages/uploads/src/prismaExtension.ts | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/packages/uploads/src/prismaExtension.ts b/packages/uploads/src/prismaExtension.ts index 4a876c3b4b0c..34d0423d3348 100644 --- a/packages/uploads/src/prismaExtension.ts +++ b/packages/uploads/src/prismaExtension.ts @@ -8,7 +8,12 @@ import { ulid } from 'ulid' import { getPaths } from '@redwoodjs/project-config' -type FilterOutDollarPrefixed = T extends `$${string}` ? never : T +type FilterOutDollarPrefixed = T extends `$${string}` + ? never + : T extends symbol // Remove symbol here, because it doesn't help users + ? never + : T + // Filter out $on, $connect, etc. type ModelNames = FilterOutDollarPrefixed @@ -59,19 +64,20 @@ export const createUploadsExtension = ( // but without these types prisma won''t show types for the new methods const prismaInstance = new PrismaClient() - async function deleteUploadsFromDiskForArgs({ + async function deleteUploadsFromDiskForArgs({ model, args, fields, }: { model: string - args: runtime.JsArgs // @TODO: type this better this is actually a where + args: T fields: string[] }) { - const record = - // in the project its OK - // @ts-expect-error can't get prisma to stop complaining here - await prismaInstance[model as ModelNames].findFirstOrThrow(args) + // With strict mode you cannot call findFirstOrThrow with the same args, because it is a union type + // Ideally there's a better way to do this + const record = await ( + prismaInstance[model as ModelNames] as any + ).findFirstOrThrow(args) // Delete the file from the file system fields.forEach(async (field) => { From d8a67951c7014e9cda820afe024bfed8ef3c9795 Mon Sep 17 00:00:00 2001 From: Danny Choudhury Date: Tue, 6 Aug 2024 13:32:10 +0700 Subject: [PATCH 13/91] Fix types appearing correctly --- packages/uploads/package.json | 10 ++++++++-- packages/uploads/prisma/package.json | 2 +- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/packages/uploads/package.json b/packages/uploads/package.json index f0b8cff5e6b3..395478389cd1 100644 --- a/packages/uploads/package.json +++ b/packages/uploads/package.json @@ -11,8 +11,14 @@ "license": "MIT", "exports": { "./prisma": { - "require": "./dist/cjs/prismaExtension.js", - "import": "./dist/prismaExtension.js" + "require": { + "types": "./dist/cjs/prismaExtension.d.ts", + "default": "./dist/cjs/prismaExtension.js" + }, + "import": { + "types": "./dist/prismaExtension.d.ts", + "default": "./dist/prismaExtension.js" + } } }, "files": [ diff --git a/packages/uploads/prisma/package.json b/packages/uploads/prisma/package.json index 56705d7ff21b..9b83f1f2dc6d 100644 --- a/packages/uploads/prisma/package.json +++ b/packages/uploads/prisma/package.json @@ -1,4 +1,4 @@ { "main": "./index.js", - "types": "../dist/cjs/prisma/index.d.ts" + "types": "../dist/cjs/prismaExtension.d.ts" } From 8ddf5d6d0c0821624907505f03cdbb875e617990 Mon Sep 17 00:00:00 2001 From: Danny Choudhury Date: Tue, 6 Aug 2024 15:07:15 +0700 Subject: [PATCH 14/91] Try using zx to setup --- packages/uploads/package.json | 4 ++-- packages/uploads/src/prismaExtension.ts | 2 ++ packages/uploads/vitest.config.mts | 3 ++- packages/uploads/vitest.setup.mts | 7 +++++++ 4 files changed, 13 insertions(+), 3 deletions(-) create mode 100644 packages/uploads/vitest.setup.mts diff --git a/packages/uploads/package.json b/packages/uploads/package.json index 395478389cd1..0ff566444471 100644 --- a/packages/uploads/package.json +++ b/packages/uploads/package.json @@ -33,8 +33,8 @@ "check:attw": "tsx attw.ts", "check:package": "concurrently npm:check:attw yarn publint", "setup:test": "npx prisma migrate reset -f --schema src/__tests__/unit-test-schema.prisma", - "test": "yarn run setup:test && vitest run", - "test:watch": "yarn run setup:test && vitest watch" + "test": "vitest run", + "test:watch": "vitest watch" }, "dependencies": { "@redwoodjs/project-config": "workspace:*", diff --git a/packages/uploads/src/prismaExtension.ts b/packages/uploads/src/prismaExtension.ts index 34d0423d3348..dea174623012 100644 --- a/packages/uploads/src/prismaExtension.ts +++ b/packages/uploads/src/prismaExtension.ts @@ -44,6 +44,8 @@ type ResultExtends = { [key in ModelNames]?: { withDataUri: { needs: any + // @TODO(TS): this generates unknowns. We dont have access to the Prisma type here + // because it depends on the type it was called from compute: (record: T) => () => Promise } withPublicUrl: { diff --git a/packages/uploads/vitest.config.mts b/packages/uploads/vitest.config.mts index 580e8462c027..cb0188bdb924 100644 --- a/packages/uploads/vitest.config.mts +++ b/packages/uploads/vitest.config.mts @@ -5,7 +5,8 @@ export default defineConfig({ exclude: [...configDefaults.exclude, '**/fixtures'], deps: { interopDefault: false, - } + }, + globalSetup: ['vitest.setup.mts'], }, }) diff --git a/packages/uploads/vitest.setup.mts b/packages/uploads/vitest.setup.mts new file mode 100644 index 000000000000..b673e2cfb2eb --- /dev/null +++ b/packages/uploads/vitest.setup.mts @@ -0,0 +1,7 @@ +import { $ } from 'zx' + +export default async function setup() { + console.log('[setup] Setting up unit test prisma db....') + await $`npx prisma migrate reset -f --skip-seed --schema src/__tests__/unit-test-schema.prisma` + console.log('[setup] Done! \n') +} From a0069db4fa4e9b70980905415ee7afac2b77e679 Mon Sep 17 00:00:00 2001 From: Danny Choudhury Date: Tue, 6 Aug 2024 15:58:04 +0700 Subject: [PATCH 15/91] Try cleaning prisma first --- packages/uploads/vitest.setup.mts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/uploads/vitest.setup.mts b/packages/uploads/vitest.setup.mts index b673e2cfb2eb..01987b744896 100644 --- a/packages/uploads/vitest.setup.mts +++ b/packages/uploads/vitest.setup.mts @@ -2,6 +2,7 @@ import { $ } from 'zx' export default async function setup() { console.log('[setup] Setting up unit test prisma db....') + await $`yarn clean:prisma` await $`npx prisma migrate reset -f --skip-seed --schema src/__tests__/unit-test-schema.prisma` console.log('[setup] Done! \n') } From c2a268e4f678928f4ca6ece9629e9934332fb8cc Mon Sep 17 00:00:00 2001 From: Danny Choudhury Date: Tue, 6 Aug 2024 17:08:14 +0700 Subject: [PATCH 16/91] Remove publicUri extension for now. Add more tests, unhardcode withDataUri --- .../src/__tests__/prismaExtension.test.ts | 131 ++++++++++++++++-- packages/uploads/src/prismaExtension.ts | 42 +++--- 2 files changed, 140 insertions(+), 33 deletions(-) diff --git a/packages/uploads/src/__tests__/prismaExtension.test.ts b/packages/uploads/src/__tests__/prismaExtension.test.ts index 17555cfd1797..6f1c778d606b 100644 --- a/packages/uploads/src/__tests__/prismaExtension.test.ts +++ b/packages/uploads/src/__tests__/prismaExtension.test.ts @@ -1,6 +1,7 @@ import fs from 'node:fs/promises' import { PrismaClient } from '@prisma/client' +import { vol } from 'memfs' import { describe, it, vi, expect } from 'vitest' import { createUploadsExtension } from '../prismaExtension' @@ -10,9 +11,22 @@ import { dataUrlPng } from './fileMocks' vi.mock('node:fs/promises', () => ({ default: { writeFile: vi.fn(), + unlink: vi.fn(), + readFile: vi.fn((path, encoding) => { + if (encoding === 'base64url') { + return 'BASE64::THIS_IS_A_MOCKED_DATA_URL' + } + + return 'MOCKED_FILE_CONTENT' + }), }, })) +vol.fromJSON({ + '/tmp/tus-uploads/123.json': '{}', + '/tmp/tus-uploads/ABCD.json': '{}', +}) + describe('Uploads Prisma Extension', () => { const dummyUploadConfig = { fields: 'uploadField', @@ -25,11 +39,18 @@ describe('Uploads Prisma Extension', () => { savePath: '/dumbo', onFileSaved: vi.fn(), } + + const tusConfig = { + tusUploadDirectory: '/tmp/tus-uploads', + } const prismaClient = new PrismaClient().$extends( - createUploadsExtension({ - dummy: dummyUploadConfig, - dumbo: dumboUploadConfig, - }), + createUploadsExtension( + { + dummy: dummyUploadConfig, + dumbo: dumboUploadConfig, + }, + tusConfig, + ), ) describe('Query extensions', () => { @@ -72,16 +93,91 @@ describe('Uploads Prisma Extension', () => { await expect(emptyUploadFieldPromise).resolves.not.toThrow() }) - // @TODO implement these tests - // it('handles updates, and removes old files', async () => {}) + it('handles updates, and removes old files', async () => { + const dum1 = await prismaClient.dummy.create({ + data: { + uploadField: dataUrlPng, + }, + }) - // it('handles deletes, and removes files', async () => {}) + const originalPath = dum1.uploadField - // it('supports custom file name functions', async () => {}) + const dum2 = await prismaClient.dummy.update({ + where: { id: dum1.id }, + data: { + uploadField: dataUrlPng, + }, + }) - // it('supports custom save path functions', async () => {}) + expect(dum2.uploadField).not.toEqual(originalPath) + expect(dum2.uploadField).toMatch(/bazinga\/.*\.png/) + expect(fs.unlink).toHaveBeenCalledWith(originalPath) + }) - // it('will move file to new location with TUS uploads', async () => {}) + it('handles deletes, and removes files', async () => { + const dum1 = await prismaClient.dummy.create({ + data: { + uploadField: dataUrlPng, + }, + }) + + await prismaClient.dummy.delete({ + where: { id: dum1.id }, + }) + + expect(fs.unlink).toHaveBeenCalledWith(dum1.uploadField) + }) + + it('supports custom file name and save path functions', async () => { + const customNameConfig = { + fields: 'firstUpload', + savePath: '/custom', + onFileSaved: vi.fn(), + fileName: (args) => { + // 👇 Using args here + return `my-name-is-dumbo-${args.data.id}` + }, + } + + const clientWithFileName = new PrismaClient().$extends( + createUploadsExtension({ + dumbo: customNameConfig, + }), + ) + + const dumbo = await clientWithFileName.dumbo.create({ + data: { + firstUpload: dataUrlPng, + secondUpload: '', + id: 55, + }, + }) + + expect(customNameConfig.onFileSaved).toHaveBeenCalled() + expect(dumbo.firstUpload).toBe('/custom/my-name-is-dumbo-55.png') + + // Delete it to clean up + await clientWithFileName.dumbo.delete({ + where: { + id: 55, + }, + }) + }) + + // @TODO have to figure out how to mock the + // it('will move file to new location with TUS uploads', async () => { + // const dumbo = await prismaClient.dumbo.create({ + // data: { + // firstUpload: + // 'http://example.com/.redwood/functions/tusUploadEndpoint/123', + // secondUpload: + // 'http://example.com/.redwood/functions/tusUploadEndpoint/ABCD', + // }, + // }) + + // expect(dumbo.firstUpload).toBe('balknsdg') + // expect(dumbo.secondUpload).toBe('balknsdg') + // }) // it('will remove old file when updating with TUS uploads', async () => {}) @@ -89,9 +185,20 @@ describe('Uploads Prisma Extension', () => { }) describe('Result extensions', () => { - it('will return a data URL for the file', async () => {}) + it('will return a data URL for the file', async () => { + const res1 = await ( + await prismaClient.dummy.create({ + data: { + uploadField: dataUrlPng, + }, + }) + ).withDataUri() // WHY NO TYPES? + + // Mocked in FS mocks + expect(res1.uploadField).toBe('BASE64::THIS_IS_A_MOCKED_DATA_URL') + }) + // @TODO implement - // it('will return a public URL for the file', async () => {}) // it('if file is not found, will throw an error', async () => {}) // it('if saved file is not a path, will throw an error', async () => {}) }) diff --git a/packages/uploads/src/prismaExtension.ts b/packages/uploads/src/prismaExtension.ts index dea174623012..4abe870b95ae 100644 --- a/packages/uploads/src/prismaExtension.ts +++ b/packages/uploads/src/prismaExtension.ts @@ -142,34 +142,30 @@ export const createUploadsExtension = ( // findMany({ query, args, operation }) {} } + // This makes the result extension only available for models with uploadFields + const needs = Object.fromEntries(uploadFields.map((field) => [field, true])) + console.log(`👉 \n ~ needs:`, needs) + resultExtends[modelName] = { withDataUri: { - needs: { avatar: true }, // specify the field name here, so it doesn't appear as a function if avatar isn't requested - compute(contact) { + needs, + compute(modelData) { return async () => { + const base64UploadFields: Record = {} + for await (const field of uploadFields) { + base64UploadFields[field] = await fs.readFile( + modelData[field], + 'base64url', + ) + } + // @TODO: edge cases // 1. If readfile fails - file not found, etc. // 2. If not a path, relative or absolute, throw error - const base64Content = await fs.readFile(contact.avatar, 'base64url') - return { - ...contact, - avatar: base64Content, - } - } - }, - }, - withPublicUrl: { - needs: { avatar: true }, // specify the field name here, so it doesn't appear as a function if avatar isn't requested - compute(contact) { - return async () => { - // @TODO: Test cases - // 1. If the avatar is a base64 string, we should return it as is, but warn - // 2. If absolute path, but not public, throw error - // 3. If relative path, but not public, throw error - const webPublicPath = contact.avatar.replace('web/public', '') + return { - ...contact, - avatar: webPublicPath, + ...modelData, + ...base64UploadFields, } } }, @@ -177,6 +173,8 @@ export const createUploadsExtension = ( } } + console.log('xxxx resultExtends', resultExtends) + return { name: 'redwood-upload-prisma-plugin', query: queryExtends as QueryExtends, @@ -301,6 +299,8 @@ async function saveTusUpload( fileName: string }, ) { + // Get the last part of the TUS upload url + // http://localhost:8910/.redwood/functions/uploadTUS/👉28fa96bf5772338d51👈 const tusId = uploadUrl.split('/').slice(-1).pop() if (!tusId) { From 19c85a4b329fd87fd59f9fc9824ff760bc77b428 Mon Sep 17 00:00:00 2001 From: Danny Choudhury Date: Wed, 7 Aug 2024 13:32:45 +0700 Subject: [PATCH 17/91] Get type mapping nearly there! Just need to make sure the type of compute in results extensions is correct --- .../src/__tests__/prismaExtension.test.ts | 2 +- packages/uploads/src/prismaExtension.ts | 77 ++++++++----------- 2 files changed, 35 insertions(+), 44 deletions(-) diff --git a/packages/uploads/src/__tests__/prismaExtension.test.ts b/packages/uploads/src/__tests__/prismaExtension.test.ts index 6f1c778d606b..09323211781c 100644 --- a/packages/uploads/src/__tests__/prismaExtension.test.ts +++ b/packages/uploads/src/__tests__/prismaExtension.test.ts @@ -192,7 +192,7 @@ describe('Uploads Prisma Extension', () => { uploadField: dataUrlPng, }, }) - ).withDataUri() // WHY NO TYPES? + ).withDataUri() // Mocked in FS mocks expect(res1.uploadField).toBe('BASE64::THIS_IS_A_MOCKED_DATA_URL') diff --git a/packages/uploads/src/prismaExtension.ts b/packages/uploads/src/prismaExtension.ts index 4abe870b95ae..8f77f0ac7bc7 100644 --- a/packages/uploads/src/prismaExtension.ts +++ b/packages/uploads/src/prismaExtension.ts @@ -2,6 +2,7 @@ import fs from 'node:fs/promises' import path from 'node:path' import { PrismaClient } from '@prisma/client' +import { Prisma } from '@prisma/client/extension' import type * as runtime from '@prisma/client/runtime/library' import mime from 'mime-types' import { ulid } from 'ulid' @@ -24,41 +25,20 @@ export type UploadConfigForModel = { onFileSaved?: (filePath: string) => void | Promise } -export type UploadsConfig = { - [key in ModelNames]?: UploadConfigForModel -} +export type UploadsConfig = Record< + MName, + UploadConfigForModel +> type TUSServerConfig = { tusUploadDirectory: string } -type QueryExtends = { - [key in ModelNames]?: { - update: any - create: any - delete: any - } -} - -type ResultExtends = { - [key in ModelNames]?: { - withDataUri: { - needs: any - // @TODO(TS): this generates unknowns. We dont have access to the Prisma type here - // because it depends on the type it was called from - compute: (record: T) => () => Promise - } - withPublicUrl: { - needs: any - compute: (record: T) => () => Promise - } - } -} - -type ExtendsType = runtime.ExtensionArgs +// type ExtendsType = runtime.ExtensionArgs +// type ExtendsType = Parameters[0] -export const createUploadsExtension = ( - config: UploadsConfig, +export const createUploadsExtension = ( + config: UploadsConfig, tusConfig?: TUSServerConfig, ) => { // @MARK typing these with ExtendsType['query'] and ExtendsType['result'] @@ -87,15 +67,21 @@ export const createUploadsExtension = ( await fs.unlink(filePath) }) } - // This gives us typesafety when we write the extension, - // but we override it on return, because TS complains on instantiation of the PrismaClient - // its important that the resultsExtends - const queryExtends: ExtendsType['query'] = {} - const resultExtends: ExtendsType['result'] = {} + + const queryExtends: runtime.ExtensionArgs['query'] = {} + + const resultExtends = {} as { + [K in MNames]: { + withDataUri: { + needs: Record + compute: (modelData: T) => () => Promise + } + } + } for (const modelName in config) { // Guaranteed to have modelConfig, we're looping over config 🙄 - const modelConfig = config[modelName as ModelNames] as UploadConfigForModel + const modelConfig = config[modelName] as UploadConfigForModel const uploadFields = Array.isArray(modelConfig.fields) ? modelConfig.fields : [modelConfig.fields] @@ -143,8 +129,9 @@ export const createUploadsExtension = ( } // This makes the result extension only available for models with uploadFields - const needs = Object.fromEntries(uploadFields.map((field) => [field, true])) - console.log(`👉 \n ~ needs:`, needs) + const needs: any = Object.fromEntries( + uploadFields.map((field) => [field, true]), + ) resultExtends[modelName] = { withDataUri: { @@ -152,9 +139,11 @@ export const createUploadsExtension = ( compute(modelData) { return async () => { const base64UploadFields: Record = {} + type ModelField = keyof typeof modelData + for await (const field of uploadFields) { base64UploadFields[field] = await fs.readFile( - modelData[field], + modelData[field as ModelField] as string, 'base64url', ) } @@ -175,11 +164,13 @@ export const createUploadsExtension = ( console.log('xxxx resultExtends', resultExtends) - return { - name: 'redwood-upload-prisma-plugin', - query: queryExtends as QueryExtends, - result: resultExtends as ResultExtends, - } + return Prisma.defineExtension((client) => { + return client.$extends({ + name: 'redwood-upload-prisma-plugin', + query: queryExtends, + result: resultExtends, + }) + }) } /** From 5a884e5dfb21c05a7f53078bb83ec964c8c0690f Mon Sep 17 00:00:00 2001 From: Danny Choudhury Date: Wed, 7 Aug 2024 15:06:17 +0700 Subject: [PATCH 18/91] Hack the result from compute --- packages/uploads/src/prismaExtension.ts | 53 ++++++++++++------------- 1 file changed, 26 insertions(+), 27 deletions(-) diff --git a/packages/uploads/src/prismaExtension.ts b/packages/uploads/src/prismaExtension.ts index 8f77f0ac7bc7..94c5ca103dcb 100644 --- a/packages/uploads/src/prismaExtension.ts +++ b/packages/uploads/src/prismaExtension.ts @@ -74,7 +74,12 @@ export const createUploadsExtension = ( [K in MNames]: { withDataUri: { needs: Record - compute: (modelData: T) => () => Promise + compute: ( + // @MARK: this is a hack + // There has to be a better way to type this... because if you used a select or omit + // it would be a different type + modelData: ReturnType, + ) => () => ReturnType } } } @@ -129,45 +134,39 @@ export const createUploadsExtension = ( } // This makes the result extension only available for models with uploadFields - const needs: any = Object.fromEntries( - uploadFields.map((field) => [field, true]), - ) + const needs = Object.fromEntries(uploadFields.map((field) => [field, true])) resultExtends[modelName] = { withDataUri: { needs, - compute(modelData) { - return async () => { - const base64UploadFields: Record = {} - type ModelField = keyof typeof modelData - - for await (const field of uploadFields) { - base64UploadFields[field] = await fs.readFile( - modelData[field as ModelField] as string, - 'base64url', - ) - } - - // @TODO: edge cases - // 1. If readfile fails - file not found, etc. - // 2. If not a path, relative or absolute, throw error - - return { - ...modelData, - ...base64UploadFields, - } + async compute(modelData) { + const base64UploadFields: Record = {} + type ModelField = keyof typeof modelData + + for await (const field of uploadFields) { + base64UploadFields[field] = await fs.readFile( + modelData[field as ModelField] as string, + 'base64url', + ) + } + + // @TODO: edge cases + // 1. If readfile fails - file not found, etc. + // 2. If not a path, relative or absolute, throw error + + return { + ...modelData, + ...base64UploadFields, } }, }, } } - console.log('xxxx resultExtends', resultExtends) - return Prisma.defineExtension((client) => { return client.$extends({ name: 'redwood-upload-prisma-plugin', - query: queryExtends, + // query: queryExtends, result: resultExtends, }) }) From a7d06f4cbe8f7dbcc5f4b8f84bcfa9c56e79612c Mon Sep 17 00:00:00 2001 From: Danny Choudhury Date: Wed, 7 Aug 2024 15:33:49 +0700 Subject: [PATCH 19/91] Update comments --- packages/uploads/src/prismaExtension.ts | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/packages/uploads/src/prismaExtension.ts b/packages/uploads/src/prismaExtension.ts index 94c5ca103dcb..a3415890fe73 100644 --- a/packages/uploads/src/prismaExtension.ts +++ b/packages/uploads/src/prismaExtension.ts @@ -33,17 +33,12 @@ export type UploadsConfig = Record< type TUSServerConfig = { tusUploadDirectory: string } - -// type ExtendsType = runtime.ExtensionArgs -// type ExtendsType = Parameters[0] - export const createUploadsExtension = ( config: UploadsConfig, tusConfig?: TUSServerConfig, ) => { - // @MARK typing these with ExtendsType['query'] and ExtendsType['result'] - // will create an error when we instiate the PrismaClient 🤷 - // but without these types prisma won''t show types for the new methods + // @TODO I think we can use Prisma.getExtensionContext(this) + // instead of creating a new PrismaClient instance const prismaInstance = new PrismaClient() async function deleteUploadsFromDiskForArgs({ From 6974d456cb7b19e7c0cf5bfda3b0cf85a13ffcbd Mon Sep 17 00:00:00 2001 From: Danny Choudhury Date: Wed, 7 Aug 2024 15:35:44 +0700 Subject: [PATCH 20/91] Bump prisma client to match main --- packages/uploads/package.json | 2 +- yarn.lock | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/uploads/package.json b/packages/uploads/package.json index 0ff566444471..3a1065e9f0c0 100644 --- a/packages/uploads/package.json +++ b/packages/uploads/package.json @@ -44,7 +44,7 @@ }, "devDependencies": { "@arethetypeswrong/cli": "0.15.3", - "@prisma/client": "5.17.0", + "@prisma/client": "5.18.0", "@redwoodjs/framework-tools": "workspace:*", "@types/fs-extra": "11.0.4", "@types/mime-types": "2.1.4", diff --git a/yarn.lock b/yarn.lock index 324f1d04eee3..93d40020a6e7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8675,7 +8675,7 @@ __metadata: resolution: "@redwoodjs/uploads@workspace:packages/uploads" dependencies: "@arethetypeswrong/cli": "npm:0.15.3" - "@prisma/client": "npm:5.17.0" + "@prisma/client": "npm:5.18.0" "@redwoodjs/framework-tools": "workspace:*" "@redwoodjs/project-config": "workspace:*" "@types/fs-extra": "npm:11.0.4" From f5c696a8007212a10b628f6953c4d8589538a402 Mon Sep 17 00:00:00 2001 From: Danny Choudhury Date: Wed, 7 Aug 2024 16:09:33 +0700 Subject: [PATCH 21/91] Fix extension types without prisma generate --- packages/uploads/src/prismaExtension.ts | 42 +++++++++++++------------ 1 file changed, 22 insertions(+), 20 deletions(-) diff --git a/packages/uploads/src/prismaExtension.ts b/packages/uploads/src/prismaExtension.ts index a3415890fe73..fd9a4673da70 100644 --- a/packages/uploads/src/prismaExtension.ts +++ b/packages/uploads/src/prismaExtension.ts @@ -25,7 +25,7 @@ export type UploadConfigForModel = { onFileSaved?: (filePath: string) => void | Promise } -export type UploadsConfig = Record< +export type UploadsConfig = Record< MName, UploadConfigForModel > @@ -81,7 +81,7 @@ export const createUploadsExtension = ( for (const modelName in config) { // Guaranteed to have modelConfig, we're looping over config 🙄 - const modelConfig = config[modelName] as UploadConfigForModel + const modelConfig = config[modelName as MNames] as UploadConfigForModel const uploadFields = Array.isArray(modelConfig.fields) ? modelConfig.fields : [modelConfig.fields] @@ -134,24 +134,26 @@ export const createUploadsExtension = ( resultExtends[modelName] = { withDataUri: { needs, - async compute(modelData) { - const base64UploadFields: Record = {} - type ModelField = keyof typeof modelData - - for await (const field of uploadFields) { - base64UploadFields[field] = await fs.readFile( - modelData[field as ModelField] as string, - 'base64url', - ) - } - - // @TODO: edge cases - // 1. If readfile fails - file not found, etc. - // 2. If not a path, relative or absolute, throw error - - return { - ...modelData, - ...base64UploadFields, + compute(modelData) { + return async () => { + const base64UploadFields: Record = {} + type ModelField = keyof typeof modelData + + for await (const field of uploadFields) { + base64UploadFields[field] = await fs.readFile( + modelData[field as ModelField] as string, + 'base64url', + ) + } + + // @TODO: edge cases + // 1. If readfile fails - file not found, etc. + // 2. If not a path, relative or absolute, throw error + + return { + ...modelData, + ...base64UploadFields, + } } }, }, From fd28dd282b4af5f1e711936e0aa8529134eb5c3c Mon Sep 17 00:00:00 2001 From: Danny Choudhury Date: Wed, 7 Aug 2024 16:57:36 +0700 Subject: [PATCH 22/91] Return to generics --- packages/uploads/src/prismaExtension.ts | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/packages/uploads/src/prismaExtension.ts b/packages/uploads/src/prismaExtension.ts index fd9a4673da70..531377b48f5a 100644 --- a/packages/uploads/src/prismaExtension.ts +++ b/packages/uploads/src/prismaExtension.ts @@ -69,12 +69,11 @@ export const createUploadsExtension = ( [K in MNames]: { withDataUri: { needs: Record - compute: ( - // @MARK: this is a hack - // There has to be a better way to type this... because if you used a select or omit - // it would be a different type - modelData: ReturnType, - ) => () => ReturnType + compute: ( + // @MARK: this generic doesn't get picked up by prisma + // the returned type ends up being unknown + modelData: T, + ) => () => Promise } } } @@ -163,7 +162,7 @@ export const createUploadsExtension = ( return Prisma.defineExtension((client) => { return client.$extends({ name: 'redwood-upload-prisma-plugin', - // query: queryExtends, + query: queryExtends, result: resultExtends, }) }) From 1a428402dae872d93451fbd028a5ec92e6f570d7 Mon Sep 17 00:00:00 2001 From: Danny Choudhury Date: Wed, 7 Aug 2024 17:49:47 +0700 Subject: [PATCH 23/91] Remove prisma test from CI, fix withDataUri --- packages/uploads/package.json | 3 +- .../src/__tests__/getFileExtension.test.ts | 8 +- .../__tests__/prismaExtension.local.test.ts | 226 ++++++++++++++++++ .../src/__tests__/prismaExtension.test.ts | 205 ---------------- packages/uploads/src/fileSave.utils.ts | 135 +++++++++++ packages/uploads/src/prismaExtension.ts | 128 +--------- packages/uploads/vitest.config.mts | 1 - packages/uploads/vitest.setup.mts | 8 - 8 files changed, 373 insertions(+), 341 deletions(-) create mode 100644 packages/uploads/src/__tests__/prismaExtension.local.test.ts delete mode 100644 packages/uploads/src/__tests__/prismaExtension.test.ts create mode 100644 packages/uploads/src/fileSave.utils.ts delete mode 100644 packages/uploads/vitest.setup.mts diff --git a/packages/uploads/package.json b/packages/uploads/package.json index 3a1065e9f0c0..1bb59627bb80 100644 --- a/packages/uploads/package.json +++ b/packages/uploads/package.json @@ -34,7 +34,8 @@ "check:package": "concurrently npm:check:attw yarn publint", "setup:test": "npx prisma migrate reset -f --schema src/__tests__/unit-test-schema.prisma", "test": "vitest run", - "test:watch": "vitest watch" + "test:watch": "vitest watch", + "test:prismaExtension": "PRISMA_EXTENSION_TESTS=true vitest run" }, "dependencies": { "@redwoodjs/project-config": "workspace:*", diff --git a/packages/uploads/src/__tests__/getFileExtension.test.ts b/packages/uploads/src/__tests__/getFileExtension.test.ts index 85629d8b4429..5f72570bc33b 100644 --- a/packages/uploads/src/__tests__/getFileExtension.test.ts +++ b/packages/uploads/src/__tests__/getFileExtension.test.ts @@ -1,21 +1,21 @@ import { describe, it, expect } from 'vitest' -import { getFileExtension } from '../prismaExtension' +import { getFileExtensionFromDataUri } from '../fileSave.utils' describe('getFileExtension', () => { it('should return the correct file extension for a given data type', () => { const dataType = 'data:image/png;base64' - const extension = getFileExtension(dataType) + const extension = getFileExtensionFromDataUri(dataType) expect(extension).toBe('png') }) it('handles svgs', () => { const dataType = 'data:image/svg+xml;base64' - expect(getFileExtension(dataType)).toBe('svg') + expect(getFileExtensionFromDataUri(dataType)).toBe('svg') }) it('handles gif', () => { const dataType = 'data:image/gif;base64' - expect(getFileExtension(dataType)).toBe('gif') + expect(getFileExtensionFromDataUri(dataType)).toBe('gif') }) }) diff --git a/packages/uploads/src/__tests__/prismaExtension.local.test.ts b/packages/uploads/src/__tests__/prismaExtension.local.test.ts new file mode 100644 index 000000000000..ddca4bd28518 --- /dev/null +++ b/packages/uploads/src/__tests__/prismaExtension.local.test.ts @@ -0,0 +1,226 @@ +import fs from 'node:fs/promises' + +import { PrismaClient } from '@prisma/client' +import { vol } from 'memfs' +import { describe, it, vi, expect, beforeAll } from 'vitest' +import { $ } from 'zx' + +import { createUploadsExtension } from '../prismaExtension' + +import { dataUrlPng } from './fileMocks' + +/*** + * NOTE: this test does not run in CI, because it requires a local prisma db + * which causes build failures elsewhere. It's still useful to have locally when adding/changing features though + * + * To run it use the script `yarn test:prismaExtension` + */ + +const shouldRunPrismaExtensionTests = + process.env.PRISMA_EXTENSION_TESTS === 'true' + +vi.mock('node:fs/promises', () => ({ + default: { + writeFile: vi.fn(), + unlink: vi.fn(), + readFile: vi.fn((path, encoding) => { + if (encoding === 'base64url') { + return 'BASE64::THIS_IS_A_MOCKED_DATA_URL' + } + + return 'MOCKED_FILE_CONTENT' + }), + }, +})) + +vol.fromJSON({ + '/tmp/tus-uploads/123.json': '{}', + '/tmp/tus-uploads/ABCD.json': '{}', +}) + +describe.runIf(shouldRunPrismaExtensionTests)( + 'Uploads Prisma Extension', + () => { + beforeAll(async () => { + console.log('[setup] Setting up unit test prisma db....') + await $`yarn clean:prisma` + await $`npx prisma migrate reset -f --skip-seed --schema src/__tests__/unit-test-schema.prisma` + console.log('[setup] Done! \n') + }) + + const dummyUploadConfig = { + fields: 'uploadField', + savePath: '/bazinga', + onFileSaved: vi.fn(), + } + + const dumboUploadConfig = { + fields: ['firstUpload', 'secondUpload'], + savePath: '/dumbo', + onFileSaved: vi.fn(), + } + + const tusConfig = { + tusUploadDirectory: '/tmp/tus-uploads', + } + const prismaClient = new PrismaClient().$extends( + createUploadsExtension( + { + dummy: dummyUploadConfig, + dumbo: dumboUploadConfig, + }, + tusConfig, + ), + ) + + describe('Query extensions', () => { + it('will create a file with base64 encoded png', async () => { + const dum1 = await prismaClient.dummy.create({ + data: { + uploadField: dataUrlPng, + }, + }) + + expect(dummyUploadConfig.onFileSaved).toHaveBeenCalled() + expect(dum1.uploadField).toMatch(/bazinga\/.*\.png/) + expect(fs.writeFile).toHaveBeenCalledWith( + expect.stringMatching(/bazinga\/.*\.png/), + expect.anything(), // no need to check content here, makes test slow + ) + }) + + it('handles multiple upload fields', async () => { + const dumbo = await prismaClient.dumbo.create({ + data: { + firstUpload: dataUrlPng, + secondUpload: dataUrlPng, + }, + }) + + expect(dumbo.firstUpload).toMatch(/dumbo\/.*\.png/) + expect(dumbo.secondUpload).toMatch(/dumbo\/.*\.png/) + + expect(dumboUploadConfig.onFileSaved).toHaveBeenCalledTimes(2) + }) + + it('handles empty fields', async () => { + const emptyUploadFieldPromise = prismaClient.dummy.create({ + data: { + uploadField: '', + }, + }) + + await expect(emptyUploadFieldPromise).resolves.not.toThrow() + }) + + it('handles updates, and removes old files', async () => { + const dum1 = await prismaClient.dummy.create({ + data: { + uploadField: dataUrlPng, + }, + }) + + const originalPath = dum1.uploadField + + const dum2 = await prismaClient.dummy.update({ + where: { id: dum1.id }, + data: { + uploadField: dataUrlPng, + }, + }) + + expect(dum2.uploadField).not.toEqual(originalPath) + expect(dum2.uploadField).toMatch(/bazinga\/.*\.png/) + expect(fs.unlink).toHaveBeenCalledWith(originalPath) + }) + + it('handles deletes, and removes files', async () => { + const dum1 = await prismaClient.dummy.create({ + data: { + uploadField: dataUrlPng, + }, + }) + + await prismaClient.dummy.delete({ + where: { id: dum1.id }, + }) + + expect(fs.unlink).toHaveBeenCalledWith(dum1.uploadField) + }) + + it('supports custom file name and save path functions', async () => { + const customNameConfig = { + fields: 'firstUpload', + savePath: '/custom', + onFileSaved: vi.fn(), + fileName: (args) => { + // 👇 Using args here + return `my-name-is-dumbo-${args.data.id}` + }, + } + + const clientWithFileName = new PrismaClient().$extends( + createUploadsExtension({ + dumbo: customNameConfig, + }), + ) + + const dumbo = await clientWithFileName.dumbo.create({ + data: { + firstUpload: dataUrlPng, + secondUpload: '', + id: 55, + }, + }) + + expect(customNameConfig.onFileSaved).toHaveBeenCalled() + expect(dumbo.firstUpload).toBe('/custom/my-name-is-dumbo-55.png') + + // Delete it to clean up + await clientWithFileName.dumbo.delete({ + where: { + id: 55, + }, + }) + }) + + // @TODO have to figure out how to mock the + // it('will move file to new location with TUS uploads', async () => { + // const dumbo = await prismaClient.dumbo.create({ + // data: { + // firstUpload: + // 'http://example.com/.redwood/functions/tusUploadEndpoint/123', + // secondUpload: + // 'http://example.com/.redwood/functions/tusUploadEndpoint/ABCD', + // }, + // }) + + // expect(dumbo.firstUpload).toBe('balknsdg') + // expect(dumbo.secondUpload).toBe('balknsdg') + // }) + + // it('will remove old file when updating with TUS uploads', async () => {}) + + // it('will remove file when deleting with TUS uploads', async () => {}) + }) + + describe('Result extensions', () => { + it('will return a data URL for the file', async () => { + const res1 = await ( + await prismaClient.dummy.create({ + data: { + uploadField: dataUrlPng, + }, + }) + ).withDataUri() + + // Mocked in FS mocks + expect(res1.uploadField).toBe('BASE64::THIS_IS_A_MOCKED_DATA_URL') + }) + + // @TODO implement + // it('if file is not found, will throw an error', async () => {}) + // it('if saved file is not a path, will throw an error', async () => {}) + }) + }, +) diff --git a/packages/uploads/src/__tests__/prismaExtension.test.ts b/packages/uploads/src/__tests__/prismaExtension.test.ts deleted file mode 100644 index 09323211781c..000000000000 --- a/packages/uploads/src/__tests__/prismaExtension.test.ts +++ /dev/null @@ -1,205 +0,0 @@ -import fs from 'node:fs/promises' - -import { PrismaClient } from '@prisma/client' -import { vol } from 'memfs' -import { describe, it, vi, expect } from 'vitest' - -import { createUploadsExtension } from '../prismaExtension' - -import { dataUrlPng } from './fileMocks' - -vi.mock('node:fs/promises', () => ({ - default: { - writeFile: vi.fn(), - unlink: vi.fn(), - readFile: vi.fn((path, encoding) => { - if (encoding === 'base64url') { - return 'BASE64::THIS_IS_A_MOCKED_DATA_URL' - } - - return 'MOCKED_FILE_CONTENT' - }), - }, -})) - -vol.fromJSON({ - '/tmp/tus-uploads/123.json': '{}', - '/tmp/tus-uploads/ABCD.json': '{}', -}) - -describe('Uploads Prisma Extension', () => { - const dummyUploadConfig = { - fields: 'uploadField', - savePath: '/bazinga', - onFileSaved: vi.fn(), - } - - const dumboUploadConfig = { - fields: ['firstUpload', 'secondUpload'], - savePath: '/dumbo', - onFileSaved: vi.fn(), - } - - const tusConfig = { - tusUploadDirectory: '/tmp/tus-uploads', - } - const prismaClient = new PrismaClient().$extends( - createUploadsExtension( - { - dummy: dummyUploadConfig, - dumbo: dumboUploadConfig, - }, - tusConfig, - ), - ) - - describe('Query extensions', () => { - it('will create a file with base64 encoded png', async () => { - const dum1 = await prismaClient.dummy.create({ - data: { - uploadField: dataUrlPng, - }, - }) - - expect(dummyUploadConfig.onFileSaved).toHaveBeenCalled() - expect(dum1.uploadField).toMatch(/bazinga\/.*\.png/) - expect(fs.writeFile).toHaveBeenCalledWith( - expect.stringMatching(/bazinga\/.*\.png/), - expect.anything(), // no need to check content here, makes test slow - ) - }) - - it('handles multiple upload fields', async () => { - const dumbo = await prismaClient.dumbo.create({ - data: { - firstUpload: dataUrlPng, - secondUpload: dataUrlPng, - }, - }) - - expect(dumbo.firstUpload).toMatch(/dumbo\/.*\.png/) - expect(dumbo.secondUpload).toMatch(/dumbo\/.*\.png/) - - expect(dumboUploadConfig.onFileSaved).toHaveBeenCalledTimes(2) - }) - - it('handles empty fields', async () => { - const emptyUploadFieldPromise = prismaClient.dummy.create({ - data: { - uploadField: '', - }, - }) - - await expect(emptyUploadFieldPromise).resolves.not.toThrow() - }) - - it('handles updates, and removes old files', async () => { - const dum1 = await prismaClient.dummy.create({ - data: { - uploadField: dataUrlPng, - }, - }) - - const originalPath = dum1.uploadField - - const dum2 = await prismaClient.dummy.update({ - where: { id: dum1.id }, - data: { - uploadField: dataUrlPng, - }, - }) - - expect(dum2.uploadField).not.toEqual(originalPath) - expect(dum2.uploadField).toMatch(/bazinga\/.*\.png/) - expect(fs.unlink).toHaveBeenCalledWith(originalPath) - }) - - it('handles deletes, and removes files', async () => { - const dum1 = await prismaClient.dummy.create({ - data: { - uploadField: dataUrlPng, - }, - }) - - await prismaClient.dummy.delete({ - where: { id: dum1.id }, - }) - - expect(fs.unlink).toHaveBeenCalledWith(dum1.uploadField) - }) - - it('supports custom file name and save path functions', async () => { - const customNameConfig = { - fields: 'firstUpload', - savePath: '/custom', - onFileSaved: vi.fn(), - fileName: (args) => { - // 👇 Using args here - return `my-name-is-dumbo-${args.data.id}` - }, - } - - const clientWithFileName = new PrismaClient().$extends( - createUploadsExtension({ - dumbo: customNameConfig, - }), - ) - - const dumbo = await clientWithFileName.dumbo.create({ - data: { - firstUpload: dataUrlPng, - secondUpload: '', - id: 55, - }, - }) - - expect(customNameConfig.onFileSaved).toHaveBeenCalled() - expect(dumbo.firstUpload).toBe('/custom/my-name-is-dumbo-55.png') - - // Delete it to clean up - await clientWithFileName.dumbo.delete({ - where: { - id: 55, - }, - }) - }) - - // @TODO have to figure out how to mock the - // it('will move file to new location with TUS uploads', async () => { - // const dumbo = await prismaClient.dumbo.create({ - // data: { - // firstUpload: - // 'http://example.com/.redwood/functions/tusUploadEndpoint/123', - // secondUpload: - // 'http://example.com/.redwood/functions/tusUploadEndpoint/ABCD', - // }, - // }) - - // expect(dumbo.firstUpload).toBe('balknsdg') - // expect(dumbo.secondUpload).toBe('balknsdg') - // }) - - // it('will remove old file when updating with TUS uploads', async () => {}) - - // it('will remove file when deleting with TUS uploads', async () => {}) - }) - - describe('Result extensions', () => { - it('will return a data URL for the file', async () => { - const res1 = await ( - await prismaClient.dummy.create({ - data: { - uploadField: dataUrlPng, - }, - }) - ).withDataUri() - - // Mocked in FS mocks - expect(res1.uploadField).toBe('BASE64::THIS_IS_A_MOCKED_DATA_URL') - }) - - // @TODO implement - // it('if file is not found, will throw an error', async () => {}) - // it('if saved file is not a path, will throw an error', async () => {}) - }) -}) diff --git a/packages/uploads/src/fileSave.utils.ts b/packages/uploads/src/fileSave.utils.ts new file mode 100644 index 000000000000..6d6eaa94b0ae --- /dev/null +++ b/packages/uploads/src/fileSave.utils.ts @@ -0,0 +1,135 @@ +import fs from 'node:fs/promises' +import path from 'node:path' + +import mime from 'mime-types' + +import { getPaths } from '@redwoodjs/project-config' + +export type TUSServerConfig = { + tusUploadDirectory: string +} + +/** + * This function takes an upload field, determines whether its TUS or Base64, and saves it to the file system. + */ +export async function saveUploadToFile( + uploadUrlOrDataUrl: string, + { fileName, saveDir }: { saveDir: string; fileName: string }, + tusConfig?: TUSServerConfig, +) { + let outputPath: string | null = null + + if (isBase64DataUri(uploadUrlOrDataUrl)) { + outputPath = await saveBase64DataUriToFile(uploadUrlOrDataUrl, { + saveDir, + fileName, + }) + } else if (uploadUrlOrDataUrl.startsWith('http')) { + if (!tusConfig) { + throw new Error('TusConfig not supplied.') + } + + outputPath = await saveTusUpload(uploadUrlOrDataUrl, { + tusConfig, + saveDir, + fileName, + }) + } // @TODO: add support for form uploads? + + if (!outputPath) { + throw new Error('Unsupported upload URL') + } + + // @MARK: we can create a new record on the uploads table here + + return outputPath +} + +// @MARK: if we block the TUS GET, we don't really need to move it +// We send the TUS upload URL as the value of the field +export async function saveTusUpload( + uploadUrl: string, + { + tusConfig, + saveDir, + fileName, + }: { + tusConfig: TUSServerConfig + saveDir: string + fileName: string + }, +) { + // Get the last part of the TUS upload url + // http://localhost:8910/.redwood/functions/uploadTUS/👉28fa96bf5772338d51👈 + const tusId = uploadUrl.split('/').slice(-1).pop() + + if (!tusId) { + throw new Error('Could not extract upload ID from URL') + } + + if (!tusConfig.tusUploadDirectory) { + throw new Error( + 'You have to configure the TUS Upload Directory in the prisma extension. It is required for TUS uploads', + ) + } + + // Optional Step.... + const metaFile = path.join( + path.isAbsolute(tusConfig.tusUploadDirectory) + ? tusConfig.tusUploadDirectory + : // @MARK: if the directory supplied isn't relative + path.join(getPaths().base, tusConfig.tusUploadDirectory), + `${tusId}.json`, + ) + // Can't await import, because JSON file. + const tusMeta = require(metaFile) + + const fileExtension = tusMeta.metadata.filetype.split('/')[1] + + const savedFilePath = path.join(saveDir, `${fileName}.${fileExtension}`) + + // @MARK: we can also move... + await fs.copyFile( + path.join(tusConfig.tusUploadDirectory, tusId), + savedFilePath, + ) + + return savedFilePath +} + +function isBase64DataUri(uploadUrlOrDataUrl: string) { + // Check if the uploadUrlOrDataUrl is a valid base64 string + const base64Regex = /^data:(.*?);base64,/ + return base64Regex.test(uploadUrlOrDataUrl) +} + +async function saveBase64DataUriToFile( + dataUrlString: string, + { saveDir, fileName }: { saveDir: string; fileName: string }, +) { + const [dataType, fileContent] = dataUrlString.split(',') + // format is data:image/png;base64,.... + const fileExtension = getFileExtensionFromDataUri(dataType) + const filePath = path.join(saveDir, `${fileName}.${fileExtension}`) + + await fs.writeFile(filePath, Buffer.from(fileContent, 'base64')) + + return filePath +} + +export function getFileExtensionFromDataUri(dataType: string): string { + const mimeType = dataType.split(':')[1].split(';')[0] + const extension = mime.extension(mimeType) + if (!extension) { + throw new Error(`Unsupported file type: ${mimeType}`) + } + return extension +} + +export async function fileToDataUri(filePath: string) { + const base64Data = await fs.readFile(filePath, 'base64') + const ext = path.extname(filePath) + const mimeType = mime.lookup(ext) + + return `data:${mimeType};base64,${base64Data}` +} diff --git a/packages/uploads/src/prismaExtension.ts b/packages/uploads/src/prismaExtension.ts index 531377b48f5a..1a584b3f1ab4 100644 --- a/packages/uploads/src/prismaExtension.ts +++ b/packages/uploads/src/prismaExtension.ts @@ -1,13 +1,15 @@ import fs from 'node:fs/promises' -import path from 'node:path' import { PrismaClient } from '@prisma/client' import { Prisma } from '@prisma/client/extension' import type * as runtime from '@prisma/client/runtime/library' -import mime from 'mime-types' import { ulid } from 'ulid' -import { getPaths } from '@redwoodjs/project-config' +import { + fileToDataUri, + saveUploadToFile, + type TUSServerConfig, +} from './fileSave.utils.js' type FilterOutDollarPrefixed = T extends `$${string}` ? never @@ -30,9 +32,6 @@ export type UploadsConfig = Record< UploadConfigForModel > -type TUSServerConfig = { - tusUploadDirectory: string -} export const createUploadsExtension = ( config: UploadsConfig, tusConfig?: TUSServerConfig, @@ -139,9 +138,8 @@ export const createUploadsExtension = ( type ModelField = keyof typeof modelData for await (const field of uploadFields) { - base64UploadFields[field] = await fs.readFile( + base64UploadFields[field] = await fileToDataUri( modelData[field as ModelField] as string, - 'base64url', ) } @@ -237,117 +235,3 @@ async function saveUploads( data: newData, } } - -async function saveUploadToFile( - uploadUrlOrDataUrl: string, - { fileName, saveDir }: { saveDir: string; fileName: string }, - tusConfig?: TUSServerConfig, -) { - let outputPath: string | null = null - - if (isBase65(uploadUrlOrDataUrl)) { - outputPath = await saveBase65File(uploadUrlOrDataUrl, { - saveDir, - fileName, - }) - } else if (uploadUrlOrDataUrl.startsWith('http')) { - if (!tusConfig) { - throw new Error('TusConfig not supplied.') - } - - outputPath = await saveTusUpload(uploadUrlOrDataUrl, { - tusConfig, - saveDir, - fileName, - }) - } // @TODO: add support for form uploads? - - if (!outputPath) { - throw new Error('Unsupported upload URL') - } - - // @MARK: we can create a new record on the uploads table here - - return outputPath -} - -// @MARK: if we block the TUS GET, we don't really need to move it -// We send the TUS upload URL as the value of the field -async function saveTusUpload( - uploadUrl: string, - { - tusConfig, - saveDir, - fileName, - }: { - tusConfig: TUSServerConfig - saveDir: string - fileName: string - }, -) { - // Get the last part of the TUS upload url - // http://localhost:8910/.redwood/functions/uploadTUS/👉28fa96bf5772338d51👈 - const tusId = uploadUrl.split('/').slice(-1).pop() - - if (!tusId) { - throw new Error('Could not extract upload ID from URL') - } - - if (!tusConfig.tusUploadDirectory) { - throw new Error( - 'You have to configure the TUS Upload Directory in the prisma extension. It is required for TUS uploads', - ) - } - - // Optional Step.... - const metaFile = path.join( - path.isAbsolute(tusConfig.tusUploadDirectory) - ? tusConfig.tusUploadDirectory - : // @MARK: if the directory supplied isn't relative - path.join(getPaths().base, tusConfig.tusUploadDirectory), - `${tusId}.json`, - ) - // Can't await import, because JSON file. - const tusMeta = require(metaFile) - - const fileExtension = tusMeta.metadata.filetype.split('/')[1] - - const savedFilePath = path.join(saveDir, `${fileName}.${fileExtension}`) - - // @MARK: we can also move... - await fs.copyFile( - path.join(tusConfig.tusUploadDirectory, tusId), - savedFilePath, - ) - - return savedFilePath -} - -function isBase65(uploadUrlOrDataUrl: string) { - // Check if the uploadUrlOrDataUrl is a valid base64 string - const base64Regex = /^data:(.*?);base64,/ - return base64Regex.test(uploadUrlOrDataUrl) -} - -async function saveBase65File( - dataUrlString: string, - { saveDir, fileName }: { saveDir: string; fileName: string }, -) { - const [dataType, fileContent] = dataUrlString.split(',') - // format is data:image/png;base64,.... - const fileExtension = getFileExtension(dataType) - const filePath = path.join(saveDir, `${fileName}.${fileExtension}`) - - await fs.writeFile(filePath, Buffer.from(fileContent, 'base64')) - - return filePath -} - -export function getFileExtension(dataType: string): string { - const mimeType = dataType.split(':')[1].split(';')[0] - const extension = mime.extension(mimeType) - if (!extension) { - throw new Error(`Unsupported file type: ${mimeType}`) - } - return extension -} diff --git a/packages/uploads/vitest.config.mts b/packages/uploads/vitest.config.mts index cb0188bdb924..984991bff461 100644 --- a/packages/uploads/vitest.config.mts +++ b/packages/uploads/vitest.config.mts @@ -6,7 +6,6 @@ export default defineConfig({ deps: { interopDefault: false, }, - globalSetup: ['vitest.setup.mts'], }, }) diff --git a/packages/uploads/vitest.setup.mts b/packages/uploads/vitest.setup.mts deleted file mode 100644 index 01987b744896..000000000000 --- a/packages/uploads/vitest.setup.mts +++ /dev/null @@ -1,8 +0,0 @@ -import { $ } from 'zx' - -export default async function setup() { - console.log('[setup] Setting up unit test prisma db....') - await $`yarn clean:prisma` - await $`npx prisma migrate reset -f --skip-seed --schema src/__tests__/unit-test-schema.prisma` - console.log('[setup] Done! \n') -} From 6dcf81fe6966de5e0779384c91ae3fd699852b13 Mon Sep 17 00:00:00 2001 From: Danny Choudhury Date: Wed, 7 Aug 2024 18:57:46 +0700 Subject: [PATCH 24/91] Cleanup tests --- .../uploads/src/__tests__/prismaExtension.local.test.ts | 9 +++++---- packages/uploads/src/fileSave.utils.ts | 2 +- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/packages/uploads/src/__tests__/prismaExtension.local.test.ts b/packages/uploads/src/__tests__/prismaExtension.local.test.ts index ddca4bd28518..ce6cc4dc9287 100644 --- a/packages/uploads/src/__tests__/prismaExtension.local.test.ts +++ b/packages/uploads/src/__tests__/prismaExtension.local.test.ts @@ -24,8 +24,8 @@ vi.mock('node:fs/promises', () => ({ writeFile: vi.fn(), unlink: vi.fn(), readFile: vi.fn((path, encoding) => { - if (encoding === 'base64url') { - return 'BASE64::THIS_IS_A_MOCKED_DATA_URL' + if (encoding === 'base64') { + return 'BASE64_FILE_CONTENT' } return 'MOCKED_FILE_CONTENT' @@ -43,7 +43,6 @@ describe.runIf(shouldRunPrismaExtensionTests)( () => { beforeAll(async () => { console.log('[setup] Setting up unit test prisma db....') - await $`yarn clean:prisma` await $`npx prisma migrate reset -f --skip-seed --schema src/__tests__/unit-test-schema.prisma` console.log('[setup] Done! \n') }) @@ -215,7 +214,9 @@ describe.runIf(shouldRunPrismaExtensionTests)( ).withDataUri() // Mocked in FS mocks - expect(res1.uploadField).toBe('BASE64::THIS_IS_A_MOCKED_DATA_URL') + expect(res1.uploadField).toBe( + 'data:image/png;base64,BASE64_FILE_CONTENT', + ) }) // @TODO implement diff --git a/packages/uploads/src/fileSave.utils.ts b/packages/uploads/src/fileSave.utils.ts index 6d6eaa94b0ae..e7d23bd7f548 100644 --- a/packages/uploads/src/fileSave.utils.ts +++ b/packages/uploads/src/fileSave.utils.ts @@ -34,7 +34,7 @@ export async function saveUploadToFile( saveDir, fileName, }) - } // @TODO: add support for form uploads? + } if (!outputPath) { throw new Error('Unsupported upload URL') From 60828f3985622e4df5283a14ac23ec1ffd236295 Mon Sep 17 00:00:00 2001 From: Danny Choudhury Date: Wed, 7 Aug 2024 20:38:36 +0700 Subject: [PATCH 25/91] Try generating a local prisma client for tests --- packages/uploads/.gitignore | 1 + .../src/__tests__/fileSave.units.test.ts | 60 +++++ .../src/__tests__/getFileExtension.test.ts | 21 -- .../__tests__/prismaExtension.local.test.ts | 227 ------------------ .../src/__tests__/prismaExtension.test.ts | 219 +++++++++++++++++ .../src/__tests__/unit-test-schema.prisma | 1 + packages/uploads/src/fileSave.utils.ts | 4 +- packages/uploads/src/prismaExtension.ts | 2 +- packages/uploads/vitest.config.mts | 10 + packages/uploads/vitest.setup.mts | 8 + 10 files changed, 303 insertions(+), 250 deletions(-) create mode 100644 packages/uploads/src/__tests__/fileSave.units.test.ts delete mode 100644 packages/uploads/src/__tests__/getFileExtension.test.ts delete mode 100644 packages/uploads/src/__tests__/prismaExtension.local.test.ts create mode 100644 packages/uploads/src/__tests__/prismaExtension.test.ts create mode 100644 packages/uploads/vitest.setup.mts diff --git a/packages/uploads/.gitignore b/packages/uploads/.gitignore index e5c46c18f78e..9b5d07c455ec 100644 --- a/packages/uploads/.gitignore +++ b/packages/uploads/.gitignore @@ -1,3 +1,4 @@ src/__tests__/migrations/* src/__tests__/for_unit_test.db* .attw.json +src/__tests__/prisma-client/* diff --git a/packages/uploads/src/__tests__/fileSave.units.test.ts b/packages/uploads/src/__tests__/fileSave.units.test.ts new file mode 100644 index 000000000000..5907ea216522 --- /dev/null +++ b/packages/uploads/src/__tests__/fileSave.units.test.ts @@ -0,0 +1,60 @@ +import { describe, it, expect, vi } from 'vitest' + +import { + getFileExtensionFromDataUri, + saveUploadToFile, +} from '../fileSave.utils' + +describe('getFileExtension', () => { + it('should return the correct file extension for a given data type', () => { + const dataType = 'data:image/png;base64' + const extension = getFileExtensionFromDataUri(dataType) + expect(extension).toBe('png') + }) + + it('handles svgs', () => { + const dataType = 'data:image/svg+xml;base64' + expect(getFileExtensionFromDataUri(dataType)).toBe('svg') + }) + + it('handles gif', () => { + const dataType = 'data:image/gif;base64' + expect(getFileExtensionFromDataUri(dataType)).toBe('gif') + }) +}) + +describe('saveUploadToFile', () => { + vi.mock('node:fs/promises', () => ({ + default: { + writeFile: vi.fn(), + unlink: vi.fn(), + // readFile: vi.fn((path, encoding) => { + // if (encoding === 'base64') { + // return 'BASE64_FILE_CONTENT' + // } + + // return 'MOCKED_FILE_CONTENT' + // }), + }, + })) + + // it('Should call saveBase64DataUriToFile if the uploadUrlOrDataUrl is a base64 data uri', async () => { + // saveUploadToFile('data:image/png;base64,....', { + // saveDir: 'uploads', + // fileName: 'test', + // }) + // }) + + // it('Should call saveTusFileToFile if the uploadUrlOrDataUrl is a tus url', async () => {}) + + it('Should throw an error if the uploadUrlOrDataUrl is not a base64 data uri or a tus url', async () => { + try { + await saveUploadToFile('/random/path/to/whatever.png', { + saveDir: 'uploads', + fileName: 'test', + }) + } catch (e) { + expect(e.message).toBe('Unsupported upload format') + } + }) +}) diff --git a/packages/uploads/src/__tests__/getFileExtension.test.ts b/packages/uploads/src/__tests__/getFileExtension.test.ts deleted file mode 100644 index 5f72570bc33b..000000000000 --- a/packages/uploads/src/__tests__/getFileExtension.test.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { describe, it, expect } from 'vitest' - -import { getFileExtensionFromDataUri } from '../fileSave.utils' - -describe('getFileExtension', () => { - it('should return the correct file extension for a given data type', () => { - const dataType = 'data:image/png;base64' - const extension = getFileExtensionFromDataUri(dataType) - expect(extension).toBe('png') - }) - - it('handles svgs', () => { - const dataType = 'data:image/svg+xml;base64' - expect(getFileExtensionFromDataUri(dataType)).toBe('svg') - }) - - it('handles gif', () => { - const dataType = 'data:image/gif;base64' - expect(getFileExtensionFromDataUri(dataType)).toBe('gif') - }) -}) diff --git a/packages/uploads/src/__tests__/prismaExtension.local.test.ts b/packages/uploads/src/__tests__/prismaExtension.local.test.ts deleted file mode 100644 index ce6cc4dc9287..000000000000 --- a/packages/uploads/src/__tests__/prismaExtension.local.test.ts +++ /dev/null @@ -1,227 +0,0 @@ -import fs from 'node:fs/promises' - -import { PrismaClient } from '@prisma/client' -import { vol } from 'memfs' -import { describe, it, vi, expect, beforeAll } from 'vitest' -import { $ } from 'zx' - -import { createUploadsExtension } from '../prismaExtension' - -import { dataUrlPng } from './fileMocks' - -/*** - * NOTE: this test does not run in CI, because it requires a local prisma db - * which causes build failures elsewhere. It's still useful to have locally when adding/changing features though - * - * To run it use the script `yarn test:prismaExtension` - */ - -const shouldRunPrismaExtensionTests = - process.env.PRISMA_EXTENSION_TESTS === 'true' - -vi.mock('node:fs/promises', () => ({ - default: { - writeFile: vi.fn(), - unlink: vi.fn(), - readFile: vi.fn((path, encoding) => { - if (encoding === 'base64') { - return 'BASE64_FILE_CONTENT' - } - - return 'MOCKED_FILE_CONTENT' - }), - }, -})) - -vol.fromJSON({ - '/tmp/tus-uploads/123.json': '{}', - '/tmp/tus-uploads/ABCD.json': '{}', -}) - -describe.runIf(shouldRunPrismaExtensionTests)( - 'Uploads Prisma Extension', - () => { - beforeAll(async () => { - console.log('[setup] Setting up unit test prisma db....') - await $`npx prisma migrate reset -f --skip-seed --schema src/__tests__/unit-test-schema.prisma` - console.log('[setup] Done! \n') - }) - - const dummyUploadConfig = { - fields: 'uploadField', - savePath: '/bazinga', - onFileSaved: vi.fn(), - } - - const dumboUploadConfig = { - fields: ['firstUpload', 'secondUpload'], - savePath: '/dumbo', - onFileSaved: vi.fn(), - } - - const tusConfig = { - tusUploadDirectory: '/tmp/tus-uploads', - } - const prismaClient = new PrismaClient().$extends( - createUploadsExtension( - { - dummy: dummyUploadConfig, - dumbo: dumboUploadConfig, - }, - tusConfig, - ), - ) - - describe('Query extensions', () => { - it('will create a file with base64 encoded png', async () => { - const dum1 = await prismaClient.dummy.create({ - data: { - uploadField: dataUrlPng, - }, - }) - - expect(dummyUploadConfig.onFileSaved).toHaveBeenCalled() - expect(dum1.uploadField).toMatch(/bazinga\/.*\.png/) - expect(fs.writeFile).toHaveBeenCalledWith( - expect.stringMatching(/bazinga\/.*\.png/), - expect.anything(), // no need to check content here, makes test slow - ) - }) - - it('handles multiple upload fields', async () => { - const dumbo = await prismaClient.dumbo.create({ - data: { - firstUpload: dataUrlPng, - secondUpload: dataUrlPng, - }, - }) - - expect(dumbo.firstUpload).toMatch(/dumbo\/.*\.png/) - expect(dumbo.secondUpload).toMatch(/dumbo\/.*\.png/) - - expect(dumboUploadConfig.onFileSaved).toHaveBeenCalledTimes(2) - }) - - it('handles empty fields', async () => { - const emptyUploadFieldPromise = prismaClient.dummy.create({ - data: { - uploadField: '', - }, - }) - - await expect(emptyUploadFieldPromise).resolves.not.toThrow() - }) - - it('handles updates, and removes old files', async () => { - const dum1 = await prismaClient.dummy.create({ - data: { - uploadField: dataUrlPng, - }, - }) - - const originalPath = dum1.uploadField - - const dum2 = await prismaClient.dummy.update({ - where: { id: dum1.id }, - data: { - uploadField: dataUrlPng, - }, - }) - - expect(dum2.uploadField).not.toEqual(originalPath) - expect(dum2.uploadField).toMatch(/bazinga\/.*\.png/) - expect(fs.unlink).toHaveBeenCalledWith(originalPath) - }) - - it('handles deletes, and removes files', async () => { - const dum1 = await prismaClient.dummy.create({ - data: { - uploadField: dataUrlPng, - }, - }) - - await prismaClient.dummy.delete({ - where: { id: dum1.id }, - }) - - expect(fs.unlink).toHaveBeenCalledWith(dum1.uploadField) - }) - - it('supports custom file name and save path functions', async () => { - const customNameConfig = { - fields: 'firstUpload', - savePath: '/custom', - onFileSaved: vi.fn(), - fileName: (args) => { - // 👇 Using args here - return `my-name-is-dumbo-${args.data.id}` - }, - } - - const clientWithFileName = new PrismaClient().$extends( - createUploadsExtension({ - dumbo: customNameConfig, - }), - ) - - const dumbo = await clientWithFileName.dumbo.create({ - data: { - firstUpload: dataUrlPng, - secondUpload: '', - id: 55, - }, - }) - - expect(customNameConfig.onFileSaved).toHaveBeenCalled() - expect(dumbo.firstUpload).toBe('/custom/my-name-is-dumbo-55.png') - - // Delete it to clean up - await clientWithFileName.dumbo.delete({ - where: { - id: 55, - }, - }) - }) - - // @TODO have to figure out how to mock the - // it('will move file to new location with TUS uploads', async () => { - // const dumbo = await prismaClient.dumbo.create({ - // data: { - // firstUpload: - // 'http://example.com/.redwood/functions/tusUploadEndpoint/123', - // secondUpload: - // 'http://example.com/.redwood/functions/tusUploadEndpoint/ABCD', - // }, - // }) - - // expect(dumbo.firstUpload).toBe('balknsdg') - // expect(dumbo.secondUpload).toBe('balknsdg') - // }) - - // it('will remove old file when updating with TUS uploads', async () => {}) - - // it('will remove file when deleting with TUS uploads', async () => {}) - }) - - describe('Result extensions', () => { - it('will return a data URL for the file', async () => { - const res1 = await ( - await prismaClient.dummy.create({ - data: { - uploadField: dataUrlPng, - }, - }) - ).withDataUri() - - // Mocked in FS mocks - expect(res1.uploadField).toBe( - 'data:image/png;base64,BASE64_FILE_CONTENT', - ) - }) - - // @TODO implement - // it('if file is not found, will throw an error', async () => {}) - // it('if saved file is not a path, will throw an error', async () => {}) - }) - }, -) diff --git a/packages/uploads/src/__tests__/prismaExtension.test.ts b/packages/uploads/src/__tests__/prismaExtension.test.ts new file mode 100644 index 000000000000..665f660e2189 --- /dev/null +++ b/packages/uploads/src/__tests__/prismaExtension.test.ts @@ -0,0 +1,219 @@ +import fs from 'node:fs/promises' + +import { PrismaClient } from '@prisma/client' +import { vol } from 'memfs' +import { describe, it, vi, expect, beforeAll } from 'vitest' +import { $ } from 'zx' + +import { createUploadsExtension } from '../prismaExtension' + +import { dataUrlPng } from './fileMocks' + +/*** + * NOTE: this test does not run in CI, because it requires a local prisma db + * which causes build failures elsewhere. It's still useful to have locally when adding/changing features though + * + * To run it use the script `yarn test:prismaExtension` + */ + +vi.mock('node:fs/promises', () => ({ + default: { + writeFile: vi.fn(), + unlink: vi.fn(), + readFile: vi.fn((path, encoding) => { + if (encoding === 'base64') { + return 'BASE64_FILE_CONTENT' + } + + return 'MOCKED_FILE_CONTENT' + }), + }, +})) + +vol.fromJSON({ + '/tmp/tus-uploads/123.json': '{}', + '/tmp/tus-uploads/ABCD.json': '{}', +}) + +describe('Uploads Prisma Extension', () => { + beforeAll(async () => { + console.log('[setup] Setting up unit test prisma db....') + await $`npx prisma migrate reset -f --skip-seed --schema src/__tests__/unit-test-schema.prisma` + console.log('[setup] Done! \n') + }) + + const dummyUploadConfig = { + fields: 'uploadField', + savePath: '/bazinga', + onFileSaved: vi.fn(), + } + + const dumboUploadConfig = { + fields: ['firstUpload', 'secondUpload'], + savePath: '/dumbo', + onFileSaved: vi.fn(), + } + + const tusConfig = { + tusUploadDirectory: '/tmp/tus-uploads', + } + const prismaClient = new PrismaClient().$extends( + createUploadsExtension( + { + dummy: dummyUploadConfig, + dumbo: dumboUploadConfig, + }, + tusConfig, + ), + ) + + describe('Query extensions', () => { + it('will create a file with base64 encoded png', async () => { + const dum1 = await prismaClient.dummy.create({ + data: { + uploadField: dataUrlPng, + }, + }) + + expect(dummyUploadConfig.onFileSaved).toHaveBeenCalled() + expect(dum1.uploadField).toMatch(/bazinga\/.*\.png/) + expect(fs.writeFile).toHaveBeenCalledWith( + expect.stringMatching(/bazinga\/.*\.png/), + expect.anything(), // no need to check content here, makes test slow + ) + }) + + it('handles multiple upload fields', async () => { + const dumbo = await prismaClient.dumbo.create({ + data: { + firstUpload: dataUrlPng, + secondUpload: dataUrlPng, + }, + }) + + expect(dumbo.firstUpload).toMatch(/dumbo\/.*\.png/) + expect(dumbo.secondUpload).toMatch(/dumbo\/.*\.png/) + + expect(dumboUploadConfig.onFileSaved).toHaveBeenCalledTimes(2) + }) + + it('handles empty fields', async () => { + const emptyUploadFieldPromise = prismaClient.dummy.create({ + data: { + uploadField: '', + }, + }) + + await expect(emptyUploadFieldPromise).resolves.not.toThrow() + }) + + it('handles updates, and removes old files', async () => { + const dum1 = await prismaClient.dummy.create({ + data: { + uploadField: dataUrlPng, + }, + }) + + const originalPath = dum1.uploadField + + const dum2 = await prismaClient.dummy.update({ + where: { id: dum1.id }, + data: { + uploadField: dataUrlPng, + }, + }) + + expect(dum2.uploadField).not.toEqual(originalPath) + expect(dum2.uploadField).toMatch(/bazinga\/.*\.png/) + expect(fs.unlink).toHaveBeenCalledWith(originalPath) + }) + + it('handles deletes, and removes files', async () => { + const dum1 = await prismaClient.dummy.create({ + data: { + uploadField: dataUrlPng, + }, + }) + + await prismaClient.dummy.delete({ + where: { id: dum1.id }, + }) + + expect(fs.unlink).toHaveBeenCalledWith(dum1.uploadField) + }) + + it('supports custom file name and save path functions', async () => { + const customNameConfig = { + fields: 'firstUpload', + savePath: '/custom', + onFileSaved: vi.fn(), + fileName: (args) => { + // 👇 Using args here + return `my-name-is-dumbo-${args.data.id}` + }, + } + + const clientWithFileName = new PrismaClient().$extends( + createUploadsExtension({ + dumbo: customNameConfig, + }), + ) + + const dumbo = await clientWithFileName.dumbo.create({ + data: { + firstUpload: dataUrlPng, + secondUpload: '', + id: 55, + }, + }) + + expect(customNameConfig.onFileSaved).toHaveBeenCalled() + expect(dumbo.firstUpload).toBe('/custom/my-name-is-dumbo-55.png') + + // Delete it to clean up + await clientWithFileName.dumbo.delete({ + where: { + id: 55, + }, + }) + }) + + // @TODO have to figure out how to mock the + // it('will move file to new location with TUS uploads', async () => { + // const dumbo = await prismaClient.dumbo.create({ + // data: { + // firstUpload: + // 'http://example.com/.redwood/functions/tusUploadEndpoint/123', + // secondUpload: + // 'http://example.com/.redwood/functions/tusUploadEndpoint/ABCD', + // }, + // }) + + // expect(dumbo.firstUpload).toBe('balknsdg') + // expect(dumbo.secondUpload).toBe('balknsdg') + // }) + + // it('will remove old file when updating with TUS uploads', async () => {}) + + // it('will remove file when deleting with TUS uploads', async () => {}) + }) + + describe('Result extensions', () => { + it('will return a data URL for the file', async () => { + const res1 = await ( + await prismaClient.dummy.create({ + data: { + uploadField: dataUrlPng, + }, + }) + ).withDataUri() + + // Mocked in FS mocks + expect(res1.uploadField).toBe('data:image/png;base64,BASE64_FILE_CONTENT') + }) + + // @TODO implement + // it('if file is not found, will throw an error', async () => {}) + // it('if saved file is not a path, will throw an error', async () => {}) + }) +}) diff --git a/packages/uploads/src/__tests__/unit-test-schema.prisma b/packages/uploads/src/__tests__/unit-test-schema.prisma index 2040c947ba95..e4c606541958 100644 --- a/packages/uploads/src/__tests__/unit-test-schema.prisma +++ b/packages/uploads/src/__tests__/unit-test-schema.prisma @@ -5,6 +5,7 @@ datasource db { generator client { provider = "prisma-client-js" + output = "./prisma-client" // <-- we generated a local prisma client so it doesn't interfere with the mono repo } model Dummy { diff --git a/packages/uploads/src/fileSave.utils.ts b/packages/uploads/src/fileSave.utils.ts index e7d23bd7f548..b39fb3be361f 100644 --- a/packages/uploads/src/fileSave.utils.ts +++ b/packages/uploads/src/fileSave.utils.ts @@ -34,6 +34,8 @@ export async function saveUploadToFile( saveDir, fileName, }) + } else { + throw new Error('Unsupported upload format') } if (!outputPath) { @@ -98,7 +100,7 @@ export async function saveTusUpload( } function isBase64DataUri(uploadUrlOrDataUrl: string) { - // Check if the uploadUrlOrDataUrl is a valid base64 string + // Check if the uploadUrlOrDataUrl is a valid base64 data uri const base64Regex = /^data:(.*?);base64,/ return base64Regex.test(uploadUrlOrDataUrl) } diff --git a/packages/uploads/src/prismaExtension.ts b/packages/uploads/src/prismaExtension.ts index 1a584b3f1ab4..0c0ec40191d7 100644 --- a/packages/uploads/src/prismaExtension.ts +++ b/packages/uploads/src/prismaExtension.ts @@ -1,7 +1,7 @@ import fs from 'node:fs/promises' import { PrismaClient } from '@prisma/client' -import { Prisma } from '@prisma/client/extension' +import { Prisma } from '@prisma/client' import type * as runtime from '@prisma/client/runtime/library' import { ulid } from 'ulid' diff --git a/packages/uploads/vitest.config.mts b/packages/uploads/vitest.config.mts index 984991bff461..92ccad350ca8 100644 --- a/packages/uploads/vitest.config.mts +++ b/packages/uploads/vitest.config.mts @@ -1,4 +1,9 @@ import { defineConfig, configDefaults } from 'vitest/config' +import { fileURLToPath } from 'url'; +import path from 'path'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); export default defineConfig({ test: { @@ -6,6 +11,11 @@ export default defineConfig({ deps: { interopDefault: false, }, + globalSetup: ['vitest.setup.mts'], + alias: { + // We alias prisma client, so that it doesn't interfere with other packages in the mono repo + '@prisma/client': path.resolve(__dirname, 'src/__tests__/prisma-client'), + }, }, }) diff --git a/packages/uploads/vitest.setup.mts b/packages/uploads/vitest.setup.mts new file mode 100644 index 000000000000..01987b744896 --- /dev/null +++ b/packages/uploads/vitest.setup.mts @@ -0,0 +1,8 @@ +import { $ } from 'zx' + +export default async function setup() { + console.log('[setup] Setting up unit test prisma db....') + await $`yarn clean:prisma` + await $`npx prisma migrate reset -f --skip-seed --schema src/__tests__/unit-test-schema.prisma` + console.log('[setup] Done! \n') +} From c5f462f04ec4c59a36d46d9b1a9f6c203f541eb7 Mon Sep 17 00:00:00 2001 From: Danny Choudhury Date: Wed, 7 Aug 2024 20:48:42 +0700 Subject: [PATCH 26/91] Remove extraneous beforeAll --- packages/uploads/package.json | 3 +-- packages/uploads/src/__tests__/prismaExtension.test.ts | 9 +-------- 2 files changed, 2 insertions(+), 10 deletions(-) diff --git a/packages/uploads/package.json b/packages/uploads/package.json index 1bb59627bb80..3a1065e9f0c0 100644 --- a/packages/uploads/package.json +++ b/packages/uploads/package.json @@ -34,8 +34,7 @@ "check:package": "concurrently npm:check:attw yarn publint", "setup:test": "npx prisma migrate reset -f --schema src/__tests__/unit-test-schema.prisma", "test": "vitest run", - "test:watch": "vitest watch", - "test:prismaExtension": "PRISMA_EXTENSION_TESTS=true vitest run" + "test:watch": "vitest watch" }, "dependencies": { "@redwoodjs/project-config": "workspace:*", diff --git a/packages/uploads/src/__tests__/prismaExtension.test.ts b/packages/uploads/src/__tests__/prismaExtension.test.ts index 665f660e2189..272d7827b597 100644 --- a/packages/uploads/src/__tests__/prismaExtension.test.ts +++ b/packages/uploads/src/__tests__/prismaExtension.test.ts @@ -2,8 +2,7 @@ import fs from 'node:fs/promises' import { PrismaClient } from '@prisma/client' import { vol } from 'memfs' -import { describe, it, vi, expect, beforeAll } from 'vitest' -import { $ } from 'zx' +import { describe, it, vi, expect } from 'vitest' import { createUploadsExtension } from '../prismaExtension' @@ -36,12 +35,6 @@ vol.fromJSON({ }) describe('Uploads Prisma Extension', () => { - beforeAll(async () => { - console.log('[setup] Setting up unit test prisma db....') - await $`npx prisma migrate reset -f --skip-seed --schema src/__tests__/unit-test-schema.prisma` - console.log('[setup] Done! \n') - }) - const dummyUploadConfig = { fields: 'uploadField', savePath: '/bazinga', From f2571a796f0858adc720b4319cdf7019aa1bcd57 Mon Sep 17 00:00:00 2001 From: Danny Choudhury Date: Wed, 7 Aug 2024 21:08:32 +0700 Subject: [PATCH 27/91] Put . in front of schema :( --- packages/uploads/package.json | 2 +- packages/uploads/vitest.setup.mts | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/uploads/package.json b/packages/uploads/package.json index 3a1065e9f0c0..af2ffffd2b64 100644 --- a/packages/uploads/package.json +++ b/packages/uploads/package.json @@ -32,7 +32,7 @@ "build:types-cjs": "tsc --build --verbose tsconfig.types-cjs.json", "check:attw": "tsx attw.ts", "check:package": "concurrently npm:check:attw yarn publint", - "setup:test": "npx prisma migrate reset -f --schema src/__tests__/unit-test-schema.prisma", + "setup:test": "npx prisma migrate reset -f --schema ./src/__tests__/unit-test-schema.prisma", "test": "vitest run", "test:watch": "vitest watch" }, diff --git a/packages/uploads/vitest.setup.mts b/packages/uploads/vitest.setup.mts index 01987b744896..163282422cb4 100644 --- a/packages/uploads/vitest.setup.mts +++ b/packages/uploads/vitest.setup.mts @@ -1,8 +1,9 @@ import { $ } from 'zx' export default async function setup() { + $.verbose = true console.log('[setup] Setting up unit test prisma db....') await $`yarn clean:prisma` - await $`npx prisma migrate reset -f --skip-seed --schema src/__tests__/unit-test-schema.prisma` + await $`npx prisma migrate reset -f --skip-seed --schema ./src/__tests__/unit-test-schema.prisma` console.log('[setup] Done! \n') } From 91c4601835ce40fee4cd3c4548b5b3fcdad4c408 Mon Sep 17 00:00:00 2001 From: Danny Choudhury Date: Wed, 7 Aug 2024 21:30:23 +0700 Subject: [PATCH 28/91] Use db push instead --- packages/uploads/package.json | 2 +- packages/uploads/vitest.setup.mts | 3 +-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/uploads/package.json b/packages/uploads/package.json index af2ffffd2b64..6b40f3742880 100644 --- a/packages/uploads/package.json +++ b/packages/uploads/package.json @@ -32,7 +32,7 @@ "build:types-cjs": "tsc --build --verbose tsconfig.types-cjs.json", "check:attw": "tsx attw.ts", "check:package": "concurrently npm:check:attw yarn publint", - "setup:test": "npx prisma migrate reset -f --schema ./src/__tests__/unit-test-schema.prisma", + "setup:test": "npx prisma db push --schema ./src/__tests__/unit-test-schema.prisma", "test": "vitest run", "test:watch": "vitest watch" }, diff --git a/packages/uploads/vitest.setup.mts b/packages/uploads/vitest.setup.mts index 163282422cb4..80434f24618b 100644 --- a/packages/uploads/vitest.setup.mts +++ b/packages/uploads/vitest.setup.mts @@ -3,7 +3,6 @@ import { $ } from 'zx' export default async function setup() { $.verbose = true console.log('[setup] Setting up unit test prisma db....') - await $`yarn clean:prisma` - await $`npx prisma migrate reset -f --skip-seed --schema ./src/__tests__/unit-test-schema.prisma` + await $`npx prisma db push --schema ./src/__tests__/unit-test-schema.prisma` console.log('[setup] Done! \n') } From e5048a5e9e85e92dd8f1862851e4d16d1242d467 Mon Sep 17 00:00:00 2001 From: Danny Choudhury Date: Wed, 7 Aug 2024 21:33:18 +0700 Subject: [PATCH 29/91] Add TUS tests --- .../src/__tests__/prismaExtension.test.ts | 112 ++++++++++++++---- packages/uploads/src/fileSave.utils.ts | 2 +- 2 files changed, 90 insertions(+), 24 deletions(-) diff --git a/packages/uploads/src/__tests__/prismaExtension.test.ts b/packages/uploads/src/__tests__/prismaExtension.test.ts index 272d7827b597..81cbc410c802 100644 --- a/packages/uploads/src/__tests__/prismaExtension.test.ts +++ b/packages/uploads/src/__tests__/prismaExtension.test.ts @@ -8,13 +8,6 @@ import { createUploadsExtension } from '../prismaExtension' import { dataUrlPng } from './fileMocks' -/*** - * NOTE: this test does not run in CI, because it requires a local prisma db - * which causes build failures elsewhere. It's still useful to have locally when adding/changing features though - * - * To run it use the script `yarn test:prismaExtension` - */ - vi.mock('node:fs/promises', () => ({ default: { writeFile: vi.fn(), @@ -26,6 +19,7 @@ vi.mock('node:fs/promises', () => ({ return 'MOCKED_FILE_CONTENT' }), + copyFile: vi.fn(), }, })) @@ -171,24 +165,96 @@ describe('Uploads Prisma Extension', () => { }) }) - // @TODO have to figure out how to mock the - // it('will move file to new location with TUS uploads', async () => { - // const dumbo = await prismaClient.dumbo.create({ - // data: { - // firstUpload: - // 'http://example.com/.redwood/functions/tusUploadEndpoint/123', - // secondUpload: - // 'http://example.com/.redwood/functions/tusUploadEndpoint/ABCD', - // }, - // }) + it('will move file to new location with TUS uploads', async () => { + // Mock TUS metadata files + vi.mock('/tmp/tus-uploads/123.json', () => { + return { + metadata: { + filetype: 'image/png', + }, + } + }) - // expect(dumbo.firstUpload).toBe('balknsdg') - // expect(dumbo.secondUpload).toBe('balknsdg') - // }) + vi.mock('/tmp/tus-uploads/ABCD.json', () => { + return { + metadata: { + filetype: 'application/pdf', + }, + } + }) - // it('will remove old file when updating with TUS uploads', async () => {}) + const dumbo = await prismaClient.dumbo.create({ + data: { + firstUpload: + 'http://example.com/.redwood/functions/tusUploadEndpoint/123', + secondUpload: + 'http://example.com/.redwood/functions/tusUploadEndpoint/ABCD', + }, + }) + + expect(fs.copyFile).toHaveBeenCalledTimes(2) + expect(dumbo.firstUpload).toMatch(/dumbo\/.*\.png/) + expect(dumbo.secondUpload).toMatch(/dumbo\/.*\.pdf/) + }) + + it('will remove old file when updating with TUS uploads', async () => { + // Mock TUS metadata files + vi.mock('/tmp/tus-uploads/512356.json', () => { + return { + metadata: { + filetype: 'image/gif', + }, + } + }) + + const dumbo = await prismaClient.dumbo.create({ + data: { + firstUpload: + 'http://example.com/.redwood/functions/tusUploadEndpoint/123', + secondUpload: '', + }, + }) - // it('will remove file when deleting with TUS uploads', async () => {}) + const originalPath = dumbo.firstUpload + + const dumbo2 = await prismaClient.dumbo.update({ + where: { id: dumbo.id }, + data: { + firstUpload: + 'http://example.com/.redwood/functions/tusUploadEndpoint/512356', + }, + }) + + expect(dumbo2.firstUpload).not.toEqual(originalPath) + expect(dumbo2.firstUpload).toMatch(/dumbo\/.*\.gif/) + + // And deletes it! + expect(fs.unlink).toHaveBeenCalledWith(originalPath) + }) + + it('will remove file when deleting with TUS uploads', async () => { + // Mock TUS metadata files + vi.mock('/tmp/tus-uploads/512356.json', () => { + return { + metadata: { + filetype: 'image/gif', + }, + } + }) + + const dummy = await prismaClient.dummy.create({ + data: { + uploadField: + 'http://example.com/.redwood/functions/tusUploadEndpoint/123', + }, + }) + + await prismaClient.dummy.delete({ + where: { id: dummy.id }, + }) + + expect(fs.unlink).toHaveBeenCalledWith(dummy.uploadField) + }) }) describe('Result extensions', () => { @@ -205,7 +271,7 @@ describe('Uploads Prisma Extension', () => { expect(res1.uploadField).toBe('data:image/png;base64,BASE64_FILE_CONTENT') }) - // @TODO implement + // @TODO Handle edge cases (file removed, data modified, etc.) // it('if file is not found, will throw an error', async () => {}) // it('if saved file is not a path, will throw an error', async () => {}) }) diff --git a/packages/uploads/src/fileSave.utils.ts b/packages/uploads/src/fileSave.utils.ts index b39fb3be361f..42b165a8e721 100644 --- a/packages/uploads/src/fileSave.utils.ts +++ b/packages/uploads/src/fileSave.utils.ts @@ -84,7 +84,7 @@ export async function saveTusUpload( `${tusId}.json`, ) // Can't await import, because JSON file. - const tusMeta = require(metaFile) + const tusMeta = await import(metaFile) const fileExtension = tusMeta.metadata.filetype.split('/')[1] From 28041a40b43f7d5d83fb4c54a659987709f0b849 Mon Sep 17 00:00:00 2001 From: Danny Choudhury Date: Wed, 7 Aug 2024 21:34:21 +0700 Subject: [PATCH 30/91] Add changeset --- .changesets/11154.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 .changesets/11154.md diff --git a/.changesets/11154.md b/.changesets/11154.md new file mode 100644 index 000000000000..44c278ffb1f5 --- /dev/null +++ b/.changesets/11154.md @@ -0,0 +1,15 @@ +- feat(rw-uploads): Create uploads package with prisma extension (#11154) by @dac09 + +This PR does the following: +- creates new `@redwoodjs/uploads` package. This is configured to be a dual esm/cjs package. +- exports prisma extension with legacy support e.g. + ``` + import { + createUploadsExtension, + UploadsConfig, + } from '@redwoodjs/uploads/prisma' + ``` +- scaffolds unit test structure for the prisma extension +- the prisma extension does the following: +a) If a base64 or TUS upload URL string is sent, it saves or moves it to a file +b) Adds a result extension (withDataUri), to make it easier to deal with files From 087ed7f0f7b3d32ee83dc5a0ad335bbb7023bd9d Mon Sep 17 00:00:00 2001 From: Danny Choudhury Date: Wed, 7 Aug 2024 21:43:15 +0700 Subject: [PATCH 31/91] Clean up in progress stuff --- ...e.units.test.ts => fileSave.utils.test.ts} | 25 +------------------ 1 file changed, 1 insertion(+), 24 deletions(-) rename packages/uploads/src/__tests__/{fileSave.units.test.ts => fileSave.utils.test.ts} (59%) diff --git a/packages/uploads/src/__tests__/fileSave.units.test.ts b/packages/uploads/src/__tests__/fileSave.utils.test.ts similarity index 59% rename from packages/uploads/src/__tests__/fileSave.units.test.ts rename to packages/uploads/src/__tests__/fileSave.utils.test.ts index 5907ea216522..940937383778 100644 --- a/packages/uploads/src/__tests__/fileSave.units.test.ts +++ b/packages/uploads/src/__tests__/fileSave.utils.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, vi } from 'vitest' +import { describe, it, expect } from 'vitest' import { getFileExtensionFromDataUri, @@ -24,29 +24,6 @@ describe('getFileExtension', () => { }) describe('saveUploadToFile', () => { - vi.mock('node:fs/promises', () => ({ - default: { - writeFile: vi.fn(), - unlink: vi.fn(), - // readFile: vi.fn((path, encoding) => { - // if (encoding === 'base64') { - // return 'BASE64_FILE_CONTENT' - // } - - // return 'MOCKED_FILE_CONTENT' - // }), - }, - })) - - // it('Should call saveBase64DataUriToFile if the uploadUrlOrDataUrl is a base64 data uri', async () => { - // saveUploadToFile('data:image/png;base64,....', { - // saveDir: 'uploads', - // fileName: 'test', - // }) - // }) - - // it('Should call saveTusFileToFile if the uploadUrlOrDataUrl is a tus url', async () => {}) - it('Should throw an error if the uploadUrlOrDataUrl is not a base64 data uri or a tus url', async () => { try { await saveUploadToFile('/random/path/to/whatever.png', { From c6ec5d86b7016fea7c1894bd8439c018e1a65ac5 Mon Sep 17 00:00:00 2001 From: Danny Choudhury Date: Wed, 7 Aug 2024 21:45:37 +0700 Subject: [PATCH 32/91] Remove old comment --- packages/uploads/src/fileSave.utils.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/uploads/src/fileSave.utils.ts b/packages/uploads/src/fileSave.utils.ts index 42b165a8e721..757ed221bfc3 100644 --- a/packages/uploads/src/fileSave.utils.ts +++ b/packages/uploads/src/fileSave.utils.ts @@ -83,7 +83,6 @@ export async function saveTusUpload( path.join(getPaths().base, tusConfig.tusUploadDirectory), `${tusId}.json`, ) - // Can't await import, because JSON file. const tusMeta = await import(metaFile) const fileExtension = tusMeta.metadata.filetype.split('/')[1] From ec85ddcd04a150825b913ab63267fa5b5e899cc5 Mon Sep 17 00:00:00 2001 From: Danny Choudhury Date: Wed, 7 Aug 2024 21:56:08 +0700 Subject: [PATCH 33/91] Fix weird CJS/ESM issue with JSON import --- packages/uploads/src/fileSave.utils.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/packages/uploads/src/fileSave.utils.ts b/packages/uploads/src/fileSave.utils.ts index 757ed221bfc3..550a388b6014 100644 --- a/packages/uploads/src/fileSave.utils.ts +++ b/packages/uploads/src/fileSave.utils.ts @@ -83,9 +83,14 @@ export async function saveTusUpload( path.join(getPaths().base, tusConfig.tusUploadDirectory), `${tusId}.json`, ) - const tusMeta = await import(metaFile) + const metafile = await import(metaFile, { assert: { type: 'json' } }) - const fileExtension = tusMeta.metadata.filetype.split('/')[1] + // CJS dynamic imports wrap in default + const tusMetadata: { filetype: string } = metafile.metadata + ? metafile.metadata + : metafile.default.metadata + + const fileExtension = tusMetadata.filetype.split('/')[1] const savedFilePath = path.join(saveDir, `${fileName}.${fileExtension}`) From 821c02af3d277ebf9fd79346c02c99449267b5e7 Mon Sep 17 00:00:00 2001 From: Danny Choudhury Date: Thu, 8 Aug 2024 14:32:51 +0700 Subject: [PATCH 34/91] Get types working for result extension --- .../src/__tests__/prismaExtension.test.ts | 3 ++- packages/uploads/src/prismaExtension.ts | 16 ++++++---------- 2 files changed, 8 insertions(+), 11 deletions(-) diff --git a/packages/uploads/src/__tests__/prismaExtension.test.ts b/packages/uploads/src/__tests__/prismaExtension.test.ts index 81cbc410c802..c811392a955b 100644 --- a/packages/uploads/src/__tests__/prismaExtension.test.ts +++ b/packages/uploads/src/__tests__/prismaExtension.test.ts @@ -1,12 +1,13 @@ import fs from 'node:fs/promises' -import { PrismaClient } from '@prisma/client' import { vol } from 'memfs' import { describe, it, vi, expect } from 'vitest' import { createUploadsExtension } from '../prismaExtension' import { dataUrlPng } from './fileMocks' +// @MARK: use the local prisma client +import { PrismaClient } from './prisma-client' vi.mock('node:fs/promises', () => ({ default: { diff --git a/packages/uploads/src/prismaExtension.ts b/packages/uploads/src/prismaExtension.ts index 0c0ec40191d7..e182e340bf43 100644 --- a/packages/uploads/src/prismaExtension.ts +++ b/packages/uploads/src/prismaExtension.ts @@ -27,10 +27,8 @@ export type UploadConfigForModel = { onFileSaved?: (filePath: string) => void | Promise } -export type UploadsConfig = Record< - MName, - UploadConfigForModel -> +export type UploadsConfig = + Record export const createUploadsExtension = ( config: UploadsConfig, @@ -68,11 +66,9 @@ export const createUploadsExtension = ( [K in MNames]: { withDataUri: { needs: Record - compute: ( - // @MARK: this generic doesn't get picked up by prisma - // the returned type ends up being unknown - modelData: T, - ) => () => Promise + compute: ( + modelData: Record, + ) => (this: T) => Promise } } } @@ -148,7 +144,7 @@ export const createUploadsExtension = ( // 2. If not a path, relative or absolute, throw error return { - ...modelData, + ...(modelData as any), ...base64UploadFields, } } From 30cc64ed84018a21917f0e73b85c6fcb2ef61556 Mon Sep 17 00:00:00 2001 From: Danny Choudhury Date: Thu, 8 Aug 2024 14:44:24 +0700 Subject: [PATCH 35/91] Clean up types and comments for result extends --- packages/uploads/src/prismaExtension.ts | 28 ++++++++++++------------- 1 file changed, 13 insertions(+), 15 deletions(-) diff --git a/packages/uploads/src/prismaExtension.ts b/packages/uploads/src/prismaExtension.ts index e182e340bf43..b40957868b4a 100644 --- a/packages/uploads/src/prismaExtension.ts +++ b/packages/uploads/src/prismaExtension.ts @@ -38,6 +38,17 @@ export const createUploadsExtension = ( // instead of creating a new PrismaClient instance const prismaInstance = new PrismaClient() + type ResultExtends = { + [K in MNames]: { + withDataUri: { + needs: Record + compute: ( + modelData: Record, + ) => (this: T) => Promise + } + } + } + async function deleteUploadsFromDiskForArgs({ model, args, @@ -62,17 +73,7 @@ export const createUploadsExtension = ( const queryExtends: runtime.ExtensionArgs['query'] = {} - const resultExtends = {} as { - [K in MNames]: { - withDataUri: { - needs: Record - compute: ( - modelData: Record, - ) => (this: T) => Promise - } - } - } - + const resultExtends = {} as ResultExtends for (const modelName in config) { // Guaranteed to have modelConfig, we're looping over config 🙄 const modelConfig = config[modelName as MNames] as UploadConfigForModel @@ -139,11 +140,8 @@ export const createUploadsExtension = ( ) } - // @TODO: edge cases - // 1. If readfile fails - file not found, etc. - // 2. If not a path, relative or absolute, throw error - return { + // modelData is of type unknown at this point ...(modelData as any), ...base64UploadFields, } From e80e556b44bef237601590a172341dc8749a4968 Mon Sep 17 00:00:00 2001 From: Danny Choudhury Date: Tue, 13 Aug 2024 14:50:47 +0700 Subject: [PATCH 36/91] WIP: adapter --- packages/uploads/src/FileSystemStorage.ts | 29 +++ packages/uploads/src/StorageAdapter.ts | 22 ++ .../src/__tests__/fileSave.utils.test.ts | 37 --- .../src/__tests__/prismaExtension.test.ts | 29 ++- packages/uploads/src/fileSave.utils.ts | 129 ----------- packages/uploads/src/prismaExtension.ts | 97 ++++---- yarn.lock | 210 +++++++++++++++++- 7 files changed, 325 insertions(+), 228 deletions(-) create mode 100644 packages/uploads/src/FileSystemStorage.ts create mode 100644 packages/uploads/src/StorageAdapter.ts delete mode 100644 packages/uploads/src/__tests__/fileSave.utils.test.ts diff --git a/packages/uploads/src/FileSystemStorage.ts b/packages/uploads/src/FileSystemStorage.ts new file mode 100644 index 000000000000..7da595b54be5 --- /dev/null +++ b/packages/uploads/src/FileSystemStorage.ts @@ -0,0 +1,29 @@ +import fs from 'node:fs/promises' +import path from 'node:path' + +import type { StorageAdapter } from './StorageAdapter.js' +import type { SaveOptions } from './StorageAdapter.js' + +export class FileSystemStorage implements StorageAdapter { + // let basePath: string + // @TODO enable base path + // constructor({ basePath }) { + // this.basePath = basePath + // } + + async save(o_file: File, saveOpts: SaveOptions) { + // const file = new File([o_file], o_file.name) + // console.log(`👉 \n ~ FileSystemStorage ~ file:`, file.name) + console.log(`👉 \n ~ FileSystemStorage ~ file:`, await o_file.text()) + + const location = path.join(saveOpts.path, saveOpts.fileName + o_file.type) + const nodeBuffer = await o_file.arrayBuffer() + const extension = path.extname(o_file.name) + + await fs.writeFile(`${location}.${extension}`, Buffer.from(nodeBuffer)) + return { location } + } + async remove(filePath: string) { + await fs.unlink(filePath) + } +} diff --git a/packages/uploads/src/StorageAdapter.ts b/packages/uploads/src/StorageAdapter.ts new file mode 100644 index 000000000000..ecc0f28ef58a --- /dev/null +++ b/packages/uploads/src/StorageAdapter.ts @@ -0,0 +1,22 @@ +/** + * The storage adapter will just save the file and return + * { + * fileId: string, + * location: string, // depending on storage it could be a path + * } + */ + +export type AdapterResult = { + location: string +} + +export type SaveOptions = { + fileName: string + path: string +} + +export abstract class StorageAdapter { + abstract save(file: File, saveOpts?: SaveOptions): Promise + abstract remove(fileLocation: AdapterResult['location']): Promise + // abstract replace(fileId: string, file: File): Promise +} diff --git a/packages/uploads/src/__tests__/fileSave.utils.test.ts b/packages/uploads/src/__tests__/fileSave.utils.test.ts deleted file mode 100644 index 940937383778..000000000000 --- a/packages/uploads/src/__tests__/fileSave.utils.test.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { describe, it, expect } from 'vitest' - -import { - getFileExtensionFromDataUri, - saveUploadToFile, -} from '../fileSave.utils' - -describe('getFileExtension', () => { - it('should return the correct file extension for a given data type', () => { - const dataType = 'data:image/png;base64' - const extension = getFileExtensionFromDataUri(dataType) - expect(extension).toBe('png') - }) - - it('handles svgs', () => { - const dataType = 'data:image/svg+xml;base64' - expect(getFileExtensionFromDataUri(dataType)).toBe('svg') - }) - - it('handles gif', () => { - const dataType = 'data:image/gif;base64' - expect(getFileExtensionFromDataUri(dataType)).toBe('gif') - }) -}) - -describe('saveUploadToFile', () => { - it('Should throw an error if the uploadUrlOrDataUrl is not a base64 data uri or a tus url', async () => { - try { - await saveUploadToFile('/random/path/to/whatever.png', { - saveDir: 'uploads', - fileName: 'test', - }) - } catch (e) { - expect(e.message).toBe('Unsupported upload format') - } - }) -}) diff --git a/packages/uploads/src/__tests__/prismaExtension.test.ts b/packages/uploads/src/__tests__/prismaExtension.test.ts index c811392a955b..7b7689ae69a2 100644 --- a/packages/uploads/src/__tests__/prismaExtension.test.ts +++ b/packages/uploads/src/__tests__/prismaExtension.test.ts @@ -3,9 +3,10 @@ import fs from 'node:fs/promises' import { vol } from 'memfs' import { describe, it, vi, expect } from 'vitest' -import { createUploadsExtension } from '../prismaExtension' +import { FileSystemStorage } from '../FileSystemStorage.js' +import { createUploadsExtension } from '../prismaExtension.js' -import { dataUrlPng } from './fileMocks' +import { dataUrlPng } from './fileMocks.js' // @MARK: use the local prisma client import { PrismaClient } from './prisma-client' @@ -42,24 +43,27 @@ describe('Uploads Prisma Extension', () => { onFileSaved: vi.fn(), } - const tusConfig = { - tusUploadDirectory: '/tmp/tus-uploads', - } const prismaClient = new PrismaClient().$extends( createUploadsExtension( { dummy: dummyUploadConfig, dumbo: dumboUploadConfig, }, - tusConfig, + new FileSystemStorage(), ), ) describe('Query extensions', () => { - it('will create a file with base64 encoded png', async () => { + it.only('will create a file with base64 encoded png', async () => { + const file = new File(['hello'], 'hello.txt', { + type: 'text/plain', + }) + console.log(`👉 \n ~ file:`, file) + console.log(`👉 \n ~ file arraybuf:`, await file.arrayBuffer()) + const dum1 = await prismaClient.dummy.create({ data: { - uploadField: dataUrlPng, + uploadField: file, }, }) @@ -142,9 +146,12 @@ describe('Uploads Prisma Extension', () => { } const clientWithFileName = new PrismaClient().$extends( - createUploadsExtension({ - dumbo: customNameConfig, - }), + createUploadsExtension( + { + dumbo: customNameConfig, + }, + new FileSystemStorage(), + ), ) const dumbo = await clientWithFileName.dumbo.create({ diff --git a/packages/uploads/src/fileSave.utils.ts b/packages/uploads/src/fileSave.utils.ts index 550a388b6014..193626c89d87 100644 --- a/packages/uploads/src/fileSave.utils.ts +++ b/packages/uploads/src/fileSave.utils.ts @@ -3,135 +3,6 @@ import path from 'node:path' import mime from 'mime-types' -import { getPaths } from '@redwoodjs/project-config' - -export type TUSServerConfig = { - tusUploadDirectory: string -} - -/** - * This function takes an upload field, determines whether its TUS or Base64, and saves it to the file system. - */ -export async function saveUploadToFile( - uploadUrlOrDataUrl: string, - { fileName, saveDir }: { saveDir: string; fileName: string }, - tusConfig?: TUSServerConfig, -) { - let outputPath: string | null = null - - if (isBase64DataUri(uploadUrlOrDataUrl)) { - outputPath = await saveBase64DataUriToFile(uploadUrlOrDataUrl, { - saveDir, - fileName, - }) - } else if (uploadUrlOrDataUrl.startsWith('http')) { - if (!tusConfig) { - throw new Error('TusConfig not supplied.') - } - - outputPath = await saveTusUpload(uploadUrlOrDataUrl, { - tusConfig, - saveDir, - fileName, - }) - } else { - throw new Error('Unsupported upload format') - } - - if (!outputPath) { - throw new Error('Unsupported upload URL') - } - - // @MARK: we can create a new record on the uploads table here - - return outputPath -} - -// @MARK: if we block the TUS GET, we don't really need to move it -// We send the TUS upload URL as the value of the field -export async function saveTusUpload( - uploadUrl: string, - { - tusConfig, - saveDir, - fileName, - }: { - tusConfig: TUSServerConfig - saveDir: string - fileName: string - }, -) { - // Get the last part of the TUS upload url - // http://localhost:8910/.redwood/functions/uploadTUS/👉28fa96bf5772338d51👈 - const tusId = uploadUrl.split('/').slice(-1).pop() - - if (!tusId) { - throw new Error('Could not extract upload ID from URL') - } - - if (!tusConfig.tusUploadDirectory) { - throw new Error( - 'You have to configure the TUS Upload Directory in the prisma extension. It is required for TUS uploads', - ) - } - - // Optional Step.... - const metaFile = path.join( - path.isAbsolute(tusConfig.tusUploadDirectory) - ? tusConfig.tusUploadDirectory - : // @MARK: if the directory supplied isn't relative - path.join(getPaths().base, tusConfig.tusUploadDirectory), - `${tusId}.json`, - ) - const metafile = await import(metaFile, { assert: { type: 'json' } }) - - // CJS dynamic imports wrap in default - const tusMetadata: { filetype: string } = metafile.metadata - ? metafile.metadata - : metafile.default.metadata - - const fileExtension = tusMetadata.filetype.split('/')[1] - - const savedFilePath = path.join(saveDir, `${fileName}.${fileExtension}`) - - // @MARK: we can also move... - await fs.copyFile( - path.join(tusConfig.tusUploadDirectory, tusId), - savedFilePath, - ) - - return savedFilePath -} - -function isBase64DataUri(uploadUrlOrDataUrl: string) { - // Check if the uploadUrlOrDataUrl is a valid base64 data uri - const base64Regex = /^data:(.*?);base64,/ - return base64Regex.test(uploadUrlOrDataUrl) -} - -async function saveBase64DataUriToFile( - dataUrlString: string, - { saveDir, fileName }: { saveDir: string; fileName: string }, -) { - const [dataType, fileContent] = dataUrlString.split(',') - // format is data:image/png;base64,.... - const fileExtension = getFileExtensionFromDataUri(dataType) - const filePath = path.join(saveDir, `${fileName}.${fileExtension}`) - - await fs.writeFile(filePath, Buffer.from(fileContent, 'base64')) - - return filePath -} - -export function getFileExtensionFromDataUri(dataType: string): string { - const mimeType = dataType.split(':')[1].split(';')[0] - const extension = mime.extension(mimeType) - if (!extension) { - throw new Error(`Unsupported file type: ${mimeType}`) - } - return extension -} - export async function fileToDataUri(filePath: string) { const base64Data = await fs.readFile(filePath, 'base64') const ext = path.extname(filePath) diff --git a/packages/uploads/src/prismaExtension.ts b/packages/uploads/src/prismaExtension.ts index b40957868b4a..710d018cf411 100644 --- a/packages/uploads/src/prismaExtension.ts +++ b/packages/uploads/src/prismaExtension.ts @@ -1,15 +1,10 @@ -import fs from 'node:fs/promises' - import { PrismaClient } from '@prisma/client' import { Prisma } from '@prisma/client' import type * as runtime from '@prisma/client/runtime/library' import { ulid } from 'ulid' -import { - fileToDataUri, - saveUploadToFile, - type TUSServerConfig, -} from './fileSave.utils.js' +import { fileToDataUri } from './fileSave.utils.js' +import type { StorageAdapter } from './StorageAdapter.js' type FilterOutDollarPrefixed = T extends `$${string}` ? never @@ -32,7 +27,7 @@ export type UploadsConfig = export const createUploadsExtension = ( config: UploadsConfig, - tusConfig?: TUSServerConfig, + storageAdapter: StorageAdapter, ) => { // @TODO I think we can use Prisma.getExtensionContext(this) // instead of creating a new PrismaClient instance @@ -49,15 +44,18 @@ export const createUploadsExtension = ( } } - async function deleteUploadsFromDiskForArgs({ - model, - args, - fields, - }: { - model: string - args: T - fields: string[] - }) { + async function deleteUpload( + { + model, + args, + fields, + }: { + model: string + args: T + fields: string[] + }, + storageAdapter: StorageAdapter, + ) { // With strict mode you cannot call findFirstOrThrow with the same args, because it is a union type // Ideally there's a better way to do this const record = await ( @@ -67,7 +65,7 @@ export const createUploadsExtension = ( // Delete the file from the file system fields.forEach(async (field) => { const filePath = record[field] - await fs.unlink(filePath) + await storageAdapter.remove(filePath) }) } @@ -83,20 +81,23 @@ export const createUploadsExtension = ( queryExtends[modelName] = { async update({ query, model, args }) { - await deleteUploadsFromDiskForArgs({ - model, - args: { - // The update args contains data, which we don't need to supply to delete - where: args.where, + await deleteUpload( + { + model, + args: { + // The update args contains data, which we don't need to supply to delete + where: args.where, + }, + fields: uploadFields, }, - fields: uploadFields, - }) + storageAdapter, + ) const uploadArgs = await saveUploads( uploadFields, args, modelConfig, - tusConfig, + storageAdapter, ) return query(uploadArgs) @@ -106,21 +107,23 @@ export const createUploadsExtension = ( uploadFields, args, modelConfig, - tusConfig, + storageAdapter, ) return query(uploadArgs) }, async delete({ model, query, args }) { - await deleteUploadsFromDiskForArgs({ - model, - args, - fields: uploadFields, - }) + await deleteUpload( + { + model, + args, + fields: uploadFields, + }, + storageAdapter, + ) return query(args) }, - // findMany({ query, args, operation }) {} } // This makes the result extension only available for models with uploadFields @@ -169,11 +172,11 @@ async function saveUploads( uploadFields: string[], args: runtime.JsArgs & { data?: { - [key: string]: runtime.JsInputValue + [key: string]: runtime.JsInputValue | File } }, modelConfig: UploadConfigForModel, - tusConfig?: TUSServerConfig, + storageAdapter: StorageAdapter, ) { const fieldsToUpdate: { [key: string]: string @@ -183,13 +186,14 @@ async function saveUploads( throw new Error('No data in prisma query') } - // For each upload property, we need to: + // For each upload property, we need to:z // 1. save the file to the file system (path or name from config) // 2. replace the value of the field for await (const field of uploadFields) { - const uploadUrlOrDataUrl = args.data[field] as string + const uploadFile = args.data[field] as File + console.log(`👉 \n ~ uploadFile:`, uploadFile) - if (!uploadUrlOrDataUrl) { + if (!uploadFile) { continue } @@ -203,21 +207,18 @@ async function saveUploads( ? modelConfig.savePath(args) : modelConfig.savePath || 'web/public/uploads' - const savedFilePath = await saveUploadToFile( - uploadUrlOrDataUrl, - { - fileName, - saveDir, - }, - tusConfig, - ) + const savedFile = await storageAdapter.save(uploadFile, { + fileName, + path: saveDir, + }) - fieldsToUpdate[field] = savedFilePath + // @TODO should we return location or fileId? + fieldsToUpdate[field] = savedFile.location // Call the onFileSaved callback // Having it here means it'll always trigger whether create/update if (modelConfig.onFileSaved) { - await modelConfig.onFileSaved(savedFilePath) + await modelConfig.onFileSaved(savedFile.location) } } diff --git a/yarn.lock b/yarn.lock index d8e95521b463..2bc153502df2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -205,6 +205,23 @@ __metadata: languageName: node linkType: hard +"@arethetypeswrong/cli@npm:0.15.3": + version: 0.15.3 + resolution: "@arethetypeswrong/cli@npm:0.15.3" + dependencies: + "@arethetypeswrong/core": "npm:0.15.1" + chalk: "npm:^4.1.2" + cli-table3: "npm:^0.6.3" + commander: "npm:^10.0.1" + marked: "npm:^9.1.2" + marked-terminal: "npm:^6.0.0" + semver: "npm:^7.5.4" + bin: + attw: dist/index.js + checksum: 10c0/5998ab4a2195f9036a5c1988f73912a0a82cceeaa6a4e647b04414ad956a62163d8286b2a936941f23065b0c872f2bbdf9196fe3cac19c40b8b62a643d91c3c2 + languageName: node + linkType: hard + "@arethetypeswrong/cli@npm:0.15.4": version: 0.15.4 resolution: "@arethetypeswrong/cli@npm:0.15.4" @@ -11448,6 +11465,18 @@ __metadata: languageName: node linkType: hard +"@vitest/expect@npm:2.0.4": + version: 2.0.4 + resolution: "@vitest/expect@npm:2.0.4" + dependencies: + "@vitest/spy": "npm:2.0.4" + "@vitest/utils": "npm:2.0.4" + chai: "npm:^5.1.1" + tinyrainbow: "npm:^1.2.0" + checksum: 10c0/18acdd6b1f5001830722fab7d41b0bd754e37572dded74d1549c5e8f40e58d9e4bbbb6a8ce6be1200b04653237329ba1aeeb3330c2a41f1024450016464d491e + languageName: node + linkType: hard + "@vitest/expect@npm:2.0.5": version: 2.0.5 resolution: "@vitest/expect@npm:2.0.5" @@ -11460,7 +11489,16 @@ __metadata: languageName: node linkType: hard -"@vitest/pretty-format@npm:2.0.5, @vitest/pretty-format@npm:^2.0.5": +"@vitest/pretty-format@npm:2.0.4": + version: 2.0.4 + resolution: "@vitest/pretty-format@npm:2.0.4" + dependencies: + tinyrainbow: "npm:^1.2.0" + checksum: 10c0/c2ac3ca302b93ad53ea2977209ee4eb31a313c18690034a09f8ec5528d7e82715c233c4927ecf8b364203c5e5475231d9b737b3fb7680eea71882e1eae11e473 + languageName: node + linkType: hard + +"@vitest/pretty-format@npm:2.0.5, @vitest/pretty-format@npm:^2.0.4, @vitest/pretty-format@npm:^2.0.5": version: 2.0.5 resolution: "@vitest/pretty-format@npm:2.0.5" dependencies: @@ -11469,6 +11507,16 @@ __metadata: languageName: node linkType: hard +"@vitest/runner@npm:2.0.4": + version: 2.0.4 + resolution: "@vitest/runner@npm:2.0.4" + dependencies: + "@vitest/utils": "npm:2.0.4" + pathe: "npm:^1.1.2" + checksum: 10c0/b550372ce5e2c6a3f08dbd584ea669723fc0d789ebaa4224b703f12e908813fb76b963ea9ac2265aa751cab0309f637dc1fa7ce3fb3e67e08e52e241d33237ee + languageName: node + linkType: hard + "@vitest/runner@npm:2.0.5": version: 2.0.5 resolution: "@vitest/runner@npm:2.0.5" @@ -11479,6 +11527,17 @@ __metadata: languageName: node linkType: hard +"@vitest/snapshot@npm:2.0.4": + version: 2.0.4 + resolution: "@vitest/snapshot@npm:2.0.4" + dependencies: + "@vitest/pretty-format": "npm:2.0.4" + magic-string: "npm:^0.30.10" + pathe: "npm:^1.1.2" + checksum: 10c0/67608c5b1e2f8b02ebc95286cd644c31ea29344c81d67151375b6eebf088a0eea242756eefb509aac626b8f7f091044fdcbc80d137d811ead1117a4a524e2d74 + languageName: node + linkType: hard + "@vitest/snapshot@npm:2.0.5": version: 2.0.5 resolution: "@vitest/snapshot@npm:2.0.5" @@ -11490,6 +11549,15 @@ __metadata: languageName: node linkType: hard +"@vitest/spy@npm:2.0.4": + version: 2.0.4 + resolution: "@vitest/spy@npm:2.0.4" + dependencies: + tinyspy: "npm:^3.0.0" + checksum: 10c0/ef0d0c5e36bb6dfa3ef7561368b39c92cd89bb52d112ec13345dfc99981796a9af98bafd35ce6952322a6a7534eaad144485fe7764628d94d77edeba5fa773b6 + languageName: node + linkType: hard + "@vitest/spy@npm:2.0.5": version: 2.0.5 resolution: "@vitest/spy@npm:2.0.5" @@ -11499,6 +11567,18 @@ __metadata: languageName: node linkType: hard +"@vitest/utils@npm:2.0.4": + version: 2.0.4 + resolution: "@vitest/utils@npm:2.0.4" + dependencies: + "@vitest/pretty-format": "npm:2.0.4" + estree-walker: "npm:^3.0.3" + loupe: "npm:^3.1.1" + tinyrainbow: "npm:^1.2.0" + checksum: 10c0/48e0bad3aa463d147b125e355b6bc6c5b4a5eab600132ebafac8379800273b2f47df17dbf76fe179b1500cc6b5866ead2d375a39a9114a03f705eb8850b93afa + languageName: node + linkType: hard + "@vitest/utils@npm:2.0.5": version: 2.0.5 resolution: "@vitest/utils@npm:2.0.5" @@ -12036,7 +12116,7 @@ __metadata: languageName: node linkType: hard -"ansi-escapes@npm:^6.0.0": +"ansi-escapes@npm:^6.0.0, ansi-escapes@npm:^6.2.0": version: 6.2.1 resolution: "ansi-escapes@npm:6.2.1" checksum: 10c0/a2c6f58b044be5f69662ee17073229b492daa2425a7fd99a665db6c22eab6e4ab42752807def7281c1c7acfed48f87f2362dda892f08c2c437f1b39c6b033103 @@ -12098,6 +12178,13 @@ __metadata: languageName: node linkType: hard +"ansicolors@npm:~0.3.2": + version: 0.3.2 + resolution: "ansicolors@npm:0.3.2" + checksum: 10c0/e202182895e959c5357db6c60791b2abaade99fcc02221da11a581b26a7f83dc084392bc74e4d3875c22f37b3c9ef48842e896e3bfed394ec278194b8003e0ac + languageName: node + linkType: hard + "any-promise@npm:^1.0.0": version: 1.3.0 resolution: "any-promise@npm:1.3.0" @@ -13470,6 +13557,18 @@ __metadata: languageName: node linkType: hard +"cardinal@npm:^2.1.1": + version: 2.1.1 + resolution: "cardinal@npm:2.1.1" + dependencies: + ansicolors: "npm:~0.3.2" + redeyed: "npm:~2.1.0" + bin: + cdl: ./bin/cdl.js + checksum: 10c0/0051d0e64c0e1dff480c1aace4c018c48ecca44030533257af3f023107ccdeb061925603af6d73710f0345b0ae0eb57e5241d181d9b5fdb595d45c5418161675 + languageName: node + linkType: hard + "caseless@npm:~0.12.0": version: 0.12.0 resolution: "caseless@npm:0.12.0" @@ -16249,7 +16348,7 @@ __metadata: languageName: node linkType: hard -"esbuild@npm:^0.21.3": +"esbuild@npm:^0.21.3, esbuild@npm:~0.21.5": version: 0.21.5 resolution: "esbuild@npm:0.21.5" dependencies: @@ -22044,6 +22143,22 @@ __metadata: languageName: node linkType: hard +"marked-terminal@npm:^6.0.0": + version: 6.2.0 + resolution: "marked-terminal@npm:6.2.0" + dependencies: + ansi-escapes: "npm:^6.2.0" + cardinal: "npm:^2.1.1" + chalk: "npm:^5.3.0" + cli-table3: "npm:^0.6.3" + node-emoji: "npm:^2.1.3" + supports-hyperlinks: "npm:^3.0.0" + peerDependencies: + marked: ">=1 <12" + checksum: 10c0/72d4093cbb1ee864ced1f88fdb6fb8dbfea56d6aa3d8a1ec401ac51866ff3c32382c3f4642b19f2d808c798efde23b10300b99e3b6475b3f79e41e7741581d54 + languageName: node + linkType: hard + "marked-terminal@npm:^7.1.0": version: 7.1.0 resolution: "marked-terminal@npm:7.1.0" @@ -25906,6 +26021,15 @@ __metadata: languageName: node linkType: hard +"redeyed@npm:~2.1.0": + version: 2.1.1 + resolution: "redeyed@npm:2.1.1" + dependencies: + esprima: "npm:~4.0.0" + checksum: 10c0/350f5e39aebab3886713a170235c38155ee64a74f0f7e629ecc0144ba33905efea30c2c3befe1fcbf0b0366e344e7bfa34e6b2502b423c9a467d32f1306ef166 + languageName: node + linkType: hard + "redis-errors@npm:^1.0.0, redis-errors@npm:^1.2.0": version: 1.2.0 resolution: "redis-errors@npm:1.2.0" @@ -28632,6 +28756,22 @@ __metadata: languageName: node linkType: hard +"tsx@npm:4.16.2": + version: 4.16.2 + resolution: "tsx@npm:4.16.2" + dependencies: + esbuild: "npm:~0.21.5" + fsevents: "npm:~2.3.3" + get-tsconfig: "npm:^4.7.5" + dependenciesMeta: + fsevents: + optional: true + bin: + tsx: dist/cli.mjs + checksum: 10c0/9df52264f88be00ca473e7d7eda43bb038cc09028514996b864db78645e9cd297c71485f0fdd4985464d6dc46424f8bef9f8c4bd56692c4fcf4d71621ae21763 + languageName: node + linkType: hard + "tsx@npm:4.17.0": version: 4.17.0 resolution: "tsx@npm:4.17.0" @@ -29474,6 +29614,21 @@ __metadata: languageName: node linkType: hard +"vite-node@npm:2.0.4": + version: 2.0.4 + resolution: "vite-node@npm:2.0.4" + dependencies: + cac: "npm:^6.7.14" + debug: "npm:^4.3.5" + pathe: "npm:^1.1.2" + tinyrainbow: "npm:^1.2.0" + vite: "npm:^5.0.0" + bin: + vite-node: vite-node.mjs + checksum: 10c0/2689b05b391b59cf3d15e1e80884e9b054f2ca90b2150cc7a08b0f234e79e6750a28cc8d107a57f005185e759c3bc020030f687065317fc37fe169ce17f4cdb7 + languageName: node + linkType: hard + "vite-node@npm:2.0.5": version: 2.0.5 resolution: "vite-node@npm:2.0.5" @@ -29557,6 +29712,55 @@ __metadata: languageName: node linkType: hard +"vitest@npm:2.0.4": + version: 2.0.4 + resolution: "vitest@npm:2.0.4" + dependencies: + "@ampproject/remapping": "npm:^2.3.0" + "@vitest/expect": "npm:2.0.4" + "@vitest/pretty-format": "npm:^2.0.4" + "@vitest/runner": "npm:2.0.4" + "@vitest/snapshot": "npm:2.0.4" + "@vitest/spy": "npm:2.0.4" + "@vitest/utils": "npm:2.0.4" + chai: "npm:^5.1.1" + debug: "npm:^4.3.5" + execa: "npm:^8.0.1" + magic-string: "npm:^0.30.10" + pathe: "npm:^1.1.2" + std-env: "npm:^3.7.0" + tinybench: "npm:^2.8.0" + tinypool: "npm:^1.0.0" + tinyrainbow: "npm:^1.2.0" + vite: "npm:^5.0.0" + vite-node: "npm:2.0.4" + why-is-node-running: "npm:^2.3.0" + peerDependencies: + "@edge-runtime/vm": "*" + "@types/node": ^18.0.0 || >=20.0.0 + "@vitest/browser": 2.0.4 + "@vitest/ui": 2.0.4 + happy-dom: "*" + jsdom: "*" + peerDependenciesMeta: + "@edge-runtime/vm": + optional: true + "@types/node": + optional: true + "@vitest/browser": + optional: true + "@vitest/ui": + optional: true + happy-dom: + optional: true + jsdom: + optional: true + bin: + vitest: vitest.mjs + checksum: 10c0/139200d0bda3270fd00641e4bd5524f78a2b1fe9a3d4a0d5ba2b6ed08bbcf6f1e711cc4bfd8b0d823628a2fcab00f822bb210bd5bf3c6a9260fd6115ea085a3d + languageName: node + linkType: hard + "vitest@npm:2.0.5": version: 2.0.5 resolution: "vitest@npm:2.0.5" From 71160fe4cc6aeb90d177add38a9cca8b163775f3 Mon Sep 17 00:00:00 2001 From: Danny Choudhury Date: Wed, 14 Aug 2024 20:50:08 +0700 Subject: [PATCH 37/91] WIP: commit changes --- packages/internal/src/generate/graphqlCodeGen.ts | 1 + packages/uploads/src/prismaExtension.ts | 6 ++++-- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/internal/src/generate/graphqlCodeGen.ts b/packages/internal/src/generate/graphqlCodeGen.ts index 08b383f0ea36..73f51e42a4d6 100644 --- a/packages/internal/src/generate/graphqlCodeGen.ts +++ b/packages/internal/src/generate/graphqlCodeGen.ts @@ -288,6 +288,7 @@ async function getPluginConfig(side: CodegenSide) { JSONObject: 'Prisma.JsonObject', Time: side === CodegenSide.WEB ? 'string' : 'Date | string', Byte: 'Buffer', + File: 'File', }, // prevent type names being PetQueryQuery, RW generators already append // Query/Mutation/etc diff --git a/packages/uploads/src/prismaExtension.ts b/packages/uploads/src/prismaExtension.ts index 710d018cf411..aa0248c9b408 100644 --- a/packages/uploads/src/prismaExtension.ts +++ b/packages/uploads/src/prismaExtension.ts @@ -22,8 +22,10 @@ export type UploadConfigForModel = { onFileSaved?: (filePath: string) => void | Promise } -export type UploadsConfig = - Record +export type UploadsConfig = Record< + MName, + UploadConfigForModel +> export const createUploadsExtension = ( config: UploadsConfig, From b2dfc189a5480b0d5c354e12a2e043c0ac62bd15 Mon Sep 17 00:00:00 2001 From: Danny Choudhury Date: Thu, 15 Aug 2024 15:49:15 +0700 Subject: [PATCH 38/91] Regenerate lock file --- yarn.lock | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/yarn.lock b/yarn.lock index 5675e4cc3f83..690534615d91 100644 --- a/yarn.lock +++ b/yarn.lock @@ -12093,6 +12093,13 @@ __metadata: languageName: node linkType: hard +"ansi-escapes@npm:^6.2.0": + version: 6.2.1 + resolution: "ansi-escapes@npm:6.2.1" + checksum: 10c0/a2c6f58b044be5f69662ee17073229b492daa2425a7fd99a665db6c22eab6e4ab42752807def7281c1c7acfed48f87f2362dda892f08c2c437f1b39c6b033103 + languageName: node + linkType: hard + "ansi-escapes@npm:^7.0.0": version: 7.0.0 resolution: "ansi-escapes@npm:7.0.0" From 3001a5ee0b46d6a76da17abd9bbe1611a26772f9 Mon Sep 17 00:00:00 2001 From: Danny Choudhury Date: Thu, 15 Aug 2024 18:29:03 +0700 Subject: [PATCH 39/91] Create processors, add some tests Add various stores --- packages/uploads/src/FileSystemStorage.ts | 30 +++--- packages/uploads/src/MemoryStorage.ts | 42 ++++++++ packages/uploads/src/StorageAdapter.ts | 17 ++- .../src/__tests__/createProcessors.test.ts | 101 ++++++++++++++++++ .../src/__tests__/prismaExtension.test.ts | 100 +---------------- packages/uploads/src/createProcessors.ts | 49 +++++++++ packages/uploads/src/prismaExtension.ts | 2 +- 7 files changed, 225 insertions(+), 116 deletions(-) create mode 100644 packages/uploads/src/MemoryStorage.ts create mode 100644 packages/uploads/src/__tests__/createProcessors.test.ts create mode 100644 packages/uploads/src/createProcessors.ts diff --git a/packages/uploads/src/FileSystemStorage.ts b/packages/uploads/src/FileSystemStorage.ts index 7da595b54be5..6a431b0d79b8 100644 --- a/packages/uploads/src/FileSystemStorage.ts +++ b/packages/uploads/src/FileSystemStorage.ts @@ -1,26 +1,26 @@ import fs from 'node:fs/promises' import path from 'node:path' -import type { StorageAdapter } from './StorageAdapter.js' -import type { SaveOptions } from './StorageAdapter.js' +import mime from 'mime-types' +import { ulid } from 'ulid' + +import type { AdapterOptions, SaveOptionsOverride, StorageAdapter } from './StorageAdapter.js' export class FileSystemStorage implements StorageAdapter { - // let basePath: string - // @TODO enable base path - // constructor({ basePath }) { - // this.basePath = basePath - // } + constructor(adapterOpts: AdapterOptions) { + this.adapterOpts = adapterOpts + } + + adapterOpts: AdapterOptions - async save(o_file: File, saveOpts: SaveOptions) { - // const file = new File([o_file], o_file.name) - // console.log(`👉 \n ~ FileSystemStorage ~ file:`, file.name) - console.log(`👉 \n ~ FileSystemStorage ~ file:`, await o_file.text()) + async save(file: File, saveOpts?: SaveOptionsOverride) { + const randomFileName = ulid() + const extension = mime.extension(file.type) + const location = path.join(saveOpts?.path || this.adapterOpts.baseDir, saveOpts?.fileName || randomFileName + `.${extension}`) + const nodeBuffer = await file.arrayBuffer() - const location = path.join(saveOpts.path, saveOpts.fileName + o_file.type) - const nodeBuffer = await o_file.arrayBuffer() - const extension = path.extname(o_file.name) - await fs.writeFile(`${location}.${extension}`, Buffer.from(nodeBuffer)) + await fs.writeFile(location, Buffer.from(nodeBuffer)) return { location } } async remove(filePath: string) { diff --git a/packages/uploads/src/MemoryStorage.ts b/packages/uploads/src/MemoryStorage.ts new file mode 100644 index 000000000000..1e6d4bd3e3ee --- /dev/null +++ b/packages/uploads/src/MemoryStorage.ts @@ -0,0 +1,42 @@ +import path from 'node:path' + +import mime from 'mime-types' +import { ulid } from 'ulid' + +import type { AdapterOptions, StorageAdapter } from './StorageAdapter.js' +import type { SaveOptionsOverride } from './StorageAdapter.js' + +export class MemoryStorage implements StorageAdapter { + constructor(adapterOpts: AdapterOptions) { + this.adapterOpts = adapterOpts + } + + adapterOpts: AdapterOptions + store: Record = {} + + async save(file: File, saveOpts?: SaveOptionsOverride) { + const randomFileName = ulid() + const extension = mime.extension(file.type) ? `.${mime.extension(file.type)}` : '' + const location = path.join(saveOpts?.path || this.adapterOpts.baseDir, saveOpts?.fileName || randomFileName + `${extension}`) + const nodeBuffer = await file.arrayBuffer() + + const result = `${location}` + this.store[result] = Buffer.from(nodeBuffer) + + return { + location: result + } + } + async remove(filePath: string) { + delete this.store[filePath] + } + +// Not sure about read method... should it be in the base class? + async read(filePath: string) { + return this.store[filePath] + } + + async clear() { + this.store = {} + } +} diff --git a/packages/uploads/src/StorageAdapter.ts b/packages/uploads/src/StorageAdapter.ts index ecc0f28ef58a..b1871ec314b4 100644 --- a/packages/uploads/src/StorageAdapter.ts +++ b/packages/uploads/src/StorageAdapter.ts @@ -10,13 +10,22 @@ export type AdapterResult = { location: string } -export type SaveOptions = { - fileName: string - path: string +export type SaveOptionsOverride = { + fileName?: string + path?: string +} + +export type AdapterOptions = { + baseDir: string } export abstract class StorageAdapter { - abstract save(file: File, saveOpts?: SaveOptions): Promise + adapterOpts: AdapterOptions + constructor(adapterOpts: AdapterOptions){ + this.adapterOpts = adapterOpts + } + + abstract save(file: File, saveOpts?: SaveOptionsOverride): Promise abstract remove(fileLocation: AdapterResult['location']): Promise // abstract replace(fileId: string, file: File): Promise } diff --git a/packages/uploads/src/__tests__/createProcessors.test.ts b/packages/uploads/src/__tests__/createProcessors.test.ts new file mode 100644 index 000000000000..dc9acbd578db --- /dev/null +++ b/packages/uploads/src/__tests__/createProcessors.test.ts @@ -0,0 +1,101 @@ + +import { describe, it, expect } from 'vitest' + +import { createUploadProcessors } from '../createProcessors.js' +import { MemoryStorage } from '../MemoryStorage.js' + +const memStore = new MemoryStorage({ + baseDir: '/memory_store_basedir' +}) + +const uploadsConfig = { + dumbo: { + fields: ['firstUpload', 'secondUpload'], + }, + dummy: { + fields: 'uploadField', + }, +} + +describe('Create processors', () => { + const processors = createUploadProcessors(memStore, uploadsConfig) + + it('should create processors with CapitalCased model name', () => { + expect(processors.processDumboUploads).toBeDefined() + expect(processors.processDummyUploads).toBeDefined() + }) + + it('Should replace file types with location strings', async() => { + const data = { + firstUpload: new File(['Meaow'], 'kitten.txt', { + type: 'text/plain', + }), + secondUpload: new File(['Woof'], 'puppy.txt', { + type: 'text/plain', + }), + } + + const result = await processors.processDumboUploads(data) + + expect(result.firstUpload).toMatch(/\/memory_store_basedir\/.*\.txt/) + expect(result.secondUpload).toMatch(/\/memory_store_basedir\/.*\.txt/) + + const firstContents = await memStore.read(result.firstUpload) + expect(firstContents.toString()).toBe('Meaow') + + const secondContents = await memStore.read(result.secondUpload) + expect(secondContents.toString()).toBe('Woof') + }) + + it('Should be able to override save options', async() => { + const data = { + uploadField: new File(['Hello'], 'hello.png', { + type: 'image/png', + }), + } + + const fileNameOverrideOnly = await processors.processDummyUploads(data, { + fileName: 'overridden.txt', + }) + + const pathOverrideOnly = await processors.processDummyUploads(data, { + path: '/bazinga', +}) + +const bothOverride = await processors.processDummyUploads(data, { + path: '/bazinga', + fileName: 'overridden.png', +}) + + expect(fileNameOverrideOnly.uploadField).toBe('/memory_store_basedir/overridden.txt') // 👈 overrode the extension too + + expect(pathOverrideOnly.uploadField).toMatch(/\/bazinga\/.*\.png/) + // Overriding path ignores the baseDir + expect(pathOverrideOnly.uploadField).not.toContain('memory_store_basedir') + + + expect(bothOverride.uploadField).toBe('/bazinga/overridden.png') +}) + +it('Should not add extension for unknown file type', async() => { + const data = { + uploadField: new File(['Hello'], 'hello', { + type: 'bazinga/unknown', + }), + } + + const noOverride = await processors.processDummyUploads(data) + + // No extension + expect(noOverride.uploadField).toMatch(/\/memory_store_basedir\/.*[^.]+$/); + + const withOverride = await processors.processDummyUploads(data, { + fileName: 'hello', + }) + expect(withOverride.uploadField).toMatch(/[^.]+$/); + expect(withOverride.uploadField).toBe('/memory_store_basedir/hello') + +}) + + +}) \ No newline at end of file diff --git a/packages/uploads/src/__tests__/prismaExtension.test.ts b/packages/uploads/src/__tests__/prismaExtension.test.ts index 7b7689ae69a2..24919f5a106a 100644 --- a/packages/uploads/src/__tests__/prismaExtension.test.ts +++ b/packages/uploads/src/__tests__/prismaExtension.test.ts @@ -33,14 +33,10 @@ vol.fromJSON({ describe('Uploads Prisma Extension', () => { const dummyUploadConfig = { fields: 'uploadField', - savePath: '/bazinga', - onFileSaved: vi.fn(), } const dumboUploadConfig = { fields: ['firstUpload', 'secondUpload'], - savePath: '/dumbo', - onFileSaved: vi.fn(), } const prismaClient = new PrismaClient().$extends( @@ -49,7 +45,10 @@ describe('Uploads Prisma Extension', () => { dummy: dummyUploadConfig, dumbo: dumboUploadConfig, }, - new FileSystemStorage(), + // Test with file system storage, and mock the fs calls + new FileSystemStorage({ + baseDir: '/tmp', + }), ), ) @@ -173,97 +172,6 @@ describe('Uploads Prisma Extension', () => { }) }) - it('will move file to new location with TUS uploads', async () => { - // Mock TUS metadata files - vi.mock('/tmp/tus-uploads/123.json', () => { - return { - metadata: { - filetype: 'image/png', - }, - } - }) - - vi.mock('/tmp/tus-uploads/ABCD.json', () => { - return { - metadata: { - filetype: 'application/pdf', - }, - } - }) - - const dumbo = await prismaClient.dumbo.create({ - data: { - firstUpload: - 'http://example.com/.redwood/functions/tusUploadEndpoint/123', - secondUpload: - 'http://example.com/.redwood/functions/tusUploadEndpoint/ABCD', - }, - }) - - expect(fs.copyFile).toHaveBeenCalledTimes(2) - expect(dumbo.firstUpload).toMatch(/dumbo\/.*\.png/) - expect(dumbo.secondUpload).toMatch(/dumbo\/.*\.pdf/) - }) - - it('will remove old file when updating with TUS uploads', async () => { - // Mock TUS metadata files - vi.mock('/tmp/tus-uploads/512356.json', () => { - return { - metadata: { - filetype: 'image/gif', - }, - } - }) - - const dumbo = await prismaClient.dumbo.create({ - data: { - firstUpload: - 'http://example.com/.redwood/functions/tusUploadEndpoint/123', - secondUpload: '', - }, - }) - - const originalPath = dumbo.firstUpload - - const dumbo2 = await prismaClient.dumbo.update({ - where: { id: dumbo.id }, - data: { - firstUpload: - 'http://example.com/.redwood/functions/tusUploadEndpoint/512356', - }, - }) - - expect(dumbo2.firstUpload).not.toEqual(originalPath) - expect(dumbo2.firstUpload).toMatch(/dumbo\/.*\.gif/) - - // And deletes it! - expect(fs.unlink).toHaveBeenCalledWith(originalPath) - }) - - it('will remove file when deleting with TUS uploads', async () => { - // Mock TUS metadata files - vi.mock('/tmp/tus-uploads/512356.json', () => { - return { - metadata: { - filetype: 'image/gif', - }, - } - }) - - const dummy = await prismaClient.dummy.create({ - data: { - uploadField: - 'http://example.com/.redwood/functions/tusUploadEndpoint/123', - }, - }) - - await prismaClient.dummy.delete({ - where: { id: dummy.id }, - }) - - expect(fs.unlink).toHaveBeenCalledWith(dummy.uploadField) - }) - }) describe('Result extensions', () => { it('will return a data URL for the file', async () => { diff --git a/packages/uploads/src/createProcessors.ts b/packages/uploads/src/createProcessors.ts new file mode 100644 index 000000000000..bcfe78ebc3fc --- /dev/null +++ b/packages/uploads/src/createProcessors.ts @@ -0,0 +1,49 @@ +import type { UploadsConfig } from "./prismaExtension.js" +import type { SaveOptionsOverride, StorageAdapter } from "./StorageAdapter.js" + +// Assumes you pass in the graphql type +type MakeFilesString = { + [K in keyof T]: T[K] extends File ? string : T[K] +} + +export const createUploadProcessors = ( + storage: StorageAdapter, + uploadConfig: UploadsConfig +) => { + type Processors = { + [K in keyof typeof uploadConfig]: >(data: T, overrideSaveOptions?: SaveOptionsOverride) => Promise> + } + + // @TODO TS: how do I get make it process${keyof UploadsConfig}Uploads so it autocompletes? + const processors: Processors = {} + + Object.keys(uploadConfig).forEach((model) => { + const currentModelUploadFields = Array.isArray(uploadConfig[model].fields) ? uploadConfig[model].fields : [uploadConfig[model].fields] + + const capitalCaseModel = `${model.charAt(0).toUpperCase() + model.slice(1)}` + processors[`process${capitalCaseModel}Uploads`] = async( + data, + overrideSaveOptions + ) => { + const updatedFields = {} as Record + for await (const field of currentModelUploadFields) { + if (data[field]) { + // @TODO deal with file lists + const file = data[field] as File + + // @TODO: should we automatically create a directory for each model? + // you can always override it in the saveOpts + const { location } = await storage.save(file, overrideSaveOptions) + + updatedFields[field] = location + } + } + return { + ...data, + ...updatedFields + } + } + }) + + return processors +} diff --git a/packages/uploads/src/prismaExtension.ts b/packages/uploads/src/prismaExtension.ts index aa0248c9b408..3dbd04cbfe12 100644 --- a/packages/uploads/src/prismaExtension.ts +++ b/packages/uploads/src/prismaExtension.ts @@ -22,7 +22,7 @@ export type UploadConfigForModel = { onFileSaved?: (filePath: string) => void | Promise } -export type UploadsConfig = Record< +export type UploadsConfig = Record< MName, UploadConfigForModel > From 4e6f56becdcc142383bd1a17db6ac8a12d3696c8 Mon Sep 17 00:00:00 2001 From: Danny Choudhury Date: Thu, 15 Aug 2024 18:35:25 +0700 Subject: [PATCH 40/91] Remove outdated tests --- packages/uploads/src/__tests__/fileMocks.js | 2 - .../src/__tests__/prismaExtension.test.ts | 134 +----------------- 2 files changed, 3 insertions(+), 133 deletions(-) delete mode 100644 packages/uploads/src/__tests__/fileMocks.js diff --git a/packages/uploads/src/__tests__/fileMocks.js b/packages/uploads/src/__tests__/fileMocks.js deleted file mode 100644 index 469745009c0b..000000000000 --- a/packages/uploads/src/__tests__/fileMocks.js +++ /dev/null @@ -1,2 +0,0 @@ -export const dataUrlPng = - 'data:image/png;base64,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d78TDUS6RADRSfCKAAIIIIAAAggggAACCCCAAAIIIIBAmguUlpbaI9iwYYOcc845smXLFtlrr72kU6dONjioCzXPySefLLfffrtdrvN69+6tLzZpc+kBAwZIQUGBfU8AcRcMLwgggAACCCCAAAIIIIAAAggggAACCNQUAQ0ctmzZUsaNGyebN2+WBQsWyBVXXGEPb+TIkfLRRx/Z6VtuuUWKiork22+/lR07dsgDDzxg58+cOVMefPBBO52xdWthwE6V87/8vAJp1bpJOXOTDQEEEEAAAQQQQAABBBBAAAEEEEAAAQSSEdCRl2fNmmUDgW3atIm6CQ3+ufTmm2/KsGHD7NvRo0fLGWec4RbZV22a3K5dO1m7dq2cf/758uqrrwYt1zcaVHzooYdszcXFixcLg6iEETEDAQQQQAABBBBAAAEEEEAAAQQQQACB6iOgwT79Fy1pf4YZGRlhi7WZcmhatGiRt61bb701dLF9f/PNN9sAotZi1AAmAcSITMxEAAEEEEAAAQQQQAABBBBAAAEEEECgegjoICjXXntt1MJECh5qv4e5ublh68ybN8+bpzUcIyWt7ah9Ji5cuFDmzp1LADESEvMQQAABBBBAAAEEEEAAAQQQQAABBBCoLgK9evWSs846KyXFWbp0qd3OvvvuG3N72n+iBhCXLVsmDKISk4qFCCCAAAIIIIAAAggggAACCCCAAAII1ByBDh062INZsmSJaNPnaGnVqlV2keYngBhNifkIIIAAAggggAACCCCAAAIIIIAAAgjUMIHu3bt7R/TVV1950/4JraWoAUZN3bp1I4Dox2EaAQQQQAABBBBAAAEEEEAAAQQQQACBmizQsWNHcc2X77///oiH+uCDD9r52o+i9pNIDcSITMxEAAEEEEAAAQQQQAABBBBAAAEEEECg5glkZWXJvffeaw/srbfekquvvlp0tGVNeXl5cscdd8gTTzxh399+++3SuHFjAohWg/8QQAABBBBAAAEEEEAAAQQQQAABBBCoJQIXXXSRDBs2zB7tM888I02aNJF99tlHGjZs6AUXjz32WLnppptsHmog1pIPBoeJAAIIIIAAAggggAACCCCAAAIIIJBeAjk5ObbA2dnZCRe8Tp06UdfJyMiQUaNGyZNPPilt27a1+VauXGlfdfTlP//5zzJhwgTJzc218zK2bi2MPtxKhN3k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gAACCCCAAAK1RIAAYi050RwmAggggAACCCCAAAIIIIAAAggggAACyQgQQExGjXUQQAABBBBAAAEEEEAAAQQQQAABBBCoJQIEEGvJieYwEUAAAQQQQAABBBBAAAEEEEAAAQQQSEaAAGIyaqyDAAIIIIAAAggggAACCCCAAAIIIIBALREggFhLTjSHiQACCCCAAAIIIIAAAggggAACCCCAQDICBBCTUWMdBBBAAAEEEEAAAQQQQAABBBBAAAEEaokAAcRacqI5TAQQQAABBBBAAAEEEEAAAQQQQAABBJIRIICYjBrrIIAAAggggAACCCCAAAIIIIAAAgggUEsECCDWkhPNYSKAAAIIIIAAAggggAACCCCAAAIIIJCMAAHEZNRYBwEEEEAAAQQQQAABBBBAAAEEEEAAgVoiQACxlpxoDhMBBBBAAAEEEEAAAQQQQAABBBBAAIFkBAggJqPGOggggAACCCCAAAIIIIAAAggggAACCNQSAQKIteREc5gIIIAAAggggAACCCCAAAIIIIAAAggkI0AAMRk11kEAAQQQQAABBBBAAAEEEEAAAQQQQKCWCBBArCUnmsNEAAEEEEAAAQQQQAABBBBAAAEEEEAgGQECiMmosQ4CCCCAAAIIIIAAAggggAACCCCAAAK1RIAAYi050RwmAggggAACCCCAAAIIIIAAAggggAACyQgQQExGjXUQQAABBBBAAAEEEEAAAQQQQAABBBCoJQIEEGvJieYwEUAAAQQQQAABBBBAAAEEEEAAAQQQSEaAAGIyaqyDAAIIIIAAAggggAACCCCAAAIIIIBALREggFhLTjSHiQACCCCAAAIIIIAAAggggAACCCCAQDICBBCTUWMdBBBAAAEEEEAAAQQQQAABBBBAAAEEaokAAcRacqI5TAQQQAABBBBAAAEEEEAAAQQQQAABBJIRyE5mpXRZp6ioSP7+97/b4vbr10+OPfbYPVb0GTNmyGeffSY//vijbN68WTp37iw9evSQs88+Wxo0aLBHyrV69Wr55JNPZPLkybZMRx55pAwaNMiWK16Bli5dKq+99poEAgE55phjpH///vFWibq8Op2nqIX0LdBjf+utt+T777+X7du3y0EHHSTHH3+8HHHEEb5c4ZOlpaUydepU+eKLL2TmzJnSqVMnOeqoo2Tw4MFxPwPJ7jO0FD/88IO89957dvbB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EEAAAQQQQAABBBBAAAEEEEAAgVoiQACxlpxoDhMBBBBAAAEEEEAAAQQQQAABBBBAAIFkBAggJqPGOggggAACCCCAAAIIIIAAAggggAACCNQSgeyaeJylpaUydepU+eKLL2TmzJnSqVMnOeqoo2Tw4MHSoEGDuIf82muvyZIlS6Ru3bpy0003xc0fK8PEiRNtGTTPzTffLDk5ObGy79FlgUBARo8eLdOnT5d58+ZJ27ZtpWfPnnLRRRfFdVu9erV88sknMnnyZNm8ebMceeSRMmjQIOnRo0fMY6rIPkM3/Mwzz8imTZvs7BtuuEHq168fmoX3COxxgZEjR8ratWvDypGVlSX777+//Zvp2rVr2PKqnjFhwgT5+uuvpXPnznL22WdXePfRjjt0w23atJFLL700dDbvKyDwwgsvyKpVq+Too4+WI444IuEt6XfqBx98IN98843MnTtX9H23bt2kT58+MnToUMnMDH8WWVhYKI899pjd1xVXXCEtWrRIeL+sUHMEnn/+edHfCX379pUhQ4ZEPDD9fI0fP94uO/3006V79+4R873//vsyZ84cadmypVx++eXy008/yf+zdybw2lVj/18pGhGVoiKiEkKZkl4SypAMyRT+ppAxRJpFMhVSMssYGTJThswyE0KGzDJGMob7f32X91rvda977fvse5/zPOc85/zW53POvffaa9rfvfcarnWta+21117pb3/7WzrjjDPSbW9722a8peBJ3/Lcc89NX/nKV9IPf/jDtM022+Rvae+9905bb711ZxG76s8+7Ubsh8YMPO4NbnCDdN3rXrf5HcfwOhYBERhOoOs7bKX4uMc9bs5xVyue/ERABERgtRC45JK/j2b5u+hXF5vMZ+m6f/7zn6P73Oc+I4M38Wed0dFPf/rTzsJbx25kHduxeJ2Be1644Q1vWNIz4VzPWKs/mHVkR7e+9a1LWSM/EySOzjrrrM5CffKTnxxd+cpXbsZ93vOe1xlvPnnWib7zne8cy//Xv/51HUTnIrAkCNz4xjcee1fjt+bHd73rXUcXXnjhopbXOrC5nPe4xz0WpBx97pv7v9WtbrUg+SmR/yNw85vfPD/LZz3rWf/n2fPIBMmjac+ONu7DH/7wRGq/+93vynv+9a9/feK6PFYWgbvd7W75fTAhdueNP/zhDy/vzNOe9rTOcN6veshDHpLDnHDC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VERABERABERABERgyRDYc88908EHH5zMyHuyzXnSrrvu2iyb7ZCbdtttt+Y1eYqACIiACIiACIiACIjASiYgDcQl9PSxhYONia222moJlUpFEQEREAEREIE1nwCb/7z//e/PG6fEu8HGJVqJbKJz9NFHx0s6FgEREAEREIE1hgC2iRlL8req7RSvMVBUUBEQgQUlIBuIC4pTiYmACIiACIiACKwJBLAVxQBrww03XBOKqzKKgAiIgAiIgAiIgAiIwKIS0BLmRcWvzEVABERABERABBaDQN+dvBejbMpTBERABERABERABERABJYaAS1hXmpPROURAREQAREQAREQAREQAREQAREQAREQAREQgSVEQALEJfQwVBQREAEREAEREAEREAEREAEREAEREAEREAERWGoEJEBcak9E5REBERABERABERABERABERABERABERABERCBJURAAsQl9DBUFBEQAREQAREQAREQAREQAREQAREQAREQARFYagQkQFxqT0TlEQEREAEREAEREAEREAEREAEREAEREAEREIElREACxCX0MFQUERABERABERABERABERABERABERABERABEVhqBCRAXGpPROURAREQAREQAREQAREQAREQAREQAREQAREQgSVEQALEJfQwVBQREAEREAEREAEREAEREAEREAEREAEREAERWGoEJEBcak9E5REBERABERABERABERABERABERABERABERCBJURAAsQl9DBUFBEQAREQAREQAREQAREQAREQAREQAREQARFYagQkQFxqT0TlEQEREAEREAEREAEREAEREAEREAEREAEREIElREACxCX0MFQUERABERABERABERABERABERABERABERABEVhqBCRAXGpPROURAREQAREQAREQAREQAREQAREQAREQAREQgSVEQALEJfQwVBQREAEREAEREAEREAEREAEREAEREAEREAERWGoEJEBcak9E5REBERABERABERABERABERABERABERABERCBJURAAsQl9DBUFBEQAREQAREQAREQAREQAREQAREQAREQARFYagQkQFxqT0TlEQEREAEREAEREAEREAEREAEREAEREAEREIElREACxCX0MFQUERABERABERABERABERABERABERABERABEVhqBNYZUqBfX/THIdEURwREQAREQAREQAREQAREQAREQAREQAREQAREYA0jMEiAuPkWG69ht6niioAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIDCGgJcxDqCmOCIiACIiACIiACIiACIiACIiACIiACIiACKwQAhIgrpAHrdsUAREQAREQAREQAREQAREQAREQAREQAREQgSEEJEAcQk1xREAEREAEREAEREAEREAEREAEREAEREAERGCFEJAAcYU8aN2mCIiACIiACIiACIiACIiACIiACIiACIiACAwhIAHiEGqKIwIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIrhIAEiCvkQes2RUAEREAEREAEREAEREAEREAEREAEREAERGAIAQkQh1BTHBEQAREQAREQAREQAREQAREQAREQAREQARFYIQQkQFwhD1q3KQIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAJDCEiAOISa4oiACIiACIiACIiACIiACIiACIiACIiACIjACiEgAeIKedC6TREQAREQAREQAREQAREQAREQAREQAREQAREYQkACxCHUFEcEREAEREAEREAEREAEREAEREAEREAEREAEVggBCRBXyIPWbYqACIiACIiACIiACIiACIiACIiACIiACIjAEAISIA6hpjgiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIisEIISIC4Qh60blMEREAEREAEREAEREAEREAEREAEREAEREAEhhCQAHEINcURAREQAREQAREQAREQAREQAREQAREQAREQgRVCQALEFfKgdZsiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiMISABIhDqCmOCIiACIiACIiACIiACIiACIiACIiACIiACKwQAhIgrpAHrdsUAREQAREQAREQAREQAREQAREQAREQAREQgSEEJEAcQk1xREAEREAEREAEREAEREAEREAEREAEREAERGCFEJAAcYU8aN2mCIiACIiACIiACIiACIiACIiACIiACIiACAwhIAHiEGqKIwIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIrhIAEiCvkQes2RUAEREAEREAEREAEREAEREAEREAEREAERGAIAQkQh1BTHBEQAREQAREQAREQAREQAREQAREQAREQARFYIQTWWY73+Z///Cd9/vOfT5/5zGfSl7/85XSd61wn7b777mmPPfZIG2644dRb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wrzmPgKVXAREQAREQAREYFUSsOWfyWwcZg1DW6Kcd46+7LLLskarLYdKZiuq7I7tuwSvyvIobREQAREQAREQAREQARFY0wiss6YVWOUVAREQAREQAREQgVkI7Lnnnunggw9OZiMx2eY8adddd21GP+aYY9Juu+3WvCZPERABERABERABERABEVjJBKSBuISfPrYXsfu01VZbLeFSqmgiIAIiIAIisPQJnHjiien9739/qjUMzWB7QivRNqFItknX0r8RlVAEREAEREAEGgSwTczYkb9Vbae4kb28REAEVgAB2UBcAQ9ZtygCIiACIiACIjBOAFtRDLA23HDD8Qs6EwEREAEREAEREAEREAERmCCgJcwTSOQhAiIgAiIgAiKw3An03cl7uXPQ/YmACIiACIiACIiACIhAHwJawtyHksKIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIwAolIAHiCn3wum0REAEREAEREAEREAEREAEREAEREAEREAER6ENAAsQ+lBRGBERABERABERABERABERABERABERABERABFYoAQkQV+iD122LgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIQB8CEiD2oaQwIiACIiACIiACIiACIiACIiACIiACIiACIrBCCUiAuEIfvG5bBERABERABERABERABERABERABERABERABPoQkACxDyWFEQEREAEREAEREAEREAEREAEREAEREAEREIEVSkACxBX64HXbIiACIiACIiACIiACIiACIiACIiACIiACItCHgASIfSgpjAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAisUAISIK7QB6/bFgEREAEREAEREAEREAEREAEREAEREAEREIE+BCRA7ENJYURABERABERABERABERABERABERABERABERghRKQAHGFPnjdtgiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAj0ISABYh9KCiMCIiACIiACIiACIiACIiACIiACIiACIiACK5SABIgr9MHrtkVABERABERABERABERABERABERABERABESgDwEJEPtQUhgREAEREAEREAEREAEREAEREAEREAEREAERWKEEJEBcoQ9ety0CIiACIiACIiACIiACIiACIiACIiACIiACfQhIgNiHksKIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIwAolIAHiCn3wum0REAEREAEREAEREAEREAEREAEREAEREAER6ENgnT6B6jC/vuiPtZfORUAEREAEREAEREAEREAEREAEREAEREAEREAEliGBQQLEzbfYeBmi0C2JgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAjUBLSEuSaicxEQAREQAREQAREQAREQAREQAREQAREQAREQgUJAAsSCQgciIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAI1AQkQayI6FwEREAEREAEREAEREAEREAEREAEREAEREAERKAQkQCwodCACIiACIiACIiACIiACIiACIiACIiACIiACIlATkACxJqJzERABERABERABERABERABERABERABERABERCBQkACxIJCByIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAjUBCRBrIjoXAREQAREQAREQAREQAREQAREQAREQAREQAREoBCRALCh0IAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiUBOQALEmonMREAEREAEREAEREAEREAEREAEREAEREAEREIFCQALEgkIHIiACIiACIiACIiACIiACIiACIiACIiACIiACNQEJEGsiOhcBERABERABERABERABERABERABERABERABESgEJEAsKHQgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiJQE5AAsSaicxEQAREQAREQAREQAREQAREQAREQAREQAREQgUJAAsSCQgciIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAI1AQkQayI6FwEREAEREAEREAEREAEREAEREAEREAEREAERKAQkQCwodCACIiACIiACIiACIiACIiACIiACIiACIiACIlATkACxJqJzERABERABERABERABERABERABERABERABERCBQkACxIJCByIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAiIgAjUBCRBrIjoXAREQAREQAREQAREQAREQAREQAREQAREQAREoBCRALCh0IAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiIAIiUBOQALEmonMREAEREAEREAEREAEREAEREAEREAEREAEREIFCQALEgkIHIiACIiACIiACIiACIiACIiACIiACIiACIiACNQEJEGsiOhcBERABERABERABERABERABERABERABERABESgE1ilHy+jgP//5T/r85z+fPvOZz6Qvf/nL6TrXuU7afffd0x577JE23HDDqXd62WWXpbe97W3pG9/4RvrRj36Urn71q6cb3ehG6X73u1+68pWvPDVun4sf+9iZ4n6vAABAAElEQVTHcpkI++QnPzld/vKX7xNtUcKMRqP07ne/O33xi19MF1xwQbrGNa6RWTzwgQ+ck+Nf/vKX9MlPfjJ9+tOfTt///vfTzjvvnJ/Bbrvtli53uely63PPPTd99KMfTd/+9rfTuuuum3baaad0r3vdK22zzTYzcXjlK1+ZLr744hzn8Y9/fNpggw1miq/AIrAqCLzuda9Lv/nNbyaSXnvttdO2226bdtxxx7T99ttPXF/dHh/5yEfSV7/61XS9610vf3/zzb/rvut0qXMf/OAH1946nweBN7zhDelXv/pV+p//+Z+06667zpwSbeoHP/jB9LWvfS1997vfTZzvsMMO6SY3uUnaZ599mnX63//+9/SSl7wk5/Wwhz0sbbbZZjPnqwjLl8BPfvKTdOaZZ+b+wS9/+cu09dZb53dq//33T5tuuumSuvHXvOY16Xe/+13ae++9041vfONctu9973tpr732Sn/729/SGWeckW5729suqTKrMCIgAkuLQBz/zVWyxz3ucXOOs+ZKQ9dFQAREYJURuOSSv49m+bvoVxebXGnpun/+85+j+9znPiMDNvF3/etff/TTn/60s/AmtBqZsHEiHmld7WpXG73//e/vjNv3wg1veMOSvgnn+kZb7eF++MMfjm5961uXskaeJkgcnXXWWZ1l+vnPfz6K9xnjmiBw9I9//KMZ969//evooQ99aDNP0njBC17QjNfyfOc73zmWzq9//etWMPmJwGonYAPQsXczfh9+fNe73nV04YUXrvayxQytA5vLeY973CN6Dz7uc9/c/61udavBeShim8DNb37z/Cyf9axntQNM8TVB8mjas6Ou//CHPzyRgglcynv+9a9/feK6PFYmAfpoXrd4fRd/baJ29OxnP7sJx4SOIxuEj770pS81r68qT+/PvOhFLypZnHDCCeX9Puigg4q/DkRABESgReCJT3xiqTNindc6vuiii1pJyE8EREAElgSBZaWBaESzNoQJt6w+TskG4VnrDU1CtNG+853v5HM0E7faaqscxv+dd9556d73vnf605/+lL3Q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' diff --git a/packages/uploads/src/__tests__/prismaExtension.test.ts b/packages/uploads/src/__tests__/prismaExtension.test.ts index 24919f5a106a..c93e8926b638 100644 --- a/packages/uploads/src/__tests__/prismaExtension.test.ts +++ b/packages/uploads/src/__tests__/prismaExtension.test.ts @@ -1,12 +1,10 @@ -import fs from 'node:fs/promises' import { vol } from 'memfs' -import { describe, it, vi, expect } from 'vitest' +import { describe, it, vi } from 'vitest' import { FileSystemStorage } from '../FileSystemStorage.js' import { createUploadsExtension } from '../prismaExtension.js' -import { dataUrlPng } from './fileMocks.js' // @MARK: use the local prisma client import { PrismaClient } from './prisma-client' @@ -53,138 +51,12 @@ describe('Uploads Prisma Extension', () => { ) describe('Query extensions', () => { - it.only('will create a file with base64 encoded png', async () => { - const file = new File(['hello'], 'hello.txt', { - type: 'text/plain', - }) - console.log(`👉 \n ~ file:`, file) - console.log(`👉 \n ~ file arraybuf:`, await file.arrayBuffer()) - - const dum1 = await prismaClient.dummy.create({ - data: { - uploadField: file, - }, - }) - - expect(dummyUploadConfig.onFileSaved).toHaveBeenCalled() - expect(dum1.uploadField).toMatch(/bazinga\/.*\.png/) - expect(fs.writeFile).toHaveBeenCalledWith( - expect.stringMatching(/bazinga\/.*\.png/), - expect.anything(), // no need to check content here, makes test slow - ) - }) - - it('handles multiple upload fields', async () => { - const dumbo = await prismaClient.dumbo.create({ - data: { - firstUpload: dataUrlPng, - secondUpload: dataUrlPng, - }, - }) - - expect(dumbo.firstUpload).toMatch(/dumbo\/.*\.png/) - expect(dumbo.secondUpload).toMatch(/dumbo\/.*\.png/) - - expect(dumboUploadConfig.onFileSaved).toHaveBeenCalledTimes(2) - }) - - it('handles empty fields', async () => { - const emptyUploadFieldPromise = prismaClient.dummy.create({ - data: { - uploadField: '', - }, - }) - - await expect(emptyUploadFieldPromise).resolves.not.toThrow() - }) - - it('handles updates, and removes old files', async () => { - const dum1 = await prismaClient.dummy.create({ - data: { - uploadField: dataUrlPng, - }, - }) - - const originalPath = dum1.uploadField - - const dum2 = await prismaClient.dummy.update({ - where: { id: dum1.id }, - data: { - uploadField: dataUrlPng, - }, - }) - - expect(dum2.uploadField).not.toEqual(originalPath) - expect(dum2.uploadField).toMatch(/bazinga\/.*\.png/) - expect(fs.unlink).toHaveBeenCalledWith(originalPath) - }) - - it('handles deletes, and removes files', async () => { - const dum1 = await prismaClient.dummy.create({ - data: { - uploadField: dataUrlPng, - }, - }) - - await prismaClient.dummy.delete({ - where: { id: dum1.id }, - }) - - expect(fs.unlink).toHaveBeenCalledWith(dum1.uploadField) - }) - - it('supports custom file name and save path functions', async () => { - const customNameConfig = { - fields: 'firstUpload', - savePath: '/custom', - onFileSaved: vi.fn(), - fileName: (args) => { - // 👇 Using args here - return `my-name-is-dumbo-${args.data.id}` - }, - } - - const clientWithFileName = new PrismaClient().$extends( - createUploadsExtension( - { - dumbo: customNameConfig, - }, - new FileSystemStorage(), - ), - ) - - const dumbo = await clientWithFileName.dumbo.create({ - data: { - firstUpload: dataUrlPng, - secondUpload: '', - id: 55, - }, - }) - - expect(customNameConfig.onFileSaved).toHaveBeenCalled() - expect(dumbo.firstUpload).toBe('/custom/my-name-is-dumbo-55.png') - - // Delete it to clean up - await clientWithFileName.dumbo.delete({ - where: { - id: 55, - }, - }) - }) + it('not implemented yet after refactor') + }) describe('Result extensions', () => { it('will return a data URL for the file', async () => { - const res1 = await ( - await prismaClient.dummy.create({ - data: { - uploadField: dataUrlPng, - }, - }) - ).withDataUri() - - // Mocked in FS mocks - expect(res1.uploadField).toBe('data:image/png;base64,BASE64_FILE_CONTENT') }) // @TODO Handle edge cases (file removed, data modified, etc.) From 808a023f0d399ee638dc4a85a02e39d00fdf8e57 Mon Sep 17 00:00:00 2001 From: Danny Choudhury Date: Fri, 16 Aug 2024 12:42:37 +0700 Subject: [PATCH 41/91] Try setting prisma override --- packages/uploads/prisma-override.d.ts | 9 +++ .../src/__tests__/createProcessors.test.ts | 73 ++++++++++--------- .../src/__tests__/prismaExtension.test.ts | 2 +- .../src/__tests__/unit-test-schema.prisma | 5 ++ packages/uploads/src/createProcessors.ts | 29 +++++--- packages/uploads/src/prismaExtension.ts | 10 ++- packages/uploads/tsconfig.json | 5 +- 7 files changed, 81 insertions(+), 52 deletions(-) create mode 100644 packages/uploads/prisma-override.d.ts diff --git a/packages/uploads/prisma-override.d.ts b/packages/uploads/prisma-override.d.ts new file mode 100644 index 000000000000..ec14d355db25 --- /dev/null +++ b/packages/uploads/prisma-override.d.ts @@ -0,0 +1,9 @@ +// Locally, within this project we override the type for @prisma/client to the one we generate locally +// This is so that we get accurate types (rather than the default anys) - and when the prismaExtension runs +// it will still use the types from '@prisma/client' which points to the user's prisma client and not ours + +import { PrismaClient as LocalPrismaClient } from './src/__tests__/prisma-client/index.d.ts' + +declare module '@prisma/client' { + export class PrismaClient extends LocalPrismaClient {} +} diff --git a/packages/uploads/src/__tests__/createProcessors.test.ts b/packages/uploads/src/__tests__/createProcessors.test.ts index dc9acbd578db..d7e2c018644e 100644 --- a/packages/uploads/src/__tests__/createProcessors.test.ts +++ b/packages/uploads/src/__tests__/createProcessors.test.ts @@ -3,12 +3,15 @@ import { describe, it, expect } from 'vitest' import { createUploadProcessors } from '../createProcessors.js' import { MemoryStorage } from '../MemoryStorage.js' +import type { UploadsConfig } from '../prismaExtension.js' const memStore = new MemoryStorage({ - baseDir: '/memory_store_basedir' + baseDir: '/memory_store_basedir', }) -const uploadsConfig = { +// @TODO(TS): How can I make this accept not all model names? +// This is error-ing out because it wants all the models in my prisma client +const uploadsConfig: UploadsConfig = { dumbo: { fields: ['firstUpload', 'secondUpload'], }, @@ -25,7 +28,7 @@ describe('Create processors', () => { expect(processors.processDummyUploads).toBeDefined() }) - it('Should replace file types with location strings', async() => { + it('Should replace file types with location strings', async () => { const data = { firstUpload: new File(['Meaow'], 'kitten.txt', { type: 'text/plain', @@ -47,7 +50,7 @@ describe('Create processors', () => { expect(secondContents.toString()).toBe('Woof') }) - it('Should be able to override save options', async() => { + it('Should be able to override save options', async () => { const data = { uploadField: new File(['Hello'], 'hello.png', { type: 'image/png', @@ -56,46 +59,44 @@ describe('Create processors', () => { const fileNameOverrideOnly = await processors.processDummyUploads(data, { fileName: 'overridden.txt', - }) - - const pathOverrideOnly = await processors.processDummyUploads(data, { - path: '/bazinga', -}) + }) -const bothOverride = await processors.processDummyUploads(data, { - path: '/bazinga', - fileName: 'overridden.png', -}) + const pathOverrideOnly = await processors.processDummyUploads(data, { + path: '/bazinga', + }) - expect(fileNameOverrideOnly.uploadField).toBe('/memory_store_basedir/overridden.txt') // 👈 overrode the extension too + const bothOverride = await processors.processDummyUploads(data, { + path: '/bazinga', + fileName: 'overridden.png', + }) - expect(pathOverrideOnly.uploadField).toMatch(/\/bazinga\/.*\.png/) - // Overriding path ignores the baseDir - expect(pathOverrideOnly.uploadField).not.toContain('memory_store_basedir') + expect(fileNameOverrideOnly.uploadField).toBe( + '/memory_store_basedir/overridden.txt', + ) // 👈 overrode the extension too + expect(pathOverrideOnly.uploadField).toMatch(/\/bazinga\/.*\.png/) + // Overriding path ignores the baseDir + expect(pathOverrideOnly.uploadField).not.toContain('memory_store_basedir') - expect(bothOverride.uploadField).toBe('/bazinga/overridden.png') -}) + expect(bothOverride.uploadField).toBe('/bazinga/overridden.png') + }) -it('Should not add extension for unknown file type', async() => { - const data = { - uploadField: new File(['Hello'], 'hello', { - type: 'bazinga/unknown', - }), - } + it('Should not add extension for unknown file type', async () => { + const data = { + uploadField: new File(['Hello'], 'hello', { + type: 'bazinga/unknown', + }), + } - const noOverride = await processors.processDummyUploads(data) + const noOverride = await processors.processDummyUploads(data) - // No extension - expect(noOverride.uploadField).toMatch(/\/memory_store_basedir\/.*[^.]+$/); + // No extension + expect(noOverride.uploadField).toMatch(/\/memory_store_basedir\/.*[^.]+$/) - const withOverride = await processors.processDummyUploads(data, { - fileName: 'hello', + const withOverride = await processors.processDummyUploads(data, { + fileName: 'hello', + }) + expect(withOverride.uploadField).toMatch(/[^.]+$/) + expect(withOverride.uploadField).toBe('/memory_store_basedir/hello') }) - expect(withOverride.uploadField).toMatch(/[^.]+$/); - expect(withOverride.uploadField).toBe('/memory_store_basedir/hello') - -}) - - }) \ No newline at end of file diff --git a/packages/uploads/src/__tests__/prismaExtension.test.ts b/packages/uploads/src/__tests__/prismaExtension.test.ts index c93e8926b638..7236510ff9d5 100644 --- a/packages/uploads/src/__tests__/prismaExtension.test.ts +++ b/packages/uploads/src/__tests__/prismaExtension.test.ts @@ -51,7 +51,7 @@ describe('Uploads Prisma Extension', () => { ) describe('Query extensions', () => { - it('not implemented yet after refactor') + it('not implemented yet after refactor', async () => {}) }) diff --git a/packages/uploads/src/__tests__/unit-test-schema.prisma b/packages/uploads/src/__tests__/unit-test-schema.prisma index e4c606541958..905afc366007 100644 --- a/packages/uploads/src/__tests__/unit-test-schema.prisma +++ b/packages/uploads/src/__tests__/unit-test-schema.prisma @@ -18,3 +18,8 @@ model Dumbo { firstUpload String secondUpload String } + +model NoUploadFields { + id Int @id @default(autoincrement()) + name String +} \ No newline at end of file diff --git a/packages/uploads/src/createProcessors.ts b/packages/uploads/src/createProcessors.ts index bcfe78ebc3fc..c35ddd9dced3 100644 --- a/packages/uploads/src/createProcessors.ts +++ b/packages/uploads/src/createProcessors.ts @@ -10,21 +10,32 @@ export const createUploadProcessors = ( storage: StorageAdapter, uploadConfig: UploadsConfig ) => { + type modelNamesInUploadConfig = keyof typeof uploadConfig + + type uploadProcessorNames = + `process${Capitalize}Uploads` type Processors = { - [K in keyof typeof uploadConfig]: >(data: T, overrideSaveOptions?: SaveOptionsOverride) => Promise> + [K in uploadProcessorNames]: >( + data: T, + overrideSaveOptions?: SaveOptionsOverride, + ) => Promise> } - // @TODO TS: how do I get make it process${keyof UploadsConfig}Uploads so it autocompletes? - const processors: Processors = {} + const processors = {} as Processors Object.keys(uploadConfig).forEach((model) => { - const currentModelUploadFields = Array.isArray(uploadConfig[model].fields) ? uploadConfig[model].fields : [uploadConfig[model].fields] + const modelKey = model as keyof typeof uploadConfig + + const currentModelUploadFields = Array.isArray( + uploadConfig[modelKey].fields, + ) + ? uploadConfig[modelKey].fields + : [uploadConfig[modelKey].fields] const capitalCaseModel = `${model.charAt(0).toUpperCase() + model.slice(1)}` - processors[`process${capitalCaseModel}Uploads`] = async( - data, - overrideSaveOptions - ) => { + const processorKey = `process${capitalCaseModel}Uploads` as keyof Processors + + processors[processorKey] = async (data, overrideSaveOptions) => { const updatedFields = {} as Record for await (const field of currentModelUploadFields) { if (data[field]) { @@ -40,7 +51,7 @@ export const createUploadProcessors = ( } return { ...data, - ...updatedFields + ...updatedFields, } } }) diff --git a/packages/uploads/src/prismaExtension.ts b/packages/uploads/src/prismaExtension.ts index 3dbd04cbfe12..7c3b659391f8 100644 --- a/packages/uploads/src/prismaExtension.ts +++ b/packages/uploads/src/prismaExtension.ts @@ -3,6 +3,10 @@ import { Prisma } from '@prisma/client' import type * as runtime from '@prisma/client/runtime/library' import { ulid } from 'ulid' +// @TODO(TS): UploadsConfig behaves differently here.. probably +// the prisma-override not quite there yet? +// import { PrismaClient } from './__tests__/prisma-client/index.js' +// import { Prisma } from './__tests__/prisma-client/index.js' import { fileToDataUri } from './fileSave.utils.js' import type { StorageAdapter } from './StorageAdapter.js' @@ -22,10 +26,8 @@ export type UploadConfigForModel = { onFileSaved?: (filePath: string) => void | Promise } -export type UploadsConfig = Record< - MName, - UploadConfigForModel -> +export type UploadsConfig = + Record export const createUploadsExtension = ( config: UploadsConfig, diff --git a/packages/uploads/tsconfig.json b/packages/uploads/tsconfig.json index 43d163492556..3484bfda0d5c 100644 --- a/packages/uploads/tsconfig.json +++ b/packages/uploads/tsconfig.json @@ -7,13 +7,14 @@ "rootDir": "src", "outDir": "dist" }, - "include": ["src"], + "include": ["src", "prisma-override.d.ts"], + "references": [ { "path": "../project-config" }, { "path": "../framework-tools" - }, + } ] } From 1c99b1404aaf345a9a9ff460e6908786e84ac2d5 Mon Sep 17 00:00:00 2001 From: Danny Choudhury Date: Fri, 16 Aug 2024 13:32:26 +0700 Subject: [PATCH 42/91] Add file list processor --- .../src/__tests__/createProcessors.test.ts | 41 +++++++++++++++++-- packages/uploads/src/createProcessors.ts | 18 ++++++++ 2 files changed, 56 insertions(+), 3 deletions(-) diff --git a/packages/uploads/src/__tests__/createProcessors.test.ts b/packages/uploads/src/__tests__/createProcessors.test.ts index d7e2c018644e..060a995259e1 100644 --- a/packages/uploads/src/__tests__/createProcessors.test.ts +++ b/packages/uploads/src/__tests__/createProcessors.test.ts @@ -1,7 +1,9 @@ - import { describe, it, expect } from 'vitest' -import { createUploadProcessors } from '../createProcessors.js' +import { + createFileListProcessor, + createUploadProcessors, +} from '../createProcessors.js' import { MemoryStorage } from '../MemoryStorage.js' import type { UploadsConfig } from '../prismaExtension.js' @@ -99,4 +101,37 @@ describe('Create processors', () => { expect(withOverride.uploadField).toMatch(/[^.]+$/) expect(withOverride.uploadField).toBe('/memory_store_basedir/hello') }) -}) \ No newline at end of file +}) +// FileLists +// Problem is - in the database world, a string[] is not a thing +// so we need a generic way of doing this +describe('FileList processing', () => { + const fileListProcessor = createFileListProcessor(memStore) + + const notPrismaData = [ + new File(['Hello'], 'hello.png', { + type: 'image/png', + }), + new File(['World'], 'world.jpeg', { + type: 'image/jpeg', + }), + ] + + it('Should handle FileLists', async () => { + const result = await fileListProcessor(notPrismaData) + + expect(result).toHaveLength(2) + expect(result[0]).toMatch(/\/memory_store_basedir\/.*\.png/) + expect(result[1]).toMatch(/\/memory_store_basedir\/.*\.jpeg/) + }) + + it('Should handle FileLists with SaveOptions', async () => { + const result = await fileListProcessor(notPrismaData, { + path: '/bazinga_not_mem_store', + }) + + expect(result).toHaveLength(2) + expect(result[0]).toMatch(/\/bazinga_not_mem_store\/.*\.png/) + expect(result[1]).toMatch(/\/bazinga_not_mem_store\/.*\.jpeg/) + }) +}) diff --git a/packages/uploads/src/createProcessors.ts b/packages/uploads/src/createProcessors.ts index c35ddd9dced3..2505a1128cc5 100644 --- a/packages/uploads/src/createProcessors.ts +++ b/packages/uploads/src/createProcessors.ts @@ -6,6 +6,24 @@ type MakeFilesString = { [K in keyof T]: T[K] extends File ? string : T[K] } +export const createFileListProcessor = (storage: StorageAdapter) => { + return async (files: File[], pathOverrideOnly?: { path?: string }) => { + const locations = await Promise.all( + files.map(async (file) => { + const { location } = await storage.save(file, pathOverrideOnly) + return location + }) + ) + + return locations + } + +} + +/* +This creates a processor for each model in the uploads config (i.e. tied to a model in the prisma schema) +The processor will only handle single file uploads, not file lists. +*/ export const createUploadProcessors = ( storage: StorageAdapter, uploadConfig: UploadsConfig From 964433982ba46415ce549a4540f959c18b5e91fd Mon Sep 17 00:00:00 2001 From: Danny Choudhury Date: Fri, 16 Aug 2024 17:30:25 +0700 Subject: [PATCH 43/91] WIP --- packages/uploads/src/FileSystemStorage.ts | 20 +-- packages/uploads/src/MemoryStorage.ts | 26 ++-- packages/uploads/src/StorageAdapter.ts | 11 +- .../src/__tests__/createProcessors.test.ts | 17 +- .../src/__tests__/prismaExtension.test.ts | 147 ++++++++++++++---- packages/uploads/src/createProcessors.ts | 21 +-- packages/uploads/src/prismaExtension.ts | 112 ++++--------- packages/uploads/src/setup.ts | 27 ++++ 8 files changed, 229 insertions(+), 152 deletions(-) create mode 100644 packages/uploads/src/setup.ts diff --git a/packages/uploads/src/FileSystemStorage.ts b/packages/uploads/src/FileSystemStorage.ts index 6a431b0d79b8..85ef8e1aeb49 100644 --- a/packages/uploads/src/FileSystemStorage.ts +++ b/packages/uploads/src/FileSystemStorage.ts @@ -4,22 +4,22 @@ import path from 'node:path' import mime from 'mime-types' import { ulid } from 'ulid' -import type { AdapterOptions, SaveOptionsOverride, StorageAdapter } from './StorageAdapter.js' - -export class FileSystemStorage implements StorageAdapter { - constructor(adapterOpts: AdapterOptions) { - this.adapterOpts = adapterOpts - } - - adapterOpts: AdapterOptions +import type { SaveOptionsOverride } from './StorageAdapter.js' +import { StorageAdapter } from './StorageAdapter.js' +export class FileSystemStorage + extends StorageAdapter + implements StorageAdapter +{ async save(file: File, saveOpts?: SaveOptionsOverride) { const randomFileName = ulid() const extension = mime.extension(file.type) - const location = path.join(saveOpts?.path || this.adapterOpts.baseDir, saveOpts?.fileName || randomFileName + `.${extension}`) + const location = path.join( + saveOpts?.path || this.adapterOpts.baseDir, + saveOpts?.fileName || randomFileName + `.${extension}`, + ) const nodeBuffer = await file.arrayBuffer() - await fs.writeFile(location, Buffer.from(nodeBuffer)) return { location } } diff --git a/packages/uploads/src/MemoryStorage.ts b/packages/uploads/src/MemoryStorage.ts index 1e6d4bd3e3ee..c478c3bf3944 100644 --- a/packages/uploads/src/MemoryStorage.ts +++ b/packages/uploads/src/MemoryStorage.ts @@ -3,35 +3,35 @@ import path from 'node:path' import mime from 'mime-types' import { ulid } from 'ulid' -import type { AdapterOptions, StorageAdapter } from './StorageAdapter.js' +import { StorageAdapter } from './StorageAdapter.js' import type { SaveOptionsOverride } from './StorageAdapter.js' -export class MemoryStorage implements StorageAdapter { - constructor(adapterOpts: AdapterOptions) { - this.adapterOpts = adapterOpts - } - - adapterOpts: AdapterOptions +export class MemoryStorage extends StorageAdapter implements StorageAdapter { store: Record = {} async save(file: File, saveOpts?: SaveOptionsOverride) { - const randomFileName = ulid() - const extension = mime.extension(file.type) ? `.${mime.extension(file.type)}` : '' - const location = path.join(saveOpts?.path || this.adapterOpts.baseDir, saveOpts?.fileName || randomFileName + `${extension}`) + const fileName = saveOpts?.fileName || ulid() + const extension = mime.extension(file.type) + ? `.${mime.extension(file.type)}` + : '' + const location = path.join( + saveOpts?.path || this.adapterOpts.baseDir, + fileName + `${extension}`, + ) const nodeBuffer = await file.arrayBuffer() const result = `${location}` this.store[result] = Buffer.from(nodeBuffer) return { - location: result + location: result, } } async remove(filePath: string) { - delete this.store[filePath] + delete this.store[filePath] } -// Not sure about read method... should it be in the base class? + // Not sure about read method... should it be in the base class? async read(filePath: string) { return this.store[filePath] } diff --git a/packages/uploads/src/StorageAdapter.ts b/packages/uploads/src/StorageAdapter.ts index b1871ec314b4..f3883b17b935 100644 --- a/packages/uploads/src/StorageAdapter.ts +++ b/packages/uploads/src/StorageAdapter.ts @@ -21,11 +21,18 @@ export type AdapterOptions = { export abstract class StorageAdapter { adapterOpts: AdapterOptions - constructor(adapterOpts: AdapterOptions){ + constructor(adapterOpts: AdapterOptions) { this.adapterOpts = adapterOpts } - abstract save(file: File, saveOpts?: SaveOptionsOverride): Promise + getAdapterOptions() { + return this.adapterOpts + } + + abstract save( + file: File, + saveOpts?: SaveOptionsOverride, + ): Promise abstract remove(fileLocation: AdapterResult['location']): Promise // abstract replace(fileId: string, file: File): Promise } diff --git a/packages/uploads/src/__tests__/createProcessors.test.ts b/packages/uploads/src/__tests__/createProcessors.test.ts index 060a995259e1..e2624e7e8975 100644 --- a/packages/uploads/src/__tests__/createProcessors.test.ts +++ b/packages/uploads/src/__tests__/createProcessors.test.ts @@ -23,7 +23,7 @@ const uploadsConfig: UploadsConfig = { } describe('Create processors', () => { - const processors = createUploadProcessors(memStore, uploadsConfig) + const processors = createUploadProcessors(uploadsConfig, memStore) it('should create processors with CapitalCased model name', () => { expect(processors.processDumboUploads).toBeDefined() @@ -42,8 +42,11 @@ describe('Create processors', () => { const result = await processors.processDumboUploads(data) - expect(result.firstUpload).toMatch(/\/memory_store_basedir\/.*\.txt/) - expect(result.secondUpload).toMatch(/\/memory_store_basedir\/.*\.txt/) + // Location strings in this format: {baseDir/{model}-{field}-{ulid}.{ext} + expect(result.firstUpload).toMatch(/\/memory_store_basedir\/dumbo-*.*\.txt/) + expect(result.secondUpload).toMatch( + /\/memory_store_basedir\/dumbo-*.*\.txt/, + ) const firstContents = await memStore.read(result.firstUpload) expect(firstContents.toString()).toBe('Meaow') @@ -60,7 +63,7 @@ describe('Create processors', () => { } const fileNameOverrideOnly = await processors.processDummyUploads(data, { - fileName: 'overridden.txt', + fileName: 'overridden', }) const pathOverrideOnly = await processors.processDummyUploads(data, { @@ -69,12 +72,12 @@ describe('Create processors', () => { const bothOverride = await processors.processDummyUploads(data, { path: '/bazinga', - fileName: 'overridden.png', + fileName: 'overridden', }) expect(fileNameOverrideOnly.uploadField).toBe( - '/memory_store_basedir/overridden.txt', - ) // 👈 overrode the extension too + '/memory_store_basedir/overridden.png', + ) expect(pathOverrideOnly.uploadField).toMatch(/\/bazinga\/.*\.png/) // Overriding path ignores the baseDir diff --git a/packages/uploads/src/__tests__/prismaExtension.test.ts b/packages/uploads/src/__tests__/prismaExtension.test.ts index 7236510ff9d5..f891dd080aa2 100644 --- a/packages/uploads/src/__tests__/prismaExtension.test.ts +++ b/packages/uploads/src/__tests__/prismaExtension.test.ts @@ -1,9 +1,10 @@ +import fs from 'node:fs/promises' -import { vol } from 'memfs' -import { describe, it, vi } from 'vitest' +import { describe, it, vi, expect, beforeEach } from 'vitest' import { FileSystemStorage } from '../FileSystemStorage.js' -import { createUploadsExtension } from '../prismaExtension.js' +import type { UploadsConfig } from '../prismaExtension.js' +import { setupUploads } from '../setup.js' // @MARK: use the local prisma client import { PrismaClient } from './prisma-client' @@ -12,22 +13,13 @@ vi.mock('node:fs/promises', () => ({ default: { writeFile: vi.fn(), unlink: vi.fn(), - readFile: vi.fn((path, encoding) => { - if (encoding === 'base64') { - return 'BASE64_FILE_CONTENT' - } - + readFile: vi.fn(() => { return 'MOCKED_FILE_CONTENT' }), copyFile: vi.fn(), }, })) -vol.fromJSON({ - '/tmp/tus-uploads/123.json': '{}', - '/tmp/tus-uploads/ABCD.json': '{}', -}) - describe('Uploads Prisma Extension', () => { const dummyUploadConfig = { fields: 'uploadField', @@ -37,28 +29,127 @@ describe('Uploads Prisma Extension', () => { fields: ['firstUpload', 'secondUpload'], } - const prismaClient = new PrismaClient().$extends( - createUploadsExtension( - { - dummy: dummyUploadConfig, - dumbo: dumboUploadConfig, - }, - // Test with file system storage, and mock the fs calls - new FileSystemStorage({ - baseDir: '/tmp', - }), - ), + const uploadConfig: UploadsConfig = { + dummy: dummyUploadConfig, + dumbo: dumboUploadConfig, + } + + const { prismaExtension, uploadsProcessors } = setupUploads( + uploadConfig, + new FileSystemStorage({ + baseDir: '/tmp', + }), ) + const prismaClient = new PrismaClient().$extends(prismaExtension) + describe('Query extensions', () => { - it('not implemented yet after refactor', async () => {}) - }) + beforeEach(() => { + vi.resetAllMocks() + }) + + const sampleFile = new File(['heres-some-content'], 'dummy.txt', { + type: 'text/plain', + }) + describe('create', () => { + it('create will save files', async () => { + const processedData = await uploadsProcessors.processDummyUploads({ + uploadField: sampleFile, + }) - describe('Result extensions', () => { - it('will return a data URL for the file', async () => { + expect(fs.writeFile).toHaveBeenCalled() + const dummy = await prismaClient.dummy.create({ + data: processedData, + }) + + expect(dummy).toMatchObject({ + uploadField: expect.stringMatching(/\/tmp\/.*\.txt$/), + }) + }) + + it('will remove the file if the create fails', async () => { + try { + await prismaClient.dumbo.create({ + data: { + firstUpload: '/tmp/first.txt', + secondUpload: '/bazinga/second.txt', + // @ts-expect-error Checking the error here + id: 'this-is-the-incorrect-type', + }, + }) + } catch { + expect(fs.unlink).toHaveBeenNthCalledWith(1, '/tmp/first.txt') + expect(fs.unlink).toHaveBeenNthCalledWith(2, '/bazinga/second.txt') + } + + expect.assertions(2) + }) + }) + + describe('createMany', () => { + it('createMany will remove files if all the create fails', async () => { + try { + await prismaClient.dumbo.createMany({ + data: [ + { + firstUpload: '/one/first.txt', + secondUpload: '/one/second.txt', + id: 'break', + }, + { + firstUpload: '/two/first.txt', + secondUpload: '/two/second.txt', + id: 'break2', + }, + ], + }) + } catch (e) { + expect(fs.unlink).toHaveBeenCalledTimes(4) + expect(fs.unlink).toHaveBeenNthCalledWith(1, '/one/first.txt') + expect(fs.unlink).toHaveBeenNthCalledWith(2, '/one/second.txt') + expect(fs.unlink).toHaveBeenNthCalledWith(3, '/two/first.txt') + expect(fs.unlink).toHaveBeenNthCalledWith(4, '/two/second.txt') + } + + expect.assertions(4) + }) + + it('createMany will remove files from only the creates that fail', async () => { + try { + await prismaClient.dumbo.createMany({ + data: [ + // This one will go through + { + firstUpload: '/one/first.txt', + secondUpload: '/one/second.txt', + }, + { + firstUpload: '/two/first.txt', + secondUpload: '/two/second.txt', + id: 'break2', + }, + ], + }) + } catch (e) { + console.log(e) + expect(fs.unlink).toHaveBeenCalledTimes(2) + expect(fs.unlink).toHaveBeenNthCalledWith(1, '/two/first.txt') + expect(fs.unlink).toHaveBeenNthCalledWith(2, '/two/second.txt') + } + + expect.assertions(4) + }) }) + // describe('update', () => {}) + + // describe('delete', () => {}) + }) + + describe('Result extensions', () => { + it('will return a data URL for the file', async () => {}) + // @TODO Handle edge cases (file removed, data modified, etc.) // it('if file is not found, will throw an error', async () => {}) // it('if saved file is not a path, will throw an error', async () => {}) diff --git a/packages/uploads/src/createProcessors.ts b/packages/uploads/src/createProcessors.ts index 2505a1128cc5..af29a18a32c9 100644 --- a/packages/uploads/src/createProcessors.ts +++ b/packages/uploads/src/createProcessors.ts @@ -1,5 +1,7 @@ -import type { UploadsConfig } from "./prismaExtension.js" -import type { SaveOptionsOverride, StorageAdapter } from "./StorageAdapter.js" +import { ulid } from 'ulid' + +import type { UploadsConfig } from './prismaExtension.js' +import type { SaveOptionsOverride, StorageAdapter } from './StorageAdapter.js' // Assumes you pass in the graphql type type MakeFilesString = { @@ -12,12 +14,11 @@ export const createFileListProcessor = (storage: StorageAdapter) => { files.map(async (file) => { const { location } = await storage.save(file, pathOverrideOnly) return location - }) + }), ) return locations } - } /* @@ -25,15 +26,17 @@ This creates a processor for each model in the uploads config (i.e. tied to a mo The processor will only handle single file uploads, not file lists. */ export const createUploadProcessors = ( + uploadConfig: UploadsConfig, storage: StorageAdapter, - uploadConfig: UploadsConfig ) => { type modelNamesInUploadConfig = keyof typeof uploadConfig type uploadProcessorNames = `process${Capitalize}Uploads` + type Processors = { [K in uploadProcessorNames]: >( + // @TODO(TS): T should be the type of the model data: T, overrideSaveOptions?: SaveOptionsOverride, ) => Promise> @@ -57,12 +60,12 @@ export const createUploadProcessors = ( const updatedFields = {} as Record for await (const field of currentModelUploadFields) { if (data[field]) { - // @TODO deal with file lists const file = data[field] as File - // @TODO: should we automatically create a directory for each model? - // you can always override it in the saveOpts - const { location } = await storage.save(file, overrideSaveOptions) + const saveOptions = overrideSaveOptions || { + fileName: `${model}-${field}-${ulid()}`, + } + const { location } = await storage.save(file, saveOptions) updatedFields[field] = location } diff --git a/packages/uploads/src/prismaExtension.ts b/packages/uploads/src/prismaExtension.ts index 7c3b659391f8..36e22291c340 100644 --- a/packages/uploads/src/prismaExtension.ts +++ b/packages/uploads/src/prismaExtension.ts @@ -1,7 +1,6 @@ import { PrismaClient } from '@prisma/client' import { Prisma } from '@prisma/client' import type * as runtime from '@prisma/client/runtime/library' -import { ulid } from 'ulid' // @TODO(TS): UploadsConfig behaves differently here.. probably // the prisma-override not quite there yet? @@ -20,6 +19,7 @@ type FilterOutDollarPrefixed = T extends `$${string}` type ModelNames = FilterOutDollarPrefixed export type UploadConfigForModel = { + // @TODO(TS): I want the fields here to be the fields of the model fields: string[] | string savePath?: ((args: unknown) => string) | string fileName?: (args: unknown) => string @@ -84,6 +84,16 @@ export const createUploadsExtension = ( : [modelConfig.fields] queryExtends[modelName] = { + async create({ query, args }) { + try { + const result = await query(args) + return result + } catch (e) { + // If the create fails, we need to delete the uploaded files + await removeUploadedFiles(uploadFields, args) + throw e + } + }, async update({ query, model, args }) { await deleteUpload( { @@ -97,25 +107,17 @@ export const createUploadsExtension = ( storageAdapter, ) - const uploadArgs = await saveUploads( - uploadFields, - args, - modelConfig, - storageAdapter, - ) - - return query(uploadArgs) + // Same as create 👇 + try { + const result = await query(args) + return result + } catch (e) { + // If the create fails, we need to delete the uploaded files + await removeUploadedFiles(uploadFields, args) + throw e + } }, - async create({ query, args }) { - const uploadArgs = await saveUploads( - uploadFields, - args, - modelConfig, - storageAdapter, - ) - return query(uploadArgs) - }, async delete({ model, query, args }) { await deleteUpload( { @@ -165,72 +167,16 @@ export const createUploadsExtension = ( result: resultExtends, }) }) -} -/** - * Returns new args to use in create or update. - * - * Pass this to the query function! - */ -async function saveUploads( - uploadFields: string[], - args: runtime.JsArgs & { - data?: { - [key: string]: runtime.JsInputValue | File - } - }, - modelConfig: UploadConfigForModel, - storageAdapter: StorageAdapter, -) { - const fieldsToUpdate: { - [key: string]: string - } = {} - - if (!args.data) { - throw new Error('No data in prisma query') - } - - // For each upload property, we need to:z - // 1. save the file to the file system (path or name from config) - // 2. replace the value of the field - for await (const field of uploadFields) { - const uploadFile = args.data[field] as File - console.log(`👉 \n ~ uploadFile:`, uploadFile) - - if (!uploadFile) { - continue - } - - const fileName = - modelConfig.fileName && typeof modelConfig.fileName === 'function' - ? modelConfig.fileName(args) - : ulid() - - const saveDir = - typeof modelConfig.savePath === 'function' - ? modelConfig.savePath(args) - : modelConfig.savePath || 'web/public/uploads' - - const savedFile = await storageAdapter.save(uploadFile, { - fileName, - path: saveDir, - }) - - // @TODO should we return location or fileId? - fieldsToUpdate[field] = savedFile.location - - // Call the onFileSaved callback - // Having it here means it'll always trigger whether create/update - if (modelConfig.onFileSaved) { - await modelConfig.onFileSaved(savedFile.location) + // @TODO(TS): According to TS, data could be a non-object... + // Setting args to JsArgs causes errors. This could be a legit issue + async function removeUploadedFiles(uploadFields: string[], args: any) { + for await (const field of uploadFields) { + const uploadLocation = args.data?.[field] as string + if (uploadLocation) { + console.log('Removing file >>>', uploadLocation) + await storageAdapter.remove(uploadLocation) + } } } - - // Can't spread according to TS - const newData = Object.assign(args.data, fieldsToUpdate) - - return { - ...args, - data: newData, - } } diff --git a/packages/uploads/src/setup.ts b/packages/uploads/src/setup.ts new file mode 100644 index 000000000000..e21042326488 --- /dev/null +++ b/packages/uploads/src/setup.ts @@ -0,0 +1,27 @@ +import { + createFileListProcessor, + createUploadProcessors, +} from './createProcessors.js' +import { + createUploadsExtension, + type UploadsConfig, +} from './prismaExtension.js' +import type { StorageAdapter } from './StorageAdapter.js' + +export const setupUploads = ( + uploadsConfig: UploadsConfig, + storageAdapter: StorageAdapter, +) => { + const prismaExtension = createUploadsExtension(uploadsConfig, storageAdapter) + const uploadsProcessors = createUploadProcessors( + uploadsConfig, + storageAdapter, + ) + const fileListProcessor = createFileListProcessor(storageAdapter) + + return { + prismaExtension, + uploadsProcessors, + fileListProcessor, + } +} From 52c57c98f710499fadd459438a1426e256e53e3a Mon Sep 17 00:00:00 2001 From: Danny Choudhury Date: Fri, 16 Aug 2024 17:36:26 +0700 Subject: [PATCH 44/91] Bit of cleanup --- packages/uploads/src/FileSystemStorage.ts | 7 +++++-- packages/uploads/src/MemoryStorage.ts | 6 +++--- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/packages/uploads/src/FileSystemStorage.ts b/packages/uploads/src/FileSystemStorage.ts index 85ef8e1aeb49..0b3312effbec 100644 --- a/packages/uploads/src/FileSystemStorage.ts +++ b/packages/uploads/src/FileSystemStorage.ts @@ -12,11 +12,14 @@ export class FileSystemStorage implements StorageAdapter { async save(file: File, saveOpts?: SaveOptionsOverride) { - const randomFileName = ulid() + const fileName = saveOpts?.fileName || ulid() const extension = mime.extension(file.type) + ? `.${mime.extension(file.type)}` + : '' + const location = path.join( saveOpts?.path || this.adapterOpts.baseDir, - saveOpts?.fileName || randomFileName + `.${extension}`, + fileName + `${extension}`, ) const nodeBuffer = await file.arrayBuffer() diff --git a/packages/uploads/src/MemoryStorage.ts b/packages/uploads/src/MemoryStorage.ts index c478c3bf3944..64d667a1f9cc 100644 --- a/packages/uploads/src/MemoryStorage.ts +++ b/packages/uploads/src/MemoryStorage.ts @@ -14,17 +14,17 @@ export class MemoryStorage extends StorageAdapter implements StorageAdapter { const extension = mime.extension(file.type) ? `.${mime.extension(file.type)}` : '' + const location = path.join( saveOpts?.path || this.adapterOpts.baseDir, fileName + `${extension}`, ) const nodeBuffer = await file.arrayBuffer() - const result = `${location}` - this.store[result] = Buffer.from(nodeBuffer) + this.store[location] = Buffer.from(nodeBuffer) return { - location: result, + location, } } async remove(filePath: string) { From 540bdcc7ffe9732fafcf413a8b9c930995631ada Mon Sep 17 00:00:00 2001 From: Danny Choudhury Date: Mon, 19 Aug 2024 10:08:33 +0700 Subject: [PATCH 45/91] WIP: Start on signature generation --- .../src/__tests__/generateSignature.test.ts | 14 ++++++++++++++ packages/uploads/src/lib/generateSignature.ts | 16 ++++++++++++++++ 2 files changed, 30 insertions(+) create mode 100644 packages/uploads/src/__tests__/generateSignature.test.ts create mode 100644 packages/uploads/src/lib/generateSignature.ts diff --git a/packages/uploads/src/__tests__/generateSignature.test.ts b/packages/uploads/src/__tests__/generateSignature.test.ts new file mode 100644 index 000000000000..d587baa22fa6 --- /dev/null +++ b/packages/uploads/src/__tests__/generateSignature.test.ts @@ -0,0 +1,14 @@ +import { beforeAll, describe, test } from 'vitest' + +import { generateSignature } from '../lib/generateSignature.js' + +describe('Generate signature', () => { + beforeAll(() => { + process.env.RW_UPLOADS_SECRET = 'bazinga' + }) + + test('It creates a signature', () => { + const out = generateSignature('/tmp/myfile.txt', 500) + console.log(`👉 \n ~ out:`, out) + }) +}) diff --git a/packages/uploads/src/lib/generateSignature.ts b/packages/uploads/src/lib/generateSignature.ts new file mode 100644 index 000000000000..055e3b3a3bc8 --- /dev/null +++ b/packages/uploads/src/lib/generateSignature.ts @@ -0,0 +1,16 @@ +import crypto from 'node:crypto' +export const generateSignature = (filePath: string, expiresIn: number) => { + if (!process.env.RW_UPLOADS_SECRET) { + throw new Error( + 'Please configure RW_UPLOADS_SECRET in your environment variables', + ) + } + + const expires = Math.floor(Date.now() / 1000) + expiresIn + const signature = crypto + .createHmac('sha256', process.env.RW_UPLOADS_SECRET) + .update(`${filePath}:${expires}`) + .digest('hex') + + return signature +} From d70ae4b2e73e38a242e28e658b21c64f31d85533 Mon Sep 17 00:00:00 2001 From: Danny Choudhury Date: Mon, 19 Aug 2024 10:35:13 +0700 Subject: [PATCH 46/91] Uplaod yarn.lock --- yarn.lock | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/yarn.lock b/yarn.lock index 16367989ff78..3ae6b1e1a939 100644 --- a/yarn.lock +++ b/yarn.lock @@ -25365,6 +25365,19 @@ __metadata: languageName: node linkType: hard +"publint@npm:0.2.9": + version: 0.2.9 + resolution: "publint@npm:0.2.9" + dependencies: + npm-packlist: "npm:^5.1.3" + picocolors: "npm:^1.0.1" + sade: "npm:^1.8.1" + bin: + publint: lib/cli.js + checksum: 10c0/b414f40c2bc9372119346d5684eccb12bdf8066fc821301880d9dcdec0a5d852bbe926cb4583511f3e97736c53d1723e46e49285c9463bcb808cbfd979d5c2fc + languageName: node + linkType: hard + "pump@npm:^2.0.0": version: 2.0.1 resolution: "pump@npm:2.0.1" From 7478dcbea95d87594f7233405cacaf26c9db6391 Mon Sep 17 00:00:00 2001 From: Danny Choudhury Date: Mon, 19 Aug 2024 13:34:19 +0700 Subject: [PATCH 47/91] Setup signature generation and validation (no URLs just yet) --- .../src/__tests__/generateSignature.test.ts | 14 -- .../uploads/src/__tests__/signedUrls.test.ts | 133 ++++++++++++++++++ packages/uploads/src/lib/generateSignature.ts | 16 --- packages/uploads/src/lib/signedUrls.ts | 111 +++++++++++++++ 4 files changed, 244 insertions(+), 30 deletions(-) delete mode 100644 packages/uploads/src/__tests__/generateSignature.test.ts create mode 100644 packages/uploads/src/__tests__/signedUrls.test.ts delete mode 100644 packages/uploads/src/lib/generateSignature.ts create mode 100644 packages/uploads/src/lib/signedUrls.ts diff --git a/packages/uploads/src/__tests__/generateSignature.test.ts b/packages/uploads/src/__tests__/generateSignature.test.ts deleted file mode 100644 index d587baa22fa6..000000000000 --- a/packages/uploads/src/__tests__/generateSignature.test.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { beforeAll, describe, test } from 'vitest' - -import { generateSignature } from '../lib/generateSignature.js' - -describe('Generate signature', () => { - beforeAll(() => { - process.env.RW_UPLOADS_SECRET = 'bazinga' - }) - - test('It creates a signature', () => { - const out = generateSignature('/tmp/myfile.txt', 500) - console.log(`👉 \n ~ out:`, out) - }) -}) diff --git a/packages/uploads/src/__tests__/signedUrls.test.ts b/packages/uploads/src/__tests__/signedUrls.test.ts new file mode 100644 index 000000000000..09d6db959eda --- /dev/null +++ b/packages/uploads/src/__tests__/signedUrls.test.ts @@ -0,0 +1,133 @@ +import { + beforeAll, + describe, + expect, + beforeEach, + afterEach, + vi, + it, +} from 'vitest' + +import { + EXPIRES_IN, + generateSignature, + validateSignature, +} from '../lib/signedUrls.js' + +describe('Signed URLs', () => { + beforeAll(() => { + process.env.RW_UPLOADS_SECRET = 'bazinga' + }) + + it('Can creates a signature', () => { + const { signature, expires } = generateSignature( + '/tmp/myfile.txt', + EXPIRES_IN.days(5), + ) + + expect(signature).toBeDefined() + + expect(diffInDaysFromNow(expires as number)).toBeCloseTo(5) + }) + + it('throws with correct error when wrong expires passed', () => { + const { signature, expires } = generateSignature( + '/tmp/myfile.txt', + EXPIRES_IN.days(1), + ) + + expect(() => + validateSignature({ + filePath: '/tmp/myfile.txt', + signature, + expires, + }), + ).not.toThrow() + + expect(() => + validateSignature({ + filePath: '/tmp/myfile.txt', + signature, + expires: 12512351, + }), + ).toThrowError('Signature has expired') + }) + + it('Throws an invalid signature when signature is wrong', () => { + const { signature, expires } = generateSignature( + '/tmp/myfile.txt', + EXPIRES_IN.days(1), + ) + + expect(() => + validateSignature({ + filePath: '/tmp/myfile.txt', + signature, + expires, + }), + ).not.toThrow() + + expect(() => + validateSignature({ + filePath: '/tmp/myfile.txt', + signature: 'im-the-wrong-signature', + expires, + }), + ).toThrowError('Invalid signature') + }) + + it('Throws an invalid signature when file path is wrong', () => { + const { signature, expires } = generateSignature( + '/tmp/myfile.txt', + EXPIRES_IN.days(20), + ) + expect(() => + validateSignature({ + filePath: '/tmp/some-other-file.txt', + signature, + expires, + }), + ).toThrowError('Invalid signature') + }) +}) + +describe('Expired signature', () => { + beforeEach(() => { + vi.useFakeTimers() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('throws an error when the signature has expired', () => { + const filePath = '/bazinga/kittens.png' + const { signature, expires } = generateSignature( + filePath, + EXPIRES_IN.minutes(15), + ) + + const validation = () => + validateSignature({ + filePath, + signature, + expires, + }) + + expect(validation).not.toThrow() + + // Time travel to the future + vi.advanceTimersByTime(EXPIRES_IN.days(1)) + + expect(validation).toThrowError('Signature has expired') + }) +}) + +// Some utility function to make tests more readable +function diffInMinsFromNow(time: number) { + return Math.abs(time - Date.now()) / 60000 +} + +function diffInDaysFromNow(time: number) { + return Math.abs(time - Date.now()) / 86400000 +} diff --git a/packages/uploads/src/lib/generateSignature.ts b/packages/uploads/src/lib/generateSignature.ts deleted file mode 100644 index 055e3b3a3bc8..000000000000 --- a/packages/uploads/src/lib/generateSignature.ts +++ /dev/null @@ -1,16 +0,0 @@ -import crypto from 'node:crypto' -export const generateSignature = (filePath: string, expiresIn: number) => { - if (!process.env.RW_UPLOADS_SECRET) { - throw new Error( - 'Please configure RW_UPLOADS_SECRET in your environment variables', - ) - } - - const expires = Math.floor(Date.now() / 1000) + expiresIn - const signature = crypto - .createHmac('sha256', process.env.RW_UPLOADS_SECRET) - .update(`${filePath}:${expires}`) - .digest('hex') - - return signature -} diff --git a/packages/uploads/src/lib/signedUrls.ts b/packages/uploads/src/lib/signedUrls.ts new file mode 100644 index 000000000000..313ef900ca32 --- /dev/null +++ b/packages/uploads/src/lib/signedUrls.ts @@ -0,0 +1,111 @@ +import crypto from 'node:crypto' + +export const generateSignature = (filePath: string, expiresInMs?: number) => { + if (!process.env.RW_UPLOADS_SECRET) { + throw new Error( + 'Please configure RW_UPLOADS_SECRET in your environment variables', + ) + } + + if (expiresInMs) { + const expires = Date.now() + expiresInMs + const signature = crypto + .createHmac('sha256', process.env.RW_UPLOADS_SECRET) + .update(`${filePath}:${expires}`) + .digest('hex') + + return { expires, signature } + } else { + // Does not expire + const signature = crypto + .createHmac('sha256', process.env.RW_UPLOADS_SECRET) + .update(filePath) + .digest('hex') + + return { + signature, + expires: undefined, + } + } +} + +/** + * The signature and expires have to be extracted from the URL + */ +export const validateSignature = ({ + signature, + filePath, + expires, +}: { + filePath: string + signature: string + expires?: number +}) => { + // Note, expires not the same as expiresIn + if (!process.env.RW_UPLOADS_SECRET) { + throw new Error( + 'Please configure RW_UPLOADS_SECRET in your environment variables', + ) + } + + if (expires) { + // No need to validate if the signature has expired + if (Date.now() > expires) { + throw new Error('Signature has expired') + } + } + + const validSignature = expires + ? crypto + .createHmac('sha256', process.env.RW_UPLOADS_SECRET) + .update(`${filePath}:${expires}`) + .digest('hex') + : crypto + .createHmac('sha256', process.env.RW_UPLOADS_SECRET) + .update(`${filePath}`) + .digest('hex') + + if (validSignature !== signature) { + throw new Error('Invalid signature') + } +} + +export const getSignedDetailsFromUrl = (url: string) => { + const urlObj = new URL(url) + const expires = urlObj.searchParams.get('expires') + return { + expires: expires ? parseInt(expires) : undefined, + file: urlObj.searchParams.get('file'), + signature: urlObj.searchParams.get('s'), + } +} + +type SigningParms = { filePath: string; expiresIn?: number } + +export const getSignedUriString = ( + endpoint: string, + { filePath, expiresIn }: SigningParms, +) => { + const { signature, expires } = generateSignature(filePath, expiresIn) + + // This way you can pass in a path with params already + const signedUrl = new URL(endpoint) + signedUrl.searchParams.set('s', signature) + if (expires) { + signedUrl.searchParams.set('expires', expires.toString()) + } + + signedUrl.searchParams.set('path', filePath) + + return signedUrl.toString() +} + +export const EXPIRES_IN = { + seconds: (s: number) => s * 1000, + minutes: (m: number) => m * 60 * 1000, + hours: (h: number) => h * 60 * 60 * 1000, + days: (d: number) => d * 24 * 60 * 60 * 1000, + weeks: (w: number) => w * 7 * 24 * 60 * 60 * 1000, + months: (m: number) => m * 30 * 24 * 60 * 60 * 1000, + years: (y: number) => y * 365 * 24 * 60 * 60 * 1000, +} From 6243643ea2af2796ebf66545881d629a05ef82b6 Mon Sep 17 00:00:00 2001 From: Danny Choudhury Date: Mon, 19 Aug 2024 15:01:05 +0700 Subject: [PATCH 48/91] Update extension tests --- .../src/__tests__/prismaExtension.test.ts | 51 +++++++++++++++---- 1 file changed, 42 insertions(+), 9 deletions(-) diff --git a/packages/uploads/src/__tests__/prismaExtension.test.ts b/packages/uploads/src/__tests__/prismaExtension.test.ts index f891dd080aa2..498325ebafdd 100644 --- a/packages/uploads/src/__tests__/prismaExtension.test.ts +++ b/packages/uploads/src/__tests__/prismaExtension.test.ts @@ -87,7 +87,9 @@ describe('Uploads Prisma Extension', () => { }) }) - describe('createMany', () => { + // Not implemented yet + // Ideally it would just work automatically... but I guess we need to do all the variants + describe.skip('createMany', () => { it('createMany will remove files if all the create fails', async () => { try { await prismaClient.dumbo.createMany({ @@ -142,16 +144,47 @@ describe('Uploads Prisma Extension', () => { }) }) - // describe('update', () => {}) + describe('update', () => { + it('update will remove the old file, save new one', async () => { + const dummy = await prismaClient.dummy.create({ + data: { + uploadField: '/tmp/old.txt', + }, + }) - // describe('delete', () => {}) - }) + const updatedDummy = await prismaClient.dummy.update({ + data: { + uploadField: '/tmp/new.txt', + }, + where: { + id: dummy.id, + }, + }) + + expect(fs.unlink).toHaveBeenCalledWith('/tmp/old.txt') + expect(updatedDummy.uploadField).toBe('/tmp/new.txt') + }) + }) + + describe('delete', () => { + it('delete will remove all uploads', async () => { + const dumbo = await prismaClient.dumbo.create({ + data: { + firstUpload: '/tmp/first.txt', + secondUpload: '/tmp/second.txt', + }, + }) - describe('Result extensions', () => { - it('will return a data URL for the file', async () => {}) + await prismaClient.dumbo.delete({ + where: { + id: dumbo.id, + }, + }) - // @TODO Handle edge cases (file removed, data modified, etc.) - // it('if file is not found, will throw an error', async () => {}) - // it('if saved file is not a path, will throw an error', async () => {}) + expect(fs.unlink).toHaveBeenCalledTimes(2) + expect(fs.unlink).toHaveBeenCalledWith('/tmp/first.txt') + expect(fs.unlink).toHaveBeenCalledWith('/tmp/second.txt') + }) + }) }) }) From a5fd94a10c308cb39606a96e5412fe3030c7b483 Mon Sep 17 00:00:00 2001 From: Danny Choudhury Date: Mon, 19 Aug 2024 15:01:23 +0700 Subject: [PATCH 49/91] Move filename generation to base adapter --- packages/uploads/src/FileSystemStorage.ts | 19 ++++++------------- packages/uploads/src/MemoryStorage.ts | 10 +++------- packages/uploads/src/StorageAdapter.ts | 14 ++++++++++++++ 3 files changed, 23 insertions(+), 20 deletions(-) diff --git a/packages/uploads/src/FileSystemStorage.ts b/packages/uploads/src/FileSystemStorage.ts index 0b3312effbec..653305fd1384 100644 --- a/packages/uploads/src/FileSystemStorage.ts +++ b/packages/uploads/src/FileSystemStorage.ts @@ -1,31 +1,24 @@ import fs from 'node:fs/promises' import path from 'node:path' -import mime from 'mime-types' -import { ulid } from 'ulid' import type { SaveOptionsOverride } from './StorageAdapter.js' import { StorageAdapter } from './StorageAdapter.js' -export class FileSystemStorage - extends StorageAdapter - implements StorageAdapter -{ - async save(file: File, saveOpts?: SaveOptionsOverride) { - const fileName = saveOpts?.fileName || ulid() - const extension = mime.extension(file.type) - ? `.${mime.extension(file.type)}` - : '' +export class FileSystemStorage extends StorageAdapter implements StorageAdapter { + async save(file: File, saveOverride?: SaveOptionsOverride) { + const fileName = this.generateFileNameWithExtension(saveOverride, file) const location = path.join( - saveOpts?.path || this.adapterOpts.baseDir, - fileName + `${extension}`, + saveOverride?.path || this.adapterOpts.baseDir, + fileName, ) const nodeBuffer = await file.arrayBuffer() await fs.writeFile(location, Buffer.from(nodeBuffer)) return { location } } + async remove(filePath: string) { await fs.unlink(filePath) } diff --git a/packages/uploads/src/MemoryStorage.ts b/packages/uploads/src/MemoryStorage.ts index 64d667a1f9cc..f02ea05a9220 100644 --- a/packages/uploads/src/MemoryStorage.ts +++ b/packages/uploads/src/MemoryStorage.ts @@ -1,7 +1,5 @@ import path from 'node:path' -import mime from 'mime-types' -import { ulid } from 'ulid' import { StorageAdapter } from './StorageAdapter.js' import type { SaveOptionsOverride } from './StorageAdapter.js' @@ -10,14 +8,11 @@ export class MemoryStorage extends StorageAdapter implements StorageAdapter { store: Record = {} async save(file: File, saveOpts?: SaveOptionsOverride) { - const fileName = saveOpts?.fileName || ulid() - const extension = mime.extension(file.type) - ? `.${mime.extension(file.type)}` - : '' + const fileName = this.generateFileNameWithExtension(saveOpts, file) const location = path.join( saveOpts?.path || this.adapterOpts.baseDir, - fileName + `${extension}`, + fileName, ) const nodeBuffer = await file.arrayBuffer() @@ -27,6 +22,7 @@ export class MemoryStorage extends StorageAdapter implements StorageAdapter { location, } } + async remove(filePath: string) { delete this.store[filePath] } diff --git a/packages/uploads/src/StorageAdapter.ts b/packages/uploads/src/StorageAdapter.ts index f3883b17b935..64a3d0d288d7 100644 --- a/packages/uploads/src/StorageAdapter.ts +++ b/packages/uploads/src/StorageAdapter.ts @@ -6,6 +6,9 @@ * } */ +import mime from 'mime-types' +import { ulid } from 'ulid' + export type AdapterResult = { location: string } @@ -29,6 +32,17 @@ export abstract class StorageAdapter { return this.adapterOpts } + generateFileNameWithExtension( + saveOpts: SaveOptionsOverride | undefined, + file: File, + ) { + const fileName = saveOpts?.fileName || ulid() + const extension = mime.extension(file.type) + ? `.${mime.extension(file.type)}` + : '' + return `${fileName}${extension}` + } + abstract save( file: File, saveOpts?: SaveOptionsOverride, From f1aaafbaaa7fe648cbfccb76d02656039f5ee785 Mon Sep 17 00:00:00 2001 From: Danny Choudhury Date: Mon, 19 Aug 2024 15:01:46 +0700 Subject: [PATCH 50/91] More signed url tests --- .../uploads/src/__tests__/signedUrls.test.ts | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/packages/uploads/src/__tests__/signedUrls.test.ts b/packages/uploads/src/__tests__/signedUrls.test.ts index 09d6db959eda..c23650cb2f98 100644 --- a/packages/uploads/src/__tests__/signedUrls.test.ts +++ b/packages/uploads/src/__tests__/signedUrls.test.ts @@ -6,11 +6,13 @@ import { afterEach, vi, it, + test, } from 'vitest' import { EXPIRES_IN, generateSignature, + getSignedDetailsFromUrl, validateSignature, } from '../lib/signedUrls.js' @@ -123,11 +125,18 @@ describe('Expired signature', () => { }) }) -// Some utility function to make tests more readable -function diffInMinsFromNow(time: number) { - return Math.abs(time - Date.now()) / 60000 -} +test('Parses details related to signatures from a url string', () => { + const url = + 'https://example.com/signedFile?file=/path/to/hello.txt&s=s1gnatur3&expires=123123' + + const { file, signature, expires } = getSignedDetailsFromUrl(url) + + expect(file).toBe('/path/to/hello.txt') + expect(signature).toBe('s1gnatur3') + expect(expires).toBe(123123) +}) +// Util functions to make the tests more readable function diffInDaysFromNow(time: number) { return Math.abs(time - Date.now()) / 86400000 } From e48d364b0482b2c0c3a5d0b586fd06bbd639dcf7 Mon Sep 17 00:00:00 2001 From: Danny Choudhury Date: Mon, 19 Aug 2024 15:21:57 +0700 Subject: [PATCH 51/91] Few changes --- packages/uploads/src/__tests__/signedUrls.test.ts | 13 +++++++++++++ packages/uploads/src/lib/signedUrls.ts | 12 ++++++------ packages/uploads/src/prismaExtension.ts | 4 +--- 3 files changed, 20 insertions(+), 9 deletions(-) diff --git a/packages/uploads/src/__tests__/signedUrls.test.ts b/packages/uploads/src/__tests__/signedUrls.test.ts index c23650cb2f98..b5e517ef1fb9 100644 --- a/packages/uploads/src/__tests__/signedUrls.test.ts +++ b/packages/uploads/src/__tests__/signedUrls.test.ts @@ -12,6 +12,7 @@ import { import { EXPIRES_IN, generateSignature, + generateSignedQueryParams, getSignedDetailsFromUrl, validateSignature, } from '../lib/signedUrls.js' @@ -136,6 +137,18 @@ test('Parses details related to signatures from a url string', () => { expect(expires).toBe(123123) }) +test('Generates a signed url', () => { + const signedQueryParams = generateSignedQueryParams('/files/bazinga', { + filePath: '/path/to/hello.txt', + expiresIn: EXPIRES_IN.days(1), + }) + + expect(signedQueryParams).toContain('/files/bazinga?s=') + expect(signedQueryParams).toContain('s=') + expect(signedQueryParams).toContain('expires=') + expect(signedQueryParams).toContain('path=') // The actual file path +}) + // Util functions to make the tests more readable function diffInDaysFromNow(time: number) { return Math.abs(time - Date.now()) / 86400000 diff --git a/packages/uploads/src/lib/signedUrls.ts b/packages/uploads/src/lib/signedUrls.ts index 313ef900ca32..b626e8e1dff1 100644 --- a/packages/uploads/src/lib/signedUrls.ts +++ b/packages/uploads/src/lib/signedUrls.ts @@ -82,22 +82,22 @@ export const getSignedDetailsFromUrl = (url: string) => { type SigningParms = { filePath: string; expiresIn?: number } -export const getSignedUriString = ( +export const generateSignedQueryParams = ( endpoint: string, { filePath, expiresIn }: SigningParms, ) => { const { signature, expires } = generateSignature(filePath, expiresIn) // This way you can pass in a path with params already - const signedUrl = new URL(endpoint) - signedUrl.searchParams.set('s', signature) + const params = new URLSearchParams() + params.set('s', signature) if (expires) { - signedUrl.searchParams.set('expires', expires.toString()) + params.set('expires', expires.toString()) } - signedUrl.searchParams.set('path', filePath) + params.set('path', filePath) - return signedUrl.toString() + return `${endpoint}?${params.toString()}` } export const EXPIRES_IN = { diff --git a/packages/uploads/src/prismaExtension.ts b/packages/uploads/src/prismaExtension.ts index 36e22291c340..9ea56f21bd74 100644 --- a/packages/uploads/src/prismaExtension.ts +++ b/packages/uploads/src/prismaExtension.ts @@ -141,11 +141,10 @@ export const createUploadsExtension = ( compute(modelData) { return async () => { const base64UploadFields: Record = {} - type ModelField = keyof typeof modelData for await (const field of uploadFields) { base64UploadFields[field] = await fileToDataUri( - modelData[field as ModelField] as string, + modelData[field] as string, ) } @@ -174,7 +173,6 @@ export const createUploadsExtension = ( for await (const field of uploadFields) { const uploadLocation = args.data?.[field] as string if (uploadLocation) { - console.log('Removing file >>>', uploadLocation) await storageAdapter.remove(uploadLocation) } } From 75b38356ebf47e3f91b7388e718283d2444c692d Mon Sep 17 00:00:00 2001 From: Danny Choudhury Date: Mon, 19 Aug 2024 16:00:41 +0700 Subject: [PATCH 52/91] Add withSignedUrl result extension --- packages/uploads/prisma-override.d.ts | 2 +- .../src/__tests__/prismaExtension.test.ts | 190 ------------------ .../src/__tests__/queryExtensions.test.ts | 183 +++++++++++++++++ .../src/__tests__/resultExtensions.test.ts | 48 +++++ packages/uploads/src/prismaExtension.ts | 33 ++- 5 files changed, 262 insertions(+), 194 deletions(-) delete mode 100644 packages/uploads/src/__tests__/prismaExtension.test.ts create mode 100644 packages/uploads/src/__tests__/queryExtensions.test.ts create mode 100644 packages/uploads/src/__tests__/resultExtensions.test.ts diff --git a/packages/uploads/prisma-override.d.ts b/packages/uploads/prisma-override.d.ts index ec14d355db25..f976517c35be 100644 --- a/packages/uploads/prisma-override.d.ts +++ b/packages/uploads/prisma-override.d.ts @@ -2,7 +2,7 @@ // This is so that we get accurate types (rather than the default anys) - and when the prismaExtension runs // it will still use the types from '@prisma/client' which points to the user's prisma client and not ours -import { PrismaClient as LocalPrismaClient } from './src/__tests__/prisma-client/index.d.ts' +import type { PrismaClient as LocalPrismaClient } from './src/__tests__/prisma-client/index.d.ts' declare module '@prisma/client' { export class PrismaClient extends LocalPrismaClient {} diff --git a/packages/uploads/src/__tests__/prismaExtension.test.ts b/packages/uploads/src/__tests__/prismaExtension.test.ts deleted file mode 100644 index 498325ebafdd..000000000000 --- a/packages/uploads/src/__tests__/prismaExtension.test.ts +++ /dev/null @@ -1,190 +0,0 @@ -import fs from 'node:fs/promises' - -import { describe, it, vi, expect, beforeEach } from 'vitest' - -import { FileSystemStorage } from '../FileSystemStorage.js' -import type { UploadsConfig } from '../prismaExtension.js' -import { setupUploads } from '../setup.js' - -// @MARK: use the local prisma client -import { PrismaClient } from './prisma-client' - -vi.mock('node:fs/promises', () => ({ - default: { - writeFile: vi.fn(), - unlink: vi.fn(), - readFile: vi.fn(() => { - return 'MOCKED_FILE_CONTENT' - }), - copyFile: vi.fn(), - }, -})) - -describe('Uploads Prisma Extension', () => { - const dummyUploadConfig = { - fields: 'uploadField', - } - - const dumboUploadConfig = { - fields: ['firstUpload', 'secondUpload'], - } - - const uploadConfig: UploadsConfig = { - dummy: dummyUploadConfig, - dumbo: dumboUploadConfig, - } - - const { prismaExtension, uploadsProcessors } = setupUploads( - uploadConfig, - new FileSystemStorage({ - baseDir: '/tmp', - }), - ) - - const prismaClient = new PrismaClient().$extends(prismaExtension) - - describe('Query extensions', () => { - beforeEach(() => { - vi.resetAllMocks() - }) - - const sampleFile = new File(['heres-some-content'], 'dummy.txt', { - type: 'text/plain', - }) - - describe('create', () => { - it('create will save files', async () => { - const processedData = await uploadsProcessors.processDummyUploads({ - uploadField: sampleFile, - }) - - expect(fs.writeFile).toHaveBeenCalled() - const dummy = await prismaClient.dummy.create({ - data: processedData, - }) - - expect(dummy).toMatchObject({ - uploadField: expect.stringMatching(/\/tmp\/.*\.txt$/), - }) - }) - - it('will remove the file if the create fails', async () => { - try { - await prismaClient.dumbo.create({ - data: { - firstUpload: '/tmp/first.txt', - secondUpload: '/bazinga/second.txt', - // @ts-expect-error Checking the error here - id: 'this-is-the-incorrect-type', - }, - }) - } catch { - expect(fs.unlink).toHaveBeenNthCalledWith(1, '/tmp/first.txt') - expect(fs.unlink).toHaveBeenNthCalledWith(2, '/bazinga/second.txt') - } - - expect.assertions(2) - }) - }) - - // Not implemented yet - // Ideally it would just work automatically... but I guess we need to do all the variants - describe.skip('createMany', () => { - it('createMany will remove files if all the create fails', async () => { - try { - await prismaClient.dumbo.createMany({ - data: [ - { - firstUpload: '/one/first.txt', - secondUpload: '/one/second.txt', - id: 'break', - }, - { - firstUpload: '/two/first.txt', - secondUpload: '/two/second.txt', - id: 'break2', - }, - ], - }) - } catch (e) { - expect(fs.unlink).toHaveBeenCalledTimes(4) - expect(fs.unlink).toHaveBeenNthCalledWith(1, '/one/first.txt') - expect(fs.unlink).toHaveBeenNthCalledWith(2, '/one/second.txt') - expect(fs.unlink).toHaveBeenNthCalledWith(3, '/two/first.txt') - expect(fs.unlink).toHaveBeenNthCalledWith(4, '/two/second.txt') - } - - expect.assertions(4) - }) - - it('createMany will remove files from only the creates that fail', async () => { - try { - await prismaClient.dumbo.createMany({ - data: [ - // This one will go through - { - firstUpload: '/one/first.txt', - secondUpload: '/one/second.txt', - }, - { - firstUpload: '/two/first.txt', - secondUpload: '/two/second.txt', - id: 'break2', - }, - ], - }) - } catch (e) { - console.log(e) - expect(fs.unlink).toHaveBeenCalledTimes(2) - expect(fs.unlink).toHaveBeenNthCalledWith(1, '/two/first.txt') - expect(fs.unlink).toHaveBeenNthCalledWith(2, '/two/second.txt') - } - - expect.assertions(4) - }) - }) - - describe('update', () => { - it('update will remove the old file, save new one', async () => { - const dummy = await prismaClient.dummy.create({ - data: { - uploadField: '/tmp/old.txt', - }, - }) - - const updatedDummy = await prismaClient.dummy.update({ - data: { - uploadField: '/tmp/new.txt', - }, - where: { - id: dummy.id, - }, - }) - - expect(fs.unlink).toHaveBeenCalledWith('/tmp/old.txt') - expect(updatedDummy.uploadField).toBe('/tmp/new.txt') - }) - }) - - describe('delete', () => { - it('delete will remove all uploads', async () => { - const dumbo = await prismaClient.dumbo.create({ - data: { - firstUpload: '/tmp/first.txt', - secondUpload: '/tmp/second.txt', - }, - }) - - await prismaClient.dumbo.delete({ - where: { - id: dumbo.id, - }, - }) - - expect(fs.unlink).toHaveBeenCalledTimes(2) - expect(fs.unlink).toHaveBeenCalledWith('/tmp/first.txt') - expect(fs.unlink).toHaveBeenCalledWith('/tmp/second.txt') - }) - }) - }) -}) diff --git a/packages/uploads/src/__tests__/queryExtensions.test.ts b/packages/uploads/src/__tests__/queryExtensions.test.ts new file mode 100644 index 000000000000..317a0f46ef59 --- /dev/null +++ b/packages/uploads/src/__tests__/queryExtensions.test.ts @@ -0,0 +1,183 @@ +import fs from 'node:fs/promises' + +import { describe, it, vi, expect, beforeEach } from 'vitest' + +import { FileSystemStorage } from '../FileSystemStorage.js' +import type { UploadsConfig } from '../prismaExtension.js' +import { setupUploads } from '../setup.js' + +// @MARK: use the local prisma client in the test +import { PrismaClient } from './prisma-client' + +vi.mock('node:fs/promises', () => ({ + default: { + writeFile: vi.fn(), + unlink: vi.fn(), + readFile: vi.fn(() => { + return 'MOCKED_FILE_CONTENT' + }), + copyFile: vi.fn(), + }, +})) +describe('Query extensions', () => { + const uploadConfig: UploadsConfig = { + dummy: { + fields: 'uploadField', + }, + dumbo: { + fields: ['firstUpload', 'secondUpload'], + }, + } + + const { prismaExtension, uploadsProcessors } = setupUploads( + uploadConfig, + new FileSystemStorage({ + baseDir: '/tmp', + }), + ) + + const prismaClient = new PrismaClient().$extends(prismaExtension) + + beforeEach(() => { + vi.resetAllMocks() + }) + + const sampleFile = new File(['heres-some-content'], 'dummy.txt', { + type: 'text/plain', + }) + + describe('create', () => { + it('create will save files', async () => { + const processedData = await uploadsProcessors.processDummyUploads({ + uploadField: sampleFile, + }) + + expect(fs.writeFile).toHaveBeenCalled() + const dummy = await prismaClient.dummy.create({ + data: processedData, + }) + + expect(dummy).toMatchObject({ + uploadField: expect.stringMatching(/\/tmp\/.*\.txt$/), + }) + }) + + it('will remove the file if the create fails', async () => { + try { + await prismaClient.dumbo.create({ + data: { + firstUpload: '/tmp/first.txt', + secondUpload: '/bazinga/second.txt', + // @ts-expect-error Checking the error here + id: 'this-is-the-incorrect-type', + }, + }) + } catch { + expect(fs.unlink).toHaveBeenNthCalledWith(1, '/tmp/first.txt') + expect(fs.unlink).toHaveBeenNthCalledWith(2, '/bazinga/second.txt') + } + + expect.assertions(2) + }) + }) + + // Not implemented yet + // Ideally it would just work automatically... but I guess we need to do all the variants + describe.skip('createMany', () => { + it('createMany will remove files if all the create fails', async () => { + try { + await prismaClient.dumbo.createMany({ + data: [ + { + firstUpload: '/one/first.txt', + secondUpload: '/one/second.txt', + id: 'break', + }, + { + firstUpload: '/two/first.txt', + secondUpload: '/two/second.txt', + id: 'break2', + }, + ], + }) + } catch (e) { + expect(fs.unlink).toHaveBeenCalledTimes(4) + expect(fs.unlink).toHaveBeenNthCalledWith(1, '/one/first.txt') + expect(fs.unlink).toHaveBeenNthCalledWith(2, '/one/second.txt') + expect(fs.unlink).toHaveBeenNthCalledWith(3, '/two/first.txt') + expect(fs.unlink).toHaveBeenNthCalledWith(4, '/two/second.txt') + } + + expect.assertions(4) + }) + + it('createMany will remove files from only the creates that fail', async () => { + try { + await prismaClient.dumbo.createMany({ + data: [ + // This one will go through + { + firstUpload: '/one/first.txt', + secondUpload: '/one/second.txt', + }, + { + firstUpload: '/two/first.txt', + secondUpload: '/two/second.txt', + id: 'break2', + }, + ], + }) + } catch (e) { + console.log(e) + expect(fs.unlink).toHaveBeenCalledTimes(2) + expect(fs.unlink).toHaveBeenNthCalledWith(1, '/two/first.txt') + expect(fs.unlink).toHaveBeenNthCalledWith(2, '/two/second.txt') + } + + expect.assertions(4) + }) + }) + + describe('update', () => { + it('update will remove the old file, save new one', async () => { + const dummy = await prismaClient.dummy.create({ + data: { + uploadField: '/tmp/old.txt', + }, + }) + + const updatedDummy = await prismaClient.dummy.update({ + data: { + uploadField: '/tmp/new.txt', + }, + where: { + id: dummy.id, + }, + }) + + expect(fs.unlink).toHaveBeenCalledWith('/tmp/old.txt') + expect(updatedDummy.uploadField).toBe('/tmp/new.txt') + }) + }) + + describe('delete', () => { + it('delete will remove all uploads', async () => { + const dumbo = await prismaClient.dumbo.create({ + data: { + firstUpload: '/tmp/first.txt', + secondUpload: '/tmp/second.txt', + }, + }) + + await prismaClient.dumbo.delete({ + where: { + id: dumbo.id, + }, + }) + + expect(fs.unlink).toHaveBeenCalledTimes(2) + expect(fs.unlink).toHaveBeenCalledWith('/tmp/first.txt') + expect(fs.unlink).toHaveBeenCalledWith('/tmp/second.txt') + }) + }) +}) diff --git a/packages/uploads/src/__tests__/resultExtensions.test.ts b/packages/uploads/src/__tests__/resultExtensions.test.ts new file mode 100644 index 000000000000..bc77605ec864 --- /dev/null +++ b/packages/uploads/src/__tests__/resultExtensions.test.ts @@ -0,0 +1,48 @@ +import { describe, it, beforeAll, expect } from 'vitest' + +import { MemoryStorage } from '../MemoryStorage.js' +import type { UploadsConfig } from '../prismaExtension.js' +import { setupUploads } from '../setup.js' + +// @MARK: use the local prisma client in the test +import { PrismaClient } from './prisma-client' + +describe('Result extensions', () => { + const uploadConfig: UploadsConfig = { + dummy: { + fields: 'uploadField', + }, + dumbo: { + fields: ['firstUpload', 'secondUpload'], + }, + } + + const { prismaExtension } = setupUploads( + uploadConfig, + new MemoryStorage({ + baseDir: '/tmp', + }), + ) + + const prismaClient = new PrismaClient().$extends(prismaExtension) + + describe('withSignedUrl', () => { + beforeAll(() => { + process.env.RW_UPLOADS_SECRET = 'gasdg' + }) + it('Generates signed urls for each upload field', async () => { + const dumbo = await prismaClient.dumbo.create({ + data: { + firstUpload: '/dumbo/first.txt', + secondUpload: '/dumbo/second.txt', + }, + }) + + const signedUrlDumbo = await dumbo.withSignedUrl(1000) + expect(signedUrlDumbo.firstUpload).toContain('path=%2Fdumbo%2Ffirst.txt') + expect(signedUrlDumbo.secondUpload).toContain( + 'path=%2Fdumbo%2Fsecond.txt', + ) + }) + }) +}) diff --git a/packages/uploads/src/prismaExtension.ts b/packages/uploads/src/prismaExtension.ts index 9ea56f21bd74..846fedd6f352 100644 --- a/packages/uploads/src/prismaExtension.ts +++ b/packages/uploads/src/prismaExtension.ts @@ -7,6 +7,7 @@ import type * as runtime from '@prisma/client/runtime/library' // import { PrismaClient } from './__tests__/prisma-client/index.js' // import { Prisma } from './__tests__/prisma-client/index.js' import { fileToDataUri } from './fileSave.utils.js' +import { generateSignedQueryParams } from './lib/signedUrls.js' import type { StorageAdapter } from './StorageAdapter.js' type FilterOutDollarPrefixed = T extends `$${string}` @@ -45,6 +46,12 @@ export const createUploadsExtension = ( modelData: Record, ) => (this: T) => Promise } + withSignedUrl: { + needs: Record + compute: ( + modelData: Record, + ) => (this: T) => Promise + } } } @@ -62,9 +69,8 @@ export const createUploadsExtension = ( ) { // With strict mode you cannot call findFirstOrThrow with the same args, because it is a union type // Ideally there's a better way to do this - const record = await ( - prismaInstance[model as ModelNames] as any - ).findFirstOrThrow(args) + const record = + await prismaInstance[model as ModelNames].findFirstOrThrow(args) // Delete the file from the file system fields.forEach(async (field) => { @@ -156,6 +162,27 @@ export const createUploadsExtension = ( } }, }, + withSignedUrl: { + needs, + compute(modelData) { + return (expiresIn?: number) => { + const signedUrlFields: Record = {} + + for (const field of uploadFields) { + signedUrlFields[field] = generateSignedQueryParams('/HARDCODED', { + filePath: modelData[field] as string, + expiresIn: expiresIn, + }) + } + + return { + // modelData is of type unknown at this point + ...(modelData as any), + ...signedUrlFields, + } + } + }, + }, } } From ef8aa4ac0125a38e16bdf05f106fc193650d9bde Mon Sep 17 00:00:00 2001 From: Danny Choudhury Date: Mon, 19 Aug 2024 17:43:51 +0700 Subject: [PATCH 53/91] Get UploadConfig type working with fields --- packages/uploads/src/prismaExtension.ts | 35 ++++++++++++++++--------- 1 file changed, 23 insertions(+), 12 deletions(-) diff --git a/packages/uploads/src/prismaExtension.ts b/packages/uploads/src/prismaExtension.ts index 846fedd6f352..bf343b729992 100644 --- a/packages/uploads/src/prismaExtension.ts +++ b/packages/uploads/src/prismaExtension.ts @@ -2,10 +2,10 @@ import { PrismaClient } from '@prisma/client' import { Prisma } from '@prisma/client' import type * as runtime from '@prisma/client/runtime/library' -// @TODO(TS): UploadsConfig behaves differently here.. probably -// the prisma-override not quite there yet? +// local imports // import { PrismaClient } from './__tests__/prisma-client/index.js' -// import { Prisma } from './__tests__/prisma-client/index.js' +// import type { Prisma } from './__tests__/prisma-client/index.js' +// import type * as PrismaAll from './__tests__/prisma-client/index.js' import { fileToDataUri } from './fileSave.utils.js' import { generateSignedQueryParams } from './lib/signedUrls.js' import type { StorageAdapter } from './StorageAdapter.js' @@ -19,16 +19,24 @@ type FilterOutDollarPrefixed = T extends `$${string}` // Filter out $on, $connect, etc. type ModelNames = FilterOutDollarPrefixed -export type UploadConfigForModel = { - // @TODO(TS): I want the fields here to be the fields of the model - fields: string[] | string +type PrismaModelFields = keyof Prisma.Result< + PrismaClient[MName], + any, + 'findFirstOrThrow' +> + +export type UploadConfigForModel = { + fields: + | PrismaModelFields + | PrismaModelFields[] savePath?: ((args: unknown) => string) | string fileName?: (args: unknown) => string onFileSaved?: (filePath: string) => void | Promise } -export type UploadsConfig = - Record +export type UploadsConfig = { + [K in MNames]?: UploadConfigForModel +} export const createUploadsExtension = ( config: UploadsConfig, @@ -70,6 +78,7 @@ export const createUploadsExtension = ( // With strict mode you cannot call findFirstOrThrow with the same args, because it is a union type // Ideally there's a better way to do this const record = + // @ts-expect-error laskndglkn await prismaInstance[model as ModelNames].findFirstOrThrow(args) // Delete the file from the file system @@ -84,10 +93,12 @@ export const createUploadsExtension = ( const resultExtends = {} as ResultExtends for (const modelName in config) { // Guaranteed to have modelConfig, we're looping over config 🙄 - const modelConfig = config[modelName as MNames] as UploadConfigForModel - const uploadFields = Array.isArray(modelConfig.fields) - ? modelConfig.fields - : [modelConfig.fields] + const modelConfig = config[modelName] + const uploadFields = ( + Array.isArray(modelConfig.fields) + ? modelConfig.fields + : [modelConfig.fields] + ) as string[] queryExtends[modelName] = { async create({ query, args }) { From a75133700e46581019d6c68b12b737e9bd49d655 Mon Sep 17 00:00:00 2001 From: Danny Choudhury Date: Mon, 19 Aug 2024 18:18:34 +0700 Subject: [PATCH 54/91] Fix types for resultExtension --- packages/uploads/src/createProcessors.ts | 14 +++++++++----- packages/uploads/src/prismaExtension.ts | 14 ++++++++------ packages/uploads/tsconfig.json | 2 +- packages/uploads/vitest.config.mts | 16 ++++++++++------ 4 files changed, 28 insertions(+), 18 deletions(-) diff --git a/packages/uploads/src/createProcessors.ts b/packages/uploads/src/createProcessors.ts index af29a18a32c9..470d17342a84 100644 --- a/packages/uploads/src/createProcessors.ts +++ b/packages/uploads/src/createProcessors.ts @@ -47,11 +47,15 @@ export const createUploadProcessors = ( Object.keys(uploadConfig).forEach((model) => { const modelKey = model as keyof typeof uploadConfig - const currentModelUploadFields = Array.isArray( - uploadConfig[modelKey].fields, - ) - ? uploadConfig[modelKey].fields - : [uploadConfig[modelKey].fields] + const currentModelConfig = uploadConfig[modelKey] + + if (!currentModelConfig) { + return + } + + const currentModelUploadFields = Array.isArray(currentModelConfig.fields) + ? currentModelConfig.fields + : [currentModelConfig.fields] const capitalCaseModel = `${model.charAt(0).toUpperCase() + model.slice(1)}` const processorKey = `process${capitalCaseModel}Uploads` as keyof Processors diff --git a/packages/uploads/src/prismaExtension.ts b/packages/uploads/src/prismaExtension.ts index bf343b729992..8dd07260f0d8 100644 --- a/packages/uploads/src/prismaExtension.ts +++ b/packages/uploads/src/prismaExtension.ts @@ -1,11 +1,8 @@ import { PrismaClient } from '@prisma/client' -import { Prisma } from '@prisma/client' +import type { Prisma } from '@prisma/client' +import { Prisma as PrismaExtension } from '@prisma/client/extension' import type * as runtime from '@prisma/client/runtime/library' -// local imports -// import { PrismaClient } from './__tests__/prisma-client/index.js' -// import type { Prisma } from './__tests__/prisma-client/index.js' -// import type * as PrismaAll from './__tests__/prisma-client/index.js' import { fileToDataUri } from './fileSave.utils.js' import { generateSignedQueryParams } from './lib/signedUrls.js' import type { StorageAdapter } from './StorageAdapter.js' @@ -94,6 +91,11 @@ export const createUploadsExtension = ( for (const modelName in config) { // Guaranteed to have modelConfig, we're looping over config 🙄 const modelConfig = config[modelName] + + if (!modelConfig) { + continue + } + const uploadFields = ( Array.isArray(modelConfig.fields) ? modelConfig.fields @@ -197,7 +199,7 @@ export const createUploadsExtension = ( } } - return Prisma.defineExtension((client) => { + return PrismaExtension.defineExtension((client) => { return client.$extends({ name: 'redwood-upload-prisma-plugin', query: queryExtends, diff --git a/packages/uploads/tsconfig.json b/packages/uploads/tsconfig.json index 3484bfda0d5c..84e3d7c1df9d 100644 --- a/packages/uploads/tsconfig.json +++ b/packages/uploads/tsconfig.json @@ -8,7 +8,7 @@ "outDir": "dist" }, "include": ["src", "prisma-override.d.ts"], - + "exclude": ["dist", "node_modules", "**/__mocks__"], "references": [ { "path": "../project-config" diff --git a/packages/uploads/vitest.config.mts b/packages/uploads/vitest.config.mts index 92ccad350ca8..2903bac00e26 100644 --- a/packages/uploads/vitest.config.mts +++ b/packages/uploads/vitest.config.mts @@ -1,9 +1,10 @@ +import path from 'path' +import { fileURLToPath } from 'url' + import { defineConfig, configDefaults } from 'vitest/config' -import { fileURLToPath } from 'url'; -import path from 'path'; -const __filename = fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename); +const __filename = fileURLToPath(import.meta.url) +const __dirname = path.dirname(__filename) export default defineConfig({ test: { @@ -13,9 +14,12 @@ export default defineConfig({ }, globalSetup: ['vitest.setup.mts'], alias: { - // We alias prisma client, so that it doesn't interfere with other packages in the mono repo + // We alias prisma client, otherwise you'll get "prisma client not initialized" + '@prisma/client/extension': path.resolve( + __dirname, + '../../node_modules/@prisma/client/extension.js', + ), '@prisma/client': path.resolve(__dirname, 'src/__tests__/prisma-client'), }, }, - }) From 8cd00818d5cf7ea857bdd3b581dfaab9cd4e77fc Mon Sep 17 00:00:00 2001 From: Danny Choudhury Date: Mon, 19 Aug 2024 18:48:59 +0700 Subject: [PATCH 55/91] Keep these changes --- .../src/__tests__/queryExtensions.test.ts | 5 +++- .../src/__tests__/resultExtensions.test.ts | 23 +++++++++++++++++-- packages/uploads/tsconfig.json | 1 + 3 files changed, 26 insertions(+), 3 deletions(-) diff --git a/packages/uploads/src/__tests__/queryExtensions.test.ts b/packages/uploads/src/__tests__/queryExtensions.test.ts index 317a0f46ef59..7a5b2cc1f4c1 100644 --- a/packages/uploads/src/__tests__/queryExtensions.test.ts +++ b/packages/uploads/src/__tests__/queryExtensions.test.ts @@ -7,7 +7,7 @@ import type { UploadsConfig } from '../prismaExtension.js' import { setupUploads } from '../setup.js' // @MARK: use the local prisma client in the test -import { PrismaClient } from './prisma-client' +import { PrismaClient } from './prisma-client/index.js' vi.mock('node:fs/promises', () => ({ default: { @@ -91,11 +91,13 @@ describe('Query extensions', () => { { firstUpload: '/one/first.txt', secondUpload: '/one/second.txt', + // @ts-expect-error Intentional bro id: 'break', }, { firstUpload: '/two/first.txt', secondUpload: '/two/second.txt', + // @ts-expect-error Intentional bro id: 'break2', }, ], @@ -123,6 +125,7 @@ describe('Query extensions', () => { { firstUpload: '/two/first.txt', secondUpload: '/two/second.txt', + // @ts-expect-error Intentional bro id: 'break2', }, ], diff --git a/packages/uploads/src/__tests__/resultExtensions.test.ts b/packages/uploads/src/__tests__/resultExtensions.test.ts index bc77605ec864..5daf296e21cd 100644 --- a/packages/uploads/src/__tests__/resultExtensions.test.ts +++ b/packages/uploads/src/__tests__/resultExtensions.test.ts @@ -5,7 +5,7 @@ import type { UploadsConfig } from '../prismaExtension.js' import { setupUploads } from '../setup.js' // @MARK: use the local prisma client in the test -import { PrismaClient } from './prisma-client' +import { PrismaClient } from './prisma-client/index.js' describe('Result extensions', () => { const uploadConfig: UploadsConfig = { @@ -38,11 +38,30 @@ describe('Result extensions', () => { }, }) - const signedUrlDumbo = await dumbo.withSignedUrl(1000) + const signedUrlDumbo = await dumbo.withSignedUrl() expect(signedUrlDumbo.firstUpload).toContain('path=%2Fdumbo%2Ffirst.txt') expect(signedUrlDumbo.secondUpload).toContain( 'path=%2Fdumbo%2Fsecond.txt', ) }) + + it('laskdng', async () => { + const customClient = new PrismaClient().$extends({ + result: { + dumbo: { + helloJosh: { + compute() { + return () => { + return 'hello josh' + } + }, + }, + }, + }, + }) + + const dumbo = await customClient.dumbo.findFirst({ where: { id: 1 } }) + dumbo?.helloJosh() + }) }) }) diff --git a/packages/uploads/tsconfig.json b/packages/uploads/tsconfig.json index 84e3d7c1df9d..7458881c480d 100644 --- a/packages/uploads/tsconfig.json +++ b/packages/uploads/tsconfig.json @@ -8,6 +8,7 @@ "outDir": "dist" }, "include": ["src", "prisma-override.d.ts"], + // Excluding types here causes types to be inaccurate in tests "exclude": ["dist", "node_modules", "**/__mocks__"], "references": [ { From 32942a4637d6c9ad99dadee290e657ddaa218bc5 Mon Sep 17 00:00:00 2001 From: Danny Choudhury Date: Mon, 19 Aug 2024 19:15:25 +0700 Subject: [PATCH 56/91] Warn comment on vitest alias --- packages/uploads/vitest.config.mts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/uploads/vitest.config.mts b/packages/uploads/vitest.config.mts index 2903bac00e26..ee7fa5cb1f93 100644 --- a/packages/uploads/vitest.config.mts +++ b/packages/uploads/vitest.config.mts @@ -15,6 +15,7 @@ export default defineConfig({ globalSetup: ['vitest.setup.mts'], alias: { // We alias prisma client, otherwise you'll get "prisma client not initialized" + // Important to have the subpath first here '@prisma/client/extension': path.resolve( __dirname, '../../node_modules/@prisma/client/extension.js', From 32e870de3589e6f88b9442122ee80c0eb5a6c5f8 Mon Sep 17 00:00:00 2001 From: Danny Choudhury Date: Mon, 19 Aug 2024 19:41:48 +0700 Subject: [PATCH 57/91] Fix typing on keys being hardcoded --- packages/uploads/src/createProcessors.ts | 11 ++++++----- packages/uploads/src/prismaExtension.ts | 2 +- packages/uploads/src/setup.ts | 12 ++++++------ 3 files changed, 13 insertions(+), 12 deletions(-) diff --git a/packages/uploads/src/createProcessors.ts b/packages/uploads/src/createProcessors.ts index 470d17342a84..a7e0349e0ae8 100644 --- a/packages/uploads/src/createProcessors.ts +++ b/packages/uploads/src/createProcessors.ts @@ -1,6 +1,5 @@ import { ulid } from 'ulid' -import type { UploadsConfig } from './prismaExtension.js' import type { SaveOptionsOverride, StorageAdapter } from './StorageAdapter.js' // Assumes you pass in the graphql type @@ -25,14 +24,16 @@ export const createFileListProcessor = (storage: StorageAdapter) => { This creates a processor for each model in the uploads config (i.e. tied to a model in the prisma schema) The processor will only handle single file uploads, not file lists. */ -export const createUploadProcessors = ( - uploadConfig: UploadsConfig, +export const createUploadProcessors = < + TUploadConfig extends Record, +>( + uploadConfig: TUploadConfig, storage: StorageAdapter, ) => { - type modelNamesInUploadConfig = keyof typeof uploadConfig + type modelNamesInUploadConfig = keyof TUploadConfig type uploadProcessorNames = - `process${Capitalize}Uploads` + `process${Capitalize}Uploads` type Processors = { [K in uploadProcessorNames]: >( diff --git a/packages/uploads/src/prismaExtension.ts b/packages/uploads/src/prismaExtension.ts index 8dd07260f0d8..de81bb4da1e7 100644 --- a/packages/uploads/src/prismaExtension.ts +++ b/packages/uploads/src/prismaExtension.ts @@ -14,7 +14,7 @@ type FilterOutDollarPrefixed = T extends `$${string}` : T // Filter out $on, $connect, etc. -type ModelNames = FilterOutDollarPrefixed +export type ModelNames = FilterOutDollarPrefixed type PrismaModelFields = keyof Prisma.Result< PrismaClient[MName], diff --git a/packages/uploads/src/setup.ts b/packages/uploads/src/setup.ts index e21042326488..3b470bc7e501 100644 --- a/packages/uploads/src/setup.ts +++ b/packages/uploads/src/setup.ts @@ -2,21 +2,21 @@ import { createFileListProcessor, createUploadProcessors, } from './createProcessors.js' -import { - createUploadsExtension, - type UploadsConfig, -} from './prismaExtension.js' +import type { ModelNames, UploadsConfig } from './prismaExtension.js' +import { createUploadsExtension } from './prismaExtension.js' import type { StorageAdapter } from './StorageAdapter.js' -export const setupUploads = ( - uploadsConfig: UploadsConfig, +export const setupUploads = ( + uploadsConfig: UploadsConfig, storageAdapter: StorageAdapter, ) => { const prismaExtension = createUploadsExtension(uploadsConfig, storageAdapter) + const uploadsProcessors = createUploadProcessors( uploadsConfig, storageAdapter, ) + const fileListProcessor = createFileListProcessor(storageAdapter) return { From 4274d95d4a0265901975d0bb8d24eb9d05cf644d Mon Sep 17 00:00:00 2001 From: Danny Choudhury Date: Tue, 20 Aug 2024 13:32:05 +0700 Subject: [PATCH 58/91] Refactor signed url generator --- .../src/__tests__/resultExtensions.test.ts | 47 +++-- packages/uploads/src/lib/signedUrls.ts | 163 ++++++++++-------- packages/uploads/src/prismaExtension.ts | 28 ++- packages/uploads/src/setup.ts | 13 +- 4 files changed, 140 insertions(+), 111 deletions(-) diff --git a/packages/uploads/src/__tests__/resultExtensions.test.ts b/packages/uploads/src/__tests__/resultExtensions.test.ts index 5daf296e21cd..28dbec3a296d 100644 --- a/packages/uploads/src/__tests__/resultExtensions.test.ts +++ b/packages/uploads/src/__tests__/resultExtensions.test.ts @@ -1,4 +1,4 @@ -import { describe, it, beforeAll, expect } from 'vitest' +import { describe, it, expect, vi } from 'vitest' import { MemoryStorage } from '../MemoryStorage.js' import type { UploadsConfig } from '../prismaExtension.js' @@ -7,6 +7,20 @@ import { setupUploads } from '../setup.js' // @MARK: use the local prisma client in the test import { PrismaClient } from './prisma-client/index.js' +vi.mock('@redwoodjs/project-config', async (importOriginal) => { + const originalProjectConfig = (await importOriginal()) as any + return { + ...originalProjectConfig, + getConfig: () => { + return { + web: { + apiUrl: '/.redwood/functions', + }, + } + }, + } +}) + describe('Result extensions', () => { const uploadConfig: UploadsConfig = { dummy: { @@ -22,14 +36,15 @@ describe('Result extensions', () => { new MemoryStorage({ baseDir: '/tmp', }), + { + endpoint: '/signed-url', + secret: 'my-sekret', + }, ) const prismaClient = new PrismaClient().$extends(prismaExtension) describe('withSignedUrl', () => { - beforeAll(() => { - process.env.RW_UPLOADS_SECRET = 'gasdg' - }) it('Generates signed urls for each upload field', async () => { const dumbo = await prismaClient.dumbo.create({ data: { @@ -38,30 +53,14 @@ describe('Result extensions', () => { }, }) - const signedUrlDumbo = await dumbo.withSignedUrl() + const signedUrlDumbo = await dumbo.withSignedUrl(254) + expect(signedUrlDumbo.firstUpload).toContain( + '/.redwood/functions/signed-url', + ) expect(signedUrlDumbo.firstUpload).toContain('path=%2Fdumbo%2Ffirst.txt') expect(signedUrlDumbo.secondUpload).toContain( 'path=%2Fdumbo%2Fsecond.txt', ) }) - - it('laskdng', async () => { - const customClient = new PrismaClient().$extends({ - result: { - dumbo: { - helloJosh: { - compute() { - return () => { - return 'hello josh' - } - }, - }, - }, - }, - }) - - const dumbo = await customClient.dumbo.findFirst({ where: { id: 1 } }) - dumbo?.helloJosh() - }) }) }) diff --git a/packages/uploads/src/lib/signedUrls.ts b/packages/uploads/src/lib/signedUrls.ts index b626e8e1dff1..5f4c240c4afb 100644 --- a/packages/uploads/src/lib/signedUrls.ts +++ b/packages/uploads/src/lib/signedUrls.ts @@ -1,72 +1,103 @@ import crypto from 'node:crypto' -export const generateSignature = (filePath: string, expiresInMs?: number) => { - if (!process.env.RW_UPLOADS_SECRET) { - throw new Error( - 'Please configure RW_UPLOADS_SECRET in your environment variables', - ) - } +import { getConfig } from '@redwoodjs/project-config' - if (expiresInMs) { - const expires = Date.now() + expiresInMs - const signature = crypto - .createHmac('sha256', process.env.RW_UPLOADS_SECRET) - .update(`${filePath}:${expires}`) - .digest('hex') - - return { expires, signature } - } else { - // Does not expire - const signature = crypto - .createHmac('sha256', process.env.RW_UPLOADS_SECRET) - .update(filePath) - .digest('hex') - - return { - signature, - expires: undefined, - } - } +export type SignedUrlSettings = { + endpoint: string // The path to the signed url endpoint, or a full url (include http(s)://) + secret: string // The secret to sign the urls with } -/** - * The signature and expires have to be extracted from the URL - */ -export const validateSignature = ({ - signature, - filePath, - expires, -}: { - filePath: string - signature: string - expires?: number -}) => { - // Note, expires not the same as expiresIn - if (!process.env.RW_UPLOADS_SECRET) { - throw new Error( - 'Please configure RW_UPLOADS_SECRET in your environment variables', - ) +export class UrlSigner { + private secret: string + private endpoint: string + + constructor({ secret, endpoint }: SignedUrlSettings) { + this.secret = secret + this.endpoint = endpoint + + this.endpoint = endpoint.startsWith('http') + ? endpoint + : `${getConfig().web.apiUrl}${endpoint}` } - if (expires) { - // No need to validate if the signature has expired - if (Date.now() > expires) { - throw new Error('Signature has expired') + generateSignature(filePath: string, expiresInMs?: number) { + if (!this.secret) { + throw new Error('Please configure the secret') } - } - const validSignature = expires - ? crypto - .createHmac('sha256', process.env.RW_UPLOADS_SECRET) + if (expiresInMs) { + const expires = Date.now() + expiresInMs + const signature = crypto + .createHmac('sha256', this.secret) .update(`${filePath}:${expires}`) .digest('hex') - : crypto - .createHmac('sha256', process.env.RW_UPLOADS_SECRET) - .update(`${filePath}`) + + return { expires, signature } + } else { + // Does not expire + const signature = crypto + .createHmac('sha256', this.secret) + .update(filePath) .digest('hex') - if (validSignature !== signature) { - throw new Error('Invalid signature') + return { + signature, + expires: undefined, + } + } + } + + /** + * The signature and expires have to be extracted from the URL + */ + validateSignature({ + signature, + filePath, + expires, + }: { + filePath: string + signature: string + expires?: number + }) { + if (!this.secret) { + throw new Error('Please configure the secret') + } + + if (expires) { + // No need to validate if the signature has expired + if (Date.now() > expires) { + throw new Error('Signature has expired') + } + } + + const validSignature = expires + ? crypto + .createHmac('sha256', this.secret) + .update(`${filePath}:${expires}`) + .digest('hex') + : crypto + .createHmac('sha256', this.secret) + .update(`${filePath}`) + .digest('hex') + + if (validSignature !== signature) { + throw new Error('Invalid signature') + } + } + + generateSignedUrl(filePath: string, expiresIn?: number) { + const { signature, expires } = this.generateSignature(filePath, expiresIn) + + // This way you can pass in a path with params already + const params = new URLSearchParams() + params.set('s', signature) + if (expires) { + params.set('expires', expires.toString()) + } + + params.set('path', filePath) + + return `${this.endpoint}?${params.toString()}` } } @@ -80,26 +111,6 @@ export const getSignedDetailsFromUrl = (url: string) => { } } -type SigningParms = { filePath: string; expiresIn?: number } - -export const generateSignedQueryParams = ( - endpoint: string, - { filePath, expiresIn }: SigningParms, -) => { - const { signature, expires } = generateSignature(filePath, expiresIn) - - // This way you can pass in a path with params already - const params = new URLSearchParams() - params.set('s', signature) - if (expires) { - params.set('expires', expires.toString()) - } - - params.set('path', filePath) - - return `${endpoint}?${params.toString()}` -} - export const EXPIRES_IN = { seconds: (s: number) => s * 1000, minutes: (m: number) => m * 60 * 1000, diff --git a/packages/uploads/src/prismaExtension.ts b/packages/uploads/src/prismaExtension.ts index de81bb4da1e7..5e30eacf1952 100644 --- a/packages/uploads/src/prismaExtension.ts +++ b/packages/uploads/src/prismaExtension.ts @@ -3,8 +3,10 @@ import type { Prisma } from '@prisma/client' import { Prisma as PrismaExtension } from '@prisma/client/extension' import type * as runtime from '@prisma/client/runtime/library' + import { fileToDataUri } from './fileSave.utils.js' -import { generateSignedQueryParams } from './lib/signedUrls.js' +import type { SignedUrlSettings } from './lib/signedUrls.js' +import { UrlSigner } from './lib/signedUrls.js' import type { StorageAdapter } from './StorageAdapter.js' type FilterOutDollarPrefixed = T extends `$${string}` @@ -26,9 +28,6 @@ export type UploadConfigForModel = { fields: | PrismaModelFields | PrismaModelFields[] - savePath?: ((args: unknown) => string) | string - fileName?: (args: unknown) => string - onFileSaved?: (filePath: string) => void | Promise } export type UploadsConfig = { @@ -38,11 +37,17 @@ export type UploadsConfig = { export const createUploadsExtension = ( config: UploadsConfig, storageAdapter: StorageAdapter, + signedUrlSettings?: SignedUrlSettings, ) => { // @TODO I think we can use Prisma.getExtensionContext(this) // instead of creating a new PrismaClient instance const prismaInstance = new PrismaClient() + let signedUrlGenerator: UrlSigner + if (signedUrlSettings) { + signedUrlGenerator = new UrlSigner(signedUrlSettings) + } + type ResultExtends = { [K in MNames]: { withDataUri: { @@ -55,7 +60,7 @@ export const createUploadsExtension = ( needs: Record compute: ( modelData: Record, - ) => (this: T) => Promise + ) => (this: T, expiresIn?: number) => Promise } } } @@ -179,13 +184,18 @@ export const createUploadsExtension = ( needs, compute(modelData) { return (expiresIn?: number) => { + if (!signedUrlGenerator) { + throw new Error( + 'Please supply signed url settings in setupUpload()', + ) + } const signedUrlFields: Record = {} for (const field of uploadFields) { - signedUrlFields[field] = generateSignedQueryParams('/HARDCODED', { - filePath: modelData[field] as string, - expiresIn: expiresIn, - }) + signedUrlFields[field] = signedUrlGenerator.generateSignedUrl( + modelData[field] as string, + expiresIn, + ) } return { diff --git a/packages/uploads/src/setup.ts b/packages/uploads/src/setup.ts index 3b470bc7e501..d7d20d894316 100644 --- a/packages/uploads/src/setup.ts +++ b/packages/uploads/src/setup.ts @@ -2,15 +2,24 @@ import { createFileListProcessor, createUploadProcessors, } from './createProcessors.js' -import type { ModelNames, UploadsConfig } from './prismaExtension.js' +import type { + ModelNames, + SignedUrlSettings, + UploadsConfig, +} from './prismaExtension.js' import { createUploadsExtension } from './prismaExtension.js' import type { StorageAdapter } from './StorageAdapter.js' export const setupUploads = ( uploadsConfig: UploadsConfig, storageAdapter: StorageAdapter, + signedUrlSettings?: SignedUrlSettings, ) => { - const prismaExtension = createUploadsExtension(uploadsConfig, storageAdapter) + const prismaExtension = createUploadsExtension( + uploadsConfig, + storageAdapter, + signedUrlSettings, + ) const uploadsProcessors = createUploadProcessors( uploadsConfig, From 33926d6de6d49111feab2e50741b27ff39d84c98 Mon Sep 17 00:00:00 2001 From: Danny Choudhury Date: Tue, 20 Aug 2024 13:50:01 +0700 Subject: [PATCH 59/91] Update tests too --- .../uploads/src/__tests__/signedUrls.test.ts | 106 ++++++++---------- packages/uploads/src/lib/signedUrls.ts | 33 ++++-- 2 files changed, 70 insertions(+), 69 deletions(-) diff --git a/packages/uploads/src/__tests__/signedUrls.test.ts b/packages/uploads/src/__tests__/signedUrls.test.ts index b5e517ef1fb9..72a021e28ab7 100644 --- a/packages/uploads/src/__tests__/signedUrls.test.ts +++ b/packages/uploads/src/__tests__/signedUrls.test.ts @@ -1,32 +1,23 @@ -import { - beforeAll, - describe, - expect, - beforeEach, - afterEach, - vi, - it, - test, -} from 'vitest' +import { describe, expect, beforeEach, afterEach, vi, it, test } from 'vitest' import { EXPIRES_IN, - generateSignature, - generateSignedQueryParams, + UrlSigner, getSignedDetailsFromUrl, - validateSignature, } from '../lib/signedUrls.js' -describe('Signed URLs', () => { - beforeAll(() => { - process.env.RW_UPLOADS_SECRET = 'bazinga' - }) +const signer = new UrlSigner({ + // Doing this means we don't need to mock getConfig + endpoint: 'https://myapiside.com/access-signed-file', + secret: 'bazinga-3-32-151', +}) +describe('UrlSigner', () => { it('Can creates a signature', () => { - const { signature, expires } = generateSignature( - '/tmp/myfile.txt', - EXPIRES_IN.days(5), - ) + const { signature, expiry: expires } = signer.generateSignature({ + filePath: '/tmp/myfile.txt', + expiresInMs: EXPIRES_IN.days(5), + }) expect(signature).toBeDefined() @@ -34,67 +25,68 @@ describe('Signed URLs', () => { }) it('throws with correct error when wrong expires passed', () => { - const { signature, expires } = generateSignature( - '/tmp/myfile.txt', - EXPIRES_IN.days(1), - ) + const { signature, expiry: expires } = signer.generateSignature({ + filePath: '/tmp/myfile.txt', + expiresInMs: EXPIRES_IN.days(1), + }) expect(() => - validateSignature({ + signer.validateSignature({ filePath: '/tmp/myfile.txt', signature, - expires, + expiry: expires, }), ).not.toThrow() expect(() => - validateSignature({ + signer.validateSignature({ filePath: '/tmp/myfile.txt', signature, - expires: 12512351, + expiry: 12512351, }), ).toThrowError('Signature has expired') }) it('Throws an invalid signature when signature is wrong', () => { - const { signature, expires } = generateSignature( - '/tmp/myfile.txt', - EXPIRES_IN.days(1), - ) + const { signature, expiry } = signer.generateSignature({ + filePath: '/tmp/myfile.txt', + expiresInMs: EXPIRES_IN.days(1), + }) expect(() => - validateSignature({ + signer.validateSignature({ filePath: '/tmp/myfile.txt', signature, - expires, + expiry, }), ).not.toThrow() expect(() => - validateSignature({ + signer.validateSignature({ filePath: '/tmp/myfile.txt', signature: 'im-the-wrong-signature', - expires, + expiry, }), ).toThrowError('Invalid signature') }) it('Throws an invalid signature when file path is wrong', () => { - const { signature, expires } = generateSignature( - '/tmp/myfile.txt', - EXPIRES_IN.days(20), - ) + const { signature, expiry } = signer.generateSignature({ + filePath: '/tmp/myfile.txt', + expiresInMs: EXPIRES_IN.days(20), + }) expect(() => - validateSignature({ + signer.validateSignature({ filePath: '/tmp/some-other-file.txt', signature, - expires, + expiry, }), ).toThrowError('Invalid signature') }) }) describe('Expired signature', () => { + // Seprate, so we can mock the times beforeEach(() => { vi.useFakeTimers() }) @@ -105,16 +97,16 @@ describe('Expired signature', () => { it('throws an error when the signature has expired', () => { const filePath = '/bazinga/kittens.png' - const { signature, expires } = generateSignature( + const { signature, expiry } = signer.generateSignature({ filePath, - EXPIRES_IN.minutes(15), - ) + expiresInMs: EXPIRES_IN.minutes(15), + }) const validation = () => - validateSignature({ + signer.validateSignature({ filePath, signature, - expires, + expiry, }) expect(validation).not.toThrow() @@ -138,15 +130,15 @@ test('Parses details related to signatures from a url string', () => { }) test('Generates a signed url', () => { - const signedQueryParams = generateSignedQueryParams('/files/bazinga', { - filePath: '/path/to/hello.txt', - expiresIn: EXPIRES_IN.days(1), - }) - - expect(signedQueryParams).toContain('/files/bazinga?s=') - expect(signedQueryParams).toContain('s=') - expect(signedQueryParams).toContain('expires=') - expect(signedQueryParams).toContain('path=') // The actual file path + const signedUrl = signer.generateSignedUrl( + '/files/bazinga', + EXPIRES_IN.days(1), + ) + + expect(signedUrl).toContain('https://myapiside.com/access-signed-file?s=') + expect(signedUrl).toMatch(/s=.*/) + expect(signedUrl).toMatch(/expires=[0-9]+/) + expect(signedUrl).toContain(`path=${encodeURIComponent('/files/bazinga')}`) // The actual file path }) // Util functions to make the tests more readable diff --git a/packages/uploads/src/lib/signedUrls.ts b/packages/uploads/src/lib/signedUrls.ts index 5f4c240c4afb..f15280400eaf 100644 --- a/packages/uploads/src/lib/signedUrls.ts +++ b/packages/uploads/src/lib/signedUrls.ts @@ -20,19 +20,25 @@ export class UrlSigner { : `${getConfig().web.apiUrl}${endpoint}` } - generateSignature(filePath: string, expiresInMs?: number) { + generateSignature({ + filePath, + expiresInMs, + }: { + filePath: string + expiresInMs?: number + }) { if (!this.secret) { throw new Error('Please configure the secret') } if (expiresInMs) { - const expires = Date.now() + expiresInMs + const expiry = Date.now() + expiresInMs const signature = crypto .createHmac('sha256', this.secret) - .update(`${filePath}:${expires}`) + .update(`${filePath}:${expiry}`) .digest('hex') - return { expires, signature } + return { expiry, signature } } else { // Does not expire const signature = crypto @@ -42,7 +48,7 @@ export class UrlSigner { return { signature, - expires: undefined, + expiry: undefined, } } } @@ -53,27 +59,27 @@ export class UrlSigner { validateSignature({ signature, filePath, - expires, + expiry, }: { filePath: string signature: string - expires?: number + expiry?: number }) { if (!this.secret) { throw new Error('Please configure the secret') } - if (expires) { + if (expiry) { // No need to validate if the signature has expired - if (Date.now() > expires) { + if (Date.now() > expiry) { throw new Error('Signature has expired') } } - const validSignature = expires + const validSignature = expiry ? crypto .createHmac('sha256', this.secret) - .update(`${filePath}:${expires}`) + .update(`${filePath}:${expiry}`) .digest('hex') : crypto .createHmac('sha256', this.secret) @@ -86,7 +92,10 @@ export class UrlSigner { } generateSignedUrl(filePath: string, expiresIn?: number) { - const { signature, expires } = this.generateSignature(filePath, expiresIn) + const { signature, expiry: expires } = this.generateSignature({ + filePath, + expiresInMs: expiresIn, + }) // This way you can pass in a path with params already const params = new URLSearchParams() From 4cebfd19112316afe7810291058c6a5442519dc2 Mon Sep 17 00:00:00 2001 From: Danny Choudhury Date: Tue, 20 Aug 2024 14:33:49 +0700 Subject: [PATCH 60/91] Pass urlSigner instance to setup --- .../uploads/src/__tests__/resultExtensions.test.ts | 5 +++-- packages/uploads/src/prismaExtension.ts | 14 ++++---------- packages/uploads/src/setup.ts | 11 ++++------- 3 files changed, 11 insertions(+), 19 deletions(-) diff --git a/packages/uploads/src/__tests__/resultExtensions.test.ts b/packages/uploads/src/__tests__/resultExtensions.test.ts index 28dbec3a296d..23b81e051e28 100644 --- a/packages/uploads/src/__tests__/resultExtensions.test.ts +++ b/packages/uploads/src/__tests__/resultExtensions.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect, vi } from 'vitest' +import { UrlSigner } from '../lib/signedUrls.js' import { MemoryStorage } from '../MemoryStorage.js' import type { UploadsConfig } from '../prismaExtension.js' import { setupUploads } from '../setup.js' @@ -36,10 +37,10 @@ describe('Result extensions', () => { new MemoryStorage({ baseDir: '/tmp', }), - { + new UrlSigner({ endpoint: '/signed-url', secret: 'my-sekret', - }, + }), ) const prismaClient = new PrismaClient().$extends(prismaExtension) diff --git a/packages/uploads/src/prismaExtension.ts b/packages/uploads/src/prismaExtension.ts index 5e30eacf1952..c28f10b3006d 100644 --- a/packages/uploads/src/prismaExtension.ts +++ b/packages/uploads/src/prismaExtension.ts @@ -5,8 +5,7 @@ import type * as runtime from '@prisma/client/runtime/library' import { fileToDataUri } from './fileSave.utils.js' -import type { SignedUrlSettings } from './lib/signedUrls.js' -import { UrlSigner } from './lib/signedUrls.js' +import type { UrlSigner } from './lib/signedUrls.js' import type { StorageAdapter } from './StorageAdapter.js' type FilterOutDollarPrefixed = T extends `$${string}` @@ -37,17 +36,12 @@ export type UploadsConfig = { export const createUploadsExtension = ( config: UploadsConfig, storageAdapter: StorageAdapter, - signedUrlSettings?: SignedUrlSettings, + urlSigner?: UrlSigner, ) => { // @TODO I think we can use Prisma.getExtensionContext(this) // instead of creating a new PrismaClient instance const prismaInstance = new PrismaClient() - let signedUrlGenerator: UrlSigner - if (signedUrlSettings) { - signedUrlGenerator = new UrlSigner(signedUrlSettings) - } - type ResultExtends = { [K in MNames]: { withDataUri: { @@ -184,7 +178,7 @@ export const createUploadsExtension = ( needs, compute(modelData) { return (expiresIn?: number) => { - if (!signedUrlGenerator) { + if (!urlSigner) { throw new Error( 'Please supply signed url settings in setupUpload()', ) @@ -192,7 +186,7 @@ export const createUploadsExtension = ( const signedUrlFields: Record = {} for (const field of uploadFields) { - signedUrlFields[field] = signedUrlGenerator.generateSignedUrl( + signedUrlFields[field] = urlSigner.generateSignedUrl( modelData[field] as string, expiresIn, ) diff --git a/packages/uploads/src/setup.ts b/packages/uploads/src/setup.ts index d7d20d894316..589840b7e5e5 100644 --- a/packages/uploads/src/setup.ts +++ b/packages/uploads/src/setup.ts @@ -2,23 +2,20 @@ import { createFileListProcessor, createUploadProcessors, } from './createProcessors.js' -import type { - ModelNames, - SignedUrlSettings, - UploadsConfig, -} from './prismaExtension.js' +import type { UrlSigner } from './lib/signedUrls.js' +import type { ModelNames, UploadsConfig } from './prismaExtension.js' import { createUploadsExtension } from './prismaExtension.js' import type { StorageAdapter } from './StorageAdapter.js' export const setupUploads = ( uploadsConfig: UploadsConfig, storageAdapter: StorageAdapter, - signedUrlSettings?: SignedUrlSettings, + urlSigner?: UrlSigner, ) => { const prismaExtension = createUploadsExtension( uploadsConfig, storageAdapter, - signedUrlSettings, + urlSigner, ) const uploadsProcessors = createUploadProcessors( From d2b57f7b72cac5e80472d3482c127e80823d5a74 Mon Sep 17 00:00:00 2001 From: Danny Choudhury Date: Tue, 20 Aug 2024 17:08:30 +0700 Subject: [PATCH 61/91] Refactor exports, etc. --- packages/uploads/build.mts | 31 ++++++++++--------- packages/uploads/package.json | 28 +++++++++++++++-- packages/uploads/src/FileSystemStorage.ts | 14 +++++++-- .../src/__tests__/queryExtensions.test.ts | 2 +- .../src/__tests__/resultExtensions.test.ts | 4 +-- .../uploads/src/__tests__/signedUrls.test.ts | 2 +- packages/uploads/src/{setup.ts => index.ts} | 4 ++- packages/uploads/src/prismaExtension.ts | 6 +++- packages/uploads/src/{lib => }/signedUrls.ts | 0 9 files changed, 65 insertions(+), 26 deletions(-) rename packages/uploads/src/{setup.ts => index.ts} (87%) rename packages/uploads/src/{lib => }/signedUrls.ts (100%) diff --git a/packages/uploads/build.mts b/packages/uploads/build.mts index d1ec3b003bfb..da389f21e834 100644 --- a/packages/uploads/build.mts +++ b/packages/uploads/build.mts @@ -1,32 +1,33 @@ -import { writeFileSync } from 'node:fs' - import { build, defaultBuildOptions } from '@redwoodjs/framework-tools' -import { generateCjsTypes } from '@redwoodjs/framework-tools/cjsTypes' +import { + generateTypesCjs, + generateTypesEsm, + insertCommonJsPackageJson, +} from '@redwoodjs/framework-tools/generateTypes' -// CJS build +// ESM build await build({ buildOptions: { ...defaultBuildOptions, - outdir: 'dist/cjs', + format: 'esm', packages: 'external', }, }) -// ESM build +await generateTypesEsm() + +// CJS build await build({ buildOptions: { ...defaultBuildOptions, - format: 'esm', + outdir: 'dist/cjs', packages: 'external', }, }) -// Place a package.json file with `type: commonjs` in the dist/cjs folder so that -// all .js files are treated as CommonJS files. -writeFileSync('dist/cjs/package.json', JSON.stringify({ type: 'commonjs' })) - -// Place a package.json file with `type: module` in the dist folder so that -// all .js files are treated as ES Module files. -writeFileSync('dist/package.json', JSON.stringify({ type: 'module' })) +await generateTypesCjs() -await generateCjsTypes() +await insertCommonJsPackageJson({ + buildFileUrl: import.meta.url, + cjsDir: 'dist/cjs', +}) diff --git a/packages/uploads/package.json b/packages/uploads/package.json index 6b40f3742880..7b9fb8372c20 100644 --- a/packages/uploads/package.json +++ b/packages/uploads/package.json @@ -1,15 +1,36 @@ { "name": "@redwoodjs/uploads", - "type": "module", "version": "7.0.0", - "types": "dist/prismaExtension.d.ts", "repository": { "type": "git", "url": "git+https://github.com/redwoodjs/redwood.git", "directory": "packages/uploads" }, "license": "MIT", + "type": "module", "exports": { + ".": { + "require": { + "types": "./dist/cjs/index.d.ts", + "default": "./dist/cjs/index.js" + }, + "import": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + } + }, + "./FileSystemStorage": { + "require": "./dist/cjs/FileSystemStorage.js", + "import": "./dist/FileSystemStorage.js" + }, + "./MemoryStorage": { + "require": "./dist/cjs/MemoryStorage.js", + "import": "./dist/MemoryStorage.js" + }, + "./signedUrl": { + "require": "./dist/cjs/signedUrls.js", + "import": "./dist/signedUrls.js" + }, "./prisma": { "require": { "types": "./dist/cjs/prismaExtension.d.ts", @@ -21,6 +42,7 @@ } } }, + "types": "dist/prismaExtension.d.ts", "files": [ "dist", "prisma" @@ -56,4 +78,4 @@ "vitest": "2.0.4" }, "gitHead": "3905ed045508b861b495f8d5630d76c7a157d8f1" -} +} \ No newline at end of file diff --git a/packages/uploads/src/FileSystemStorage.ts b/packages/uploads/src/FileSystemStorage.ts index 653305fd1384..d99af186d90a 100644 --- a/packages/uploads/src/FileSystemStorage.ts +++ b/packages/uploads/src/FileSystemStorage.ts @@ -1,11 +1,21 @@ +import { existsSync, mkdirSync } from 'node:fs' import fs from 'node:fs/promises' import path from 'node:path' - import type { SaveOptionsOverride } from './StorageAdapter.js' import { StorageAdapter } from './StorageAdapter.js' -export class FileSystemStorage extends StorageAdapter implements StorageAdapter { +export class FileSystemStorage + extends StorageAdapter + implements StorageAdapter +{ + constructor(opts: { baseDir: string }) { + super(opts) + if (!existsSync(opts.baseDir)) { + console.log('Creating baseDir', opts.baseDir) + mkdirSync(opts.baseDir, { recursive: true }) + } + } async save(file: File, saveOverride?: SaveOptionsOverride) { const fileName = this.generateFileNameWithExtension(saveOverride, file) diff --git a/packages/uploads/src/__tests__/queryExtensions.test.ts b/packages/uploads/src/__tests__/queryExtensions.test.ts index 7a5b2cc1f4c1..2e5d1f9914ef 100644 --- a/packages/uploads/src/__tests__/queryExtensions.test.ts +++ b/packages/uploads/src/__tests__/queryExtensions.test.ts @@ -4,7 +4,7 @@ import { describe, it, vi, expect, beforeEach } from 'vitest' import { FileSystemStorage } from '../FileSystemStorage.js' import type { UploadsConfig } from '../prismaExtension.js' -import { setupUploads } from '../setup.js' +import { setupUploads } from '../index.js' // @MARK: use the local prisma client in the test import { PrismaClient } from './prisma-client/index.js' diff --git a/packages/uploads/src/__tests__/resultExtensions.test.ts b/packages/uploads/src/__tests__/resultExtensions.test.ts index 23b81e051e28..89165925afb0 100644 --- a/packages/uploads/src/__tests__/resultExtensions.test.ts +++ b/packages/uploads/src/__tests__/resultExtensions.test.ts @@ -1,9 +1,9 @@ import { describe, it, expect, vi } from 'vitest' -import { UrlSigner } from '../lib/signedUrls.js' +import { setupUploads } from '../index.js' import { MemoryStorage } from '../MemoryStorage.js' import type { UploadsConfig } from '../prismaExtension.js' -import { setupUploads } from '../setup.js' +import { UrlSigner } from '../signedUrls.js' // @MARK: use the local prisma client in the test import { PrismaClient } from './prisma-client/index.js' diff --git a/packages/uploads/src/__tests__/signedUrls.test.ts b/packages/uploads/src/__tests__/signedUrls.test.ts index 72a021e28ab7..f02caf5474d0 100644 --- a/packages/uploads/src/__tests__/signedUrls.test.ts +++ b/packages/uploads/src/__tests__/signedUrls.test.ts @@ -4,7 +4,7 @@ import { EXPIRES_IN, UrlSigner, getSignedDetailsFromUrl, -} from '../lib/signedUrls.js' +} from '../signedUrls.js' const signer = new UrlSigner({ // Doing this means we don't need to mock getConfig diff --git a/packages/uploads/src/setup.ts b/packages/uploads/src/index.ts similarity index 87% rename from packages/uploads/src/setup.ts rename to packages/uploads/src/index.ts index 589840b7e5e5..b02b65f0bd5a 100644 --- a/packages/uploads/src/setup.ts +++ b/packages/uploads/src/index.ts @@ -2,9 +2,9 @@ import { createFileListProcessor, createUploadProcessors, } from './createProcessors.js' -import type { UrlSigner } from './lib/signedUrls.js' import type { ModelNames, UploadsConfig } from './prismaExtension.js' import { createUploadsExtension } from './prismaExtension.js' +import type { UrlSigner } from './signedUrls.js' import type { StorageAdapter } from './StorageAdapter.js' export const setupUploads = ( @@ -31,3 +31,5 @@ export const setupUploads = ( fileListProcessor, } } + +export type { ModelNames, UploadsConfig } from './prismaExtension.js' diff --git a/packages/uploads/src/prismaExtension.ts b/packages/uploads/src/prismaExtension.ts index c28f10b3006d..0f5e28b93c2a 100644 --- a/packages/uploads/src/prismaExtension.ts +++ b/packages/uploads/src/prismaExtension.ts @@ -5,7 +5,7 @@ import type * as runtime from '@prisma/client/runtime/library' import { fileToDataUri } from './fileSave.utils.js' -import type { UrlSigner } from './lib/signedUrls.js' +import type { UrlSigner } from './signedUrls.js' import type { StorageAdapter } from './StorageAdapter.js' type FilterOutDollarPrefixed = T extends `$${string}` @@ -186,6 +186,10 @@ export const createUploadsExtension = ( const signedUrlFields: Record = {} for (const field of uploadFields) { + if (!signedUrlFields[field]) { + continue + } + signedUrlFields[field] = urlSigner.generateSignedUrl( modelData[field] as string, expiresIn, diff --git a/packages/uploads/src/lib/signedUrls.ts b/packages/uploads/src/signedUrls.ts similarity index 100% rename from packages/uploads/src/lib/signedUrls.ts rename to packages/uploads/src/signedUrls.ts From 2119b5d2fd2872393be9b73d4b6d7a976cc0e98a Mon Sep 17 00:00:00 2001 From: Danny Choudhury Date: Tue, 20 Aug 2024 17:11:26 +0700 Subject: [PATCH 62/91] Fix signedurl creation --- packages/uploads/src/__tests__/queryExtensions.test.ts | 9 ++++++++- packages/uploads/src/prismaExtension.ts | 2 +- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/packages/uploads/src/__tests__/queryExtensions.test.ts b/packages/uploads/src/__tests__/queryExtensions.test.ts index 2e5d1f9914ef..4fb12bcbeaf0 100644 --- a/packages/uploads/src/__tests__/queryExtensions.test.ts +++ b/packages/uploads/src/__tests__/queryExtensions.test.ts @@ -3,8 +3,8 @@ import fs from 'node:fs/promises' import { describe, it, vi, expect, beforeEach } from 'vitest' import { FileSystemStorage } from '../FileSystemStorage.js' -import type { UploadsConfig } from '../prismaExtension.js' import { setupUploads } from '../index.js' +import type { UploadsConfig } from '../prismaExtension.js' // @MARK: use the local prisma client in the test import { PrismaClient } from './prisma-client/index.js' @@ -19,6 +19,13 @@ vi.mock('node:fs/promises', () => ({ copyFile: vi.fn(), }, })) + +// For creation of FS adapter +vi.mock('node:fs', () => ({ + existsSync: vi.fn(() => true), + mkdirSync: vi.fn(), +})) + describe('Query extensions', () => { const uploadConfig: UploadsConfig = { dummy: { diff --git a/packages/uploads/src/prismaExtension.ts b/packages/uploads/src/prismaExtension.ts index 0f5e28b93c2a..00c216a05d2a 100644 --- a/packages/uploads/src/prismaExtension.ts +++ b/packages/uploads/src/prismaExtension.ts @@ -186,7 +186,7 @@ export const createUploadsExtension = ( const signedUrlFields: Record = {} for (const field of uploadFields) { - if (!signedUrlFields[field]) { + if (!modelData[field]) { continue } From cb08dc2e7ada4eb5281c1ca22c75db1f2620912c Mon Sep 17 00:00:00 2001 From: Danny Choudhury Date: Tue, 20 Aug 2024 22:16:53 +0700 Subject: [PATCH 63/91] Fix signing by using s instead of signature as argument Add read to base adapter --- packages/uploads/src/FileSystemStorage.ts | 9 ++ packages/uploads/src/MemoryStorage.ts | 7 +- packages/uploads/src/StorageAdapter.ts | 5 +- .../src/__tests__/createProcessors.test.ts | 6 +- .../uploads/src/__tests__/signedUrls.test.ts | 132 ++++++++++++++---- packages/uploads/src/signedUrls.ts | 58 ++++++-- 6 files changed, 169 insertions(+), 48 deletions(-) diff --git a/packages/uploads/src/FileSystemStorage.ts b/packages/uploads/src/FileSystemStorage.ts index d99af186d90a..de054a84ff8d 100644 --- a/packages/uploads/src/FileSystemStorage.ts +++ b/packages/uploads/src/FileSystemStorage.ts @@ -2,6 +2,8 @@ import { existsSync, mkdirSync } from 'node:fs' import fs from 'node:fs/promises' import path from 'node:path' +import mime from 'mime-types' + import type { SaveOptionsOverride } from './StorageAdapter.js' import { StorageAdapter } from './StorageAdapter.js' @@ -29,6 +31,13 @@ export class FileSystemStorage return { location } } + async read(filePath: string) { + return { + contents: await fs.readFile(filePath), + type: mime.lookup(filePath), + } + } + async remove(filePath: string) { await fs.unlink(filePath) } diff --git a/packages/uploads/src/MemoryStorage.ts b/packages/uploads/src/MemoryStorage.ts index f02ea05a9220..905ee7b8e2ff 100644 --- a/packages/uploads/src/MemoryStorage.ts +++ b/packages/uploads/src/MemoryStorage.ts @@ -1,5 +1,6 @@ import path from 'node:path' +import mime from 'mime-types' import { StorageAdapter } from './StorageAdapter.js' import type { SaveOptionsOverride } from './StorageAdapter.js' @@ -27,9 +28,11 @@ export class MemoryStorage extends StorageAdapter implements StorageAdapter { delete this.store[filePath] } - // Not sure about read method... should it be in the base class? async read(filePath: string) { - return this.store[filePath] + return { + contents: this.store[filePath], + type: mime.lookup(filePath), + } } async clear() { diff --git a/packages/uploads/src/StorageAdapter.ts b/packages/uploads/src/StorageAdapter.ts index 64a3d0d288d7..c2df085752bc 100644 --- a/packages/uploads/src/StorageAdapter.ts +++ b/packages/uploads/src/StorageAdapter.ts @@ -48,5 +48,8 @@ export abstract class StorageAdapter { saveOpts?: SaveOptionsOverride, ): Promise abstract remove(fileLocation: AdapterResult['location']): Promise - // abstract replace(fileId: string, file: File): Promise + abstract read(fileLocation: AdapterResult['location']): Promise<{ + contents: Buffer | string + type: ReturnType + }> } diff --git a/packages/uploads/src/__tests__/createProcessors.test.ts b/packages/uploads/src/__tests__/createProcessors.test.ts index e2624e7e8975..5db76b6b46a5 100644 --- a/packages/uploads/src/__tests__/createProcessors.test.ts +++ b/packages/uploads/src/__tests__/createProcessors.test.ts @@ -48,10 +48,12 @@ describe('Create processors', () => { /\/memory_store_basedir\/dumbo-*.*\.txt/, ) - const firstContents = await memStore.read(result.firstUpload) + const { contents: firstContents } = await memStore.read(result.firstUpload) expect(firstContents.toString()).toBe('Meaow') - const secondContents = await memStore.read(result.secondUpload) + const { contents: secondContents } = await memStore.read( + result.secondUpload, + ) expect(secondContents.toString()).toBe('Woof') }) diff --git a/packages/uploads/src/__tests__/signedUrls.test.ts b/packages/uploads/src/__tests__/signedUrls.test.ts index f02caf5474d0..10d9747de74d 100644 --- a/packages/uploads/src/__tests__/signedUrls.test.ts +++ b/packages/uploads/src/__tests__/signedUrls.test.ts @@ -1,10 +1,6 @@ import { describe, expect, beforeEach, afterEach, vi, it, test } from 'vitest' -import { - EXPIRES_IN, - UrlSigner, - getSignedDetailsFromUrl, -} from '../signedUrls.js' +import { EXPIRES_IN, UrlSigner } from '../signedUrls.js' const signer = new UrlSigner({ // Doing this means we don't need to mock getConfig @@ -32,21 +28,36 @@ describe('UrlSigner', () => { expect(() => signer.validateSignature({ - filePath: '/tmp/myfile.txt', - signature, + path: '/tmp/myfile.txt', + s: signature, expiry: expires, }), ).not.toThrow() expect(() => signer.validateSignature({ - filePath: '/tmp/myfile.txt', - signature, + path: '/tmp/myfile.txt', + s: signature, expiry: 12512351, }), ).toThrowError('Signature has expired') }) + it('Handles url encoded filePaths', () => { + const { signature, expiry: expires } = signer.generateSignature({ + filePath: '/tmp/myfile.txt', + expiresInMs: EXPIRES_IN.days(1), + }) + + expect(() => + signer.validateSignature({ + path: encodeURIComponent('/tmp/myfile.txt'), + s: signature, + expiry: expires, + }), + ).not.toThrow() + }) + it('Throws an invalid signature when signature is wrong', () => { const { signature, expiry } = signer.generateSignature({ filePath: '/tmp/myfile.txt', @@ -55,16 +66,16 @@ describe('UrlSigner', () => { expect(() => signer.validateSignature({ - filePath: '/tmp/myfile.txt', - signature, + path: '/tmp/myfile.txt', + s: signature, expiry, }), ).not.toThrow() expect(() => signer.validateSignature({ - filePath: '/tmp/myfile.txt', - signature: 'im-the-wrong-signature', + path: '/tmp/myfile.txt', + s: 'im-the-wrong-signature', expiry, }), ).toThrowError('Invalid signature') @@ -77,8 +88,8 @@ describe('UrlSigner', () => { }) expect(() => signer.validateSignature({ - filePath: '/tmp/some-other-file.txt', - signature, + path: '/tmp/some-other-file.txt', + s: signature, expiry, }), ).toThrowError('Invalid signature') @@ -104,8 +115,8 @@ describe('Expired signature', () => { const validation = () => signer.validateSignature({ - filePath, - signature, + path: filePath, + s: signature, expiry, }) @@ -118,17 +129,6 @@ describe('Expired signature', () => { }) }) -test('Parses details related to signatures from a url string', () => { - const url = - 'https://example.com/signedFile?file=/path/to/hello.txt&s=s1gnatur3&expires=123123' - - const { file, signature, expires } = getSignedDetailsFromUrl(url) - - expect(file).toBe('/path/to/hello.txt') - expect(signature).toBe('s1gnatur3') - expect(expires).toBe(123123) -}) - test('Generates a signed url', () => { const signedUrl = signer.generateSignedUrl( '/files/bazinga', @@ -137,10 +137,84 @@ test('Generates a signed url', () => { expect(signedUrl).toContain('https://myapiside.com/access-signed-file?s=') expect(signedUrl).toMatch(/s=.*/) - expect(signedUrl).toMatch(/expires=[0-9]+/) + expect(signedUrl).toMatch(/expiry=[0-9]+/) expect(signedUrl).toContain(`path=${encodeURIComponent('/files/bazinga')}`) // The actual file path }) +describe('validatePath', () => { + beforeEach(() => { + vi.useFakeTimers() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('validates a path or url with a valid signature and expiry', () => { + const filePath = '/tmp/myfile.txt' + const expiresInMs = EXPIRES_IN.days(1) + const { signature, expiry } = signer.generateSignature({ + filePath, + expiresInMs, + }) + + const signedPath = `/bazinga?s=${signature}&expiry=${expiry}&path=${encodeURIComponent( + filePath, + )}` + + // When its just a path + expect(() => signer.validateSignedUrl(signedPath)).not.toThrow() + expect(signer.validateSignedUrl(signedPath)).toBe(filePath) + + // When its a full url + const signedUrl = `https://myredwoodapp.com/bazinga?s=${signature}&expiry=${expiry}&path=${encodeURIComponent( + filePath, + )}` + + expect(() => signer.validateSignedUrl(signedUrl)).not.toThrow() + expect(signer.validateSignedUrl(signedUrl)).toBe(filePath) + }) + + it('throws an error when the signature has expired', () => { + const filePath = '/tmp/myfile.txt' + const expiresInMs = EXPIRES_IN.minutes(15) + const { signature, expiry } = signer.generateSignature({ + filePath, + expiresInMs, + }) + + const url = `/bazinga?s=${signature}&expiry=${expiry}&path=${encodeURIComponent( + filePath, + )}` + + // Time travel to the future + vi.advanceTimersByTime(EXPIRES_IN.days(1)) + + expect(() => signer.validateSignedUrl(url)).toThrowError( + 'Signature has expired', + ) + }) + + it('throws an error when the signature is invalid', () => { + const filePath = '/tmp/myfile.txt' + const expiresInMs = EXPIRES_IN.days(1) + const { signature, expiry } = signer.generateSignature({ + filePath, + expiresInMs, + }) + + const url = `/bazinga?s=${signature}&expiry=${expiry}&path=${encodeURIComponent( + filePath, + )}` + + const invalidSignatureUrl = url.replace(signature, 'invalid-signature') + + expect(() => signer.validateSignedUrl(invalidSignatureUrl)).toThrowError( + 'Invalid signature', + ) + }) +}) + // Util functions to make the tests more readable function diffInDaysFromNow(time: number) { return Math.abs(time - Date.now()) / 86400000 diff --git a/packages/uploads/src/signedUrls.ts b/packages/uploads/src/signedUrls.ts index f15280400eaf..1edb2d64816f 100644 --- a/packages/uploads/src/signedUrls.ts +++ b/packages/uploads/src/signedUrls.ts @@ -7,6 +7,11 @@ export type SignedUrlSettings = { secret: string // The secret to sign the urls with } +export type SignatureValidationArgs = { + path: string + s: string + expiry?: number | string +} export class UrlSigner { private secret: string private endpoint: string @@ -57,42 +62,67 @@ export class UrlSigner { * The signature and expires have to be extracted from the URL */ validateSignature({ - signature, - filePath, + s: signature, + path: filePath, // In the URL we call it path expiry, - }: { - filePath: string - signature: string - expiry?: number - }) { + }: SignatureValidationArgs) { if (!this.secret) { throw new Error('Please configure the secret') } if (expiry) { - // No need to validate if the signature has expired - if (Date.now() > expiry) { + // No need to validate if the signature has expired, + // but make sure its a number! + if (Date.now() > +expiry) { throw new Error('Signature has expired') } } + // Decoded filePath + const decodedFilePath = decodeURIComponent(filePath) + const validSignature = expiry ? crypto .createHmac('sha256', this.secret) - .update(`${filePath}:${expiry}`) + .update(`${decodedFilePath}:${expiry}`) .digest('hex') : crypto .createHmac('sha256', this.secret) - .update(`${filePath}`) + .update(`${decodedFilePath}`) .digest('hex') if (validSignature !== signature) { throw new Error('Invalid signature') } + + return decodedFilePath + } + + validateSignedUrl(fullPathWithQueryParametersOrUrl: string) { + const url = new URL( + fullPathWithQueryParametersOrUrl, + // We don't care about the host, but just need to create a URL object + // to parse search params + fullPathWithQueryParametersOrUrl.startsWith('http') + ? undefined + : 'http://localhost', + ) + + const path = url.searchParams.get('path') as string + + this.validateSignature({ + // Note the signature is called 's' in the URL + s: url.searchParams.get('s') as string, + expiry: url.searchParams.get('expiry') as string, + path, + }) + + // Return the decoded path + return decodeURIComponent(path) } generateSignedUrl(filePath: string, expiresIn?: number) { - const { signature, expiry: expires } = this.generateSignature({ + const { signature, expiry } = this.generateSignature({ filePath, expiresInMs: expiresIn, }) @@ -100,8 +130,8 @@ export class UrlSigner { // This way you can pass in a path with params already const params = new URLSearchParams() params.set('s', signature) - if (expires) { - params.set('expires', expires.toString()) + if (expiry) { + params.set('expiry', expiry.toString()) } params.set('path', filePath) From c6d2b0927333f63589db4fdfac9f2bcc4cc1a3cd Mon Sep 17 00:00:00 2001 From: Danny Choudhury Date: Wed, 21 Aug 2024 14:23:11 +0700 Subject: [PATCH 64/91] Don't throw when invalid upload path passed to delete --- .../src/__tests__/queryExtensions.test.ts | 29 +++++++++++++++++++ packages/uploads/src/prismaExtension.ts | 11 +++++-- 2 files changed, 38 insertions(+), 2 deletions(-) diff --git a/packages/uploads/src/__tests__/queryExtensions.test.ts b/packages/uploads/src/__tests__/queryExtensions.test.ts index 4fb12bcbeaf0..f3c07026d126 100644 --- a/packages/uploads/src/__tests__/queryExtensions.test.ts +++ b/packages/uploads/src/__tests__/queryExtensions.test.ts @@ -1,5 +1,6 @@ import fs from 'node:fs/promises' +import type { MockedFunction } from 'vitest' import { describe, it, vi, expect, beforeEach } from 'vitest' import { FileSystemStorage } from '../FileSystemStorage.js' @@ -168,6 +169,10 @@ describe('Query extensions', () => { expect(fs.unlink).toHaveBeenCalledWith('/tmp/old.txt') expect(updatedDummy.uploadField).toBe('/tmp/new.txt') }) + + it('should not delete the file if the update fails', async () => {}) + + it('should not delete files from other fields', async () => {}) }) describe('delete', () => { @@ -189,5 +194,29 @@ describe('Query extensions', () => { expect(fs.unlink).toHaveBeenCalledWith('/tmp/first.txt') expect(fs.unlink).toHaveBeenCalledWith('/tmp/second.txt') }) + + it('Should handle if a bad path is provided', async () => { + ;(fs.unlink as MockedFunction).mockRejectedValueOnce( + new Error('unlink error'), + ) + + const invalidPathDumbo = await prismaClient.dumbo.create({ + data: { + firstUpload: '', + secondUpload: 'im-a-invalid-path', + }, + }) + + const deletePromise = prismaClient.dumbo.delete({ + where: { + id: invalidPathDumbo.id, + }, + }) + + await expect(deletePromise).resolves.not.toThrow() + + expect(fs.unlink).toHaveBeenCalledOnce() + expect(fs.unlink).toHaveBeenCalledWith('im-a-invalid-path') + }) }) }) diff --git a/packages/uploads/src/prismaExtension.ts b/packages/uploads/src/prismaExtension.ts index 00c216a05d2a..77a4a2c3298d 100644 --- a/packages/uploads/src/prismaExtension.ts +++ b/packages/uploads/src/prismaExtension.ts @@ -74,13 +74,20 @@ export const createUploadsExtension = ( // With strict mode you cannot call findFirstOrThrow with the same args, because it is a union type // Ideally there's a better way to do this const record = - // @ts-expect-error laskndglkn + // @TODO not sure how to resolve this error + // @ts-expect-error Complaning because findFirstOrThrow args is a union type await prismaInstance[model as ModelNames].findFirstOrThrow(args) // Delete the file from the file system fields.forEach(async (field) => { const filePath = record[field] - await storageAdapter.remove(filePath) + if (filePath) { + try { + await storageAdapter.remove(filePath) + } catch { + // Swallow the error, we don't want to stop the delete operation + } + } }) } From a0634c104b98b6eea3a77005c876731e2118063c Mon Sep 17 00:00:00 2001 From: Danny Choudhury Date: Wed, 21 Aug 2024 18:18:59 +0700 Subject: [PATCH 65/91] Handle empty lists in file list processor --- packages/uploads/src/__tests__/createProcessors.test.ts | 6 ++++++ packages/uploads/src/createProcessors.ts | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/uploads/src/__tests__/createProcessors.test.ts b/packages/uploads/src/__tests__/createProcessors.test.ts index 5db76b6b46a5..b6e7a0aa599a 100644 --- a/packages/uploads/src/__tests__/createProcessors.test.ts +++ b/packages/uploads/src/__tests__/createProcessors.test.ts @@ -139,4 +139,10 @@ describe('FileList processing', () => { expect(result[0]).toMatch(/\/bazinga_not_mem_store\/.*\.png/) expect(result[1]).toMatch(/\/bazinga_not_mem_store\/.*\.jpeg/) }) + + it('Should handle empty FileLists', async () => { + const promise = fileListProcessor() + + await expect(promise).resolves.not.toThrow() + }) }) diff --git a/packages/uploads/src/createProcessors.ts b/packages/uploads/src/createProcessors.ts index a7e0349e0ae8..263773ee5995 100644 --- a/packages/uploads/src/createProcessors.ts +++ b/packages/uploads/src/createProcessors.ts @@ -8,7 +8,7 @@ type MakeFilesString = { } export const createFileListProcessor = (storage: StorageAdapter) => { - return async (files: File[], pathOverrideOnly?: { path?: string }) => { + return async (files: File[] = [], pathOverrideOnly?: { path?: string }) => { const locations = await Promise.all( files.map(async (file) => { const { location } = await storage.save(file, pathOverrideOnly) From 06307f3fe63b168978108258f2eaf69649e02b2d Mon Sep 17 00:00:00 2001 From: Danny Choudhury Date: Wed, 21 Aug 2024 18:19:49 +0700 Subject: [PATCH 66/91] Use storage adapter in dataUri --- packages/uploads/src/fileSave.utils.ts | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/packages/uploads/src/fileSave.utils.ts b/packages/uploads/src/fileSave.utils.ts index 193626c89d87..aebd14bb302c 100644 --- a/packages/uploads/src/fileSave.utils.ts +++ b/packages/uploads/src/fileSave.utils.ts @@ -1,12 +1,9 @@ -import fs from 'node:fs/promises' -import path from 'node:path' +import type { StorageAdapter } from './StorageAdapter.js' -import mime from 'mime-types' +export async function fileToDataUri(filePath: string, storage: StorageAdapter) { + const { contents, type: mimeType } = await storage.read(filePath) -export async function fileToDataUri(filePath: string) { - const base64Data = await fs.readFile(filePath, 'base64') - const ext = path.extname(filePath) - const mimeType = mime.lookup(ext) + const base64Data = Buffer.from(contents).toString('base64') return `data:${mimeType};base64,${base64Data}` } From d2d207f04f97d5b71be643057cb08d5ed1ed3623 Mon Sep 17 00:00:00 2001 From: Danny Choudhury Date: Thu, 22 Aug 2024 12:13:32 +0700 Subject: [PATCH 67/91] Constraints --- packages/uploads/package.json | 12 +- yarn.lock | 238 +--------------------------------- 2 files changed, 11 insertions(+), 239 deletions(-) diff --git a/packages/uploads/package.json b/packages/uploads/package.json index 7b9fb8372c20..244e577a2633 100644 --- a/packages/uploads/package.json +++ b/packages/uploads/package.json @@ -60,22 +60,20 @@ }, "dependencies": { "@redwoodjs/project-config": "workspace:*", - "fs-extra": "11.2.0", "mime-types": "2.1.35", "ulid": "2.3.0" }, "devDependencies": { - "@arethetypeswrong/cli": "0.15.3", + "@arethetypeswrong/cli": "0.15.4", "@prisma/client": "5.18.0", "@redwoodjs/framework-tools": "workspace:*", - "@types/fs-extra": "11.0.4", "@types/mime-types": "2.1.4", "concurrently": "8.2.2", "esbuild": "0.23.0", - "publint": "0.2.9", - "tsx": "4.16.2", + "publint": "0.2.10", + "tsx": "4.17.0", "typescript": "5.5.4", - "vitest": "2.0.4" + "vitest": "2.0.5" }, "gitHead": "3905ed045508b861b495f8d5630d76c7a157d8f1" -} \ No newline at end of file +} diff --git a/yarn.lock b/yarn.lock index 1ad808bf70da..f15f2bd545d3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -242,23 +242,6 @@ __metadata: languageName: node linkType: hard -"@arethetypeswrong/cli@npm:0.15.3": - version: 0.15.3 - resolution: "@arethetypeswrong/cli@npm:0.15.3" - dependencies: - "@arethetypeswrong/core": "npm:0.15.1" - chalk: "npm:^4.1.2" - cli-table3: "npm:^0.6.3" - commander: "npm:^10.0.1" - marked: "npm:^9.1.2" - marked-terminal: "npm:^6.0.0" - semver: "npm:^7.5.4" - bin: - attw: dist/index.js - checksum: 10c0/5998ab4a2195f9036a5c1988f73912a0a82cceeaa6a4e647b04414ad956a62163d8286b2a936941f23065b0c872f2bbdf9196fe3cac19c40b8b62a643d91c3c2 - languageName: node - linkType: hard - "@arethetypeswrong/cli@npm:0.15.4": version: 0.15.4 resolution: "@arethetypeswrong/cli@npm:0.15.4" @@ -8725,21 +8708,19 @@ __metadata: version: 0.0.0-use.local resolution: "@redwoodjs/uploads@workspace:packages/uploads" dependencies: - "@arethetypeswrong/cli": "npm:0.15.3" + "@arethetypeswrong/cli": "npm:0.15.4" "@prisma/client": "npm:5.18.0" "@redwoodjs/framework-tools": "workspace:*" "@redwoodjs/project-config": "workspace:*" - "@types/fs-extra": "npm:11.0.4" "@types/mime-types": "npm:2.1.4" concurrently: "npm:8.2.2" esbuild: "npm:0.23.0" - fs-extra: "npm:11.2.0" mime-types: "npm:2.1.35" - publint: "npm:0.2.9" - tsx: "npm:4.16.2" + publint: "npm:0.2.10" + tsx: "npm:4.17.0" typescript: "npm:5.5.4" ulid: "npm:2.3.0" - vitest: "npm:2.0.4" + vitest: "npm:2.0.5" languageName: unknown linkType: soft @@ -11559,18 +11540,6 @@ __metadata: languageName: node linkType: hard -"@vitest/expect@npm:2.0.4": - version: 2.0.4 - resolution: "@vitest/expect@npm:2.0.4" - dependencies: - "@vitest/spy": "npm:2.0.4" - "@vitest/utils": "npm:2.0.4" - chai: "npm:^5.1.1" - tinyrainbow: "npm:^1.2.0" - checksum: 10c0/18acdd6b1f5001830722fab7d41b0bd754e37572dded74d1549c5e8f40e58d9e4bbbb6a8ce6be1200b04653237329ba1aeeb3330c2a41f1024450016464d491e - languageName: node - linkType: hard - "@vitest/expect@npm:2.0.5": version: 2.0.5 resolution: "@vitest/expect@npm:2.0.5" @@ -11583,16 +11552,7 @@ __metadata: languageName: node linkType: hard -"@vitest/pretty-format@npm:2.0.4": - version: 2.0.4 - resolution: "@vitest/pretty-format@npm:2.0.4" - dependencies: - tinyrainbow: "npm:^1.2.0" - checksum: 10c0/c2ac3ca302b93ad53ea2977209ee4eb31a313c18690034a09f8ec5528d7e82715c233c4927ecf8b364203c5e5475231d9b737b3fb7680eea71882e1eae11e473 - languageName: node - linkType: hard - -"@vitest/pretty-format@npm:2.0.5, @vitest/pretty-format@npm:^2.0.4, @vitest/pretty-format@npm:^2.0.5": +"@vitest/pretty-format@npm:2.0.5, @vitest/pretty-format@npm:^2.0.5": version: 2.0.5 resolution: "@vitest/pretty-format@npm:2.0.5" dependencies: @@ -11601,16 +11561,6 @@ __metadata: languageName: node linkType: hard -"@vitest/runner@npm:2.0.4": - version: 2.0.4 - resolution: "@vitest/runner@npm:2.0.4" - dependencies: - "@vitest/utils": "npm:2.0.4" - pathe: "npm:^1.1.2" - checksum: 10c0/b550372ce5e2c6a3f08dbd584ea669723fc0d789ebaa4224b703f12e908813fb76b963ea9ac2265aa751cab0309f637dc1fa7ce3fb3e67e08e52e241d33237ee - languageName: node - linkType: hard - "@vitest/runner@npm:2.0.5": version: 2.0.5 resolution: "@vitest/runner@npm:2.0.5" @@ -11621,17 +11571,6 @@ __metadata: languageName: node linkType: hard -"@vitest/snapshot@npm:2.0.4": - version: 2.0.4 - resolution: "@vitest/snapshot@npm:2.0.4" - dependencies: - "@vitest/pretty-format": "npm:2.0.4" - magic-string: "npm:^0.30.10" - pathe: "npm:^1.1.2" - checksum: 10c0/67608c5b1e2f8b02ebc95286cd644c31ea29344c81d67151375b6eebf088a0eea242756eefb509aac626b8f7f091044fdcbc80d137d811ead1117a4a524e2d74 - languageName: node - linkType: hard - "@vitest/snapshot@npm:2.0.5": version: 2.0.5 resolution: "@vitest/snapshot@npm:2.0.5" @@ -11643,15 +11582,6 @@ __metadata: languageName: node linkType: hard -"@vitest/spy@npm:2.0.4": - version: 2.0.4 - resolution: "@vitest/spy@npm:2.0.4" - dependencies: - tinyspy: "npm:^3.0.0" - checksum: 10c0/ef0d0c5e36bb6dfa3ef7561368b39c92cd89bb52d112ec13345dfc99981796a9af98bafd35ce6952322a6a7534eaad144485fe7764628d94d77edeba5fa773b6 - languageName: node - linkType: hard - "@vitest/spy@npm:2.0.5": version: 2.0.5 resolution: "@vitest/spy@npm:2.0.5" @@ -11661,18 +11591,6 @@ __metadata: languageName: node linkType: hard -"@vitest/utils@npm:2.0.4": - version: 2.0.4 - resolution: "@vitest/utils@npm:2.0.4" - dependencies: - "@vitest/pretty-format": "npm:2.0.4" - estree-walker: "npm:^3.0.3" - loupe: "npm:^3.1.1" - tinyrainbow: "npm:^1.2.0" - checksum: 10c0/48e0bad3aa463d147b125e355b6bc6c5b4a5eab600132ebafac8379800273b2f47df17dbf76fe179b1500cc6b5866ead2d375a39a9114a03f705eb8850b93afa - languageName: node - linkType: hard - "@vitest/utils@npm:2.0.5": version: 2.0.5 resolution: "@vitest/utils@npm:2.0.5" @@ -12197,13 +12115,6 @@ __metadata: languageName: node linkType: hard -"ansi-escapes@npm:^6.2.0": - version: 6.2.1 - resolution: "ansi-escapes@npm:6.2.1" - checksum: 10c0/a2c6f58b044be5f69662ee17073229b492daa2425a7fd99a665db6c22eab6e4ab42752807def7281c1c7acfed48f87f2362dda892f08c2c437f1b39c6b033103 - languageName: node - linkType: hard - "ansi-escapes@npm:^7.0.0": version: 7.0.0 resolution: "ansi-escapes@npm:7.0.0" @@ -12259,13 +12170,6 @@ __metadata: languageName: node linkType: hard -"ansicolors@npm:~0.3.2": - version: 0.3.2 - resolution: "ansicolors@npm:0.3.2" - checksum: 10c0/e202182895e959c5357db6c60791b2abaade99fcc02221da11a581b26a7f83dc084392bc74e4d3875c22f37b3c9ef48842e896e3bfed394ec278194b8003e0ac - languageName: node - linkType: hard - "any-promise@npm:^1.0.0": version: 1.3.0 resolution: "any-promise@npm:1.3.0" @@ -13630,18 +13534,6 @@ __metadata: languageName: node linkType: hard -"cardinal@npm:^2.1.1": - version: 2.1.1 - resolution: "cardinal@npm:2.1.1" - dependencies: - ansicolors: "npm:~0.3.2" - redeyed: "npm:~2.1.0" - bin: - cdl: ./bin/cdl.js - checksum: 10c0/0051d0e64c0e1dff480c1aace4c018c48ecca44030533257af3f023107ccdeb061925603af6d73710f0345b0ae0eb57e5241d181d9b5fdb595d45c5418161675 - languageName: node - linkType: hard - "caseless@npm:~0.12.0": version: 0.12.0 resolution: "caseless@npm:0.12.0" @@ -16450,7 +16342,7 @@ __metadata: languageName: node linkType: hard -"esbuild@npm:^0.21.3, esbuild@npm:~0.21.5": +"esbuild@npm:^0.21.3": version: 0.21.5 resolution: "esbuild@npm:0.21.5" dependencies: @@ -22254,22 +22146,6 @@ __metadata: languageName: node linkType: hard -"marked-terminal@npm:^6.0.0": - version: 6.2.0 - resolution: "marked-terminal@npm:6.2.0" - dependencies: - ansi-escapes: "npm:^6.2.0" - cardinal: "npm:^2.1.1" - chalk: "npm:^5.3.0" - cli-table3: "npm:^0.6.3" - node-emoji: "npm:^2.1.3" - supports-hyperlinks: "npm:^3.0.0" - peerDependencies: - marked: ">=1 <12" - checksum: 10c0/72d4093cbb1ee864ced1f88fdb6fb8dbfea56d6aa3d8a1ec401ac51866ff3c32382c3f4642b19f2d808c798efde23b10300b99e3b6475b3f79e41e7741581d54 - languageName: node - linkType: hard - "marked-terminal@npm:^7.1.0": version: 7.1.0 resolution: "marked-terminal@npm:7.1.0" @@ -25479,19 +25355,6 @@ __metadata: languageName: node linkType: hard -"publint@npm:0.2.9": - version: 0.2.9 - resolution: "publint@npm:0.2.9" - dependencies: - npm-packlist: "npm:^5.1.3" - picocolors: "npm:^1.0.1" - sade: "npm:^1.8.1" - bin: - publint: lib/cli.js - checksum: 10c0/b414f40c2bc9372119346d5684eccb12bdf8066fc821301880d9dcdec0a5d852bbe926cb4583511f3e97736c53d1723e46e49285c9463bcb808cbfd979d5c2fc - languageName: node - linkType: hard - "pump@npm:^2.0.0": version: 2.0.1 resolution: "pump@npm:2.0.1" @@ -26143,15 +26006,6 @@ __metadata: languageName: node linkType: hard -"redeyed@npm:~2.1.0": - version: 2.1.1 - resolution: "redeyed@npm:2.1.1" - dependencies: - esprima: "npm:~4.0.0" - checksum: 10c0/350f5e39aebab3886713a170235c38155ee64a74f0f7e629ecc0144ba33905efea30c2c3befe1fcbf0b0366e344e7bfa34e6b2502b423c9a467d32f1306ef166 - languageName: node - linkType: hard - "redis-errors@npm:^1.0.0, redis-errors@npm:^1.2.0": version: 1.2.0 resolution: "redis-errors@npm:1.2.0" @@ -28892,22 +28746,6 @@ __metadata: languageName: node linkType: hard -"tsx@npm:4.16.2": - version: 4.16.2 - resolution: "tsx@npm:4.16.2" - dependencies: - esbuild: "npm:~0.21.5" - fsevents: "npm:~2.3.3" - get-tsconfig: "npm:^4.7.5" - dependenciesMeta: - fsevents: - optional: true - bin: - tsx: dist/cli.mjs - checksum: 10c0/9df52264f88be00ca473e7d7eda43bb038cc09028514996b864db78645e9cd297c71485f0fdd4985464d6dc46424f8bef9f8c4bd56692c4fcf4d71621ae21763 - languageName: node - linkType: hard - "tsx@npm:4.17.0": version: 4.17.0 resolution: "tsx@npm:4.17.0" @@ -29747,21 +29585,6 @@ __metadata: languageName: node linkType: hard -"vite-node@npm:2.0.4": - version: 2.0.4 - resolution: "vite-node@npm:2.0.4" - dependencies: - cac: "npm:^6.7.14" - debug: "npm:^4.3.5" - pathe: "npm:^1.1.2" - tinyrainbow: "npm:^1.2.0" - vite: "npm:^5.0.0" - bin: - vite-node: vite-node.mjs - checksum: 10c0/2689b05b391b59cf3d15e1e80884e9b054f2ca90b2150cc7a08b0f234e79e6750a28cc8d107a57f005185e759c3bc020030f687065317fc37fe169ce17f4cdb7 - languageName: node - linkType: hard - "vite-node@npm:2.0.5": version: 2.0.5 resolution: "vite-node@npm:2.0.5" @@ -29845,55 +29668,6 @@ __metadata: languageName: node linkType: hard -"vitest@npm:2.0.4": - version: 2.0.4 - resolution: "vitest@npm:2.0.4" - dependencies: - "@ampproject/remapping": "npm:^2.3.0" - "@vitest/expect": "npm:2.0.4" - "@vitest/pretty-format": "npm:^2.0.4" - "@vitest/runner": "npm:2.0.4" - "@vitest/snapshot": "npm:2.0.4" - "@vitest/spy": "npm:2.0.4" - "@vitest/utils": "npm:2.0.4" - chai: "npm:^5.1.1" - debug: "npm:^4.3.5" - execa: "npm:^8.0.1" - magic-string: "npm:^0.30.10" - pathe: "npm:^1.1.2" - std-env: "npm:^3.7.0" - tinybench: "npm:^2.8.0" - tinypool: "npm:^1.0.0" - tinyrainbow: "npm:^1.2.0" - vite: "npm:^5.0.0" - vite-node: "npm:2.0.4" - why-is-node-running: "npm:^2.3.0" - peerDependencies: - "@edge-runtime/vm": "*" - "@types/node": ^18.0.0 || >=20.0.0 - "@vitest/browser": 2.0.4 - "@vitest/ui": 2.0.4 - happy-dom: "*" - jsdom: "*" - peerDependenciesMeta: - "@edge-runtime/vm": - optional: true - "@types/node": - optional: true - "@vitest/browser": - optional: true - "@vitest/ui": - optional: true - happy-dom: - optional: true - jsdom: - optional: true - bin: - vitest: vitest.mjs - checksum: 10c0/139200d0bda3270fd00641e4bd5524f78a2b1fe9a3d4a0d5ba2b6ed08bbcf6f1e711cc4bfd8b0d823628a2fcab00f822bb210bd5bf3c6a9260fd6115ea085a3d - languageName: node - linkType: hard - "vitest@npm:2.0.5": version: 2.0.5 resolution: "vitest@npm:2.0.5" From 0ef44e39a693e571a539fa084cc1fee4dbfc51d9 Mon Sep 17 00:00:00 2001 From: Danny Choudhury Date: Thu, 22 Aug 2024 12:13:39 +0700 Subject: [PATCH 68/91] Refactor base64 handling --- packages/uploads/src/__tests__/queryExtensions.test.ts | 8 ++++++-- .../uploads/src/{fileSave.utils.ts => fileHandling.ts} | 0 packages/uploads/src/prismaExtension.ts | 3 ++- 3 files changed, 8 insertions(+), 3 deletions(-) rename packages/uploads/src/{fileSave.utils.ts => fileHandling.ts} (100%) diff --git a/packages/uploads/src/__tests__/queryExtensions.test.ts b/packages/uploads/src/__tests__/queryExtensions.test.ts index f3c07026d126..48a9b2a0e368 100644 --- a/packages/uploads/src/__tests__/queryExtensions.test.ts +++ b/packages/uploads/src/__tests__/queryExtensions.test.ts @@ -170,9 +170,13 @@ describe('Query extensions', () => { expect(updatedDummy.uploadField).toBe('/tmp/new.txt') }) - it('should not delete the file if the update fails', async () => {}) + it('should not delete the file if the update fails', async () => { + throw new Error('Not implemented yet') + }) - it('should not delete files from other fields', async () => {}) + it('should not delete files from other fields', async () => { + throw new Error('Not implemented yet') + }) }) describe('delete', () => { diff --git a/packages/uploads/src/fileSave.utils.ts b/packages/uploads/src/fileHandling.ts similarity index 100% rename from packages/uploads/src/fileSave.utils.ts rename to packages/uploads/src/fileHandling.ts diff --git a/packages/uploads/src/prismaExtension.ts b/packages/uploads/src/prismaExtension.ts index 77a4a2c3298d..08d1222ab75d 100644 --- a/packages/uploads/src/prismaExtension.ts +++ b/packages/uploads/src/prismaExtension.ts @@ -4,7 +4,7 @@ import { Prisma as PrismaExtension } from '@prisma/client/extension' import type * as runtime from '@prisma/client/runtime/library' -import { fileToDataUri } from './fileSave.utils.js' +import { fileToDataUri } from './fileHandling.js' import type { UrlSigner } from './signedUrls.js' import type { StorageAdapter } from './StorageAdapter.js' @@ -170,6 +170,7 @@ export const createUploadsExtension = ( for await (const field of uploadFields) { base64UploadFields[field] = await fileToDataUri( modelData[field] as string, + storageAdapter, ) } From cd43150d623264f1ef6813d33e7f53e3a86b2741 Mon Sep 17 00:00:00 2001 From: Danny Choudhury Date: Thu, 22 Aug 2024 12:44:52 +0700 Subject: [PATCH 69/91] Update README --- packages/uploads/README.md | 125 +++++++++++++++++++++++++++++++++++-- 1 file changed, 121 insertions(+), 4 deletions(-) diff --git a/packages/uploads/README.md b/packages/uploads/README.md index b6b7c36ce457..764a58aec2cd 100644 --- a/packages/uploads/README.md +++ b/packages/uploads/README.md @@ -1,7 +1,124 @@ -# Uploads +# `@redwoodjs/uploads` This package houses -- Prisma extension for handling uploads -- Base64 file picker uploader (?) -- TUS uploader (?) +- Prisma extension for handling uploads. Currently + a) Query Extension: will save, delete, replace files on disk during CRUD + b) Result Extension: gives you functions like `.withSignedUri` on configured prisma results - which will take the paths, and convert it to a signed url +- Storage adapters e.g. FS and Memory to use with the prisma extension +- Processors - i.e. utility functions which will take [`Files`](https://developer.mozilla.org/en-US/docs/Web/API/File) and save them to storage + +## Usage + +In `api/src/uploads.ts` - setup uploads - processors, storage and the prisma extension. + +```ts +// api/src/lib/uploads.ts + +import { UploadsConfig } from '@redwoodjs/uploads' +import { setupUploads } from '@redwoodjs/uploads' +import { FileSystemStorage } from '@redwoodjs/uploads/FileSystemStorage' +import { UrlSigner } from '@redwoodjs/uploads/signedUrl' + +const uploadConfig: UploadsConfig = { + // 👇 prisma model + profile: { + // 👇 pass in fields that are going to be File uploads + // these should be configured as string in the Prisma.schema + fields: ['avatar', 'coverPhoto'], + }, +} + +// 👇 exporting these allows you access elsewhere on the api side +export const storage = new FileSystemStorage({ + baseDir: './uploads', +}) + +// Optional +export const urlSigner = new UrlSigner({ + secret: process.env.UPLOADS_SECRET, + endpoint: '/signedUrl', +}) + +const { uploadsProcessors, prismaExtension, fileListProcessor } = setupUploads( + uploadConfig, + storage, + urlSigner, +) + +export { uploadsProcessors, prismaExtension, fileListProcessor } +``` + +### Configuring db to use the prisma extension + +```ts +// api/src/lib/db.ts + +import { PrismaClient } from '@prisma/client' + +import { emitLogLevels, handlePrismaLogging } from '@redwoodjs/api/logger' + +import { logger } from './logger' +import { prismaExtension } from './uploads' + +// 👇 Notice here we create prisma client, and don't export it yet +export const prismaClient = new PrismaClient({ + log: emitLogLevels(['info', 'warn', 'error']), +}) + +handlePrismaLogging({ + db: prismaClient, + logger, + logLevels: ['info', 'warn', 'error'], +}) + +// 👇 Export db after adding uploads extension +export const db = prismaClient.$extends(prismaExtension) +``` + +## Using Prisma extension + +### A) CRUD operations + +No need to do anything here, but you have to use processors to supply Prisma with data in the correct format. + +### B) Result extensions + +```ts +// api/src/services/profiles/profiles.ts + +export const profile: QueryResolvers['profile'] = async ({ id }) => { + // 👇 await the result from your prisma query + const profile = await db.profile.findUnique({ + where: { id }, + }) + + // Convert the avatar and coverPhoto fields to signed URLs + // Note that you still need to add a api endpoint to handle these signed urls + return profile?.withSignedUrl() +} +``` + +## Using processors + +In your services, you can use the preconfigured "processors" to convert Files to strings for Prisma to save into the database. The processors, and storage adapters determine where the file is saved. + +```ts +// api/src/services/profiles/profiles.ts + +export const updateProfile: MutationResolvers['updateProfile'] = async ({ + id, + input, +}) => { + const processedInput = await uploadsProcessors.processProfileUploads(input) + + // This becomes a string 👇 + // The configuration on where it was saved is passed when we setup uploads in src/lib/uploads.ts + // processedInput.avatar = '/mySavePath/profile/avatar/generatedId.jpg' + + return db.profile.update({ + data: processedInput, + where: { id }, + }) +} +``` From 3b8d4a1e297d233285dfc75ea5c5549d07ab39cb Mon Sep 17 00:00:00 2001 From: Danny Choudhury Date: Thu, 22 Aug 2024 15:33:58 +0700 Subject: [PATCH 70/91] Handle update edgecases --- .../src/__tests__/queryExtensions.test.ts | 76 +++++++++-- .../src/__tests__/unit-test-schema.prisma | 1 + packages/uploads/src/prismaExtension.ts | 128 +++++++++--------- 3 files changed, 131 insertions(+), 74 deletions(-) diff --git a/packages/uploads/src/__tests__/queryExtensions.test.ts b/packages/uploads/src/__tests__/queryExtensions.test.ts index 48a9b2a0e368..567596407410 100644 --- a/packages/uploads/src/__tests__/queryExtensions.test.ts +++ b/packages/uploads/src/__tests__/queryExtensions.test.ts @@ -1,13 +1,14 @@ import fs from 'node:fs/promises' import type { MockedFunction } from 'vitest' -import { describe, it, vi, expect, beforeEach } from 'vitest' +import { describe, it, vi, expect, beforeEach, beforeAll } from 'vitest' import { FileSystemStorage } from '../FileSystemStorage.js' import { setupUploads } from '../index.js' import type { UploadsConfig } from '../prismaExtension.js' // @MARK: use the local prisma client in the test +import type { Dumbo, Dummy } from './prisma-client/index.js' import { PrismaClient } from './prisma-client/index.js' vi.mock('node:fs/promises', () => ({ @@ -99,13 +100,13 @@ describe('Query extensions', () => { { firstUpload: '/one/first.txt', secondUpload: '/one/second.txt', - // @ts-expect-error Intentional bro + // @ts-expect-error Intentional id: 'break', }, { firstUpload: '/two/first.txt', secondUpload: '/two/second.txt', - // @ts-expect-error Intentional bro + // @ts-expect-error Intentional id: 'break2', }, ], @@ -133,7 +134,7 @@ describe('Query extensions', () => { { firstUpload: '/two/first.txt', secondUpload: '/two/second.txt', - // @ts-expect-error Intentional bro + // @ts-expect-error Intentional id: 'break2', }, ], @@ -150,19 +151,34 @@ describe('Query extensions', () => { }) describe('update', () => { - it('update will remove the old file, save new one', async () => { - const dummy = await prismaClient.dummy.create({ + let ogDummy: Dummy + let ogDumbo: Dumbo + beforeAll(async () => { + ogDummy = await prismaClient.dummy.create({ data: { uploadField: '/tmp/old.txt', }, }) + ogDumbo = await prismaClient.dumbo.create({ + data: { + firstUpload: '/tmp/oldFirst.txt', + secondUpload: '/tmp/oldSecond.txt', + }, + }) + }) + + beforeEach(() => { + vi.resetAllMocks() + }) + + it('update will remove the old file, save new one', async () => { const updatedDummy = await prismaClient.dummy.update({ data: { uploadField: '/tmp/new.txt', }, where: { - id: dummy.id, + id: ogDummy.id, }, }) @@ -171,11 +187,51 @@ describe('Query extensions', () => { }) it('should not delete the file if the update fails', async () => { - throw new Error('Not implemented yet') + const failedUpdatePromise = prismaClient.dummy.update({ + data: { + // @ts-expect-error Intentional + id: 'this-is-the-incorrect-type', + }, + where: { + id: ogDummy.id, + }, + }) + + // Id is invalid, so the update should fail + await expect(failedUpdatePromise).rejects.toThrowError() + + // The old one should NOT be deleted + expect(fs.unlink).not.toHaveBeenCalled() + }) + + it.only('should only delete old files from the fields that are being updated', async () => { + const updatedDumbo = await prismaClient.dumbo.update({ + data: { + firstUpload: '/tmp/newFirst.txt', + }, + where: { + id: ogDumbo.id, + }, + }) + + expect(updatedDumbo.firstUpload).toBe('/tmp/newFirst.txt') + expect(updatedDumbo.secondUpload).toBe('/tmp/oldSecond.txt') + expect(fs.unlink).toHaveBeenCalledOnce() + expect(fs.unlink).toHaveBeenCalledWith('/tmp/oldFirst.txt') }) - it('should not delete files from other fields', async () => { - throw new Error('Not implemented yet') + it('should not delete files on update of non-upload fields', async () => { + // In this case, we're only updating the message field + await prismaClient.dumbo.update({ + data: { + message: 'Hello world', + }, + where: { + id: ogDumbo.id, + }, + }) + + expect(fs.unlink).not.toHaveBeenCalled() }) }) diff --git a/packages/uploads/src/__tests__/unit-test-schema.prisma b/packages/uploads/src/__tests__/unit-test-schema.prisma index 905afc366007..1a8cd0489e04 100644 --- a/packages/uploads/src/__tests__/unit-test-schema.prisma +++ b/packages/uploads/src/__tests__/unit-test-schema.prisma @@ -17,6 +17,7 @@ model Dumbo { id Int @id @default(autoincrement()) firstUpload String secondUpload String + message String? } model NoUploadFields { diff --git a/packages/uploads/src/prismaExtension.ts b/packages/uploads/src/prismaExtension.ts index 08d1222ab75d..db5c31262b54 100644 --- a/packages/uploads/src/prismaExtension.ts +++ b/packages/uploads/src/prismaExtension.ts @@ -3,7 +3,6 @@ import type { Prisma } from '@prisma/client' import { Prisma as PrismaExtension } from '@prisma/client/extension' import type * as runtime from '@prisma/client/runtime/library' - import { fileToDataUri } from './fileHandling.js' import type { UrlSigner } from './signedUrls.js' import type { StorageAdapter } from './StorageAdapter.js' @@ -59,38 +58,6 @@ export const createUploadsExtension = ( } } - async function deleteUpload( - { - model, - args, - fields, - }: { - model: string - args: T - fields: string[] - }, - storageAdapter: StorageAdapter, - ) { - // With strict mode you cannot call findFirstOrThrow with the same args, because it is a union type - // Ideally there's a better way to do this - const record = - // @TODO not sure how to resolve this error - // @ts-expect-error Complaning because findFirstOrThrow args is a union type - await prismaInstance[model as ModelNames].findFirstOrThrow(args) - - // Delete the file from the file system - fields.forEach(async (field) => { - const filePath = record[field] - if (filePath) { - try { - await storageAdapter.remove(filePath) - } catch { - // Swallow the error, we don't want to stop the delete operation - } - } - }) - } - const queryExtends: runtime.ExtensionArgs['query'] = {} const resultExtends = {} as ResultExtends @@ -115,43 +82,64 @@ export const createUploadsExtension = ( return result } catch (e) { // If the create fails, we need to delete the uploaded files - await removeUploadedFiles(uploadFields, args) + await removeUploadedFiles( + uploadFields, + args.data as Record, + ) throw e } }, async update({ query, model, args }) { - await deleteUpload( - { - model, - args: { - // The update args contains data, which we don't need to supply to delete - where: args.where, - }, - fields: uploadFields, - }, - storageAdapter, + // Check if any of the uploadFields are present in args.data + // We only want to process fields that are being updated + const uploadFieldsToUpdate = uploadFields.filter( + (field) => + // All of this non-sense is to make typescript happy. I'm not sure how data could be anything but an object + typeof args.data === 'object' && + args.data !== null && + field in args.data, ) - // Same as create 👇 - try { - const result = await query(args) - return result - } catch (e) { - // If the create fails, we need to delete the uploaded files - await removeUploadedFiles(uploadFields, args) - throw e + // If no upload fields are present, proceed with the original query + // avoid overhead of extra lookups + if (uploadFieldsToUpdate.length == 0) { + return query(args) + } else { + const originalRecord = await prismaInstance[ + model as ModelNames + // @ts-expect-error TS in strict mode will error due to union type. We cannot narrow it down here. + ].findFirstOrThrow({ + where: args.where, + // @TODO: should we select here to reduce the amount of data we're handling + }) + + // Similar, but not same as create + try { + const result = await query(args) + + // **After** we've updated the record, we need to delete the old file. + await removeUploadedFiles(uploadFieldsToUpdate, originalRecord) + + return result + } catch (e) { + // If the update fails, we need to delete the newly uploaded files + // but not the ones that already exist! + await removeUploadedFiles( + uploadFieldsToUpdate, + args.data as Record, + ) + throw e + } } }, async delete({ model, query, args }) { - await deleteUpload( - { - model, - args, - fields: uploadFields, - }, - storageAdapter, - ) + /** Delete args are the same as findFirst, essentially a where clause */ + const record = + // @ts-expect-error TS in strict mode will error due to union type. We cannot narrow it down here. + await prismaInstance[model as ModelNames].findFirstOrThrow(args) + + await removeUploadedFiles(uploadFields, record) return query(args) }, @@ -225,11 +213,23 @@ export const createUploadsExtension = ( // @TODO(TS): According to TS, data could be a non-object... // Setting args to JsArgs causes errors. This could be a legit issue - async function removeUploadedFiles(uploadFields: string[], args: any) { - for await (const field of uploadFields) { - const uploadLocation = args.data?.[field] as string + async function removeUploadedFiles( + fieldsToDelete: string[], + data: Record, + ) { + if (!data) { + console.warn('Empty data object passed to removeUploadedFiles') + return + } + + for await (const field of fieldsToDelete) { + const uploadLocation = data?.[field] if (uploadLocation) { - await storageAdapter.remove(uploadLocation) + try { + await storageAdapter.remove(uploadLocation) + } catch { + // Swallow the error, we don't want to stop the delete operation + } } } } From 9fc42ce3546e4af1d71f0d70df659f4ac953c31e Mon Sep 17 00:00:00 2001 From: Danny Choudhury Date: Thu, 22 Aug 2024 16:31:10 +0700 Subject: [PATCH 71/91] Prettify --- .changesets/11154.md | 139 ++++++++++++++++++++++++++++++++++++++----- 1 file changed, 124 insertions(+), 15 deletions(-) diff --git a/.changesets/11154.md b/.changesets/11154.md index 44c278ffb1f5..920a5b7ae784 100644 --- a/.changesets/11154.md +++ b/.changesets/11154.md @@ -1,15 +1,124 @@ -- feat(rw-uploads): Create uploads package with prisma extension (#11154) by @dac09 - -This PR does the following: -- creates new `@redwoodjs/uploads` package. This is configured to be a dual esm/cjs package. -- exports prisma extension with legacy support e.g. - ``` - import { - createUploadsExtension, - UploadsConfig, - } from '@redwoodjs/uploads/prisma' - ``` -- scaffolds unit test structure for the prisma extension -- the prisma extension does the following: -a) If a base64 or TUS upload URL string is sent, it saves or moves it to a file -b) Adds a result extension (withDataUri), to make it easier to deal with files +- feat(rw-uploads): Create uploads package with prisma extension and upload processor (#11154) by @dac09 + +Introduces `@redwoodjs/uploads` package which houses + +- Prisma extension for handling uploads. Currently + a) Query Extension: will save, delete, replace files on disk during CRUD + b) Result Extension: gives you functions like `.withSignedUri` on configured prisma results - which will take the paths, and convert it to a signed url +- Storage adapters e.g. FS and Memory to use with the prisma extension +- Processors - i.e. utility functions which will take [`Files`](https://developer.mozilla.org/en-US/docs/Web/API/File) and save them to storage + +## Usage + +In `api/src/uploads.ts` - setup uploads - processors, storage and the prisma extension. + +```ts +// api/src/lib/uploads.ts + +import { UploadsConfig } from '@redwoodjs/uploads' +import { setupUploads } from '@redwoodjs/uploads' +import { FileSystemStorage } from '@redwoodjs/uploads/FileSystemStorage' +import { UrlSigner } from '@redwoodjs/uploads/signedUrl' + +const uploadConfig: UploadsConfig = { + // 👇 prisma model + profile: { + // 👇 pass in fields that are going to be File uploads + // these should be configured as string in the Prisma.schema + fields: ['avatar', 'coverPhoto'], + }, +} + +// 👇 exporting these allows you access elsewhere on the api side +export const storage = new FileSystemStorage({ + baseDir: './uploads', +}) + +// Optional +export const urlSigner = new UrlSigner({ + secret: process.env.UPLOADS_SECRET, + endpoint: '/signedUrl', +}) + +const { uploadsProcessors, prismaExtension, fileListProcessor } = setupUploads( + uploadConfig, + storage, + urlSigner, +) + +export { uploadsProcessors, prismaExtension, fileListProcessor } +``` + +### Configuring db to use the prisma extension + +```ts +// api/src/lib/db.ts + +import { PrismaClient } from '@prisma/client' + +import { emitLogLevels, handlePrismaLogging } from '@redwoodjs/api/logger' + +import { logger } from './logger' +import { prismaExtension } from './uploads' + +// 👇 Notice here we create prisma client, and don't export it yet +export const prismaClient = new PrismaClient({ + log: emitLogLevels(['info', 'warn', 'error']), +}) + +handlePrismaLogging({ + db: prismaClient, + logger, + logLevels: ['info', 'warn', 'error'], +}) + +// 👇 Export db after adding uploads extension +export const db = prismaClient.$extends(prismaExtension) +``` + +## Using Prisma extension + +### A) CRUD operations + +No need to do anything here, but you have to use processors to supply Prisma with data in the correct format. + +### B) Result extensions + +```ts +// api/src/services/profiles/profiles.ts + +export const profile: QueryResolvers['profile'] = async ({ id }) => { + // 👇 await the result from your prisma query + const profile = await db.profile.findUnique({ + where: { id }, + }) + + // Convert the avatar and coverPhoto fields to signed URLs + // Note that you still need to add a api endpoint to handle these signed urls + return profile?.withSignedUrl() +} +``` + +## Using processors + +In your services, you can use the preconfigured "processors" to convert Files to strings for Prisma to save into the database. The processors, and storage adapters determine where the file is saved. + +```ts +// api/src/services/profiles/profiles.ts + +export const updateProfile: MutationResolvers['updateProfile'] = async ({ + id, + input, +}) => { + const processedInput = await uploadsProcessors.processProfileUploads(input) + + // This becomes a string 👇 + // The configuration on where it was saved is passed when we setup uploads in src/lib/uploads.ts + // processedInput.avatar = '/mySavePath/profile/avatar/generatedId.jpg' + + return db.profile.update({ + data: processedInput, + where: { id }, + }) +} +``` From e400acf42af5c9b5de4ab4d91e381974d82d9cd4 Mon Sep 17 00:00:00 2001 From: Danny Choudhury Date: Thu, 22 Aug 2024 16:36:25 +0700 Subject: [PATCH 72/91] Add Memory storage tests --- .../src/__tests__/MemoryStorage.test.ts | 56 +++++++++++++++++++ 1 file changed, 56 insertions(+) create mode 100644 packages/uploads/src/__tests__/MemoryStorage.test.ts diff --git a/packages/uploads/src/__tests__/MemoryStorage.test.ts b/packages/uploads/src/__tests__/MemoryStorage.test.ts new file mode 100644 index 000000000000..ffad7c70cad8 --- /dev/null +++ b/packages/uploads/src/__tests__/MemoryStorage.test.ts @@ -0,0 +1,56 @@ +import { beforeEach, describe, expect, test } from 'vitest' + +import { MemoryStorage } from '../MemoryStorage.js' + +describe('MemoryStorage', () => { + let storage: MemoryStorage + + beforeEach(() => { + storage = new MemoryStorage({ baseDir: 'uploads' }) + }) + + test('save should store a file in memory', async () => { + const file = new File(['test content'], 'test.txt', { type: 'text/plain' }) + const result = await storage.save(file) + + expect(result).toHaveProperty('location') + expect(result.location).toMatch(/uploads\/.*\.txt$/) + expect(storage.store[result.location]).toBeDefined() + }) + + test('remove should delete a file from memory', async () => { + const file = new File(['test content'], 'test.txt', { type: 'text/plain' }) + const { location } = await storage.save(file) + + await storage.remove(location) + expect(storage.store[location]).toBeUndefined() + }) + + test('read should return file contents and type', async () => { + const file = new File(['ABCDEF'], 'test.txt', { type: 'image/png' }) + const { location } = await storage.save(file) + + const result = await storage.read(location) + expect(result.contents).toBeInstanceOf(Buffer) + expect(result.contents.toString()).toBe('ABCDEF') + expect(result.type).toBe('image/png') + }) + + test('clear should remove all stored files', async () => { + const file1 = new File(['content 1'], 'file1.txt', { type: 'text/plain' }) + const file2 = new File(['content 2'], 'file2.txt', { type: 'text/plain' }) + + await storage.save(file1) + await storage.save(file2) + + await storage.clear() + expect(Object.keys(storage.store).length).toBe(0) + }) + + test('save should use custom path when provided', async () => { + const file = new File(['test content'], 'test.txt', { type: 'text/plain' }) + const result = await storage.save(file, { path: 'custom/path' }) + + expect(result.location).toContain('custom/path') + }) +}) From 2f7d822a72d685c93458aa64572610fdcc16dbb4 Mon Sep 17 00:00:00 2001 From: Danny Choudhury Date: Thu, 22 Aug 2024 16:52:23 +0700 Subject: [PATCH 73/91] Add tests for storage adapters --- packages/uploads/src/FileSystemStorage.ts | 2 +- .../src/__tests__/FileSystemsStorage.test.ts | 82 +++++++++++++++++++ .../src/__tests__/MemoryStorage.test.ts | 8 +- 3 files changed, 85 insertions(+), 7 deletions(-) create mode 100644 packages/uploads/src/__tests__/FileSystemsStorage.test.ts diff --git a/packages/uploads/src/FileSystemStorage.ts b/packages/uploads/src/FileSystemStorage.ts index de054a84ff8d..88dfd29b96ca 100644 --- a/packages/uploads/src/FileSystemStorage.ts +++ b/packages/uploads/src/FileSystemStorage.ts @@ -14,7 +14,7 @@ export class FileSystemStorage constructor(opts: { baseDir: string }) { super(opts) if (!existsSync(opts.baseDir)) { - console.log('Creating baseDir', opts.baseDir) + console.log('Creating baseDir >', opts.baseDir) mkdirSync(opts.baseDir, { recursive: true }) } } diff --git a/packages/uploads/src/__tests__/FileSystemsStorage.test.ts b/packages/uploads/src/__tests__/FileSystemsStorage.test.ts new file mode 100644 index 000000000000..5a266a424eb0 --- /dev/null +++ b/packages/uploads/src/__tests__/FileSystemsStorage.test.ts @@ -0,0 +1,82 @@ +import { vol } from 'memfs' +import { beforeEach, describe, expect, test, vi } from 'vitest' + +import { FileSystemStorage } from '../FileSystemStorage.js' + +// Mock the entire fs module +vi.mock('node:fs', async () => { + const memfs = await import('memfs') + return { + ...memfs.fs, + default: memfs.fs, + } +}) + +// Mock the fs/promises module +vi.mock('node:fs/promises', async () => { + const memfs = await import('memfs') + return { + ...memfs.fs.promises, + default: memfs.fs.promises, + } +}) + +describe('FileSystemStorage', () => { + let storage: FileSystemStorage + const baseDir = '/tmp/test_uploads' + + beforeEach(() => { + vol.reset() + storage = new FileSystemStorage({ baseDir }) + }) + + const plainFile = new File(['test content'], 'test.txt', { + type: 'text/plain', + }) + + test('save should store a file on the file system', async () => { + const result = await storage.save(plainFile) + + expect(result).toHaveProperty('location') + expect(result.location).toMatch(/\/tmp\/test_uploads\/.*\.txt$/) + expect(vol.existsSync(result.location)).toBe(true) + }) + + test('remove should delete a file fron ', async () => { + const { location } = await storage.save(plainFile) + + await storage.remove(location) + expect(vol.existsSync(location)).toBe(false) + }) + + test('read should return file contents and type', async () => { + const { location: plainFileLocation } = await storage.save(plainFile) + + const plainFileReadResult = await storage.read(plainFileLocation) + expect(plainFileReadResult.contents).toBeInstanceOf(Buffer) + expect(plainFileReadResult.contents.toString()).toBe('test content') + expect(plainFileReadResult.type).toBe('text/plain') + + const imageFile = new File(['ABCDEF'], 'test.png', { type: 'image/png' }) + const { location } = await storage.save(imageFile) + + const result = await storage.read(location) + expect(result.contents).toBeInstanceOf(Buffer) + expect(result.contents.toString()).toBe('ABCDEF') + expect(result.type).toBe('image/png') + }) + + test('save should use custom path, with no baseDir, when provided', async () => { + // Note that using a custom path means you need to create the directory yourself! + vol.mkdirSync('/my_custom/path', { recursive: true }) + + const result = await storage.save(plainFile, { + path: '/my_custom/path', + fileName: 'bazinga', + }) + + // Note that it doesn't have the baseDir! + expect(result.location).toEqual('/my_custom/path/bazinga.txt') + expect(vol.existsSync(result.location)).toBe(true) + }) +}) diff --git a/packages/uploads/src/__tests__/MemoryStorage.test.ts b/packages/uploads/src/__tests__/MemoryStorage.test.ts index ffad7c70cad8..12bb49972db2 100644 --- a/packages/uploads/src/__tests__/MemoryStorage.test.ts +++ b/packages/uploads/src/__tests__/MemoryStorage.test.ts @@ -1,13 +1,9 @@ -import { beforeEach, describe, expect, test } from 'vitest' +import { describe, expect, test } from 'vitest' import { MemoryStorage } from '../MemoryStorage.js' describe('MemoryStorage', () => { - let storage: MemoryStorage - - beforeEach(() => { - storage = new MemoryStorage({ baseDir: 'uploads' }) - }) + const storage = new MemoryStorage({ baseDir: 'uploads' }) test('save should store a file in memory', async () => { const file = new File(['test content'], 'test.txt', { type: 'text/plain' }) From 1d94e1fbc626194e628846db6e28d42eacd0664d Mon Sep 17 00:00:00 2001 From: Danny Choudhury Date: Thu, 22 Aug 2024 17:05:00 +0700 Subject: [PATCH 74/91] Remove import aliasing for now --- packages/uploads/prisma/index.js | 2 -- packages/uploads/prisma/package.json | 4 ---- 2 files changed, 6 deletions(-) delete mode 100644 packages/uploads/prisma/index.js delete mode 100644 packages/uploads/prisma/package.json diff --git a/packages/uploads/prisma/index.js b/packages/uploads/prisma/index.js deleted file mode 100644 index f21ea7b2fd3d..000000000000 --- a/packages/uploads/prisma/index.js +++ /dev/null @@ -1,2 +0,0 @@ -/* eslint-env es6, commonjs */ -module.exports = require('../dist/cjs/prismaExtension.js') diff --git a/packages/uploads/prisma/package.json b/packages/uploads/prisma/package.json deleted file mode 100644 index 9b83f1f2dc6d..000000000000 --- a/packages/uploads/prisma/package.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "main": "./index.js", - "types": "../dist/cjs/prismaExtension.d.ts" -} From 5bc4ae45db831d8c0bb700727da53fa0704850a0 Mon Sep 17 00:00:00 2001 From: Danny Choudhury Date: Thu, 22 Aug 2024 17:05:36 +0700 Subject: [PATCH 75/91] Clean up package.json --- packages/uploads/package.json | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/uploads/package.json b/packages/uploads/package.json index 244e577a2633..c0893d7d2758 100644 --- a/packages/uploads/package.json +++ b/packages/uploads/package.json @@ -42,13 +42,12 @@ } } }, - "types": "dist/prismaExtension.d.ts", "files": [ "dist", "prisma" ], "scripts": { - "build": "tsx ./build.mts && run build:types", + "build": "tsx ./build.mts", "build:pack": "yarn pack -o redwoodjs-uploads.tgz", "build:types": "tsc --build --verbose", "build:types-cjs": "tsc --build --verbose tsconfig.types-cjs.json", @@ -76,4 +75,4 @@ "vitest": "2.0.5" }, "gitHead": "3905ed045508b861b495f8d5630d76c7a157d8f1" -} +} \ No newline at end of file From 33ee3bb97efb0b75f7620489d354fa6df0df9102 Mon Sep 17 00:00:00 2001 From: Danny Choudhury Date: Fri, 23 Aug 2024 14:21:33 +0700 Subject: [PATCH 76/91] Build prisma client before building package Ignore test files from packaging --- packages/uploads/package.json | 4 ++-- packages/uploads/tsconfig.json | 3 ++- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/uploads/package.json b/packages/uploads/package.json index c0893d7d2758..644d5fa78813 100644 --- a/packages/uploads/package.json +++ b/packages/uploads/package.json @@ -44,10 +44,10 @@ }, "files": [ "dist", - "prisma" + "!dist/**/*.test.d.*" ], "scripts": { - "build": "tsx ./build.mts", + "build": "yarn setup:test && tsx ./build.mts", "build:pack": "yarn pack -o redwoodjs-uploads.tgz", "build:types": "tsc --build --verbose", "build:types-cjs": "tsc --build --verbose tsconfig.types-cjs.json", diff --git a/packages/uploads/tsconfig.json b/packages/uploads/tsconfig.json index 7458881c480d..d8a2b7c67426 100644 --- a/packages/uploads/tsconfig.json +++ b/packages/uploads/tsconfig.json @@ -8,7 +8,8 @@ "outDir": "dist" }, "include": ["src", "prisma-override.d.ts"], - // Excluding types here causes types to be inaccurate in tests + // Excluding tests (as in root compilerOption) causes types to be inaccurate in tests + // This overrides the exclude in the root compilerOption "exclude": ["dist", "node_modules", "**/__mocks__"], "references": [ { From d4f0d4433a8a0a8c53c14d20f03de54083fa17c3 Mon Sep 17 00:00:00 2001 From: Danny Choudhury Date: Fri, 23 Aug 2024 14:55:50 +0700 Subject: [PATCH 77/91] Ignore generated prisma client from linting --- .eslintrc.js | 1 + packages/uploads/src/__tests__/queryExtensions.test.ts | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/.eslintrc.js b/.eslintrc.js index a7094e6c548f..3ee6aec61361 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -59,6 +59,7 @@ module.exports = { 'packages/babel-config/src/__tests__/__fixtures__/**/*', 'packages/codemods/**/__testfixtures__/**/*', 'packages/cli/**/__testfixtures__/**/*', + 'packages/uploads/src/__tests__/prisma-client/*', ], rules: { curly: 'error', diff --git a/packages/uploads/src/__tests__/queryExtensions.test.ts b/packages/uploads/src/__tests__/queryExtensions.test.ts index 567596407410..8a000e804098 100644 --- a/packages/uploads/src/__tests__/queryExtensions.test.ts +++ b/packages/uploads/src/__tests__/queryExtensions.test.ts @@ -111,7 +111,7 @@ describe('Query extensions', () => { }, ], }) - } catch (e) { + } catch { expect(fs.unlink).toHaveBeenCalledTimes(4) expect(fs.unlink).toHaveBeenNthCalledWith(1, '/one/first.txt') expect(fs.unlink).toHaveBeenNthCalledWith(2, '/one/second.txt') From 7eaa1b0cd48e6c9f6d4f877ab1b36b081bef037a Mon Sep 17 00:00:00 2001 From: Danny Choudhury Date: Fri, 23 Aug 2024 15:38:08 +0700 Subject: [PATCH 78/91] Remove only from tests --- .../src/__tests__/queryExtensions.test.ts | 62 +------------------ 1 file changed, 1 insertion(+), 61 deletions(-) diff --git a/packages/uploads/src/__tests__/queryExtensions.test.ts b/packages/uploads/src/__tests__/queryExtensions.test.ts index 8a000e804098..61b2187320e6 100644 --- a/packages/uploads/src/__tests__/queryExtensions.test.ts +++ b/packages/uploads/src/__tests__/queryExtensions.test.ts @@ -90,66 +90,6 @@ describe('Query extensions', () => { }) }) - // Not implemented yet - // Ideally it would just work automatically... but I guess we need to do all the variants - describe.skip('createMany', () => { - it('createMany will remove files if all the create fails', async () => { - try { - await prismaClient.dumbo.createMany({ - data: [ - { - firstUpload: '/one/first.txt', - secondUpload: '/one/second.txt', - // @ts-expect-error Intentional - id: 'break', - }, - { - firstUpload: '/two/first.txt', - secondUpload: '/two/second.txt', - // @ts-expect-error Intentional - id: 'break2', - }, - ], - }) - } catch { - expect(fs.unlink).toHaveBeenCalledTimes(4) - expect(fs.unlink).toHaveBeenNthCalledWith(1, '/one/first.txt') - expect(fs.unlink).toHaveBeenNthCalledWith(2, '/one/second.txt') - expect(fs.unlink).toHaveBeenNthCalledWith(3, '/two/first.txt') - expect(fs.unlink).toHaveBeenNthCalledWith(4, '/two/second.txt') - } - - expect.assertions(4) - }) - - it('createMany will remove files from only the creates that fail', async () => { - try { - await prismaClient.dumbo.createMany({ - data: [ - // This one will go through - { - firstUpload: '/one/first.txt', - secondUpload: '/one/second.txt', - }, - { - firstUpload: '/two/first.txt', - secondUpload: '/two/second.txt', - // @ts-expect-error Intentional - id: 'break2', - }, - ], - }) - } catch (e) { - console.log(e) - expect(fs.unlink).toHaveBeenCalledTimes(2) - expect(fs.unlink).toHaveBeenNthCalledWith(1, '/two/first.txt') - expect(fs.unlink).toHaveBeenNthCalledWith(2, '/two/second.txt') - } - - expect.assertions(4) - }) - }) - describe('update', () => { let ogDummy: Dummy let ogDumbo: Dumbo @@ -204,7 +144,7 @@ describe('Query extensions', () => { expect(fs.unlink).not.toHaveBeenCalled() }) - it.only('should only delete old files from the fields that are being updated', async () => { + it('should only delete old files from the fields that are being updated', async () => { const updatedDumbo = await prismaClient.dumbo.update({ data: { firstUpload: '/tmp/newFirst.txt', From 0e74764d94e2c543fcb6b3287f9301fdf2be3de1 Mon Sep 17 00:00:00 2001 From: Danny Choudhury Date: Fri, 23 Aug 2024 16:29:22 +0700 Subject: [PATCH 79/91] Update tests to pass on windows too --- packages/uploads/src/FileSystemStorage.ts | 9 ++-- .../src/__tests__/FileSystemsStorage.test.ts | 5 +- .../src/__tests__/MemoryStorage.test.ts | 6 ++- .../src/__tests__/createProcessors.test.ts | 46 ++++++++++++++----- .../src/__tests__/queryExtensions.test.ts | 9 ++-- 5 files changed, 54 insertions(+), 21 deletions(-) diff --git a/packages/uploads/src/FileSystemStorage.ts b/packages/uploads/src/FileSystemStorage.ts index 88dfd29b96ca..5f03c96a2b53 100644 --- a/packages/uploads/src/FileSystemStorage.ts +++ b/packages/uploads/src/FileSystemStorage.ts @@ -4,6 +4,8 @@ import path from 'node:path' import mime from 'mime-types' +import { ensurePosixPath } from '@redwoodjs/project-config' + import type { SaveOptionsOverride } from './StorageAdapter.js' import { StorageAdapter } from './StorageAdapter.js' @@ -14,15 +16,16 @@ export class FileSystemStorage constructor(opts: { baseDir: string }) { super(opts) if (!existsSync(opts.baseDir)) { - console.log('Creating baseDir >', opts.baseDir) - mkdirSync(opts.baseDir, { recursive: true }) + const posixBaseDir = ensurePosixPath(opts.baseDir) + console.log('Creating baseDir >', posixBaseDir) + mkdirSync(posixBaseDir, { recursive: true }) } } async save(file: File, saveOverride?: SaveOptionsOverride) { const fileName = this.generateFileNameWithExtension(saveOverride, file) const location = path.join( - saveOverride?.path || this.adapterOpts.baseDir, + ensurePosixPath(saveOverride?.path || this.adapterOpts.baseDir), fileName, ) const nodeBuffer = await file.arrayBuffer() diff --git a/packages/uploads/src/__tests__/FileSystemsStorage.test.ts b/packages/uploads/src/__tests__/FileSystemsStorage.test.ts index 5a266a424eb0..88395e360a23 100644 --- a/packages/uploads/src/__tests__/FileSystemsStorage.test.ts +++ b/packages/uploads/src/__tests__/FileSystemsStorage.test.ts @@ -1,6 +1,8 @@ import { vol } from 'memfs' import { beforeEach, describe, expect, test, vi } from 'vitest' +import { ensurePosixPath } from '@redwoodjs/project-config' + import { FileSystemStorage } from '../FileSystemStorage.js' // Mock the entire fs module @@ -38,7 +40,8 @@ describe('FileSystemStorage', () => { const result = await storage.save(plainFile) expect(result).toHaveProperty('location') - expect(result.location).toMatch(/\/tmp\/test_uploads\/.*\.txt$/) + const posixLocation = ensurePosixPath(result.location) + expect(posixLocation).toMatch(/\/tmp\/test_uploads\/.*\.txt$/) expect(vol.existsSync(result.location)).toBe(true) }) diff --git a/packages/uploads/src/__tests__/MemoryStorage.test.ts b/packages/uploads/src/__tests__/MemoryStorage.test.ts index 12bb49972db2..2f5f427ca998 100644 --- a/packages/uploads/src/__tests__/MemoryStorage.test.ts +++ b/packages/uploads/src/__tests__/MemoryStorage.test.ts @@ -1,5 +1,7 @@ import { describe, expect, test } from 'vitest' +import { ensurePosixPath } from '@redwoodjs/project-config' + import { MemoryStorage } from '../MemoryStorage.js' describe('MemoryStorage', () => { @@ -10,7 +12,7 @@ describe('MemoryStorage', () => { const result = await storage.save(file) expect(result).toHaveProperty('location') - expect(result.location).toMatch(/uploads\/.*\.txt$/) + expect(ensurePosixPath(result.location)).toMatch(/uploads\/.*\.txt$/) expect(storage.store[result.location]).toBeDefined() }) @@ -47,6 +49,6 @@ describe('MemoryStorage', () => { const file = new File(['test content'], 'test.txt', { type: 'text/plain' }) const result = await storage.save(file, { path: 'custom/path' }) - expect(result.location).toContain('custom/path') + expect(ensurePosixPath(result.location)).toContain('custom/path') }) }) diff --git a/packages/uploads/src/__tests__/createProcessors.test.ts b/packages/uploads/src/__tests__/createProcessors.test.ts index b6e7a0aa599a..f559c174593c 100644 --- a/packages/uploads/src/__tests__/createProcessors.test.ts +++ b/packages/uploads/src/__tests__/createProcessors.test.ts @@ -1,5 +1,7 @@ import { describe, it, expect } from 'vitest' +import { ensurePosixPath } from '@redwoodjs/project-config' + import { createFileListProcessor, createUploadProcessors, @@ -43,8 +45,10 @@ describe('Create processors', () => { const result = await processors.processDumboUploads(data) // Location strings in this format: {baseDir/{model}-{field}-{ulid}.{ext} - expect(result.firstUpload).toMatch(/\/memory_store_basedir\/dumbo-*.*\.txt/) - expect(result.secondUpload).toMatch( + expect(ensurePosixPath(result.firstUpload)).toMatch( + /\/memory_store_basedir\/dumbo-*.*\.txt/, + ) + expect(ensurePosixPath(result.secondUpload)).toMatch( /\/memory_store_basedir\/dumbo-*.*\.txt/, ) @@ -77,34 +81,43 @@ describe('Create processors', () => { fileName: 'overridden', }) - expect(fileNameOverrideOnly.uploadField).toBe( + expect(ensurePosixPath(fileNameOverrideOnly.uploadField)).toBe( '/memory_store_basedir/overridden.png', ) - expect(pathOverrideOnly.uploadField).toMatch(/\/bazinga\/.*\.png/) + expect(ensurePosixPath(pathOverrideOnly.uploadField)).toMatch( + /\/bazinga\/.*\.png/, + ) // Overriding path ignores the baseDir expect(pathOverrideOnly.uploadField).not.toContain('memory_store_basedir') - expect(bothOverride.uploadField).toBe('/bazinga/overridden.png') + expect(ensurePosixPath(bothOverride.uploadField)).toBe( + '/bazinga/overridden.png', + ) }) it('Should not add extension for unknown file type', async () => { const data = { uploadField: new File(['Hello'], 'hello', { - type: 'bazinga/unknown', + type: 'bazinga/unknown', // we don't use this anyway }), } const noOverride = await processors.processDummyUploads(data) // No extension - expect(noOverride.uploadField).toMatch(/\/memory_store_basedir\/.*[^.]+$/) + expect(ensurePosixPath(noOverride.uploadField)).toMatch( + /\/memory_store_basedir\/.*[^.]+$/, + ) const withOverride = await processors.processDummyUploads(data, { fileName: 'hello', }) + expect(withOverride.uploadField).toMatch(/[^.]+$/) - expect(withOverride.uploadField).toBe('/memory_store_basedir/hello') + expect(ensurePosixPath(withOverride.uploadField)).toBe( + '/memory_store_basedir/hello', + ) }) }) // FileLists @@ -126,8 +139,13 @@ describe('FileList processing', () => { const result = await fileListProcessor(notPrismaData) expect(result).toHaveLength(2) - expect(result[0]).toMatch(/\/memory_store_basedir\/.*\.png/) - expect(result[1]).toMatch(/\/memory_store_basedir\/.*\.jpeg/) + + expect(ensurePosixPath(result[0])).toMatch( + /\/memory_store_basedir\/.*\.png/, + ) + expect(ensurePosixPath(result[1])).toMatch( + /\/memory_store_basedir\/.*\.jpeg/, + ) }) it('Should handle FileLists with SaveOptions', async () => { @@ -136,8 +154,12 @@ describe('FileList processing', () => { }) expect(result).toHaveLength(2) - expect(result[0]).toMatch(/\/bazinga_not_mem_store\/.*\.png/) - expect(result[1]).toMatch(/\/bazinga_not_mem_store\/.*\.jpeg/) + expect(ensurePosixPath(result[0])).toMatch( + /\/bazinga_not_mem_store\/.*\.png/, + ) + expect(ensurePosixPath(result[1])).toMatch( + /\/bazinga_not_mem_store\/.*\.jpeg/, + ) }) it('Should handle empty FileLists', async () => { diff --git a/packages/uploads/src/__tests__/queryExtensions.test.ts b/packages/uploads/src/__tests__/queryExtensions.test.ts index 61b2187320e6..f16f25ea9912 100644 --- a/packages/uploads/src/__tests__/queryExtensions.test.ts +++ b/packages/uploads/src/__tests__/queryExtensions.test.ts @@ -3,6 +3,8 @@ import fs from 'node:fs/promises' import type { MockedFunction } from 'vitest' import { describe, it, vi, expect, beforeEach, beforeAll } from 'vitest' +import { ensurePosixPath } from '@redwoodjs/project-config' + import { FileSystemStorage } from '../FileSystemStorage.js' import { setupUploads } from '../index.js' import type { UploadsConfig } from '../prismaExtension.js' @@ -66,9 +68,10 @@ describe('Query extensions', () => { data: processedData, }) - expect(dummy).toMatchObject({ - uploadField: expect.stringMatching(/\/tmp\/.*\.txt$/), - }) + // On windows the slahes are different + const uploadFieldPath = ensurePosixPath(dummy.uploadField) + + expect(uploadFieldPath).toMatch(/\/tmp\/.*\.txt$/) }) it('will remove the file if the create fails', async () => { From e0115a24e3e9450ad423cabcfa390b6292be6f70 Mon Sep 17 00:00:00 2001 From: Danny Choudhury Date: Fri, 23 Aug 2024 16:45:41 +0700 Subject: [PATCH 80/91] Missed one test --- packages/uploads/src/__tests__/FileSystemsStorage.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/uploads/src/__tests__/FileSystemsStorage.test.ts b/packages/uploads/src/__tests__/FileSystemsStorage.test.ts index 88395e360a23..7da26ef9f7df 100644 --- a/packages/uploads/src/__tests__/FileSystemsStorage.test.ts +++ b/packages/uploads/src/__tests__/FileSystemsStorage.test.ts @@ -79,7 +79,9 @@ describe('FileSystemStorage', () => { }) // Note that it doesn't have the baseDir! - expect(result.location).toEqual('/my_custom/path/bazinga.txt') + expect(ensurePosixPath(result.location)).toEqual( + '/my_custom/path/bazinga.txt', + ) expect(vol.existsSync(result.location)).toBe(true) }) }) From 4cbb3b1a2c16b7977a68dd5ce58b1e2576b651b8 Mon Sep 17 00:00:00 2001 From: Danny Choudhury Date: Mon, 26 Aug 2024 16:28:28 +0700 Subject: [PATCH 81/91] =?UTF-8?q?Add=20new=20line=20to=20end=20of=20packag?= =?UTF-8?q?e.json=20=F0=9F=A4=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/uploads/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/uploads/package.json b/packages/uploads/package.json index 644d5fa78813..4cbb7d6dc6f3 100644 --- a/packages/uploads/package.json +++ b/packages/uploads/package.json @@ -75,4 +75,4 @@ "vitest": "2.0.5" }, "gitHead": "3905ed045508b861b495f8d5630d76c7a157d8f1" -} \ No newline at end of file +} From 6a297d4530b5f2a0dd19056cc0ee633ce0e24600 Mon Sep 17 00:00:00 2001 From: Danny Choudhury Date: Mon, 26 Aug 2024 18:53:50 +0700 Subject: [PATCH 82/91] Put fileList processor inside processors --- .../uploads/src/__tests__/createProcessors.test.ts | 13 +++++-------- packages/uploads/src/createProcessors.ts | 7 +++++-- packages/uploads/src/index.ts | 8 +------- 3 files changed, 11 insertions(+), 17 deletions(-) diff --git a/packages/uploads/src/__tests__/createProcessors.test.ts b/packages/uploads/src/__tests__/createProcessors.test.ts index f559c174593c..51dab21cbf64 100644 --- a/packages/uploads/src/__tests__/createProcessors.test.ts +++ b/packages/uploads/src/__tests__/createProcessors.test.ts @@ -2,10 +2,7 @@ import { describe, it, expect } from 'vitest' import { ensurePosixPath } from '@redwoodjs/project-config' -import { - createFileListProcessor, - createUploadProcessors, -} from '../createProcessors.js' +import { createUploadProcessors } from '../createProcessors.js' import { MemoryStorage } from '../MemoryStorage.js' import type { UploadsConfig } from '../prismaExtension.js' @@ -124,7 +121,7 @@ describe('Create processors', () => { // Problem is - in the database world, a string[] is not a thing // so we need a generic way of doing this describe('FileList processing', () => { - const fileListProcessor = createFileListProcessor(memStore) + const processors = createUploadProcessors(uploadsConfig, memStore) const notPrismaData = [ new File(['Hello'], 'hello.png', { @@ -136,7 +133,7 @@ describe('FileList processing', () => { ] it('Should handle FileLists', async () => { - const result = await fileListProcessor(notPrismaData) + const result = await processors.processFileList(notPrismaData) expect(result).toHaveLength(2) @@ -149,7 +146,7 @@ describe('FileList processing', () => { }) it('Should handle FileLists with SaveOptions', async () => { - const result = await fileListProcessor(notPrismaData, { + const result = await processors.processFileList(notPrismaData, { path: '/bazinga_not_mem_store', }) @@ -163,7 +160,7 @@ describe('FileList processing', () => { }) it('Should handle empty FileLists', async () => { - const promise = fileListProcessor() + const promise = processors.processFileList() await expect(promise).resolves.not.toThrow() }) diff --git a/packages/uploads/src/createProcessors.ts b/packages/uploads/src/createProcessors.ts index 263773ee5995..7f867226e8bb 100644 --- a/packages/uploads/src/createProcessors.ts +++ b/packages/uploads/src/createProcessors.ts @@ -65,7 +65,7 @@ export const createUploadProcessors = < const updatedFields = {} as Record for await (const field of currentModelUploadFields) { if (data[field]) { - const file = data[field] as File + const file = data[field] const saveOptions = overrideSaveOptions || { fileName: `${model}-${field}-${ulid()}`, @@ -82,5 +82,8 @@ export const createUploadProcessors = < } }) - return processors + return { + ...processors, + processFileList: createFileListProcessor(storage), + } } diff --git a/packages/uploads/src/index.ts b/packages/uploads/src/index.ts index b02b65f0bd5a..5ebda08c89c2 100644 --- a/packages/uploads/src/index.ts +++ b/packages/uploads/src/index.ts @@ -1,7 +1,4 @@ -import { - createFileListProcessor, - createUploadProcessors, -} from './createProcessors.js' +import { createUploadProcessors } from './createProcessors.js' import type { ModelNames, UploadsConfig } from './prismaExtension.js' import { createUploadsExtension } from './prismaExtension.js' import type { UrlSigner } from './signedUrls.js' @@ -23,12 +20,9 @@ export const setupUploads = ( storageAdapter, ) - const fileListProcessor = createFileListProcessor(storageAdapter) - return { prismaExtension, uploadsProcessors, - fileListProcessor, } } From 9b474d789b476fcc4aae60b2baf4baa3a2e9cddc Mon Sep 17 00:00:00 2001 From: Danny Choudhury Date: Mon, 26 Aug 2024 22:03:20 +0700 Subject: [PATCH 83/91] Remove outdated TODOs --- packages/uploads/src/__tests__/createProcessors.test.ts | 2 -- packages/uploads/src/createProcessors.ts | 2 +- packages/uploads/src/prismaExtension.ts | 3 +-- 3 files changed, 2 insertions(+), 5 deletions(-) diff --git a/packages/uploads/src/__tests__/createProcessors.test.ts b/packages/uploads/src/__tests__/createProcessors.test.ts index 51dab21cbf64..632b9d6ac22e 100644 --- a/packages/uploads/src/__tests__/createProcessors.test.ts +++ b/packages/uploads/src/__tests__/createProcessors.test.ts @@ -10,8 +10,6 @@ const memStore = new MemoryStorage({ baseDir: '/memory_store_basedir', }) -// @TODO(TS): How can I make this accept not all model names? -// This is error-ing out because it wants all the models in my prisma client const uploadsConfig: UploadsConfig = { dumbo: { fields: ['firstUpload', 'secondUpload'], diff --git a/packages/uploads/src/createProcessors.ts b/packages/uploads/src/createProcessors.ts index 7f867226e8bb..08a06c7e2028 100644 --- a/packages/uploads/src/createProcessors.ts +++ b/packages/uploads/src/createProcessors.ts @@ -35,9 +35,9 @@ export const createUploadProcessors = < type uploadProcessorNames = `process${Capitalize}Uploads` + // @TODO(TS): Is there a way to make the type of data more specific? type Processors = { [K in uploadProcessorNames]: >( - // @TODO(TS): T should be the type of the model data: T, overrideSaveOptions?: SaveOptionsOverride, ) => Promise> diff --git a/packages/uploads/src/prismaExtension.ts b/packages/uploads/src/prismaExtension.ts index db5c31262b54..d3960959269a 100644 --- a/packages/uploads/src/prismaExtension.ts +++ b/packages/uploads/src/prismaExtension.ts @@ -211,8 +211,7 @@ export const createUploadsExtension = ( }) }) - // @TODO(TS): According to TS, data could be a non-object... - // Setting args to JsArgs causes errors. This could be a legit issue + async function removeUploadedFiles( fieldsToDelete: string[], data: Record, From b7f6b9f63a52de22c336b16b4247cdce94ec92b1 Mon Sep 17 00:00:00 2001 From: Danny Choudhury Date: Mon, 26 Aug 2024 22:16:27 +0700 Subject: [PATCH 84/91] Prettier --- packages/uploads/src/prismaExtension.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/uploads/src/prismaExtension.ts b/packages/uploads/src/prismaExtension.ts index d3960959269a..0c53ddf82b26 100644 --- a/packages/uploads/src/prismaExtension.ts +++ b/packages/uploads/src/prismaExtension.ts @@ -211,7 +211,6 @@ export const createUploadsExtension = ( }) }) - async function removeUploadedFiles( fieldsToDelete: string[], data: Record, From 3434411c2b742e0277bf91ea4c64de5caecc5cb9 Mon Sep 17 00:00:00 2001 From: Danny Choudhury Date: Tue, 27 Aug 2024 23:08:51 +0700 Subject: [PATCH 85/91] Make with signedUrl result extension take an object instead --- packages/uploads/src/__tests__/resultExtensions.test.ts | 4 +++- packages/uploads/src/prismaExtension.ts | 4 ++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/uploads/src/__tests__/resultExtensions.test.ts b/packages/uploads/src/__tests__/resultExtensions.test.ts index 89165925afb0..2469f4901093 100644 --- a/packages/uploads/src/__tests__/resultExtensions.test.ts +++ b/packages/uploads/src/__tests__/resultExtensions.test.ts @@ -54,7 +54,9 @@ describe('Result extensions', () => { }, }) - const signedUrlDumbo = await dumbo.withSignedUrl(254) + const signedUrlDumbo = await dumbo.withSignedUrl({ + expiresIn: 254, + }) expect(signedUrlDumbo.firstUpload).toContain( '/.redwood/functions/signed-url', ) diff --git a/packages/uploads/src/prismaExtension.ts b/packages/uploads/src/prismaExtension.ts index 0c53ddf82b26..f116a79f952b 100644 --- a/packages/uploads/src/prismaExtension.ts +++ b/packages/uploads/src/prismaExtension.ts @@ -53,7 +53,7 @@ export const createUploadsExtension = ( needs: Record compute: ( modelData: Record, - ) => (this: T, expiresIn?: number) => Promise + ) => (this: T, { expiresIn }: { expiresIn?: number }) => Promise } } } @@ -173,7 +173,7 @@ export const createUploadsExtension = ( withSignedUrl: { needs, compute(modelData) { - return (expiresIn?: number) => { + return ({ expiresIn }: { expiresIn?: number }) => { if (!urlSigner) { throw new Error( 'Please supply signed url settings in setupUpload()', From a0d4183b6df76d8681520ab23265c10a2c6f751c Mon Sep 17 00:00:00 2001 From: Danny Choudhury Date: Wed, 28 Aug 2024 11:16:42 +0700 Subject: [PATCH 86/91] Make SignedUrl args optional --- packages/uploads/src/prismaExtension.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/uploads/src/prismaExtension.ts b/packages/uploads/src/prismaExtension.ts index f116a79f952b..985045be784c 100644 --- a/packages/uploads/src/prismaExtension.ts +++ b/packages/uploads/src/prismaExtension.ts @@ -32,6 +32,10 @@ export type UploadsConfig = { [K in MNames]?: UploadConfigForModel } +type WithSignedUrlArgs = { + expiresIn?: number +} + export const createUploadsExtension = ( config: UploadsConfig, storageAdapter: StorageAdapter, @@ -53,7 +57,7 @@ export const createUploadsExtension = ( needs: Record compute: ( modelData: Record, - ) => (this: T, { expiresIn }: { expiresIn?: number }) => Promise + ) => (this: T, signArgs?: WithSignedUrlArgs) => Promise } } } @@ -173,7 +177,7 @@ export const createUploadsExtension = ( withSignedUrl: { needs, compute(modelData) { - return ({ expiresIn }: { expiresIn?: number }) => { + return ({ expiresIn }: WithSignedUrlArgs = {}) => { if (!urlSigner) { throw new Error( 'Please supply signed url settings in setupUpload()', From 6d683c75bfe0cd08f16e10bb424840529670c567 Mon Sep 17 00:00:00 2001 From: Danny Choudhury Date: Thu, 29 Aug 2024 13:32:18 +0700 Subject: [PATCH 87/91] Accept dataloss in uploads prisma setup for tests --- packages/uploads/package.json | 4 ++-- packages/uploads/vitest.setup.mts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/uploads/package.json b/packages/uploads/package.json index 4cbb7d6dc6f3..2f1895d0cb3d 100644 --- a/packages/uploads/package.json +++ b/packages/uploads/package.json @@ -53,7 +53,7 @@ "build:types-cjs": "tsc --build --verbose tsconfig.types-cjs.json", "check:attw": "tsx attw.ts", "check:package": "concurrently npm:check:attw yarn publint", - "setup:test": "npx prisma db push --schema ./src/__tests__/unit-test-schema.prisma", + "setup:test": "npx prisma db push --accept-data-loss --schema ./src/__tests__/unit-test-schema.prisma", "test": "vitest run", "test:watch": "vitest watch" }, @@ -75,4 +75,4 @@ "vitest": "2.0.5" }, "gitHead": "3905ed045508b861b495f8d5630d76c7a157d8f1" -} +} \ No newline at end of file diff --git a/packages/uploads/vitest.setup.mts b/packages/uploads/vitest.setup.mts index 80434f24618b..702c707e71da 100644 --- a/packages/uploads/vitest.setup.mts +++ b/packages/uploads/vitest.setup.mts @@ -3,6 +3,6 @@ import { $ } from 'zx' export default async function setup() { $.verbose = true console.log('[setup] Setting up unit test prisma db....') - await $`npx prisma db push --schema ./src/__tests__/unit-test-schema.prisma` + await $`npx prisma db push --accept-data-loss --schema ./src/__tests__/unit-test-schema.prisma` console.log('[setup] Done! \n') } From 1b43a2374661b4f4c24d08365e533798131bac4c Mon Sep 17 00:00:00 2001 From: Danny Choudhury Date: Thu, 29 Aug 2024 16:34:22 +0700 Subject: [PATCH 88/91] Add tests for delete failure --- .../src/__tests__/queryExtensions.test.ts | 27 +++++++++++++++++++ .../src/__tests__/unit-test-schema.prisma | 26 +++++++++++++----- packages/uploads/src/prismaExtension.ts | 14 +++++----- 3 files changed, 55 insertions(+), 12 deletions(-) diff --git a/packages/uploads/src/__tests__/queryExtensions.test.ts b/packages/uploads/src/__tests__/queryExtensions.test.ts index f16f25ea9912..faa47cc587d4 100644 --- a/packages/uploads/src/__tests__/queryExtensions.test.ts +++ b/packages/uploads/src/__tests__/queryExtensions.test.ts @@ -198,6 +198,33 @@ describe('Query extensions', () => { expect(fs.unlink).toHaveBeenCalledWith('/tmp/second.txt') }) + it('delete will not remove any uploads if the delete fails', async () => { + const bookWithCover = await prismaClient.book.create({ + data: { + name: 'Prisma extensions for dummies', + cover: { + create: { + photo: '/tmp/book-covers/prisma-for-dummies.jpg', + }, + }, + }, + }) + + // This delete will fail because the book is associated with a cover BUTTTT + // test serves more as documentation (and to prevent regression if Prisma changes behavior) + // Because Prisma will throw the validation __before__ the delete in the extension is called + + try { + await prismaClient.bookCover.delete({ + where: { + id: bookWithCover.coverId, + }, + }) + } catch {} + + expect(fs.unlink).not.toHaveBeenCalled() + }) + it('Should handle if a bad path is provided', async () => { ;(fs.unlink as MockedFunction).mockRejectedValueOnce( new Error('unlink error'), diff --git a/packages/uploads/src/__tests__/unit-test-schema.prisma b/packages/uploads/src/__tests__/unit-test-schema.prisma index 1a8cd0489e04..21f28a1f1ccb 100644 --- a/packages/uploads/src/__tests__/unit-test-schema.prisma +++ b/packages/uploads/src/__tests__/unit-test-schema.prisma @@ -9,18 +9,32 @@ generator client { } model Dummy { - id Int @id @default(autoincrement()) + id Int @id @default(autoincrement()) uploadField String } model Dumbo { - id Int @id @default(autoincrement()) - firstUpload String + id Int @id @default(autoincrement()) + firstUpload String secondUpload String - message String? + message String? } model NoUploadFields { - id Int @id @default(autoincrement()) + id Int @id @default(autoincrement()) name String -} \ No newline at end of file +} + +model Book { + id Int @id @default(autoincrement()) + coverId Int @unique + cover BookCover @relation(fields: [coverId], references: [id]) + name String +} + +model BookCover { + id Int @id @default(autoincrement()) + // This is the upload field, + photo String + book Book? +} diff --git a/packages/uploads/src/prismaExtension.ts b/packages/uploads/src/prismaExtension.ts index 985045be784c..8be0f0dc9a3b 100644 --- a/packages/uploads/src/prismaExtension.ts +++ b/packages/uploads/src/prismaExtension.ts @@ -138,14 +138,16 @@ export const createUploadsExtension = ( }, async delete({ model, query, args }) { - /** Delete args are the same as findFirst, essentially a where clause */ - const record = - // @ts-expect-error TS in strict mode will error due to union type. We cannot narrow it down here. - await prismaInstance[model as ModelNames].findFirstOrThrow(args) + const deleteResult = await query(args) + storageAdapter.remove(args.where.id) - await removeUploadedFiles(uploadFields, record) + await removeUploadedFiles( + uploadFields, + // We don't know the exact type here + deleteResult as Record, + ) - return query(args) + return deleteResult }, } From b6586ef22f1169e55ba77d12beff73b463394586 Mon Sep 17 00:00:00 2001 From: Danny Choudhury Date: Thu, 29 Aug 2024 17:01:09 +0700 Subject: [PATCH 89/91] Whoopsie --- packages/uploads/src/prismaExtension.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/uploads/src/prismaExtension.ts b/packages/uploads/src/prismaExtension.ts index 8be0f0dc9a3b..b8a4498baaa1 100644 --- a/packages/uploads/src/prismaExtension.ts +++ b/packages/uploads/src/prismaExtension.ts @@ -139,8 +139,6 @@ export const createUploadsExtension = ( async delete({ model, query, args }) { const deleteResult = await query(args) - storageAdapter.remove(args.where.id) - await removeUploadedFiles( uploadFields, // We don't know the exact type here From 4508df719bfb9bea9a1efb6df35f3921b4682747 Mon Sep 17 00:00:00 2001 From: Danny Choudhury Date: Thu, 29 Aug 2024 17:14:39 +0700 Subject: [PATCH 90/91] Allow empty catch in query extension test --- packages/uploads/src/__tests__/queryExtensions.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/uploads/src/__tests__/queryExtensions.test.ts b/packages/uploads/src/__tests__/queryExtensions.test.ts index faa47cc587d4..70cfad682547 100644 --- a/packages/uploads/src/__tests__/queryExtensions.test.ts +++ b/packages/uploads/src/__tests__/queryExtensions.test.ts @@ -220,6 +220,7 @@ describe('Query extensions', () => { id: bookWithCover.coverId, }, }) + // eslint-disable-next-line no-empty } catch {} expect(fs.unlink).not.toHaveBeenCalled() From 5351342f2de535346b96631fb1359994bfd5a408 Mon Sep 17 00:00:00 2001 From: Danny Choudhury Date: Thu, 29 Aug 2024 17:15:06 +0700 Subject: [PATCH 91/91] Lint --- packages/uploads/package.json | 2 +- packages/uploads/src/prismaExtension.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/uploads/package.json b/packages/uploads/package.json index 2f1895d0cb3d..2f58a831df53 100644 --- a/packages/uploads/package.json +++ b/packages/uploads/package.json @@ -75,4 +75,4 @@ "vitest": "2.0.5" }, "gitHead": "3905ed045508b861b495f8d5630d76c7a157d8f1" -} \ No newline at end of file +} diff --git a/packages/uploads/src/prismaExtension.ts b/packages/uploads/src/prismaExtension.ts index b8a4498baaa1..8d532382a98e 100644 --- a/packages/uploads/src/prismaExtension.ts +++ b/packages/uploads/src/prismaExtension.ts @@ -137,7 +137,7 @@ export const createUploadsExtension = ( } }, - async delete({ model, query, args }) { + async delete({ query, args }) { const deleteResult = await query(args) await removeUploadedFiles( uploadFields,