Skip to content
Snippets Groups Projects
Code owners
Assign users and groups as approvers for specific file changes. Learn more.
fileStorage.js 11.62 KiB
const AWS = require('aws-sdk')
const fs = require('fs-extra')
const config = require('config')
const forEach = require('lodash/forEach')
const sharp = require('sharp')
const crypto = require('crypto')
const path = require('path')
const mime = require('mime-types')
const logger = require('@pubsweet/logger')

const {
  convertFileStreamIntoBuffer,
  getFileExtension,
  getImageFileMetadata,
  writeFileFromStream,
} = require('../helpers')

const { tempFolderPath } = config.get('pubsweet-server')

// Initializing Storage Interface
let s3

const healthCheck = () => {
  try {
    if (!s3) {
      throw new Error(
        's3 does not exist! Probably configuration is missing/invalid',
      )
    }

    const { bucket } = config.get('fileStorage')

    return new Promise((resolve, reject) => {
      s3.getBucketLogging({ Bucket: bucket }, (err, data) => {
        if (err) {
          logger.error(
            'File Storage Healthcheck: Communication to remote file service unsuccessful',
          )
          return reject(err)
        }

        logger.info('File Storage Healthcheck: OK')
        return resolve(data)
      })
    })
  } catch (e) {
    throw new Error(e)
  }
}

if (
  config.has('pubsweet-server.useFileStorage') &&
  config.get('pubsweet-server.useFileStorage')
) {
  if (!config.has('fileStorage')) {
    throw new Error(
      'you have declared that you will use file storage but fileStorage configuration is missing',
    )
  }

  const {
    accessKeyId,
    secretAccessKey,
    bucket,
    protocol,
    host,
    port,
  } = config.get('fileStorage')

  if (!protocol) {
    throw new Error(
      'missing required protocol param for initializing file storage',
    )
  }

  if (!host) {
    throw new Error('missing required host param for initializing file storage')
  }

  if (!accessKeyId) {
    throw new Error(
      'missing required accessKeyId param for initializing file storage',
    )
  }

  if (!accessKeyId) {
    throw new Error(
      'missing required secretAccessKey param for initializing file storage',
    )
  }

  if (!bucket) {
    throw new Error(
      'missing required bucket param for initializing file storage',
    )
  }

  const serverUrl = `${protocol}://${host}${port ? `:${port}` : ''}`

  s3 = new AWS.S3({
    accessKeyId,
    signatureVersion: 'v4',
    secretAccessKey,
    s3ForcePathStyle: true,
    endpoint: serverUrl,
  })

  healthCheck()
}

const createImageVersions = async (
  buffer,
  tempDirRoot,
  filenameWithoutExtension,
  originalImageWidth,
  isSVG,
) => {
  try {
    const {
      maximumWidthForSmallImages,
      maximumWidthForMediumImages,
    } = config.get('fileStorage')

    const mediumWidth = maximumWidthForMediumImages
      ? parseInt(maximumWidthForMediumImages, 10)
      : 640

    const smallWidth = maximumWidthForSmallImages
      ? parseInt(maximumWidthForSmallImages, 10)
      : 180

    const smallFilePath = path.join(
      tempDirRoot,
      `${filenameWithoutExtension}_small.${isSVG ? 'svg' : 'jpeg'}`,
    )

    const mediumFilePath = path.join(
      tempDirRoot,
      `${filenameWithoutExtension}_medium.${isSVG ? 'svg' : 'jpeg'}`,
    )

    // all the versions of SVG will be the same as the original file
    if (isSVG) {
      await sharp(buffer).toFile(smallFilePath)
      await sharp(buffer).toFile(mediumFilePath)

      return {
        tempSmallFile: smallFilePath,
        tempMediumFile: mediumFilePath,
      }
    }

    await sharp(buffer)
      .resize({
        width: smallWidth,
      })
      .toFile(smallFilePath)

    if (originalImageWidth < mediumWidth) {
      await sharp(buffer).toFile(mediumFilePath)
    } else {
      await sharp(buffer).resize({ width: mediumWidth }).toFile(mediumFilePath)
    }

    return {
      tempSmallFile: smallFilePath,
      tempMediumFile: mediumFilePath,
    }
  } catch (e) {
    throw new Error(e)
  }
}

