1. Project Clover database Tue Dec 20 2016 21:24:09 CET
  2. Package com.xpn.xwiki.web

File DownloadAction.java

 

Coverage histogram

../../../../img/srcFileCovDistChart10.png
0% of files have more coverage

Code metrics

50
146
15
1
514
316
54
0.37
9.73
15
3.6

Classes

Class Line # Actions
DownloadAction 65 146 0% 54 16
0.924170692.4%
 

Contributing tests

This file is covered by 33 tests. .

Source view

1    /*
2    * See the NOTICE file distributed with this work for additional
3    * information regarding copyright ownership.
4    *
5    * This is free software; you can redistribute it and/or modify it
6    * under the terms of the GNU Lesser General Public License as
7    * published by the Free Software Foundation; either version 2.1 of
8    * the License, or (at your option) any later version.
9    *
10    * This software is distributed in the hope that it will be useful,
11    * but WITHOUT ANY WARRANTY; without even the implied warranty of
12    * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
13    * Lesser General Public License for more details.
14    *
15    * You should have received a copy of the GNU Lesser General Public
16    * License along with this software; if not, write to the Free
17    * Software Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA
18    * 02110-1301 USA, or see the FSF site: http://www.fsf.org.
19    */
20    package com.xpn.xwiki.web;
21   
22    import java.io.IOException;
23    import java.io.InputStream;
24    import java.nio.charset.IllegalCharsetNameException;
25    import java.util.ArrayList;
26    import java.util.Arrays;
27    import java.util.Collections;
28    import java.util.HashMap;
29    import java.util.List;
30    import java.util.Map;
31    import java.util.regex.Matcher;
32    import java.util.regex.Pattern;
33   
34    import javax.servlet.http.HttpServletResponse;
35   
36    import org.apache.commons.io.IOUtils;
37    import org.apache.commons.io.input.BoundedInputStream;
38    import org.apache.commons.lang3.StringUtils;
39    import org.apache.commons.lang3.math.NumberUtils;
40    import org.apache.commons.lang3.tuple.ImmutablePair;
41    import org.apache.commons.lang3.tuple.Pair;
42    import org.xwiki.configuration.ConfigurationSource;
43    import org.xwiki.context.Execution;
44    import org.xwiki.context.ExecutionContext;
45    import org.xwiki.model.EntityType;
46    import org.xwiki.model.reference.DocumentReference;
47    import org.xwiki.model.reference.EntityReference;
48    import org.xwiki.resource.ResourceReference;
49    import org.xwiki.resource.ResourceReferenceManager;
50    import org.xwiki.resource.entity.EntityResourceReference;
51   
52    import com.xpn.xwiki.XWiki;
53    import com.xpn.xwiki.XWikiContext;
54    import com.xpn.xwiki.XWikiException;
55    import com.xpn.xwiki.doc.XWikiAttachment;
56    import com.xpn.xwiki.doc.XWikiDocument;
57    import com.xpn.xwiki.plugin.XWikiPluginManager;
58    import com.xpn.xwiki.util.Util;
59   
60    /**
61    * The action for downloading attachments from the server.
62    *
63    * @version $Id: 8972ff4a3581c2d07a64e7b391fb79e58d489f03 $
64    */
 
