DEFRA/ivory-front-office

View on GitHub
server/modules/photos/add-photograph.handlers.js

Summary

Maintainability
A
0 mins
Test Coverage
const Joi = require('@hapi/joi')
const path = require('path')
const { utils, joiUtilities } = require('defra-hapi-utils')
const { uuid, setNestedVal, getNestedVal } = utils
const { Item } = require('ivory-data-mapping').cache
const photos = require('defra-hapi-photos')
const { logger } = require('defra-logging-facade')
const config = require('../../config')
const { createError } = joiUtilities

class AddPhotographsHandlers extends require('defra-hapi-handlers') {
  get Item () {
    return Item
  }

  get fieldname () {
    return 'photograph'
  }

  get maxPhotos () {
    return config.photoUploadMaxPhotos
  }

  get validFileTypes () {
    return {
      JPG: { mimeType: 'image/jpeg' },
      JPEG: { mimeType: 'image/jpeg' },
      PNG: { mimeType: 'image/png' }
    }
  }

  get mimeTypes () {
    return Object.values(this.validFileTypes).map(({ mimeType }) => mimeType)
  }

  get photoSchema () {
    const { minKb, maxMb } = this.photos
    return Joi.object({
      _data: Joi.binary().min(minKb * 1024).max(maxMb * 1024 * 1024).optional(), // Check the file data buffer size (the important one)
      hapi: Joi.object({
        headers: Joi.object({
          'content-type': Joi.string().valid(...this.mimeTypes).required() // Check the content-type is set, so we can set it in S3
        }).unknown(true),
        filename: Joi.string().required() // Check a filename is there to get the extension from
      }).unknown(true)
    }).unknown(true)
  }

  get schema () {
    return Joi.alternatives().try(
      Joi.object({ [this.fieldname]: this.photoSchema }),
      Joi.object({ [this.fieldname]: Joi.array().items(this.photoSchema).max(this.maxPhotos) })
    )
  }

  get errorMessages () {
    const fileTypes = Object.keys((this.validFileTypes)).join(', ')
    const { minKb, maxMb } = this.photos
    return {
      [this.fieldname]: {
        'string.empty': 'You must add a photo',
        'array.max': `Only a maximum of ${this.maxPhotos} files can be uploaded – try again`,
        'any.only': `The selected file must be a ${fileTypes.replace(/,\s([^,]+)$/, ' or $1')}`,
        'binary.min': `The selected file must be bigger than ${minKb}KB`,
        'binary.max': `You can only upload ${maxMb}MB at a time. If you selected more than one photo - try adding them separately`,
        'custom.uploadfailed': 'The selected file could not be uploaded – try again'
      }
    }
  }

  async hasPhotos (request) {
    const { photos = [] } = await this.Item.get(request) || {}
    return photos.length > 0
  }

  async failAction (request, h, errors) {
    switch (getNestedVal(request, 'response.output.statusCode')) {
      case 408: // Request timeout
      case 413: // Payload exceeds maximum
        return super.failAction(request, h, createError(request, [this.fieldname], 'binary.max'))
      default:
        return super.failAction(request, h, errors)
    }
  }

  // Overrides parent class handleGet
  async handleGet (request, h, errors) {
    const cancelLink = await this.getNextPath(request)
    const { photos = [] } = await this.Item.get(request) || {}
    this.viewData = {
      addedPhotos: photos.length,
      maxPhotos: this.maxPhotos,
      mimeTypes: this.mimeTypes.join(', '),
      cancelLink
    }
    const result = await super.handleGet(request, h, errors)
    if (errors && !getNestedVal(result, 'source.context.DefraCsrfToken')) {
      // Make sure the Csrf Token is included during a photograph upload error
      setNestedVal(result, 'source.context.DefraCsrfToken', request.state.DefraCsrfToken)
    }
    return result
  }

  async handleUpload (request, h, item, photoPayload) {
    const originalFilename = path.basename(photoPayload.hapi.filename)
    const fileExtension = path.extname(originalFilename)
    const contentType = photoPayload.hapi.headers['content-type']
    const filename = uuid() + fileExtension

    let filenameUploaded
    try {
      filenameUploaded = await this.photos.upload(filename, contentType, photoPayload)
    } catch (err) {
      // The upload failed, so tell the user to try again
      // Rather than building from scratch, generate an example error structure and overwrite the type
      logger.error(`Caught error from upload in handler: ${err}`)
      return err
    }

    const photo = { filename: filenameUploaded, originalFilename, rank: item.photos.length, confirmed: false }
    item.photos.push(photo)
  }

  // Overrides parent class handlePost
  async handlePost (request, h) {
    const item = await this.Item.get(request) || { description: '  ' } // Had to include description of spaces so the service doesn't fail saving an empty item
    if (!getNestedVal(item, 'photos.length')) {
    // It's the first photo, so create the photos array
      item.photos = []
    }

    const payload = request.payload[this.fieldname]

    const data = Array.isArray(payload) ? payload : [payload]

    if (item.photos.length + data.length > this.maxPhotos) {
      return this.failAction(request, h, createError(request, [this.fieldname], 'array.max'))
    }

    const errors = await Promise.all(data.map(async (photoPayload) => this.handleUpload(request, h, item, photoPayload)))
    const error = errors.find((error) => error)

    if (error) {
      return this.failAction(request, h, createError(request, [this.fieldname], 'custom.uploadfailed'))
    }

    await this.Item.set(request, item)

    return super.handlePost(request, h)
  }

  async getPayload () {
    this.photos = await photos.getPhotos()
    return { // https://hapi.dev/api/?v=18.4.0#route.options.payload
      allow: 'multipart/form-data',
      output: 'stream',
      parse: true,
      maxBytes: this.photos.payloadMaxBytes // Hapi defaults to 1048576 (1MB). Allow the max photo size plus some additional payload data.
    }
  }
}

module.exports = AddPhotographsHandlers