UNPKG

@compas/store

Version:

Postgres & S3-compatible wrappers for common things

534 lines (478 loc) 14.6 kB
import { createReadStream } from "node:fs"; import { DeleteObjectsCommand, HeadObjectCommand } from "@aws-sdk/client-s3"; import { Upload } from "@aws-sdk/lib-storage"; import { AppError, eventStart, eventStop, isNil, streamToBuffer, uuid, } from "@compas/stdlib"; import { fileTypeFromBuffer, fileTypeFromFile, fileTypeStream, } from "file-type"; import { decode, sign, verify } from "jws"; import mime from "mime-types"; import sharp from "sharp"; import { queryFile } from "./generated/database/file.js"; import { queries } from "./generated.js"; import { objectStorageGetObjectStream, objectStorageListObjects, } from "./object-storage.js"; import { query } from "./query.js"; import { queueWorkerAddJob } from "./queue-worker.js"; export const STORE_FILE_IMAGE_TYPES = [ "image/png", "image/jpeg", "image/jpg", "image/webp", "image/avif", "image/gif", "image/svg+xml", ]; /** * Create or update a file. The file store is backed by a Postgres table and S3 object. * If no 'contentType' is passed, it is inferred from the 'magic bytes' from the source. * Defaulting to a wildcard. * * By passing in an `allowedContentTypes` array via the last options object, it is * possible to validate the inferred content type. This also overwrites the passed in * content type. * * You can set `allowedContentTypes` to `image/png, image/jpeg, image/jpg, image/webp, * image/avif, image/gif` if you only want to accept files that can be sent by * {@link fileSendTransformedImageResponse}. * * If 'fileTransformInPlaceOptions' is provided, this function will call * {@link fileTransformInPlace}. Note that image processing is computational heavy, so * in a high-throughput scenario you may want to schedule a job which calls * {@link fileTransformInPlace} instead of passing this option directly. * * @param {import("postgres").Sql} sql * @param {import("@aws-sdk/client-s3").S3Client} s3Client * @param {{ * bucketName: string, * allowedContentTypes?: Array<string>, * schedulePlaceholderImageJob?: boolean, * fileTransformInPlaceOptions?: FileTransformInPlaceOptions, * }} options * @param {Partial<import("./generated/common/types.d.ts").StoreFile> & Pick<import("./generated/common/types.d.ts").StoreFile, * "name">} props * @param {import("stream").Readable|string|Buffer} source * @returns {Promise<import("./generated/common/types.d.ts").StoreFile>} */ export async function fileCreateOrUpdate( sql, s3Client, options, props, source, ) { if (!props?.name) { throw AppError.validationError(`file.createOrUpdate.invalidName`); } if (isNil(source)) { throw AppError.validationError(`file.createOrUpdate.missingSource`); } if (isNil(props.id)) { // Generate a random id if none specified. The user can also specify a random ID // since we use upsert below. props.id = uuid(); } props.contentLength = props.contentLength ?? 0; // Since the client can lie to us, we can try to find the used content-type and save // that. if ( Array.isArray(options.allowedContentTypes) || isNil(props.contentType) || props.contentType === "*/*" ) { const deduceResult = await fileCheckContentType(options, props, source); props.contentType = deduceResult.contentType; source = deduceResult.source ?? source; } props.bucketName = options.bucketName; props.meta = props.meta ?? {}; if (typeof source === "string") { source = createReadStream(source); } if ( options.fileTransformInPlaceOptions && STORE_FILE_IMAGE_TYPES.includes(props.contentType ?? "") ) { const fileBuffer = Buffer.isBuffer(source) ? source : await streamToBuffer(source); source = await fileTransformInPlaceInternal( fileBuffer, options.fileTransformInPlaceOptions, ); if (props.contentType === "image/svg+xml") { // After the transform, svgs are converted as png's // See Sharp's toBuffer docs for more information. props.contentType = "image/png"; } } const upload = new Upload({ client: s3Client, params: { Key: props.id, Bucket: props.bucketName, ContentType: props.contentType, Body: source, }, }); await upload.done(); const headResult = await s3Client.send( new HeadObjectCommand({ Bucket: props.bucketName, Key: props.id, }), ); props.contentLength = headResult.ContentLength; // @ts-expect-error const [result] = await queries.fileUpsertOnId(sql, props); if ( options.schedulePlaceholderImageJob && STORE_FILE_IMAGE_TYPES.includes(props.contentType ?? "") ) { await queueWorkerAddJob(sql, { name: "compas.file.generatePlaceholderImage", data: { fileId: result.id, }, }); } return result; } /** * The various options supported by {@link fileTransformInPlace}. * By default transforms SVG input in to PNG. This can't be disabled, skip calling this * method on SVG inputs if that's not the wanted behavior. * * All operations use [Sharp](https://sharp.pixelplumbing.com/) under the hood. * * @typedef {object} FileTransformInPlaceOptions * @property {boolean} [stripMetadata] Original image metadata is kept on the original * image, but removed in the transforms. If this option is set, all metadata will be * stripped on the original as well. You may want to do this for files that are * publicly accessible. * @property {number|false} [rotate] The angle to rotate to. If not provided, an auto * * rotation will be attempted based on the image metadata. */ /** * Internal operations on the buffer. Returns a new transformed buffer. * * @param {Buffer} fileBuffer * @param {FileTransformInPlaceOptions} operations * @returns {Promise<Buffer>} */ async function fileTransformInPlaceInternal(fileBuffer, operations) { const sharpInstance = sharp(fileBuffer, { failOn: "error" }); if (operations.stripMetadata !== true) { sharpInstance.withMetadata({}); } if (operations.rotate !== false) { if (Number.isInteger(operations.rotate)) { sharpInstance.rotate(operations.rotate); } else { sharpInstance.rotate(); } } return await sharpInstance.toBuffer(); } /** * Edit the file in place, resetting the placeholder and transforms. * * Supports: * - Rotating the image * * @param {import("@compas/stdlib").InsightEvent} event * @param {import("postgres").Sql} sql * @param {import("@aws-sdk/client-s3").S3Client} s3Client * @param {import("./generated/common/types.d.ts").StoreFile} file * @param {FileTransformInPlaceOptions} operations * @returns {Promise<void>} */ export async function fileTransformInPlace( event, sql, s3Client, file, operations, ) { eventStart(event, "file.transformInPlace"); if (!STORE_FILE_IMAGE_TYPES.includes(file.contentType)) { // Don't even attempt it. eventStop(event); return; } const fileStream = await objectStorageGetObjectStream(s3Client, { bucketName: file.bucketName, objectKey: file.id, }); const fileBuffer = await streamToBuffer(fileStream); const transformedBuffer = await fileTransformInPlaceInternal( fileBuffer, operations, ); await fileCreateOrUpdate( sql, s3Client, { bucketName: file.bucketName, schedulePlaceholderImageJob: true, }, { id: file.id, name: file.name, meta: { ...file.meta, transforms: undefined, transformedFromOriginal: undefined, originalHeight: undefined, originalWidth: undefined, placeholderImage: undefined, }, }, transformedBuffer, ); eventStop(event); } /** * Infer the contentType and check against the allowed content types. * This checks the magic bytes of the provided source to deduce the content type. * * @param {{ * bucketName: string, * allowedContentTypes?: Array<string>, * schedulePlaceholderImageJob?: boolean, * }} options * @param {Partial<import("./generated/common/types.d.ts").StoreFile> & Pick<import("./generated/common/types.d.ts").StoreFile, * "name">} props * @param {import("stream").Readable|string|Buffer} source * @returns {Promise<{ * source: import("stream").Readable|string|Buffer, * contentType: string, * }>} */ async function fileCheckContentType(options, props, source) { let contentType = undefined; if (source instanceof Uint8Array || source instanceof ArrayBuffer) { const result = await fileTypeFromBuffer(source); contentType = result?.mime; } else if (typeof source === "string") { const result = await fileTypeFromFile(source); contentType = result?.mime; } else if ( typeof source?.pipe === "function" && // @ts-ignore typeof source?._read === "function" ) { // @ts-ignore const sourceWithFileType = await fileTypeStream(source); // Set source to the new pass through stream created by `fileTypeStream` source = sourceWithFileType; contentType = sourceWithFileType.fileType?.mime; } contentType = contentType ?? mime.lookup(props.name) ?? "*/*"; if ( Array.isArray(options.allowedContentTypes) && !options.allowedContentTypes.includes(contentType) ) { throw AppError.validationError("file.createOrUpdate.invalidContentType", { found: contentType, allowed: options.allowedContentTypes, }); } return { source, contentType, }; } /** * File deletes should be done via `queries.storeFileDelete()`. By calling this * function, all files that don't exist in the database will be removed from the S3 * bucket * * @param {import("postgres").Sql} sql * @param {import("@aws-sdk/client-s3").S3Client} s3Client * @param {{ * bucketName: string, * }} options * @returns {Promise<void>} */ export async function fileSyncDeletedWithObjectStorage(sql, s3Client, options) { // Delete transformations where the original is already removed await queries.fileDelete(sql, { $raw: query`meta->>'transformedFromOriginal' IS NOT NULL AND NOT EXISTS (SELECT FROM "file" f2 WHERE f2.id = (f.meta->>'transformedFromOriginal')::uuid)`, }); const objectsInStore = ( await queryFile({ select: ["id"], where: { bucketName: options.bucketName, }, }).execRaw(sql) ).map((it) => it.id); // S3 supports up to 1000 deletions in a single request const maxSetSize = 999; const deletingSet = []; for await (const part of objectStorageListObjects(s3Client, { bucketName: options.bucketName, })) { for (const obj of part?.Contents ?? []) { if (!obj.Key) { continue; } if (objectsInStore.includes(obj.Key)) { continue; } deletingSet.push({ Key: obj.Key, }); } } if (deletingSet.length === 0) { return; } while (deletingSet.length) { await s3Client.send( new DeleteObjectsCommand({ Bucket: options.bucketName, Delete: { Objects: deletingSet.splice(0, maxSetSize), Quiet: true, }, }), ); } } /** * Format a StoreFile, so it can be used in the response. * * @param {import("./generated/common/types.d.ts").StoreFile} file * @param {object} options * @param {string} options.url * @param {{ * signingKey: string, * maxAgeInSeconds: number, * }} [options.signAccessToken] * @returns {import("./generated/common/types.d.ts").StoreFileResponse} */ export function fileFormatMetadata(file, options) { if (!options.url) { throw AppError.serverError({ message: `'fileFormatMetadata' requires that the url is provided.`, }); } if (options.signAccessToken) { options.url += `?accessToken=${fileSignAccessToken({ fileId: file.id, maxAgeInSeconds: options.signAccessToken.maxAgeInSeconds, signingKey: options.signAccessToken.signingKey, })}`; } else { options.url += `?v=${file.id}`; } return { id: file.id, name: file.name, contentType: file.contentType, originalWidth: file.meta?.originalWidth, originalHeight: file.meta?.originalHeight, altText: file.meta?.altText, placeholderImage: file.meta?.placeholderImage, url: options.url, }; } /** * Generate a signed string, based on the file id and the max age that it is allowed ot * be accessed. * * @see {fileVerifyAccessToken} * * @param {{ * fileId: string, * signingKey: string, * maxAgeInSeconds: number, * }} options * @returns {string} */ export function fileSignAccessToken(options) { if ( typeof options.fileId !== "string" || typeof options.signingKey !== "string" || typeof options.maxAgeInSeconds !== "number" ) { throw AppError.serverError({ message: "Incorrect arguments to 'fileSignAccessToken'. Expects fileId: string, signingKey: string, maxAgeInSeconds: number.", }); } const d = new Date(); d.setSeconds(d.getSeconds() + options.maxAgeInSeconds); return sign({ header: { alg: "HS256", typ: "JWT", }, secret: options.signingKey, payload: { fileId: options.fileId, exp: Math.floor(d.getTime() / 1000), }, }); } /** * Verify and decode the fileAccessToken returning the fileId that it was signed for. * Returns an Either<fileId: string, AppError> * * @see {fileSignAccessToken} * * @param {{ * fileAccessToken: string, * signingKey: string, * expectedFileId: string, * }} options * @returns {void} */ export function fileVerifyAccessToken(options) { if ( typeof options.fileAccessToken !== "string" || typeof options.signingKey !== "string" || typeof options.expectedFileId !== "string" || !options.fileAccessToken || !options.signingKey ) { throw AppError.serverError({ message: "Incorrect arguments to 'fileVerifyAndDecodeSignedAccessToken'. Expects fileAccessToken: string, signingKey: string.", }); } let isValid; try { isValid = verify(options.fileAccessToken, "HS256", options.signingKey); } catch { isValid = false; } if (!isValid) { throw AppError.validationError( "file.verifyAndDecodeAccessToken.invalidToken", {}, ); } const decoded = decode(options.fileAccessToken); if (decoded.payload.exp * 1000 < Date.now()) { throw AppError.validationError( `file.verifyAndDecodeAccessToken.expiredToken`, ); } if (decoded.payload.fileId !== options.expectedFileId) { throw AppError.validationError( `file.verifyAndDecodeAccessToken.invalidToken`, ); } }