const path = require("path"); const logger = require("../utils/logger"); const { getSignedUrl } = require("@aws-sdk/s3-request-presigner"); const { InstanceRegion } = require("../utils/instanceMgr"); const base64UrlEncode = require("./util/base64UrlEncode"); const createHmacSha256 = require("./util/createHmacSha256"); const { S3Client, PutObjectCommand, GetObjectCommand, CopyObjectCommand, DeleteObjectCommand } = require("@aws-sdk/client-s3"); const { GET_DOCUMENTS_BY_JOB, QUERY_TEMPORARY_DOCS, GET_DOCUMENTS_BY_IDS, GET_DOCUMENTS_BY_BILL, DELETE_MEDIA_DOCUMENTS } = require("../graphql-client/queries"); const yazl = require("yazl"); const imgproxyBaseUrl = process.env.IMGPROXY_BASE_URL; // `https://u4gzpp5wm437dnm75qa42tvza40fguqr.lambda-url.ca-central-1.on.aws` //Direct Lambda function access to bypass CDN. const imgproxySalt = process.env.IMGPROXY_SALT; const imgproxyDestinationBucket = process.env.IMGPROXY_DESTINATION_BUCKET; /** * Generate a Signed URL Link for the s3 bucket. * All Uploads must be going to the same Shop and JobId * @param req * @param res * @returns {Promise<*>} */ const generateSignedUploadUrls = async (req, res) => { const { filenames, bodyshopid, jobid } = req.body; try { logger.log("imgproxy-upload-start", "DEBUG", req.user?.email, jobid, { filenames, bodyshopid, jobid }); const signedUrls = []; for (const filename of filenames) { const key = filename; const client = new S3Client({ region: InstanceRegion() }); // Check if filename indicates PDF and set content type accordingly const isPdf = filename.toLowerCase().endsWith(".pdf"); const commandParams = { Bucket: imgproxyDestinationBucket, Key: key, StorageClass: "INTELLIGENT_TIERING" }; if (isPdf) { commandParams.ContentType = "application/pdf"; } const command = new PutObjectCommand(commandParams); // For PDFs, we need to add conditions to the presigned URL to enforce content type const presignedUrlOptions = { expiresIn: 360 }; if (isPdf) { presignedUrlOptions.signableHeaders = new Set(["content-type"]); } const presignedUrl = await getSignedUrl(client, command, presignedUrlOptions); signedUrls.push({ filename, presignedUrl, key, ...(isPdf && { contentType: "application/pdf" }) }); } logger.log("imgproxy-upload-success", "DEBUG", req.user?.email, jobid, { signedUrls }); return res.json({ success: true, signedUrls }); } catch (error) { logger.log("imgproxy-upload-error", "ERROR", req.user?.email, jobid, { message: error.message, stack: error.stack }); return res.status(400).json({ success: false, message: error.message, stack: error.stack }); } }; /** * Get Thumbnail URLS * @param req * @param res * @returns {Promise<*>} */ const getThumbnailUrls = async (req, res) => { const { jobid, billid } = req.body; try { logger.log("imgproxy-thumbnails", "DEBUG", req.user?.email, jobid, { billid, jobid }); //Delayed as the key structure may change slightly from what it is currently and will require evaluating mobile components. const client = req.userGraphQLClient; //If there's no jobid and no billid, we're in temporary documents. const data = await (billid ? client.request(GET_DOCUMENTS_BY_BILL, { billId: billid }) : jobid ? client.request(GET_DOCUMENTS_BY_JOB, { jobId: jobid }) : client.request(QUERY_TEMPORARY_DOCS)); const thumbResizeParams = `rs:fill:250:250:1/g:ce`; const s3client = new S3Client({ region: InstanceRegion() }); const proxiedUrls = []; for (const document of data.documents) { //Format to follow: /////< base 64 URL encoded to image path> //When working with documents from Cloudinary, the URL does not include the extension. let key = keyStandardize(document); // Build the S3 path to the object. const fullS3Path = `s3://${imgproxyDestinationBucket}/${key}`; const base64UrlEncodedKeyString = base64UrlEncode(fullS3Path); //Thumbnail Generation Block const thumbProxyPath = `${thumbResizeParams}/${base64UrlEncodedKeyString}`; const thumbHmacSalt = createHmacSha256(`${imgproxySalt}/${thumbProxyPath}`); //Full Size URL block const fullSizeProxyPath = `${base64UrlEncodedKeyString}`; const fullSizeHmacSalt = createHmacSha256(`${imgproxySalt}/${fullSizeProxyPath}`); const s3Props = {}; if (!document.type.startsWith("image")) { //If not a picture, we need to get a signed download link to the file using S3 (or cloudfront preferably) const command = new GetObjectCommand({ Bucket: imgproxyDestinationBucket, Key: key }); s3Props.presignedGetUrl = await getSignedUrl(s3client, command, { expiresIn: 360 }); const originalProxyPath = `raw:1/${base64UrlEncodedKeyString}`; const originalHmacSalt = createHmacSha256(`${imgproxySalt}/${originalProxyPath}`); s3Props.originalUrlViaProxyPath = `${imgproxyBaseUrl}/${originalHmacSalt}/${originalProxyPath}`; } proxiedUrls.push({ originalUrl: `${imgproxyBaseUrl}/${fullSizeHmacSalt}/${fullSizeProxyPath}`, thumbnailUrl: `${imgproxyBaseUrl}/${thumbHmacSalt}/${thumbProxyPath}`, fullS3Path, base64UrlEncodedKeyString, thumbProxyPath, ...s3Props, ...document }); } return res.json(proxiedUrls); //Iterate over them, build the link based on the media type, and return the array. } catch (error) { logger.log("imgproxy-thumbnails-error", "ERROR", req.user?.email, jobid, { jobid, billid, message: error.message, stack: error.stack }); return res.status(400).json({ message: error.message, stack: error.stack }); } }; /** * Download Files * @param req * @param res * @returns {Promise<*>} */ const downloadFiles = async (req, res) => { const { jobId, billid, documentids } = req.body; logger.log("imgproxy-download", "DEBUG", req.user?.email, jobId, { billid, jobId, documentids }); const client = req.userGraphQLClient; let data; try { data = await client.request(GET_DOCUMENTS_BY_IDS, { documentIds: documentids }); } catch (error) { logger.log("imgproxy-download-error", "ERROR", req.user?.email, jobId, { jobId, billid, message: error.message, stack: error.stack }); return res.status(400).json({ message: error.message }); } const s3client = new S3Client({ region: InstanceRegion() }); const zipfile = new yazl.ZipFile(); const filename = `archive-${jobId || "na"}-${new Date().toISOString().replace(/[:.]/g, "-")}.zip`; res.setHeader("Content-Type", "application/zip"); res.setHeader("Content-Disposition", `attachment; filename="${filename}"`); // Handle zipfile stream errors zipfile.outputStream.on("error", (err) => { logger.log("imgproxy-download-zipstream-error", "ERROR", req.user?.email, jobId, { message: err.message, stack: err.stack }); // Cannot send another response here, just destroy the connection res.destroy(err); }); zipfile.outputStream.pipe(res); try { for (const doc of data.documents) { let key = keyStandardize(doc); let response; try { response = await s3client.send( new GetObjectCommand({ Bucket: imgproxyDestinationBucket, Key: key }) ); } catch (err) { logger.log("imgproxy-download-s3-error", "ERROR", req.user?.email, jobId, { key, message: err.message, stack: err.stack }); // Optionally, skip this file or add a placeholder file in the zip continue; } // Attach error handler to S3 stream response.Body.on("error", (err) => { logger.log("imgproxy-download-s3stream-error", "ERROR", req.user?.email, jobId, { key, message: err.message, stack: err.stack }); res.destroy(err); }); zipfile.addReadStream(response.Body, path.basename(key)); } zipfile.end(); } catch (error) { logger.log("imgproxy-download-error", "ERROR", req.user?.email, jobId, { jobId, billid, message: error.message, stack: error.stack }); // Cannot send another response here, just destroy the connection res.destroy(error); } }; /** * Stream original image content by document ID * @param req * @param res * @returns {Promise<*>} */ const getOriginalImageByDocumentId = async (req, res) => { const { body: { documentId }, user, userGraphQLClient } = req; if (!documentId) { return res.status(400).json({ message: "documentId is required" }); } try { logger.log("imgproxy-original-image", "DEBUG", user?.email, null, { documentId }); const { documents } = await userGraphQLClient.request(GET_DOCUMENTS_BY_IDS, { documentIds: [documentId] }); if (!documents || documents.length === 0) { return res.status(404).json({ message: "Document not found" }); } const [document] = documents; const { type } = document; if (!type || !type.startsWith("image")) { return res.status(400).json({ message: "Document is not an image" }); } const s3client = new S3Client({ region: InstanceRegion() }); const key = keyStandardize(document); let s3Response; try { s3Response = await s3client.send( new GetObjectCommand({ Bucket: imgproxyDestinationBucket, Key: key }) ); } catch (err) { logger.log("imgproxy-original-image-s3-error", "ERROR", user?.email, null, { key, message: err.message, stack: err.stack }); return res.status(400).json({ message: "Unable to retrieve image" }); } res.setHeader("Content-Type", type || "image/jpeg"); s3Response.Body.on("error", (err) => { logger.log("imgproxy-original-image-s3stream-error", "ERROR", user?.email, null, { key, message: err.message, stack: err.stack }); res.destroy(err); }); s3Response.Body.pipe(res); } catch (error) { logger.log("imgproxy-original-image-error", "ERROR", req.user?.email, null, { documentId, message: error.message, stack: error.stack }); return res.status(400).json({ message: error.message, stack: error.stack }); } }; /** * Delete Files * @param req * @param res * @returns {Promise<*>} */ const deleteFiles = async (req, res) => { //Mark a file for deletion in s3. Lifecycle deletion will actually delete the copy in the future. //Mark as deleted from the documents section of the database. const { ids } = req.body; try { logger.log("imgproxy-delete-files", "DEBUG", req.user.email, null, { ids }); const client = req.userGraphQLClient; //Do this to make sure that they are only deleting things that they have access to const data = await client.request(GET_DOCUMENTS_BY_IDS, { documentIds: ids }); const s3client = new S3Client({ region: InstanceRegion() }); const deleteTransactions = []; data.documents.forEach((document) => { deleteTransactions.push( (async () => { try { // Delete the original object await s3client.send( new DeleteObjectCommand({ Bucket: imgproxyDestinationBucket, Key: document.key }) ); return document; } catch (error) { return { document, error: error, bucket: imgproxyDestinationBucket }; } })() ); }); const result = await Promise.all(deleteTransactions); const errors = result.filter((d) => d.error); //Delete only the successful deletes. const deleteMutationResult = await client.request(DELETE_MEDIA_DOCUMENTS, { ids: result.filter((t) => !t.error).map((d) => d.id) }); return res.json({ errors, deleteMutationResult }); } catch (error) { logger.log("imgproxy-delete-files-error", "ERROR", req.user.email, null, { ids, message: error.message, stack: error.stack }); return res.status(400).json({ message: error.message, stack: error.stack }); } }; /** * Move Files * @param req * @param res * @returns {Promise<*>} */ const moveFiles = async (req, res) => { const { documents, tojobid } = req.body; try { logger.log("imgproxy-move-files", "DEBUG", req.user.email, null, { documents, tojobid }); const s3client = new S3Client({ region: InstanceRegion() }); const moveTransactions = []; documents.forEach((document) => { moveTransactions.push( (async () => { try { // Copy the object to the new key await s3client.send( new CopyObjectCommand({ Bucket: imgproxyDestinationBucket, CopySource: `${imgproxyDestinationBucket}/${document.from}`, Key: document.to, StorageClass: "INTELLIGENT_TIERING" }) ); // Delete the original object await s3client.send( new DeleteObjectCommand({ Bucket: imgproxyDestinationBucket, Key: document.from }) ); return document; } catch (error) { return { id: document.id, from: document.from, error: error, bucket: imgproxyDestinationBucket }; } })() ); }); const result = await Promise.all(moveTransactions); const errors = result.filter((d) => d.error); let mutations = ""; result .filter((d) => !d.error) .forEach((d, idx) => { //Create mutation text mutations = mutations + ` update_doc${idx}:update_documents_by_pk(pk_columns: { id: "${d.id}" }, _set: {key: "${d.to}", jobid: "${tojobid}"}){ id } `; }); const client = req.userGraphQLClient; if (mutations !== "") { const mutationResult = await client.request(`mutation { ${mutations} }`); return res.json({ errors, mutationResult }); } return res.json({ errors: "No images were successfully moved on remote server. " }); } catch (error) { logger.log("imgproxy-move-files-error", "ERROR", req.user.email, null, { documents, tojobid, message: error.message, stack: error.stack }); return res.status(400).json({ message: error.message, stack: error.stack }); } }; const keyStandardize = (doc) => { if (/\.[^/.]+$/.test(doc.key)) { return doc.key; } else { return `${doc.key}.${doc.extension.toLowerCase()}`; } }; module.exports = { generateSignedUploadUrls, getThumbnailUrls, getOriginalImageByDocumentId, downloadFiles, deleteFiles, moveFiles };