Skip to content
New issue

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

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

Already on GitHub? Sign in to your account

Payload v3 accepts only single file uploads, export fetchAPIFileUpload as a workaround #10125

Open
max-degterev opened this issue Dec 21, 2024 · 2 comments
Labels
status: needs-triage Possible bug which hasn't been reproduced yet

Comments

@max-degterev
Copy link

max-degterev commented Dec 21, 2024

Describe the Bug

Payload v2 used to be able to handle any shape of multipart data and parse files accordingly. v3 is only able to find a single file named file.

Ideally as a long term solution it would be great to have payload automatically figure out data types passed in a multipart body and create matching data structure. So just req.files = files and done I guess...

As a quick and dirty fix it would be also great to just expose fetchAPIFileUpload so that end users can easily create custom body parsers while still using global payload upload configuration.

Link to the code that reproduces this issue

https://github.com/payloadcms/payload/blob/main/packages/next/src/fetchAPI-multipart/index.ts

Reproduction Steps

upgrade to v3
make sad noises attempting to migrate file uploads

Which area(s) are affected? (Select all that apply)

area: core

Environment Info

v3
@max-degterev max-degterev added status: needs-triage Possible bug which hasn't been reproduced yet validate-reproduction labels Dec 21, 2024
@max-degterev
Copy link
Author

Temporary hackfix:

import fs from 'node:fs/promises';

const packages = {
  "@payloadcms/next": {
    "exports": {
      "./fetchAPI-multipart": {
        "import": "./dist/fetchAPI-multipart/index.js",
        "types": "./dist/fetchAPI-multipart/index.d.ts",
        "default": "./dist/fetchAPI-multipart/index.js"
      },
    },
  },
};

const deepMerge = (target, overrides) => {
  for (const [key, value] of Object.entries(overrides)) {
    if (!target[key]) target[key] = {};
    if (typeof value === 'object') return deepMerge(target[key], value);
    target[key] = value;
  }
};

const postinstall = async () => Object.entries(packages).forEach(async ([name, patches]) => {
  const packageJsonPath = `node_modules/${name}/package.json`;

  const packageString = await fs.readFile(packageJsonPath, 'utf8');
  const packageJson = JSON.parse(packageString);

  deepMerge(packageJson, patches);

  await fs.writeFile(packageJsonPath, JSON.stringify(packageJson, null, 2));
});

postinstall();
console.log(`Applied patches to ${Object.keys(packages).length} packages`);

run this in "postinstall"

@max-degterev
Copy link
Author

max-degterev commented Dec 21, 2024

And this brings back the old style of body parsing, automatically finding data field and safely parsing as JSON as a bonus

import { set } from 'lodash-es';
import { APIError, type PayloadRequest } from 'payload';
import { fetchAPIFileUpload, type FetchAPIFileUploadResponse } from '@payloadcms/next/fetchAPI-multipart';
import { v4 as uuidv4 } from 'uuid';

type UploadedFile = FetchAPIFileUploadResponse['files'][string];
type NestedFiles = {
  // Not using Record because TS fails to handle circular references
  [key: string]: UploadedFile | NestedFiles,
};

export type PayloadRequestWithFiles = Omit<PayloadRequest, 'file'> & {
  files?: NestedFiles,
};

const createFilesStrucure = (files: FetchAPIFileUploadResponse['files']) => {
  const result: Record<string, UploadedFile> = {};
  Object.entries(files).forEach(([key, file]) => {
    if (!file || !file.size || !file.data || !file.data.length) return;

    const parts = (file.name || 'file').split('.');
    const random = uuidv4().slice(0, 8);
    const extension = parts.pop();

    // Patch file name to include random string, avoiding collisions
    // eslint-disable-next-line no-param-reassign
    file.name = `${parts.join('.')}-${random}.${extension}`;

    set(result, key, file);
  });
  return result;
};

export const addDataAndFilesToRequest = async (req: PayloadRequest) => {
  const { body, headers, method, payload } = req;
  const mutableReq = req as PayloadRequestWithFiles;

  if (method && ['PATCH', 'POST', 'PUT'].includes(method.toUpperCase()) && body) {
    const [contentType] = (headers.get('Content-Type') || '').split(';');
    const bodyByteSize = parseInt(req.headers.get('Content-Length') || '0', 10);

    if (contentType === 'application/json') {
      let data = {};
      try {
        data = await req.json?.();
      } catch (error) {
        req.payload.logger.error(error);
      } finally {
        mutableReq.data = data;
        mutableReq.json = () => Promise.resolve(data);
      }
    } else if (bodyByteSize && contentType.includes('multipart/')) {
      const { error, fields, files } = await fetchAPIFileUpload({
        options: payload.config.upload,
        request: req as Request,
      });

      if (error) throw new APIError(error.message);

      mutableReq.files = createFilesStrucure(files);

      // Guessing that the string field is JSON compatible, not throwing an error if it's not
      const maybeJSON = Object.values(fields).find((value) => typeof value === 'string');
      if (maybeJSON) {
        try {
          mutableReq.data = JSON.parse(maybeJSON);
        } catch (_) { /* Ignore */ }
      }
    }
  }
};

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
status: needs-triage Possible bug which hasn't been reproduced yet
Projects
None yet
Development

No branches or pull requests

1 participant