1. Project Clover database Sat Feb 2 2019 06:45:20 CET
  2. Package com.xpn.xwiki.web

File SkinAction.java

 

Coverage histogram

../../../../img/srcFileCovDistChart6.png
72% of files have more coverage

Code metrics

42
140
14
1
506
275
49
0.35
10
14
3.5

Classes

Class Line # Actions
SkinAction 60 140 0% 49 78
0.602040860.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: 53e62db3a43bf2aa029ed680ba233aa16a961707 $
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  1312 toggle @Override
80    public String render(XWikiContext context) throws XWikiException
81    {
82  1319 try {
83  1318 return render(context.getRequest().getPathInfo(), context);
84    } catch (IOException e) {
85  0 context.getResponse().setStatus(404);
86  0 return "docdoesnotexist";
87    }
88    }
89   
 
90  1360 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 https://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  1364 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  1363 XWikiDocument doc = context.getDoc();
111   
112    // The base skin could be either a filesystem directory, or an xdocument.
113  1368 String baseskin = xwiki.getBaseSkin(context, true);
114  1360 XWikiDocument baseskindoc = xwiki.getDocument(baseskin, context);
115   
116    // The default base skin is always a filesystem directory.
117  1368 String defaultbaseskin = xwiki.getDefaultBaseSkin(context);
118   
119  1367 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  1369 int idx = path.lastIndexOf(DELIMITER);
125  1372 boolean found = false;
126  4030 while (idx > 0) {
127  4025 try {
128  4027 String filename = Util.decodeURI(path.substring(idx + 1), context);
129  4033 LOGGER.debug("Trying [{}]", filename);
130   
131    // Try on the current skin document.
132  4021 if (renderSkin(filename, doc, context)) {
133  157 found = true;
134  157 break;
135    }
136   
137    // Try on the base skin document, if it is not the same as above.
138  3867 if (StringUtils.isNotEmpty(baseskin) && !doc.getName().equals(baseskin)) {
139  3867 if (renderSkin(filename, baseskindoc, context)) {
140  99 found = true;
141  99 break;
142    }
143    }
144   
145    // Try on the default base skin, if it wasn't already tested above.
146  3776 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  3770 if (renderFileFromFilesystem(getResourceFilePath(filename), context)) {
158  1115 found = true;
159  1116 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  2658 idx = path.lastIndexOf(DELIMITER, idx - 1);
170    }
171  1373 if (!found) {
172  0 context.getResponse().setStatus(404);
173  0 return "docdoesnotexist";
174    }
175  1372 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  7886 toggle public String getSkinFilePath(String filename, String skin) throws IOException
186    {
187  7906 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  7907 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  7899 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  3770 toggle public String getResourceFilePath(String filename) throws IOException
204    {
205  3778 String path = URI.create(DELIMITER + RESOURCES_DIRECTORY + DELIMITER + filename).normalize().toString();
206    // Test to prevent someone from using "../" in the filename!
207  3779 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  3774 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  7882 toggle private boolean renderSkin(String filename, XWikiDocument doc, XWikiContext context)
232    throws XWikiException, IOException
233    {
234  7898 LOGGER.debug("Rendering file [{}] within the [{}] document", filename, doc.getDocumentReference());
235  7899 try {
236  7899 if (doc.isNew()) {
237  7903 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  7894 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  11662 toggle private boolean renderFileFromFilesystem(String path, XWikiContext context) throws XWikiException
260    {
261  11686 LOGGER.debug("Rendering filesystem file from path [{}]", path);
262   
263  11684 XWikiResponse response = context.getResponse();
264  11681 try {
265  11679 byte[] data;
266  11678 data = context.getWiki().getResourceContentAsBytes(path);
267  1373 if (data != null && data.length > 0) {
268  1372 String filename = path.substring(path.lastIndexOf("/") + 1, path.length());
269   
270  1373 Date modified = null;
271   
272    // Evaluate the file only if it's of a supported type.
273  1372 String mimetype = context.getEngineContext().getMimeType(filename.toLowerCase());
274  1373 if (isCssMimeType(mimetype) || isJavascriptMimeType(mimetype) || isLessCssFile(filename)) {
275    // Always force UTF-8, as this is the assumed encoding for text files.
276  1221 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  1221 DocumentReference superadminUserReference = new DocumentReference(context.getMainXWiki(),
280    XWiki.SYSTEM_SPACE, XWikiRightService.SUPERADMIN_USER);
281  1220 String evaluatedContent =
282    evaluateVelocity(rawContent, path, superadminUserReference, null, context);
283   
284  1221 byte[] newdata = evaluatedContent.getBytes(ENCODING);
285    // If the content contained velocity code, then it should not be cached
286  1221 if (Arrays.equals(newdata, data)) {
287  191 modified = context.getWiki().getResourceLastModificationDate(path);
288    } else {
289  1030 modified = new Date();
290  1030 data = newdata;
291    }
292   
293  1221 response.setCharacterEncoding(ENCODING);
294    } else {
295  152 modified = context.getWiki().getResourceLastModificationDate(path);
296    }
297   
298    // Write the content to the response's output stream.
299  1373 setupHeaders(response, mimetype, modified, data.length);
300  1373 try {
301  1373 response.getOutputStream().write(data);
302    } catch (IOException e) {
303  0 throw new XWikiException(XWikiException.MODULE_XWIKI_APP,
304    XWikiException.ERROR_XWIKI_APP_SEND_RESPONSE_EXCEPTION, "Exception while sending response", e);
305    }
306   
307  1370 return true;
308    }
309    } catch (IOException ex) {
310  10271 LOGGER.info("Skin file [{}] does not exist or cannot be accessed", path);
311    }
312  10303 return false;
313    }
314   
315    /**
316    * Tries to serve the content of an XWikiSkins object field as a skin file.
317    *
318    * @param filename The name of the skin file that should be rendered.
319    * @param doc The skin {@link XWikiDocument document}.
320    * @param context The current {@link XWikiContext request context}.
321    * @return <tt>true</tt> if the object exists, and the field is set to a non-empty value, and its content was
322    * successfully sent.
323    * @throws IOException If the response cannot be sent.
324    */
 
325  0 toggle public boolean renderFileFromObjectField(String filename, XWikiDocument doc, final XWikiContext context)
326    throws IOException
327    {
328  0 LOGGER.debug("... as object property");
329   
330  0 BaseObject object = doc.getObject("XWiki.XWikiSkins");
331  0 String content = null;
332  0 if (object != null) {
333  0 content = object.getStringValue(filename);
334    }
335   
336  0 if (!StringUtils.isBlank(content)) {
337  0 XWiki xwiki = context.getWiki();
338   
339    // Evaluate the file only if it's of a supported type.
340  0 String mimetype = xwiki.getEngineContext().getMimeType(filename.toLowerCase());
341  0 if (isCssMimeType(mimetype) || isJavascriptMimeType(mimetype)) {
342  0 final ObjectPropertyReference propertyReference =
343    new ObjectPropertyReference(filename, object.getReference());
344   
345    // Evaluate the content with the rights of the document's author.
346  0 content = evaluateVelocity(content, propertyReference, doc.getAuthorReference(),
347    doc.getDocumentReference(), context);
348    }
349   
350    // Prepare the response.
351  0 XWikiResponse response = context.getResponse();
352    // Since object fields are read as unicode strings, the result does not depend on the wiki encoding. Force
353    // the output to UTF-8.
354  0 response.setCharacterEncoding(ENCODING);
355   
356    // Write the content to the response's output stream.
357  0 byte[] data = content.getBytes(ENCODING);
358  0 setupHeaders(response, mimetype, doc.getDate(), data.length);
359  0 response.getOutputStream().write(data);
360   
361  0 return true;
362    } else {
363  0 LOGGER.debug("Object field not found or empty");
364    }
365   
366  0 return false;
367    }
368   
 
369  0 toggle private String evaluateVelocity(String content, EntityReference reference, DocumentReference author,
370    final DocumentReference sourceDocument, XWikiContext context)
371    {
372  0 EntityReferenceSerializer<String> serializer = Utils.getComponent(EntityReferenceSerializer.TYPE_STRING);
373  0 String namespace = serializer.serialize(reference);
374   
375  0 return evaluateVelocity(content, namespace, author, sourceDocument, context);
376    }
377   
 
378  1217 toggle private String evaluateVelocity(final String content, final String namespace, final DocumentReference author,
379    final DocumentReference sourceDocument, final XWikiContext context)
380    {
381  1218 String result = content;
382   
383  1221 try {
384  1220 result = Utils.getComponent(AuthorExecutor.class)
385    .call(() -> context.getWiki().evaluateVelocity(content, namespace), author, sourceDocument);
386    } catch (Exception e) {
387    // Should not happen since there is nothing in the call() method throwing an exception.
388  0 LOGGER.error("Failed to evaluate velocity content for namespace {} with the rights of the user {}",
389    namespace, author, e);
390    }
391   
392  1221 return result;
393    }
394   
395    /**
396    * Tries to serve the content of an attachment as a skin file.
397    *
398    * @param filename The name of the skin file that should be rendered.
399    * @param doc The skin {@link XWikiDocument document}.
400    * @param context The current {@link XWikiContext request context}.
401    * @return <tt>true</tt> if the attachment was found and its content was successfully sent.
402    * @throws IOException If the response cannot be sent.
403    * @throws XWikiException If the attachment cannot be loaded.
404    */
 
405  0 toggle public boolean renderFileFromAttachment(String filename, XWikiDocument doc, XWikiContext context)
406    throws IOException, XWikiException
407    {
408  0 LOGGER.debug("... as attachment");
409   
410  0 XWikiAttachment attachment = doc.getAttachment(filename);
411  0 if (attachment != null) {
412  0 XWiki xwiki = context.getWiki();
413  0 XWikiResponse response = context.getResponse();
414   
415    // Evaluate the file only if it's of a supported type.
416  0 String mimetype = xwiki.getEngineContext().getMimeType(filename.toLowerCase());
417  0 if (isCssMimeType(mimetype) || isJavascriptMimeType(mimetype)) {
418  0 byte[] data = attachment.getContent(context);
419    // Always force UTF-8, as this is the assumed encoding for text files.
420  0 String velocityCode = new String(data, ENCODING);
421   
422    // Evaluate the content with the rights of the document's author.
423  0 String evaluatedContent =
424    evaluateVelocity(velocityCode, attachment.getReference(), doc.getAuthorReference(),
425    doc.getDocumentReference(), context);
426   
427    // Prepare the response.
428  0 response.setCharacterEncoding(ENCODING);
429   
430    // Write the content to the response's output stream.
431  0 data = evaluatedContent.getBytes(ENCODING);
432  0 setupHeaders(response, mimetype, attachment.getDate(), data.length);
433  0 response.getOutputStream().write(data);
434    } else {
435    // Otherwise, return the raw content.
436  0 setupHeaders(response, mimetype, attachment.getDate(), attachment.getContentSize(context));
437  0 IOUtils.copy(attachment.getContentInputStream(context), response.getOutputStream());
438    }
439   
440  0 return true;
441    } else {
442  0 LOGGER.debug("Attachment not found");
443    }
444   
445  0 return false;
446    }
447   
448    /**
449    * Checks if a mimetype indicates a javascript file.
450    *
451    * @param mimetype The mime type to check.
452    * @return <tt>true</tt> if the mime type represents a javascript file.
453    */
 
454  835 toggle public boolean isJavascriptMimeType(String mimetype)
455    {
456  835 boolean result =
457    "text/javascript".equalsIgnoreCase(mimetype) || "application/x-javascript".equalsIgnoreCase(mimetype)
458    || "application/javascript".equalsIgnoreCase(mimetype);
459  835 result |= "application/ecmascript".equalsIgnoreCase(mimetype) || "text/ecmascript".equalsIgnoreCase(mimetype);
460  835 return result;
461    }
462   
463    /**
464    * Checks if a mimetype indicates a CSS file.
465    *
466    * @param mimetype The mime type to check.
467    * @return <tt>true</tt> if the mime type represents a css file.
468    */
 
469  1370 toggle public boolean isCssMimeType(String mimetype)
470    {
471  1369 return "text/css".equalsIgnoreCase(mimetype);
472    }
473   
474    /**
475    * Checks if a file is a LESS file that should be parsed by velocity.
476    *
477    * @param filename name of the file to check.
478    * @return <tt>true</tt> if the filename represents a LESS.vm file.
479    */
 
480  153 toggle private boolean isLessCssFile(String filename)
481    {
482  153 return filename.toLowerCase().endsWith(".less.vm");
483    }
484   
485    /**
486    * Sets several headers to properly identify the response.
487    *
488    * @param response The servlet response object, where the headers should be set.
489    * @param mimetype The mimetype of the file. Used in the "Content-Type" header.
490    * @param lastChanged The date of the last change of the file. Used in the "Last-Modified" header.
491    * @param length The length of the content (in bytes). Used in the "Content-Length" header.
492    */
 
493  1373 toggle protected void setupHeaders(XWikiResponse response, String mimetype, Date lastChanged, int length)
494    {
495  1372 if (!StringUtils.isBlank(mimetype)) {
496  1274 response.setContentType(mimetype);
497    } else {
498  99 response.setContentType("application/octet-stream");
499    }
500  1373 response.setDateHeader("Last-Modified", lastChanged.getTime());
501    // Cache for one month (30 days)
502  1373 response.setHeader("Cache-Control", "public");
503  1373 response.setDateHeader("Expires", (new Date()).getTime() + 30 * 24 * 3600 * 1000L);
504  1373 response.setContentLength(length);
505    }
506    }