diff --git a/keyserver/src/creators/upload-creator.js b/keyserver/src/creators/upload-creator.js index b3a77e203..832667ee4 100644 --- a/keyserver/src/creators/upload-creator.js +++ b/keyserver/src/creators/upload-creator.js @@ -1,71 +1,87 @@ // @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 { makeUploadURI } from '../fetchers/upload-fetchers.js'; import type { Viewer } from '../session/viewer.js'; +type UploadContent = + | { + +storage: 'keyserver', + +buffer: Buffer, + } + | { + +storage: 'blob_service', + +blobHolder: string, + }; + export type UploadInput = { - name: string, - mime: string, - mediaType: MediaType, - buffer: Buffer, - dimensions: Dimensions, - loop: boolean, - encryptionKey?: string, + +name: string, + +mime: string, + +mediaType: MediaType, + +content: UploadContent, + +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, encryptionKey } = uploadInfo; + const { content, dimensions, mediaType, loop, encryptionKey } = uploadInfo; + const buffer = + content.storage === 'keyserver' ? content.buffer : Buffer.alloc(0); + const blobHolder = + content.storage === 'blob_service' ? content.blobHolder : undefined; + const uri = makeUploadURI(blobHolder, id, secret); + return { uploadResult: { id, - uri: getUploadURL(id, secret), + uri, dimensions, mediaType, loop, }, insert: [ id, viewer.userID, mediaType, uploadInfo.name, uploadInfo.mime, - uploadInfo.buffer, + buffer, secret, Date.now(), - JSON.stringify({ ...dimensions, loop, encryptionKey }), + JSON.stringify({ ...dimensions, loop, blobHolder, 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/fetchers/upload-fetchers.js b/keyserver/src/fetchers/upload-fetchers.js index 9af2077d6..107029f34 100644 --- a/keyserver/src/fetchers/upload-fetchers.js +++ b/keyserver/src/fetchers/upload-fetchers.js @@ -1,357 +1,395 @@ // @flow import ip from 'internal-ip'; import _keyBy from 'lodash/fp/keyBy.js'; import type { Media, Image, EncryptedImage } from 'lib/types/media-types.js'; import { messageTypes } from 'lib/types/message-types-enum.js'; import type { MediaMessageServerDBContent } from 'lib/types/messages/media.js'; import { getUploadIDsFromMediaMessageServerDBContents } from 'lib/types/messages/media.js'; import { threadPermissions } from 'lib/types/thread-types.js'; import type { ThreadFetchMediaResult, ThreadFetchMediaRequest, } from 'lib/types/thread-types.js'; +import { makeBlobServiceURI } from 'lib/utils/blob-service.js'; import { isDev } from 'lib/utils/dev-utils.js'; import { ServerError } from 'lib/utils/errors.js'; import { dbQuery, SQL } from '../database/database.js'; import type { Viewer } from '../session/viewer.js'; import { getAndAssertCommAppURLFacts } from '../utils/urls.js'; type UploadInfo = { content: Buffer, mime: string, }; async function fetchUpload( viewer: Viewer, id: string, secret: string, ): Promise { const query = SQL` - SELECT content, mime + SELECT content, mime, extra FROM uploads WHERE id = ${id} AND secret = ${secret} `; const [result] = await dbQuery(query); if (result.length === 0) { throw new ServerError('invalid_parameters'); } const [row] = result; - const { content, mime } = row; + const { content, mime, extra } = row; + const { blobHolder } = JSON.parse(extra); + if (blobHolder) { + throw new ServerError('resource_unavailable'); + } return { content, mime }; } async function fetchUploadChunk( id: string, secret: string, pos: number, len: number, ): Promise { // We use pos + 1 because SQL is 1-indexed whereas js is 0-indexed const query = SQL` - SELECT SUBSTRING(content, ${pos + 1}, ${len}) AS content, mime + SELECT SUBSTRING(content, ${pos + 1}, ${len}) AS content, mime, extra FROM uploads WHERE id = ${id} AND secret = ${secret} `; const [result] = await dbQuery(query); if (result.length === 0) { throw new ServerError('invalid_parameters'); } const [row] = result; - const { content, mime } = row; + const { content, mime, extra } = row; + if (extra) { + const { blobHolder } = JSON.parse(extra); + if (blobHolder) { + throw new ServerError('resource_unavailable'); + } + } return { content, mime, }; } // Returns total size in bytes. async function getUploadSize(id: string, secret: string): Promise { const query = SQL` - SELECT LENGTH(content) AS length + SELECT LENGTH(content) AS length, extra FROM uploads WHERE id = ${id} AND secret = ${secret} `; const [result] = await dbQuery(query); if (result.length === 0) { throw new ServerError('invalid_parameters'); } const [row] = result; - const { length } = row; + const { length, extra } = row; + if (extra) { + const { blobHolder } = JSON.parse(extra); + if (blobHolder) { + throw new ServerError('resource_unavailable'); + } + } return length; } function getUploadURL(id: string, secret: string): string { const { baseDomain, basePath } = getAndAssertCommAppURLFacts(); const uploadPath = `${basePath}upload/${id}/${secret}`; if (isDev) { const ipV4 = ip.v4.sync() || 'localhost'; const port = parseInt(process.env.PORT, 10) || 3000; return `http://${ipV4}:${port}${uploadPath}`; } return `${baseDomain}${uploadPath}`; } +function makeUploadURI(holder: ?string, id: string, secret: string): string { + if (holder) { + return makeBlobServiceURI(holder); + } + return getUploadURL(id, secret); +} + function imagesFromRow(row: Object): Image | EncryptedImage { const uploadExtra = JSON.parse(row.uploadExtra); - const { width, height } = uploadExtra; + const { width, height, blobHolder } = uploadExtra; const { uploadType: type, uploadSecret: secret } = row; const id = row.uploadID.toString(); const dimensions = { width, height }; - const uri = getUploadURL(id, secret); + const uri = makeUploadURI(blobHolder, id, secret); const isEncrypted = !!uploadExtra.encryptionKey; if (type !== 'photo') { throw new ServerError('invalid_parameters'); } if (!isEncrypted) { return { id, type: 'photo', uri, dimensions }; } return { id, type: 'encrypted_photo', holder: uri, dimensions, encryptionKey: uploadExtra.encryptionKey, }; } async function fetchImages( viewer: Viewer, mediaIDs: $ReadOnlyArray, ): Promise<$ReadOnlyArray> { const query = SQL` SELECT id AS uploadID, secret AS uploadSecret, type AS uploadType, extra AS uploadExtra FROM uploads WHERE id IN (${mediaIDs}) AND uploader = ${viewer.id} AND container IS NULL `; const [result] = await dbQuery(query); return result.map(imagesFromRow); } async function fetchMediaForThread( viewer: Viewer, request: ThreadFetchMediaRequest, ): Promise { const visibleExtractString = `$.${threadPermissions.VISIBLE}.value`; const query = SQL` SELECT content.photo AS uploadID, u.secret AS uploadSecret, u.type AS uploadType, u.extra AS uploadExtra, u.container, u.creation_time, NULL AS thumbnailID, NULL AS thumbnailUploadSecret, NULL AS thumbnailUploadExtra FROM messages m LEFT JOIN JSON_TABLE( m.content, "$[*]" COLUMNS(photo INT PATH "$") ) content ON 1 LEFT JOIN uploads u ON u.id = content.photo LEFT JOIN memberships mm ON mm.thread = ${request.threadID} AND mm.user = ${viewer.id} WHERE m.thread = ${request.threadID} AND m.type = ${messageTypes.IMAGES} AND JSON_EXTRACT(mm.permissions, ${visibleExtractString}) IS TRUE UNION SELECT content.media AS uploadID, uv.secret AS uploadSecret, uv.type AS uploadType, uv.extra AS uploadExtra, uv.container, uv.creation_time, content.thumbnail AS thumbnailID, ut.secret AS thumbnailUploadSecret, ut.extra AS thumbnailUploadExtra FROM messages m LEFT JOIN JSON_TABLE( m.content, "$[*]" COLUMNS( media INT PATH "$.uploadID", thumbnail INT PATH "$.thumbnailUploadID" ) ) content ON 1 LEFT JOIN uploads uv ON uv.id = content.media LEFT JOIN uploads ut ON ut.id = content.thumbnail LEFT JOIN memberships mm ON mm.thread = ${request.threadID} AND mm.user = ${viewer.id} WHERE m.thread = ${request.threadID} AND m.type = ${messageTypes.MULTIMEDIA} AND JSON_EXTRACT(mm.permissions, ${visibleExtractString}) IS TRUE ORDER BY creation_time DESC LIMIT ${request.limit} OFFSET ${request.offset} `; const [uploads] = await dbQuery(query); const media = uploads.map(upload => { const { uploadID, uploadType, uploadSecret, uploadExtra } = upload; - const { width, height, encryptionKey } = JSON.parse(uploadExtra); + const { width, height, encryptionKey, blobHolder } = + JSON.parse(uploadExtra); const dimensions = { width, height }; + const uri = makeUploadURI(blobHolder, uploadID, uploadSecret); if (uploadType === 'photo') { if (encryptionKey) { return { type: 'encrypted_photo', id: uploadID.toString(), - holder: getUploadURL(uploadID, uploadSecret), + holder: uri, encryptionKey, dimensions, }; } return { type: 'photo', id: uploadID.toString(), - uri: getUploadURL(uploadID, uploadSecret), + uri, dimensions, }; } const { thumbnailID, thumbnailUploadSecret, thumbnailUploadExtra } = upload; + const { + encryptionKey: thumbnailEncryptionKey, + blobHolder: thumbnailBlobHolder, + } = JSON.parse(thumbnailUploadExtra); + const thumbnailURI = makeUploadURI( + thumbnailBlobHolder, + thumbnailID, + thumbnailUploadSecret, + ); if (encryptionKey) { - const { encryptionKey: thumbnailEncryptionKey } = - JSON.parse(thumbnailUploadExtra); return { type: 'encrypted_video', id: uploadID.toString(), - holder: getUploadURL(uploadID, uploadSecret), + holder: uri, encryptionKey, dimensions, thumbnailID, - thumbnailHolder: getUploadURL(thumbnailID, thumbnailUploadSecret), + thumbnailHolder: thumbnailURI, thumbnailEncryptionKey, }; } return { type: 'video', id: uploadID.toString(), - uri: getUploadURL(uploadID, uploadSecret), + uri, dimensions, thumbnailID, - thumbnailURI: getUploadURL(thumbnailID, thumbnailUploadSecret), + thumbnailURI, }; }); return { media }; } async function fetchUploadsForMessage( viewer: Viewer, mediaMessageContents: $ReadOnlyArray, ): Promise<$ReadOnlyArray> { const uploadIDs = getUploadIDsFromMediaMessageServerDBContents(mediaMessageContents); const query = SQL` SELECT id AS uploadID, secret AS uploadSecret, type AS uploadType, extra AS uploadExtra FROM uploads WHERE id IN (${uploadIDs}) AND uploader = ${viewer.id} AND container IS NULL `; const [uploads] = await dbQuery(query); return uploads; } async function fetchMediaFromMediaMessageContent( viewer: Viewer, mediaMessageContents: $ReadOnlyArray, ): Promise<$ReadOnlyArray> { const uploads = await fetchUploadsForMessage(viewer, mediaMessageContents); return constructMediaFromMediaMessageContentsAndUploadRows( mediaMessageContents, uploads, ); } function constructMediaFromMediaMessageContentsAndUploadRows( mediaMessageContents: $ReadOnlyArray, uploadRows: $ReadOnlyArray, ): $ReadOnlyArray { const uploadMap = _keyBy('uploadID')(uploadRows); const media: Media[] = []; for (const mediaMessageContent of mediaMessageContents) { const primaryUploadID = mediaMessageContent.uploadID; const primaryUpload = uploadMap[primaryUploadID]; - const primaryUploadSecret = primaryUpload.uploadSecret; - const primaryUploadURI = getUploadURL(primaryUploadID, primaryUploadSecret); - const uploadExtra = JSON.parse(primaryUpload.uploadExtra); - const { width, height, loop, encryptionKey } = uploadExtra; + const { width, height, loop, blobHolder, encryptionKey } = uploadExtra; const dimensions = { width, height }; + const primaryUploadURI = makeUploadURI( + blobHolder, + primaryUploadID, + primaryUpload.uploadSecret, + ); + if (mediaMessageContent.type === 'photo') { if (encryptionKey) { media.push({ type: 'encrypted_photo', id: primaryUploadID, holder: primaryUploadURI, encryptionKey, dimensions, }); } else { media.push({ type: 'photo', id: primaryUploadID, uri: primaryUploadURI, dimensions, }); } continue; } const thumbnailUploadID = mediaMessageContent.thumbnailUploadID; const thumbnailUpload = uploadMap[thumbnailUploadID]; - const thumbnailUploadSecret = thumbnailUpload.uploadSecret; - const thumbnailUploadURI = getUploadURL( + const thumbnailUploadExtra = JSON.parse(thumbnailUpload.uploadExtra); + const { blobHolder: thumbnailBlobHolder } = thumbnailUploadExtra; + const thumbnailUploadURI = makeUploadURI( + thumbnailBlobHolder, thumbnailUploadID, - thumbnailUploadSecret, + thumbnailUpload.uploadSecret, ); - const thumbnailUploadExtra = JSON.parse(thumbnailUpload.uploadExtra); if (encryptionKey) { const video = { type: 'encrypted_video', id: primaryUploadID, holder: primaryUploadURI, encryptionKey, dimensions, thumbnailID: thumbnailUploadID, thumbnailHolder: thumbnailUploadURI, thumbnailEncryptionKey: thumbnailUploadExtra.encryptionKey, }; media.push(loop ? { ...video, loop } : video); } else { const video = { type: 'video', id: primaryUploadID, uri: primaryUploadURI, dimensions, thumbnailID: thumbnailUploadID, thumbnailURI: thumbnailUploadURI, }; media.push(loop ? { ...video, loop } : video); } } return media; } export { fetchUpload, fetchUploadChunk, getUploadSize, getUploadURL, + makeUploadURI, imagesFromRow, fetchImages, fetchMediaForThread, fetchMediaFromMediaMessageContent, constructMediaFromMediaMessageContentsAndUploadRows, }; diff --git a/keyserver/src/uploads/media-utils.js b/keyserver/src/uploads/media-utils.js index 353b280be..2290f1404 100644 --- a/keyserver/src/uploads/media-utils.js +++ b/keyserver/src/uploads/media-utils.js @@ -1,219 +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( 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, + content: { storage: 'keyserver', 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, + content: { storage: 'keyserver', 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, + content: { storage: 'keyserver', 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, + content: { storage: 'keyserver', buffer: convertedBuffer }, dimensions: convertedDimensions, loop: inputLoop, }; } export { validateAndConvert };