From 56d50b855be6ceb61325304f01e29cd1c3020037 Mon Sep 17 00:00:00 2001 From: Allan Carr Date: Fri, 12 Dec 2025 19:39:02 -0800 Subject: [PATCH] IO-3464 S3 Document Editor Signed-off-by: Allan Carr --- .../document-editor.component.jsx | 64 ++++++++++++-- server/media/imgproxy-media.js | 87 +++++++++++++++++-- server/routes/mediaRoutes.js | 2 + 3 files changed, 140 insertions(+), 13 deletions(-) diff --git a/client/src/components/document-editor/document-editor.component.jsx b/client/src/components/document-editor/document-editor.component.jsx index f27875efe..8df1be57f 100644 --- a/client/src/components/document-editor/document-editor.component.jsx +++ b/client/src/components/document-editor/document-editor.component.jsx @@ -1,4 +1,5 @@ //import "tui-image-editor/dist/tui-image-editor.css"; +import axios from "axios"; import { Result } from "antd"; import * as markerjs2 from "markerjs2"; import { useCallback, useEffect, useRef, useState } from "react"; @@ -6,8 +7,7 @@ import { useTranslation } from "react-i18next"; import { connect } from "react-redux"; import { createStructuredSelector } from "reselect"; import { selectBodyshop, selectCurrentUser } from "../../redux/user/user.selectors"; -import { handleUpload } from "../documents-upload/documents-upload.utility"; -import { GenerateSrcUrl } from "../jobs-documents-gallery/job-documents.utility"; +import { handleUpload } from "../documents-upload-imgproxy/documents-upload-imgproxy.utility.js"; import LoadingSpinner from "../loading-spinner/loading-spinner.component"; import { useNotification } from "../../contexts/Notifications/notificationContext.jsx"; @@ -23,6 +23,9 @@ export function DocumentEditorComponent({ currentUser, bodyshop, document }) { const imgRef = useRef(null); const [loading, setLoading] = useState(false); const [uploaded, setuploaded] = useState(false); + const [imageUrl, setImageUrl] = useState(null); + const [imageLoaded, setImageLoaded] = useState(false); + const [imageLoading, setImageLoading] = useState(true); const markerArea = useRef(null); const { t } = useTranslation(); const notification = useNotification(); @@ -55,7 +58,7 @@ export function DocumentEditorComponent({ currentUser, bodyshop, document }) { ); useEffect(() => { - if (imgRef.current !== null) { + if (imgRef.current !== null && imageLoaded && !markerArea.current) { // create a marker.js MarkerArea markerArea.current = new markerjs2.MarkerArea(imgRef.current); @@ -78,7 +81,47 @@ export function DocumentEditorComponent({ currentUser, bodyshop, document }) { //markerArea.current.settings.displayMode = "inline"; markerArea.current.show(); } - }, [triggerUpload]); + }, [triggerUpload, imageLoaded]); + + useEffect(() => { + // When the document changes, fetch the image via axios so auth and base URL are applied + let isCancelled = false; + + const loadImage = async () => { + if (!document || !document.id) return; + + setImageLoaded(false); + setImageLoading(true); + + try { + const response = await axios.post( + "/media/imgproxy/original", + { documentId: document.id }, + { responseType: "blob" } + ); + + if (isCancelled) return; + + const blobUrl = URL.createObjectURL(response.data); + setImageUrl((prevUrl) => { + if (prevUrl) URL.revokeObjectURL(prevUrl); + return blobUrl; + }); + } catch (error) { + console.error("Failed to fetch original image blob", error); + } finally { + if (!isCancelled) { + setImageLoading(false); + } + } + }; + + loadImage(); + + return () => { + isCancelled = true; + }; + }, [document]); async function b64toBlob(url) { const res = await fetch(url); @@ -87,16 +130,21 @@ export function DocumentEditorComponent({ currentUser, bodyshop, document }) { return (
- {!loading && !uploaded && ( + {!loading && !uploaded && imageUrl && ( sample setImageLoaded(true)} + onError={(error) => { + console.error("Failed to load original image", error); + }} style={{ maxWidth: "90vw", maxHeight: "90vh" }} /> )} - {loading && } + {(loading || imageLoading || !imageLoaded) && !uploaded && ( + + )} {uploaded && }
); diff --git a/server/media/imgproxy-media.js b/server/media/imgproxy-media.js index 85bc3393f..b6f480e5b 100644 --- a/server/media/imgproxy-media.js +++ b/server/media/imgproxy-media.js @@ -44,25 +44,25 @@ const generateSignedUploadUrls = async (req, res) => { 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 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']); + presignedUrlOptions.signableHeaders = new Set(["content-type"]); } const presignedUrl = await getSignedUrl(client, command, presignedUrlOptions); @@ -265,6 +265,82 @@ const downloadFiles = async (req, res) => { } }; +/** + * 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 @@ -425,6 +501,7 @@ const keyStandardize = (doc) => { module.exports = { generateSignedUploadUrls, getThumbnailUrls, + getOriginalImageByDocumentId, downloadFiles, deleteFiles, moveFiles diff --git a/server/routes/mediaRoutes.js b/server/routes/mediaRoutes.js index 59ee836ec..1f356c5b9 100644 --- a/server/routes/mediaRoutes.js +++ b/server/routes/mediaRoutes.js @@ -4,6 +4,7 @@ const { createSignedUploadURL, downloadFiles, renameKeys, deleteFiles } = requir const { generateSignedUploadUrls: createSignedUploadURLImgproxy, getThumbnailUrls: getThumbnailUrlsImgproxy, + getOriginalImageByDocumentId: getOriginalImageByDocumentIdImgproxy, downloadFiles: downloadFilesImgproxy, moveFiles: moveFilesImgproxy, deleteFiles: deleteFilesImgproxy @@ -24,5 +25,6 @@ router.post("/imgproxy/thumbnails", getThumbnailUrlsImgproxy); router.post("/imgproxy/download", downloadFilesImgproxy); router.post("/imgproxy/rename", moveFilesImgproxy); router.post("/imgproxy/delete", deleteFilesImgproxy); +router.post("/imgproxy/original", getOriginalImageByDocumentIdImgproxy); module.exports = router;