diff --git a/client/src/components/documents-upload-imgproxy/documents-upload-imgproxy.utility.js b/client/src/components/documents-upload-imgproxy/documents-upload-imgproxy.utility.js index 280469669..184cf86c6 100644 --- a/client/src/components/documents-upload-imgproxy/documents-upload-imgproxy.utility.js +++ b/client/src/components/documents-upload-imgproxy/documents-upload-imgproxy.utility.js @@ -38,13 +38,10 @@ export const uploadToS3 = async ( context, notification ) => { - const { bodyshop, jobId, billId, uploaded_by, callback, tagsArray } = context; + const { bodyshop, jobId, billId, uploaded_by, callback } = context; - //Set variables for getting the signed URL. - let timestamp = Math.floor(Date.now() / 1000); - //Get the signed url. - - const signedURLResponse = await axios.post("/media/proxy/sign", { + //Get the signed url allowing us to PUT to S3. + const signedURLResponse = await axios.post("/media/imgproxy/sign", { filenames: [key], bodyshopid: bodyshop.id, jobid: jobId @@ -60,19 +57,16 @@ export const uploadToS3 = async ( return; } + //Key should be same as we provided to maintain backwards compatibility. const { presignedUrl: preSignedUploadUrlToS3, key: s3Key } = signedURLResponse.data.signedUrls[0]; - //Build request to end to cloudinary. var options = { - // headers: { "X-Requested-With": "XMLHttpRequest" }, onUploadProgress: (e) => { if (onProgress) onProgress({ percent: (e.loaded / e.total) * 100 }); } }; - - const cloudinaryUploadResponse = await cleanAxios.put(preSignedUploadUrlToS3, file, options); - + const s3UploadResponse = await cleanAxios.put(preSignedUploadUrlToS3, file, options); //Insert the document with the matching key. let takenat; if (fileType.includes("image")) { @@ -94,18 +88,18 @@ export const uploadToS3 = async ( uploaded_by: uploaded_by, key: s3Key, type: fileType, - extension: cloudinaryUploadResponse.data.format || extension, + extension: s3UploadResponse.data.format || extension, bodyshopid: bodyshop.id, - size: cloudinaryUploadResponse.data.bytes || file.size, + size: s3UploadResponse.data.bytes || file.size, //Leftover from Cloudinary. We don't do any optimization on upload, so it will always be file.size. takenat } ] } }); + if (!documentInsert.errors) { if (onSuccess) onSuccess({ - //TODO: Since this may go server side, we can just manage the state locally. uid: documentInsert.data.insert_documents.returning[0].id, name: documentInsert.data.insert_documents.returning[0].name, status: "done", @@ -130,17 +124,6 @@ export const uploadToS3 = async ( } }; -//Also needs to be updated in media JS and mobile app. -export function DetermineFileType(filetype) { - if (!filetype) return "auto"; - else if (filetype.startsWith("image")) return "image"; - else if (filetype.startsWith("video")) return "video"; - else if (filetype.startsWith("application/pdf")) return "image"; - else if (filetype.startsWith("application")) return "raw"; - - return "auto"; -} - function replaceAccents(str) { // Verifies if the String has accents and replace them if (str.search(/[\xC0-\xFF]/g) > -1) { @@ -167,6 +150,5 @@ function replaceAccents(str) { .replace(/[\xFE]/g, "p") .replace(/[\xFD\xFF]/g, "y"); } - return str; } diff --git a/client/src/components/jobs-documents-gallery/jobs-documents-gallery.component.jsx b/client/src/components/jobs-documents-gallery/jobs-documents-gallery.component.jsx index a3d667a35..fdc919320 100644 --- a/client/src/components/jobs-documents-gallery/jobs-documents-gallery.component.jsx +++ b/client/src/components/jobs-documents-gallery/jobs-documents-gallery.component.jsx @@ -1,7 +1,6 @@ import { EditFilled, FileExcelFilled, SyncOutlined } from "@ant-design/icons"; import { Button, Card, Col, Row, Space } from "antd"; -import axios from "axios"; -import React, { useEffect, useState } from "react"; +import { useEffect, useState } from "react"; import { Gallery } from "react-grid-gallery"; import { useTranslation } from "react-i18next"; import Lightbox from "react-image-lightbox"; @@ -9,7 +8,6 @@ import "react-image-lightbox/style.css"; import { connect } from "react-redux"; import { createStructuredSelector } from "reselect"; import { selectBodyshop } from "../../redux/user/user.selectors"; -import DocumentsUploadImgproxyComponent from "../documents-upload-imgproxy/documents-upload-imgproxy.component"; import DocumentsUploadComponent from "../documents-upload/documents-upload.component"; import { DetermineFileType } from "../documents-upload/documents-upload.utility"; import { HasFeatureAccess } from "../feature-wrapper/feature-wrapper.component"; @@ -116,85 +114,6 @@ function JobsDocumentsComponent({ setgalleryImages(documents); }, [data, setgalleryImages, t]); - const getProxiedUrls = async () => { - const result = await axios.post("/media/proxy/thumbnails", { jobid: jobId }); - let documents = result.data.reduce( - (acc, value) => { - const fileType = DetermineFileType(value.type); - if (value.type.startsWith("image")) { - acc.images.push({ - src: value.thumbnailUrl, - fullsize: value.originalUrl, - height: 225, - width: 225, - isSelected: false, - key: value.key, - extension: value.extension, - id: value.id, - type: value.type, - size: value.size, - tags: [{ value: value.type, title: value.type }] - }); - } else { - let thumb; - switch (fileType) { - case "raw": - thumb = `${window.location.origin}/file.png`; - break; - default: - thumb = GenerateThumbUrl(value); - break; - } - - const fileName = value.key.split("/").pop(); - acc.other.push({ - source: value.originalUrlViaProxyPath, - src: value.thumbnailUrl, - fullsize: value.presignedGetUrl, - tags: [ - { - value: fileName, - title: fileName - }, - - { value: value.type, title: value.type }, - ...(value.bill - ? [ - { - value: value.bill.vendor.name, - title: t("vendors.fields.name") - }, - { value: value.bill.date, title: t("bills.fields.date") }, - { - value: value.bill.invoice_number, - title: t("bills.fields.invoice_number") - } - ] - : []) - ], - height: 225, - width: 225, - isSelected: false, - extension: value.extension, - key: value.key, - id: value.id, - type: value.type, - size: value.size - }); - } - - return acc; - }, - { images: [], other: [] } - ); - console.log("*** ~ file: jobs-documents-gallery.component.jsx:198 ~ getProxiedUrls ~ documents:", documents); - setgalleryImages(documents); - }; - - // useEffect(() => { - // if (data) getProxiedUrls(); - // }, [data]); - const hasMediaAccess = HasFeatureAccess({ bodyshop, featureName: "media" }); const hasMobileAccess = HasFeatureAccess({ bodyshop, featureName: "mobile" }); return ( @@ -218,19 +137,6 @@ function JobsDocumentsComponent({ )} - - - - - - - ; if (error) return ; - return ( - - ); + if (useImgProxy) { + return ( + + ); + } else { + return ( + + ); + } } diff --git a/client/src/components/jobs-documents-imgproxy-gallery/jobs-document-imgproxy-gallery.download.component.jsx b/client/src/components/jobs-documents-imgproxy-gallery/jobs-document-imgproxy-gallery.download.component.jsx new file mode 100644 index 000000000..2d649bcc9 --- /dev/null +++ b/client/src/components/jobs-documents-imgproxy-gallery/jobs-document-imgproxy-gallery.download.component.jsx @@ -0,0 +1,78 @@ +import { Button, Space } from "antd"; +import axios from "axios"; +import React, { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { logImEXEvent } from "../../firebase/firebase.utils"; +import cleanAxios from "../../utils/CleanAxios"; +import formatBytes from "../../utils/formatbytes"; +//import yauzl from "yauzl"; + +import { connect } from "react-redux"; +import { createStructuredSelector } from "reselect"; +import { selectBodyshop } from "../../redux/user/user.selectors"; + +const mapStateToProps = createStructuredSelector({ + bodyshop: selectBodyshop +}); +const mapDispatchToProps = (dispatch) => ({ + //setUserLanguage: language => dispatch(setUserLanguage(language)) +}); +export default connect(mapStateToProps, mapDispatchToProps)(JobsDocumentsImgproxyDownloadButton); + +export function JobsDocumentsImgproxyDownloadButton({ bodyshop, galleryImages, identifier }) { + const { t } = useTranslation(); + const [download, setDownload] = useState(null); + const [loading, setLoading] = useState(false); + + const imagesToDownload = [ + ...galleryImages.images.filter((image) => image.isSelected), + ...galleryImages.other.filter((image) => image.isSelected) + ]; + + function downloadProgress(progressEvent) { + setDownload((currentDownloadState) => { + return { + downloaded: progressEvent.loaded || 0, + speed: (progressEvent.loaded || 0) - ((currentDownloadState && currentDownloadState.downloaded) || 0) + }; + }); + } + function standardMediaDownload(bufferData) { + const a = document.createElement("a"); + const url = window.URL.createObjectURL(new Blob([bufferData])); + a.href = url; + a.download = `${identifier || "documents"}.zip`; + a.click(); + } + const handleDownload = async () => { + logImEXEvent("jobs_documents_download"); + setLoading(true); + const zipUrl = await axios({ + url: "/media/imgproxy/download", + method: "POST", + data: { documentids: imagesToDownload.map((_) => _.id) } + }); + + const theDownloadedZip = await cleanAxios({ + url: zipUrl.data.url, + method: "GET", + responseType: "arraybuffer", + onDownloadProgress: downloadProgress + }); + setLoading(false); + setDownload(null); + + standardMediaDownload(theDownloadedZip.data); + }; + + return ( + <> + + + ); +} diff --git a/client/src/components/jobs-documents-imgproxy-gallery/jobs-document-imgproxy-gallery.reassign.component.jsx b/client/src/components/jobs-documents-imgproxy-gallery/jobs-document-imgproxy-gallery.reassign.component.jsx new file mode 100644 index 000000000..19b7d9352 --- /dev/null +++ b/client/src/components/jobs-documents-imgproxy-gallery/jobs-document-imgproxy-gallery.reassign.component.jsx @@ -0,0 +1,129 @@ +import { useApolloClient } from "@apollo/client"; +import { Button, Form, Popover, Space } from "antd"; +import axios from "axios"; +import { useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { connect } from "react-redux"; +import { createStructuredSelector } from "reselect"; +import { useNotification } from "../../contexts/Notifications/notificationContext.jsx"; +import { GET_DOC_SIZE_BY_JOB } from "../../graphql/documents.queries.js"; +import { selectBodyshop } from "../../redux/user/user.selectors.js"; +import JobSearchSelect from "../job-search-select/job-search-select.component.jsx"; + +const mapStateToProps = createStructuredSelector({ + bodyshop: selectBodyshop +}); +const mapDispatchToProps = (dispatch) => ({ + //setUserLanguage: language => dispatch(setUserLanguage(language)) +}); +export default connect(mapStateToProps, mapDispatchToProps)(JobsDocumentsImgproxyGalleryReassign); + +export function JobsDocumentsImgproxyGalleryReassign({ bodyshop, galleryImages, callback }) { + const { t } = useTranslation(); + const [form] = Form.useForm(); + const notification = useNotification(); + + const selectedImages = useMemo(() => { + return [ + ...galleryImages.images.filter((image) => image.isSelected), + ...galleryImages.other.filter((image) => image.isSelected) + ]; + }, [galleryImages]); + const client = useApolloClient(); + const [open, setOpen] = useState(false); + const [loading, setLoading] = useState(false); + + const handleFinish = async ({ jobid }) => { + setLoading(true); + + //Check to see if the space remaining on the new job is sufficient. If it isn't cancel this. + const newJobData = await client.query({ + query: GET_DOC_SIZE_BY_JOB, + variables: { jobId: jobid } + }); + + const transferedDocSizeTotal = selectedImages.reduce((acc, val) => acc + val.size, 0); + + const shouldPreventTransfer = + bodyshop.jobsizelimit - newJobData.data.documents_aggregate.aggregate.sum.size < transferedDocSizeTotal; + + if (shouldPreventTransfer) { + notification.open({ + key: "cannotuploaddocuments", + type: "error", + message: t("documents.labels.reassign_limitexceeded_title"), + description: t("documents.labels.reassign_limitexceeded") + }); + setLoading(false); + return; + } + + const res = await axios.post("/media/imgproxy/rename", { + tojobid: jobid, + documents: selectedImages.map((i) => { + //Need to check if the current key folder is null, or another job. + const currentKeys = i.key.split("/"); + currentKeys[1] = jobid; + currentKeys.join("/"); + return { + id: i.id, + from: i.key, + to: currentKeys.join("/"), + extension: i.extension, + type: i.type + }; + }) + }); + //Add in confirmation & errors. + if (callback) callback(); + + if (res.errors) { + notification["error"]({ + message: t("documents.errors.updating", { + message: JSON.stringify(res.errors) + }) + }); + } + if (!res.mutationResult?.errors) { + notification["success"]({ + message: t("documents.successes.updated") + }); + } + setOpen(false); + setLoading(false); + }; + + const popContent = ( +
+
+ + + +
+ + + + +
+ ); + + return ( + + + + ); +} diff --git a/client/src/components/jobs-documents-imgproxy-gallery/jobs-documents-imgproxy-gallery.component.jsx b/client/src/components/jobs-documents-imgproxy-gallery/jobs-documents-imgproxy-gallery.component.jsx new file mode 100644 index 000000000..0a9beacc1 --- /dev/null +++ b/client/src/components/jobs-documents-imgproxy-gallery/jobs-documents-imgproxy-gallery.component.jsx @@ -0,0 +1,247 @@ +import { EditFilled, FileExcelFilled, SyncOutlined } from "@ant-design/icons"; +import { Button, Card, Col, Row, Space, Typography } from "antd"; +import axios from "axios"; +import { useEffect, useState } from "react"; +import { Gallery } from "react-grid-gallery"; +import { useTranslation } from "react-i18next"; +import Lightbox from "react-image-lightbox"; +import "react-image-lightbox/style.css"; +import { connect } from "react-redux"; +import { createStructuredSelector } from "reselect"; +import { selectBodyshop } from "../../redux/user/user.selectors"; +import DocumentsUploadImgproxyComponent from "../documents-upload-imgproxy/documents-upload-imgproxy.component"; +import { HasFeatureAccess } from "../feature-wrapper/feature-wrapper.component"; +import UpsellComponent, { upsellEnum } from "../upsell/upsell.component"; +import JobsDocumentsDownloadButton from "./jobs-document-imgproxy-gallery.download.component"; +import JobsDocumentsGalleryReassign from "./jobs-document-imgproxy-gallery.reassign.component"; +import JobsDocumentsDeleteButton from "./jobs-documents-imgproxy-gallery.delete.component"; +import JobsDocumentsGallerySelectAllComponent from "./jobs-documents-imgproxy-gallery.selectall.component"; + +const mapStateToProps = createStructuredSelector({ + bodyshop: selectBodyshop +}); +const mapDispatchToProps = (dispatch) => ({}); + +function JobsDocumentsImgproxyComponent({ + bodyshop, + data, + jobId, + refetch, + billId, + billsCallback, + totalSize, + downloadIdentifier, + ignoreSizeLimit +}) { + const [galleryImages, setgalleryImages] = useState({ images: [], other: [] }); + const { t } = useTranslation(); + const [modalState, setModalState] = useState({ open: false, index: 0 }); + + const fetchThumbnails = async () => { + const result = await axios.post("/media/imgproxy/thumbnails", { jobid: jobId }); + let documents = result.data.reduce( + (acc, value) => { + if (value.type.startsWith("image")) { + acc.images.push({ + src: value.thumbnailUrl, + fullsize: value.originalUrl, + height: 225, + width: 225, + isSelected: false, + key: value.key, + extension: value.extension, + id: value.id, + type: value.type, + size: value.size, + tags: [{ value: value.type, title: value.type }] + }); + } else { + const fileName = value.key.split("/").pop(); + acc.other.push({ + source: value.originalUrlViaProxyPath, + src: value.thumbnailUrl, + fullsize: value.presignedGetUrl, + tags: [ + { + value: fileName, + title: fileName + }, + + { value: value.type, title: value.type }, + ...(value.bill + ? [ + { + value: value.bill.vendor.name, + title: t("vendors.fields.name") + }, + { value: value.bill.date, title: t("bills.fields.date") }, + { + value: value.bill.invoice_number, + title: t("bills.fields.invoice_number") + } + ] + : []) + ], + height: 225, + width: 225, + isSelected: false, + extension: value.extension, + key: value.key, + id: value.id, + type: value.type, + size: value.size + }); + } + return acc; + }, + { images: [], other: [] } + ); + setgalleryImages(documents); + }; + useEffect(() => { + if (data) { + fetchThumbnails(); + } + }, [data, setgalleryImages, t]); + + const hasMediaAccess = HasFeatureAccess({ bodyshop, featureName: "media" }); + const hasMobileAccess = HasFeatureAccess({ bodyshop, featureName: "mobile" }); + return ( +
+ IMG PROXY COMPONENTS (DELETE ME) + + + + + + + + {!billId && } + + + {!hasMediaAccess && ( + + + + + + )} + + + + + + {hasMediaAccess && !hasMobileAccess && ( + + + + + + )} + + + { + setModalState({ open: true, index: index }); + // window.open( + // item.fullsize, + // "_blank", + // "toolbar=0,location=0,menubar=0" + // ); + }} + onSelect={(index, image) => { + setgalleryImages({ + ...galleryImages, + images: galleryImages.images.map((g, idx) => + index === idx ? { ...g, isSelected: !g.isSelected } : g + ) + }); + }} + /> + + + + + { + return { + backgroundImage: , + height: "100%", + width: "100%", + cursor: "pointer" + }; + }} + onClick={(index) => { + window.open(galleryImages.other[index].source, "_blank", "toolbar=0,location=0,menubar=0"); + }} + onSelect={(index) => { + setgalleryImages({ + ...galleryImages, + other: galleryImages.other.map((g, idx) => (index === idx ? { ...g, isSelected: !g.isSelected } : g)) + }); + }} + /> + + + {modalState.open && ( + { + const newWindow = window.open( + `${window.location.protocol}//${window.location.host}/edit?documentId=${ + galleryImages.images[modalState.index].id + }`, + "_blank", + "noopener,noreferrer" + ); + if (newWindow) newWindow.opener = null; + }} + /> + ]} + mainSrc={galleryImages.images[modalState.index].fullsize} + nextSrc={galleryImages.images[(modalState.index + 1) % galleryImages.images.length].fullsize} + prevSrc={ + galleryImages.images[(modalState.index + galleryImages.images.length - 1) % galleryImages.images.length] + .fullsize + } + onCloseRequest={() => setModalState({ open: false, index: 0 })} + onMovePrevRequest={() => + setModalState({ + ...modalState, + index: (modalState.index + galleryImages.images.length - 1) % galleryImages.images.length + }) + } + onMoveNextRequest={() => + setModalState({ + ...modalState, + index: (modalState.index + 1) % galleryImages.images.length + }) + } + /> + )} + +
+ ); +} + +export default connect(mapStateToProps, mapDispatchToProps)(JobsDocumentsImgproxyComponent); diff --git a/client/src/components/jobs-documents-imgproxy-gallery/jobs-documents-imgproxy-gallery.container.jsx b/client/src/components/jobs-documents-imgproxy-gallery/jobs-documents-imgproxy-gallery.container.jsx new file mode 100644 index 000000000..50dd4bbe9 --- /dev/null +++ b/client/src/components/jobs-documents-imgproxy-gallery/jobs-documents-imgproxy-gallery.container.jsx @@ -0,0 +1,29 @@ +import { useQuery } from "@apollo/client"; +import { GET_DOCUMENTS_BY_JOB } from "../../graphql/documents.queries"; +import AlertComponent from "../alert/alert.component"; +import LoadingSpinner from "../loading-spinner/loading-spinner.component"; +import JobDocuments from "./jobs-documents-imgproxy-gallery.component"; + +export default function JobsDocumentsImgproxyContainer({ jobId, billId, documentsList, billsCallback }) { + const { loading, error, data, refetch } = useQuery(GET_DOCUMENTS_BY_JOB, { + variables: { jobId: jobId }, + fetchPolicy: "network-only", + nextFetchPolicy: "network-only", + skip: !!billId + }); + + if (loading) return ; + if (error) return ; + + return ( + + ); +} diff --git a/client/src/components/jobs-documents-imgproxy-gallery/jobs-documents-imgproxy-gallery.delete.component.jsx b/client/src/components/jobs-documents-imgproxy-gallery/jobs-documents-imgproxy-gallery.delete.component.jsx new file mode 100644 index 000000000..51ebfbf2b --- /dev/null +++ b/client/src/components/jobs-documents-imgproxy-gallery/jobs-documents-imgproxy-gallery.delete.component.jsx @@ -0,0 +1,68 @@ +import { QuestionCircleOutlined } from "@ant-design/icons"; +import { Button, Popconfirm } from "antd"; +import axios from "axios"; +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { logImEXEvent } from "../../firebase/firebase.utils.js"; +import { useNotification } from "../../contexts/Notifications/notificationContext.jsx"; +//Context: currentUserEmail, bodyshop, jobid, invoiceid + +export default function JobsDocumentsImgproxyDeleteButton({ galleryImages, deletionCallback }) { + const { t } = useTranslation(); + const notification = useNotification(); + + const imagesToDelete = [ + ...galleryImages.images.filter((image) => image.isSelected), + ...galleryImages.other.filter((image) => image.isSelected) + ]; + const [loading, setLoading] = useState(false); + + const handleDelete = async () => { + logImEXEvent("job_documents_delete", { count: imagesToDelete.length }); + try { + setLoading(true); + const res = await axios.post("/media/imgproxy/delete", { + ids: imagesToDelete.map((d) => d.id) + }); + + if (res.data.error) { + notification["error"]({ + message: t("documents.errors.deleting", { + error: JSON.stringify(res.data.error.response.errors) + }) + }); + } else { + notification.open({ + key: "docdeletedsuccesfully", + type: "success", + message: t("documents.successes.delete") + }); + + if (deletionCallback) deletionCallback(); + } + } catch (error) { + notification["error"]({ + message: t("documents.errors.deleting", { + error: error.message + }) + }); + } + setLoading(false); + }; + + return ( + } + onConfirm={handleDelete} + title={t("documents.labels.confirmdelete")} + okText={t("general.actions.delete")} + okButtonProps={{ danger: true }} + cancelText={t("general.actions.cancel")} + > + + + ); +} diff --git a/client/src/components/jobs-documents-imgproxy-gallery/jobs-documents-imgproxy-gallery.external.component.jsx b/client/src/components/jobs-documents-imgproxy-gallery/jobs-documents-imgproxy-gallery.external.component.jsx new file mode 100644 index 000000000..ed89cc1ca --- /dev/null +++ b/client/src/components/jobs-documents-imgproxy-gallery/jobs-documents-imgproxy-gallery.external.component.jsx @@ -0,0 +1,50 @@ +import { useEffect } from "react"; +import { Gallery } from "react-grid-gallery"; +import { useTranslation } from "react-i18next"; +import { GenerateSrcUrl, GenerateThumbUrl } from "./job-documents-imgproxy.utility"; + +function JobsDocumentImgproxyGalleryExternal({ + data, + + externalMediaState +}) { + const [galleryImages, setgalleryImages] = externalMediaState; + const { t } = useTranslation(); + + useEffect(() => { + let documents = data.reduce((acc, value) => { + if (value.type.startsWith("image")) { + acc.push({ + fullsize: GenerateSrcUrl(value), + src: GenerateThumbUrl(value), + thumbnailHeight: 225, + thumbnailWidth: 225, + isSelected: false, + key: value.key, + extension: value.extension, + id: value.id, + type: value.type, + tags: [{ value: value.type, title: value.type }], + size: value.size + }); + } + + return acc; + }, []); + setgalleryImages(documents); + }, [data, setgalleryImages, t]); + + return ( +
+ { + setgalleryImages(galleryImages.map((g, idx) => (index === idx ? { ...g, isSelected: !g.isSelected } : g))); + }} + /> +
+ ); +} + +export default JobsDocumentImgproxyGalleryExternal; diff --git a/client/src/components/jobs-documents-imgproxy-gallery/jobs-documents-imgproxy-gallery.selectall.component.jsx b/client/src/components/jobs-documents-imgproxy-gallery/jobs-documents-imgproxy-gallery.selectall.component.jsx new file mode 100644 index 000000000..25162e7d0 --- /dev/null +++ b/client/src/components/jobs-documents-imgproxy-gallery/jobs-documents-imgproxy-gallery.selectall.component.jsx @@ -0,0 +1,55 @@ +import { Button, Space } from "antd"; +import { useTranslation } from "react-i18next"; + +export default function JobsDocumentsImgproxyGallerySelectAllComponent({ galleryImages, setGalleryImages }) { + const { t } = useTranslation(); + + const handleSelectAll = () => { + setGalleryImages({ + ...galleryImages, + other: galleryImages.other.map((i) => { + return { ...i, isSelected: true }; + }), + images: galleryImages.images.map((i) => { + return { ...i, isSelected: true }; + }) + }); + }; + const handleSelectAllImages = () => { + setGalleryImages({ + ...galleryImages, + + images: galleryImages.images.map((i) => { + return { ...i, isSelected: true }; + }) + }); + }; + const handleSelectAllDocuments = () => { + setGalleryImages({ + ...galleryImages, + other: galleryImages.other.map((i) => { + return { ...i, isSelected: true }; + }) + }); + }; + const handleDeselectAll = () => { + setGalleryImages({ + ...galleryImages, + other: galleryImages.other.map((i) => { + return { ...i, isSelected: false }; + }), + images: galleryImages.images.map((i) => { + return { ...i, isSelected: false }; + }) + }); + }; + + return ( + + + + + + + ); +} diff --git a/client/src/components/jobs-documents-imgproxy-gallery/jobs-documents-imgproxy-gallery.styles.scss b/client/src/components/jobs-documents-imgproxy-gallery/jobs-documents-imgproxy-gallery.styles.scss new file mode 100644 index 000000000..6df357ffc --- /dev/null +++ b/client/src/components/jobs-documents-imgproxy-gallery/jobs-documents-imgproxy-gallery.styles.scss @@ -0,0 +1,10 @@ +/* you can make up upload button and sample style by using stylesheets */ +.ant-upload-select-picture-card i { + font-size: 32px; + color: #999; +} + +.ant-upload-select-picture-card .ant-upload-text { + margin-top: 8px; + color: #666; +} diff --git a/client/src/pages/temporary-docs/temporary-docs.component.jsx b/client/src/pages/temporary-docs/temporary-docs.component.jsx index 092011e65..95ff5cabe 100644 --- a/client/src/pages/temporary-docs/temporary-docs.component.jsx +++ b/client/src/pages/temporary-docs/temporary-docs.component.jsx @@ -2,6 +2,7 @@ import { useQuery } from "@apollo/client"; import React from "react"; import AlertComponent from "../../components/alert/alert.component"; import JobsDocumentsComponent from "../../components/jobs-documents-gallery/jobs-documents-gallery.component"; +import JobsDocumentsContainer from "../../components/jobs-documents-gallery/jobs-documents-gallery.container"; import LoadingSpinner from "../../components/loading-spinner/loading-spinner.component"; import { QUERY_TEMPORARY_DOCS } from "../../graphql/documents.queries"; @@ -22,7 +23,7 @@ export function TemporaryDocsComponent({ bodyshop }) { const { loading, error, data, refetch } = useQuery(QUERY_TEMPORARY_DOCS, { fetchPolicy: "network-only", nextFetchPolicy: "network-only", - skip: bodyshop.uselocalmediaserver + skip: bodyshop.uselocalmediaserver //TODO: Add skip if imgproxy is enabled. }); if (loading) return ; @@ -32,12 +33,14 @@ export function TemporaryDocsComponent({ bodyshop }) { return ; } return ( - + <> + + ); } diff --git a/package-lock.json b/package-lock.json index d0ea5ebcc..608a76ac4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,10 +15,12 @@ "@aws-sdk/client-secrets-manager": "^3.738.0", "@aws-sdk/client-ses": "^3.738.0", "@aws-sdk/credential-provider-node": "^3.738.0", + "@aws-sdk/lib-storage": "^3.743.0", "@aws-sdk/s3-request-presigner": "^3.731.1", "@opensearch-project/opensearch": "^2.13.0", "@socket.io/admin-ui": "^0.5.1", "@socket.io/redis-adapter": "^8.3.0", + "archiver": "^7.0.1", "aws4": "^1.13.2", "axios": "^1.7.7", "better-queue": "^3.8.12", @@ -794,6 +796,37 @@ "node": ">=18.0.0" } }, + "node_modules/@aws-sdk/lib-storage": { + "version": "3.743.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/lib-storage/-/lib-storage-3.743.0.tgz", + "integrity": "sha512-Rf/5sljlEJRVtB5C4UjLCOIcK2ODZet9rQsRtsn0bIc2byURbpOdqIGvfEcKWPayoXCS4dC/5bdjhL1zhZ0TMg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/abort-controller": "^4.0.1", + "@smithy/middleware-endpoint": "^4.0.2", + "@smithy/smithy-client": "^4.1.2", + "buffer": "5.6.0", + "events": "3.3.0", + "stream-browserify": "3.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@aws-sdk/client-s3": "^3.743.0" + } + }, + "node_modules/@aws-sdk/lib-storage/node_modules/buffer": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.6.0.tgz", + "integrity": "sha512-/gDYp/UtU0eA1ys8bOs9J6a+E/KWIY+DZ+Q2WESNUA0jFRsJOc0SNUO6xJ5SGA1xueg3NL65W6s+NY5l9cunuw==", + "license": "MIT", + "dependencies": { + "base64-js": "^1.0.2", + "ieee754": "^1.1.4" + } + }, "node_modules/@aws-sdk/middleware-bucket-endpoint": { "version": "3.734.0", "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-bucket-endpoint/-/middleware-bucket-endpoint-3.734.0.tgz", @@ -2488,6 +2521,16 @@ "node": ">=14" } }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, "node_modules/@protobufjs/aspromise": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", @@ -3678,7 +3721,6 @@ "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", "license": "MIT", - "optional": true, "dependencies": { "event-target-shim": "^5.0.0" }, @@ -3813,6 +3855,155 @@ "integrity": "sha512-lYe4Gx7QT+MKGbDsA+Z+he/Wtef0BiwDOlK/XkBrdfsh9J/jPPXbX0tE9x9cl27Tmu5gg3QUbUrQYa/y+KOHPQ==", "license": "ISC" }, + "node_modules/archiver": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/archiver/-/archiver-7.0.1.tgz", + "integrity": "sha512-ZcbTaIqJOfCc03QwD468Unz/5Ir8ATtvAHsK+FdXbDIbGfihqh9mrvdcYunQzqn4HrvWWaFyaxJhGZagaJJpPQ==", + "license": "MIT", + "dependencies": { + "archiver-utils": "^5.0.2", + "async": "^3.2.4", + "buffer-crc32": "^1.0.0", + "readable-stream": "^4.0.0", + "readdir-glob": "^1.1.2", + "tar-stream": "^3.0.0", + "zip-stream": "^6.0.1" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/archiver-utils": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/archiver-utils/-/archiver-utils-5.0.2.tgz", + "integrity": "sha512-wuLJMmIBQYCsGZgYLTy5FIB2pF6Lfb6cXMSF8Qywwk3t20zWnAi7zLcQFdKQmIB8wyZpY5ER38x08GbwtR2cLA==", + "license": "MIT", + "dependencies": { + "glob": "^10.0.0", + "graceful-fs": "^4.2.0", + "is-stream": "^2.0.1", + "lazystream": "^1.0.0", + "lodash": "^4.17.15", + "normalize-path": "^3.0.0", + "readable-stream": "^4.0.0" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/archiver-utils/node_modules/brace-expansion": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/archiver-utils/node_modules/glob": { + "version": "10.4.5", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", + "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/archiver-utils/node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, + "node_modules/archiver-utils/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "license": "ISC" + }, + "node_modules/archiver-utils/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/archiver-utils/node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/archiver-utils/node_modules/readable-stream": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz", + "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==", + "license": "MIT", + "dependencies": { + "abort-controller": "^3.0.0", + "buffer": "^6.0.3", + "events": "^3.3.0", + "process": "^0.11.10", + "string_decoder": "^1.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, + "node_modules/archiver/node_modules/readable-stream": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz", + "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==", + "license": "MIT", + "dependencies": { + "abort-controller": "^3.0.0", + "buffer": "^6.0.3", + "events": "^3.3.0", + "process": "^0.11.10", + "string_decoder": "^1.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, "node_modules/are-we-there-yet": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/are-we-there-yet/-/are-we-there-yet-2.0.0.tgz", @@ -4090,12 +4281,25 @@ "js-md4": "^0.3.2" } }, + "node_modules/b4a": { + "version": "1.6.7", + "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.6.7.tgz", + "integrity": "sha512-OnAYlL5b7LEkALw87fUVafQw5rVR9RjwGd4KUwNQ6DrrNmaVaUCgLipfVlzrPQ4tWOR9P0IXGNOx50jYCCdSJg==", + "license": "Apache-2.0" + }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "license": "MIT" }, + "node_modules/bare-events": { + "version": "2.5.4", + "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.5.4.tgz", + "integrity": "sha512-+gFfDkR8pj4/TrWCGUGWmJIkBwuxPS5F+a5yWjOHQt2hHvNZd5YLzadjmDUtFmMM4y429bnKLa8bYBMHcYdnQA==", + "license": "Apache-2.0", + "optional": true + }, "node_modules/base64-js": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", @@ -4259,6 +4463,39 @@ "node": ">= 0.4.0" } }, + "node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "node_modules/buffer-crc32": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-1.0.0.tgz", + "integrity": "sha512-Db1SbgBS/fg/392AblrMJk97KggmvYhr4pB5ZIMTWtaivCPMWLkmb7m21cJvpvgK+J3nsU2CmmixNBZx4vFj/w==", + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/buffer-equal-constant-time": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", @@ -4612,6 +4849,38 @@ "node": ">=18" } }, + "node_modules/compress-commons": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/compress-commons/-/compress-commons-6.0.2.tgz", + "integrity": "sha512-6FqVXeETqWPoGcfzrXb37E50NP0LXT8kAMu5ooZayhWWdgEY4lBEEcbQNXtkuKQsGduxiIcI4gOTsxTmuq/bSg==", + "license": "MIT", + "dependencies": { + "crc-32": "^1.2.0", + "crc32-stream": "^6.0.0", + "is-stream": "^2.0.1", + "normalize-path": "^3.0.0", + "readable-stream": "^4.0.0" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/compress-commons/node_modules/readable-stream": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz", + "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==", + "license": "MIT", + "dependencies": { + "abort-controller": "^3.0.0", + "buffer": "^6.0.3", + "events": "^3.3.0", + "process": "^0.11.10", + "string_decoder": "^1.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, "node_modules/compressible": { "version": "2.0.18", "resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz", @@ -4831,6 +5100,47 @@ "node": ">=10.0.0" } }, + "node_modules/crc-32": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz", + "integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==", + "license": "Apache-2.0", + "bin": { + "crc32": "bin/crc32.njs" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/crc32-stream": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/crc32-stream/-/crc32-stream-6.0.0.tgz", + "integrity": "sha512-piICUB6ei4IlTv1+653yq5+KoqfBYmj9bw6LqXoOneTMDXk5nM1qt12mFW1caG3LlJXEKW1Bp0WggEmIfQB34g==", + "license": "MIT", + "dependencies": { + "crc-32": "^1.2.0", + "readable-stream": "^4.0.0" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/crc32-stream/node_modules/readable-stream": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz", + "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==", + "license": "MIT", + "dependencies": { + "abort-controller": "^3.0.0", + "buffer": "^6.0.3", + "events": "^3.3.0", + "process": "^0.11.10", + "string_decoder": "^1.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, "node_modules/crisp-status-reporter": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/crisp-status-reporter/-/crisp-status-reporter-1.2.2.tgz", @@ -5965,11 +6275,19 @@ "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", "license": "MIT", - "optional": true, "engines": { "node": ">=6" } }, + "node_modules/events": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", + "license": "MIT", + "engines": { + "node": ">=0.8.x" + } + }, "node_modules/express": { "version": "4.21.2", "resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz", @@ -6828,6 +7146,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC" + }, "node_modules/graphql": { "version": "16.10.0", "resolved": "https://registry.npmjs.org/graphql/-/graphql-16.10.0.tgz", @@ -7098,6 +7422,26 @@ "node": ">=0.10.0" } }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -8007,6 +8351,48 @@ "integrity": "sha512-Xq9nH7KlWZmXAtodXDDRE7vs6DU1gTU8zYDHDiWLSip45Egwq3plLHzPn27NgvzL2r1LMPC1vdqh98sQxtqj4A==", "license": "MIT" }, + "node_modules/lazystream": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/lazystream/-/lazystream-1.0.1.tgz", + "integrity": "sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==", + "license": "MIT", + "dependencies": { + "readable-stream": "^2.0.5" + }, + "engines": { + "node": ">= 0.6.3" + } + }, + "node_modules/lazystream/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/lazystream/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/lazystream/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, "node_modules/levn": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", @@ -8608,6 +8994,15 @@ "node": ">=6" } }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/notepack.io": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/notepack.io/-/notepack.io-3.0.1.tgz", @@ -9096,6 +9491,15 @@ "url": "https://github.com/prettier/prettier?sponsor=1" } }, + "node_modules/process": { + "version": "0.11.10", + "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", + "integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==", + "license": "MIT", + "engines": { + "node": ">= 0.6.0" + } + }, "node_modules/process-nextick-args": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", @@ -9306,6 +9710,36 @@ "node": ">= 6" } }, + "node_modules/readdir-glob": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/readdir-glob/-/readdir-glob-1.1.3.tgz", + "integrity": "sha512-v05I2k7xN8zXvPD9N+z/uhXPaj0sUFCe2rcWZIpBsqxfP7xXFQ0tipAd/wjj1YxWyWtUS5IDJpOG82JKt2EAVA==", + "license": "Apache-2.0", + "dependencies": { + "minimatch": "^5.1.0" + } + }, + "node_modules/readdir-glob/node_modules/brace-expansion": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/readdir-glob/node_modules/minimatch": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz", + "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/recursive-diff": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/recursive-diff/-/recursive-diff-1.0.9.tgz", @@ -10356,6 +10790,16 @@ "node": ">= 0.8" } }, + "node_modules/stream-browserify": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/stream-browserify/-/stream-browserify-3.0.0.tgz", + "integrity": "sha512-H73RAHsVBapbim0tU2JwwOiXUj+fikfiaoYAKHF3VJfA0pe2BCzkhAHBlLG6REzE+2WNZcxOXjK7lkso+9euLA==", + "license": "MIT", + "dependencies": { + "inherits": "~2.0.4", + "readable-stream": "^3.5.0" + } + }, "node_modules/stream-events": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/stream-events/-/stream-events-1.0.5.tgz", @@ -10381,6 +10825,19 @@ "node": ">=10.0.0" } }, + "node_modules/streamx": { + "version": "2.22.0", + "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.22.0.tgz", + "integrity": "sha512-sLh1evHOzBy/iWRiR6d1zRcLao4gGZr3C1kzNz4fopCOKJb6xD9ub8Mpi9Mr1R6id5o43S+d93fI48UC5uM9aw==", + "license": "MIT", + "dependencies": { + "fast-fifo": "^1.3.2", + "text-decoder": "^1.1.0" + }, + "optionalDependencies": { + "bare-events": "^2.2.0" + } + }, "node_modules/strict-uri-encode": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/strict-uri-encode/-/strict-uri-encode-2.0.0.tgz", @@ -10652,6 +11109,17 @@ "node": ">=10" } }, + "node_modules/tar-stream": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.1.7.tgz", + "integrity": "sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ==", + "license": "MIT", + "dependencies": { + "b4a": "^1.6.4", + "fast-fifo": "^1.2.0", + "streamx": "^2.15.0" + } + }, "node_modules/tar/node_modules/minipass": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/minipass/-/minipass-5.0.0.tgz", @@ -10754,6 +11222,15 @@ "rimraf": "bin.js" } }, + "node_modules/text-decoder": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.3.tgz", + "integrity": "sha512-3/o9z3X0X0fTupwsYvR03pJ/DjWuqqrfwBgTQzdWDiQSm9KitAyz/9WqsT2JQW7KV2m+bC2ol/zqpW37NHxLaA==", + "license": "Apache-2.0", + "dependencies": { + "b4a": "^1.6.4" + } + }, "node_modules/text-hex": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/text-hex/-/text-hex-1.0.0.tgz", @@ -11766,6 +12243,36 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "node_modules/zip-stream": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/zip-stream/-/zip-stream-6.0.1.tgz", + "integrity": "sha512-zK7YHHz4ZXpW89AHXUPbQVGKI7uvkd3hzusTdotCg1UxyaVtg0zFJSTfW/Dq5f7OBBVnq6cZIaC8Ti4hb6dtCA==", + "license": "MIT", + "dependencies": { + "archiver-utils": "^5.0.0", + "compress-commons": "^6.0.2", + "readable-stream": "^4.0.0" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/zip-stream/node_modules/readable-stream": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz", + "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==", + "license": "MIT", + "dependencies": { + "abort-controller": "^3.0.0", + "buffer": "^6.0.3", + "events": "^3.3.0", + "process": "^0.11.10", + "string_decoder": "^1.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } } } } diff --git a/package.json b/package.json index c24e79f8e..91b4bc851 100644 --- a/package.json +++ b/package.json @@ -25,10 +25,12 @@ "@aws-sdk/client-secrets-manager": "^3.738.0", "@aws-sdk/client-ses": "^3.738.0", "@aws-sdk/credential-provider-node": "^3.738.0", + "@aws-sdk/lib-storage": "^3.743.0", "@aws-sdk/s3-request-presigner": "^3.731.1", "@opensearch-project/opensearch": "^2.13.0", "@socket.io/admin-ui": "^0.5.1", "@socket.io/redis-adapter": "^8.3.0", + "archiver": "^7.0.1", "aws4": "^1.13.2", "axios": "^1.7.7", "better-queue": "^3.8.12", diff --git a/server/graphql-client/queries.js b/server/graphql-client/queries.js index fa08614fb..a3bc38917 100644 --- a/server/graphql-client/queries.js +++ b/server/graphql-client/queries.js @@ -2722,3 +2722,30 @@ exports.GET_DOCUMENTS_BY_JOB = ` } } }`; + +exports.QUERY_TEMPORARY_DOCS = ` query QUERY_TEMPORARY_DOCS { + documents(where: { jobid: { _is_null: true } }, order_by: { takenat: desc }) { + id + name + key + type + extension + size + takenat + } + }`; + +exports.GET_DOCUMENTS_BY_IDS = ` + query GET_DOCUMENTS_BY_IDS($documentIds: [uuid!]!) { + documents(where: {id: {_in: $documentIds}}, order_by: {takenat: desc}) { + id + name + key + type + extension + size + takenat + } +} + + `; diff --git a/server/media/imgprox-media.js b/server/media/imgprox-media.js index 2ee4d5d5b..4cb0b7d94 100644 --- a/server/media/imgprox-media.js +++ b/server/media/imgprox-media.js @@ -3,13 +3,30 @@ require("dotenv").config({ path: path.resolve(process.cwd(), `.env.${process.env.NODE_ENV || "development"}`) }); const logger = require("../utils/logger"); -const { S3Client, PutObjectCommand, GetObjectCommand } = require("@aws-sdk/client-s3"); +const { + S3Client, + PutObjectCommand, + GetObjectCommand, + CopyObjectCommand, + DeleteObjectCommand +} = require("@aws-sdk/client-s3"); +const { Upload } = require("@aws-sdk/lib-storage"); + const { getSignedUrl } = require("@aws-sdk/s3-request-presigner"); const crypto = require("crypto"); const { InstanceRegion } = require("../utils/instanceMgr"); -const { GET_DOCUMENTS_BY_JOB } = require("../graphql-client/queries"); -//TODO: Remove hardcoded values. -const imgproxyBaseUrl = process.env.IMGPROXY_BASE_URL; +const { + GET_DOCUMENTS_BY_JOB, + QUERY_TEMPORARY_DOCS, + GET_DOCUMENTS_BY_IDS, + DELETE_MEDIA_DOCUMENTS +} = require("../graphql-client/queries"); +const archiver = require("archiver"); +const stream = require("node:stream"); + +const imgproxyBaseUrl = + // `https://u4gzpp5wm437dnm75qa42tvza40fguqr.lambda-url.ca-central-1.on.aws` || //Direct Lambda function access to bypass CDN. + process.env.IMGPROXY_BASE_URL; const imgproxyKey = process.env.IMGPROXY_KEY; const imgproxySalt = process.env.IMGPROXY_SALT; const imgproxyDestinationBucket = process.env.IMGPROXY_DESTINATION_BUCKET; @@ -31,10 +48,13 @@ exports.generateSignedUploadUrls = async (req, res) => { const signedUrls = []; for (const filename of filenames) { - // TODO: Implement a different, unique file naming convention. const key = filename; //GenerateKey({ bodyshopid, jobid, filename }); const client = new S3Client({ region: InstanceRegion() }); - const command = new PutObjectCommand({ Bucket: imgproxyDestinationBucket, Key: key }); + const command = new PutObjectCommand({ + Bucket: imgproxyDestinationBucket, + Key: key, + StorageClass: "INTELLIGENT_TIERING" + }); const presignedUrl = await getSignedUrl(client, command, { expiresIn: 360 }); signedUrls.push({ filename, presignedUrl, key }); } @@ -61,11 +81,14 @@ exports.getThumbnailUrls = async (req, res) => { const { jobid, billid } = req.body; try { - //TODO: Query for all documents related to the job. - //Delayed as the key structure may change slightly from what it is currently and will require evaluating mobile components. + 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; - const data = await client.request(GET_DOCUMENTS_BY_JOB, { jobId: jobid }); + //If there's no jobid and no billid, we're in temporary documents. + const data = await (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() }); @@ -123,7 +146,9 @@ exports.getThumbnailUrls = async (req, res) => { res.json(proxiedUrls); //Iterate over them, build the link based on the media type, and return the array. } catch (error) { - logger.log("imgproxy-get-proxied-urls-error", "ERROR", req.user?.email, jobid, { + logger.log("imgproxy-thumbnails-error", "ERROR", req.user?.email, jobid, { + jobid, + billid, message: error.message, stack: error.stack }); @@ -137,20 +162,194 @@ exports.getBillFiles = async (req, res) => { exports.downloadFiles = async (req, res) => { //Given a series of document IDs or keys, generate a file (or a link) to download all images in bulk + const { jobid, billid, documentids } = req.body; + try { + logger.log("imgproxy-download", "DEBUG", req.user?.email, jobid, { billid, jobid, documentids }); + + //Delayed as the key structure may change slightly from what it is currently and will require evaluating mobile components. + const client = req.userGraphQLClient; + //Query for the keys of the document IDs + const data = await client.request(GET_DOCUMENTS_BY_IDS, { documentIds: documentids }); + //Using the Keys, get all of the S3 links, zip them, and send back to the client. + const s3client = new S3Client({ region: InstanceRegion() }); + const archiveStream = archiver("zip"); + archiveStream.on("error", (error) => { + console.error("Archival encountered an error:", error); + throw new Error(error); + }); + const passthrough = new stream.PassThrough(); + + archiveStream.pipe(passthrough); + for (const key of data.documents.map((d) => d.key)) { + const response = await s3client.send(new GetObjectCommand({ Bucket: imgproxyDestinationBucket, Key: key })); + // :: `response.Body` is a Buffer + console.log(path.basename(key)); + archiveStream.append(response.Body, { name: path.basename(key) }); + } + + archiveStream.finalize(); + + const archiveKey = `archives/${jobid}/archive-${new Date().toISOString()}.zip`; + + const parallelUploads3 = new Upload({ + client: s3client, + queueSize: 4, // optional concurrency configuration + leavePartsOnError: false, // optional manually handle dropped parts + params: { Bucket: imgproxyDestinationBucket, Key: archiveKey, Body: passthrough } + }); + + parallelUploads3.on("httpUploadProgress", (progress) => { + console.log(progress); + }); + + const uploadResult = await parallelUploads3.done(); + //Generate the presigned URL to download it. + const presignedUrl = await getSignedUrl( + s3client, + new GetObjectCommand({ Bucket: imgproxyDestinationBucket, Key: archiveKey }), + { expiresIn: 360 } + ); + + res.json({ success: true, url: presignedUrl }); + //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 + }); + res.status(400).json({ message: error.message, stack: error.stack }); + } }; exports.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 + const deleteResult = 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); + console.log("*** ~ file: imgprox-media.js:260 ~ exports.deleteFiles ~ result:", result); + const errors = result.filter((d) => d.error); + + //Delete only the succesful deletes. + const deleteMutationResult = await client.request(DELETE_MEDIA_DOCUMENTS, { + ids: result.filter((t) => !t.error).map((d) => d.id) + }); + + res.json({ errors, deleteMutationResult }); + } catch (error) { + logger.log("imgproxy-delete-files-error", "ERROR", req.user.email, null, { + ids, + message: error.message, + stack: error.stack + }); + res.status(400).json({ message: error.message, stack: error.stack }); + } }; -//Gerneate a key for the s3 bucket by popping off the extension, add a timestamp, and add back the extension. -//This is to prevent any collisions/duplicates in the bucket. -function GenerateKey({ bodyshopid, jobid, filename }) { - let nameArray = filename.split("."); - let extension = nameArray.pop(); - return `${bodyshopid}/${jobid}/${nameArray.join(".")}-${Date.now()}`; -} +exports.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 + const copyresult = await s3client.send( + new CopyObjectCommand({ + Bucket: imgproxyDestinationBucket, + CopySource: `${imgproxyDestinationBucket}/${document.from}`, + Key: document.to, + StorageClass: "INTELLIGENT_TIERING" + }) + ); + + // Delete the original object + const deleteResult = 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} + }`); + res.json({ errors, mutationResult }); + } else { + res.json({ errors: "No images were succesfully 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 + }); + res.status(400).json({ message: error.message, stack: error.stack }); + } +}; function base64UrlEncode(str) { return Buffer.from(str).toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); diff --git a/server/routes/mediaRoutes.js b/server/routes/mediaRoutes.js index 9718b85d1..293ac51a0 100644 --- a/server/routes/mediaRoutes.js +++ b/server/routes/mediaRoutes.js @@ -1,7 +1,13 @@ const express = require("express"); const router = express.Router(); const { createSignedUploadURL, downloadFiles, renameKeys, deleteFiles } = require("../media/media"); -const { generateSignedUploadUrls, getThumbnailUrls } = require("../media/imgprox-media"); +const { + generateSignedUploadUrls, + getThumbnailUrls, + downloadFiles: downloadFilesImgproxy, + moveFiles, + deleteFiles: deleteFilesImgproxy +} = require("../media/imgprox-media"); const validateFirebaseIdTokenMiddleware = require("../middleware/validateFirebaseIdTokenMiddleware"); const withUserGraphQLClientMiddleware = require("../middleware/withUserGraphQLClientMiddleware"); @@ -13,7 +19,10 @@ router.post("/download", downloadFiles); router.post("/rename", renameKeys); router.post("/delete", deleteFiles); -router.post("/proxy/sign", generateSignedUploadUrls); -router.post("/proxy/thumbnails", getThumbnailUrls); +router.post("/imgproxy/sign", generateSignedUploadUrls); +router.post("/imgproxy/thumbnails", getThumbnailUrls); +router.post("/imgproxy/download", downloadFilesImgproxy); +router.post("/imgproxy/rename", moveFiles); +router.post("/imgproxy/delete", deleteFilesImgproxy); module.exports = router;