From 27e80f9d1267eec58d9de60e403f46edd3d5f67d Mon Sep 17 00:00:00 2001 From: Ming Ze <83811379+tangmz@users.noreply.github.com> Date: Thu, 16 Apr 2026 15:27:52 +0100 Subject: [PATCH] Add Excel support (.xlsx, .xls) --- .../public/locales/de/translation.json | 2 + .../public/locales/en/translation.json | 2 + .../public/locales/es/translation.json | 2 + .../public/locales/fr/translation.json | 2 + .../public/locales/hi/translation.json | 2 + .../public/locales/it/translation.json | 2 + .../public/locales/kr/translation.json | 2 + apps/OpenSign/src/pages/Form.jsx | 53 ++++- .../cloud/customRoute/customApp.js | 2 + .../cloud/customRoute/docxtopdf.js | 3 +- .../cloud/customRoute/exceltopdf.js | 223 ++++++++++++++++++ 11 files changed, 292 insertions(+), 3 deletions(-) create mode 100644 apps/OpenSignServer/cloud/customRoute/exceltopdf.js diff --git a/apps/OpenSign/public/locales/de/translation.json b/apps/OpenSign/public/locales/de/translation.json index d477402ef..72085706a 100644 --- a/apps/OpenSign/public/locales/de/translation.json +++ b/apps/OpenSign/public/locales/de/translation.json @@ -923,6 +923,8 @@ "contact-already-exists": "Der Kontakt existiert bereits.", "docx-error": "Wir haben derzeit Probleme mit der Verarbeitung von DOCX-Dateien. Bitte laden Sie die PDF-Datei hoch", "docx-error-contact": "oder kontaktieren Sie uns unter support@opensignlabs.com", + "excel-error": "Wir haben derzeit Probleme mit der Verarbeitung von Excel-Dateien. Bitte laden Sie die PDF-Datei hoch", + "excel-error-contact": "oder kontaktieren Sie uns unter support@opensignlabs.com", "agreement-note": "Hinweis: Durch Ihre Zustimmung unterzeichnen Sie das Dokument nicht sofort. Sie können das Dokument nur elektronisch einsehen. Sie haben die Möglichkeit, es vollständig zu lesen und anschließend zu entscheiden, ob Sie es unterzeichnen möchten.", "draft-template-info-p1": "Um Ihre Vorlage öffentlich zu machen, muss sie entweder eine einzelne Rolle enthalten oder, wenn sie mehrere Rollen umfasst, müssen alle zusätzlichen Rollen bereits den Unterzeichnern zugewiesen sein. Die nicht zugewiesene öffentliche Rolle muss leer bleiben und an erster Stelle stehen.", "visit-below-link": "Besuchen Sie den untenstehenden Link, um mehr zu erfahren -", diff --git a/apps/OpenSign/public/locales/en/translation.json b/apps/OpenSign/public/locales/en/translation.json index 5553f6a33..a6e31654a 100644 --- a/apps/OpenSign/public/locales/en/translation.json +++ b/apps/OpenSign/public/locales/en/translation.json @@ -923,6 +923,8 @@ "contact-already-exists": "contact already exists.", "docx-error": "We are currently experiencing some issues with processing DOCX files. Please upload the PDF file", "docx-error-contact": "or contact us on support@opensignlabs.com", + "excel-error": "We are currently experiencing some issues with processing Excel files. Please upload the PDF file", + "excel-error-contact": "or contact us on support@opensignlabs.com", "agreement-note": "Note: Agreeing to this does not mean you are signing the document immediately. This only allows you to review the document electronically. You will have the opportunity to read it in full and decide whether to sign it afterward.", "draft-template-info-p1": "To make your template public, it must either contain a single role, or, if it includes multiple roles, all additional roles must already be assigned to signers. The unassigned public role should remain empty and must be placed in the first position.", "visit-below-link": "Visit below link to know more -", diff --git a/apps/OpenSign/public/locales/es/translation.json b/apps/OpenSign/public/locales/es/translation.json index 2d7fd544d..580d9ff78 100644 --- a/apps/OpenSign/public/locales/es/translation.json +++ b/apps/OpenSign/public/locales/es/translation.json @@ -923,6 +923,8 @@ "contact-already-exists": "El contacto ya existe.", "docx-error": "Actualmente estamos experimentando problemas con el procesamiento de archivos DOCX. Por favor, sube el archivo PDF", "docx-error-contact": "o contáctanos en support@opensignlabs.com", + "excel-error": "Actualmente estamos experimentando problemas con el procesamiento de archivos Excel. Por favor, sube el archivo PDF", + "excel-error-contact": "o contáctanos en support@opensignlabs.com", "agreement-note": "Nota: Aceptar esto no significa que esté firmando el documento de inmediato. Esto solo le permite revisar el documento electrónicamente. Tendrá la oportunidad de leerlo en su totalidad y decidir si desea firmarlo después.", "draft-template-info-p1": "Para hacer que tu plantilla sea pública, debe contener un único rol o, si incluye múltiples roles, todos los roles adicionales deben estar ya asignados a firmantes. El rol público no asignado debe permanecer vacío y debe estar en la primera posición.", "visit-below-link": "Visita el siguiente enlace para saber más -", diff --git a/apps/OpenSign/public/locales/fr/translation.json b/apps/OpenSign/public/locales/fr/translation.json index 5a228cc77..604980d2e 100644 --- a/apps/OpenSign/public/locales/fr/translation.json +++ b/apps/OpenSign/public/locales/fr/translation.json @@ -923,6 +923,8 @@ "contact-already-exists": "Le contact existe déjà.", "docx-error": "Nous rencontrons actuellement des problèmes avec le traitement des fichiers DOCX. Veuillez télécharger le fichier PDF", "docx-error-contact": "ou contactez-nous à support@opensignlabs.com", + "excel-error": "Nous rencontrons actuellement des problèmes avec le traitement des fichiers Excel. Veuillez télécharger le fichier PDF", + "excel-error-contact": "ou contactez-nous à support@opensignlabs.com", "agreement-note": "Remarque : Accepter cela ne signifie pas que vous signez immédiatement le document. Cela vous permet uniquement de consulter le document électroniquement. Vous aurez l'opportunité de le lire entièrement et de décider ensuite si vous souhaitez le signer.", "draft-template-info-p1": "Pour rendre votre modèle public, il doit contenir un seul rôle ou, s'il inclut plusieurs rôles, tous les rôles supplémentaires doivent déjà être attribués aux signataires. Le rôle public non attribué doit rester vide et être placé en première position.", "visit-below-link": "Visitez le lien ci-dessous pour en savoir plus -", diff --git a/apps/OpenSign/public/locales/hi/translation.json b/apps/OpenSign/public/locales/hi/translation.json index 987ff982e..51ebea6a1 100644 --- a/apps/OpenSign/public/locales/hi/translation.json +++ b/apps/OpenSign/public/locales/hi/translation.json @@ -923,6 +923,8 @@ "contact-already-exists": "संपर्क पहले से मौजूद है।", "docx-error": "हम वर्तमान में DOCX फ़ाइलों को संसाधित करने में कुछ समस्याओं का सामना कर रहे हैं। कृपया PDF फ़ाइल अपलोड करें", "docx-error-contact": "या support@opensignlabs.com पर हमसे संपर्क करें", + "excel-error": "हम वर्तमान में Excel फ़ाइलों को संसाधित करने में कुछ समस्याओं का सामना कर रहे हैं। कृपया PDF फ़ाइल अपलोड करें", + "excel-error-contact": "या support@opensignlabs.com पर हमसे संपर्क करें", "agreement-note": "ध्यान दें: इससे सहमत होने का मतलब यह नहीं है कि आप तुरंत दस्तावेज़ पर हस्ताक्षर कर रहे हैं। यह आपको केवल दस्तावेज़ की इलेक्ट्रॉनिक रूप से समीक्षा करने की अनुमति देता है। आपके पास इसे पूरा पढ़ने और बाद में हस्ताक्षर करने का निर्णय लेने का अवसर होगा।", "draft-template-info-p1": "अपने टेम्पलेट को सार्वजनिक करने के लिए, इसमें या तो एक ही भूमिका होनी चाहिए, या, यदि इसमें कई भूमिकाएँ शामिल हैं, तो सभी अतिरिक्त भूमिकाएँ पहले से ही हस्ताक्षरकर्ताओं को सौंपी जानी चाहिए। असाइन न की गई सार्वजनिक भूमिका खाली रहनी चाहिए और उसे पहले स्थान पर रखा जाना चाहिए।", "visit-below-link": "अधिक जानने के लिए नीचे दिए गए लिंक पर जाएं -", diff --git a/apps/OpenSign/public/locales/it/translation.json b/apps/OpenSign/public/locales/it/translation.json index d8b3e26af..ddb228a41 100644 --- a/apps/OpenSign/public/locales/it/translation.json +++ b/apps/OpenSign/public/locales/it/translation.json @@ -923,6 +923,8 @@ "contact-already-exists": "Il contatto esiste già.", "docx-error": "Stiamo riscontrando alcuni problemi con l'elaborazione dei file DOCX. Per favore, carica il file PDF", "docx-error-contact": "o contattaci a support@opensignlabs.com", + "excel-error": "Stiamo riscontrando alcuni problemi con l'elaborazione dei file Excel. Per favore, carica il file PDF", + "excel-error-contact": "o contattaci a support@opensignlabs.com", "agreement-note": "Nota: Accettare questo non significa che stai firmando immediatamente il documento. Questo ti consente solo di esaminare il documento elettronicamente. Avrai l'opportunità di leggerlo per intero e decidere successivamente se firmarlo.", "draft-template-info-p1": "Per rendere il tuo modello pubblico, deve contenere un solo ruolo oppure, se include più ruoli, tutti i ruoli aggiuntivi devono essere già assegnati ai firmatari. Il ruolo pubblico non assegnato deve rimanere vuoto e deve essere posizionato per primo.", "visit-below-link": "Visita il link qui sotto per saperne di più -", diff --git a/apps/OpenSign/public/locales/kr/translation.json b/apps/OpenSign/public/locales/kr/translation.json index 40306fe3b..f74c1af04 100644 --- a/apps/OpenSign/public/locales/kr/translation.json +++ b/apps/OpenSign/public/locales/kr/translation.json @@ -923,6 +923,8 @@ "contact-already-exists": "연락처가 이미 존재합니다.", "docx-error": "현재 DOCX 파일 처리 중 일부 문제가 발생했습니다. PDF 파일을 업로드하세요.", "docx-error-contact": "또는 support@opensignlabs.com으로 문의하세요.", + "excel-error": "현재 Excel 파일 처리 중 일부 문제가 발생했습니다. PDF 파일을 업로드하세요.", + "excel-error-contact": "또는 support@opensignlabs.com으로 문의하세요.", "agreement-note": "참고: 이에 동의한다고 해서 즉시 문서에 서명하는 것은 아닙니다. 이는 단지 전자적으로 문서를 검토할 수 있도록 허용하는 것입니다. 전체를 읽고 서명 여부를 결정할 기회가 있습니다.", "draft-template-info-p1": "템플릿을 공개하려면 단일 역할을 포함하거나 여러 역할을 포함하는 경우 모든 추가 역할이 이미 서명자에게 할당되어 있어야 합니다. 할당되지 않은 공개 역할은 비워두고 첫 번째 위치에 배치해야 합니다.", "visit-below-link": "자세한 내용은 아래 링크를 방문하세요 -", diff --git a/apps/OpenSign/src/pages/Form.jsx b/apps/OpenSign/src/pages/Form.jsx index 6423a55a0..571b8ffc4 100644 --- a/apps/OpenSign/src/pages/Form.jsx +++ b/apps/OpenSign/src/pages/Form.jsx @@ -290,6 +290,55 @@ const Forms = (props) => { } return; } + } else if ( + file.type === "application/vnd.ms-excel" || + file.type === "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" || + file.name.toLowerCase().endsWith(".xls") || + file.name.toLowerCase().endsWith(".xlsx") + ) { + try { + const baseApi = localStorage.getItem("baseUrl") || ""; + const url = removeTrailingSegment(baseApi) + "/exceltopdf"; + let fd = new FormData(); + fd.append("file", file); + setfileload(true); + setpercentage(0); + const config = { + headers: { + "content-type": "multipart/form-data", + sessiontoken: Parse.User.current().getSessionToken() + }, + signal: abortController.signal, + onUploadProgress: (progressEvent) => { + if (progressEvent.total) { + const percentCompleted = Math.round( + (progressEvent.loaded * 100) / progressEvent.total + ); + setpercentage(percentCompleted); + } + } + }; + const res = await axios.post(url, fd, config); + if (res.data?.url) { + const pdfRes = await axios.get(res.data.url, { + responseType: "arraybuffer" + }); + pdfBuffers.push(pdfRes.data); + } + setfileload(false); + } catch (err) { + setfileload(false); + removeFile(e); + console.log("err in excel to pdf ", err); + const error = + t("excel-error"); + if (err?.code === 209) { + dispatch(sessionStatus(false)); + } else { + alert(error); + } + return; + } } } @@ -784,7 +833,7 @@ const Forms = (props) => { )}
{fileupload.length > 0 ? ( @@ -812,7 +861,7 @@ const Forms = (props) => { className="op-file-input op-file-input-bordered op-file-input-sm focus:outline-none hover:border-base-content w-full text-xs" onChange={(e) => handleFileInput(e)} ref={inputFileRef} - accept="application/pdf,application/vnd.openxmlformats-officedocument.wordprocessingml.document,image/png,image/jpeg" + accept="application/pdf,application/vnd.openxmlformats-officedocument.wordprocessingml.document,application/vnd.ms-excel,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet,image/png,image/jpeg" onInvalid={(e) => e.target.setCustomValidity(t("input-required")) } diff --git a/apps/OpenSignServer/cloud/customRoute/customApp.js b/apps/OpenSignServer/cloud/customRoute/customApp.js index e48488091..968490828 100644 --- a/apps/OpenSignServer/cloud/customRoute/customApp.js +++ b/apps/OpenSignServer/cloud/customRoute/customApp.js @@ -3,6 +3,7 @@ import cors from 'cors'; import dotenv from 'dotenv'; import docxtopdf, { upload as docxUpload } from './docxtopdf.js'; +import exceltopdf, { upload as excelUpload } from './exceltopdf.js'; import decryptpdf, { upload as decryptUpload } from './decryptpdf.js'; import { deleteUserByAdmin, deleteUserPost } from './deleteAccount/deleteUser.js'; import { deleteUserGet } from './deleteAccount/deleteUserGet.js'; @@ -16,6 +17,7 @@ app.use(express.json({ limit: '100mb' })); app.use(express.urlencoded({ limit: '100mb', extended: true })); app.post('/docxtopdf', docxUpload.single('file'), docxtopdf); +app.post('/exceltopdf', excelUpload.single('file'), exceltopdf); app.post('/decryptpdf', decryptUpload.single('file'), decryptpdf); app.get('/delete-account/:userId', deleteUserGet); app.post('/delete-account/:userId/otp', deleteUserOtp); diff --git a/apps/OpenSignServer/cloud/customRoute/docxtopdf.js b/apps/OpenSignServer/cloud/customRoute/docxtopdf.js index 9617582f7..bc6054b29 100644 --- a/apps/OpenSignServer/cloud/customRoute/docxtopdf.js +++ b/apps/OpenSignServer/cloud/customRoute/docxtopdf.js @@ -147,6 +147,7 @@ export default async function docxtopdf(req, res) { // ---- DOCX -> PDF conversion with concurrency control and timeout ---- const fileName = `${generatePdfName(16)}.pdf`; + const uploadedSizeBytes = req.file.buffer.length; // Adjust timeout based on file size const timeoutMs = uploadedSizeBytes > 10 * 1024 * 1024 ? 120_000 : 90_000; @@ -216,6 +217,6 @@ export default async function docxtopdf(req, res) { msg = message; } console.error(`[DOCX2PDF] Error: ${msg}`); - return res.status(400).json({ error: message }); + return res.status(400).json({ error: msg }); } } diff --git a/apps/OpenSignServer/cloud/customRoute/exceltopdf.js b/apps/OpenSignServer/cloud/customRoute/exceltopdf.js new file mode 100644 index 000000000..2359644b1 --- /dev/null +++ b/apps/OpenSignServer/cloud/customRoute/exceltopdf.js @@ -0,0 +1,223 @@ +import axios from 'axios'; +import multer from 'multer'; +import libre from 'libreoffice-convert'; +import { exec } from 'child_process'; +import { promisify } from 'util'; +import { cloudServerUrl, getSecureUrl, serverAppId } from '../../Utils.js'; + +const execAsync = promisify(exec); + +// -------------------- Process Management -------------------- +/** + * Kill stuck LibreOffice processes + */ +async function killStuckProcesses() { + try { + // Kill soffice processes older than 2 minutes + if (process.platform === 'linux' || process.platform === 'darwin') { + await execAsync("pkill -9 -f 'soffice.*--headless' || true"); + console.log('[EXCEL2PDF] Cleaned up stuck processes'); + } else if (process.platform === 'win32') { + await execAsync('taskkill /F /IM soffice.bin /T || exit 0'); + console.log('[EXCEL2PDF] Cleaned up stuck processes (Windows)'); + } + } catch (err) { + console.error('[EXCEL2PDF] Error killing processes:', err.message); + } +} + +/** @returns {Promise} */ +async function convertLibre(input, ext, opts) { + return await new Promise((resolve, reject) => { + try { + libre.convert(input, ext, opts, (err, out) => (err ? reject(err) : resolve(out))); + } catch (e) { + reject(e); + } + }); +} + +// -------------------- Concurrency limiter with queue limits -------------------- +// CRITICAL FIX: Reduced to 1 for CPU-intensive LibreOffice conversions +const MAX_CONCURRENCY = Number(process.env.EXCEL2PDF_CONCURRENCY || 1); +let active = 0; +const queue = []; + +function runWithLimit(task) { + return new Promise((resolve, reject) => { + const run = async () => { + active++; + try { + const result = await task(); + resolve(result); + } catch (error) { + reject(error); + } finally { + active--; + if (queue.length) { + const next = queue.shift(); + next(); + } + } + }; + if (active < MAX_CONCURRENCY) run(); + else queue.push(run); + }); +} + +// -------------------- Timeout helper with cleanup -------------------- +/** + * @template T + * @param {Promise} promise + * @param {number} ms + * @param {string} [label='operation'] + * @returns {Promise} + */ +export async function withTimeout(promise, ms, label = 'operation') { + let timer; + try { + const timeout = new Promise((_, reject) => { + timer = setTimeout(async () => { + // Kill stuck processes on timeout + await killStuckProcesses(); + reject(new Error(`${label} timed out after ${ms}ms`)); + }, ms); + }); + return await Promise.race([promise, timeout]); + } finally { + if (timer) clearTimeout(timer); + } +} + +// -------------------- Multer: use memory storage -------------------- +const storage = multer.memoryStorage(); +export const upload = multer({ + storage, + limits: { fileSize: 50 * 1024 * 1024 }, // 50MB hard limit at multer level + fileFilter: (req, file, cb) => { + const okExt = /\.(xlsx|xls)$/i.test(file.originalname || ''); + const okMime = + file.mimetype === 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' || + file.mimetype === 'application/vnd.ms-excel' || + file.mimetype === 'application/octet-stream'; + if (okExt && okMime) return cb(null, true); + cb(new Error('Only .xlsx and .xls files are supported')); + }, +}); + +function generatePdfName(length) { + const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; + let result = ''; + for (let i = 0; i < length; i++) result += chars.charAt(Math.floor(Math.random() * chars.length)); + return result; +} + +export default async function exceltopdf(req, res) { + const serverUrl = cloudServerUrl; + const parseAppKey = { 'X-Parse-Application-Id': serverAppId }; + const masterKey = process.env.MASTER_KEY; + + const masterHeader = { ...parseAppKey, 'X-Parse-Master-Key': masterKey }; + const sessionHeader = { ...parseAppKey, 'X-Parse-Session-Token': req.headers['sessiontoken'] }; + + if (!req.file || !req.file.buffer) { + return res.status(400).json({ error: 'No file uploaded.' }); + } + + try { + // ---- Auth: current user ---- + const userRes = await axios.get(`${serverUrl}/users/me`, { headers: sessionHeader }); + + // ---- contracts_Users ---- + const whereUser = JSON.stringify({ + UserId: { __type: 'Pointer', className: '_User', objectId: userRes.data.objectId }, + }); + const resUser = await axios.get( + `${serverUrl}/classes/contracts_Users?where=${whereUser}&limit=1&include=TenantId`, + { headers: masterHeader } + ); + + if (!resUser?.data?.results?.length) { + return res.status(403).json({ error: 'User not linked to tenant.' }); + } + + const tenantId = resUser.data.results[0]?.TenantId?.objectId; + if (!tenantId) { + return res.status(403).json({ error: 'Tenant not found for user.' }); + } + + // ---- EXCEL -> PDF conversion with concurrency control and timeout ---- + const fileName = `${generatePdfName(16)}.pdf`; + const uploadedSizeBytes = req.file.buffer.length; + + // Adjust timeout based on file size + const timeoutMs = uploadedSizeBytes > 10 * 1024 * 1024 ? 120_000 : 90_000; + + // FIX: Increased timeout for large files, added nice priority + const pdfBuffer = await runWithLimit(async () => { + // Log for monitoring + console.log( + `[EXCEL2PDF] Starting conversion, size: ${(uploadedSizeBytes / 1024 / 1024).toFixed(2)}MB, active: ${active}, queued: ${queue.length}` + ); + + const startTime = Date.now(); + try { + const result = await withTimeout( + convertLibre(req.file.buffer, '.pdf', undefined), + timeoutMs, + 'EXCEL->PDF' + ); + console.log(`[EXCEL2PDF] Completed in ${Date.now() - startTime}ms`); + return result; + } catch (error) { + console.error(`[EXCEL2PDF] Failed after ${Date.now() - startTime}ms:`, error.message); + // Clean up on error + await killStuckProcesses(); + throw error; + } + }); + + // ---- Upload PDF ---- + const activeFileAdapter = resUser.data.results[0]?.TenantId?.ActiveFileAdapter; + let fileUrl; + if (activeFileAdapter) { + const params = { + fileBase64: Buffer.from(pdfBuffer).toString('base64'), + fileName, + id: activeFileAdapter, + }; + const url = serverUrl + '/functions/savetofileadapter'; + const headers = { 'Content-Type': 'application/json', ...sessionHeader }; + const savetos3 = await axios.post(url, params, { headers }); + fileUrl = savetos3?.data?.result?.url; + if (!fileUrl) throw new Error('No URL returned from file adapter'); + } else { + const parsefile = await axios.post(`${serverUrl}/files/${fileName}`, pdfBuffer, { + headers: { ...masterHeader, 'Content-Type': 'application/pdf' }, + }); + const fileRes = getSecureUrl(parsefile.data.url); + fileUrl = fileRes.url; + } + + return res.status(200).json({ message: 'success.', url: fileUrl }); + } catch (err) { + // More specific error messages + let msg = + err?.response?.data?.error || err?.response?.data || err?.message || 'Something went wrong.'; + // Friendly message to the client + const message = + 'We are currently experiencing some issues with processing Excel files. Please upload the PDF version or contact us on support@opensignlabs.com'; + + if (msg.includes('timed out')) { + msg = + 'Document conversion is taking too long. Please try a smaller file or contact support@opensignlabs.com'; + } else if (msg.includes('too large') || msg.includes('size')) { + msg = + 'File is too large to process. Please reduce the file size or contact support@opensignlabs.com'; + } else { + msg = message; + } + console.error(`[EXCEL2PDF] Error: ${msg}`); + return res.status(400).json({ error: msg }); + } +}