b9f5cb6f5adc52f9a23b955a2d864d2fde5471a1
[struts.git] / core / src / main / java / org / apache / struts2 / interceptor / FileUploadInterceptor.java
1 /*
2 * $Id$
3 *
4 * Licensed to the Apache Software Foundation (ASF) under one
5 * or more contributor license agreements. See the NOTICE file
6 * distributed with this work for additional information
7 * regarding copyright ownership. The ASF licenses this file
8 * to you under the Apache License, Version 2.0 (the
9 * "License"); you may not use this file except in compliance
10 * with the License. You may obtain a copy of the License at
11 *
12 * http://www.apache.org/licenses/LICENSE-2.0
13 *
14 * Unless required by applicable law or agreed to in writing,
15 * software distributed under the License is distributed on an
16 * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
17 * KIND, either express or implied. See the License for the
18 * specific language governing permissions and limitations
19 * under the License.
20 */
21
22 package org.apache.struts2.interceptor;
23
24 import com.opensymphony.xwork2.*;
25 import com.opensymphony.xwork2.inject.Container;
26 import com.opensymphony.xwork2.inject.Inject;
27 import com.opensymphony.xwork2.interceptor.AbstractInterceptor;
28 import com.opensymphony.xwork2.interceptor.ValidationAware;
29 import com.opensymphony.xwork2.util.LocalizedTextUtil;
30 import com.opensymphony.xwork2.util.TextParseUtil;
31 import org.apache.logging.log4j.LogManager;
32 import org.apache.logging.log4j.Logger;
33 import org.apache.struts2.ServletActionContext;
34 import org.apache.struts2.dispatcher.LocalizedMessage;
35 import org.apache.struts2.dispatcher.Parameter;
36 import org.apache.struts2.dispatcher.multipart.MultiPartRequestWrapper;
37 import org.apache.struts2.dispatcher.multipart.UploadedFile;
38 import org.apache.struts2.util.ContentTypeMatcher;
39
40 import javax.servlet.http.HttpServletRequest;
41 import java.io.File;
42 import java.text.NumberFormat;
43 import java.util.*;
44
45 /**
46 * <!-- START SNIPPET: description -->
47 * <p>
48 * Interceptor that is based off of {@link MultiPartRequestWrapper}, which is automatically applied for any request that
49 * includes a file. It adds the following parameters, where [File Name] is the name given to the file uploaded by the
50 * HTML form:
51 * </p>
52 * <ul>
53 *
54 * <li>[File Name] : File - the actual File</li>
55 *
56 * <li>[File Name]ContentType : String - the content type of the file</li>
57 *
58 * <li>[File Name]FileName : String - the actual name of the file uploaded (not the HTML name)</li>
59 *
60 * </ul>
61 *
62 * <p>You can get access to these files by merely providing setters in your action that correspond to any of the three
63 * patterns above, such as setDocument(File document), setDocumentContentType(String contentType), etc.
64 * <br>See the example code section.
65 * </p>
66 *
67 * <p> This interceptor will add several field errors, assuming that the action implements {@link ValidationAware}.
68 * These error messages are based on several i18n values stored in struts-messages.properties, a default i18n file
69 * processed for all i18n requests. You can override the text of these messages by providing text for the following
70 * keys:
71 * </p>
72 *
73 * <ul>
74 *
75 * <li>struts.messages.error.uploading - a general error that occurs when the file could not be uploaded</li>
76 *
77 * <li>struts.messages.error.file.too.large - occurs when the uploaded file is too large</li>
78 *
79 * <li>struts.messages.error.content.type.not.allowed - occurs when the uploaded file does not match the expected
80 * content types specified</li>
81 *
82 * <li>struts.messages.error.file.extension.not.allowed - occurs when the uploaded file does not match the expected
83 * file extensions specified</li>
84 *
85 * </ul>
86 *
87 * <!-- END SNIPPET: description -->
88 *
89 * <p><u>Interceptor parameters:</u></p>
90 *
91 * <!-- START SNIPPET: parameters -->
92 *
93 * <ul>
94 *
95 * <li>maximumSize (optional) - the maximum size (in bytes) that the interceptor will allow a file reference to be set
96 * on the action. Note, this is <b>not</b> related to the various properties found in struts.properties.
97 * Default to approximately 2MB.</li>
98 *
99 * <li>allowedTypes (optional) - a comma separated list of content types (ie: text/html) that the interceptor will allow
100 * a file reference to be set on the action. If none is specified allow all types to be uploaded.</li>
101 *
102 * <li>allowedExtensions (optional) - a comma separated list of file extensions (ie: .html) that the interceptor will allow
103 * a file reference to be set on the action. If none is specified allow all extensions to be uploaded.</li>
104 * </ul>
105 *
106 *
107 * <!-- END SNIPPET: parameters -->
108 *
109 * <p><u>Extending the interceptor:</u></p>
110 *
111 *
112 *
113 * <!-- START SNIPPET: extending -->
114 * <p>
115 * You can extend this interceptor and override the acceptFile method to provide more control over which files
116 * are supported and which are not.
117 * </p>
118 * <!-- END SNIPPET: extending -->
119 *
120 * <p><u>Example code:</u></p>
121 *
122 * <pre>
123 * <!-- START SNIPPET: example-configuration -->
124 * &lt;action name="doUpload" class="com.example.UploadAction"&gt;
125 * &lt;interceptor-ref name="fileUpload"/&gt;
126 * &lt;interceptor-ref name="basicStack"/&gt;
127 * &lt;result name="success"&gt;good_result.jsp&lt;/result&gt;
128 * &lt;/action&gt;
129 * <!-- END SNIPPET: example-configuration -->
130 * </pre>
131 *
132 * <!-- START SNIPPET: multipart-note -->
133 * <p>
134 * You must set the encoding to <code>multipart/form-data</code> in the form where the user selects the file to upload.
135 * </p>
136 * <!-- END SNIPPET: multipart-note -->
137 *
138 * <pre>
139 * <!-- START SNIPPET: example-form -->
140 * &lt;s:form action="doUpload" method="post" enctype="multipart/form-data"&gt;
141 * &lt;s:file name="upload" label="File"/&gt;
142 * &lt;s:submit/&gt;
143 * &lt;/s:form&gt;
144 * <!-- END SNIPPET: example-form -->
145 * </pre>
146 * <p>
147 * And then in your action code you'll have access to the File object if you provide setters according to the
148 * naming convention documented in the start.
149 * </p>
150 *
151 * <pre>
152 * <!-- START SNIPPET: example-action -->
153 * package com.example;
154 *
155 * import java.io.File;
156 * import com.opensymphony.xwork2.ActionSupport;
157 *
158 * public UploadAction extends ActionSupport {
159 * private File file;
160 * private String contentType;
161 * private String filename;
162 *
163 * public void setUpload(File file) {
164 * this.file = file;
165 * }
166 *
167 * public void setUploadContentType(String contentType) {
168 * this.contentType = contentType;
169 * }
170 *
171 * public void setUploadFileName(String filename) {
172 * this.filename = filename;
173 * }
174 *
175 * public String execute() {
176 * //...
177 * return SUCCESS;
178 * }
179 * }
180 * <!-- END SNIPPET: example-action -->
181 * </pre>
182 */
183 public class FileUploadInterceptor extends AbstractInterceptor {
184
185 private static final long serialVersionUID = -4764627478894962478L;
186
187 protected static final Logger LOG = LogManager.getLogger(FileUploadInterceptor.class);
188
189 protected Long maximumSize;
190 protected Set<String> allowedTypesSet = Collections.emptySet();
191 protected Set<String> allowedExtensionsSet = Collections.emptySet();
192
193 private ContentTypeMatcher matcher;
194 private Container container;
195
196 @Inject
197 public void setMatcher(ContentTypeMatcher matcher) {
198 this.matcher = matcher;
199 }
200
201 @Inject
202 public void setContainer(Container container) {
203 this.container = container;
204 }
205
206 /**
207 * Sets the allowed extensions
208 *
209 * @param allowedExtensions A comma-delimited list of extensions
210 */
211 public void setAllowedExtensions(String allowedExtensions) {
212 allowedExtensionsSet = TextParseUtil.commaDelimitedStringToSet(allowedExtensions);
213 }
214
215 /**
216 * Sets the allowed mimetypes
217 *
218 * @param allowedTypes A comma-delimited list of types
219 */
220 public void setAllowedTypes(String allowedTypes) {
221 allowedTypesSet = TextParseUtil.commaDelimitedStringToSet(allowedTypes);
222 }
223
224 /**
225 * Sets the maximum size of an uploaded file
226 *
227 * @param maximumSize The maximum size in bytes
228 */
229 public void setMaximumSize(Long maximumSize) {
230 this.maximumSize = maximumSize;
231 }
232
233 /* (non-Javadoc)
234 * @see com.opensymphony.xwork2.interceptor.Interceptor#intercept(com.opensymphony.xwork2.ActionInvocation)
235 */
236
237 public String intercept(ActionInvocation invocation) throws Exception {
238 ActionContext ac = invocation.getInvocationContext();
239
240 HttpServletRequest request = (HttpServletRequest) ac.get(ServletActionContext.HTTP_REQUEST);
241
242 if (!(request instanceof MultiPartRequestWrapper)) {
243 if (LOG.isDebugEnabled()) {
244 ActionProxy proxy = invocation.getProxy();
245 LOG.debug(getTextMessage("struts.messages.bypass.request", new String[]{proxy.getNamespace(), proxy.getActionName()}));
246 }
247
248 return invocation.invoke();
249 }
250
251 ValidationAware validation = null;
252
253 Object action = invocation.getAction();
254
255 if (action instanceof ValidationAware) {
256 validation = (ValidationAware) action;
257 }
258
259 MultiPartRequestWrapper multiWrapper = (MultiPartRequestWrapper) request;
260
261 if (multiWrapper.hasErrors()) {
262 for (LocalizedMessage error : multiWrapper.getErrors()) {
263 if (validation != null) {
264 validation.addActionError(LocalizedTextUtil.findText(error.getClazz(), error.getTextKey(), ActionContext.getContext().getLocale(), error.getDefaultMessage(), error.getArgs()));
265 }
266 }
267 }
268
269 // bind allowed Files
270 Enumeration fileParameterNames = multiWrapper.getFileParameterNames();
271 while (fileParameterNames != null && fileParameterNames.hasMoreElements()) {
272 // get the value of this input tag
273 String inputName = (String) fileParameterNames.nextElement();
274
275 // get the content type
276 String[] contentType = multiWrapper.getContentTypes(inputName);
277
278 if (isNonEmpty(contentType)) {
279 // get the name of the file from the input tag
280 String[] fileName = multiWrapper.getFileNames(inputName);
281
282 if (isNonEmpty(fileName)) {
283 // get a File object for the uploaded File
284 UploadedFile[] files = multiWrapper.getFiles(inputName);
285 if (files != null && files.length > 0) {
286 List<UploadedFile> acceptedFiles = new ArrayList<>(files.length);
287 List<String> acceptedContentTypes = new ArrayList<>(files.length);
288 List<String> acceptedFileNames = new ArrayList<>(files.length);
289 String contentTypeName = inputName + "ContentType";
290 String fileNameName = inputName + "FileName";
291
292 for (int index = 0; index < files.length; index++) {
293 if (acceptFile(action, files[index], fileName[index], contentType[index], inputName, validation)) {
294 acceptedFiles.add(files[index]);
295 acceptedContentTypes.add(contentType[index]);
296 acceptedFileNames.add(fileName[index]);
297 }
298 }
299
300 if (!acceptedFiles.isEmpty()) {
301 Map<String, Parameter> newParams = new HashMap<>();
302 newParams.put(inputName, new Parameter.File(inputName, acceptedFiles.toArray(new UploadedFile[acceptedFiles.size()])));
303 newParams.put(contentTypeName, new Parameter.File(contentTypeName, acceptedContentTypes.toArray(new String[acceptedContentTypes.size()])));
304 newParams.put(fileNameName, new Parameter.File(fileNameName, acceptedFileNames.toArray(new String[acceptedFileNames.size()])));
305 ac.getParameters().appendAll(newParams);
306 }
307 }
308 } else {
309 if (LOG.isWarnEnabled()) {
310 LOG.warn(getTextMessage(action, "struts.messages.invalid.file", new String[]{inputName}));
311 }
312 }
313 } else {
314 if (LOG.isWarnEnabled()) {
315 LOG.warn(getTextMessage(action, "struts.messages.invalid.content.type", new String[]{inputName}));
316 }
317 }
318 }
319
320 // invoke action
321 return invocation.invoke();
322 }
323
324 /**
325 * Override for added functionality. Checks if the proposed file is acceptable based on contentType and size.
326 *
327 * @param action - uploading action for message retrieval.
328 * @param file - proposed upload file.
329 * @param filename - name of the file.
330 * @param contentType - contentType of the file.
331 * @param inputName - inputName of the file.
332 * @param validation - Non-null ValidationAware if the action implements ValidationAware, allowing for better
333 * logging.
334 * @return true if the proposed file is acceptable by contentType and size.
335 */
336 protected boolean acceptFile(Object action, UploadedFile file, String filename, String contentType, String inputName, ValidationAware validation) {
337 boolean fileIsAcceptable = false;
338
339 // If it's null the upload failed
340 if (file == null) {
341 String errMsg = getTextMessage(action, "struts.messages.error.uploading", new String[]{inputName});
342 if (validation != null) {
343 validation.addFieldError(inputName, errMsg);
344 }
345
346 if (LOG.isWarnEnabled()) {
347 LOG.warn(errMsg);
348 }
349 } else if (maximumSize != null && maximumSize < file.length()) {
350 String errMsg = getTextMessage(action, "struts.messages.error.file.too.large", new String[]{inputName, filename, file.getName(), "" + file.length(), getMaximumSizeStr(action)});
351 if (validation != null) {
352 validation.addFieldError(inputName, errMsg);
353 }
354
355 if (LOG.isWarnEnabled()) {
356 LOG.warn(errMsg);
357 }
358 } else if ((!allowedTypesSet.isEmpty()) && (!containsItem(allowedTypesSet, contentType))) {
359 String errMsg = getTextMessage(action, "struts.messages.error.content.type.not.allowed", new String[]{inputName, filename, file.getName(), contentType});
360 if (validation != null) {
361 validation.addFieldError(inputName, errMsg);
362 }
363
364 if (LOG.isWarnEnabled()) {
365 LOG.warn(errMsg);
366 }
367 } else if ((!allowedExtensionsSet.isEmpty()) && (!hasAllowedExtension(allowedExtensionsSet, filename))) {
368 String errMsg = getTextMessage(action, "struts.messages.error.file.extension.not.allowed", new String[]{inputName, filename, file.getName(), contentType});
369 if (validation != null) {
370 validation.addFieldError(inputName, errMsg);
371 }
372
373 if (LOG.isWarnEnabled()) {
374 LOG.warn(errMsg);
375 }
376 } else {
377 fileIsAcceptable = true;
378 }
379
380 return fileIsAcceptable;
381 }
382
383 private String getMaximumSizeStr(Object action) {
384 return NumberFormat.getNumberInstance(getLocaleProvider(action).getLocale()).format(maximumSize);
385 }
386
387 /**
388 * @param extensionCollection - Collection of extensions (all lowercase).
389 * @param filename - filename to check.
390 * @return true if the filename has an allowed extension, false otherwise.
391 */
392 private boolean hasAllowedExtension(Collection<String> extensionCollection, String filename) {
393 if (filename == null) {
394 return false;
395 }
396
397 String lowercaseFilename = filename.toLowerCase();
398 for (String extension : extensionCollection) {
399 if (lowercaseFilename.endsWith(extension)) {
400 return true;
401 }
402 }
403
404 return false;
405 }
406
407 /**
408 * @param itemCollection - Collection of string items (all lowercase).
409 * @param item - Item to search for.
410 * @return true if itemCollection contains the item, false otherwise.
411 */
412 private boolean containsItem(Collection<String> itemCollection, String item) {
413 for (String pattern : itemCollection)
414 if (matchesWildcard(pattern, item))
415 return true;
416 return false;
417 }
418
419 private boolean matchesWildcard(String pattern, String text) {
420 Object o = matcher.compilePattern(pattern);
421 return matcher.match(new HashMap<String, String>(), text, o);
422 }
423
424 private boolean isNonEmpty(Object[] objArray) {
425 boolean result = false;
426 for (int index = 0; index < objArray.length && !result; index++) {
427 if (objArray[index] != null) {
428 result = true;
429 }
430 }
431 return result;
432 }
433
434 protected String getTextMessage(String messageKey, String[] args) {
435 return getTextMessage(this, messageKey, args);
436 }
437
438 protected String getTextMessage(Object action, String messageKey, String[] args) {
439 if (action instanceof TextProvider) {
440 return ((TextProvider) action).getText(messageKey, args);
441 }
442 return getTextProvider(action).getText(messageKey, args);
443 }
444
445 private TextProvider getTextProvider(Object action) {
446 TextProviderFactory tpf = new TextProviderFactory();
447 if (container != null) {
448 container.inject(tpf);
449 }
450 LocaleProvider localeProvider = getLocaleProvider(action);
451 return tpf.createInstance(action.getClass(), localeProvider);
452 }
453
454 private LocaleProvider getLocaleProvider(Object action) {
455 LocaleProvider localeProvider;
456 if (action instanceof LocaleProvider) {
457 localeProvider = (LocaleProvider) action;
458 } else {
459 localeProvider = container.getInstance(LocaleProvider.class);
460 }
461 return localeProvider;
462 }
463
464 }