/**
* Licensed to Apereo under one or more contributor license
* agreements. See the NOTICE file distributed with this work
* for additional information regarding copyright ownership.
* Apereo 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 the following location:
*
* 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.apereo.portal.portlets.dynamicskin.storage;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.net.URL;
import java.text.MessageFormat;
import java.util.Map.Entry;
import java.util.Set;
import java.util.SortedSet;
import java.util.TreeSet;
import java.util.concurrent.CopyOnWriteArraySet;
import javax.portlet.PortletContext;
import javax.portlet.PortletRequest;
import javax.xml.parsers.DocumentBuilder;
import javax.xml.parsers.DocumentBuilderFactory;
import javax.xml.parsers.ParserConfigurationException;
import net.sf.ehcache.Cache;
import net.sf.ehcache.Element;
import org.apache.commons.io.IOUtils;
import org.apache.commons.lang3.StringUtils;
import org.apereo.portal.portlets.dynamicskin.DynamicRespondrSkinConstants;
import org.apereo.portal.portlets.dynamicskin.DynamicSkinException;
import org.apereo.portal.portlets.dynamicskin.DynamicSkinInstanceData;
import org.apereo.portal.portlets.dynamicskin.DynamicSkinUniqueTokenGenerator;
import org.lesscss.LessCompiler;
import org.lesscss.LessException;
import org.lesscss.LessSource;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.util.Assert;
import org.w3c.dom.Document;
import org.w3c.dom.NodeList;
import org.xml.sax.SAXException;
/**
* Abstract base class for {@link DynamicSkinService} classes.
*/
public abstract class AbstractDynamicSkinService implements DynamicSkinService {
protected static final String LESS_CSS_JAVASCRIPT_URL = "/media/skins/common/javascript/less/less-1.6.2.js";
protected static final String DYNASKIN_TEMPLATE_INCLUDE_FILE = "{0}/{1}.less";
protected static final String DYNASKIN_INCLUDE_FILE = "{0}/configuredSkin-{1}.less";
protected static final String DYNAMIC_SKIN_FILENAME_BASE = "skin";
protected final Logger log = LoggerFactory.getLogger(getClass());
protected MessageFormat skinTemplateIncludeFile = new MessageFormat(DYNASKIN_TEMPLATE_INCLUDE_FILE);
protected MessageFormat skinIncludeFile = new MessageFormat(DYNASKIN_INCLUDE_FILE);
protected String localRelativeRootPath = DynamicRespondrSkinConstants.DEFAULT_RELATIVE_ROOT_FOLDER;
protected String lessCssJavascriptUrlPath = LESS_CSS_JAVASCRIPT_URL;
/**
* Set of CSS instance keys for skin files that currently exist. Thread-safe for concurrent reads and inserts.
*/
protected Set<String> instanceKeysForExistingCss = new CopyOnWriteArraySet<String>();
protected Cache cssSkinFailureCache;
private DynamicSkinUniqueTokenGenerator uniqueTokenGenerator;
private DynamicSkinCssFileNamer cssFileNamer;
public AbstractDynamicSkinService(
final DynamicSkinUniqueTokenGenerator uniqueTokenGenerator,
final DynamicSkinCssFileNamer namer,
final Cache failureCache) {
Assert.notNull(failureCache);
Assert.notNull(uniqueTokenGenerator);
Assert.notNull(namer);
this.cssSkinFailureCache = failureCache;
this.uniqueTokenGenerator = uniqueTokenGenerator;
this.cssFileNamer = namer;
}
@Override
abstract public String getSkinCssPath(DynamicSkinInstanceData data);
public String getSkinLessTemplatePath(DynamicSkinInstanceData data) {
final String templateRelativePath =
this.skinTemplateIncludeFile.format(new Object[] {this.localRelativeRootPath, data.getSkinName()});
return data.getPortletAbsolutePathRoot() + templateRelativePath;
}
public String getSkinLessPath(DynamicSkinInstanceData data) {
final String includeRelativePath =
this.skinIncludeFile.format(new Object[] {this.localRelativeRootPath, this.getUniqueToken(data)});
return data.getPortletAbsolutePathRoot() + includeRelativePath;
}
/**
* Return true if the skin file already exists. Check memory first in a concurrent manner to allow multiple
* threads to check simultaneously.
*
* @param filePathname Fully-qualified file path name of the .css file
* @return True if file exists on the file system.
*/
@Override
public boolean skinCssFileExists(DynamicSkinInstanceData data) {
final String cssInstanceKey = this.getCssInstanceKey(data);
// Check the existing map first since it is faster than accessing the actual file.
if (this.instanceKeysForExistingCss.contains(cssInstanceKey)) {
return true;
}
boolean exists = this.innerSkinCssFileExists(data);
if (exists) {
if (!this.supportsRetainmentOfNonCurrentCss()) {
this.instanceKeysForExistingCss.clear();
}
this.instanceKeysForExistingCss.add(cssInstanceKey);
}
return exists;
}
protected String getCssInstanceKey(DynamicSkinInstanceData data) {
return data.getSkinName() + this.getUniqueToken(data);
}
/**
* Methods that subclasses should define to return true if they support retaining of non-current CSS files (meaning
* that when changes are made both the old and the new compiled CSS are accessible), or false if only a single CSS
* with the latest updates is accessible. Returning true would prevent recompilation if, for example, some skin
* changes are made but then are changed back to the original values. If this method returns true, then that
* requires the subclass to be able to map CSS instance keys to the proper corresponding CSS files.
*
* @return true if retainment of old CSS files is supported; false otherwise
*/
abstract protected boolean supportsRetainmentOfNonCurrentCss();
/**
* Method that subclasses should define to do expensive check to see if the skin CSS file exists. This method
* should differ from {@link #skinFileExists(DynamicSkinInstanceData)} in that it does actual check for the
* existence of the file on every request versus only when an in-memory cached marker value is not found.
*
* @see #compiledCssFilepaths
* @param data skin instance data
* @return true if css file exists, false otherwise
*/
abstract protected boolean innerSkinCssFileExists(DynamicSkinInstanceData data);
/**
* Creates the skin css file in a thread-safe manner that allows multiple different skin files to be created
* simultaneously to handle large tenant situations where all the custom CSS files were cleared away after a
* uPortal deploy.
*
* Since the less compilation phase is fairly slow (several seconds) and intensive, this method will
* allow multiple threads to process different less compilations at the same time but ensure the same
* output file will not be created multiple times. Also this method will not let a bad LESS file cause repeated
* LESS compilations and completely take down the portal. The bad file will be blacklisted for a period
* of time to limit performance impacts.
*
* @see DynamicSkinService#generateSkinCssFile(DynamicSkinInstanceData)
*/
@Override
public void generateSkinCssFile(DynamicSkinInstanceData data) {
final String cssInstanceKey = this.getCssInstanceKey(data);
synchronized(cssInstanceKey) {
if (this.instanceKeysForExistingCss.contains(cssInstanceKey)) {
/*
* Two or more threads needing the same CSS file managed to invoke
* this method. An earlier thread has already generated the file
* we need. Concurrency features of the CopyOnWriteArraySet
* (compiledCssFilepaths) guarantee that we will enter this if {}
* block (and exit) for a filePathname that's been successfully
* generated by another thread.
*/
return;
}
try {
if (!this.cssSkinFailureCache.getKeysWithExpiryCheck().contains(cssInstanceKey)) {
this.createLessIncludeFile(data);
this.processLessFile(data);
if (!this.supportsRetainmentOfNonCurrentCss()) {
this.instanceKeysForExistingCss.clear();
}
this.instanceKeysForExistingCss.add(cssInstanceKey);
} else {
// Though this should never happen except when developers are modifying the LESS files and make a mistake,
// if we previously tried to create the CSS file and failed for some reason, don't try to compile it
// again for a bit since the process is so processor intensive. It would virtually hang the uPortal
// service trying to compile a bad LESS file repeatedly on different threads.
log.warn("Skipping generation of CSS file {} due to previous LESS compilation failures", cssInstanceKey);
}
} catch (Exception e) {
this.cssSkinFailureCache.put(new Element(cssInstanceKey, cssInstanceKey));
throw new RuntimeException("Error compiling the LESS file to create: " + cssInstanceKey, e);
}
}
}
/**
* Create the less include file by appending the configurable preference definitions (minus the configuration
* prefix string) to the end of the template file; e.g. portlet preference name
* PREFcolor1 is written to the less file as @color1:prefValue
*
* For preferences that end in "URL" or "Url", the values must be written as: url('<value>');
* So for example, preference PREFmyImageUrl with value "http://fake.site/images/blah.png" would be written to the
* less file as @myImageUrl: url('http://fake.site/images/blah.png');
*
* @param prefs Portlet preferences
* @param filename name of the less include file to create
* @param templateFile template less include file
* @throws IOException
*/
protected void createLessIncludeFile(DynamicSkinInstanceData data) throws IOException {
// Create a set of less variable assignments.
final StringBuilder str = new StringBuilder();
for (Entry<String, String> entry : data.getVariableNameToValueMap().entrySet()) {
this.appendPrefAsVariable(str, entry.getKey(), entry.getValue());
}
// Create byte[]s of the template and preferences content
byte[] prefsContent = str.toString().getBytes();
File f = new File(this.getSkinLessTemplatePath(data));
byte[] templateContent = IOUtils.toByteArray(f.toURI());
// Create a less include file by appending the less variable definitions to the end of the template less
// include file. Insure there is a newline at the end of the template content or the first preference
// value will be lost.
byte[] newline = "\n".getBytes();
byte[] fileContent = new byte[templateContent.length + newline.length + prefsContent.length];
System.arraycopy(templateContent, 0, fileContent, 0, templateContent.length);
System.arraycopy(newline, 0, fileContent, templateContent.length, newline.length);
System.arraycopy(prefsContent, 0, fileContent, templateContent.length + newline.length, prefsContent.length);
File lessInclude = new File(this.getSkinLessPath(data));
IOUtils.write(fileContent, new FileOutputStream(lessInclude));
}
private void appendPrefAsVariable(final StringBuilder str, final String name, final String value) {
if (StringUtils.isBlank(value)) {
log.warn("Dynamic Skin Variable \"{}\" is not set", name);
} else {
str.append("@").append(name).append(": ").append(value).append(";\n");
}
}
/**
* Less compile the include file into a temporary css file. When done rename the temporary css file to the
* correct output filename. Since the less compilation phase takes several seconds, this insures the
* output css file is does not exist on the filesystem until it is complete.
*
* @param lessIncludeFilepath less include file that includes all dependencies
* @param outputFilepath name of the output css file
* @param lessCssJavascriptUrl lessCssJavascript compiler url
* @throws IOException
* @throws LessException
*/
private void processLessFile(DynamicSkinInstanceData data) throws IOException, LessException {
final PortletContext ctx = data.getPortletRequest().getPortletSession().getPortletContext();
final URL lessCssJavascriptUrl = ctx.getResource(this.lessCssJavascriptUrlPath);
final LessSource lessSource = new LessSource(new File(this.getSkinLessPath(data)));
if (log.isDebugEnabled()) {
final String result = lessSource.getNormalizedContent();
final File lessSourceOutput = new File(this.getSkinCssTempFileAbsolutePath(data) + "lesssource");
IOUtils.write(result, new FileOutputStream(lessSourceOutput));
log.debug(
"Full Less source from include file {0}, using lessCssJavascript at {1}"
+ ", is at {2}, output css will be written to {3}",
this.getSkinLessPath(data),
lessCssJavascriptUrl.toString(),
lessSourceOutput,
this.getSkinCssPath(data));
}
final LessCompiler compiler = new LessCompiler();
compiler.setLessJs(lessCssJavascriptUrl);
compiler.setCompress(true);
final File tempOutputFile = new File(this.getSkinCssTempFileAbsolutePath(data));
compiler.compile(lessSource, tempOutputFile);
this.moveCssFileToFinalLocation(data, tempOutputFile);
}
protected String getLocalRootAbsoluteFilepath(DynamicSkinInstanceData data) {
return data.getPortletAbsolutePathRoot() + this.localRelativeRootPath;
}
protected String getSkinCssFilename(final DynamicSkinInstanceData data) {
final String result = this.cssFileNamer.generateCssFileName(data);
if (StringUtils.isBlank(result)) {
throw new DynamicSkinException("Dynamic Skin CSS filename cannot be null or empty.");
}
return result;
}
protected String getSkinCssTempFileAbsolutePath(DynamicSkinInstanceData data) {
return this.getLocalRootAbsoluteFilepath(data) + this.getSkinCssFilename(data);
}
abstract protected void moveCssFileToFinalLocation(DynamicSkinInstanceData data, final File tempCssFile);
protected String getUniqueToken(DynamicSkinInstanceData data) {
final String result = this.uniqueTokenGenerator.generateToken(data);
if (StringUtils.isBlank(result)) {
throw new DynamicSkinException("Dynamic Skin unique token cannot be null or empty.");
}
return result;
}
@Override
/**
* Returns the set of skins to use. This implementation parses the skinList.xml file and returns the set of
* skin-key element values. If there is an error parsing the XML file, return an empty set.
*/
public SortedSet<String> getSkinNames(PortletRequest request) {
// Context to access the filesystem
PortletContext ctx = request.getPortletSession().getPortletContext();
// Determine the full path to the skins directory
String skinsFilepath = ctx.getRealPath(this.localRelativeRootPath + "/skinList.xml");
// Create File object to access the filesystem
File skinList = new File(skinsFilepath);
TreeSet<String> skins = new TreeSet<>();
try {
DocumentBuilderFactory dbFactory
= DocumentBuilderFactory.newInstance();
DocumentBuilder dBuilder = dbFactory.newDocumentBuilder();
Document doc = dBuilder.parse(skinList);
doc.getDocumentElement().normalize();
NodeList nList = doc.getElementsByTagName("skin-key");
for (int temp = 0; temp < nList.getLength(); temp++) {
org.w3c.dom.Element element = (org.w3c.dom.Element) nList.item(temp);
String skinName = element.getTextContent();
log.debug("Found skin-key value {}", skinName);
skins.add(skinName);
}
} catch (SAXException | ParserConfigurationException | IOException e) {
log.error("Error processing skinsFilepath {}", skinsFilepath, e);
}
return skins;
}
@Value("${dynamic-skin.less-css-javascript-location:" + LESS_CSS_JAVASCRIPT_URL + "}")
public void setLessCssJavascriptUrlPath(String lessCssJavascriptUrlPath) {
this.lessCssJavascriptUrlPath = lessCssJavascriptUrlPath;
}
public void setLocalRelativeRootPath(String path) {
this.localRelativeRootPath = path;
}
public void setUniqueTokenGenerator(final DynamicSkinUniqueTokenGenerator tokenGenerator) {
this.uniqueTokenGenerator = tokenGenerator;
}
public void setSkinTemplateIncludeFile(String skinTemplateIncludeFile) {
this.skinTemplateIncludeFile = new MessageFormat(skinTemplateIncludeFile);
}
public void setSkinIncludeFile(String skinIncludeFile) {
this.skinIncludeFile = new MessageFormat(skinIncludeFile);
}
}