/*
* Weblounge: Web Content Management System
* Copyright (c) 2003 - 2011 The Weblounge Team
* http://entwinemedia.com/weblounge
*
* This program is free software; you can redistribute it and/or
* modify it under the terms of the GNU Lesser General Public License
* as published by the Free Software Foundation; either version 2
* of the License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with this program; if not, write to the Free Software Foundation
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*/
package ch.entwine.weblounge.dispatcher.impl.handler;
import static ch.entwine.weblounge.common.Times.MS_PER_DAY;
import ch.entwine.weblounge.common.content.PreviewGenerator;
import ch.entwine.weblounge.common.content.Resource;
import ch.entwine.weblounge.common.content.ResourceContent;
import ch.entwine.weblounge.common.content.ResourceURI;
import ch.entwine.weblounge.common.content.ResourceUtils;
import ch.entwine.weblounge.common.content.image.ImageStyle;
import ch.entwine.weblounge.common.impl.content.ResourceURIImpl;
import ch.entwine.weblounge.common.impl.content.image.ImageStyleUtils;
import ch.entwine.weblounge.common.impl.language.LanguageUtils;
import ch.entwine.weblounge.common.impl.request.RequestUtils;
import ch.entwine.weblounge.common.language.Language;
import ch.entwine.weblounge.common.repository.ContentRepository;
import ch.entwine.weblounge.common.repository.ContentRepositoryException;
import ch.entwine.weblounge.common.request.WebloungeRequest;
import ch.entwine.weblounge.common.request.WebloungeResponse;
import ch.entwine.weblounge.common.security.User;
import ch.entwine.weblounge.common.site.Environment;
import ch.entwine.weblounge.common.site.Site;
import ch.entwine.weblounge.common.url.WebUrl;
import ch.entwine.weblounge.dispatcher.RequestHandler;
import ch.entwine.weblounge.dispatcher.impl.DispatchUtils;
import org.apache.commons.io.FileUtils;
import org.apache.commons.io.FilenameUtils;
import org.apache.commons.io.IOUtils;
import org.apache.commons.lang.StringUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.EOFException;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.UnsupportedEncodingException;
import java.net.URLDecoder;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.Date;
import java.util.List;
import java.util.UUID;
import javax.servlet.http.HttpServletResponse;
import javax.ws.rs.core.MediaType;
/**
* This request handler is used to handle requests to scaled images in the
* repository.
*/
public final class PreviewRequestHandlerImpl implements RequestHandler {
/** Alternate uri prefix */
protected static final String URI_PREFIX = "/weblounge-previews/";
/** Name of the image style parameter */
protected static final String OPT_IMAGE_STYLE = "style";
/** Length of a UUID */
protected static final int UUID_LENGTH = 36;
/** The server environment */
protected Environment environment = Environment.Production;
/** Logging facility */
protected static final Logger logger = LoggerFactory.getLogger(PreviewRequestHandlerImpl.class);
/** The preview generators */
private final List<PreviewGenerator> previewGenerators = new ArrayList<PreviewGenerator>();
/** The list of previews that are being created at the moment */
private final List<String> previews = new ArrayList<String>();
/**
* Handles the request for an image resource that is believed to be in the
* content repository. The handler scales the image as requested, sets the
* response headers and the writes the image contents to the response.
* <p>
* This method returns <code>true</code> if the handler is decided to handle
* the request, <code>false</code> otherwise.
*
* @param request
* the weblounge request
* @param response
* the weblounge response
*/
public boolean service(WebloungeRequest request, WebloungeResponse response) {
WebUrl url = request.getUrl();
Site site = request.getSite();
String path = url.getPath();
String fileName = null;
// This request handler can only be used with the prefix
if (!path.startsWith(URI_PREFIX))
return false;
// Get hold of the content repository
ContentRepository contentRepository = site.getContentRepository();
if (contentRepository == null) {
logger.warn("No content repository found for site '{}'", site);
return false;
} else if (contentRepository.isIndexing()) {
logger.debug("Content repository of site '{}' is currently being indexed", site);
DispatchUtils.sendServiceUnavailable(request, response);
return true;
}
// Check if the request uri matches the special uri for previews. If so, try
// to extract the id from the last part of the path. If not, check if there
// is an image with the current path.
ResourceURI resourceURI = null;
Resource<?> resource = null;
try {
String id = null;
String imagePath = null;
String uriSuffix = StringUtils.chomp(path.substring(URI_PREFIX.length()), "/");
uriSuffix = URLDecoder.decode(uriSuffix, "utf-8");
// Check whether we are looking at a uuid or a url path
if (uriSuffix.length() == UUID_LENGTH) {
id = uriSuffix;
} else if (uriSuffix.length() >= UUID_LENGTH) {
int lastSeparator = uriSuffix.indexOf('/');
if (lastSeparator == UUID_LENGTH && uriSuffix.indexOf('/', lastSeparator + 1) < 0) {
id = uriSuffix.substring(0, lastSeparator);
fileName = uriSuffix.substring(lastSeparator + 1);
} else {
imagePath = uriSuffix;
fileName = FilenameUtils.getName(imagePath);
}
} else {
imagePath = "/" + uriSuffix;
fileName = FilenameUtils.getName(imagePath);
}
// Try to load the resource
resourceURI = new ResourceURIImpl(null, site, imagePath, id);
resource = contentRepository.get(resourceURI);
if (resource == null) {
logger.debug("No resource found at {}", resourceURI);
return false;
}
} catch (ContentRepositoryException e) {
logger.error("Error loading resource from {}: {}", contentRepository, e.getMessage());
DispatchUtils.sendInternalError(request, response);
return true;
} catch (UnsupportedEncodingException e) {
logger.error("Error decoding resource url {} using utf-8: {}", path, e.getMessage());
DispatchUtils.sendInternalError(request, response);
return true;
}
// Agree to serve the preview
logger.debug("Preview handler agrees to handle {}", path);
// Check the request method. Only GET is supported right now.
String requestMethod = request.getMethod().toUpperCase();
if ("OPTIONS".equals(requestMethod)) {
String verbs = "OPTIONS,GET";
logger.trace("Answering options request to {} with {}", url, verbs);
response.setHeader("Allow", verbs);
response.setContentLength(0);
return true;
} else if (!"GET".equals(requestMethod)) {
logger.debug("Image request handler does not support {} requests", requestMethod);
DispatchUtils.sendError(HttpServletResponse.SC_METHOD_NOT_ALLOWED, request, response);
return true;
}
// Is it published?
// TODO: Fix this. imageResource.isPublished() currently returns false,
// as both from and to dates are null (see PublishingCtx)
// if (!imageResource.isPublished()) {
// logger.debug("Access to unpublished image {}", imageURI);
// DispatchUtils.sendNotFound(request, response);
// return true;
// }
// Can the resource be accessed by the current user?
User user = request.getUser();
try {
// TODO: Check permission
// PagePermission p = new PagePermission(page, user);
// AccessController.checkPermission(p);
} catch (SecurityException e) {
logger.warn("Access to resource {} denied for user {}", resourceURI, user);
DispatchUtils.sendAccessDenied(request, response);
return true;
}
// Determine the response language by filename
Language language = null;
if (StringUtils.isNotBlank(fileName)) {
for (ResourceContent c : resource.contents()) {
if (c.getFilename().equalsIgnoreCase(fileName)) {
if (language != null) {
logger.debug("Unable to determine language from ambiguous filename");
language = LanguageUtils.getPreferredContentLanguage(resource, request, site);
break;
}
language = c.getLanguage();
}
}
if (language == null)
language = LanguageUtils.getPreferredContentLanguage(resource, request, site);
} else {
language = LanguageUtils.getPreferredContentLanguage(resource, request, site);
}
// If the filename did not lead to a language, apply language resolution
if (language == null) {
logger.warn("Resource {} does not exist in any supported language", resourceURI);
DispatchUtils.sendNotFound(request, response);
return true;
}
// Find a resource preview generator
PreviewGenerator previewGenerator = null;
synchronized (previewGenerators) {
for (PreviewGenerator generator : previewGenerators) {
if (generator.supports(resource)) {
previewGenerator = generator;
break;
}
}
}
// If we did not find a preview generator, we need to let go
if (previewGenerator == null) {
logger.debug("Unable to generate preview for {} since no suitable preview generator is available", resource);
DispatchUtils.sendServiceUnavailable(request, response);
return true;
}
// Extract the image style
ImageStyle style = null;
String styleId = StringUtils.trimToNull(request.getParameter(OPT_IMAGE_STYLE));
if (styleId != null) {
style = ImageStyleUtils.findStyle(styleId, site);
if (style == null) {
DispatchUtils.sendBadRequest("Image style '" + styleId + "' not found", request, response);
return true;
}
}
// Get the path to the preview image
File previewFile = ImageStyleUtils.getScaledFile(resource, language, style);
// Check the modified headers
long revalidationTime = MS_PER_DAY;
long expirationDate = System.currentTimeMillis() + revalidationTime;
if (!ResourceUtils.hasChanged(request, previewFile)) {
logger.debug("Scaled preview {} was not modified", resourceURI);
response.setDateHeader("Expires", expirationDate);
DispatchUtils.sendNotModified(request, response);
return true;
}
// Load the image contents from the repository
ResourceContent resourceContents = resource.getContent(language);
// Add mime type header
String contentType = resourceContents.getMimetype();
if (contentType == null)
contentType = MediaType.APPLICATION_OCTET_STREAM;
// Set the content type
String characterEncoding = response.getCharacterEncoding();
if (StringUtils.isNotBlank(characterEncoding))
response.setContentType(contentType + "; charset=" + characterEncoding.toLowerCase());
else
response.setContentType(contentType);
// Browser caches and proxies are allowed to keep a copy
response.setHeader("Cache-Control", "public, max-age=" + revalidationTime);
// Set Expires header
response.setDateHeader("Expires", expirationDate);
// Write the image back to the client
InputStream previewInputStream = null;
try {
if (previewFile.isFile() && previewFile.lastModified() >= resourceContents.getCreationDate().getTime()) {
previewInputStream = new FileInputStream(previewFile);
} else {
previewInputStream = createPreview(request, response, resource, language, style, previewGenerator, previewFile, contentRepository);
}
if (previewInputStream == null) {
// Assuming that createPreview() is setting the response header in the
// case of failure
return true;
}
// Add last modified header
response.setDateHeader("Last-Modified", previewFile.lastModified());
response.setHeader("ETag", ResourceUtils.getETagValue(previewFile.lastModified()));
response.setHeader("Content-Disposition", "inline; filename=" + previewFile.getName());
response.setHeader("Content-Length", Long.toString(previewFile.length()));
previewInputStream = new FileInputStream(previewFile);
IOUtils.copy(previewInputStream, response.getOutputStream());
response.getOutputStream().flush();
return true;
} catch (EOFException e) {
logger.debug("Error writing image '{}' back to client: connection closed by client", resource);
return true;
} catch (IOException e) {
DispatchUtils.sendInternalError(request, response);
if (RequestUtils.isCausedByClient(e))
return true;
logger.error("Error sending image '{}' to the client: {}", resourceURI, e.getMessage());
return true;
} catch (Throwable t) {
logger.error("Error creating scaled image '{}': {}", resourceURI, t.getMessage());
DispatchUtils.sendInternalError(request, response);
return true;
} finally {
IOUtils.closeQuietly(previewInputStream);
}
}
/**
* Creates the preview image for the given resource and returns an input
* stream to the preview or <code>null</code> if the preview could not be
* created.
*/
private InputStream createPreview(WebloungeRequest request,
WebloungeResponse response, Resource<?> resource, Language language,
ImageStyle style, PreviewGenerator previewGenerator, File previewFile,
ContentRepository contentRepository) {
String pathToImageFile = previewFile.getAbsolutePath();
boolean firstOne = true;
// Make sure the preview is not already being generated by another thread
synchronized (previews) {
while (previews.contains(pathToImageFile)) {
logger.debug("Preview at {} is being created, waiting for it to be generated", pathToImageFile);
firstOne = false;
try {
previews.wait(500);
if (previews.contains(pathToImageFile)) {
logger.trace("After waiting 500ms, preview at {} is still being worked on", pathToImageFile);
DispatchUtils.sendServiceUnavailable(request, response);
return null;
}
} catch (InterruptedException e) {
DispatchUtils.sendServiceUnavailable(request, response);
return null;
}
}
// Make sure others are waiting until we are done
if (firstOne) {
previews.add(pathToImageFile);
}
}
// Determine the resource's modification date
long resourceLastModified = ResourceUtils.getModificationDate(resource, language).getTime();
// Create the preview if this is the first request
if (firstOne) {
ResourceURI resourceURI = resource.getURI();
if (style != null)
logger.info("Creating preview of {} with style '{}' at {}", new String[] {
resource.getIdentifier(),
style.getIdentifier(),
pathToImageFile });
else
logger.info("Creating original preview of {} at {}", new String[] {
resource.getIdentifier(),
pathToImageFile });
// Get hold of the content
ResourceContent resourceContents = resource.getContent(language);
// Get the mime type
final String mimetype = resourceContents.getMimetype();
final String format = mimetype.substring(mimetype.indexOf("/") + 1);
boolean scalingFailed = false;
InputStream is = null;
FileOutputStream fos = null;
try {
is = contentRepository.getContent(resourceURI, language);
// Remove the original image
FileUtils.deleteQuietly(previewFile);
// Create a work file
File imageDirectory = previewFile.getParentFile();
String workFileName = "." + UUID.randomUUID() + "-" + previewFile.getName();
FileUtils.forceMkdir(imageDirectory);
File workImageFile = new File(imageDirectory, workFileName);
// Create the scaled image
fos = new FileOutputStream(workImageFile);
logger.debug("Creating scaled image '{}' at {}", resource, previewFile);
previewGenerator.createPreview(resource, environment, language, style, format, is, fos);
// Move the work image in place
try {
FileUtils.moveFile(workImageFile, previewFile);
} catch (IOException e) {
logger.warn("Concurrent creation of preview {} resolved by copy instead of rename", previewFile.getAbsolutePath());
FileUtils.copyFile(workImageFile, previewFile);
FileUtils.deleteQuietly(workImageFile);
} finally {
previewFile.setLastModified(Math.max(new Date().getTime(), resourceLastModified));
}
// Make sure preview generation was successful
if (!previewFile.isFile()) {
logger.warn("The file at {} is not a regular file", pathToImageFile);
scalingFailed = true;
} else if (previewFile.length() == 0) {
logger.warn("The scaled file at {} has zero length", pathToImageFile);
scalingFailed = true;
}
} catch (ContentRepositoryException e) {
logger.error("Unable to load image {}: {}", new Object[] {
resourceURI,
e.getMessage(),
e });
scalingFailed = true;
DispatchUtils.sendInternalError(request, response);
} catch (IOException e) {
logger.error("Error sending image '{}' to the client: {}", resourceURI, e.getMessage());
scalingFailed = true;
DispatchUtils.sendInternalError(request, response);
} catch (Throwable t) {
logger.error("Error creating scaled image '{}': {}", resourceURI, t.getMessage());
scalingFailed = true;
DispatchUtils.sendInternalError(request, response);
} finally {
IOUtils.closeQuietly(is);
IOUtils.closeQuietly(fos);
try {
if (scalingFailed && previewFile != null) {
logger.info("Cleaning up after failed scaling of {}", pathToImageFile);
File f = previewFile;
FileUtils.deleteQuietly(previewFile);
f = previewFile.getParentFile();
while (f != null && f.isDirectory() && (f.listFiles() == null || f.listFiles().length == 0)) {
FileUtils.deleteQuietly(f);
f = f.getParentFile();
}
}
} catch (Throwable t) {
logger.warn("Error cleaning up after failed scaling of {}", pathToImageFile);
}
synchronized (previews) {
previews.remove(pathToImageFile);
previews.notifyAll();
}
}
}
// Make sure whoever was in charge of creating the preview, was
// successful
boolean scaledImageExists = previewFile.isFile();
boolean scaledImageIsOutdated = previewFile.lastModified() < resourceLastModified;
if (!scaledImageExists || scaledImageIsOutdated) {
logger.debug("Apparently, preview rendering for {} failed", previewFile.getAbsolutePath());
DispatchUtils.sendServiceUnavailable(request, response);
return null;
} else {
try {
return new FileInputStream(previewFile);
} catch (Throwable t) {
logger.error("Error reading content from preview at {}: {}", previewFile.getAbsolutePath(), t.getMessage());
DispatchUtils.sendServiceUnavailable(request, response);
return null;
}
}
}
/**
* Sets the server environment.
*
* @param environment
* the server environment
*/
void setEnvironment(Environment environment) {
this.environment = environment;
}
/**
* Adds the preview generator to the list of registered preview generators.
*
* @param generator
* the generator
*/
void addPreviewGenerator(PreviewGenerator generator) {
synchronized (previewGenerators) {
previewGenerators.add(generator);
Collections.sort(previewGenerators, new Comparator<PreviewGenerator>() {
public int compare(PreviewGenerator a, PreviewGenerator b) {
return Integer.valueOf(b.getPriority()).compareTo(a.getPriority());
}
});
}
}
/**
* Removes the preview generator from the list of registered preview
* generators.
*
* @param generator
* the generator
*/
void removePreviewGenerator(PreviewGenerator generator) {
synchronized (previewGenerators) {
previewGenerators.remove(generator);
}
}
/**
* @see ch.entwine.weblounge.dispatcher.api.request.RequestHandler#getName()
*/
public String getName() {
return "preview request handler";
}
/**
* Returns a string representation of this request handler.
*
* @return the handler name
* @see java.lang.Object#toString()
*/
@Override
public String toString() {
return getName();
}
/**
* {@inheritDoc}
*
* @see ch.entwine.weblounge.dispatcher.RequestHandler#getPriority()
*/
public int getPriority() {
return 0;
}
}