This is an automated email from the ASF dual-hosted git repository.
mridulpathak pushed a commit to branch trunk
in repository https://gitbox.apache.org/repos/asf/ofbiz-framework.git
The following commit(s) were added to refs/heads/trunk by this push:
new 5de1b918f1 Improved: Replace OpenPDF with Apache PDFBox and drop the
OpenPDF dependency (OFBIZ-13684)
5de1b918f1 is described below
commit 5de1b918f147f47158958fa0c9f41efd1ff8a4d4
Author: Mridul Pathak <[email protected]>
AuthorDate: Thu Oct 15 17:17:19 2026 +0530
Improved: Replace OpenPDF with Apache PDFBox and drop the OpenPDF
dependency (OFBIZ-13684)
OpenPDF 1.4.2 (com.lowagie) was only kept for PdfSurveyServices and
SecuredUpload, while PDFBox 3 is already used for uploads and by Mustang. OFBiz
now uses a single PDF library.
- SecuredUpload: the PDF probe and the JavaScript check now use PDFBox. The
JavaScript check looks at every object of the document, those reachable from
the trailer and those listed in the cross-reference table, instead of only the
document-level JavaScript names tree, so JavaScript in OpenAction, Next chains,
and document, page, annotation and form field actions is now rejected too.
- New allowPdfFormFieldScripts property in security.properties, false by
default, to still accept PDF forms whose fields have keystroke, format,
validate or calculate scripts.
- PdfSurveyServices: survey AcroForm handling ported to the PDFBox form
field API, through a new package-private PdfFormHelper, with the same inputs
and outputs. The question label now comes from the field's tooltip wherever it
is set.
- buildPdfFromSurveyResponse now returns a PDF with one "question: answer"
line per answer. It never opened its OpenPDF document, so it failed whenever
the response had answers.
- Removed openpdf from libs.versions.toml and dependencies.gradle.
---
.../apache/ofbiz/content/survey/PdfFormHelper.java | 155 ++++++++++++
.../ofbiz/content/survey/PdfSurveyServices.java | 280 ++++++++++++---------
dependencies.gradle | 1 -
framework/security/config/security.properties | 5 +
.../org/apache/ofbiz/security/SecuredUpload.java | 176 ++++++++++++-
gradle/libs.versions.toml | 3 -
6 files changed, 482 insertions(+), 138 deletions(-)
diff --git
a/applications/content/src/main/java/org/apache/ofbiz/content/survey/PdfFormHelper.java
b/applications/content/src/main/java/org/apache/ofbiz/content/survey/PdfFormHelper.java
new file mode 100644
index 0000000000..d5a4ce81d6
--- /dev/null
+++
b/applications/content/src/main/java/org/apache/ofbiz/content/survey/PdfFormHelper.java
@@ -0,0 +1,155 @@
+/*******************************************************************************
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+
*******************************************************************************/
+package org.apache.ofbiz.content.survey;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.IdentityHashMap;
+import java.util.List;
+import java.util.Map;
+
+import org.apache.ofbiz.base.util.Debug;
+import org.apache.pdfbox.cos.COSDictionary;
+import org.apache.pdfbox.cos.COSName;
+import org.apache.pdfbox.pdmodel.PDDocument;
+import org.apache.pdfbox.pdmodel.PDPage;
+import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotation;
+import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotationWidget;
+import org.apache.pdfbox.pdmodel.interactive.form.PDAcroForm;
+import org.apache.pdfbox.pdmodel.interactive.form.PDButton;
+import org.apache.pdfbox.pdmodel.interactive.form.PDChoice;
+import org.apache.pdfbox.pdmodel.interactive.form.PDField;
+import org.apache.pdfbox.pdmodel.interactive.form.PDTerminalField;
+
+/**
+ * Reads and fills the AcroForm fields of a PDF document for the survey
services.
+ */
+final class PdfFormHelper {
+
+ private static final String MODULE = PdfFormHelper.class.getName();
+
+ private final PDDocument pdDocument;
+ /** For each widget annotation of the document, its page number (from 1)
and its index in the page's annotations. */
+ private final Map<COSDictionary, int[]> widgetPlaces = new
IdentityHashMap<>();
+
+ /**
+ * @param pdDocument the PDF document, which must stay open while this
helper is used
+ * @throws IOException if the annotations of a page can't be read
+ */
+ PdfFormHelper(PDDocument pdDocument) throws IOException {
+ this.pdDocument = pdDocument;
+ int pageNumber = 0;
+ for (PDPage page : pdDocument.getPages()) {
+ pageNumber++;
+ List<PDAnnotation> annotations = page.getAnnotations();
+ for (int index = 0; index < annotations.size(); index++) {
+ if (annotations.get(index) instanceof PDAnnotationWidget) {
+
widgetPlaces.putIfAbsent(annotations.get(index).getCOSObject(), new int[]
{pageNumber, index});
+ }
+ }
+ }
+ }
+
+ /**
+ * @return the fields of the document's form that are shown on a page, in
the order of the form's field tree
+ */
+ List<PDTerminalField> getFormFields() {
+ List<PDTerminalField> formFields = new ArrayList<>();
+ PDAcroForm acroForm =
pdDocument.getDocumentCatalog().getAcroForm(null);
+ if (acroForm != null) {
+ for (PDField field : acroForm.getFieldTree()) {
+ if (field instanceof PDTerminalField &&
getPlacedWidget((PDTerminalField) field) != null) {
+ formFields.add((PDTerminalField) field);
+ }
+ }
+ }
+ return formFields;
+ }
+
+ /**
+ * @return the first widget of the field that is shown on a page, null if
there is none
+ */
+ PDAnnotationWidget getPlacedWidget(PDTerminalField field) {
+ for (PDAnnotationWidget widget : field.getWidgets()) {
+ if (widgetPlaces.containsKey(widget.getCOSObject())) {
+ return widget;
+ }
+ }
+ return null;
+ }
+
+ /**
+ * @return the page number (from 1) of a widget shown on a page, and its
index in the page's annotations
+ */
+ int[] getPlace(PDAnnotationWidget widget) {
+ return widgetPlaces.get(widget.getCOSObject());
+ }
+
+ /**
+ * @return the field value as a string. A choice field gives its selected
option, an empty string when nothing is
+ * selected, and its selected options in brackets when several are.
+ */
+ String getValue(PDTerminalField field) {
+ if (field instanceof PDChoice) {
+ List<String> values = ((PDChoice) field).getValue();
+ if (values.size() <= 1) {
+ return values.isEmpty() ? "" : values.get(0);
+ }
+ }
+ return field.getValueAsString();
+ }
+
+ /**
+ * Sets the field value. A value that the field does not accept, because
it is not one of its options, is still
+ * stored in a choice field, and turns a check box or radio button off.
+ * @throws IOException if the field appearance can't be updated
+ */
+ void setValue(PDTerminalField field, String value) throws IOException {
+ try {
+ field.setValue(value);
+ } catch (IllegalArgumentException | UnsupportedOperationException e) {
+ Debug.logWarning("Value [" + value + "] is not valid for the PDF
form field [" + field.getFullyQualifiedName() + "]: "
+ + e.getMessage(), MODULE);
+ if (field instanceof PDChoice) {
+ field.getCOSObject().setString(COSName.V, value);
+ } else if (field instanceof PDButton && !((PDButton)
field).isPushButton()) {
+ field.setValue(COSName.Off.getName());
+ }
+ }
+ }
+
+ /**
+ * Turns the form fields into page content, keeping only the printable
ones, as the survey services always did.
+ * @throws IOException if the fields can't be flattened
+ */
+ void flatten() throws IOException {
+ PDAcroForm acroForm =
pdDocument.getDocumentCatalog().getAcroForm(null);
+ if (acroForm == null) {
+ return;
+ }
+ for (PDField field : acroForm.getFieldTree()) {
+ for (PDAnnotationWidget widget : field.getWidgets()) {
+ if (!widget.isPrinted()) {
+ widget.setHidden(true);
+ }
+ }
+ }
+ acroForm.flatten();
+ }
+}
diff --git
a/applications/content/src/main/java/org/apache/ofbiz/content/survey/PdfSurveyServices.java
b/applications/content/src/main/java/org/apache/ofbiz/content/survey/PdfSurveyServices.java
index ab6872cfa4..3aff92eb7d 100644
---
a/applications/content/src/main/java/org/apache/ofbiz/content/survey/PdfSurveyServices.java
+++
b/applications/content/src/main/java/org/apache/ofbiz/content/survey/PdfSurveyServices.java
@@ -24,13 +24,13 @@ import java.io.FileOutputStream;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.sql.Timestamp;
+import java.util.ArrayList;
import java.util.Date;
import java.util.HashMap;
import java.util.LinkedList;
import java.util.List;
import java.util.Locale;
import java.util.Map;
-import java.util.Set;
import org.apache.ofbiz.base.util.Debug;
import org.apache.ofbiz.base.util.GeneralException;
@@ -49,18 +49,20 @@ import org.apache.ofbiz.service.GenericServiceException;
import org.apache.ofbiz.service.LocalDispatcher;
import org.apache.ofbiz.service.ModelService;
import org.apache.ofbiz.service.ServiceUtil;
-
-import com.lowagie.text.Chunk;
-import com.lowagie.text.Document;
-import com.lowagie.text.DocumentException;
-import com.lowagie.text.Paragraph;
-import com.lowagie.text.pdf.AcroFields;
-import com.lowagie.text.pdf.PdfDictionary;
-import com.lowagie.text.pdf.PdfName;
-import com.lowagie.text.pdf.PdfObject;
-import com.lowagie.text.pdf.PdfReader;
-import com.lowagie.text.pdf.PdfStamper;
-import com.lowagie.text.pdf.PdfWriter;
+import org.apache.pdfbox.Loader;
+import org.apache.pdfbox.pdmodel.PDDocument;
+import org.apache.pdfbox.pdmodel.PDPage;
+import org.apache.pdfbox.pdmodel.PDPageContentStream;
+import org.apache.pdfbox.pdmodel.common.PDRectangle;
+import org.apache.pdfbox.pdmodel.font.PDFont;
+import org.apache.pdfbox.pdmodel.font.PDType1Font;
+import org.apache.pdfbox.pdmodel.font.Standard14Fonts;
+import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotationWidget;
+import org.apache.pdfbox.pdmodel.interactive.form.PDComboBox;
+import org.apache.pdfbox.pdmodel.interactive.form.PDListBox;
+import org.apache.pdfbox.pdmodel.interactive.form.PDRadioButton;
+import org.apache.pdfbox.pdmodel.interactive.form.PDTerminalField;
+import org.apache.pdfbox.pdmodel.interactive.form.PDTextField;
/**
@@ -81,14 +83,8 @@ public class PdfSurveyServices {
Locale locale = (Locale) context.get("locale");
Timestamp nowTimestamp = UtilDateTime.nowTimestamp();
String surveyId = null;
- try {
+ try (PDDocument pdDocument =
Loader.loadPDF(getInputByteBuffer(context, delegator).array())) {
String surveyName = (String) context.get("surveyName");
- ByteArrayOutputStream os = new ByteArrayOutputStream();
- ByteBuffer byteBuffer = getInputByteBuffer(context, delegator);
- PdfReader pdfReader = new PdfReader(byteBuffer.array());
- PdfStamper pdfStamper = new PdfStamper(pdfReader, os);
- AcroFields acroFields = pdfStamper.getAcroFields();
- Map<String, Object> acroFieldMap =
UtilGenerics.cast(acroFields.getAllFields());
String contentId = (String) context.get("contentId");
GenericValue survey = null;
@@ -107,44 +103,45 @@ public class PdfSurveyServices {
UtilMisc.<String, Object>toMap("description", "From
AcroForm in Content [" + contentId + "] for Survey [" + surveyId
+ "]", "userLogin", userLogin));
if (ServiceUtil.isError(createCategoryResultMap)) {
- pdfStamper.close();
return
ServiceUtil.returnError(ServiceUtil.getErrorMessage(createCategoryResultMap));
}
String surveyQuestionCategoryId = (String)
createCategoryResultMap.get("surveyQuestionCategoryId");
- pdfStamper.setFormFlattening(true);
- for (String fieldName : acroFieldMap.keySet()) {
- AcroFields.Item item = acroFields.getFieldItem(fieldName);
- int type = acroFields.getFieldType(fieldName);
- String value = acroFields.getField(fieldName);
- Debug.logInfo("fieldName:" + fieldName + "; item: " + item +
"; value: " + value, MODULE);
+ PdfFormHelper pdfForm = new PdfFormHelper(pdDocument);
+ for (PDTerminalField field : pdfForm.getFormFields()) {
+ String fieldName = field.getFullyQualifiedName();
+ String value = pdfForm.getValue(field);
+ Debug.logInfo("fieldName:" + fieldName + "; field: " + field +
"; value: " + value, MODULE);
GenericValue surveyQuestion =
delegator.makeValue("SurveyQuestion", UtilMisc.toMap("question", fieldName));
String surveyQuestionId =
delegator.getNextSeqId("SurveyQuestion");
surveyQuestion.set("surveyQuestionId", surveyQuestionId);
surveyQuestion.set("surveyQuestionCategoryId",
surveyQuestionCategoryId);
- if (type == AcroFields.FIELD_TYPE_TEXT) {
+ if (field instanceof PDTextField) {
surveyQuestion.set("surveyQuestionTypeId", "TEXT_SHORT");
- } else if (type == AcroFields.FIELD_TYPE_RADIOBUTTON) {
+ } else if (field instanceof PDRadioButton) {
surveyQuestion.set("surveyQuestionTypeId", "OPTION");
- } else if (type == AcroFields.FIELD_TYPE_LIST || type ==
AcroFields.FIELD_TYPE_COMBO) {
+ } else if (field instanceof PDListBox || field instanceof
PDComboBox) {
surveyQuestion.set("surveyQuestionTypeId", "OPTION");
- // TODO: handle these specially with the
acroFields.getListOptionDisplay (and getListOptionExport?)
+ // TODO: handle these specially with
PDChoice.getOptionsDisplayValues (and getOptionsExportValues?)
} else {
surveyQuestion.set("surveyQuestionTypeId", "TEXT_SHORT");
Debug.logWarning("Building Survey from PDF, fieldName=[" +
fieldName + "]: don't know how to handle field type: "
- + type + "; defaulting to short text", MODULE);
+ + field.getFieldType() + "; defaulting to short
text", MODULE);
}
// ==== create a good sequenceNum based on tab order or if no
tab order then the page location
- Integer tabPage = item.getPage(0);
- Integer tabOrder = item.getTabOrder(0);
+ PDAnnotationWidget widget = pdfForm.getPlacedWidget(field);
+ Integer tabPage = pdfForm.getPlace(widget)[0];
+ Integer tabOrder = pdfForm.getPlace(widget)[1];
Debug.logInfo("tabPage=" + tabPage + ", tabOrder=" + tabOrder,
MODULE);
- //array of float multiple of 5. For each of this groups the
values are: [page, llx, lly, urx, ury]
- float[] fieldPositions =
acroFields.getFieldPositions(fieldName);
+ // position of the field's first widget: [page, llx, lly, urx,
ury]
+ PDRectangle rectangle = widget.getRectangle() != null ?
widget.getRectangle() : new PDRectangle(0, 0);
+ float[] fieldPositions = {tabPage, rectangle.getLowerLeftX(),
rectangle.getLowerLeftY(), rectangle.getUpperRightX(),
+ rectangle.getUpperRightY()};
float fieldPage = fieldPositions[0];
float fieldLlx = fieldPositions[1];
float fieldLly = fieldPositions[2];
@@ -164,29 +161,7 @@ public class PdfSurveyServices {
}
// TODO: need to find something better to put into these
fields...
- String annotation = null;
- for (int k = 0; k < item.size(); ++k) {
- PdfDictionary dict = item.getWidget(k);
-
- // if the "/Type" value is "/Annot", then get the value of
"/TU" for the annotation
-
- PdfObject typeValue = null;
- PdfObject tuValue = null;
-
- Set<PdfName> dictKeys = UtilGenerics.cast(dict.getKeys());
- for (PdfName dictKeyName : dictKeys) {
- PdfObject dictObject = dict.get(dictKeyName);
-
- if ("/Type".equals(dictKeyName.toString())) {
- typeValue = dictObject;
- } else if ("/TU".equals(dictKeyName.toString())) {
- tuValue = dictObject;
- }
- }
- if (tuValue != null && typeValue != null &&
"/Annot".equals(typeValue.toString())) {
- annotation = tuValue.toString();
- }
- }
+ String annotation = field.getAlternateFieldName();
surveyQuestion.set("description", fieldName);
if (UtilValidate.isNotEmpty(annotation)) {
@@ -207,13 +182,12 @@ public class PdfSurveyServices {
surveyQuestion.create();
surveyQuestionAppl.create();
}
- pdfStamper.close();
if (UtilValidate.isNotEmpty(contentId)) {
survey =
EntityQuery.use(delegator).from("Survey").where("surveyId",
surveyId).queryOne();
survey.set("acroFormContentId", contentId);
survey.store();
}
- } catch (GeneralException | DocumentException | IOException e) {
+ } catch (GeneralException | IOException e) {
Debug.logError(e, "Error generating PDF: " + e.toString(), MODULE);
return ServiceUtil.returnError(UtilProperties.getMessage(RESOURCE,
"ContentPDFGeneratingError",
UtilMisc.toMap("errorString", e.toString()), locale));
@@ -229,7 +203,7 @@ public class PdfSurveyServices {
public static Map<String, Object>
buildSurveyResponseFromPdf(DispatchContext dctx, Map<String, ? extends Object>
context) {
String surveyResponseId = null;
Locale locale = (Locale) context.get("locale");
- try {
+ try (PDDocument pdDocument =
Loader.loadPDF(getInputByteBuffer(context, dctx.getDelegator()).array())) {
Delegator delegator = dctx.getDelegator();
String partyId = (String) context.get("partyId");
String surveyId = (String) context.get("surveyId");
@@ -249,16 +223,10 @@ public class PdfSurveyServices {
surveyResponse.create();
}
- ByteArrayOutputStream os = new ByteArrayOutputStream();
- ByteBuffer byteBuffer = getInputByteBuffer(context, delegator);
- PdfReader r = new PdfReader(byteBuffer.array());
- PdfStamper s = new PdfStamper(r, os);
- AcroFields fs = s.getAcroFields();
- Map<String, Object> hm = UtilGenerics.cast(fs.getAllFields());
- s.setFormFlattening(true);
- for (String fieldName : hm.keySet()) {
- // AcroFields.Item item = fs.getFieldItem(fieldName);
- String value = fs.getField(fieldName);
+ PdfFormHelper pdfForm = new PdfFormHelper(pdDocument);
+ for (PDTerminalField field : pdfForm.getFormFields()) {
+ String fieldName = field.getFullyQualifiedName();
+ String value = pdfForm.getValue(field);
GenericValue surveyQuestionAndAppl =
EntityQuery.use(delegator).from("SurveyQuestionAndAppl")
.where("surveyId", surveyId, "externalFieldRef",
fieldName).queryFirst();
if (surveyQuestionAndAppl == null) {
@@ -277,8 +245,7 @@ public class PdfSurveyServices {
delegator.create(surveyResponseAnswer);
}
- s.close();
- } catch (GeneralException | DocumentException | IOException e) {
+ } catch (GeneralException | IOException e) {
Debug.logError(e, "Error generating PDF: " + e.toString(), MODULE);
return ServiceUtil.returnError(UtilProperties.getMessage(RESOURCE,
"ContentPDFGeneratingError",
UtilMisc.toMap("errorString", e.toString()), locale));
@@ -294,21 +261,14 @@ public class PdfSurveyServices {
*/
public static Map<String, Object> getAcroFieldsFromPdf(DispatchContext
dctx, Map<String, ? extends Object> context) throws GeneralException {
Map<String, Object> acroFieldMap = new HashMap<>();
- ByteArrayOutputStream os = new ByteArrayOutputStream();
Delegator delegator = dctx.getDelegator();
ByteBuffer byteBuffer = getInputByteBuffer(context, delegator);
- try (PdfReader r = new PdfReader(byteBuffer.array());
- PdfStamper s = new PdfStamper(r, os)) {
- AcroFields fs = s.getAcroFields();
- Map<String, Object> map = UtilGenerics.cast(fs.getAllFields());
- s.setFormFlattening(true);
-
- for (String fieldName : map.keySet()) {
- String parmValue = fs.getField(fieldName);
- acroFieldMap.put(fieldName, parmValue);
+ try (PDDocument pdDocument = Loader.loadPDF(byteBuffer.array())) {
+ PdfFormHelper pdfForm = new PdfFormHelper(pdDocument);
+ for (PDTerminalField field : pdfForm.getFormFields()) {
+ acroFieldMap.put(field.getFullyQualifiedName(),
pdfForm.getValue(field));
}
-
- } catch (DocumentException | IOException e) {
+ } catch (IOException e) {
Debug.logError(e, MODULE);
return ServiceUtil.returnError(e.getMessage());
}
@@ -326,39 +286,35 @@ public class PdfSurveyServices {
try {
Map<String, Object> acroFieldMap =
UtilGenerics.cast(context.get("acroFieldMap"));
ByteBuffer byteBuffer = getInputByteBuffer(context, delegator);
- PdfReader r = new PdfReader(byteBuffer.array());
ByteArrayOutputStream baos = new ByteArrayOutputStream();
- PdfStamper s = new PdfStamper(r, baos);
- AcroFields fs = s.getAcroFields();
- Map<String, Object> map = UtilGenerics.cast(fs.getAllFields());
- s.setFormFlattening(true);
-
- for (String fieldName : map.keySet()) {
- String fieldValue = fs.getField(fieldName);
- Object obj = acroFieldMap.get(fieldName);
- if (obj instanceof Date) {
- Date d = (Date) obj;
- fieldValue = UtilDateTime.toDateString(d);
- } else if (obj instanceof Long) {
- Long lg = (Long) obj;
- fieldValue = lg.toString();
- } else if (obj instanceof Integer) {
- Integer ii = (Integer) obj;
- fieldValue = ii.toString();
- } else {
- fieldValue = (String) obj;
- }
+ try (PDDocument pdDocument = Loader.loadPDF(byteBuffer.array())) {
+ PdfFormHelper pdfForm = new PdfFormHelper(pdDocument);
+ for (PDTerminalField field : pdfForm.getFormFields()) {
+ String fieldValue;
+ Object obj =
acroFieldMap.get(field.getFullyQualifiedName());
+ if (obj instanceof Date) {
+ Date d = (Date) obj;
+ fieldValue = UtilDateTime.toDateString(d);
+ } else if (obj instanceof Long) {
+ Long lg = (Long) obj;
+ fieldValue = lg.toString();
+ } else if (obj instanceof Integer) {
+ Integer ii = (Integer) obj;
+ fieldValue = ii.toString();
+ } else {
+ fieldValue = (String) obj;
+ }
- if (UtilValidate.isNotEmpty(fieldValue)) {
- fs.setField(fieldName, fieldValue);
+ if (UtilValidate.isNotEmpty(fieldValue)) {
+ pdfForm.setValue(field, fieldValue);
+ }
}
+ pdfForm.flatten();
+ pdDocument.save(baos);
}
-
- s.close();
- baos.close();
ByteBuffer outByteBuffer = ByteBuffer.wrap(baos.toByteArray());
results.put("outByteBuffer", outByteBuffer);
- } catch (DocumentException | IOException | GeneralException e) {
+ } catch (IOException | GeneralException e) {
Debug.logError(e, MODULE);
results = ServiceUtil.returnError(e.getMessage());
}
@@ -376,7 +332,6 @@ public class PdfSurveyServices {
String contentId = (String) context.get("contentId");
String surveyId = null;
- Document document = new Document();
try {
if (UtilValidate.isNotEmpty(surveyResponseId)) {
GenericValue surveyResponse =
EntityQuery.use(delegator).from("SurveyResponse").where("surveyResponseId",
@@ -395,9 +350,7 @@ public class PdfSurveyServices {
}
}
- ByteArrayOutputStream baos = new ByteArrayOutputStream();
- PdfWriter.getInstance(document, baos);
-
+ List<String> lines = new ArrayList<>();
List<GenericValue> responses =
EntityQuery.use(delegator).from("SurveyResponseAnswer").where("surveyResponseId",
surveyResponseId).queryList();
for (GenericValue surveyResponseAnswer : responses) {
@@ -423,13 +376,11 @@ public class PdfSurveyServices {
} else {
value = surveyResponseAnswer.getString("textResponse");
}
- Chunk chunk = new Chunk(surveyQuestion.getString("question") +
": " + value);
- Paragraph p = new Paragraph(chunk);
- document.add(p);
+ lines.add(surveyQuestion.getString("question") + ": " + value);
}
- ByteBuffer outByteBuffer = ByteBuffer.wrap(baos.toByteArray());
+ ByteBuffer outByteBuffer = ByteBuffer.wrap(writeTextPdf(lines));
results.put("outByteBuffer", outByteBuffer);
- } catch (GenericEntityException | DocumentException e) {
+ } catch (GenericEntityException | IOException e) {
Debug.logError(e, MODULE);
results = ServiceUtil.returnError(e.getMessage());
}
@@ -590,4 +541,89 @@ public class PdfSurveyServices {
}
return inputByteBuffer;
}
+
+ /**
+ * Writes the lines in a new A4 PDF, in 12 points Helvetica, wrapping long
lines and adding pages as needed. Characters
+ * that Helvetica can't show are replaced by "?".
+ * @param lines the text lines
+ * @return the PDF content
+ * @throws IOException if the PDF can't be written
+ */
+ private static byte[] writeTextPdf(List<String> lines) throws IOException {
+ PDFont font = new PDType1Font(Standard14Fonts.FontName.HELVETICA);
+ float fontSize = 12;
+ float leading = 16;
+ float margin = 36;
+ PDRectangle pageSize = PDRectangle.A4;
+ List<String> pdfLines = new ArrayList<>();
+ for (String line : lines) {
+ for (String paragraph : line.replace('\t', ' ').split("\\R", -1)) {
+ pdfLines.addAll(wrapLine(font, fontSize, pageSize.getWidth() -
2 * margin, toShowableText(font, paragraph)));
+ }
+ }
+ int linesPerPage = (int) ((pageSize.getHeight() - 2 * margin) /
leading);
+ try (PDDocument pdDocument = new PDDocument()) {
+ int start = 0;
+ do {
+ PDPage page = new PDPage(pageSize);
+ pdDocument.addPage(page);
+ try (PDPageContentStream content = new
PDPageContentStream(pdDocument, page)) {
+ content.beginText();
+ content.setFont(font, fontSize);
+ content.setLeading(leading);
+ content.newLineAtOffset(margin, pageSize.getHeight() -
margin - fontSize);
+ for (String line : pdfLines.subList(start, Math.min(start
+ linesPerPage, pdfLines.size()))) {
+ content.showText(line);
+ content.newLine();
+ }
+ content.endText();
+ }
+ start += linesPerPage;
+ } while (start < pdfLines.size());
+ ByteArrayOutputStream baos = new ByteArrayOutputStream();
+ pdDocument.save(baos);
+ return baos.toByteArray();
+ }
+ }
+
+ private static String toShowableText(PDFont font, String text) {
+ StringBuilder showableText = new StringBuilder();
+ text.codePoints().forEach(codePoint -> {
+ String character = new String(Character.toChars(codePoint));
+ try {
+ font.encode(character);
+ showableText.append(character);
+ } catch (IOException | IllegalArgumentException e) {
+ showableText.append('?');
+ }
+ });
+ return showableText.toString();
+ }
+
+ private static List<String> wrapLine(PDFont font, float fontSize, float
width, String text) throws IOException {
+ List<String> lines = new ArrayList<>();
+ StringBuilder line = new StringBuilder();
+ for (String word : text.split(" ", -1)) {
+ String candidate = line.length() == 0 ? word : line + " " + word;
+ if (font.getStringWidth(candidate) / 1000 * fontSize <= width) {
+ line.setLength(0);
+ line.append(candidate);
+ continue;
+ }
+ if (line.length() > 0) {
+ lines.add(line.toString());
+ line.setLength(0);
+ }
+ // a word longer than the line is cut
+ for (char character : word.toCharArray()) {
+ if (line.length() > 0 && font.getStringWidth(line.toString() +
character) / 1000 * fontSize > width) {
+ lines.add(line.toString());
+ line.setLength(0);
+ }
+ line.append(character);
+ }
+ }
+ lines.add(line.toString());
+ return lines;
+ }
}
diff --git a/dependencies.gradle b/dependencies.gradle
index af5635ce92..adf6c8f670 100644
--- a/dependencies.gradle
+++ b/dependencies.gradle
@@ -26,7 +26,6 @@ dependencies {
implementation libs.owasp.java.html.sanitizer
implementation libs.libphonenumber
implementation libs.icu4j
- implementation libs.openpdf
implementation libs.jakarta.mail.api
implementation libs.angus.mail
implementation libs.utils.mail.dkim
diff --git a/framework/security/config/security.properties
b/framework/security/config/security.properties
index 71745c21a2..5d0c06ba79 100644
--- a/framework/security/config/security.properties
+++ b/framework/security/config/security.properties
@@ -401,6 +401,11 @@ allowZUGFeRDCompliantUpload=true
# Allow uploading ZUGFeRD compliant files with no XML header and no schema
(aka DOCTYPE)
allowZUGFeRDnotSecure=false
+# Uploaded pdf files are rejected if they contain any JavaScript code.
+# Set to true to still accept pdf forms whose fields have keystroke, format,
validate or calculate scripts
+# (Acrobat adds them to any formatted, validated or calculated field). These
scripts run only when a user edits the field.
+allowPdfFormFieldScripts=false
+
#-- Popup last-visited time from database after user has logged in.
#-- So users can know of any unauthorised access to their accounts.
#-- Default is false.
diff --git
a/framework/security/src/main/java/org/apache/ofbiz/security/SecuredUpload.java
b/framework/security/src/main/java/org/apache/ofbiz/security/SecuredUpload.java
index c860af85c8..0a210479d4 100644
---
a/framework/security/src/main/java/org/apache/ofbiz/security/SecuredUpload.java
+++
b/framework/security/src/main/java/org/apache/ofbiz/security/SecuredUpload.java
@@ -41,12 +41,15 @@ import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.StandardOpenOption;
+import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Base64;
import java.util.Collection;
import java.util.Collections;
+import java.util.Deque;
import java.util.HashSet;
+import java.util.IdentityHashMap;
import java.util.Iterator;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
@@ -85,6 +88,12 @@ import org.apache.ofbiz.base.util.UtilXml;
import org.apache.ofbiz.entity.Delegator;
import org.apache.ofbiz.entity.util.EntityUtilProperties;
import org.apache.pdfbox.Loader;
+import org.apache.pdfbox.cos.COSArray;
+import org.apache.pdfbox.cos.COSBase;
+import org.apache.pdfbox.cos.COSDictionary;
+import org.apache.pdfbox.cos.COSDocument;
+import org.apache.pdfbox.cos.COSName;
+import org.apache.pdfbox.cos.COSObject;
import org.apache.pdfbox.io.RandomAccessReadBufferedFile;
import org.apache.pdfbox.pdmodel.PDDocument;
//import org.apache.pdfbox.pdmodel.PDDocument;
@@ -115,7 +124,6 @@ import com.drew.imaging.ImageMetadataReader;
import com.drew.imaging.ImageProcessingException;
import com.drew.metadata.Directory;
import com.drew.metadata.Tag;
-import com.lowagie.text.pdf.PdfReader;
public class SecuredUpload {
@@ -957,7 +965,7 @@ public class SecuredUpload {
return false;
}
// Load stream in PDF parser — just probe whether the file is a
valid PDF
- try (PdfReader ignored = new PdfReader(file.getAbsolutePath())) {
+ try (PDDocument ignored = Loader.loadPDF(file)) {
return true;
}
} catch (Exception e) {
@@ -966,6 +974,152 @@ public class SecuredUpload {
}
}
+ /** Form field additional-action triggers (keystroke, format, validate,
calculate) that run only when the field is edited. */
+ private static final Set<COSName> FORM_FIELD_TRIGGERS = Set.of(COSName.K,
COSName.F, COSName.V, COSName.C);
+
+ /**
+ * Looks for JavaScript anywhere a PDF viewer could run it: the
document-level JavaScript names tree, and any action
+ * (OpenAction, additional actions of the document, pages, annotations and
form fields, Next chains...) that is a
+ * JavaScript action or carries JavaScript code. Every object reachable
from the trailer is checked, then every
+ * object listed in the cross-reference table, even if nothing refers to
it.
+ * @param pdDocument the loaded PDF document
+ * @param formFieldScriptsAllowed when true, the scripts of the keystroke,
format, validate and calculate additional
+ * actions of form fields are not reported. They run only when a
user edits the field, and Acrobat adds them to
+ * any formatted, validated or calculated field. The same scripts
are still reported if they are also used
+ * from anywhere else in the document.
+ * @return true if JavaScript is found
+ */
+ private static boolean containsJavaScript(PDDocument pdDocument, boolean
formFieldScriptsAllowed) {
+ COSDocument cosDocument = pdDocument.getDocument();
+ Set<COSBase> scriptExemptFields = formFieldScriptsAllowed ?
getAcroFormFields(pdDocument) : Collections.emptySet();
+ Set<COSBase> visited = Collections.newSetFromMap(new
IdentityHashMap<>());
+ if (findJavaScript(List.of(cosDocument.getTrailer()),
scriptExemptFields, Collections.emptySet(), visited)) {
+ return true;
+ }
+ List<COSBase> xrefObjects = new ArrayList<>();
+ cosDocument.getXrefTable().keySet().forEach(key ->
xrefObjects.add(cosDocument.getObjectFromPool(key)));
+ return findJavaScript(xrefObjects, scriptExemptFields,
getExemptFieldScripts(scriptExemptFields), visited);
+ }
+
+ /**
+ * Walks the PDF objects reachable from the given ones, without recursion,
and stops at the first JavaScript found.
+ * @param roots the objects to start from
+ * @param scriptExemptFields the form fields whose keystroke, format,
validate and calculate additional actions are not
+ * checked; empty when form field scripts are not allowed
+ * @param skipped objects that are not checked when reached
+ * @param visited the objects already checked, updated by this walk
+ * @return true if JavaScript is found
+ */
+ private static boolean findJavaScript(List<COSBase> roots, Set<COSBase>
scriptExemptFields, Set<COSBase> skipped,
+ Set<COSBase> visited) {
+ Deque<COSBase> toVisit = new ArrayDeque<>();
+ roots.forEach(root -> pushIfNotNull(toVisit, root));
+ while (!toVisit.isEmpty()) {
+ COSBase base = dereference(toVisit.pop());
+ if (base == null || skipped.contains(base) || !visited.add(base)) {
+ continue;
+ }
+ if (base instanceof COSDictionary) {
+ COSDictionary dictionary = (COSDictionary) base;
+ if (dictionary.containsKey(COSName.JS) ||
dictionary.containsKey(COSName.JAVA_SCRIPT)
+ ||
COSName.JAVA_SCRIPT.equals(dictionary.getCOSName(COSName.S))) {
+ return true;
+ }
+ for (Map.Entry<COSName, COSBase> entry :
dictionary.entrySet()) {
+ COSBase additionalActions = dereference(entry.getValue());
+ if (COSName.AA.equals(entry.getKey()) &&
scriptExemptFields.contains(dictionary)
+ && additionalActions instanceof COSDictionary) {
+ // The additional-actions dictionary itself is not
marked as visited, so it is checked in full if it
+ // is also reached from somewhere else
+ for (Map.Entry<COSName, COSBase> action :
((COSDictionary) additionalActions).entrySet()) {
+ if
(!FORM_FIELD_TRIGGERS.contains(action.getKey())) {
+ pushIfNotNull(toVisit, action.getValue());
+ }
+ }
+ } else {
+ pushIfNotNull(toVisit, entry.getValue());
+ }
+ }
+ } else if (base instanceof COSArray) {
+ COSArray array = (COSArray) base;
+ array.forEach(element -> pushIfNotNull(toVisit, element));
+ }
+ }
+ return false;
+ }
+
+ /**
+ * @return the dictionaries of the form fields in the document's AcroForm
field tree. Dictionaries typed as something
+ * other than an annotation (a page, the catalog...) are left out,
so that their own additional actions, like a
+ * page's close action (/C), are never taken for form field ones.
+ */
+ private static Set<COSBase> getAcroFormFields(PDDocument pdDocument) {
+ Set<COSBase> acroFormFields = Collections.newSetFromMap(new
IdentityHashMap<>());
+ Set<COSBase> visited = Collections.newSetFromMap(new
IdentityHashMap<>());
+ Deque<COSBase> toVisit = new ArrayDeque<>();
+ COSDictionary acroForm =
pdDocument.getDocumentCatalog().getCOSObject().getCOSDictionary(COSName.ACRO_FORM);
+ if (acroForm != null) {
+ pushIfNotNull(toVisit,
acroForm.getDictionaryObject(COSName.FIELDS));
+ }
+ while (!toVisit.isEmpty()) {
+ COSBase base = dereference(toVisit.pop());
+ if (base == null || !visited.add(base)) {
+ continue;
+ }
+ if (base instanceof COSArray) {
+ COSArray array = (COSArray) base;
+ array.forEach(element -> pushIfNotNull(toVisit, element));
+ } else if (base instanceof COSDictionary) {
+ COSDictionary field = (COSDictionary) base;
+ COSName type = field.getCOSName(COSName.TYPE);
+ if (type == null || COSName.ANNOT.equals(type)) {
+ acroFormFields.add(field);
+ }
+ pushIfNotNull(toVisit,
field.getDictionaryObject(COSName.KIDS));
+ }
+ }
+ return acroFormFields;
+ }
+
+ /**
+ * @return every object reachable from the keystroke, format, validate and
calculate additional actions of the given
+ * script exempt form fields
+ */
+ private static Set<COSBase> getExemptFieldScripts(Set<COSBase>
scriptExemptFields) {
+ Set<COSBase> exemptFieldScripts = Collections.newSetFromMap(new
IdentityHashMap<>());
+ Deque<COSBase> toVisit = new ArrayDeque<>();
+ for (COSBase scriptExemptField : scriptExemptFields) {
+ COSBase additionalActions = dereference(((COSDictionary)
scriptExemptField).getItem(COSName.AA));
+ if (additionalActions instanceof COSDictionary) {
+ FORM_FIELD_TRIGGERS.forEach(trigger -> pushIfNotNull(toVisit,
((COSDictionary) additionalActions).getItem(trigger)));
+ }
+ }
+ while (!toVisit.isEmpty()) {
+ COSBase base = dereference(toVisit.pop());
+ if (base == null || !exemptFieldScripts.add(base)) {
+ continue;
+ }
+ if (base instanceof COSDictionary) {
+ COSDictionary dictionary = (COSDictionary) base;
+ dictionary.getValues().forEach(value -> pushIfNotNull(toVisit,
value));
+ } else if (base instanceof COSArray) {
+ COSArray array = (COSArray) base;
+ array.forEach(element -> pushIfNotNull(toVisit, element));
+ }
+ }
+ return exemptFieldScripts;
+ }
+
+ private static COSBase dereference(COSBase base) {
+ return base instanceof COSObject ? ((COSObject) base).getObject() :
base;
+ }
+
+ private static void pushIfNotNull(Deque<COSBase> toVisit, COSBase base) {
+ if (base != null) {
+ toVisit.push(base);
+ }
+ }
+
/**
* @param fileName
* @return true if it's a safe PDF file: is a PDF, and contains only 1
embedded readable (valid and secure) XML file (zUGFeRD)
@@ -982,18 +1136,16 @@ public class SecuredUpload {
return safeState;
}
// Load stream in PDF parser
- String jsCode;
- try (PdfReader reader = new PdfReader(file.getAbsolutePath())) {
- // Check 1: detect if the document contains any JavaScript code
- jsCode = reader.getJavaScript();
- }
- if (!Objects.isNull(jsCode)) {
- return safeState;
- }
- // OK no JS code, pass to check 2: detect if the document has any
embedded files
PDEmbeddedFilesNameTreeNode efTree = null;
try (PDDocument pdDocument = Loader.loadPDF(new
RandomAccessReadBufferedFile(fileName))) {
- //PDDocument.load(file)) {
+ // Check 1: detect if the document contains any JavaScript code
+ boolean formFieldScriptsAllowed =
UtilProperties.getPropertyAsBoolean("security", "allowPdfFormFieldScripts",
false);
+ if (containsJavaScript(pdDocument, formFieldScriptsAllowed)) {
+ Debug.logInfo("The file " + file.getAbsolutePath() + "
contains JavaScript code. "
+ + "For security reason it's not accepted as a PDF
file", MODULE);
+ return safeState;
+ }
+ // OK no JS code, pass to check 2: detect if the document has
any embedded files
PDDocumentNameDictionary names = new
PDDocumentNameDictionary(pdDocument.getDocumentCatalog());
efTree = names.getEmbeddedFiles();
}
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 6a06c318e5..b0aa4185af 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -25,8 +25,6 @@ ez-vcard = "0.12.2"
owasp-java-html-sanitizer = "20240325.1"
libphonenumber = "8.13.55"
icu4j = "76.1"
-# This is the last version with com.lowagie.text that is used (only) by
PdfSurveyServices class.
-openpdf = "1.4.2"
jakarta-mail-api = "2.1.5"
angus-mail = "2.0.5"
utils-mail-dkim = "3.3.0"
@@ -119,7 +117,6 @@ ez-vcard = { module = "com.googlecode.ez-vcard:ez-vcard",
version.ref = "ez-vcar
owasp-java-html-sanitizer = { module =
"com.googlecode.owasp-java-html-sanitizer:owasp-java-html-sanitizer",
version.ref = "owasp-java-html-sanitizer" }
libphonenumber = { module = "com.googlecode.libphonenumber:libphonenumber",
version.ref = "libphonenumber" }
icu4j = { module = "com.ibm.icu:icu4j", version.ref = "icu4j" }
-openpdf = { module = "com.github.librepdf:openpdf", version.ref = "openpdf" }
jakarta-mail-api = { module = "jakarta.mail:jakarta.mail-api", version.ref =
"jakarta-mail-api" }
angus-mail = { module = "org.eclipse.angus:angus-mail", version.ref =
"angus-mail" }
utils-mail-dkim = { module = "org.simplejavamail:utils-mail-dkim", version.ref
= "utils-mail-dkim" }