package org.epics.archiverappliance.common;
import java.io.BufferedInputStream;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.Closeable;
import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.URL;
import java.net.URLConnection;
import java.net.URLDecoder;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.Arrays;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Set;
import java.util.zip.GZIPOutputStream;
import java.util.zip.ZipEntry;
import java.util.zip.ZipInputStream;
import javax.servlet.ServletConfig;
import javax.servlet.ServletContext;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import org.apache.log4j.Logger;
import org.epics.archiverappliance.config.ConfigService;
import org.epics.archiverappliance.config.ConfigService.STARTUP_SEQUENCE;
import org.epics.archiverappliance.mgmt.bpl.SyncStaticContentHeadersFooters;
import org.epics.archiverappliance.retrieval.mimeresponses.MimeResponse;
/**
* Serves static content in the web app...
* Previously, org.apache.catalina.servlets.DefaultServlet was used for this purpose.
* But this ties us to Tomcat and some expressed the desire to run this in other containers.
* In addition, we needed the ability to serve content from within zip files.
* This lets us upgrade JavaScript libraries easily; many of which are delivered a multiple files in a versioned zip.
*
* This is code from http://balusc.blogspot.com/2009/02/fileservlet-supporting-resume-and.html substantially modified.
* @author mshankar
*
*
*/
public class StaticContentServlet extends HttpServlet {
private static final long serialVersionUID = 0L;
private static Logger logger = Logger.getLogger(StaticContentServlet.class.getName());
private static final int DEFAULT_BUFFER_SIZE = 10240;
// We expire content in this many minutes
private static final long DEFAULT_EXPIRE_TIME = 10*60*1000L;
private ConfigService configService = null;
private String staticContentBasePath = "ui";
/**
* List of paths for which we have to do template replacement
*/
private Set<String> templateReplacementPaths = new HashSet<String>();
@Override
public void init(ServletConfig config) throws ServletException {
this.configService = (ConfigService) config.getServletContext().getAttribute(ConfigService.CONFIG_SERVICE_NAME);
templateReplacementPaths.add("viewer/index.html");
templateReplacementPaths.add("js/mgmt.js");
}
@Override
protected void doHead(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
processRequest(request, response, false);
}
@Override
protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
processRequest(request, response, true);
}
/**
* Process the actual request.
* @param request The request to be processed.
* @param response The response to be created.
* @param content Whether the request body should be written (GET) or not (HEAD).
* @throws IOException If something fails at I/O level.
*/
private void processRequest (HttpServletRequest request, HttpServletResponse response, boolean content) throws IOException {
// Validate the requested file ------------------------------------------------------------
// Get requested file by path info - remove the leading '/'
String requestedFile = request.getPathInfo();
if(requestedFile == null || requestedFile.equals("")) {
logger.debug("Default request - send to index.html");
response.setStatus(HttpServletResponse.SC_MOVED_PERMANENTLY);
response.setHeader("Location", "index.html");
return;
}
if(requestedFile.startsWith("/")) {
requestedFile = requestedFile.substring(1, requestedFile.length());
}
logger.debug("Procesing static content request for " + requestedFile);
// Check if file is actually supplied to the request URL.
if (requestedFile == null) {
logger.error("Static content request for a null file?");
response.sendError(HttpServletResponse.SC_NOT_FOUND);
return;
}
if(configService.getStartupState() != STARTUP_SEQUENCE.STARTUP_COMPLETE) {
String msg = "Cannot process static content request for " + requestedFile + " until the appliance has completely started up.";
logger.error(msg);
response.addHeader(MimeResponse.ACCESS_CONTROL_ALLOW_ORIGIN, "*");
response.sendError(HttpServletResponse.SC_SERVICE_UNAVAILABLE, msg);
return;
}
// URL-decode the file name (might contain spaces and on) and prepare file object.
String decodedFilePath = URLDecoder.decode(requestedFile, "UTF-8");
try(PathSequence pathSeq = new PathSequence(request.getServletContext(), staticContentBasePath, decodedFilePath)) {
// Check if file actually exists in filesystem.
if (!pathSeq.exists()) {
logger.warn("Static content request for a non existent file " + decodedFilePath);
response.sendError(HttpServletResponse.SC_NOT_FOUND);
return;
}
logger.debug("Serving static content: " + pathSeq.toString());
// Prepare some variables. The ETag is an unique identifier of the file.
String fileName = pathSeq.getContentDispositionFileName();
long length = pathSeq.length();
long lastModified = pathSeq.lastModified();
String eTag = fileName + "_" + length + "_" + lastModified;
long expires = System.currentTimeMillis() + DEFAULT_EXPIRE_TIME;
// if(logger.isDebugEnabled()) {
// for(String headerName : Collections.list(request.getHeaderNames())) {
// logger.debug(headerName + " : " + request.getHeaders(headerName).nextElement());
// }
// }
// Validate request headers for caching ---------------------------------------------------
// If-None-Match header should contain "*" or ETag. If so, then return 304.
String ifNoneMatch = request.getHeader("If-None-Match");
if (ifNoneMatch != null && matches(ifNoneMatch, eTag)) {
logger.debug("Matched If-None-Match " + ifNoneMatch + " eTag " + eTag);
response.setStatus(HttpServletResponse.SC_NOT_MODIFIED);
response.setHeader("ETag", eTag); // Required in 304.
response.setDateHeader("Expires", expires); // Postpone cache with 1 week.
return;
}
// If-Modified-Since header should be greater than LastModified. If so, then return 304.
// This header is ignored if any If-None-Match header is specified.
long ifModifiedSince = request.getDateHeader("If-Modified-Since");
if (ifNoneMatch == null && ifModifiedSince != -1 && ifModifiedSince + 1000 > lastModified) {
logger.debug("Matched If-Modified-Since");
response.setStatus(HttpServletResponse.SC_NOT_MODIFIED);
response.setHeader("ETag", eTag); // Required in 304.
response.setDateHeader("Expires", expires); // Postpone cache with 1 week.
return;
}
// Validate request headers for resume ----------------------------------------------------
// If-Match header should contain "*" or ETag. If not, then return 412.
String ifMatch = request.getHeader("If-Match");
if (ifMatch != null && !matches(ifMatch, eTag)) {
logger.debug("If-Match did not match");
response.sendError(HttpServletResponse.SC_PRECONDITION_FAILED);
return;
}
// If-Unmodified-Since header should be greater than LastModified. If not, then return 412.
long ifUnmodifiedSince = request.getDateHeader("If-Unmodified-Since");
if (ifUnmodifiedSince != -1 && ifUnmodifiedSince + 1000 <= lastModified) {
logger.debug("If-Unmodified-Since did not match");
response.sendError(HttpServletResponse.SC_PRECONDITION_FAILED);
return;
}
// Prepare and initialize response --------------------------------------------------------
// Get content type by file name and set default GZIP support and content disposition.
String contentType = request.getServletContext().getMimeType(fileName);
boolean acceptsGzip = false;
String disposition = "inline";
// If content type is unknown, then set the default value.
// For all content types, see: http://www.w3schools.com/media/media_mimeref.asp
// To add new content types, add new mime-mapping entry in web.xml.
if (contentType == null) {
contentType = "application/octet-stream";
}
// If content type is text, then determine whether GZIP content encoding is supported by
// the browser and expand content type with the one and right character encoding.
if (contentType.startsWith("text")) {
String acceptEncoding = request.getHeader("Accept-Encoding");
acceptsGzip = acceptEncoding != null && accepts(acceptEncoding, "gzip");
contentType += ";charset=UTF-8";
} else if (!contentType.startsWith("image")) {
// Else, expect for images, determine content disposition. If content type is supported by
// the browser, then set to inline, else attachment which will pop a 'save as' dialogue.
String accept = request.getHeader("Accept");
disposition = accept != null && accepts(accept, contentType) ? "inline" : "attachment";
}
// Initialize response.
response.reset();
response.setBufferSize(DEFAULT_BUFFER_SIZE);
response.setHeader("Content-Disposition", disposition + ";filename=\"" + fileName + "\"");
response.setHeader("ETag", eTag);
response.setDateHeader("Last-Modified", lastModified);
response.setDateHeader("Expires", expires);
response.addHeader("ARCHAPPL_SRC", pathSeq.toString());
// Prepare streams.
InputStream input = null;
OutputStream output = null;
try {
// Open streams.
input = pathSeq.getInputStream();
output = response.getOutputStream();
response.setContentType(contentType);
if (content) {
if (acceptsGzip) {
// The browser accepts GZIP, so GZIP the content.
response.setHeader("Content-Encoding", "gzip");
output = new GZIPOutputStream(output, DEFAULT_BUFFER_SIZE);
} else {
// Content length is not directly predictable in case of GZIP.
// So only add it if there is no means of GZIP, else browser will hang.
response.setHeader("Content-Length", String.valueOf(length));
}
copy(pathSeq, input, output, length);
}
} finally {
// Gently close streams.
close(output);
close(input);
}
}
}
// Helpers (can be refactored to public utility class) ----------------------------------------
/**
* Returns true if the given accept header accepts the given value.
* @param acceptHeader The accept header.
* @param toAccept The value to be accepted.
* @return True if the given accept header accepts the given value.
*/
private static boolean accepts(String acceptHeader, String toAccept) {
String[] acceptValues = acceptHeader.split("\\s*(,|;)\\s*");
Arrays.sort(acceptValues);
return Arrays.binarySearch(acceptValues, toAccept) > -1
|| Arrays.binarySearch(acceptValues, toAccept.replaceAll("/.*$", "/*")) > -1
|| Arrays.binarySearch(acceptValues, "*/*") > -1;
}
/**
* Returns true if the given match header matches the given value.
* @param matchHeader The match header.
* @param toMatch The value to be matched.
* @return True if the given match header matches the given value.
*/
private static boolean matches(String matchHeader, String toMatch) {
String[] matchValues = matchHeader.split("\\s*,\\s*");
Arrays.sort(matchValues);
return Arrays.binarySearch(matchValues, toMatch) > -1
|| Arrays.binarySearch(matchValues, "*") > -1;
}
/**
* Copy the given input to the given output.
* @param input The input to copy from
* @param output The output to copy to.
* @param length Number of bytes to copy.
* @throws IOException If something fails at I/O level.
*/
private static void copy(PathSequence pathSeq, InputStream input, OutputStream output, long length) throws IOException {
byte[] buffer = new byte[DEFAULT_BUFFER_SIZE];
int read;
if (pathSeq.length() == length) {
while ((read = input.read(buffer)) > 0) {
output.write(buffer, 0, read);
}
} else {
long toRead = length;
while ((read = input.read(buffer)) > 0) {
if ((toRead -= read) > 0) {
output.write(buffer, 0, read);
} else {
output.write(buffer, 0, (int) toRead + read);
break;
}
}
}
}
/**
* Close the given resource.
* @param resource The resource to be closed.
*/
private static void close(Closeable resource) {
if (resource != null) {
try {
resource.close();
} catch (IOException ignore) {
// Ignore IOException. If you want to handle this anyway, it might be useful to know
// that this will generally only be thrown when the client aborted the request.
}
}
}
// Inner classes ------------------------------------------------------------------------------
/**
* A sequence of paths; some of which may be in a zip file.
* There are multiple possibilities here
* <ol>
* <li>The application server unpacks the WAR and we have a file on the file system</li>
* <li>The application server unpacks the WAR and we have a file within a zip file on the file system</li>
* <li>The application server refuses to unpack the WAR and we have a file that get as a input stream (but in this we do not have the file length readily available)</li>
* <li>The application server refuses to unpack the WAR and we have a file within a input stream that is a zip file.</li>
* </ol>
*
* All of these result in basically the same thing
* <ol>
* <li>A stream containing the requested content that can simply be written out to the servlet output stream.</li>
* <li>A length field that can be used as Content-Length</li>
* <li>A lastModified that can be used as in the ETag</li>
* <li></li>
* </ol>
* @author mshankar
*
*/
private class PathSequence implements Closeable {
/**
* This is what the client is asking for.
*/
private String fullPathToResource;
private BufferedInputStream content = null;
private long length = -1;
private long lastModified = -1;
/**
* Used for debugging purposes; indicates how we served this content.
*/
private String identifier;
private PathSequence(ServletContext servletContext, String basePath, String decodedPath) throws IOException {
this.fullPathToResource = Paths.get(basePath, decodedPath).toString();
logger.debug("Looking for resource " + fullPathToResource);
// The application server unpacks the WAR and we have a file on the file system
String pathOnDisk = servletContext.getRealPath(fullPathToResource);
if(pathOnDisk != null) {
File f = new File(pathOnDisk);
if(f.exists()) {
logger.debug("Found " + fullPathToResource + " on the file system here - " + pathOnDisk);
this.length = f.length();
this.lastModified = f.lastModified();
if(templateReplacementPaths.contains(decodedPath)) {
templateReplace(decodedPath, new FileInputStream(f));
} else {
this.content = new BufferedInputStream(new FileInputStream(f));
}
this.identifier = f.getAbsolutePath();
return;
}
}
URL pathURL = servletContext.getResource(fullPathToResource);
if(pathURL != null) {
logger.debug("Found " + fullPathToResource + " as a URL here - " + pathURL.toString());
URLConnection connection = pathURL.openConnection();
this.length = connection.getContentLengthLong();
this.lastModified = connection.getDate();
if(templateReplacementPaths.contains(decodedPath)) {
templateReplace(decodedPath, connection.getInputStream());
} else {
this.content = new BufferedInputStream(connection.getInputStream());
}
this.identifier = pathURL.toString();
return;
}
Path pathSoFar = Paths.get(basePath);
Path searchPath = Paths.get(decodedPath);
int currentIndexIntoPath = 0;
for(Path pathComponent : searchPath) {
String potentialZipPath = pathSoFar.resolve(pathComponent.toString() + ".zip").toString();
logger.debug("Checking to see if zip file " + potentialZipPath + " exists.");
URL zipFileURL = servletContext.getResource(potentialZipPath);
if(zipFileURL != null) {
logger.debug("Found zip file " + potentialZipPath + " at url " + zipFileURL);
String potentialPathWithinZip = searchPath.subpath(currentIndexIntoPath+1, searchPath.getNameCount()).toString();
logger.debug("Looking for '" + potentialPathWithinZip + "' within zip file " + zipFileURL.toString());
URLConnection connection = zipFileURL.openConnection();
try(ZipInputStream zis = new ZipInputStream(connection.getInputStream())) {
ZipEntry zentry = zis.getNextEntry();
while(zentry != null) {
// logger.debug("Zip entry '" + zentry.getName() + "'");
if(zentry.getName().equals(potentialPathWithinZip)) {
this.length = zentry.getSize();
this.lastModified = zentry.getTime();
ByteArrayOutputStream bos = new ByteArrayOutputStream();
byte[] buf = new byte[1024];
int bytesRead = zis.read(buf);
while(bytesRead > 0) {
bos.write(buf, 0, bytesRead);
bytesRead = zis.read(buf);
}
logger.debug("Read bytes " + bos.size() + " for content length " + this.length);
if(bos.size() != this.length) {
throw new IOException("ZipEntry for " + potentialPathWithinZip + " in zip file " + zipFileURL.toString() + " says the content length is " + this.length + " but we could only read " + bos.size() + " bytes");
}
if(templateReplacementPaths.contains(decodedPath)) {
templateReplace(decodedPath, new ByteArrayInputStream(bos.toByteArray()));
} else {
this.content = new BufferedInputStream(new ByteArrayInputStream(bos.toByteArray()));
}
this.identifier = zipFileURL.toString() + ".zip:" + potentialPathWithinZip;
return;
}
zentry = zis.getNextEntry();
}
}
}
pathSoFar = pathSoFar.resolve(pathComponent.toString());
currentIndexIntoPath++;
}
}
public void templateReplace(String decodedPath, InputStream is) throws IOException {
logger.debug("Template replacement for " + decodedPath.toString());
switch(decodedPath) {
case "viewer/index.html": {
HashMap<String, String> templateReplacementsForViewer = new HashMap<String, String>();
templateReplacementsForViewer.put("client_retrieval_url_base",
"<script>\n"
+ "window.global_options.retrieval_url_base = '" + configService.getMyApplianceInfo().getDataRetrievalURL() + "';\n"
+ "</script>");
ByteArrayInputStream replacedContent = SyncStaticContentHeadersFooters.templateReplaceChunksHTML(is, templateReplacementsForViewer);
this.content = new BufferedInputStream(replacedContent);
this.length = replacedContent.available();
return;
}
case "js/mgmt.js": {
HashMap<String, String> templateReplacementsForViewer = new HashMap<String, String>();
templateReplacementsForViewer.put("archivePVWorkflowBatchSize",
"var archivePVWorkflowBatchSize = " + configService.getMgmtRuntimeState().getArchivePVWorkflowBatchSize() + ";\n");
templateReplacementsForViewer.put("minimumSamplingPeriod",
"var minimumSamplingPeriod = " + configService.getInstallationProperties().getProperty("org.epics.archiverappliance.mgmt.bpl.ArchivePVAction.minimumSamplingPeriod", "0.1") + ";\n");
ByteArrayInputStream replacedContent = SyncStaticContentHeadersFooters.templateReplaceChunksJavascript(is, templateReplacementsForViewer);
this.content = new BufferedInputStream(replacedContent);
this.length = replacedContent.available();
break;
}
default:
logger.error("Template replacement for " + decodedPath.toString() + " that has been registered in error?");
}
}
boolean exists() {
return this.content != null;
}
String getContentDispositionFileName() throws IOException {
Path fullPath = Paths.get(fullPathToResource);
int pathComponentsSz = fullPath.getNameCount();
return fullPath.subpath(pathComponentsSz-1, pathComponentsSz).toString();
}
long length() {
return this.length;
}
long lastModified() {
return this.lastModified;
}
InputStream getInputStream() throws IOException {
return this.content;
}
@Override
public String toString() {
return this.identifier;
}
@Override
public void close() throws IOException {
if(this.content != null) {
try { this.content.close(); } catch (Throwable t) {}
}
}
}
}