/*
* Copyright (c) 2015 EMC Corporation
* All Rights Reserved
*/
package plugin;
import java.io.File;
import java.io.Serializable;
import java.util.Date;
import java.util.Set;
import org.apache.commons.lang.StringUtils;
import org.lesscss.LessCompiler;
import org.lesscss.LessSource;
import play.Logger;
import play.Play;
import play.PlayPlugin;
import play.cache.Cache;
import play.mvc.Http;
import play.mvc.Http.Request;
import play.mvc.Http.Response;
import play.utils.Utils;
import play.vfs.VirtualFile;
import com.google.common.collect.Sets;
public class LessPlugin extends PlayPlugin {
private static final String ETAG = "ETag";
private static final String LAST_MODIFIED = "Last-Modified";
private LessCompiler compiler = new LessCompiler();
/** The root path under which to process CSS requests (defaults to '/public/'). */
private String rootPath = "/public/";
/** Tracks whether the application is started (determines if caching is possible or not). */
private boolean started;
@Override
public void onApplicationStart() {
started = true;
rootPath = Play.configuration.getProperty("less.rootPath", "/public/");
compiler = new LessCompiler();
compiler.setCompress(isCompressEnabled());
}
/**
* Determines if compressing the CSS is enabled. This defaults to true in PROD mode and false otherwise.
*/
private boolean isCompressEnabled() {
String defaultValue = Play.mode.isProd() ? "true" : "false";
return Play.configuration.getProperty("less.compress", defaultValue).equals("true");
}
/**
* Gets the less source file for this request. If the request should be ignored by the plugin, null is returned.
*
* @param request
* the raw request.
* @return the less source, or null.
*/
private VirtualFile getLessSourceFile(Request request) {
// Only process .css requests within the configured root path
if (!(request.path.startsWith(rootPath) && request.path.endsWith(".css"))) {
return null;
}
VirtualFile cssFile = VirtualFile.fromRelativePath(request.path);
// If the file exists, don't even check less files in prod mode
if (Play.mode.isProd() && cssFile.exists()) {
return null;
}
// Check for a .less file with the same base name
VirtualFile lessFile = VirtualFile.fromRelativePath(StringUtils.removeEnd(request.path, ".css") + ".less");
return (lessFile.exists() && !lessFile.isDirectory()) ? lessFile : null;
}
@Override
public boolean rawInvocation(Request request, Response response) throws Exception {
VirtualFile lessFile = getLessSourceFile(request);
if (lessFile == null) {
return false;
}
response.contentType = "text/css";
try {
processLess(lessFile, request, response);
} catch (Exception e) {
error(response, lessFile, e);
}
return true;
}
/**
* Processes a less file and returns the compiled CSS.
*
* @param file
* the less file.
* @param request
* the HTTP request.
* @param response
* the HTTP response.
*
* @throws Exception
* if an error occurs (typically LESS compilation).
*/
private void processLess(VirtualFile file, Request request, Response response) throws Exception {
long lastModified = getLastModified(file);
if (lastModified < 0) {
sendOk(response, file);
}
else {
String etag = getETag(file, lastModified);
if (request.isModified(etag, lastModified)) {
sendOk(response, file);
}
else {
sendNotModified(response, etag, lastModified);
}
}
}
/**
* Sends the compiled CSS for the given LESS file. If a compiled CSS exists for this path with the same modification
* time it will be returned, otherwise the LESS will be compiled and cached for future requests.
*
* @param response
* the HTTP response to write to.
* @param file
* the LESS file.
*
* @throws Exception
* if an error occurs (typically a LESS compilation).
*/
private void sendOk(Response response, VirtualFile file) throws Exception {
LessSource source = new LessSource(file.getRealFile());
long lastModified = source.getLastModifiedIncludingImports();
String etag = getETag(file, lastModified);
String content = compileToCss(source, lastModified);
response.status = Http.StatusCode.OK;
response.setHeader(LAST_MODIFIED, Utils.getHttpDateFormatter().format(new Date(lastModified)));
response.setHeader(ETAG, etag);
response.print(content);
}
/**
* Sends a Not Modified response.
*
* @param response
* the HTTP response to write to.
* @param etag
* the ETag for the CSS.
* @param lastModified
* the last modified time of the CSS.
*/
private void sendNotModified(Response response, String etag, long lastModified) {
response.status = Http.StatusCode.NOT_MODIFIED;
response.setHeader(ETAG, etag);
}
/**
* Sends an Internal Server Error response.
*
* @param response
* the HTTP response to write to.
* @param file
* the requested LESS file.
* @param e
* the error that occurred.
*/
private void error(Response response, VirtualFile file, Exception e) {
response.status = Http.StatusCode.INTERNAL_ERROR;
response.print(StringUtils.defaultString(e.getMessage(), e.getClass().getName()));
Logger.error(e, "Less Compilation Failed: %s", file.relativePath());
}
/**
* Gets the last modified time for a LESS file. The cached related files for this LESS files are used to find the
* most recently modified in the collection.
*
* @param lessFile
* the LESS file.
* @return the last modified time of all the LESS files, or -1 if no previous modified time is known.
*/
private long getLastModified(VirtualFile lessFile) {
long lastModified = -1;
Set<File> relatedFiles = getCachedRelatedFiles(lessFile);
if (relatedFiles != null) {
for (File relatedFile : relatedFiles) {
lastModified = Math.max(lastModified, relatedFile.lastModified());
}
}
return lastModified;
}
/**
* Gets the HTTP ETag to use for this LESS file.
*
* @param lessFile
* the LESS file.
* @param lastModified
* the last modified time of all the related LESS files.
* @return the ETag header value.
*/
private String getETag(VirtualFile lessFile, long lastModified) {
return "\"" + lessFile.relativePath() + "-" + lastModified + "\"";
}
/**
* Gets the related files for a given LESS file, if known.
*
* @param lessFile
* the LESS file.
* @return the set of related files, if cached.
*/
private Set<File> getCachedRelatedFiles(VirtualFile lessFile) {
return getCached("LESS:" + lessFile.getRealFile().getAbsolutePath());
}
/**
* Caches all related files for this LESS source. This includes the LESS file and all imports.
*
* @param source
* the LESS source.
*/
private void cacheRelatedFiles(LessSource source) {
Set<File> files = Sets.newHashSet();
addRelatedFiles(source, files);
setCached("LESS:" + source.getAbsolutePath(), files);
}
/**
* Adds related files to the set of files. This recursively adds LESS sources and imports.
*
* @param source
* the LESS source.
* @param files
* the files to add to.
*/
private void addRelatedFiles(LessSource source, Set<File> files) {
files.add(new File(source.getAbsolutePath()));
for (LessSource lessImport : source.getImports().values()) {
addRelatedFiles(lessImport, files);
}
}
/**
* Compiles the LESS source into CSS, returning a cached version if possible.
*
* @param source
* the LESS source.
* @param lastModified
* the last modified time of the LESS files.
* @return the compiled CSS.
*
* @throws Exception
* if an error occurs (LESS compilation).
*/
private String compileToCss(LessSource source, long lastModified) throws Exception {
cacheRelatedFiles(source);
String cacheKey = "CompiledCss:" + source.getAbsolutePath();
CompiledCss compiledCss = getCached(cacheKey);
if ((compiledCss != null) && (compiledCss.lastModified == lastModified)) {
return compiledCss.content;
}
Logger.info("Compiling LESS: %s, lastModified: %s", source.getAbsolutePath(), lastModified);
String content = compiler.compile(source);
setCached(cacheKey, new CompiledCss(content, lastModified));
return content;
}
@SuppressWarnings("unchecked")
private <T> T getCached(String key) {
if (started) {
return (T) Cache.get(key);
}
return null;
}
private <T> T setCached(String key, T value) {
if (started) {
Cache.set(key, value);
}
return value;
}
/**
* Class for caching the compiled CSS result of a LESS compilation.
*/
@SuppressWarnings("serial")
private static class CompiledCss implements Serializable {
public String content;
public long lastModified;
public CompiledCss(String content, long lastModified) {
this.content = content;
this.lastModified = lastModified;
}
}
}