Search code examples
typescriptfile-uploadmultermernformik

MERN, Multer and Formik image upload


I am trying to upload an image file from React to my REST API with Formik validation and Multer, but I receive Unexpected token '<', "<!DOCTYPE "... is not valid JSON with formData. If I try to make it JSON.stringify(formData), I receive invalid inputs. Can you tell me what is the correct way of submitting such file and storing it with multer as I am trying to do?

Disclamer: I am trying to store the images on the backend in uploads/images in the src folder with ,y typescript code. Is it okay to store them there or should I opt for the dist folder with the js code?

SignupForm.tsx

const SignUpForm = () => {
  const { loading, error, sendRequest } = useHttpClient();

  const errorMsg = useSelector(selectErrorMsg);
  return (
    <Fragment>
      {error && <Error errorMessage={errorMsg} />}
      <Heading>Welcome new user</Heading>
      <Formik
        validationSchema={schema}
        validateOnChange={false}
        onSubmit={async (values) => {
          try {
            let formData = new FormData();
            formData.append("name", values.name);
            formData.append("surname", values.surname);
            formData.append("image", values.image);
            formData.append("age", values.age);
            formData.append("email", values.email);
            formData.append("password", values.password);
            console.log('data',  values);
            const responseData = await sendRequest(
              "http://localhost:5000/api/user/signup",
              "POST",
              formData,
            );
            console.log('formData ', responseData);
            
          } catch (err) {}
        }}
        initialValues={{
          name: "",
          surname: "",
          image: "",
          age: "",
          email: "",
          password: "",
          confirmPassword: "",
          terms: false,
        }}
      >
        {({
          handleSubmit,
          handleChange,
          setFieldValue,
          values,
          touched,
          errors,
        }) => (
          <Form
            encType="multipart/form-data"
            className={classes.authenticate_display}
            style={{ width: "60%" }}
            noValidate
            onSubmit={handleSubmit}
          >
            <ImageInput
              value={values.image}
              isValid={touched.image && !errors.image}
              isInvalid={!!errors.image}
              errorMessage={errors.image}
              onChange={(event) => {
                setFieldValue("image", event.target.files[0]);
              }}

ImageInput.tsx

const ImageInput = (props: ImageInputProps) => {
  const [file, setFile] = useState();
  const [previewUrl, setPreviewUrl] = useState();
  const [isValid, setIsValid] = useState(true);

  const loading = useSelector(selectLoading);
  const dispatch = useDispatch();

  const inputHandler = (event) => {
    //set image
    let pickedFile;
    if (event.target.files || event.target.files.length === 1) {
      pickedFile = event.target.files[0];
      setFile(pickedFile);
      setIsValid(true);
      return;
    } else {
      setIsValid(false);
    }
  };

  useEffect(() => {
    if (!file) {
      return;
    }
    dispatch(startLoading());
    const fileReader: any = new FileReader();
    fileReader.onload = () => {
      setPreviewUrl(fileReader.result);
    };
    fileReader.readAsDataURL(file);
    console.log("file", file);

    dispatch(stopLoading());
  }, [file]);

  return (
    <div className={classes.image_input_panel}>
      {!isValid && <Error errorMessage="Corrupted file, please try again" />}
      <div className={classes.image_preview}>
        {!previewUrl ? (
          <div className={classes.image_input_icon}>
            {loading ? <Loader /> : <i className="fa-solid fa-file-image"></i>}
          </div>
        ) : (
          <img className={classes.image} src={previewUrl} alt="Preview" />
        )}
      </div>
      <form encType="multipart/form-data">
        <Input
          label="Image"
          name="image"
          type="file"
          onInput={inputHandler}
          onChange={props.onChange}
          defaultValue={props.defaultValue}
          isValid={props.isValid}
          isInvalid={props.isInvalid}
          errorMessage={props.errorMessage}
          accept=".jpg,.png,.jpeg"
        />
      </form>
    </div>
  );
};

http-hook.ts

export const useHttpClient = () => {
  const dispatch = useDispatch();
  const error = useSelector(selectError);
  const loading = useSelector(selectLoading);

  const activeHttpRequests: any = useRef([]);

  const sendRequest = useCallback(
    async (url, method = "GET", body = null, headers = {}) => {
      dispatch(startLoading());
      const httpAbortCtrl = new AbortController();
      activeHttpRequests.current.push(httpAbortCtrl);

      try {
        const response = await fetch(url, {
          method,
          body,
          headers,
          signal: httpAbortCtrl.signal,
        });

        const responseData = await response.json();

        activeHttpRequests.current = activeHttpRequests.current.filter(
          (reqCtrl) => reqCtrl !== httpAbortCtrl
        );

        if (!response.ok) {
          throw new Error(responseData.message);
        }

        dispatch(stopLoading());
        return responseData;
      } catch (err:any) {
        dispatch(showError(err.message))
        dispatch(stopLoading())
        throw err;
      }
    },
    []
  );


  useEffect(() => {
    return () => {
      activeHttpRequests.current.forEach((abortCtrl: any) => abortCtrl.abort());
    };
  }, []);

  return { loading, error, sendRequest };
};

users-controller.tsx (the part with sign up request)

const signup = async (
  req: express.Request,
  res: express.Response,
  next: express.NextFunction
) => {
  const errors = validationResult(req);
  if (!errors.isEmpty()) {
    const error = new HttpError("Invalid inputs passed", 422);
    console.log(error);
    return next(error);
  }
  const { name, surname, age, email, password } = req.body;
  const image = 'http://localhost:5000/' + req.file.path;

  let existingUser;
  try {
    existingUser = await User.findOne({ email: email });
  } catch (err) {
    const error = new HttpError("Signing up failed", 500);
    return next(error);
  }

  if (existingUser) {
    const error = new HttpError("User exists already", 422);
    return next(error);
  }

  const createUser = new User({
    name,
    surname,
    age,
    image,
    email,
    password,
    projects: [],
    chats: [],
  });

  try {
    await createUser.save();
  } catch (err) {
    const error = new HttpError("Signing up failed", 500);
    return next(error);
  }

  res.status(201).json({ user: createUser.toObject({ getters: true }) });
};

User.tsx (model)

import mongoose from "mongoose";
import uniqueValidator from "mongoose-unique-validator";

const Schema = mongoose.Schema;

const userSchema = new Schema({
  name: { type: String, required: true },
  surname: { type: String, required: true },
  image: { type: String, required: true },
  age: { type: Number, required: true },
  email: { type: String, required: true, unique: true },
  password: { type: String, required: true, minlength: 8 },
  projects: [{ type: mongoose.Types.ObjectId, required: true, ref: "Project" }],
  chats: [{ type: mongoose.Types.ObjectId, ref: "User" }],
});

userSchema.plugin(uniqueValidator);

export default mongoose.model("User", userSchema);

file-upload.ts (multer config)

import multer from "multer";
import { v4 as uuidv4 } from "uuid";

const MIME_TYPE_MAP: any = {
  "image/png": "png",
  "image/jpeg": "jpeg",
  "image/jpg": "jpg",
};

const storage = multer.diskStorage({
  destination: (req, file, cb) => {
    cb(null, 'uploads/images/')
  },
  filename: (req, file, cb) => {
    const ext = MIME_TYPE_MAP[file.mimetype];
    cb(null, uuidv4() + "." + ext)
  }
})

const upload = multer({storage:storage})


export { upload };

user-routes.ts (part with the controller connection)

`userRouter.post(
  "/signup",
  upload.single('image'),
  [
    check("name").notEmpty(),
    check("surname").notEmpty(),
    check("email").normalizeEmail().isEmail(),
    check("password").isLength({ min: 8 }),
  ],
  signup
);`

app.ts (server multer file config)

`app.use("/uploads/images", express.static(path.join("uploads", "images")));
`

Solution

  • Okay so it looks like I had some code in the server app that immediately deleted the file. It was supposed to delete any images that would not be used anymore. In this variant the code works as intended so feel free to use it.