Skip to content

Commit

Permalink
For #6737: basic max files support
Browse files Browse the repository at this point in the history
  • Loading branch information
avernet committed Jan 31, 2025
1 parent 7c05baf commit 087b346
Show file tree
Hide file tree
Showing 25 changed files with 209 additions and 28 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package org.orbeon.datatypes


sealed trait MaximumCurrentFiles

object MaximumCurrentFiles {

case class LimitedFiles (current: Int, max: Int) extends MaximumCurrentFiles
case object UnlimitedFiles extends MaximumCurrentFiles

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package org.orbeon.datatypes

import org.orbeon.oxf.util.StringUtils.OrbeonStringOps

sealed trait MaximumFiles

object MaximumFiles {

case class LimitedFiles (count: Int) extends MaximumFiles
case object UnlimitedFiles extends MaximumFiles

// Return `None` if blank, not a long number, or lower than -1
def unapply(s: String): Option[MaximumFiles] =
s.trimAllToOpt flatMap (_.toIntOption match {
case Some(i) if i >= 0 => Some(LimitedFiles(i))
case Some(i) if i == -1 => Some(UnlimitedFiles)
case _ => None
})

}
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,9 @@ import org.orbeon.datatypes.{Mediatype, MediatypeRange}

sealed trait FileRejectionReason
object FileRejectionReason {
case object EmptyFile extends FileRejectionReason
case object EmptyFile extends FileRejectionReason
case class SizeTooLarge (permitted: Long, actual: Long) extends FileRejectionReason
case class TooManyFiles (permitted: Int) extends FileRejectionReason
case class DisallowedMediatype(clientFilenameOpt: Option[String], permitted: Set[MediatypeRange], actual: Option[Mediatype]) extends FileRejectionReason
case class FailedFileScan (fieldName: String, message: Option[String]) extends FileRejectionReason
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -438,6 +438,9 @@
<label>Maximum Aggregate Attachment Size</label>
<hint>Applies to all attachment controls in this form</hint>
</attachment-max-size-aggregate-per-form>
<attachment-max-files-per-control>
<label>Maximum Number of Files per Control</label>
</attachment-max-files-per-control>
<attachment-mediatypes>
<label>Allowed File Types</label>
<hint>For example "image/* application/pdf"</hint>
Expand Down Expand Up @@ -699,6 +702,7 @@
<item id="upload-max-size-per-file" multiple="false">Maximum Attachment Size</item>
<item id="upload-max-size-per-file" multiple="true">Maximum Attachment Size per File</item>
<item id="upload-max-size-aggregate-per-control">Maximum Aggregate Attachment Size</item>
<item id="upload-max-files-per-control">Maximum Number of Files per Control</item>
<item id="upload-mediatypes">Supported File Types</item>
<item id="excluded-dates">Dates to Exclude</item>
</constraint>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -947,7 +947,7 @@

<xf:var
name="multiple-attachments-constraints"
value="'upload-max-size-per-file', 'upload-max-size-aggregate-per-control', 'upload-mediatypes'"/>
value="'upload-max-size-per-file', 'upload-max-size-aggregate-per-control', 'upload-max-files-per-control', 'upload-mediatypes'"/>

<xf:var
name="noargs-constraints"
Expand Down Expand Up @@ -1678,6 +1678,8 @@
$resources/dialog-form-settings/attachment-max-size-per-file/label
else if ($validation-type = 'upload-max-size-aggregate-per-control') then
$resources/dialog-form-settings/attachment-max-size-aggregate-per-control/label
else if ($validation-type = 'upload-max-files-per-control') then
$resources/dialog-form-settings/attachment-max-files-per-control/label
else
$resources/dialog-control-settings/integer-argument/label"/>
<xf:alert ref="$resources/dialog-control-settings/integer-argument/alert"/>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -372,6 +372,14 @@
]
)[1]"/>

