This is an automated email from the ASF dual-hosted git repository. lukaszlenart pushed a commit to branch feature/WW-5585-dynamic-file-upload-params in repository https://gitbox.apache.org/repos/asf/struts.git
commit cf84e2e04ade6ea2a5e318c6a8d1231e053be0cd Author: Lukasz Lenart <[email protected]> AuthorDate: Sun Nov 16 10:09:39 2025 +0100 feat(fileupload): implement dynamic parameter evaluation for file upload validation - Add WithLazyParams interface to ActionFileUploadInterceptor - Enable runtime evaluation of ${...} expressions for validation rules - Add comprehensive JavaDoc with static and dynamic examples - Add 7 new unit tests for dynamic parameter scenarios - Create DynamicFileUploadAction showcase with document/image modes - All 23 tests pass successfully 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <[email protected]> --- .../fileupload/DynamicFileUploadAction.java | 214 ++++++++ apps/showcase/src/main/resources/log4j2.xml | 3 + .../src/main/resources/struts-fileupload.xml | 69 ++- apps/showcase/src/main/resources/struts.xml | 9 +- .../src/main/webapp/WEB-INF/decorators/main.jsp | 4 + .../WEB-INF/fileupload/dynamic-upload-success.jsp | 108 ++++ .../webapp/WEB-INF/fileupload/dynamic-upload.jsp | 109 ++++ .../interceptor/ActionFileUploadInterceptor.java | 79 ++- .../ActionFileUploadInterceptorTest.java | 297 +++++++++++ .../2025-10-22-dynamic-file-upload-validation.md | 590 +++++++++++++++++++++ 10 files changed, 1450 insertions(+), 32 deletions(-) diff --git a/apps/showcase/src/main/java/org/apache/struts2/showcase/fileupload/DynamicFileUploadAction.java b/apps/showcase/src/main/java/org/apache/struts2/showcase/fileupload/DynamicFileUploadAction.java new file mode 100644 index 000000000..d4beb7b9b --- /dev/null +++ b/apps/showcase/src/main/java/org/apache/struts2/showcase/fileupload/DynamicFileUploadAction.java @@ -0,0 +1,214 @@ +/* + * 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.struts2.showcase.fileupload; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.apache.struts2.ActionSupport; +import org.apache.struts2.action.UploadedFilesAware; +import org.apache.struts2.dispatcher.multipart.UploadedFile; +import org.apache.struts2.interceptor.parameter.StrutsParameter; + +import java.util.List; + +/** + * <p> + * Demonstrates dynamic file upload validation using WithLazyParams. + * This action shows how file upload validation rules can be determined + * at runtime based on action properties, session data, or other dynamic values. + * </p> + * + * <p> + * The validation parameters (allowedTypes, allowedExtensions, maximumSize) + * are set dynamically in the prepare() method and then referenced in struts.xml + * using ${...} expressions. This allows the same action to enforce different + * validation rules based on runtime conditions. + * </p> + * + * <p> + * This example demonstrates two use cases: + * </p> + * <ul> + * <li><strong>Document Upload:</strong> Accepts PDF and Word documents up to 5MB</li> + * <li><strong>Image Upload:</strong> Accepts JPEG and PNG images up to 2MB</li> + * </ul> + * + * @see org.apache.struts2.interceptor.WithLazyParams + * @see org.apache.struts2.interceptor.ActionFileUploadInterceptor + */ +public class DynamicFileUploadAction extends ActionSupport implements UploadedFilesAware { + + private static final Logger LOG = LogManager.getLogger(DynamicFileUploadAction.class); + + private UploadedFile uploadedFile; + private String contentType; + private String fileName; + private String originalName; + private String inputName; + private String uploadType = "document"; + + private UploadConfig uploadConfig; + + public String input() { + prepareUploadConfig(uploadType); + return INPUT; + } + + public String upload() { + if (uploadedFile == null) { + addActionError("Please select a file to upload"); + return INPUT; + } + + return SUCCESS; + } + + @Override + public void withUploadedFiles(List<UploadedFile> uploadedFiles) { + if (!uploadedFiles.isEmpty()) { + LOG.info("Uploaded file: {}", uploadedFiles.get(0)); + this.uploadedFile = uploadedFiles.get(0); + this.fileName = uploadedFile.getName(); + this.contentType = uploadedFile.getContentType(); + this.originalName = uploadedFile.getOriginalName(); + this.inputName = uploadedFile.getInputName(); + } + } + + // Getters and Setters + + public String getContentType() { + return contentType; + } + + public String getFileName() { + return fileName; + } + + public String getOriginalName() { + return originalName; + } + + public String getInputName() { + return inputName; + } + + public Object getUploadedFile() { + return uploadedFile != null ? uploadedFile.getContent() : null; + } + + public long getUploadSize() { + return uploadedFile != null ? uploadedFile.length() : 0; + } + + public String getUploadType() { + return uploadType; + } + + @StrutsParameter + public void setUploadType(String uploadType) { + this.uploadType = uploadType; + prepareUploadConfig(uploadType); + } + + private void prepareUploadConfig(String uploadType) { + uploadConfig = new UploadConfig(); + LOG.debug("Configure validation rules based on upload type: {}", uploadType); + if ("image".equals(uploadType)) { + // Image upload configuration + uploadConfig.setAllowedMimeTypes("image/jpeg,image/png"); + uploadConfig.setAllowedExtensions(".jpg,.jpeg,.png"); + uploadConfig.setMaxFileSize(2097152L); // 2MB + uploadConfig.setDescription("images (JPEG, PNG)"); + } else { + // Document upload configuration (default) + uploadConfig.setAllowedMimeTypes("application/pdf,application/msword,application/vnd.openxmlformats-officedocument.wordprocessingml.document"); + uploadConfig.setAllowedExtensions(".pdf,.doc,.docx"); + uploadConfig.setMaxFileSize(5242880L); // 5MB + uploadConfig.setDescription("documents (PDF, Word)"); + } + } + + /** + * Returns the upload configuration object. + * This is used in struts.xml with ${uploadConfig.allowedMimeTypes} expressions. + */ + public UploadConfig getUploadConfig() { + return uploadConfig; + } + + /** + * Configuration holder for dynamic file upload validation rules. + */ + public static class UploadConfig { + private String allowedMimeTypes; + private String allowedExtensions; + private Long maxFileSize; + private String description; + + public String getAllowedMimeTypes() { + return allowedMimeTypes; + } + + public void setAllowedMimeTypes(String allowedMimeTypes) { + this.allowedMimeTypes = allowedMimeTypes; + } + + public String getAllowedExtensions() { + return allowedExtensions; + } + + public void setAllowedExtensions(String allowedExtensions) { + this.allowedExtensions = allowedExtensions; + } + + public Long getMaxFileSize() { + return maxFileSize; + } + + public void setMaxFileSize(Long maxFileSize) { + this.maxFileSize = maxFileSize; + } + + public String getDescription() { + return description; + } + + public void setDescription(String description) { + this.description = description; + } + + /** + * Returns a human-readable string representation of the max file size. + */ + public String getMaxFileSizeFormatted() { + if (maxFileSize == null) { + return "unlimited"; + } + + if (maxFileSize < 1024) { + return maxFileSize + " bytes"; + } else if (maxFileSize < 1024 * 1024) { + return (maxFileSize / 1024) + " KB"; + } else { + return (maxFileSize / (1024 * 1024)) + " MB"; + } + } + } +} diff --git a/apps/showcase/src/main/resources/log4j2.xml b/apps/showcase/src/main/resources/log4j2.xml index d7836c717..e1672a856 100644 --- a/apps/showcase/src/main/resources/log4j2.xml +++ b/apps/showcase/src/main/resources/log4j2.xml @@ -30,5 +30,8 @@ <AppenderRef ref="STDOUT"/> </Root> <Logger name="org.apache.struts2" level="info"/> + <Logger name="org.apache.struts2.showcase.fileupload" level="debug"/> + <Logger name="org.apache.struts2.inject" level="debug"/> + <Logger name="org.apache.struts2.interceptor.ActionFileUploadInterceptor" level="debug"/> </Loggers> </Configuration> diff --git a/apps/showcase/src/main/resources/struts-fileupload.xml b/apps/showcase/src/main/resources/struts-fileupload.xml index 21ce3fb03..e42a8695a 100644 --- a/apps/showcase/src/main/resources/struts-fileupload.xml +++ b/apps/showcase/src/main/resources/struts-fileupload.xml @@ -20,43 +20,66 @@ */ --> <!DOCTYPE struts PUBLIC - "-//Apache Software Foundation//DTD Struts Configuration 6.0//EN" - "https://struts.apache.org/dtds/struts-6.0.dtd"> + "-//Apache Software Foundation//DTD Struts Configuration 6.0//EN" + "https://struts.apache.org/dtds/struts-6.0.dtd"> <struts> - <constant name="struts.multipart.maxSize" value="10240" /> + <constant name="struts.multipart.maxSize" value="10240"/> - <package name="fileupload" extends="struts-default" namespace="/fileupload"> + <package name="fileupload" extends="struts-default" namespace="/fileupload"> <action name="upload" class="org.apache.struts2.showcase.fileupload.FileUploadAction" method="input"> - <result>/WEB-INF/fileupload/upload.jsp</result> - </action> + <result>/WEB-INF/fileupload/upload.jsp</result> + </action> <action name="doUpload" class="org.apache.struts2.showcase.fileupload.FileUploadAction" method="upload"> - <result name="input">/WEB-INF/fileupload/upload.jsp</result> - <result>/WEB-INF/fileupload/upload-success.jsp</result> - </action> + <result name="input">/WEB-INF/fileupload/upload.jsp</result> + <result>/WEB-INF/fileupload/upload-success.jsp</result> + </action> - <action name="multipleUploadUsingList"> - <result>/WEB-INF/fileupload/multipleUploadUsingList.jsp</result> - </action> + <action name="multipleUploadUsingList"> + <result>/WEB-INF/fileupload/multipleUploadUsingList.jsp</result> + </action> - <action name="doMultipleUploadUsingList" class="org.apache.struts2.showcase.fileupload.MultipleFileUploadUsingListAction" method="upload"> - <result name="input">/WEB-INF/fileupload/multipleUploadUsingList.jsp</result> - <result>/WEB-INF/fileupload/multiple-success.jsp</result> - </action> + <action name="doMultipleUploadUsingList" + class="org.apache.struts2.showcase.fileupload.MultipleFileUploadUsingListAction" method="upload"> + <result name="input">/WEB-INF/fileupload/multipleUploadUsingList.jsp</result> + <result>/WEB-INF/fileupload/multiple-success.jsp</result> + </action> - <action name="multipleUploadUsingArray"> - <result>/WEB-INF/fileupload/multipleUploadUsingArray.jsp</result> - </action> + <action name="multipleUploadUsingArray"> + <result>/WEB-INF/fileupload/multipleUploadUsingArray.jsp</result> + </action> - <action name="doMultipleUploadUsingArray" class="org.apache.struts2.showcase.fileupload.MultipleFileUploadUsingArrayAction" method="upload"> - <result name="input">/WEB-INF/fileupload/multipleUploadUsingArray.jsp</result> - <result>/WEB-INF/fileupload/multiple-success.jsp</result> - </action> + <action name="doMultipleUploadUsingArray" + class="org.apache.struts2.showcase.fileupload.MultipleFileUploadUsingArrayAction" method="upload"> + <result name="input">/WEB-INF/fileupload/multipleUploadUsingArray.jsp</result> + <result>/WEB-INF/fileupload/multiple-success.jsp</result> + </action> + <!-- Dynamic File Upload with WithLazyParams --> + <action name="dynamicUpload" class="org.apache.struts2.showcase.fileupload.DynamicFileUploadAction" + method="input"> + <result name="input">/WEB-INF/fileupload/dynamic-upload.jsp</result> + </action> + + <action name="doDynamicUpload" class="org.apache.struts2.showcase.fileupload.DynamicFileUploadAction" + method="upload"> + <!-- + WithLazyParams allows dynamic parameter evaluation. + The ${...} expressions are evaluated at runtime from the ValueStack, + allowing validation rules to be determined by action state. + --> + <interceptor-ref name="defaultStack"> + <param name="actionFileUpload.allowedTypes">${uploadConfig.allowedMimeTypes}</param> + <param name="actionFileUpload.allowedExtensions">${uploadConfig.allowedExtensions}</param> + <param name="actionFileUpload.maximumSize">${uploadConfig.maxFileSize}</param> + </interceptor-ref> + <result name="input">/WEB-INF/fileupload/dynamic-upload.jsp</result> + <result>/WEB-INF/fileupload/dynamic-upload-success.jsp</result> + </action> </package> </struts> diff --git a/apps/showcase/src/main/resources/struts.xml b/apps/showcase/src/main/resources/struts.xml index 45ab3a5e0..1b57083b3 100644 --- a/apps/showcase/src/main/resources/struts.xml +++ b/apps/showcase/src/main/resources/struts.xml @@ -36,14 +36,7 @@ <constant name="struts.allowlist.enable" value="true" /> <constant name="struts.parameters.requireAnnotations" value="true" /> - <constant name="struts.allowlist.packageNames" value=" - org.apache.struts2.showcase.model, - org.apache.struts2.showcase.modelDriven.model - "/> - <constant name="struts.allowlist.classes" value=" - org.apache.struts2.showcase.hangman.Hangman, - org.apache.struts2.showcase.hangman.Vocab - "/> + <constant name="struts.allowlist.packageNames" value="org.apache.struts2.showcase"/> <constant name="struts.convention.package.locators.basePackage" value="org.apache.struts2.showcase" /> <constant name="struts.convention.result.path" value="/WEB-INF" /> diff --git a/apps/showcase/src/main/webapp/WEB-INF/decorators/main.jsp b/apps/showcase/src/main/webapp/WEB-INF/decorators/main.jsp index a020e926d..b2eeaca62 100644 --- a/apps/showcase/src/main/webapp/WEB-INF/decorators/main.jsp +++ b/apps/showcase/src/main/webapp/WEB-INF/decorators/main.jsp @@ -192,6 +192,10 @@ <s:url var="url" action="upload" namespace="/fileupload"/> <s:a href="%{#url}">Single File Upload</s:a> </li> + <li> + <s:url var="url" action="dynamicUpload" namespace="/fileupload"/> + <s:a href="%{#url}">Single File Upload - dynamic config</s:a> + </li> <li> <s:url var="url" action="multipleUploadUsingList" namespace="/fileupload"/> <s:a href="%{#url}">Multiple File Upload (List)</s:a> diff --git a/apps/showcase/src/main/webapp/WEB-INF/fileupload/dynamic-upload-success.jsp b/apps/showcase/src/main/webapp/WEB-INF/fileupload/dynamic-upload-success.jsp new file mode 100644 index 000000000..a5276a91a --- /dev/null +++ b/apps/showcase/src/main/webapp/WEB-INF/fileupload/dynamic-upload-success.jsp @@ -0,0 +1,108 @@ +<!-- +/* +* 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. +*/ +--> +<%@ page + language="java" + contentType="text/html; charset=UTF-8" + pageEncoding="UTF-8" %> +<%@ taglib prefix="s" uri="/struts-tags" %> +<html> +<head> + <title>Struts2 Showcase - Dynamic File Upload Success</title> +</head> + +<body> +<div class="page-header"> + <h1>File Upload Successful</h1> + <p class="lead">Your file was validated and uploaded successfully</p> +</div> + +<div class="container-fluid"> + <div class="row"> + <div class="col-md-12"> + <div class="alert alert-success"> + <strong>Success!</strong> Your file passed all validation checks. + </div> + + <div class="panel panel-default"> + <div class="panel-heading"> + <h3 class="panel-title">Upload Details</h3> + </div> + <div class="panel-body"> + <dl class="dl-horizontal"> + <dt>Upload Type:</dt> + <dd><s:property value="uploadType == 'image' ? 'Image' : 'Document'"/></dd> + + <dt>Content Type:</dt> + <dd><code><s:property value="contentType"/></code></dd> + + <dt>File Name:</dt> + <dd><s:property value="fileName"/></dd> + + <dt>Original Name:</dt> + <dd><s:property value="originalName"/></dd> + + <dt>File Size:</dt> + <dd><s:property value="uploadSize"/> bytes</dd> + + <dt>Input Name:</dt> + <dd><s:property value="inputName"/></dd> + + <dt>File Object:</dt> + <dd><code><s:property value="uploadedFile"/></code></dd> + </dl> + </div> + </div> + + <div class="panel panel-info"> + <div class="panel-heading"> + <h3 class="panel-title">Validation Rules Applied</h3> + </div> + <div class="panel-body"> + <dl class="dl-horizontal"> + <dt>Allowed MIME Types:</dt> + <dd><code><s:property value="uploadConfig.allowedMimeTypes"/></code></dd> + + <dt>Allowed Extensions:</dt> + <dd><code><s:property value="uploadConfig.allowedExtensions"/></code></dd> + + <dt>Maximum Size:</dt> + <dd><s:property value="uploadConfig.maxFileSizeFormatted"/></dd> + </dl> + <p class="text-muted"> + <small> + These validation rules were determined dynamically at runtime + using <code>WithLazyParams</code> and evaluated from the ValueStack. + </small> + </p> + </div> + </div> + + <div class="btn-group"> + <s:a action="dynamicUpload" cssClass="btn btn-primary"> + <i class="glyphicon glyphicon-upload"></i> Upload Another File + </s:a> + </div> + </div> + </div> +</div> + +</body> +</html> diff --git a/apps/showcase/src/main/webapp/WEB-INF/fileupload/dynamic-upload.jsp b/apps/showcase/src/main/webapp/WEB-INF/fileupload/dynamic-upload.jsp new file mode 100644 index 000000000..dd64c66e8 --- /dev/null +++ b/apps/showcase/src/main/webapp/WEB-INF/fileupload/dynamic-upload.jsp @@ -0,0 +1,109 @@ +<!-- +/* +* 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. +*/ +--> +<%@ taglib prefix="s" uri="/struts-tags" %> +<html> +<head> + <title>Struts2 Showcase - Dynamic File Upload Validation</title> +</head> + +<body> +<div class="page-header"> + <h1>Dynamic File Upload Validation</h1> + <p class="lead">Demonstrates WithLazyParams for runtime validation rules</p> +</div> + +<div class="container-fluid"> + <div class="row"> + <div class="col-md-12"> + <div class="alert alert-info"> + <h4>About This Example</h4> + <p> + This example demonstrates how to use <code>WithLazyParams</code> to configure + file upload validation rules dynamically at runtime. The validation parameters + (<code>allowedTypes</code>, <code>allowedExtensions</code>, <code>maximumSize</code>) + are evaluated from the ValueStack for each request, allowing different rules + based on action state, user permissions, or other runtime conditions. + </p> + </div> + + <div class="alert alert-success"> + <h4>Current Configuration</h4> + <ul> + <li><strong>Upload Type:</strong> <s:property + value="uploadType == 'image' ? 'Image Upload' : 'Document Upload'"/></li> + <li><strong>Allowed Types:</strong> <code><s:property value="uploadConfig.allowedMimeTypes"/></code> + </li> + <li><strong>Allowed Extensions:</strong> <code><s:property + value="uploadConfig.allowedExtensions"/></code></li> + <li><strong>Maximum Size:</strong> <s:property value="uploadConfig.maxFileSizeFormatted"/></li> + <li><strong>Description:</strong> <s:property value="uploadConfig.description"/></li> + </ul> + </div> + </div> + </div> + + <s:actionerror cssClass="alert alert-danger"/> + <s:fielderror cssClass="alert alert-warning"/> + + <div class="row"> + <div class="col-md-12"> + <s:form action="doDynamicUpload" method="POST" enctype="multipart/form-data" cssClass="form-vertical"> + <div class="form-group"> + <label class="col-sm-2 control-label">Upload Type:</label> + <div class="col-sm-10"> + <s:radio name="uploadType" + list="#{'document':'Documents (PDF, Word) - up to 5MB', 'image':'Images (JPEG, PNG) - up to 2MB'}"/> + </div> + </div> + + <s:file name="upload" label="Select File" cssClass="form-control"/> + + <div class="form-group"> + <div class="col-sm-offset-2 col-sm-10"> + <s:submit value="Upload File" cssClass="btn btn-primary"/> + <s:submit value="Refresh Rules" action="dynamicUpload" cssClass="btn btn-default"/> + </div> + </div> + </s:form> + </div> + </div> + + <div class="row"> + <div class="col-md-12"> + <div class="well"> + <h4>How It Works</h4> + <p>In <code>struts.xml</code>, the interceptor parameters use expressions:</p> + <pre><interceptor-ref name="actionFileUpload"> + <param name="allowedTypes"><strong>${uploadConfig.allowedMimeTypes}</strong></param> + <param name="allowedExtensions"><strong>${uploadConfig.allowedExtensions}</strong></param> + <param name="maximumSize"><strong>${uploadConfig.maxFileSize}</strong></param> +</interceptor-ref></pre> + <p> + These expressions are evaluated at runtime against the ValueStack, + allowing the action to control validation rules dynamically in its + <code>prepare()</code> method. + </p> + </div> + </div> + </div> +</div> +</body> +</html> diff --git a/core/src/main/java/org/apache/struts2/interceptor/ActionFileUploadInterceptor.java b/core/src/main/java/org/apache/struts2/interceptor/ActionFileUploadInterceptor.java index 911a6e4a9..79d020a34 100644 --- a/core/src/main/java/org/apache/struts2/interceptor/ActionFileUploadInterceptor.java +++ b/core/src/main/java/org/apache/struts2/interceptor/ActionFileUploadInterceptor.java @@ -71,6 +71,79 @@ import java.util.List; * a file reference to be set on the action. If none is specified allow all extensions to be uploaded.</li> * </ul> * + * <h3>Dynamic Parameter Evaluation</h3> + * <p> + * This interceptor implements {@link WithLazyParams}, which enables dynamic parameter evaluation at runtime. + * Parameters can use <code>${...}</code> expressions that will be evaluated against the ValueStack for each request, + * allowing file upload validation rules to be determined dynamically based on action properties, session data, + * or other runtime values. + * </p> + * + * <p><strong>Static configuration example:</strong></p> + * <pre> + * <action name="upload" class="com.example.UploadAction"> + * <interceptor-ref name="actionFileUpload"> + * <param name="allowedTypes">image/jpeg,image/png,application/pdf</param> + * <param name="allowedExtensions">.jpg,.png,.pdf</param> + * <param name="maximumSize">5242880</param> + * </interceptor-ref> + * <interceptor-ref name="basicStack"/> + * </action> + * </pre> + * + * <p><strong>Dynamic configuration example:</strong></p> + * <pre> + * <action name="dynamicUpload" class="com.example.DynamicUploadAction"> + * <interceptor-ref name="actionFileUpload"> + * <param name="allowedTypes">${uploadConfig.allowedMimeTypes}</param> + * <param name="allowedExtensions">${uploadConfig.allowedExtensions}</param> + * <param name="maximumSize">${uploadConfig.maxFileSize}</param> + * </interceptor-ref> + * <interceptor-ref name="basicStack"/> + * </action> + * </pre> + * + * <p><strong>Action class with dynamic configuration:</strong></p> + * <pre> + * package com.example; + * + * import org.apache.struts2.ActionSupport; + * import org.apache.struts2.action.UploadedFilesAware; + * + * public class DynamicUploadAction extends ActionSupport implements UploadedFilesAware { + * private UploadedFile uploadedFile; + * private UploadConfig uploadConfig; + * + * public void prepare() { + * // Load configuration dynamically (from database, properties, etc.) + * uploadConfig = new UploadConfig(); + * uploadConfig.setAllowedMimeTypes("image/jpeg,image/png"); + * uploadConfig.setAllowedExtensions(".jpg,.png"); + * uploadConfig.setMaxFileSize(5242880L); + * } + * + * @Override + * public void withUploadedFiles(List<UploadedFile> uploadedFiles) { + * if (!uploadedFiles.isEmpty()) { + * this.uploadedFile = uploadedFiles.get(0); + * } + * } + * + * public UploadConfig getUploadConfig() { + * return uploadConfig; + * } + * + * public String execute() { + * //... + * return SUCCESS; + * } + * } + * </pre> + * + * <p><strong>Performance Note:</strong> When using dynamic parameters with <code>${...}</code> expressions, + * parameters are evaluated for each request. For static validation rules, use literal values for better performance. + * </p> + * * <p>Example code:</p> * * <pre> @@ -124,8 +197,12 @@ import java.util.List; * } * } * </pre> + * + * @see WithLazyParams + * @see UploadedFilesAware + * @see AbstractFileUploadInterceptor */ -public class ActionFileUploadInterceptor extends AbstractFileUploadInterceptor { +public class ActionFileUploadInterceptor extends AbstractFileUploadInterceptor implements WithLazyParams { protected static final Logger LOG = LogManager.getLogger(ActionFileUploadInterceptor.class); diff --git a/core/src/test/java/org/apache/struts2/interceptor/ActionFileUploadInterceptorTest.java b/core/src/test/java/org/apache/struts2/interceptor/ActionFileUploadInterceptorTest.java index 26cf7e180..61339327d 100644 --- a/core/src/test/java/org/apache/struts2/interceptor/ActionFileUploadInterceptorTest.java +++ b/core/src/test/java/org/apache/struts2/interceptor/ActionFileUploadInterceptorTest.java @@ -570,6 +570,260 @@ public class ActionFileUploadInterceptorTest extends StrutsInternalTestCase { }; } + /** + * Tests WithLazyParams functionality - verifies that the interceptor implements + * the WithLazyParams interface for dynamic parameter evaluation + */ + public void testImplementsWithLazyParams() { + assertThat(interceptor).isInstanceOf(WithLazyParams.class); + } + + /** + * Tests dynamic parameter evaluation with ${...} expressions from ValueStack + */ + public void testDynamicParameterEvaluation() throws Exception { + request.setCharacterEncoding(StandardCharsets.UTF_8.name()); + request.setMethod("POST"); + request.addHeader("Content-type", "multipart/form-data; boundary=\"" + boundary + "\""); + + // Upload two files with different content types + String content = encodeTextFile("test.txt", "text/plain", plainContent) + + encodeTextFile("test.html", "text/html", htmlContent) + + endLine + "--" + boundary + "--"; + request.setContent(content.getBytes()); + + MyDynamicFileUploadAction action = new MyDynamicFileUploadAction(); + action.setAllowedMimeTypes("text/plain"); // Only text/plain allowed + container.inject(action); + + MockActionInvocation mai = new MockActionInvocation(); + mai.setAction(action); + mai.setResultCode("success"); + mai.setInvocationContext(ActionContext.getContext()); + + // Push action to ValueStack so ${allowedMimeTypes} can be resolved + ActionContext.getContext().getValueStack().push(action); + ActionContext.getContext().withServletRequest(createMultipartRequestMaxFiles()); + + // Simulate WithLazyParams injection by manually setting the parameters + // In real execution, DefaultActionInvocation.invoke() would call LazyParamInjector + interceptor.setAllowedTypes(action.getAllowedMimeTypes()); + + interceptor.intercept(mai); + + List<UploadedFile> files = action.getUploadFiles(); + + // Only the text/plain file should be accepted + assertThat(files).isNotNull().hasSize(1); + assertThat(files.get(0).getContentType()).isEqualTo("text/plain"); + assertThat(files.get(0).getOriginalName()).isEqualTo("test.txt"); + } + + /** + * Tests that dynamic parameters can change between requests + */ + public void testDynamicParametersChangePerRequest() throws Exception { + // First request - allow only text/plain + request.setCharacterEncoding(StandardCharsets.UTF_8.name()); + request.setMethod("POST"); + request.addHeader("Content-type", "multipart/form-data; boundary=\"" + boundary + "\""); + + String content = encodeTextFile("test.txt", "text/plain", plainContent) + + endLine + "--" + boundary + "--"; + request.setContent(content.getBytes()); + + MyDynamicFileUploadAction action1 = new MyDynamicFileUploadAction(); + action1.setAllowedMimeTypes("text/plain"); + container.inject(action1); + + MockActionInvocation mai1 = new MockActionInvocation(); + mai1.setAction(action1); + mai1.setResultCode("success"); + mai1.setInvocationContext(ActionContext.getContext()); + ActionContext.getContext().getValueStack().push(action1); + ActionContext.getContext().withServletRequest(createMultipartRequestMaxFiles()); + + interceptor.setAllowedTypes(action1.getAllowedMimeTypes()); + interceptor.intercept(mai1); + + assertThat(action1.getUploadFiles()).isNotNull().hasSize(1); + assertThat(action1.getUploadFiles().get(0).getContentType()).isEqualTo("text/plain"); + + // Second request - allow only text/html + request = new MockHttpServletRequest(); + request.setCharacterEncoding(StandardCharsets.UTF_8.name()); + request.setMethod("POST"); + request.addHeader("Content-type", "multipart/form-data; boundary=\"" + boundary + "\""); + + content = encodeTextFile("test.html", "text/html", htmlContent) + + endLine + "--" + boundary + "--"; + request.setContent(content.getBytes()); + + MyDynamicFileUploadAction action2 = new MyDynamicFileUploadAction(); + action2.setAllowedMimeTypes("text/html"); + container.inject(action2); + + MockActionInvocation mai2 = new MockActionInvocation(); + mai2.setAction(action2); + mai2.setResultCode("success"); + mai2.setInvocationContext(ActionContext.getContext()); + ActionContext.getContext().getValueStack().pop(); // Remove previous action + ActionContext.getContext().getValueStack().push(action2); + ActionContext.getContext().withServletRequest(createMultipartRequestMaxFiles()); + + // Simulate new parameter evaluation for second request + interceptor.setAllowedTypes(action2.getAllowedMimeTypes()); + interceptor.intercept(mai2); + + assertThat(action2.getUploadFiles()).isNotNull().hasSize(1); + assertThat(action2.getUploadFiles().get(0).getContentType()).isEqualTo("text/html"); + } + + /** + * Tests dynamic extension validation + */ + public void testDynamicExtensionValidation() throws Exception { + request.setCharacterEncoding(StandardCharsets.UTF_8.name()); + request.setMethod("POST"); + request.addHeader("Content-type", "multipart/form-data; boundary=\"" + boundary + "\""); + + String content = encodeTextFile("test.pdf", "application/pdf", "PDF content") + + encodeTextFile("test.doc", "application/msword", "DOC content") + + endLine + "--" + boundary + "--"; + request.setContent(content.getBytes()); + + MyDynamicFileUploadAction action = new MyDynamicFileUploadAction(); + action.setAllowedExtensions(".pdf"); + container.inject(action); + + MockActionInvocation mai = new MockActionInvocation(); + mai.setAction(action); + mai.setResultCode("success"); + mai.setInvocationContext(ActionContext.getContext()); + ActionContext.getContext().getValueStack().push(action); + ActionContext.getContext().withServletRequest(createMultipartRequestMaxFiles()); + + interceptor.setAllowedExtensions(action.getAllowedExtensions()); + interceptor.intercept(mai); + + List<UploadedFile> files = action.getUploadFiles(); + + // Only the .pdf file should be accepted + assertThat(files).isNotNull().hasSize(1); + assertThat(files.get(0).getOriginalName()).isEqualTo("test.pdf"); + } + + /** + * Tests dynamic maximum size validation + */ + public void testDynamicMaximumSizeValidation() throws Exception { + request.setCharacterEncoding(StandardCharsets.UTF_8.name()); + request.setMethod("POST"); + request.addHeader("Content-type", "multipart/form-data; boundary=---1234"); + + String content = (""" + -----1234\r + Content-Disposition: form-data; name="file"; filename="test.txt"\r + Content-Type: text/plain\r + \r + This is a test file with some content\r + -----1234--\r + """); + request.setContent(content.getBytes(StandardCharsets.US_ASCII)); + + MyDynamicFileUploadAction action = new MyDynamicFileUploadAction(); + action.setMaxFileSize(10L); // Very small size to trigger validation error + container.inject(action); + + MockActionInvocation mai = new MockActionInvocation(); + mai.setAction(action); + mai.setResultCode("success"); + mai.setInvocationContext(ActionContext.getContext()); + ActionContext.getContext().getValueStack().push(action); + ActionContext.getContext().withServletRequest(createMultipartRequestMaxFiles()); + + interceptor.setMaximumSize(action.getMaxFileSize()); + interceptor.intercept(mai); + + // File should be rejected due to size + assertThat(action.hasFieldErrors()).isTrue(); + assertThat(action.getFieldErrors().get("file")).isNotEmpty(); + } + + /** + * Tests that security validation still works correctly with dynamic parameters + */ + public void testSecurityValidationWithDynamicParameters() throws Exception { + request.setCharacterEncoding(StandardCharsets.UTF_8.name()); + request.setMethod("POST"); + request.addHeader("Content-type", "multipart/form-data; boundary=\"" + boundary + "\""); + + // Try to upload files with various potentially dangerous content types + String content = encodeTextFile("script.js", "application/javascript", "alert('xss')") + + encodeTextFile("test.pdf", "application/pdf", "PDF content") + + endLine + "--" + boundary + "--"; + request.setContent(content.getBytes()); + + MyDynamicFileUploadAction action = new MyDynamicFileUploadAction(); + action.setAllowedMimeTypes("application/pdf"); + action.setAllowedExtensions(".pdf"); + container.inject(action); + + MockActionInvocation mai = new MockActionInvocation(); + mai.setAction(action); + mai.setResultCode("success"); + mai.setInvocationContext(ActionContext.getContext()); + ActionContext.getContext().getValueStack().push(action); + ActionContext.getContext().withServletRequest(createMultipartRequestMaxFiles()); + + interceptor.setAllowedTypes(action.getAllowedMimeTypes()); + interceptor.setAllowedExtensions(action.getAllowedExtensions()); + interceptor.intercept(mai); + + List<UploadedFile> files = action.getUploadFiles(); + + // Only the PDF should be accepted, JavaScript file should be rejected + assertThat(files).isNotNull().hasSize(1); + assertThat(files.get(0).getOriginalName()).isEqualTo("test.pdf"); + assertThat(files.get(0).getContentType()).isEqualTo("application/pdf"); + } + + /** + * Tests wildcard matching with dynamic parameters + */ + public void testWildcardMatchingWithDynamicParameters() throws Exception { + request.setCharacterEncoding(StandardCharsets.UTF_8.name()); + request.setMethod("POST"); + request.addHeader("Content-type", "multipart/form-data; boundary=\"" + boundary + "\""); + + String content = encodeTextFile("test.jpg", "image/jpeg", "JPEG content") + + encodeTextFile("test.png", "image/png", "PNG content") + + encodeTextFile("test.html", "text/html", htmlContent) + + endLine + "--" + boundary + "--"; + request.setContent(content.getBytes()); + + MyDynamicFileUploadAction action = new MyDynamicFileUploadAction(); + action.setAllowedMimeTypes("image/*"); // Accept all image types + container.inject(action); + + MockActionInvocation mai = new MockActionInvocation(); + mai.setAction(action); + mai.setResultCode("success"); + mai.setInvocationContext(ActionContext.getContext()); + ActionContext.getContext().getValueStack().push(action); + ActionContext.getContext().withServletRequest(createMultipartRequestMaxFiles()); + + interceptor.setAllowedTypes(action.getAllowedMimeTypes()); + interceptor.intercept(mai); + + List<UploadedFile> files = action.getUploadFiles(); + + // Both image files should be accepted, HTML should be rejected + assertThat(files).isNotNull().hasSize(2); + assertThat(files.get(0).getContentType()).startsWith("image/"); + assertThat(files.get(1).getContentType()).startsWith("image/"); + } + public static class MyFileUploadAction extends ActionSupport implements UploadedFilesAware { private List<UploadedFile> uploadedFiles; @@ -583,4 +837,47 @@ public class ActionFileUploadInterceptorTest extends StrutsInternalTestCase { } } + /** + * Test action class that demonstrates dynamic file upload configuration + */ + public static class MyDynamicFileUploadAction extends ActionSupport implements UploadedFilesAware { + private List<UploadedFile> uploadedFiles; + private String allowedMimeTypes; + private String allowedExtensions; + private Long maxFileSize; + + @Override + public void withUploadedFiles(List<UploadedFile> uploadedFiles) { + this.uploadedFiles = uploadedFiles; + } + + public List<UploadedFile> getUploadFiles() { + return this.uploadedFiles; + } + + public String getAllowedMimeTypes() { + return allowedMimeTypes; + } + + public void setAllowedMimeTypes(String allowedMimeTypes) { + this.allowedMimeTypes = allowedMimeTypes; + } + + public String getAllowedExtensions() { + return allowedExtensions; + } + + public void setAllowedExtensions(String allowedExtensions) { + this.allowedExtensions = allowedExtensions; + } + + public Long getMaxFileSize() { + return maxFileSize; + } + + public void setMaxFileSize(Long maxFileSize) { + this.maxFileSize = maxFileSize; + } + } + } diff --git a/thoughts/shared/research/2025-10-22-dynamic-file-upload-validation.md b/thoughts/shared/research/2025-10-22-dynamic-file-upload-validation.md new file mode 100644 index 000000000..b188dc056 --- /dev/null +++ b/thoughts/shared/research/2025-10-22-dynamic-file-upload-validation.md @@ -0,0 +1,590 @@ +--- +date: 2025-10-22T00:00:00Z +topic: "Dynamic File Upload Validation Without Custom Interceptors" +tags: [research, codebase, file-upload, validation, interceptor, UploadedFilesAware] +status: complete +git_commit: 06f9f9303387edf0557d128bbd7123bded4f24f5 +--- + +# Research: Dynamic File Upload Validation Without Custom Interceptors + +**Date**: 2025-10-22 + +## Research Question + +User asked: How can I dynamically set `allowedTypes` and `allowedExtensions` for file upload validation from my action class instead of static configuration in struts.xml? + +Specific issues: +1. Static configuration works: `<param name="actionFileUpload.allowedTypes">application/pdf</param>` +2. Dynamic expression doesn't work: `<param name="actionFileUpload.allowedTypes">${acceptedFileTypes}</param>` (due to TextParseUtil.commaDelimitedStringToSet) +3. Documentation mentions `setXContentType(String contentType)` method but cannot find it in Struts 7 core +4. Needs to match validation logic with parallel batch import function (without Struts) +5. Wants to avoid writing a custom interceptor + +## Summary + +**Key Findings:** + +1. **`${...}` expressions don't work in interceptor parameters** because `TextParseUtil.commaDelimitedStringToSet()` is a pure string splitter with no OGNL evaluation, and interceptor parameters are set at startup when no ValueStack exists. + +2. **`setXContentType` is a deprecated pattern** from pre-Struts 6.4.0 using naming conventions (`setUploadContentType`). Modern approach uses `UploadedFilesAware` interface with `UploadedFile` API. + +3. **Recommended solution: Programmatic validation in action** by implementing: + - `UploadedFilesAware` interface to receive files + - `validate()` method for custom validation logic + - Shared configuration class for both Struts and batch import + - No custom interceptor needed + +4. **Alternative if needed:** Implement `WithLazyParams` interface for runtime parameter evaluation (but with per-request overhead). + +## Detailed Findings + +### 1. Why `${acceptedFileTypes}` Doesn't Work + +**File**: [core/src/main/java/org/apache/struts2/util/TextParseUtil.java#L256](https://github.com/apache/struts/blob/06f9f9303387edf0557d128bbd7123bded4f24f5/core/src/main/java/org/apache/struts2/util/TextParseUtil.java#L256) + +```java +public static Set<String> commaDelimitedStringToSet(String s) { + return Arrays.stream(s.split(",")) + .map(String::trim) + .filter(s1 -> !s1.isEmpty()) + .collect(Collectors.toSet()); +} +``` + +**The Problem:** +- This is a **pure string splitter** with zero OGNL evaluation +- If you pass `"${acceptedFileTypes}"`, it creates a Set with the literal string `"${acceptedFileTypes}"` +- No expression evaluation happens + +**Root Cause - Parameter Processing Lifecycle:** + +**Phase 1: XML Parsing (Startup)** +- File: [core/src/main/java/org/apache/struts2/config/providers/XmlHelper.java#L73-95](https://github.com/apache/struts/blob/06f9f9303387edf0557d128bbd7123bded4f24f5/core/src/main/java/org/apache/struts2/config/providers/XmlHelper.java#L73-L95) +- Parameters extracted as **literal strings** from XML +- `${foo}` remains as the string `"${foo}"` + +**Phase 2: Interceptor Instantiation (Startup)** +- File: [core/src/main/java/org/apache/struts2/factory/DefaultInterceptorFactory.java#L54-81](https://github.com/apache/struts/blob/06f9f9303387edf0557d128bbd7123bded4f24f5/core/src/main/java/org/apache/struts2/factory/DefaultInterceptorFactory.java#L54-L81) +- Properties set via `reflectionProvider.setProperties(params, interceptor)` +- OGNL is used to **set** properties, not **evaluate** the value strings +- No `ActionContext` or `ValueStack` exists yet (happens at startup) + +**Phase 3: Setter Execution** +- File: [core/src/main/java/org/apache/struts2/interceptor/AbstractFileUploadInterceptor.java#L78](https://github.com/apache/struts/blob/06f9f9303387edf0557d128bbd7123bded4f24f5/core/src/main/java/org/apache/struts2/interceptor/AbstractFileUploadInterceptor.java#L78) + +```java +public void setAllowedExtensions(String allowedExtensions) { + allowedExtensionsSet = TextParseUtil.commaDelimitedStringToSet(allowedExtensions); +} +``` + +- Receives literal string `"${acceptedFileTypes}"` +- No evaluation mechanism available +- Creates Set with that literal value + +### 2. About `setXContentType` Method + +**The Old Pattern (Deprecated - Pre-Struts 6.4.0):** + +Used automatic property binding with naming conventions: +- `setUpload(File file)` - receives the uploaded file +- `setUploadContentType(String contentType)` - receives the content type +- `setUploadFileName(String fileName)` - receives the original filename + +**Example**: [apps/showcase/src/main/java/org/apache/struts2/showcase/UITagExample.java#L245-246](https://github.com/apache/struts/blob/06f9f9303387edf0557d128bbd7123bded4f24f5/apps/showcase/src/main/java/org/apache/struts2/showcase/UITagExample.java#L245-L246) + +```java +public class UITagExample extends ActionSupport { + File picture; + String pictureContentType; // Notice the naming pattern + String pictureFileName; + + @StrutsParameter + public void setPicture(File picture) { + this.picture = picture; + } + + @StrutsParameter + public void setPictureContentType(String pictureContentType) { + this.pictureContentType = pictureContentType; + } + + @StrutsParameter + public void setPictureFileName(String pictureFileName) { + this.pictureFileName = pictureFileName; + } +} +``` + +**The Modern Pattern (Struts 6.4.0+):** + +Uses `UploadedFilesAware` interface with `UploadedFile` API: + +**Interface**: [core/src/main/java/org/apache/struts2/action/UploadedFilesAware.java](https://github.com/apache/struts/blob/06f9f9303387edf0557d128bbd7123bded4f24f5/core/src/main/java/org/apache/struts2/action/UploadedFilesAware.java) + +**Example**: [apps/showcase/src/main/java/org/apache/struts2/showcase/fileupload/FileUploadAction.java](https://github.com/apache/struts/blob/06f9f9303387edf0557d128bbd7123bded4f24f5/apps/showcase/src/main/java/org/apache/struts2/showcase/fileupload/FileUploadAction.java) + +```java +public class FileUploadAction extends ActionSupport implements UploadedFilesAware { + private UploadedFile uploadedFile; + private String contentType; + private String fileName; + private String originalName; + + @Override + public void withUploadedFiles(List<UploadedFile> uploadedFiles) { + this.uploadedFile = uploadedFiles.get(0); + this.contentType = uploadedFile.getContentType(); + this.fileName = uploadedFile.getName(); + this.originalName = uploadedFile.getOriginalName(); + } + + public String execute() { + // Programmatic validation possible here + if (contentType != null && !contentType.equals("application/pdf")) { + addFieldError("upload", "Only PDF files are allowed"); + return ERROR; + } + return SUCCESS; + } +} +``` + +### 3. File Upload Validation Architecture + +**Core Interceptor**: [core/src/main/java/org/apache/struts2/interceptor/AbstractFileUploadInterceptor.java#L105-167](https://github.com/apache/struts/blob/06f9f9303387edf0557d128bbd7123bded4f24f5/core/src/main/java/org/apache/struts2/interceptor/AbstractFileUploadInterceptor.java#L105-L167) + +The `acceptFile()` method provides validation: + +```java +protected boolean acceptFile(Object action, UploadedFile file, String originalFilename, + String contentType, String inputName) { + Set<String> errorMessages = new HashSet<>(); + ValidationAware validation = null; + + if (action instanceof ValidationAware) { + validation = (ValidationAware) action; + } + + // Validation checks: + // 1. Null file check + if (file == null || file.getContent() == null) { + String errMsg = getTextMessage(action, STRUTS_MESSAGES_ERROR_UPLOADING_KEY, + new String[]{inputName}); + if (validation != null) { + validation.addFieldError(inputName, errMsg); + } + return false; + } + + // 2. File size validation (line 139-143) + if (maximumSize != null && maximumSize < file.length()) { + String errMsg = getTextMessage(action, STRUTS_MESSAGES_ERROR_FILE_TOO_LARGE_KEY, + new String[]{inputName, originalFilename, file.getName(), + "" + file.length(), getMaximumSizeStr(action)}); + errorMessages.add(errMsg); + } + + // 3. Content type validation (line 144-150) + if ((!allowedTypesSet.isEmpty()) && (!containsItem(allowedTypesSet, contentType))) { + String errMsg = getTextMessage(action, STRUTS_MESSAGES_ERROR_CONTENT_TYPE_NOT_ALLOWED_KEY, + new String[]{inputName, originalFilename, file.getName(), contentType}); + errorMessages.add(errMsg); + } + + // 4. File extension validation (line 151-157) + if ((!allowedExtensionsSet.isEmpty()) && (!hasAllowedExtension(allowedExtensionsSet, originalFilename))) { + String errMsg = getTextMessage(action, STRUTS_MESSAGES_ERROR_FILE_EXTENSION_NOT_ALLOWED_KEY, + new String[]{inputName, originalFilename, file.getName(), contentType}); + errorMessages.add(errMsg); + } + + if (validation != null) { + for (String errorMsg : errorMessages) { + validation.addFieldError(inputName, errorMsg); + } + } + + return errorMessages.isEmpty(); +} +``` + +**Extension Matching**: [AbstractFileUploadInterceptor.java#L169-181](https://github.com/apache/struts/blob/06f9f9303387edf0557d128bbd7123bded4f24f5/core/src/main/java/org/apache/struts2/interceptor/AbstractFileUploadInterceptor.java#L169-L181) + +```java +private boolean hasAllowedExtension(Collection<String> extensionCollection, String filename) { + if (filename == null) { + return false; + } + + String lowercaseFilename = filename.toLowerCase(); + for (String extension : extensionCollection) { + if (lowercaseFilename.endsWith(extension)) { + return true; + } + } + + return false; +} +``` + +**Content Type Matching with Wildcards**: [AbstractFileUploadInterceptor.java#L183-196](https://github.com/apache/struts/blob/06f9f9303387edf0557d128bbd7123bded4f24f5/core/src/main/java/org/apache/struts2/interceptor/AbstractFileUploadInterceptor.java#L183-L196) + +```java +private boolean containsItem(Collection<String> itemCollection, String item) { + for (String pattern : itemCollection) + if (matchesWildcard(pattern, item)) + return true; + return false; +} + +private boolean matchesWildcard(String pattern, String text) { + Object o = matcher.compilePattern(pattern); + return matcher.match(new HashMap<>(), text, o); +} +``` + +Supports patterns like `text/*` matching `text/plain`, `text/html`, etc. + +### 4. Programmatic Validation Examples + +**Multiple File Upload**: [apps/showcase/src/main/java/org/apache/struts2/showcase/fileupload/MultipleFileUploadUsingArrayAction.java](https://github.com/apache/struts/blob/06f9f9303387edf0557d128bbd7123bded4f24f5/apps/showcase/src/main/java/org/apache/struts2/showcase/fileupload/MultipleFileUploadUsingArrayAction.java) + +**XML-Based Validation**: [apps/showcase/src/main/resources/org/apache/struts2/showcase/fileupload/FileUploadAction-validation.xml](https://github.com/apache/struts/blob/06f9f9303387edf0557d128bbd7123bded4f24f5/apps/showcase/src/main/resources/org/apache/struts2/showcase/fileupload/FileUploadAction-validation.xml) + +```xml +<validators> + <field name="upload"> + <field-validator type="fieldexpression"> + <param name="expression"><![CDATA[getUploadSize() > 0]]></param> + <message>File cannot be empty</message> + </field-validator> + </field> +</validators> +``` + +**Test Example**: [core/src/test/java/org/apache/struts2/interceptor/ActionFileUploadInterceptorTest.java](https://github.com/apache/struts/blob/06f9f9303387edf0557d128bbd7123bded4f24f5/core/src/test/java/org/apache/struts2/interceptor/ActionFileUploadInterceptorTest.java) + +Shows how interceptor and ValidationAware integration works. + +### 5. Alternative: WithLazyParams Interface (Advanced) + +**File**: [core/src/main/java/org/apache/struts2/interceptor/WithLazyParams.java#L39-76](https://github.com/apache/struts/blob/06f9f9303387edf0557d128bbd7123bded4f24f5/core/src/main/java/org/apache/struts2/interceptor/WithLazyParams.java#L39-L76) + +```java +public interface WithLazyParams { + class LazyParamInjector { + public Interceptor injectParams(Interceptor interceptor, + Map<String, String> params, + ActionContext invocationContext) { + for (Map.Entry<String, String> entry : params.entrySet()) { + // CRITICAL: This DOES evaluate ${...} expressions + Object paramValue = textParser.evaluate( + new char[]{ '$' }, + entry.getValue(), + valueEvaluator, // Uses ValueStack.findValue() + TextParser.DEFAULT_LOOP_COUNT + ); + ognlUtil.setProperty(entry.getKey(), paramValue, interceptor, + invocationContext.getContextMap()); + } + return interceptor; + } + } +} +``` + +**How it works:** +- Interceptors implementing `WithLazyParams` skip parameter setting during initialization +- Parameters are injected **per-request** during action invocation +- `textParser.evaluate()` resolves `${...}` expressions against ValueStack +- Happens in `DefaultActionInvocation.invoke()` + +**Limitations:** +- Per-request evaluation overhead +- Requires custom interceptor implementation +- More complex than programmatic validation + +## Code References + +### Core Files +- `core/src/main/java/org/apache/struts2/interceptor/AbstractFileUploadInterceptor.java:78` - `setAllowedExtensions()` setter +- `core/src/main/java/org/apache/struts2/interceptor/AbstractFileUploadInterceptor.java:85` - `setAllowedTypes()` setter +- `core/src/main/java/org/apache/struts2/interceptor/AbstractFileUploadInterceptor.java:105-167` - `acceptFile()` validation logic +- `core/src/main/java/org/apache/struts2/interceptor/ActionFileUploadInterceptor.java` - Concrete implementation +- `core/src/main/java/org/apache/struts2/action/UploadedFilesAware.java` - Modern interface for file handling +- `core/src/main/java/org/apache/struts2/util/TextParseUtil.java:256` - `commaDelimitedStringToSet()` method +- `core/src/main/java/org/apache/struts2/config/providers/XmlHelper.java:73-95` - XML parameter parsing +- `core/src/main/java/org/apache/struts2/factory/DefaultInterceptorFactory.java:54-81` - Interceptor instantiation +- `core/src/main/java/org/apache/struts2/interceptor/WithLazyParams.java:39-76` - Lazy parameter evaluation + +### Example Files +- `apps/showcase/src/main/java/org/apache/struts2/showcase/fileupload/FileUploadAction.java` - Modern UploadedFilesAware example +- `apps/showcase/src/main/java/org/apache/struts2/showcase/fileupload/MultipleFileUploadUsingArrayAction.java` - Multiple file upload +- `apps/showcase/src/main/java/org/apache/struts2/showcase/fileupload/MultipleFileUploadUsingListAction.java` - List-based upload +- `apps/showcase/src/main/java/org/apache/struts2/showcase/UITagExample.java:245-246` - Old setXContentType pattern +- `apps/showcase/src/main/resources/org/apache/struts2/showcase/fileupload/FileUploadAction-validation.xml` - XML validation example + +### Test Files +- `core/src/test/java/org/apache/struts2/interceptor/ActionFileUploadInterceptorTest.java` - Validation integration tests + +## Architecture Insights + +### 1. Parameter Processing Timeline + +| Phase | When | OGNL Evaluation | ValueStack Available | Can Use ${...} | +|-------|------|-----------------|---------------------|----------------| +| **XML Parsing** | Startup | ❌ No | ❌ No | ❌ No | +| **Interceptor Init** | Startup | ⚠️ Property names only | ❌ No | ❌ No | +| **Setter Methods** | Startup | ❌ No | ❌ No | ❌ No | +| **WithLazyParams** | Per-request | ✅ Yes | ✅ Yes | ✅ Yes | +| **Request Processing** | Per-request | ✅ Yes | ✅ Yes | ✅ Yes | +| **Action validate()** | Per-request | ✅ Yes | ✅ Yes | ✅ Yes | + +### 2. Validation Layers + +**Layer 1: Interceptor (Pre-Action)** +- Configured via struts.xml parameters +- Executes before action +- Adds field errors to ValidationAware +- Cannot access action properties + +**Layer 2: Action validate() Method** +- Executes after interceptor +- Full access to UploadedFile objects +- Can implement dynamic business logic +- Can load configuration from database/properties + +**Layer 3: XML Validation** +- Declarative validation rules +- Can reference action methods via expressions +- Executes after validate() method + +### 3. Security Features + +1. **Case-insensitive extension matching** - Prevents bypassing via uppercase extensions +2. **Wildcard support for content types** - Allows flexible type matching (e.g., `text/*`) +3. **Multiple validation layers** - Defense in depth +4. **Integration with ValidationAware** - Consistent error handling +5. **Null safety** - Handles null files and content appropriately + +## Recommended Solution + +### Complete Implementation Example + +```java +package com.example; + +import org.apache.struts2.ActionSupport; +import org.apache.struts2.action.UploadedFilesAware; +import org.apache.struts2.dispatcher.multipart.UploadedFile; +import java.util.Arrays; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +public class DynamicFileUploadAction extends ActionSupport implements UploadedFilesAware { + + private UploadedFile uploadedFile; + private String contentType; + private String originalName; + + // Dynamic configuration - shared with batch import + private Set<String> acceptedFileTypes; + private Set<String> acceptedFileExtensions; + + public DynamicFileUploadAction() { + // Load dynamic configuration + // This matches your batch import function's configuration + acceptedFileTypes = FileUploadConfig.getAcceptedTypes(); + acceptedFileExtensions = FileUploadConfig.getAcceptedExtensions(); + } + + @Override + public void withUploadedFiles(List<UploadedFile> uploadedFiles) { + if (!uploadedFiles.isEmpty()) { + this.uploadedFile = uploadedFiles.get(0); + this.contentType = uploadedFile.getContentType(); + this.originalName = uploadedFile.getOriginalName(); + } + } + + @Override + public void validate() { + if (uploadedFile == null) { + addFieldError("upload", "Please select a file to upload"); + return; + } + + // Validate content type + if (!isValidContentType(contentType)) { + addFieldError("upload", + "File type not allowed: " + contentType + + ". Allowed types: " + acceptedFileTypes); + } + + // Validate file extension + if (!hasValidExtension(originalName)) { + addFieldError("upload", + "File extension not allowed. Allowed extensions: " + acceptedFileExtensions); + } + + // Additional validation + if (uploadedFile.length() == 0) { + addFieldError("upload", "File cannot be empty"); + } + } + + private boolean isValidContentType(String contentType) { + if (contentType == null || acceptedFileTypes.isEmpty()) { + return false; + } + + // Support wildcard matching (e.g., "image/*") + for (String allowedType : acceptedFileTypes) { + if (matchesWildcard(allowedType, contentType)) { + return true; + } + } + return false; + } + + private boolean hasValidExtension(String filename) { + if (filename == null || acceptedFileExtensions.isEmpty()) { + return false; + } + + String lowerFilename = filename.toLowerCase(); + for (String extension : acceptedFileExtensions) { + if (lowerFilename.endsWith(extension.toLowerCase())) { + return true; + } + } + return false; + } + + private boolean matchesWildcard(String pattern, String text) { + if (pattern.contains("*")) { + String prefix = pattern.substring(0, pattern.indexOf("*")); + return text.startsWith(prefix); + } + return pattern.equals(text); + } + + public String execute() { + // Process the uploaded file + // uploadedFile.getContent() gives you InputStream + return SUCCESS; + } + + // Getters for JSP access + public String getContentType() { return contentType; } + public String getOriginalName() { return originalName; } +} +``` + +### Shared Configuration Class + +```java +public class FileUploadConfig { + private static final Set<String> ACCEPTED_TYPES; + private static final Set<String> ACCEPTED_EXTENSIONS; + + static { + // Load from properties file or database + Properties props = loadProperties("file-upload-config.properties"); + ACCEPTED_TYPES = parseCommaSeparated(props.getProperty("accepted.types")); + ACCEPTED_EXTENSIONS = parseCommaSeparated(props.getProperty("accepted.extensions")); + } + + public static Set<String> getAcceptedTypes() { + return new HashSet<>(ACCEPTED_TYPES); + } + + public static Set<String> getAcceptedExtensions() { + return new HashSet<>(ACCEPTED_EXTENSIONS); + } + + private static Set<String> parseCommaSeparated(String value) { + return Arrays.stream(value.split(",")) + .map(String::trim) + .collect(Collectors.toSet()); + } + + private static Properties loadProperties(String filename) { + Properties props = new Properties(); + try (InputStream is = FileUploadConfig.class.getClassLoader() + .getResourceAsStream(filename)) { + props.load(is); + } catch (IOException e) { + throw new RuntimeException("Failed to load config", e); + } + return props; + } +} +``` + +### struts.xml Configuration + +```xml +<action name="dynamicUpload" class="com.example.DynamicFileUploadAction"> + <interceptor-ref name="actionFileUpload"> + <!-- Basic size limit only - types/extensions validated in action --> + <param name="maximumSize">5242880</param> <!-- 5MB --> + </interceptor-ref> + <interceptor-ref name="basicStack"/> + <result name="success">upload-success.jsp</result> + <result name="input">upload-form.jsp</result> +</action> +``` + +### Configuration Properties File + +```properties +# file-upload-config.properties +# Shared between Struts upload and batch import +accepted.types=application/pdf,image/jpeg,image/png +accepted.extensions=.pdf,.jpg,.jpeg,.png +``` + +## Benefits of Recommended Solution + +✅ **No custom interceptor needed** - Uses standard Struts patterns +✅ **Dynamic configuration loading** - Can change without recompilation +✅ **Shared config between Struts and batch import** - Single source of truth +✅ **Full control over validation logic** - Can add business-specific rules +✅ **Standard validation error handling** - Integrates with Struts validation +✅ **Wildcard support** - Same pattern matching as interceptor +✅ **Case-insensitive extension matching** - Consistent with Struts security practices + +## Related Research + +- File upload security patterns in Apache Struts +- Interceptor lifecycle and parameter injection +- OGNL expression evaluation contexts +- Struts validation framework integration + +## Open Questions + +1. **Performance consideration**: Should configuration be cached vs loaded per-action instance? +2. **Configuration source**: Database vs properties file vs external service? +3. **Validation error messages**: Should they be internationalized (i18n)? +4. **Batch import integration**: Should validation logic be extracted to a shared service class? +5. **Multiple file handling**: Should validation apply to all files or per-file basis? + +## Alternative Approaches Considered + +### Option A: WithLazyParams Custom Interceptor +**Pros:** Enables `${...}` evaluation +**Cons:** Requires custom interceptor, per-request overhead, more complex + +### Option B: Struts Constants +**Pros:** Simple, no code changes +**Cons:** Not truly dynamic, requires restart to change + +### Option C: Custom Validator +**Pros:** Reusable across actions +**Cons:** More complex setup, still requires configuration loading + +**Conclusion:** Programmatic validation in action provides the best balance of flexibility, simplicity, and maintainability for this use case.
