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

File SkinAction.java

 

Coverage histogram

../../../../img/srcFileCovDistChart7.png
64% of files have more coverage

Code metrics

42
140
14
1
503
272
49
0.35
10
14
3.5

Classes

Class Line # Actions
SkinAction 60 140 0% 49 74
0.62244962.2%
 

Contributing tests

This file is covered by 8 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.net.URI;
24    import java.util.Arrays;
25    import java.util.Date;
26   
27    import org.apache.commons.io.IOUtils;
28    import org.apache.commons.lang3.StringUtils;
29    import org.slf4j.Logger;
30    import org.slf4j.LoggerFactory;
31    import org.xwiki.model.reference.DocumentReference;
32    import org.xwiki.model.reference.EntityReference;
33    import org.xwiki.model.reference.EntityReferenceSerializer;
34    import org.xwiki.model.reference.ObjectPropertyReference;
35    import org.xwiki.security.authorization.AuthorExecutor;
36   
37    import com.xpn.xwiki.XWiki;
38    import com.xpn.xwiki.XWikiContext;
39    import com.xpn.xwiki.XWikiException;
40    import com.xpn.xwiki.doc.XWikiAttachment;
41    import com.xpn.xwiki.doc.XWikiDocument;
42    import com.xpn.xwiki.objects.BaseObject;
43    import com.xpn.xwiki.user.api.XWikiRightService;
44    import com.xpn.xwiki.util.Util;
45   
46    /**
47    * <p>
48    * Action for serving skin files. It allows skins to be defined using XDocuments as skins, by letting files be placed as
49    * text fields in an XWiki.XWikiSkins object, or as attachments to the document, or as a file in the filesystem. If the
50    * file is not found in the current skin, then it is searched in its base skin, and eventually in the default base
51    * skins,
52    * </p>
53    * <p>
54    * This action indicates that the results should be publicly cacheable for 30 days.
55    * </p>
56    *
57    * @version $Id: 1b8795f305987f124454ae4db7895ba42fa5b493 $
58    * @since 1.0
59    */
 
