diff --git a/keyserver/src/creators/upload-creator.js b/keyserver/src/creators/upload-creator.js index b37635669..b3a77e203 100644 --- a/keyserver/src/creators/upload-creator.js +++ b/keyserver/src/creators/upload-creator.js @@ -1,70 +1,71 @@ // @flow import crypto from 'crypto'; import type { MediaType, UploadMultimediaResult, Dimensions, } from 'lib/types/media-types.js'; import { ServerError } from 'lib/utils/errors.js'; import createIDs from './id-creator.js'; import { dbQuery, SQL } from '../database/database.js'; import { getUploadURL } from '../fetchers/upload-fetchers.js'; import type { Viewer } from '../session/viewer.js'; export type UploadInput = { name: string, mime: string, mediaType: MediaType, buffer: Buffer, dimensions: Dimensions, loop: boolean, + encryptionKey?: string, }; async function createUploads( viewer: Viewer, uploadInfos: $ReadOnlyArray, ): Promise { if (!viewer.loggedIn) { throw new ServerError('not_logged_in'); } const ids = await createIDs('uploads', uploadInfos.length); const uploadRows = uploadInfos.map(uploadInfo => { const id = ids.shift(); const secret = crypto.randomBytes(8).toString('hex'); - const { dimensions, mediaType, loop } = uploadInfo; + const { dimensions, mediaType, loop, encryptionKey } = uploadInfo; return { uploadResult: { id, uri: getUploadURL(id, secret), dimensions, mediaType, loop, }, insert: [ id, viewer.userID, mediaType, uploadInfo.name, uploadInfo.mime, uploadInfo.buffer, secret, Date.now(), - JSON.stringify({ ...dimensions, loop }), + JSON.stringify({ ...dimensions, loop, encryptionKey }), ], }; }); const insertQuery = SQL` INSERT INTO uploads(id, uploader, type, filename, mime, content, secret, creation_time, extra) VALUES ${uploadRows.map(({ insert }) => insert)} `; await dbQuery(insertQuery); return uploadRows.map(({ uploadResult }) => uploadResult); } export default createUploads; diff --git a/keyserver/src/uploads/media-utils.js b/keyserver/src/uploads/media-utils.js index fa1bcc840..353b280be 100644 --- a/keyserver/src/uploads/media-utils.js +++ b/keyserver/src/uploads/media-utils.js @@ -1,172 +1,219 @@ // @flow import bmp from '@vingle/bmp-js'; import invariant from 'invariant'; import sharp from 'sharp'; import { serverTranscodableTypes, serverCanHandleTypes, readableFilename, + mediaConfig, } from 'lib/media/file-utils.js'; import { getImageProcessingPlan } from 'lib/media/image-utils.js'; import type { Dimensions } from 'lib/types/media-types.js'; import { deepFileInfoFromData } from 'web/media/file-utils.js'; import type { UploadInput } from '../creators/upload-creator.js'; function initializeSharp(buffer: Buffer, mime: string) { if (mime !== 'image/bmp') { return sharp(buffer); } const bitmap = bmp.decode(buffer, true); return sharp(bitmap.data, { raw: { width: bitmap.width, height: bitmap.height, channels: 4, }, }); } +type ValidateAndConvertInput = { + +initialBuffer: Buffer, + +initialName: string, + +inputDimensions: ?Dimensions, + +inputLoop: boolean, + +inputEncryptionKey: ?string, + +inputMimeType: ?string, + +size: number, // in bytes +}; async function validateAndConvert( - initialBuffer: Buffer, - initialName: string, - inputDimensions: ?Dimensions, - inputLoop: boolean, - size: number, // in bytes + input: ValidateAndConvertInput, ): Promise { + const { + initialBuffer, + initialName, + inputDimensions, + inputLoop, + inputEncryptionKey, + inputMimeType, + size, // in bytes + } = input; + + // we don't want to transcode encrypted files + if (inputEncryptionKey) { + invariant( + inputMimeType, + 'inputMimeType should be set in validateAndConvert for encrypted files', + ); + invariant( + inputDimensions, + 'inputDimensions should be set in validateAndConvert for encrypted files', + ); + + if (!serverCanHandleTypes.has(inputMimeType)) { + return null; + } + const mediaType = mediaConfig[inputMimeType]?.mediaType; + invariant( + mediaType === 'photo' || mediaType === 'video', + `mediaType for ${inputMimeType} should be photo or video`, + ); + + return { + name: initialName, + mime: inputMimeType, + mediaType, + buffer: initialBuffer, + dimensions: inputDimensions, + loop: inputLoop, + encryptionKey: inputEncryptionKey, + }; + } + const { mime, mediaType } = deepFileInfoFromData(initialBuffer); if (!mime || !mediaType) { return null; } if (!serverCanHandleTypes.has(mime)) { return null; } if (mediaType === 'video') { invariant( inputDimensions, 'inputDimensions should be set in validateAndConvert', ); return { mime: mime, mediaType: mediaType, name: initialName, buffer: initialBuffer, dimensions: inputDimensions, loop: inputLoop, }; } if (!serverTranscodableTypes.has(mime)) { // This should've gotten converted on the client return null; } return convertImage( initialBuffer, mime, initialName, inputDimensions, inputLoop, size, ); } async function convertImage( initialBuffer: Buffer, mime: string, initialName: string, inputDimensions: ?Dimensions, inputLoop: boolean, size: number, ): Promise { let sharpImage, metadata; try { sharpImage = initializeSharp(initialBuffer, mime); metadata = await sharpImage.metadata(); } catch (e) { return null; } let initialDimensions = inputDimensions; if (!initialDimensions) { if (metadata.orientation && metadata.orientation > 4) { initialDimensions = { width: metadata.height, height: metadata.width }; } else { initialDimensions = { width: metadata.width, height: metadata.height }; } } const plan = getImageProcessingPlan({ inputMIME: mime, inputDimensions: initialDimensions, inputFileSize: size, inputOrientation: metadata.orientation, }); if (plan.action === 'none') { const name = readableFilename(initialName, mime); invariant(name, `should be able to construct filename for ${mime}`); return { mime, mediaType: 'photo', name, buffer: initialBuffer, dimensions: initialDimensions, loop: inputLoop, }; } console.log(`processing image with ${JSON.stringify(plan)}`); const { targetMIME, compressionRatio, fitInside, shouldRotate } = plan; if (shouldRotate) { sharpImage = sharpImage.rotate(); } if (fitInside) { sharpImage = sharpImage.resize(fitInside.width, fitInside.height, { fit: 'inside', withoutEnlargement: true, }); } if (targetMIME === 'image/png') { sharpImage = sharpImage.png(); } else { sharpImage = sharpImage.jpeg({ quality: compressionRatio * 100 }); } const { data: convertedBuffer, info } = await sharpImage.toBuffer({ resolveWithObject: true, }); const convertedDimensions = { width: info.width, height: info.height }; const { mime: convertedMIME, mediaType: convertedMediaType } = deepFileInfoFromData(convertedBuffer); if ( !convertedMIME || !convertedMediaType || convertedMIME !== targetMIME || convertedMediaType !== 'photo' ) { return null; } const convertedName = readableFilename(initialName, targetMIME); if (!convertedName) { return null; } return { mime: targetMIME, mediaType: 'photo', name: convertedName, buffer: convertedBuffer, dimensions: convertedDimensions, loop: inputLoop, }; } export { validateAndConvert }; diff --git a/keyserver/src/uploads/uploads.js b/keyserver/src/uploads/uploads.js index a7b3b2c21..055a5af64 100644 --- a/keyserver/src/uploads/uploads.js +++ b/keyserver/src/uploads/uploads.js @@ -1,169 +1,171 @@ // @flow import type { $Request, $Response, Middleware } from 'express'; import invariant from 'invariant'; import multer from 'multer'; import { Readable } from 'stream'; import type { UploadMultimediaResult, UploadDeletionRequest, Dimensions, } from 'lib/types/media-types.js'; import { ServerError } from 'lib/utils/errors.js'; import { validateAndConvert } from './media-utils.js'; import createUploads from '../creators/upload-creator.js'; import { deleteUpload } from '../deleters/upload-deleters.js'; import { fetchUpload, fetchUploadChunk, getUploadSize, } from '../fetchers/upload-fetchers.js'; import type { MulterRequest } from '../responders/handlers.js'; import type { Viewer } from '../session/viewer.js'; const upload = multer(); const multerProcessor: Middleware<> = upload.array('multimedia'); type MultimediaUploadResult = { results: UploadMultimediaResult[], }; async function multimediaUploadResponder( viewer: Viewer, req: MulterRequest, ): Promise { const { files, body } = req; if (!files || !body || typeof body !== 'object') { throw new ServerError('invalid_parameters'); } const overrideFilename = files.length === 1 && body.filename ? body.filename : null; if (overrideFilename && typeof overrideFilename !== 'string') { throw new ServerError('invalid_parameters'); } const inputHeight = files.length === 1 && body.height ? parseInt(body.height) : null; const inputWidth = files.length === 1 && body.width ? parseInt(body.width) : null; if (!!inputHeight !== !!inputWidth) { throw new ServerError('invalid_parameters'); } const inputDimensions: ?Dimensions = inputHeight && inputWidth ? { height: inputHeight, width: inputWidth } : null; const inputLoop = !!(files.length === 1 && body.loop); const inputEncryptionKey = files.length === 1 && body.encryptionKey ? body.encryptionKey : null; if (inputEncryptionKey && typeof inputEncryptionKey !== 'string') { throw new ServerError('invalid_parameters'); } const inputMimeType = files.length === 1 && body.mimeType ? body.mimeType : null; if (inputMimeType && typeof inputMimeType !== 'string') { throw new ServerError('invalid_parameters'); } const validationResults = await Promise.all( files.map(({ buffer, size, originalname }) => - validateAndConvert( - buffer, - overrideFilename ? overrideFilename : originalname, + validateAndConvert({ + initialBuffer: buffer, + initialName: overrideFilename ? overrideFilename : originalname, inputDimensions, inputLoop, + inputEncryptionKey, + inputMimeType, size, - ), + }), ), ); const uploadInfos = validationResults.filter(Boolean); if (uploadInfos.length === 0) { throw new ServerError('invalid_parameters'); } const results = await createUploads(viewer, uploadInfos); return { results }; } async function uploadDownloadResponder( viewer: Viewer, req: $Request, res: $Response, ): Promise { const { uploadID, secret } = req.params; if (!uploadID || !secret) { throw new ServerError('invalid_parameters'); } if (!req.headers.range) { const { content, mime } = await fetchUpload(viewer, uploadID, secret); res.type(mime); res.set('Cache-Control', 'public, max-age=31557600, immutable'); if (process.env.NODE_ENV === 'development') { // Add a CORS header to allow local development using localhost const port = process.env.PORT || '3000'; res.set('Access-Control-Allow-Origin', `http://localhost:${port}`); res.set('Access-Control-Allow-Methods', 'GET'); } res.send(content); } else { const totalUploadSize = await getUploadSize(uploadID, secret); const range = req.range(totalUploadSize); if (typeof range === 'number' && range < 0) { throw new ServerError( range === -1 ? 'unsatisfiable_range' : 'malformed_header_string', ); } invariant( Array.isArray(range), 'range should be Array in uploadDownloadResponder!', ); const { start, end } = range[0]; const respWidth = end - start + 1; const { content, mime } = await fetchUploadChunk( uploadID, secret, start, respWidth, ); const respRange = `${start}-${end}/${totalUploadSize}`; const respHeaders: { [key: string]: string } = { 'Accept-Ranges': 'bytes', 'Content-Range': `bytes ${respRange}`, 'Content-Type': mime, 'Content-Length': respWidth.toString(), }; if (process.env.NODE_ENV === 'development') { // Add a CORS header to allow local development using localhost const port = process.env.PORT || '3000'; respHeaders['Access-Control-Allow-Origin'] = `http://localhost:${port}`; respHeaders['Access-Control-Allow-Methods'] = 'GET'; } // HTTP 206 Partial Content // https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/206 res.writeHead(206, respHeaders); const stream = new Readable(); stream.push(content); stream.push(null); stream.pipe(res); } } async function uploadDeletionResponder( viewer: Viewer, request: UploadDeletionRequest, ): Promise { const { id } = request; await deleteUpload(viewer, id); } export { multerProcessor, multimediaUploadResponder, uploadDownloadResponder, uploadDeletionResponder, };