IO-3464 S3 Document Editor

Signed-off-by: Allan Carr <allan@imexsystems.ca>
This commit is contained in:
Allan Carr
2025-12-12 19:39:02 -08:00
parent 6ea1c291e6
commit 56d50b855b
3 changed files with 140 additions and 13 deletions

View File

@@ -1,4 +1,5 @@
//import "tui-image-editor/dist/tui-image-editor.css"; //import "tui-image-editor/dist/tui-image-editor.css";
import axios from "axios";
import { Result } from "antd"; import { Result } from "antd";
import * as markerjs2 from "markerjs2"; import * as markerjs2 from "markerjs2";
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
@@ -6,8 +7,7 @@ import { useTranslation } from "react-i18next";
import { connect } from "react-redux"; import { connect } from "react-redux";
import { createStructuredSelector } from "reselect"; import { createStructuredSelector } from "reselect";
import { selectBodyshop, selectCurrentUser } from "../../redux/user/user.selectors"; import { selectBodyshop, selectCurrentUser } from "../../redux/user/user.selectors";
import { handleUpload } from "../documents-upload/documents-upload.utility"; import { handleUpload } from "../documents-upload-imgproxy/documents-upload-imgproxy.utility.js";
import { GenerateSrcUrl } from "../jobs-documents-gallery/job-documents.utility";
import LoadingSpinner from "../loading-spinner/loading-spinner.component"; import LoadingSpinner from "../loading-spinner/loading-spinner.component";
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx"; import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
@@ -23,6 +23,9 @@ export function DocumentEditorComponent({ currentUser, bodyshop, document }) {
const imgRef = useRef(null); const imgRef = useRef(null);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [uploaded, setuploaded] = 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 markerArea = useRef(null);
const { t } = useTranslation(); const { t } = useTranslation();
const notification = useNotification(); const notification = useNotification();
@@ -55,7 +58,7 @@ export function DocumentEditorComponent({ currentUser, bodyshop, document }) {
); );
useEffect(() => { useEffect(() => {
if (imgRef.current !== null) { if (imgRef.current !== null && imageLoaded && !markerArea.current) {
// create a marker.js MarkerArea // create a marker.js MarkerArea
markerArea.current = new markerjs2.MarkerArea(imgRef.current); markerArea.current = new markerjs2.MarkerArea(imgRef.current);
@@ -78,7 +81,47 @@ export function DocumentEditorComponent({ currentUser, bodyshop, document }) {
//markerArea.current.settings.displayMode = "inline"; //markerArea.current.settings.displayMode = "inline";
markerArea.current.show(); 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) { async function b64toBlob(url) {
const res = await fetch(url); const res = await fetch(url);
@@ -87,16 +130,21 @@ export function DocumentEditorComponent({ currentUser, bodyshop, document }) {
return ( return (
<div> <div>
{!loading && !uploaded && ( {!loading && !uploaded && imageUrl && (
<img <img
ref={imgRef} ref={imgRef}
src={GenerateSrcUrl(document)} src={imageUrl}
alt="sample" alt="sample"
crossOrigin="anonymous" onLoad={() => setImageLoaded(true)}
onError={(error) => {
console.error("Failed to load original image", error);
}}
style={{ maxWidth: "90vw", maxHeight: "90vh" }} style={{ maxWidth: "90vw", maxHeight: "90vh" }}
/> />
)} )}
{loading && <LoadingSpinner message={t("documents.labels.uploading")} />} {(loading || imageLoading || !imageLoaded) && !uploaded && (
<LoadingSpinner message={t("documents.labels.uploading")} />
)}
{uploaded && <Result status="success" title={t("documents.successes.edituploaded")} />} {uploaded && <Result status="success" title={t("documents.successes.edituploaded")} />}
</div> </div>
); );

View File

@@ -44,25 +44,25 @@ const generateSignedUploadUrls = async (req, res) => {
for (const filename of filenames) { for (const filename of filenames) {
const key = filename; const key = filename;
const client = new S3Client({ region: InstanceRegion() }); const client = new S3Client({ region: InstanceRegion() });
// Check if filename indicates PDF and set content type accordingly // Check if filename indicates PDF and set content type accordingly
const isPdf = filename.toLowerCase().endsWith('.pdf'); const isPdf = filename.toLowerCase().endsWith(".pdf");
const commandParams = { const commandParams = {
Bucket: imgproxyDestinationBucket, Bucket: imgproxyDestinationBucket,
Key: key, Key: key,
StorageClass: "INTELLIGENT_TIERING" StorageClass: "INTELLIGENT_TIERING"
}; };
if (isPdf) { if (isPdf) {
commandParams.ContentType = "application/pdf"; commandParams.ContentType = "application/pdf";
} }
const command = new PutObjectCommand(commandParams); const command = new PutObjectCommand(commandParams);
// For PDFs, we need to add conditions to the presigned URL to enforce content type // For PDFs, we need to add conditions to the presigned URL to enforce content type
const presignedUrlOptions = { expiresIn: 360 }; const presignedUrlOptions = { expiresIn: 360 };
if (isPdf) { if (isPdf) {
presignedUrlOptions.signableHeaders = new Set(['content-type']); presignedUrlOptions.signableHeaders = new Set(["content-type"]);
} }
const presignedUrl = await getSignedUrl(client, command, presignedUrlOptions); 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 * Delete Files
* @param req * @param req
@@ -425,6 +501,7 @@ const keyStandardize = (doc) => {
module.exports = { module.exports = {
generateSignedUploadUrls, generateSignedUploadUrls,
getThumbnailUrls, getThumbnailUrls,
getOriginalImageByDocumentId,
downloadFiles, downloadFiles,
deleteFiles, deleteFiles,
moveFiles moveFiles

View File

@@ -4,6 +4,7 @@ const { createSignedUploadURL, downloadFiles, renameKeys, deleteFiles } = requir
const { const {
generateSignedUploadUrls: createSignedUploadURLImgproxy, generateSignedUploadUrls: createSignedUploadURLImgproxy,
getThumbnailUrls: getThumbnailUrlsImgproxy, getThumbnailUrls: getThumbnailUrlsImgproxy,
getOriginalImageByDocumentId: getOriginalImageByDocumentIdImgproxy,
downloadFiles: downloadFilesImgproxy, downloadFiles: downloadFilesImgproxy,
moveFiles: moveFilesImgproxy, moveFiles: moveFilesImgproxy,
deleteFiles: deleteFilesImgproxy deleteFiles: deleteFilesImgproxy
@@ -24,5 +25,6 @@ router.post("/imgproxy/thumbnails", getThumbnailUrlsImgproxy);
router.post("/imgproxy/download", downloadFilesImgproxy); router.post("/imgproxy/download", downloadFilesImgproxy);
router.post("/imgproxy/rename", moveFilesImgproxy); router.post("/imgproxy/rename", moveFilesImgproxy);
router.post("/imgproxy/delete", deleteFilesImgproxy); router.post("/imgproxy/delete", deleteFilesImgproxy);
router.post("/imgproxy/original", getOriginalImageByDocumentIdImgproxy);
module.exports = router; module.exports = router;