65    public class DownloadAction extends XWikiAction
66    {
67    /** The identifier of the download action. */
68    public static final String ACTION_NAME = "download";
69   
70    /** The identifier of the attachment disposition. */
71    public static final String ATTACHMENT = "attachment";
72   
73    /** List of authorized attachment mimetypes. */
74    public static final List<String> MIMETYPE_WHITELIST =
75    Arrays.asList("audio/basic", "audio/L24", "audio/mp4", "audio/mpeg", "audio/ogg", "audio/vorbis",
76    "audio/vnd.rn-realaudio", "audio/vnd.wave", "audio/webm", "image/gif", "image/jpeg", "image/pjpeg",
77    "image/png", "image/svg+xml", "image/tiff", "text/csv", "text/plain", "text/xml", "text/rtf",
78    "video/mpeg", "video/ogg", "video/quicktime", "video/webm", "video/x-matroska", "video/x-ms-wmv",
79    "video/x-flv");
80   
81    /** Key of the whitelist in xwiki.properties. */
82    public static final String WHITELIST_PROPERTY = "attachment.download.whitelist";
83   
84    /** Key of the blacklist in xwiki.properties. */
85    public static final String BLACKLIST_PROPERTY = "attachment.download.blacklist";
86   
87    /** The URL part separator. */
88    private static final String SEPARATOR = "/";
89   
90    /** The name of the HTTP Header that signals a byte-range request. */
91    private static final String RANGE_HEADER_NAME = "Range";
92   
93    /** The format of a valid range header. */
94    private static final Pattern RANGE_HEADER_PATTERN = Pattern.compile("bytes=([0-9]+)?-([0-9]+)?");
95   
96    /**
97    * Default constructor.
98    */
 
99  43 toggle public DownloadAction()
100    {
101  43 this.handleRedirectObject = true;
102    }
103   
 
104  211 toggle @Override
105    public String render(XWikiContext context) throws XWikiException
106    {
107  211 XWikiRequest request = context.getRequest();
108  210 XWikiResponse response = context.getResponse();
109   
110  211 XWikiDocument doc = context.getDoc();
111  209 String filename = getFileName();
112  213 XWikiAttachment attachment = getAttachment(request, doc, filename);
113   
114  213 Map<String, Object> backwardCompatibilityContextObjects = null;
115   
116  213 if (attachment == null) {
117    // If some plugins extend the Download URL format for the Standard Scheme the document in the context will
118    // most likely not have a reference that corresponds to what the plugin expects. For example imagine that
119    // the URL is a Zip Explorer URL like .../download/space/page/attachment/index.html. This will be parsed
120    // as space.page.attachment@index.html by the Standard URL scheme parsers. Thus the attachment won't be
121    // found since index.html is not the correct attachment for the Zip Explorer plugin's URL format.
122    //
123    // Thus in order to preserve backward compatibility for existing plugins that have custom URL formats
124    // extending the Download URL format, we parse again the URL by considering that it doesn't contain any
125    // Nested Space. This also means that those plugins will need to completely reparse the URL if they wish to
126    // support Nested Spaces.
127    //
128    // Also note that this code below is not compatible with the notion of having several URL schemes. The real
129    // fix will be to not allow plugins to support custom URL formats and instead to have them register new
130    // Actions if they need a different URL format.
131  5 Pair<XWikiDocument, XWikiAttachment> result =
132    extractAttachmentAndDocumentFromURLWithoutSupportingNestedSpaces(request, context);
133   
134  5 if (result == null) {
135  4 throwNotFoundException(filename);
136    }
137   
138  1 XWikiDocument backwardCompatibilityDocument = result.getLeft();
139  1 attachment = result.getRight();
140   
141    // Set the new doc as the context doc so that plugins see it as the context doc
142  1 backwardCompatibilityContextObjects = new HashMap<>();
143  1 pushDocumentInContext(backwardCompatibilityContextObjects,
144    backwardCompatibilityDocument.getDocumentReference());
145    }
146   
147  209 try {
148  210 XWikiPluginManager plugins = context.getWiki().getPluginManager();
149  209 attachment = plugins.downloadAttachment(attachment, context);
150   
151  208 if (attachment == null) {
152  0 throwNotFoundException(filename);
153    }
154   
155    // Try to load the attachment content just to make sure that the attachment really exists
156    // This will throw an exception if the attachment content isn't available
157  208 try {
158  209 attachment.getContentSize(context);
159    } catch (XWikiException e) {
160  0 Object[] args = { filename };
161  0 throw new XWikiException(XWikiException.MODULE_XWIKI_APP,
162    XWikiException.ERROR_XWIKI_APP_ATTACHMENT_NOT_FOUND,
163    "Attachment content {0} not found", null, args);
164    }
165   
166  207 long lastModifiedOnClient = request.getDateHeader("If-Modified-Since");
167  209 long lastModifiedOnServer = attachment.getDate().getTime();
168  204 if (lastModifiedOnClient != -1 && lastModifiedOnClient >= lastModifiedOnServer) {
169  133 response.setStatus(HttpServletResponse.SC_NOT_MODIFIED);
170  135 return null;
171    }
172   
173    // Sending the content of the attachment
174  70 if (request.getHeader(RANGE_HEADER_NAME) != null) {
175  17 try {
176  17 if (sendPartialContent(attachment, request, response, context)) {
177  12 return null;
178    }
179    } catch (IOException ex) {
180    // Broken response...
181    }
182    }
183  58 sendContent(attachment, request, response, filename, context);
184  58 return null;
185    } finally {
186  207 if (backwardCompatibilityContextObjects != null) {
187  1 popDocumentFromContext(backwardCompatibilityContextObjects);
188    }
189    }
190    }
191   
 
192  4 toggle private void throwNotFoundException(String filename) throws XWikiException
193    {
194  4 String message = filename == null ? "Attachment not found" :
195    String.format("Attachment [%s] not found", filename);
196  4 throw new XWikiException(XWikiException.MODULE_XWIKI_APP,
197    XWikiException.ERROR_XWIKI_APP_ATTACHMENT_NOT_FOUND, message);
198    }
199   
200    /**
201    * Respond to a range request, either with the requested bytes, or with a {@code 416 REQUESTED RANGE NOT
202    * SATISFIABLE} response if the requested byte range falls outside the length of the attachment. If the range
203    * request header is syntactically invalid, nothing is written, and instead {@code false} is returned, letting the
204    * action handler ignore the Range header and treat this as a normal (full) download request.
205    *
206    * @param attachment the attachment to get content from
207    * @param request the current client request
208    * @param response the response to write to.
209    * @param context the current request context
210    * @return {@code true} if the partial content request was syntactically valid and a response was sent,
211    * {@code false} otherwise
212    * @throws XWikiException if the attachment content cannot be retrieved
213    * @throws IOException if the response cannot be written
214    */
 
215  17 toggle private boolean sendPartialContent(final XWikiAttachment attachment,
216    final XWikiRequest request,
217    final XWikiResponse response,
218    final XWikiContext context)
219    throws XWikiException, IOException
220    {
221  17 String range = request.getHeader(RANGE_HEADER_NAME);
222  17 Matcher m = RANGE_HEADER_PATTERN.matcher(range);
223  17 if (m.matches()) {
224  14 String startStr = m.group(1);
225  14 String endStr = m.group(2);
226  14 Long start = NumberUtils.createLong(startStr);
227  14 Long end = NumberUtils.createLong(endStr);
228  14 if (start == null && end != null && end > 0) {
229    // Tail request, output the last <end> bytes
230  3 start = Math.max(attachment.getContentSize(context) - end, 0L);
231  3 end = attachment.getContentSize(context) - 1L;
232    }
233  14 if (!isValidRange(start, end)) {
234  2 return false;
235    }
236  12 if (end == null) {
237  3 end = attachment.getContentSize(context) - 1L;
238    }
239  12 end = Math.min(end, attachment.getContentSize(context) - 1L);
240  12 writeByteRange(attachment, start, end, request, response, context);
241  12 return true;
242    }
243  3 return false;
244    }
245   
246    /**
247    * Write a byte range from the attachment to the response, if the requested range is valid and falls within the file
248    * limits.
249    *
250    * @param attachment the attachment to get content from
251    * @param start the first byte to write
252    * @param end the last byte to write
253    * @param request the current client request
254    * @param response the response to write to.
255    * @param context the current request context
256    * @throws XWikiException if the attachment content cannot be retrieved
257    * @throws IOException if the response cannot be written
258    */
 
259  12 toggle private void writeByteRange(final XWikiAttachment attachment, Long start, Long end,
260    final XWikiRequest request,
261    final XWikiResponse response,
262    final XWikiContext context)
263    throws XWikiException, IOException
264    {
265  12 if (start >= 0 && start < attachment.getContentSize(context)) {
266  10 InputStream data = attachment.getContentInputStream(context);
267  10 data = new BoundedInputStream(data, end + 1);
268  10 data.skip(start);
269  10 setCommonHeaders(attachment, request, response, context);
270  10 response.setStatus(HttpServletResponse.SC_PARTIAL_CONTENT);
271  10 if ((end - start + 1L) < Integer.MAX_VALUE) {
272  10 response.setContentLength((int) (end - start + 1));
273    }
274  10 response.setHeader("Content-Range", "bytes " + start + "-" + end + SEPARATOR
275    + attachment.getContentSize(context));
276  10 IOUtils.copyLarge(data, response.getOutputStream());
277    } else {
278  2 response.setStatus(HttpServletResponse.SC_REQUESTED_RANGE_NOT_SATISFIABLE);
279    }
280    }
281   
282    /**
283    * Send the attachment content in the response.
284    *
285    * @param attachment the attachment to get content from
286    * @param request the current client request
287    * @param response the response to write to.
288    * @param filename the filename to show in the message in case an exception needs to be thrown
289    * @param context the XWikiContext just in case it is needed to load the attachment content
290    * @throws XWikiException if something goes wrong
291    */
 
292  58 toggle private void sendContent(final XWikiAttachment attachment,
293    final XWikiRequest request,
294    final XWikiResponse response,
295    final String filename,
296    final XWikiContext context)
297    throws XWikiException
298    {
299  58 InputStream stream = null;
300  58 try {
301  58 setCommonHeaders(attachment, request, response, context);
302  58 response.setContentLength(attachment.getContentSize(context));
303  58 stream = attachment.getContentInputStream(context);
304  58 IOUtils.copy(stream, response.getOutputStream());
305    } catch (IOException e) {
306  0 throw new XWikiException(XWikiException.MODULE_XWIKI_APP,
307    XWikiException.ERROR_XWIKI_APP_SEND_RESPONSE_EXCEPTION,
308    "Exception while sending response", e);
309    } finally {
310  58 if (stream != null) {
311  58 IOUtils.closeQuietly(stream);
312    }
313    }
314    }
315   
316    /**
317    * @return the filename of the attachment or null if the URL didn't point to an attachment
318    */
 
319  209 toggle private String getFileName()
320    {
321    // Extract the Attachment file name from the parsed request URL that was done before this Action is called
322  211 ResourceReference resourceReference = Utils.getComponent(ResourceReferenceManager.class).getResourceReference();
323  213 EntityResourceReference entityResource = (EntityResourceReference) resourceReference;
324   
325    // Try to extract the attachment from the reference but it's possible that the URL didn't point to an
326    // attachment, in which case we return null.
327  214 EntityReference attachmentReference =
328    entityResource.getEntityReference().extractReference(EntityType.ATTACHMENT);
329   
330  214 return attachmentReference == null ? null : attachmentReference.getName();
331    }
332   
 
333  5 toggle private Pair<XWikiDocument, XWikiAttachment> extractAttachmentAndDocumentFromURLWithoutSupportingNestedSpaces(
334    XWikiRequest request, XWikiContext context)
335    {
336  5 String path = request.getRequestURI();
337   
338    // Extract the path part after the action, e.g. "/space/page/attachment/path1/path2" when you have
339    // ".../download/space/page/attachment/path1/path2".
340  5 int pos = path.indexOf(SEPARATOR + ACTION_NAME);
341  5 String subPath = path.substring(pos + (SEPARATOR + ACTION_NAME).length() + 1);
342   
343  5 List<String> segments = new ArrayList<>();
344  5 for (String pathSegment : subPath.split(SEPARATOR, -1)) {
345  14 segments.add(Util.decodeURI(pathSegment, context));
346    }
347   
348    // We need at least 3 segments
349  5 if (segments.size() < 3) {
350  2 return null;
351    }
352   
353  3 String spaceName = segments.get(0);
354  3 String pageName = segments.get(1);
355  3 String attachmentName = segments.get(2);
356   
357    // Generate the XWikiDocument and try to load it (if the user has permission to it)
358  3 DocumentReference reference = new DocumentReference(context.getWikiId(), spaceName, pageName);
359  3 XWiki xwiki = context.getWiki();
360   
361  3 XWikiDocument backwardCompatibilityDocument;
362  3 try {
363  3 backwardCompatibilityDocument = xwiki.getDocument(reference, context);
364  3 if (!backwardCompatibilityDocument.isNew()) {
365  1 if (!context.getWiki().checkAccess(context.getAction(), backwardCompatibilityDocument, context)) {
366    // No permission to access the document, consider that the attachment doesn't exist
367  0 return null;
368    }
369    } else {
370    // Document doesn't exist
371  2 return null;
372    }
373    } catch (XWikiException e) {
374    // An error happened when getting the doc or checking the permission, consider that the attachment
375    // doesn't exist
376  0 return null;
377    }
378   
379    // Look for the attachment and return it
380  1 XWikiAttachment attachment = getAttachment(request, backwardCompatibilityDocument, attachmentName);
381   
382  1 return new ImmutablePair<>(backwardCompatibilityDocument, attachment);
383    }
384   
 
385  1 toggle private void pushDocumentInContext(Map<String, Object> backupObjects, DocumentReference documentReference)
386    throws XWikiException
387    {
388  1 XWikiContext xcontext = getContext();
389   
390    // Backup current context state
391  1 XWikiDocument.backupContext(backupObjects, xcontext);
392   
393    // Make sure to get the current XWikiContext after ExcutionContext clone
394  1 xcontext = getContext();
395   
396    // Change context document
397  1 xcontext.getWiki().getDocument(documentReference, xcontext).setAsContextDoc(xcontext);
398    }
399   
 
400  1 toggle private void popDocumentFromContext(Map<String, Object> backupObjects)
401    {
402  1 XWikiDocument.restoreContext(backupObjects, getContext());
403    }
404   
 
405  3 toggle private XWikiContext getContext()
406    {
407  3 Execution execution = Utils.getComponent(Execution.class);
408  3 ExecutionContext econtext = execution.getContext();
409  3 return econtext != null ? (XWikiContext) econtext.getProperty("xwikicontext") : null;
410    }
411   
 
412  215 toggle private XWikiAttachment getAttachment(XWikiRequest request, XWikiDocument document, String filename)
413    {
414  214 XWikiAttachment attachment = null;
415   
416  213 String idStr = request.getParameter("id");
417  212 if (StringUtils.isNumeric(idStr)) {
418  2 int id = Integer.parseInt(idStr);
419  2 if (document.getAttachmentList().size() > id) {
420  1 attachment = document.getAttachmentList().get(id);
421    }
422    } else {
423  207 attachment = document.getAttachment(filename);
424    }
425   
426  214 return attachment;
427    }
428   
429    /**
430    * Set the response HTTP headers common to both partial (Range) and full responses.
431    *
432    * @param attachment the attachment to get content from
433    * @param request the current client request
434    * @param response the response to write to.
435    * @param context the current request context
436    */
 
437  68 toggle private void setCommonHeaders(final XWikiAttachment attachment,
438    final XWikiRequest request,
439    final XWikiResponse response,
440    final XWikiContext context)
441    {
442    // Choose the right content type
443  68 String mimetype = attachment.getMimeType(context);
444  68 response.setContentType(mimetype);
445  68 try {
446  68 response.setCharacterEncoding("");
447    } catch (IllegalCharsetNameException ex) {
448  0 response.setCharacterEncoding(XWiki.DEFAULT_ENCODING);
449    }
450   
451  68 String ofilename =
452    Util.encodeURI(attachment.getFilename(), context).replaceAll("\\+", "%20");
453   
454    // The inline attribute of Content-Disposition tells the browser that they should display
455    // the downloaded file in the page (see http://www.ietf.org/rfc/rfc1806.txt for more
456    // details). We do this so that JPG, GIF, PNG, etc are displayed without prompting a Save
457    // dialog box. However, all mime types that cannot be displayed by the browser do prompt a
458    // Save dialog box (exe, zip, xar, etc).
459  68 String dispType = "inline";
460   
461    // Determine whether the user who attached the file has Programming Rights or not.
462  68 boolean hasPR = false;
463  68 String author = attachment.getAuthor();
464  68 try {
465  68 hasPR =
466    context.getWiki().getRightService().hasAccessLevel(
467    "programming", author, "XWiki.XWikiPreferences", context);
468    } catch (Exception e) {
469  0 hasPR = false;
470    }
471    // If the mimetype is not authorized to be displayed inline, let's force its content disposition to download.
472  68 if ((!hasPR && !isAuthorized(mimetype)) || "1".equals(request.getParameter("force-download"))) {
473  5 dispType = ATTACHMENT;
474    }
475    // Use RFC 2231 for encoding filenames, since the normal HTTP headers only allows ASCII characters.
476    // See http://tools.ietf.org/html/rfc2231 for more details.
477  68 response.addHeader("Content-disposition", dispType + "; filename*=utf-8''" + ofilename);
478   
479  68 response.setDateHeader("Last-Modified", attachment.getDate().getTime());
480    // Advertise that downloads can be resumed
481  68 response.setHeader("Accept-Ranges", "bytes");
482    }
483   
484    /**
485    * Check if the specified byte range first and last bytes form a syntactically valid range. For a range to be valid,
486    * at least one of the ends must be specified, and if both are present, the range end must be greater than the range
487    * start.
488    *
489    * @param start the requested range start, i.e. the first byte to be transfered, or {@code null} if missing from the
490    * Range header
491    * @param end the requested range end, i.e. the last byte to be transfered, or {@code null} if missing from the
492    * Range header
493    * @return {@code true} if the range is valid, {@code false} otherwise
494    */
 
495  14 toggle private boolean isValidRange(Long start, Long end)
496    {
497  14 if (start == null && end == null) {
498  1 return false;
499    }
500  13 return start == null || end == null || end >= start;
501    }
502   
 
503  68 toggle private boolean isAuthorized(String mimeType)
504    {
505  68 ConfigurationSource configuration = Utils.getComponent(ConfigurationSource.class, "xwikiproperties");
506  68 if (configuration.containsKey(BLACKLIST_PROPERTY) && !configuration.containsKey(WHITELIST_PROPERTY)) {
507  0 List<String> blackList = (configuration.getProperty(BLACKLIST_PROPERTY, Collections.<String>emptyList()));
508  0 return !blackList.contains(mimeType);
509    } else {
510  68 List<String> whiteList = configuration.getProperty(WHITELIST_PROPERTY, MIMETYPE_WHITELIST);
511  68 return whiteList.contains(mimeType);
512    }
513    }
514    }