const getURL = async (objectKey, options = {}) => {
  if (!s3) {
    throw new Error(
      's3 does not exist! Probably configuration is missing/invalid',
    )
  }

  const { bucket } = config.get('fileStorage')
  const { expiresIn } = options

  const s3Params = {
    Bucket: bucket,
    Key: objectKey,
    Expires: expiresIn || parseInt(86400, 10), // 1 day lease
  }

  return s3.getSignedUrl('getObject', s3Params)
}

const uploadFileHandler = (fileStream, filename, mimetype) => {
  if (!s3) {
    throw new Error(
      's3 does not exist! Probably configuration is missing/invalid',
    )
  }

  const { bucket } = config.get('fileStorage')

  const params = {
    Bucket: bucket,
    Key: filename, // file name you want to save as
    Body: fileStream,
    ContentType: mimetype,
  }

  return new Promise((resolve, reject) => {
    s3.upload(params, (err, data) => {
      if (err) {
        reject(err)
      }

      // do we need etag?
      const { Key } = data
      resolve({ key: Key })
    })
  })
}

const handleImageUpload = async (fileStream, hashedFilename) => {
  try {
    const storedObjects = []
    const randomHash = crypto.randomBytes(6).toString('hex')
    const tempDirRoot = tempFolderPath || path.join(process.cwd(), 'temp')
    const tempDir = path.join(tempDirRoot, randomHash)

    await fs.ensureDir(tempDir)
    const filenameWithoutExtension = path.parse(hashedFilename).name
    const tempOriginalFilePath = path.join(tempDir, hashedFilename)
    await writeFileFromStream(fileStream, tempOriginalFilePath)

    const originalFileBuffer = await convertFileStreamIntoBuffer(
      fs.createReadStream(tempOriginalFilePath),
    )

    const {
      width,
      height,
      space,
      density,
      size,
      format,
    } = await getImageFileMetadata(originalFileBuffer)

    const localImageVersionPaths = await createImageVersions(
      originalFileBuffer,
      tempDir,
      filenameWithoutExtension,
      width,
      format === 'svg',
    )

    const { tempSmallFile, tempMediumFile } = localImageVersionPaths

    const mediumImageStream = fs.createReadStream(tempMediumFile)

    const smallImageStream = fs.createReadStream(tempSmallFile)

    const original = await uploadFileHandler(
      fs.createReadStream(tempOriginalFilePath),
      hashedFilename,
      mime.lookup(hashedFilename),
    )

    original.imageMetadata = {
      density,
      height,
      space,
      width,
    }
    original.size = size
    original.extension = `${getFileExtension(hashedFilename)}`
    original.type = 'original'
    original.mimetype = mime.lookup(hashedFilename)

    storedObjects.push(original)

    const medium = await uploadFileHandler(
      fs.createReadStream(tempMediumFile),
      path.basename(tempMediumFile),
      mime.lookup(tempMediumFile),
    )

    const mediumFileBuffer = await convertFileStreamIntoBuffer(
      mediumImageStream,
    )

    const {
      width: mWidth,
      height: mHeight,
      space: mSpace,
      density: mDensity,
      size: mSize,
    } = await getImageFileMetadata(mediumFileBuffer)

    medium.imageMetadata = {
      density: mDensity,
      height: mHeight,
      space: mSpace,
      width: mWidth,
    }
    medium.size = mSize
    medium.extension = `${getFileExtension(tempMediumFile)}`
    medium.type = 'medium'
    medium.mimetype = mime.lookup(tempMediumFile)

    storedObjects.push(medium)

    const small = await uploadFileHandler(
      fs.createReadStream(tempSmallFile),
      path.basename(tempSmallFile),
      mime.lookup(tempSmallFile),
    )

    const smallFileBuffer = await convertFileStreamIntoBuffer(smallImageStream)

    const {
      width: sWidth,
      height: sHeight,
      space: sSpace,
      density: sDensity,
      size: sSize,
    } = await getImageFileMetadata(smallFileBuffer)

    small.imageMetadata = {
      density: sDensity,
      height: sHeight,
      space: sSpace,
      width: sWidth,
    }
    small.size = sSize
    small.extension = `${getFileExtension(tempSmallFile)}`
    small.type = 'small'
    small.mimetype = mime.lookup(tempSmallFile)

    storedObjects.push(small)

    await fs.remove(tempDir)

    return storedObjects
  } catch (e) {
    throw new Error(e)
  }
}

