package info.freelibrary.djatoka.view; import gov.lanl.adore.djatoka.openurl.OpenURLJP2KService; import info.freelibrary.djatoka.Constants; import info.freelibrary.djatoka.util.CacheUtils; import info.freelibrary.util.IOUtils; import info.freelibrary.util.PairtreeObject; import info.freelibrary.util.PairtreeRoot; import info.freelibrary.util.PairtreeUtils; import info.freelibrary.util.StringUtils; import java.awt.image.BufferedImage; import java.io.File; import java.io.FileNotFoundException; import java.io.FileOutputStream; import java.io.IOException; import java.io.InputStream; import java.io.UnsupportedEncodingException; import java.net.URL; import java.net.URLDecoder; import java.net.URLEncoder; import java.util.Properties; import javax.imageio.IIOException; import javax.imageio.ImageIO; import javax.servlet.RequestDispatcher; import javax.servlet.ServletContext; import javax.servlet.ServletException; import javax.servlet.ServletOutputStream; import javax.servlet.http.HttpServlet; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import javax.servlet.http.HttpSession; import nu.xom.Attribute; import nu.xom.Builder; import nu.xom.Document; import nu.xom.Element; import nu.xom.ParsingException; import nu.xom.Serializer; import nu.xom.ValidityException; import org.slf4j.Logger; import org.slf4j.LoggerFactory; public class ImageServlet extends HttpServlet implements Constants { /** * The <code>ImageServlet</code>'s <code>serialVersionUID</code>. */ private static final long serialVersionUID = -4142816720756238591L; private static final Logger LOGGER = LoggerFactory .getLogger(ImageServlet.class); private static final String IMAGE_URL = "/resolve?url_ver=Z39.88-2004&rft_id={}&svc_id=info:lanl-repo/svc/getRegion&svc_val_fmt=info:ofi/fmt:kev:mtx:jpeg2000&svc.format={}&svc.level={}"; private static final String REGION_URL = "/resolve?url_ver=Z39.88-2004&rft_id={}&svc_id=info:lanl-repo/svc/getRegion&svc_val_fmt=info:ofi/fmt:kev:mtx:jpeg2000&svc.format={}&svc.region={}&svc.scale={}"; private static final String DZI_NS = "http://schemas.microsoft.com/deepzoom/2008"; private static final String CHARSET = "UTF-8"; private static String myFormatExt; private static String myCache; @Override protected void doGet(HttpServletRequest aRequest, HttpServletResponse aResponse) throws ServletException, IOException { String level = getServletConfig().getInitParameter("level"); String reqURI = aRequest.getRequestURI(); String servletPath = aRequest.getServletPath(); String path = reqURI.substring(servletPath.length()); String id = getID(path); if (reqURI.endsWith("/info.xml") || reqURI.endsWith("/info.json")) { try { int[] dims = getHeightWidth(aRequest, aResponse); ImageInfo imageInfo = new ImageInfo(id, dims[0], dims[1]); ServletOutputStream outStream = aResponse.getOutputStream(); imageInfo.toStream(outStream); outStream.close(); } catch (FileNotFoundException details) { aResponse.sendError(HttpServletResponse.SC_NOT_FOUND, id + " not found"); } } else { String[] regionCoords = getRegion(path); String scale = getScale(path); String region; if (level == null && scale == null) { level = DEFAULT_VIEW_LEVEL; } if (regionCoords.length == 4) { region = StringUtils.toString(regionCoords, ','); } else { region = ""; } if (LOGGER.isDebugEnabled()) { StringBuilder request = new StringBuilder(); request.append("id[").append(id).append("] "); request.append("level[").append(level).append("] "); request.append("scale[").append(scale).append("] "); request.append("region[").append(region).append("]"); LOGGER.debug("Request: " + request.toString()); } if (myCache != null) { PairtreeRoot cacheDir = new PairtreeRoot(new File(myCache)); PairtreeObject cacheObject = cacheDir.getObject(id); String fileName = CacheUtils.getFileName(level, scale, region); File imageFile = new File(cacheObject, fileName); if (imageFile.exists()) { aResponse.setHeader("Content-Length", "" + imageFile.length()); aResponse.setHeader("Cache-Control", "public, max-age=4838400"); aResponse.setContentType("image/jpg"); ServletOutputStream outStream = aResponse.getOutputStream(); IOUtils.copyStream(imageFile, outStream); IOUtils.closeQuietly(outStream); if (LOGGER.isDebugEnabled()) { LOGGER.debug("{} served from Pairtree cache", imageFile); } } else { if (LOGGER.isDebugEnabled()) { LOGGER.debug("{} not found in cache", imageFile); } serveNewImage(id, level, region, scale, aRequest, aResponse); cacheNewImage(aRequest, id + "_" + fileName, imageFile); } } else { if (LOGGER.isWarnEnabled()) { LOGGER.warn("Cache isn't configured correctly (null)"); } serveNewImage(id, level, region, scale, aRequest, aResponse); // We can't cache, because we don't have a cache configured } } } @Override public void init() throws ServletException { InputStream is = getClass().getResourceAsStream("/" + PROPERTIES_FILE); if (is != null) { try { Properties props = new Properties(); props.load(is); if (props.containsKey(VIEW_CACHE_DIR)) { // TODO: use the default java cache dir as fallback? myCache = props.getProperty(VIEW_CACHE_DIR); if (LOGGER.isDebugEnabled()) { LOGGER.debug("Cache directory set to {}", myCache); } } if (props.containsKey(VIEW_FORMAT_EXT)) { myFormatExt = props.getProperty(VIEW_FORMAT_EXT, DEFAULT_VIEW_EXT); if (LOGGER.isDebugEnabled()) { LOGGER.debug("Format extension set to {}", myFormatExt); } } } catch (IOException details) { if (LOGGER.isWarnEnabled()) { LOGGER.warn("Unable to load properties file: {}", details.getMessage()); } } } } @Override protected void doHead(HttpServletRequest aRequest, HttpServletResponse aResponse) throws ServletException, IOException { try { int[] dimensions = getHeightWidth(aRequest, aResponse); // TODO: add a content length header too if (!aResponse.isCommitted()) { aResponse.addIntHeader("X-Image-Height", dimensions[0]); aResponse.addIntHeader("X-Image-Width", dimensions[1]); aResponse.setStatus(HttpServletResponse.SC_OK); } } catch (FileNotFoundException details) { aResponse.sendError(HttpServletResponse.SC_NOT_FOUND); } } @Override protected long getLastModified(HttpServletRequest aRequest) { // TODO: really implement this using our cached files? return super.getLastModified(aRequest); } private int[] getHeightWidth(HttpServletRequest aRequest, HttpServletResponse aResponse) throws IOException, ServletException { String reqURI = aRequest.getRequestURI(); String servletPath = aRequest.getServletPath(); String path = reqURI.substring(servletPath.length()); String id = getID(path); int width = 0, height = 0; if (myCache != null) { try { PairtreeRoot cacheDir = new PairtreeRoot(new File(myCache)); PairtreeObject cacheObject = cacheDir.getObject(id); ServletContext context = getServletContext(); String filename = PairtreeUtils.encodeID(id); File dziFile = new File(cacheObject, filename + ".dzi"); if (dziFile.exists() && dziFile.length() > 0) { if (LOGGER.isDebugEnabled()) { LOGGER.debug("Reading dzi file: " + dziFile.getAbsolutePath()); } Document dzi = new Builder().build(dziFile); Element root = dzi.getRootElement(); Element size = root.getFirstChildElement("Size", DZI_NS); String wString = size.getAttributeValue("Width"); String hString = size.getAttributeValue("Height"); width = wString.equals("") ? 0 : Integer.parseInt(wString); height = hString.equals("") ? 0 : Integer.parseInt(hString); if (LOGGER.isDebugEnabled()) { LOGGER.debug("Returning width/height: {}/{}", width, height); } } else { if (dziFile.exists()) { dziFile.delete(); } if (LOGGER.isDebugEnabled()) { LOGGER.debug("Creating new dzi file: " + dziFile.getAbsolutePath()); } URL url = context.getResource("/WEB-INF/classes/dzi.xml"); Document dzi = new Builder().build(url.openStream()); FileOutputStream outStream = new FileOutputStream(dziFile); Serializer serializer = new Serializer(outStream); URL imageURL = new URL(getFullSizeImageURL(aRequest) + URLEncoder.encode(id, "UTF-8")); if (LOGGER.isDebugEnabled()) { LOGGER.debug("Writing DZI file for: {}", imageURL.toExternalForm()); } try { BufferedImage image = ImageIO.read(imageURL); Element root = dzi.getRootElement(); Element size = root .getFirstChildElement("Size", DZI_NS); Attribute wAttribute = size.getAttribute("Width"); Attribute hAttribute = size.getAttribute("Height"); // Return the width and height in response headers height = image.getHeight(); width = image.getWidth(); if (LOGGER.isDebugEnabled()) { LOGGER.debug("Returning width/height: {}/{}", width, height); } // Save it in our dzi file for easier access next time wAttribute.setValue(Integer.toString(width)); hAttribute.setValue(Integer.toString(height)); serializer.write(dzi); } catch (IIOException details) { Class<?> thrown = details.getCause().getClass(); String name = thrown.getSimpleName(); if (name.equals("FileNotFoundException")) { throw new FileNotFoundException(id + " not found"); } else { throw details; } } } } catch (ValidityException details) { aResponse.sendError( HttpServletResponse.SC_INTERNAL_SERVER_ERROR, details.getMessage()); } catch (ParsingException details) { aResponse.sendError( HttpServletResponse.SC_INTERNAL_SERVER_ERROR, details.getMessage()); } } else { // TODO: work around rather than throwing an exception throw new ServletException("Cache not correctly configured"); } return new int[] { height, width }; } private String getFullSizeImageURL(HttpServletRequest aRequest) { StringBuilder url = new StringBuilder(); url.append(aRequest.getScheme()).append("://"); url.append(aRequest.getServerName()).append(":"); url.append(aRequest.getServerPort()).append("/"); return url.append("view/fullSize/").toString(); } private void serveNewImage(String aID, String aLevel, String aRegion, String aScale, HttpServletRequest aRequest, HttpServletResponse aResponse) throws IOException, ServletException { String id = URLEncoder.encode(aID, CHARSET); RequestDispatcher dispatcher; String[] values; String url; if (aScale == null) { values = new String[] { id, DEFAULT_VIEW_FORMAT, aLevel }; url = StringUtils.formatMessage(IMAGE_URL, values); } else { values = new String[] { id, DEFAULT_VIEW_FORMAT, aRegion, aScale }; url = StringUtils.formatMessage(REGION_URL, values); } // Right now we just let the OpenURL interface do the work dispatcher = aRequest.getRequestDispatcher(url); if (LOGGER.isDebugEnabled()) { String[] messageDetails = new String[] { aID, url }; LOGGER.debug("Image requested: {} - {}", messageDetails); } dispatcher.forward(aRequest, aResponse); } private void cacheNewImage(HttpServletRequest aRequest, String aKey, File aDestFile) { HttpSession session = aRequest.getSession(); String fileName = (String) session.getAttribute(aKey); if (fileName != null) { String cacheName = (String) session.getAttribute(fileName); File cachedFile = new File(fileName); // This moves the newly created file from the adore-djatoka cache // to the freelib-djatoka cache (which is pure-FS/Pairtree-based) if (cachedFile.exists() && aDestFile != null) { if (LOGGER.isDebugEnabled()) { LOGGER.debug("Renaming cache file from {} to {}", cachedFile, aDestFile); } if (!cachedFile.renameTo(aDestFile) && LOGGER.isDebugEnabled()) { LOGGER.debug("Unable to move cache file: {}", cachedFile); } else { // This is the temp file cache used by the OpenURL layer if (!OpenURLJP2KService.removeFromTileCache(cacheName) && LOGGER.isDebugEnabled()) { LOGGER.debug( "Unable to remove OpenURL cache file link: {}", fileName); } else { session.removeAttribute(aKey); session.removeAttribute(fileName); } } } else if (LOGGER.isWarnEnabled() && !cachedFile.exists()) { LOGGER.warn( "Session had a cache file ({}), but it didn't exist", cachedFile.getAbsoluteFile()); } else if (LOGGER.isWarnEnabled()) { LOGGER.warn("Location for destination cache file was null"); } } else if (LOGGER.isWarnEnabled()) { LOGGER.warn( "Couldn't cache ({} = {}); session lacked new image information", aKey, aDestFile.getAbsolutePath()); } } /* * Working towards: * http://www.example.org/service/abcd1234/80,15,60,75/pct:100/0/color.jpg * * /domain /service /ark /region /scale /rotation /filename /ext */ private String getID(String aPath) { String path; if (aPath.startsWith("/")) { path = aPath.split("/")[1].replace('+', ' '); } else { path = aPath; } if (path.contains("%")) { // Path is URLEncoded try { path = URLDecoder.decode(path, "UTF-8"); } catch (UnsupportedEncodingException details) { // Never happens, all JVMs are required to support UTF-8 if (LOGGER.isWarnEnabled()) { LOGGER.warn("Couldn't decode path; no UTF-8 support"); } } } return path; } private String[] getRegion(String aPathInfo) { String[] coordArray = new String[] {}; if (aPathInfo.contains("/")) { String[] pathParts = aPathInfo.split("/"); if (pathParts.length > 2) { String coordsString = aPathInfo.split("/")[2]; if (!coordsString.equals("all")) { String[] coords = coordsString.split(","); if (coords.length == 4) { coordArray = coords; } else if (LOGGER.isWarnEnabled()) { LOGGER.warn( "Invalid coordinates ({}) requested in: {}", StringUtils.toString(coords, ','), aPathInfo); // TODO: throw exception? } } } } return coordArray; } private String getScale(String aPathInfo) { String scale = null; if (aPathInfo.contains("/")) { String[] pathParts = aPathInfo.split("/"); if (pathParts.length > 3) { scale = aPathInfo.split("/")[3]; } } return scale; } }