60    public class SkinAction extends XWikiAction
61    {
62    /** Logging helper. */
63    private static final Logger LOGGER = LoggerFactory.getLogger(SkinAction.class);
64   
65    /** Path delimiter. */
66    private static final String DELIMITER = "/";
67   
68    /** The directory where the skins are placed in the webapp. */
69    private static final String SKINS_DIRECTORY = "skins";
70   
71    /** The directory where resources are placed in the webapp. */
72    private static final String RESOURCES_DIRECTORY = "resources";
73   
74    /**
75    * The encoding to use when reading text resources from the filesystem and when sending css/javascript responses.
76    */
77    private static final String ENCODING = "UTF-8";
78   
 
79  1114 toggle @Override
80    public String render(XWikiContext context) throws XWikiException
81    {
82  1112 try {
83  1110 return render(context.getRequest().getPathInfo(), context);
84    } catch (IOException e) {
85  0 context.getResponse().setStatus(404);
86  0 return "docdoesnotexist";
87    }
88    }
89   
 
90  1091 toggle public String render(String path, XWikiContext context) throws XWikiException, IOException
91    {
92    // This Action expects an incoming Entity URL of the type:
93    // http://localhost:8080/xwiki/bin/skin/<path to resource on the filesystem, relative to the xwiki webapp>
94    // Example 1 (fs skin file): .../bin/skin/skins/flamingo/style.css?...
95    // Example 2 (fs resource file): .../bin/skin/resources/uicomponents/search/searchSuggest.css
96    // Example 3 (wiki skin attachment or xproperty): .../bin/skin/XWiki/DefaultSkin/somefile.css
97    //
98    // TODO: The mapping to an Entity URL is hackish and needs to be fixed,
99    // see http://jira.xwiki.org/browse/XWIKI-12449
100   
101    // Since we support Nested Spaces, these two examples will be mapped as the following Attachment References:
102    // Example 1: skins.flamingo@style\.css
103    // Example 2: resources.uicomponents.search@searchSuggest\.css
104    // Example 3: XWiki.DefaultSkin@somefile\.css
105   
106  1103 XWiki xwiki = context.getWiki();
107   
108    // Since skin paths usually contain the name of skin document, it is likely that the context document belongs to
109    // the current skin.
110  1104 XWikiDocument doc = context.getDoc();
111   
112    // The base skin could be either a filesystem directory, or an xdocument.
113  1110 String baseskin = xwiki.getBaseSkin(context, true);
114  1113 XWikiDocument baseskindoc = xwiki.getDocument(baseskin, context);
115   
116    // The default base skin is always a filesystem directory.
117  1114 String defaultbaseskin = xwiki.getDefaultBaseSkin(context);
118   
119  1114 LOGGER.debug("document: [{}] ; baseskin: [{}] ; defaultbaseskin: [{}]",
120    new Object[] { doc.getDocumentReference(), baseskin, defaultbaseskin });
121   
122    // Since we don't know exactly what does the URL point at, meaning that we don't know where the skin identifier
123    // ends and where the path to the file starts, we must try to split at every '/' character.
124  1110 int idx = path.lastIndexOf(DELIMITER);
125  1111 boolean found = false;
126  3297 while (idx > 0) {
127  3292 try {
128  3293 String filename = Util.decodeURI(path.substring(idx + 1), context);
129  3293 LOGGER.debug("Trying [{}]", filename);
130   
131    // Try on the current skin document.
132  3292 if (renderSkin(filename, doc, context)) {
133  148 found = true;
134  148 break;
135    }
136   
137    // Try on the base skin document, if it is not the same as above.
138  3148 if (StringUtils.isNotEmpty(baseskin) && !doc.getName().equals(baseskin)) {
139  3149 if (renderSkin(filename, baseskindoc, context)) {
140  95 found = true;
141  95 break;
142    }
143    }
144   
145    // Try on the default base skin, if it wasn't already tested above.
146  3054 if (StringUtils.isNotEmpty(baseskin)
147    && !(doc.getName().equals(defaultbaseskin) || baseskin.equals(defaultbaseskin))) {
148    // defaultbaseskin can only be on the filesystem, so don't try to use it as a
149    // skin document.
150  0 if (renderFileFromFilesystem(getSkinFilePath(filename, defaultbaseskin), context)) {
151  0 found = true;
152  0 break;
153    }
154    }
155   
156    // Try in the resources directory.
157  3053 if (renderFileFromFilesystem(getResourceFilePath(filename), context)) {
158  869 found = true;
159  869 break;
160    }
161    } catch (XWikiException ex) {
162  0 if (ex.getCode() == XWikiException.ERROR_XWIKI_APP_SEND_RESPONSE_EXCEPTION) {
163    // This means that the response couldn't be sent, although the file was
164    // successfully found. Signal this further, and stop trying to render.
165  0 throw ex;
166    }
167  0 LOGGER.debug(String.valueOf(idx), ex);
168    }
169  2184 idx = path.lastIndexOf(DELIMITER, idx - 1);
170    }
171  1113 if (!found) {
172  1 context.getResponse().setStatus(404);
173  1 return "docdoesnotexist";
174    }
175  1112 return null;
176    }
177   
178    /**
179    * Get the path for the given skin file in the given skin.
180    *
181    * @param filename Name of the file.
182    * @param skin Name of the skin to search in.
183    * @throws IOException if filename is invalid
184    */
 
185  6436 toggle public String getSkinFilePath(String filename, String skin) throws IOException
186    {
187  6440 String path =
188    URI.create(DELIMITER + SKINS_DIRECTORY + DELIMITER + skin + DELIMITER + filename).normalize().toString();
189    // Test to prevent someone from using "../" in the filename!
190  6440 if (!path.startsWith(DELIMITER + SKINS_DIRECTORY)) {
191  4 LOGGER.warn("Illegal access, tried to use file [{}] as a skin. Possible break-in attempt!", path);
192  4 throw new IOException("Invalid filename: '" + filename + "' for skin '" + skin + "'");
193    }
194  6430 return path;
195    }
196   
197    /**
198    * Get the path for the given file in resources.
199    *
200    * @param filename Name of the file.
201    * @throws IOException if filename is invalid
202    */
 
203  3057 toggle public String getResourceFilePath(String filename) throws IOException
204    {
205  3057 String path = URI.create(DELIMITER + RESOURCES_DIRECTORY + DELIMITER + filename).normalize().toString();
206    // Test to prevent someone from using "../" in the filename!
207  3057 if (!path.startsWith(DELIMITER + RESOURCES_DIRECTORY)) {
208  3 LOGGER.warn("Illegal access, tried to use file [{}] as a resource. Possible break-in attempt!", path);
209  3 throw new IOException("Invalid filename: '" + filename + "'");
210    }
211  3054 return path;
212    }
213   
214    /**
215    * Tries to serve a skin file using <tt>doc</tt> as a skin document. The file is searched in the following places:
216    * <ol>
217    * <li>As the content of a property with the same name as the requested filename, from an XWikiSkins object attached
218    * to the document.</li>
219    * <li>As the content of an attachment with the same name as the requested filename.</li>
220    * <li>As a file located on the filesystem, in the directory with the same name as the current document (in case the
221    * URL was actually pointing to <tt>/skins/directory/file</tt>).</li>
222    * </ol>
223    *
224    * @param filename The name of the skin file that should be rendered.
225    * @param doc The skin {@link XWikiDocument document}.
226    * @param context The current {@link XWikiContext request context}.
227    * @return <tt>true</tt> if the attachment was found and the content was successfully sent.
228    * @throws XWikiException If the attachment cannot be loaded.
229    * @throws IOException if the filename is invalid
230    */
 
231  6439 toggle private boolean renderSkin(String filename, XWikiDocument doc, XWikiContext context)
232    throws XWikiException, IOException
233    {
234  6443 LOGGER.debug("Rendering file [{}] within the [{}] document", filename, doc.getDocumentReference());
235  6436 try {
236  6441 if (doc.isNew()) {
237  6438 LOGGER.debug("[{}] is not a document", doc.getDocumentReference().getName());
238    } else {
239  0 return renderFileFromObjectField(filename, doc, context)
240    || renderFileFromAttachment(filename, doc, context) || (SKINS_DIRECTORY.equals(doc.getSpace())
241    && renderFileFromFilesystem(getSkinFilePath(filename, doc.getName()), context));
242    }
243    } catch (IOException e) {
244  0 throw new XWikiException(XWikiException.MODULE_XWIKI_APP,
245    XWikiException.ERROR_XWIKI_APP_SEND_RESPONSE_EXCEPTION, "Exception while sending response:", e);
246    }
247   
248  6437 return renderFileFromFilesystem(getSkinFilePath(filename, doc.getName()), context);
249    }
250   
251    /**
252    * Tries to serve a file from the filesystem.
253    *
254    * @param path Path of the file that should be rendered.
255    * @param context The current {@link XWikiContext request context}.
256    * @return <tt>true</tt> if the file was found and its content was successfully sent.
257    * @throws XWikiException If the response cannot be sent.
258    */
 
259  9471 toggle private boolean renderFileFromFilesystem(String path, XWikiContext context) throws XWikiException
260    {
261  9486 LOGGER.debug("Rendering filesystem file from path [{}]", path);
262   
263  9489 XWikiResponse response = context.getResponse();
264  9491 try {
265  9494 byte[] data;
266  9489 data = context.getWiki().getResourceContentAsBytes(path);
267  1113 if (data != null && data.length > 0) {
268  1112 String filename = path.substring(path.lastIndexOf("/") + 1, path.length());
269   
270  1113 Date modified = null;
271   
272    // Evaluate the file only if it's of a supported type.
273  1113 String mimetype = context.getEngineContext().getMimeType(filename.toLowerCase());
274  1111 if (isCssMimeType(mimetype) || isJavascriptMimeType(mimetype) || isLessCssFile(filename)) {
275    // Always force UTF-8, as this is the assumed encoding for text files.
276  963 String rawContent = new String(data, ENCODING);
277   
278    // Evaluate the content with the rights of the superadmin user, since this is a filesystem file.
279  967 DocumentReference superadminUserReference = new DocumentReference(context.getMainXWiki(),
280    XWiki.SYSTEM_SPACE, XWikiRightService.SUPERADMIN_USER);
281  965 String evaluatedContent = evaluateVelocity(rawContent, path, superadminUserReference, context);
282   
283  966 byte[] newdata = evaluatedContent.getBytes(ENCODING);
284    // If the content contained velocity code, then it should not be cached
285  967 if (Arrays.equals(newdata, data)) {
286  172 modified = context.getWiki().getResourceLastModificationDate(path);
287    } else {
288  795 modified = new Date();
289  795 data = newdata;
290    }
291   
292  967 response.setCharacterEncoding(ENCODING);
293    } else {
294  146 modified = context.getWiki().getResourceLastModificationDate(path);
295    }
296   
297    // Write the content to the response's output stream.
298  1113 setupHeaders(response, mimetype, modified, data.length);
299  1112 try {
300  1113 response.getOutputStream().write(data);
301    } catch (IOException e) {
302  0 throw new XWikiException(XWikiException.MODULE_XWIKI_APP,
303    XWikiException.ERROR_XWIKI_APP_SEND_RESPONSE_EXCEPTION, "Exception while sending response", e);
304    }
305   
306  1113 return true;
307    }
308    } catch (IOException ex) {
309  8380 LOGGER.info("Skin file [{}] does not exist or cannot be accessed", path);
310    }
311  8380 return false;
312    }
313   
314    /**
315    * Tries to serve the content of an XWikiSkins object field as a skin file.
316    *
317    * @param filename The name of the skin file that should be rendered.
318    * @param doc The skin {@link XWikiDocument document}.
319    * @param context The current {@link XWikiContext request context}.
320    * @return <tt>true</tt> if the object exists, and the field is set to a non-empty value, and its content was
321    * successfully sent.
322    * @throws IOException If the response cannot be sent.
323    */
 
324  0 toggle public boolean renderFileFromObjectField(String filename, XWikiDocument doc, final XWikiContext context)
325    throws IOException
326    {
327  0 LOGGER.debug("... as object property");
328   
329  0 BaseObject object = doc.getObject("XWiki.XWikiSkins");
330  0 String content = null;
331  0 if (object != null) {
332  0 content = object.getStringValue(filename);
333    }
334   
335  0 if (!StringUtils.isBlank(content)) {
336  0 XWiki xwiki = context.getWiki();
337   
338    // Evaluate the file only if it's of a supported type.
339  0 String mimetype = xwiki.getEngineContext().getMimeType(filename.toLowerCase());
340  0 if (isCssMimeType(mimetype) || isJavascriptMimeType(mimetype)) {
341  0 final ObjectPropertyReference propertyReference =
342    new ObjectPropertyReference(filename, object.getReference());
343   
344    // Evaluate the content with the rights of the document's author.
345  0 content = evaluateVelocity(content, propertyReference, doc.getAuthorReference(), context);
346    }
347   
348    // Prepare the response.
349  0 XWikiResponse response = context.getResponse();
350    // Since object fields are read as unicode strings, the result does not depend on the wiki encoding. Force
351    // the output to UTF-8.
352  0 response.setCharacterEncoding(ENCODING);
353   
354    // Write the content to the response's output stream.
355  0 byte[] data = content.getBytes(ENCODING);
356  0 setupHeaders(response, mimetype, doc.getDate(), data.length);
357  0 response.getOutputStream().write(data);
358   
359  0 return true;
360    } else {
361  0 LOGGER.debug("Object field not found or empty");
362    }
363   
364  0 return false;
365    }
366   
 
367  0 toggle private String evaluateVelocity(String content, EntityReference reference, DocumentReference author,
368    XWikiContext context)
369    {
370  0 EntityReferenceSerializer<String> serializer = Utils.getComponent(EntityReferenceSerializer.TYPE_STRING);
371  0 String namespace = serializer.serialize(reference);
372   
373  0 return evaluateVelocity(content, namespace, author, context);
374    }
375   
 
376  966 toggle private String evaluateVelocity(final String content, final String namespace, final DocumentReference author,
377    final XWikiContext context)
378    {
379  964 String result = content;
380   
381  966 try {
382  965 result = Utils.getComponent(AuthorExecutor.class)
383    .call(() -> context.getWiki().evaluateVelocity(content, namespace), author);
384    } catch (Exception e) {
385    // Should not happen since there is nothing in the call() method throwing an exception.
386  0 LOGGER.error("Failed to evaluate velocity content for namespace {} with the rights of the user {}",
387    namespace, author, e);
388    }
389   
390  967 return result;
391    }
392   
393    /**
394    * Tries to serve the content of an attachment as a skin file.
395    *
396    * @param filename The name of the skin file that should be rendered.
397    * @param doc The skin {@link XWikiDocument document}.
398    * @param context The current {@link XWikiContext request context}.
399    * @return <tt>true</tt> if the attachment was found and its content was successfully sent.
400    * @throws IOException If the response cannot be sent.
401    * @throws XWikiException If the attachment cannot be loaded.
402    */
 
403  0 toggle public boolean renderFileFromAttachment(String filename, XWikiDocument doc, XWikiContext context)
404    throws IOException, XWikiException
405    {
406  0 LOGGER.debug("... as attachment");
407   
408  0 XWikiAttachment attachment = doc.getAttachment(filename);
409  0 if (attachment != null) {
410  0 XWiki xwiki = context.getWiki();
411  0 XWikiResponse response = context.getResponse();
412   
413    // Evaluate the file only if it's of a supported type.
414  0 String mimetype = xwiki.getEngineContext().getMimeType(filename.toLowerCase());
415  0 if (isCssMimeType(mimetype) || isJavascriptMimeType(mimetype)) {
416  0 byte[] data = attachment.getContent(context);
417    // Always force UTF-8, as this is the assumed encoding for text files.
418  0 String velocityCode = new String(data, ENCODING);
419   
420    // Evaluate the content with the rights of the document's author.
421  0 String evaluatedContent =
422    evaluateVelocity(velocityCode, attachment.getReference(), doc.getAuthorReference(), context);
423   
424    // Prepare the response.
425  0 response.setCharacterEncoding(ENCODING);
426   
427    // Write the content to the response's output stream.
428  0 data = evaluatedContent.getBytes(ENCODING);
429  0 setupHeaders(response, mimetype, attachment.getDate(), data.length);
430  0 response.getOutputStream().write(data);
431    } else {
432    // Otherwise, return the raw content.
433  0 setupHeaders(response, mimetype, attachment.getDate(), attachment.getContentSize(context));
434  0 IOUtils.copy(attachment.getContentInputStream(context), response.getOutputStream());
435    }
436   
437  0 return true;
438    } else {
439  0 LOGGER.debug("Attachment not found");
440    }
441   
442  0 return false;
443    }
444   
445    /**
446    * Checks if a mimetype indicates a javascript file.
447    *
448    * @param mimetype The mime type to check.
449    * @return <tt>true</tt> if the mime type represents a javascript file.
450    */
 
451  653 toggle public boolean isJavascriptMimeType(String mimetype)
452    {
453  652 boolean result =
454    "text/javascript".equalsIgnoreCase(mimetype) || "application/x-javascript".equalsIgnoreCase(mimetype)
455    || "application/javascript".equalsIgnoreCase(mimetype);
456  652 result |= "application/ecmascript".equalsIgnoreCase(mimetype) || "text/ecmascript".equalsIgnoreCase(mimetype);
457  653 return result;
458    }
459   
460    /**
461    * Checks if a mimetype indicates a CSS file.
462    *
463    * @param mimetype The mime type to check.
464    * @return <tt>true</tt> if the mime type represents a css file.
465    */
 
466  1112 toggle public boolean isCssMimeType(String mimetype)
467    {
468  1110 return "text/css".equalsIgnoreCase(mimetype);
469    }
470   
471    /**
472    * Checks if a file is a LESS file that should be parsed by velocity.
473    *
474    * @param filename name of the file to check.
475    * @return <tt>true</tt> if the filename represents a LESS.vm file.
476    */
 
477  147 toggle private boolean isLessCssFile(String filename)
478    {
479  147 return filename.toLowerCase().endsWith(".less.vm");
480    }
481   
482    /**
483    * Sets several headers to properly identify the response.
484    *
485    * @param response The servlet response object, where the headers should be set.
486    * @param mimetype The mimetype of the file. Used in the "Content-Type" header.
487    * @param lastChanged The date of the last change of the file. Used in the "Last-Modified" header.
488    * @param length The length of the content (in bytes). Used in the "Content-Length" header.
489    */
 
490  1113 toggle protected void setupHeaders(XWikiResponse response, String mimetype, Date lastChanged, int length)
491    {
492  1112 if (!StringUtils.isBlank(mimetype)) {
493  1018 response.setContentType(mimetype);
494    } else {
495  95 response.setContentType("application/octet-stream");
496    }
497  1113 response.setDateHeader("Last-Modified", lastChanged.getTime());
498    // Cache for one month (30 days)
499  1113 response.setHeader("Cache-Control", "public");
500  1113 response.setDateHeader("Expires", (new Date()).getTime() + 30 * 24 * 3600 * 1000L);
501  1113 response.setContentLength(length);
502    }
503    }