// Returns an array of stored objects
// [{
//  type: String (values: original/small/medium),
//  key: String
//  mimetype: String
//  extension: String
//  size: Integer
// }]

const upload = async (fileStream, filename, options = {}) => {
  try {
    if (!filename) {
      throw new Error('filename is required')
    }

    const { forceFilenameAsObjectKey } = options

    const mimetype = mime.lookup(filename)
    let storedObjects = []
    let hashedFilename

    if (forceFilenameAsObjectKey) {
      hashedFilename = filename
    } else {
      hashedFilename = `${crypto
        .randomBytes(6)
        .toString('hex')}${getFileExtension(filename, true)}`
    }

    if (!mimetype.match(/^image\//)) {
      const storedObject = await uploadFileHandler(
        fileStream,
        hashedFilename,
        mimetype,
      )

      const { ContentLength } = await getFileInfo(storedObject.key)
      storedObject.type = 'original'
      storedObject.size = ContentLength
      storedObject.extension = `${getFileExtension(filename)}`
      storedObject.mimetype = mimetype
      storedObjects.push(storedObject)
      return storedObjects
    }

    storedObjects = await handleImageUpload(
      fileStream,
      hashedFilename,
      mimetype,
    )

    return storedObjects
  } catch (e) {
    throw new Error(e)
  }
}

const getFileInfo = key => {
  if (!s3) {
    throw new Error(
      's3 does not exist! Probably configuration is missing/invalid',
    )
  }

  const { bucket } = config.get('fileStorage')

  const params = {
    Bucket: bucket,
    Key: key,
  }

  return new Promise((resolve, reject) => {
    s3.getObject(params, (err, data) => {
      if (err) {
        reject(err)
      }

      resolve(data)
    })
  })
}

const list = () => {
  if (!s3) {
    throw new Error(
      's3 does not exist! Probably configuration is missing/invalid',
    )
  }

  const { bucket } = config.get('fileStorage')

  const params = {
    Bucket: bucket,
  }

  return new Promise((resolve, reject) => {
    s3.listObjects(params, (err, data) => {
      if (err) {
        reject(err)
      }

      resolve(data)
    })
  })
}

// Accepts an array of object keys
const deleteFiles = objectKeys => {
  if (!s3) {
    throw new Error(
      's3 does not exist! Probably configuration is missing/invalid',
    )
  }

  const { bucket } = config.get('fileStorage')

  if (objectKeys.length === 0) {
    throw new Error('the provided array of keys if empty')
  }

  const params = {
    Bucket: bucket,
    Delete: {
      Objects: [],
      Quiet: false,
    },
  }

  forEach(objectKeys, objectKey => {
    params.Delete.Objects.push({ Key: objectKey })
  })

  return new Promise((resolve, reject) => {
    s3.deleteObjects(params, (err, data) => {
      if (err) {
        reject(err)
      }

      resolve(data)
    })
  })
}
const download = (key, localPath) => {
  if (!s3) {
    throw new Error(
      's3 does not exist! Probably configuration is missing/invalid',
    )
  }

  const { bucket } = config.get('fileStorage')
  const fileStream = fs.createWriteStream(localPath)
  const s3Stream = s3.getObject({ Bucket: bucket, Key: key }).createReadStream()

  return new Promise((resolve, reject) => {
    // Listen for errors returned by the service
    s3Stream.on('error', err => {
      // NoSuchKey: The specified key does not exist
      reject(err)
    })

    s3Stream
      .pipe(fileStream)
      .on('error', err => {
        // capture any errors that occur when writing data to the file
        reject(err)
        console.error('File Stream:', err)
      })
      .on('close', () => {
        resolve()
      })
  })
}

module.exports = {
  healthCheck,
  getURL,
  upload,
  deleteFiles,
  list,
  download,
}