<xsl:variable
name="valid-attachment-max-files-per-control-or-empty"
as="xs:string?"
select="
p:property(string-join(('oxf.fr.detail.attachment.max-files-per-control', $app, $form), '.'))[
. castable as xs:integer and xs:integer(.) ge 1
]"/>

<xsl:variable
name="attachment-mediatypes"
as="xs:string"
Expand Down
11 changes: 11 additions & 0 deletions form-runner/jvm/src/main/resources/apps/fr/components/view.xsl
Original file line number Diff line number Diff line change
Expand Up @@ -1183,6 +1183,17 @@
event('actual')
)
)
else if (event('error-type') = 'max-files-per-control') then
xxf:format-message(
xxf:r(
'detail.messages.upload-error-max-files-per-control',
'fr-fr-resources'
),
(
event('permitted'),
event('actual')
)
)
else if (event('error-type') = 'mediatype-error') then
xxf:format-message(
xxf:r(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -297,6 +297,7 @@
<upload-error>There was an error during the upload. Please try again.</upload-error>
<upload-error-empty-file>The file uploaded is empty.</upload-error-empty-file>
<upload-error-size>The file uploaded is too large. The maximum size allowed is {0,number,integer} bytes and the actual size received is {1,number,integer} bytes.</upload-error-size>
<upload-error-max-files-per-control>Maximum number of files exceeded. Please select no more than {0} files.</upload-error-max-files-per-control>
<upload-error-mediatype>The file uploaded is of an incorrect type: {0}</upload-error-mediatype>
<upload-error-file-scan>The file uploaded was rejected by the file scanner. Please make sure this is not an infected file. Here is the file scanner message: "{0}".</upload-error-file-scan>
<lost-attachments>Some file attachments were lost due to a server issue. Please check all your attachments and upload them again if needed.</lost-attachments>
Expand Down
2 changes: 1 addition & 1 deletion form-runner/jvm/src/main/resources/apps/fr/unroll-form.xpl
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@
<!-- Handle XInclude -->
<p:processor name="oxf:xinclude">
<p:input name="config" href="#after-components"/>
<p:output name="data" ref="data"/>
<p:output name="data" ref="data" debug="xxx"/>
</p:processor>

</p:config>
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,17 @@
return
xxf:upload-max-size-aggregate-per-control(xs:integer($mip)),
true()
)[1]
and
(
for $mip in
xxf:custom-mip(
$binding,
'upload-max-files-per-control'
)[. castable as xs:integer]
return
xxf:upload-max-files-per-control(xs:integer($mip)),
true()
)[1]"/>

<xf:instance id="orbeon-resources" xxf:readonly="true" xxf:exclude-result-prefixes="#all">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,14 +17,15 @@ import org.orbeon.oxf.xforms.XFormsControls
import org.orbeon.oxf.xforms.control.XFormsComponentControl
import org.orbeon.oxf.xforms.control.controls.XFormsUploadControl
import org.orbeon.oxf.xforms.upload.UploadSupport
import org.orbeon.saxon.om.NodeInfo
import org.orbeon.scaxon.SimplePath.NodeInfoOps


object FormRunnerUploadSupport extends UploadSupport {

def currentUploadSizeAggregateForControl(controls: XFormsControls, controlEffectiveId: String): Option[Long] = {
// Return None if we couldn't find an XFormsUploadControl for the given control effective ID...
val uploadControlOpt = controls.getCurrentControlTree.findControl(controlEffectiveId).collect { case c: XFormsUploadControl => c }
val uploadControlOpt = getUploadControlOpt(controls, controlEffectiveId)

// ...or if we couldn't compute the size of the files currently attached to the corresponding bound node
uploadControlOpt.flatMap(currentUploadSize)
Expand All @@ -41,17 +42,35 @@ object FormRunnerUploadSupport extends UploadSupport {
}
}

def currentUploadFilesForControl(controls: XFormsControls, controlEffectiveId: String): Option[Int] = {
val uploadControlOpt = getUploadControlOpt(controls, controlEffectiveId)
uploadControlOpt.flatMap { uploadControl =>
getAttachmentBoundNodeOpt(uploadControl).map { boundNode =>
boundNode.child("_").size
}
}
}

private def getUploadControlOpt(
controls : XFormsControls,
controlEffectiveId : String
): Option[XFormsUploadControl] =
controls.getCurrentControlTree
.findControl(controlEffectiveId)
.collect { case c: XFormsUploadControl => c }

private def getAttachmentBoundNodeOpt(uploadControl: XFormsUploadControl): Option[NodeInfo] =
uploadControl.container.associatedControlOpt
.collect { case c: XFormsComponentControl => c }
.filter(_.staticControl.element.getName == "attachment")
.flatMap(_.boundNodeOpt)

private def currentUploadSize(uploadControl: XFormsUploadControl): Option[Long] = {
val sizes = for {
componentControl <- uploadControl.container.associatedControlOpt.collect { case c: XFormsComponentControl => c }
if componentControl.staticControl.element.getName == "attachment"
boundNode <- componentControl.boundNodeOpt
} yield {
getAttachmentBoundNodeOpt(uploadControl).map { boundNode =>
val singleAttachmentSizeOpt = boundNode.attValueOpt("size")
val multipleAttachmentsSizes = boundNode.child("_").flatMap(_.attValueOpt("size"))
(singleAttachmentSizeOpt.toSeq ++ multipleAttachmentsSizes).filter(_.nonEmpty).map(_.toLong)
val sizes = (singleAttachmentSizeOpt.toSeq ++ multipleAttachmentsSizes).filter(_.nonEmpty).map(_.toLong)
sizes.sum
}

sizes.map(_.sum)
}
}
1 change: 1 addition & 0 deletions src/main/resources/config/properties-form-runner.xml
Original file line number Diff line number Diff line change
Expand Up @@ -346,6 +346,7 @@
<property as="xs:string" name="oxf.fr.detail.attachment.max-size-per-file.*.*" value=""/>
<property as="xs:string" name="oxf.fr.detail.attachment.max-size-aggregate-per-control.*.*" value=""/>
<property as="xs:string" name="oxf.fr.detail.attachment.max-size-aggregate-per-form.*.*" value=""/>
<property as="xs:string" name="oxf.fr.detail.attachment.max-files-per-control.*.*" value=""/>
<property as="xs:string" name="oxf.fr.detail.attachment.mediatypes.*.*" value="*/*"/>

<!-- PDF fonts -->
Expand Down
5 changes: 5 additions & 0 deletions src/main/scala/org/orbeon/oxf/util/Multipart.scala
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,10 @@ import scala.jdk.CollectionConverters.*
import scala.util.control.NonFatal


case class TooManyFilesException(
permitted: Int
) extends FileUploadException

case class DisallowedMediatypeException(
clientFilenameOpt: Option[String],
permitted : Set[MediatypeRange],
Expand Down Expand Up @@ -309,6 +313,7 @@ object Multipart {
.collect {
case _: EmptyFileException => FileRejectionReason.EmptyFile
case root: SizeLimitExceededException => FileRejectionReason.SizeTooLarge(root.getPermittedSize, root.getActualSize)
case TooManyFilesException(permitted) => FileRejectionReason.TooManyFiles(permitted)
case DisallowedMediatypeException(clientFilenameOpt, permitted, actual) => FileRejectionReason.DisallowedMediatype(clientFilenameOpt, permitted, actual)
case FileScanException(fieldName, fileScanResult) => FileRejectionReason.FailedFileScan(fieldName, fileScanResult.message)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ object XFormsProperties {
val UploadMaxSizePerFileProperty = "upload.max-size-per-file"
val UploadMaxSizeAggregatePerControlProperty = "upload.max-size-aggregate-per-control"
val UploadMaxSizeAggregatePerFormProperty = "upload.max-size-aggregate-per-form"
val UploadMaxFilesPerControlProperty = "upload.max-files-per-control"
val UploadMediatypesProperty = "upload.mediatypes"

val ExternalEventsProperty = "external-events"
Expand Down Expand Up @@ -199,6 +200,7 @@ object XFormsProperties {
PropertyDefinition(UploadMaxSizePerFileProperty, "", propagateToClient = false), // blank default (see #2956)
PropertyDefinition(UploadMaxSizeAggregatePerControlProperty, "", propagateToClient = false),
PropertyDefinition(UploadMaxSizeAggregatePerFormProperty, "", propagateToClient = false),
PropertyDefinition(UploadMaxFilesPerControlProperty, "", propagateToClient = false),
PropertyDefinition(UploadMediatypesProperty, "*/*", propagateToClient = false),
PropertyDefinition(ExternalEventsProperty, "", propagateToClient = false),
PropertyDefinition(OptimizeGetAllProperty, true, propagateToClient = false),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -683,6 +683,12 @@ trait XXFormsEnvFunctions extends OrbeonFunctionLibrary {
evaluateAndSetConstraint(ValidationFunctionNames.UploadMaxSizePerFile, constraintOpt, _ => true)
}

@XPathFunction
def uploadMaxFilesPerControl(constraintOpt: Option[Long])(implicit xpc: XPathContext): Boolean = {
// For now, don't actually validate
evaluateAndSetConstraint(ValidationFunctionNames.UploadMaxSizePerFile, constraintOpt, _ => true)
}

@XPathFunction
def uploadMaxSizeAggregatePerControl(constraintOpt: Option[Long])(implicit xpc: XPathContext): Boolean = {
// For now, don't actually validate, see #6064
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,15 @@ class UploadMaxSizeAggregatePerControlValidation extends LongValidationFunction
}
}

class UploadMaxFilesPerControlValidation extends LongValidationFunction {
val propertyName = ValidationFunctionNames.UploadMaxFilesPerControl

def evaluate(value: String, constraintOpt: Option[Long]) = constraintOpt match {
case Some(constraint) => true // for now, don't actually validate
case None => true
}
}

class UploadMediatypesValidation extends StringValidationFunction {

val propertyName = ValidationFunctionNames.UploadMediatypes
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -335,6 +335,10 @@ trait XXFormsEnvFunctions extends OrbeonFunctionLibrary {
Arg(INTEGER, ALLOWS_ZERO_OR_ONE)
)

Fun(ValidationFunctionNames.UploadMaxFilesPerControl, classOf[UploadMaxFilesPerControlValidation], op = 0, min = 1, BOOLEAN, EXACTLY_ONE,
Arg(INTEGER, ALLOWS_ZERO_OR_ONE)
)

Fun(ValidationFunctionNames.UploadMediatypes, classOf[UploadMediatypesValidation], op = 0, min = 1, BOOLEAN, EXACTLY_ONE,
Arg(STRING, ALLOWS_ZERO_OR_ONE)
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ package org.orbeon.oxf.xforms.upload

import org.apache.commons.fileupload.disk.DiskFileItem
import org.apache.commons.fileupload.{FileItem, FileItemHeaders, UploadContext}
import org.orbeon.datatypes.MaximumSize
import org.orbeon.datatypes.{MaximumCurrentFiles, MaximumFiles, MaximumSize}
import org.orbeon.datatypes.MaximumSize.{LimitedSize, UnlimitedSize}
import org.orbeon.exception.OrbeonFormatter
import org.orbeon.io.IOUtils.{runQuietly, useAndClose}
Expand All @@ -34,6 +34,7 @@ import org.orbeon.oxf.util.StringUtils.*
import org.orbeon.oxf.xforms.XFormsContainingDocumentSupport.*
import org.orbeon.oxf.xforms.XFormsGlobalProperties
import org.orbeon.oxf.xforms.control.XFormsValueControl
import org.orbeon.oxf.xforms.control.controls.XFormsUploadControl
import org.orbeon.oxf.xforms.upload.api.java.{FileScan2, FileScanProvider2, FileScanResult as JFileScanResult}
import org.orbeon.oxf.xforms.upload.api.{FileScan, FileScanProvider, FileScanStatus}
import org.orbeon.xforms.Constants
Expand All @@ -58,7 +59,7 @@ object UploaderServer extends UploaderServer {
actualSize : Long
)

protected def getUploadConstraintsForControl(uuid: String, controlEffectiveId: String): Try[((MaximumSize, AllowedMediatypes), URI)] =
protected def getUploadConstraintsForControl(uuid: String, controlEffectiveId: String): Try[((MaximumSize, MaximumCurrentFiles, AllowedMediatypes), URI)] =
withDocumentAcquireLock(
uuid = uuid,
timeout = XFormsGlobalProperties.uploadXFormsAccessTimeout
Expand All @@ -83,7 +84,7 @@ trait UploaderServer {

protected implicit val logger: slf4j.Logger = LoggerFactory.createLogger("org.orbeon.xforms.upload").logger

protected def getUploadConstraintsForControl(uuid: String, controlEffectiveId: String): Try[((MaximumSize, AllowedMediatypes), URI)]
protected def getUploadConstraintsForControl(uuid: String, controlEffectiveId: String): Try[((MaximumSize, MaximumCurrentFiles, AllowedMediatypes), URI)]
protected def fileScanProviderOpt: Option[Either[FileScanProvider2, FileScanProvider]]

def processUpload(request: Request): (List[UploadResponse], Option[Throwable]) = {
Expand Down Expand Up @@ -122,7 +123,7 @@ trait UploaderServer {
setMaxBytes = trustedUploadContext.setMaxBytes,
session = session
) {
def getUploadConstraintsForControl(uuid: String, controlEffectiveId: String): Try[((MaximumSize, AllowedMediatypes), URI)] =
def getUploadConstraintsForControl(uuid: String, controlEffectiveId: String): Try[((MaximumSize, MaximumCurrentFiles, AllowedMediatypes), URI)] =
selfUploaderServer.getUploadConstraintsForControl(uuid, controlEffectiveId)
}
),
Expand Down Expand Up @@ -351,7 +352,7 @@ trait UploaderServer {
// Session keys created, for cleanup
private val sessionKeys = m.ListBuffer[String]()

def getUploadConstraintsForControl(uuid: String, controlEffectiveId: String): Try[((MaximumSize, AllowedMediatypes), URI)]
def getUploadConstraintsForControl(uuid: String, controlEffectiveId: String): Try[((MaximumSize, MaximumCurrentFiles, AllowedMediatypes), URI)]

// Can throw
def fileItemStarting(fieldName: String, fileItem: DiskFileItem): (Option[MaximumSize], AllowedMediatypes) = {
Expand All @@ -362,7 +363,7 @@ trait UploaderServer {
if (progressOpt.isDefined)
throw new IllegalStateException("more than one file provided")

val ((maxUploadSizeForControl, allowedMediatypeRangesForControl), requestUri) =
val ((maxUploadSizeForControl, maxFilesForControl, allowedMediatypeRangesForControl), requestUri) =
getUploadConstraintsForControl(uuid, fieldName).get // TODO: will throw if this is a `Failure`

val fileItemHeadersOpt = Option(fileItem.getHeaders)
Expand Down Expand Up @@ -411,6 +412,15 @@ trait UploaderServer {
}
}

// Handle max files
maxFilesForControl match {
case MaximumCurrentFiles.UnlimitedFiles =>
// Nothing to do, we're good
case MaximumCurrentFiles.LimitedFiles(current, max) =>
if (current + 1 > max)
throw TooManyFilesException(permitted = max)
}

// Handle mediatypes
checkMediatypesThrowIfDisallowed(
allowedMediatypeRanges = allowedMediatypeRangesForControl,
Expand Down
Loading

0 comments on commit 087b346

Please sign in to comment.