jodd.htmlstapler.HtmlStaplerBundlesManager Maven / Gradle / Ivy
// Copyright (c) 2003-present, Jodd Team (http://jodd.org)
// All rights reserved.
//
// Redistribution and use in source and binary forms, with or without
// modification, are permitted provided that the following conditions are met:
//
// 1. Redistributions of source code must retain the above copyright notice,
// this list of conditions and the following disclaimer.
//
// 2. Redistributions in binary form must reproduce the above copyright
// notice, this list of conditions and the following disclaimer in the
// documentation and/or other materials provided with the distribution.
//
// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
// AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
// IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
// ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
// LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
// CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
// SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
// INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
// CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
// ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
// POSSIBILITY OF SUCH DAMAGE.
package jodd.htmlstapler;
import jodd.crypt.DigestEngine;
import jodd.io.FileNameUtil;
import jodd.io.FileUtil;
import jodd.io.NetUtil;
import jodd.io.ZipUtil;
import jodd.io.findfile.FindFile;
import jodd.log.Logger;
import jodd.log.LoggerFactory;
import jodd.system.SystemUtil;
import jodd.util.Base32;
import jodd.util.CharUtil;
import jodd.util.RandomString;
import jodd.util.StringBand;
import jodd.util.StringPool;
import jodd.util.StringUtil;
import java.io.File;
import java.io.IOException;
import java.util.Arrays;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* HTML resources bundles manager.
*/
public class HtmlStaplerBundlesManager {
private static final Logger log = LoggerFactory.getLogger(HtmlStaplerBundlesManager.class);
protected int bundleCount; // counter for new bundles
protected Map actionBundles; // action -> bundleId/digest
protected Map mirrors; // temp id -> bundleId
protected final String webRoot;
protected final String contextPath;
protected final Strategy strategy;
// parameters
protected String localFilesEncoding = StringPool.UTF_8;
protected String bundleFolder;
protected String staplerPath = "jodd-bundle";
protected String localAddressAndPort = "http://localhost:8080";
protected boolean downloadLocal;
protected boolean sortResources;
protected boolean notFoundExceptionEnabled = true;
protected int randomDigestChars = 0;
private static String uniqueDigestKey;
// ---------------------------------------------------------------- strategy
public enum Strategy {
/**
* For each action manager stores the bundle id.
* This gives top performances, as the links are collected only
* once per page and bundle id (digest) is calculated only once
* as well, only first time page is accessed. However, if there
* are dynamic REST-alike links, there might be a large or infinite
* number of action links in the application. This can be handled
* in the code by overriding method resolveRealActionPath()
.
*/
ACTION_MANAGED,
/**
* Pragmatic strategy that collects all links and builds bundle id
* (digest) every time when page is processed. This gives slightly
* slower performances, but there is no additional memory consumption.
*/
RESOURCES_ONLY
}
// ---------------------------------------------------------------- init
/**
* Creates new instance and initialize it.
*/
public HtmlStaplerBundlesManager(final String contextPath, final String webRoot, final Strategy strategy) {
this.contextPath = contextPath;
this.webRoot = webRoot;
this.strategy = strategy;
this.bundleFolder = SystemUtil.info().getTempDir();
if (strategy == Strategy.ACTION_MANAGED) {
actionBundles = new HashMap<>();
mirrors = new HashMap<>();
}
}
/**
* Starts bundle usage by creating new {@link BundleAction}.
*/
public BundleAction start(final String servletPath, final String bundleName) {
return new BundleAction(this, servletPath, bundleName);
}
// ---------------------------------------------------------------- params
/**
* Returns current {@link Strategy strategy}.
*/
public Strategy getStrategy() {
return strategy;
}
/**
* Returns true
if resources are sorted before
* bundle id (a digest) is created. When sorting is enabled,
* two pages will share the same bundle even if they list
* resources in different order.
*/
public boolean isSortResources() {
return sortResources;
}
/**
* Sets the resources sorting before bundle id (i.e. a digest)
* is created.
*/
public void setSortResources(final boolean sortResources) {
this.sortResources = sortResources;
}
/**
* Returns current web root.
*/
public String getWebRoot() {
return webRoot;
}
/**
* Returns bundles folder. By default, it is a system temp folder.
*/
public String getBundleFolder() {
return bundleFolder;
}
/**
* Sets bundle folder.
*/
public void setBundleFolder(final String bundleFolder) {
this.bundleFolder = bundleFolder;
}
/**
* Returns stapler path. It is both the file system folder
* name and the web folder name.
*/
public String getStaplerPath() {
return staplerPath;
}
/**
* Sets stapler path.
*/
public void setStaplerPath(final String staplerPath) {
this.staplerPath = staplerPath;
}
/**
* Returns local files encoding. By default its UTF8.
*/
public String getLocalFilesEncoding() {
return localFilesEncoding;
}
/**
* Sets local files encoding.
*/
public void setLocalFilesEncoding(final String localFilesEncoding) {
this.localFilesEncoding = localFilesEncoding;
}
/**
* Returns local address and port for downloading
* local resources.
*/
public String getLocalAddressAndPort() {
return localAddressAndPort;
}
/**
* Specifies local address and port for downloading
* local resources. By default its "http://localhost:8080".
*/
public void setLocalAddressAndPort(final String localAddressAndPort) {
this.localAddressAndPort = localAddressAndPort;
}
/**
* Returns true
if local resource files are downloaded
* and not loaded from file system.
*/
public boolean isDownloadLocal() {
return downloadLocal;
}
/**
* Sets if local resource files should be downloaded or loaded from file system.
*/
public void setDownloadLocal(final boolean downloadLocal) {
this.downloadLocal = downloadLocal;
}
/**
* Returns true
if exception will be thrown when
* resource is not found.
*/
public boolean isNotFoundExceptionEnabled() {
return notFoundExceptionEnabled;
}
/**
* Sets if exception should be thrown when some resource is not found.
* If not enabled, the error will be logged as a warning.
*/
public void setNotFoundExceptionEnabled(final boolean notFoundExceptionEnabled) {
this.notFoundExceptionEnabled = notFoundExceptionEnabled;
}
/**
* Returns the number of random digest chars.
*/
public int getRandomDigestChars() {
return randomDigestChars;
}
/**
* Sets the number of random characters that will be appended to the
* {@link #createDigest(String) digest}. When it is set to 0, nothing
* will be appended to the digest. Otherwise, a random string will be
* generated (containing only letters and digits) and appended to the
* digest.
*
* Random digest chars is a unique key per one VM!
* This key is initialized only once.
* This is useful to automatically expire any cache that browsers may have in
* JS and CSS files, so that changes in those files will be downloaded by the
* browser.
*/
public void setRandomDigestChars(final int randomDigestChars) {
this.randomDigestChars = randomDigestChars;
if (randomDigestChars == 0) {
uniqueDigestKey = null;
}
else {
uniqueDigestKey = new RandomString().randomAlphaNumeric(randomDigestChars);
}
}
// ---------------------------------------------------------------- lookup
/**
* Creates bundle file in bundleFolder/staplerPath. Only file object
* is created, not the file content.
*/
protected File createBundleFile(final String bundleId) {
File folder = new File(bundleFolder, staplerPath);
if (!folder.exists()) {
folder.mkdirs();
}
return new File(folder, bundleId);
}
/**
* Lookups for bundle file.
*/
public File lookupBundleFile(String bundleId) {
if ((mirrors != null) && (!mirrors.isEmpty())) {
String realBundleId = mirrors.remove(bundleId);
if (realBundleId != null) {
bundleId = realBundleId;
}
}
return createBundleFile(bundleId);
}
/**
* Locates gzipped version of bundle file. If gzip file
* does not exist, it will be created.
*/
public File lookupGzipBundleFile(final File file) throws IOException {
String path = file.getPath() + ZipUtil.GZIP_EXT;
File gzipFile = new File(path);
if (!gzipFile.exists()) {
if (log.isDebugEnabled()) {
log.debug("gzip bundle to " + path);
}
ZipUtil.gzip(file);
}
return gzipFile;
}
/**
* Lookups for a bundle id for a given action.
* Returns null
if action still has no bundle.
* Returns an empty string if action has an empty bundle.
*/
public String lookupBundleId(final String actionPath) {
return actionBundles.get(actionPath);
}
// ---------------------------------------------------------------- register
/**
* Registers new, temporary bundle id for given action path.
* This id is used on first bundle usage, later it will be replaces with
* real bundle id.
*/
public String registerNewBundleId() {
bundleCount++;
return String.valueOf(bundleCount);
}
/**
* Registers new bundle that consist of provided list of source paths.
* Returns the real bundle id, as provided one is just a temporary bundle id.
*/
public synchronized String registerBundle(final String contextPath, final String actionPath, final String tempBundleId, final String bundleContentType, final List sources) {
if (tempBundleId == null || sources.isEmpty()) {
if (strategy == Strategy.ACTION_MANAGED) {
// page does not include any resource source file
actionBundles.put(actionPath, StringPool.EMPTY);
}
return null;
}
// create unique digest from the collected sources
String[] sourcesArray = sources.toArray(new String[0]);
for (int i = 0, sourcesArrayLength = sourcesArray.length; i < sourcesArrayLength; i++) {
sourcesArray[i] = sourcesArray[i].trim().toLowerCase();
}
if (sortResources) {
Arrays.sort(sourcesArray);
}
StringBand sb = new StringBand(sourcesArray.length);
for (String src : sourcesArray) {
sb.append(src);
}
String sourcesString = sb.toString();
String bundleId = createDigest(sourcesString);
bundleId += '.' + bundleContentType;
// bundle appears for the first time, create the bundle
if (strategy == Strategy.ACTION_MANAGED) {
actionBundles.put(actionPath, bundleId);
mirrors.put(tempBundleId, bundleId);
}
try {
createBundle(contextPath, actionPath, bundleId, sources);
} catch (IOException ioex) {
throw new HtmlStaplerException("Can't create bundle", ioex);
}
return bundleId;
}
/**
* Creates digest i.e. bundle id from given string.
* Returned digest must be filename safe, for all platforms.
*/
protected String createDigest(final String source) {
final DigestEngine digestEngine = DigestEngine.sha256();
final byte[] bytes = digestEngine.digest(CharUtil.toSimpleByteArray(source));
String digest = Base32.encode(bytes);
if (uniqueDigestKey != null) {
digest += uniqueDigestKey;
}
return digest;
}
/**
* Creates bundle file by loading resource files content. If bundle file already
* exist it will not be recreated!
*/
protected void createBundle(final String contextPath, final String actionPath, final String bundleId, final Listsources) throws IOException {
final File bundleFile = createBundleFile(bundleId);
if (bundleFile.exists()) {
return;
}
StringBand sb = new StringBand(sources.size() * 2);
for (String src : sources) {
if (sb.length() != 0) {
sb.append(StringPool.NEWLINE);
}
String content;
if (isExternalResource(src)) {
content = downloadString(src);
} else {
if (!downloadLocal) {
// load local resource from file system
String localFile = webRoot;
if (src.startsWith(contextPath + '/')) {
src = src.substring(contextPath.length());
}
if (src.startsWith(StringPool.SLASH)) {
// absolute path
localFile += src;
} else {
// relative path
localFile += '/' + FileNameUtil.getPathNoEndSeparator(actionPath) + '/' + src;
}
// trim link parameters, if any
int qmndx = localFile.indexOf('?');
if (qmndx != -1) {
localFile = localFile.substring(0, qmndx);
}
try {
content = FileUtil.readString(localFile);
} catch (IOException ioex) {
if (notFoundExceptionEnabled) {
throw ioex;
}
if (log.isWarnEnabled()) {
log.warn(ioex.getMessage());
}
content = null;
}
} else {
// download local resource
String localUrl = localAddressAndPort;
if (src.startsWith(StringPool.SLASH)) {
localUrl += contextPath + src;
} else {
localUrl += contextPath + FileNameUtil.getPath(actionPath) + '/' + src;
}
content = downloadString(localUrl);
}
if (content != null) {
if (isCssResource(src)) {
content = fixCssRelativeUrls(content, src);
}
}
}
if (content != null) {
content = onResourceContent(content);
sb.append(content);
}
}
FileUtil.writeString(bundleFile, sb.toString());
if (log.isInfoEnabled()) {
log.info("Bundle created: " + bundleId);
}
}
private String downloadString(final String localUrl) throws IOException {
String content;
try {
content = NetUtil.downloadString(localUrl, localFilesEncoding);
} catch (IOException ioex) {
if (notFoundExceptionEnabled) {
throw ioex;
}
if (log.isWarnEnabled()) {
log.warn("Download failed: " + localUrl + "; " + ioex.getMessage());
}
content = null;
}
return content;
}
/**
* Returns true
if resource link has to be downloaded.
* By default, if resource link starts with "http://" or with "https://"
* it will be considered as external resource.
*/
protected boolean isExternalResource(final String link) {
return link.startsWith("http://") || (link.startsWith("https://"));
}
/**
* Invoked before resource content is stored in the bundle.
* May be us used for additional resource processing, such as
* compressing, cleaning etc. By default it just returns unmodified
* content.
*/
protected String onResourceContent(final String content) {
return content;
}
// ---------------------------------------------------------------- url rewriting
/**
* Resolves real action path for given one.
* When some URLs are dynamically created, many different links points
* to the same page. Use this to prevent memory leaking.
*/
protected String resolveRealActionPath(final String actionPath) {
return actionPath;
}
// ---------------------------------------------------------------- reset
/**
* Clears all settings and removes all created bundle files from file system.
*/
public synchronized void reset() {
if (strategy == Strategy.ACTION_MANAGED) {
actionBundles.clear();
mirrors.clear();
}
final FindFile ff = new FindFile();
ff.includeDirs(false);
ff.searchPath(new File(bundleFolder, staplerPath));
File f;
int count = 0;
while ((f = ff.nextFile()) != null) {
f.delete();
count++;
}
if (log.isInfoEnabled()) {
log.info("reset: " + count + " bundle files deleted.");
}
}
// ---------------------------------------------------------------- css related
/**
* Returns true
if resource is CSS, so the
* CSS urls can be fixed.
*/
protected boolean isCssResource(final String src) {
return src.endsWith(".css");
}
private static final Pattern CSS_URL_PATTERN = Pattern.compile("url\\s*\\(\\s*([^\\)\\s]*)\\s*\\)", Pattern.CASE_INSENSITIVE);
/**
* Returns the content with all relative URLs fixed.
*/
protected String fixCssRelativeUrls(final String content, final String src) {
final String path = FileNameUtil.getPath(src);
final Matcher matcher = CSS_URL_PATTERN.matcher(content);
final StringBuilder sb = new StringBuilder(content.length());
int start = 0;
while (matcher.find()) {
sb.append(content, start, matcher.start());
final String matchedUrl = StringUtil.removeChars(matcher.group(1), "'\"");
final String url;
if (matchedUrl.startsWith("https://") || matchedUrl.startsWith("http://") || matchedUrl.startsWith("data:")) {
url = "url('" + matchedUrl + "')";
}
else {
url = fixRelativeUrl(matchedUrl, path);
}
sb.append(url);
start = matcher.end();
}
sb.append(content.substring(start));
return sb.toString();
}
/**
* For a given URL (optionally quoted), produces CSS URL
* where relative paths are fixed and prefixed with offsetPath.
*/
protected String fixRelativeUrl(final String url, final String offsetPath) {
final StringBuilder res = new StringBuilder();
res.append("url('");
if (!url.startsWith(StringPool.SLASH)) {
res
.append("../")
.append(offsetPath);
}
res.append(url).append("')");
return res.toString();
}
}