package er.attachment; import java.io.BufferedInputStream; import java.io.FileNotFoundException; import java.io.IOException; import java.io.InputStream; import java.net.URI; import java.net.URISyntaxException; import java.util.NoSuchElementException; import java.util.regex.Matcher; import java.util.regex.Pattern; import com.webobjects.appserver.WOApplication; import com.webobjects.appserver.WOContext; import com.webobjects.appserver.WODynamicURL; import com.webobjects.appserver.WORequest; import com.webobjects.appserver.WORequestHandler; import com.webobjects.appserver.WOResponse; import com.webobjects.eocontrol.EOEditingContext; import com.webobjects.eocontrol.EOGlobalID; import com.webobjects.eocontrol.EOKeyGlobalID; import com.webobjects.foundation.NSLog; import er.attachment.model.ERAttachment; import er.attachment.processors.ERAttachmentProcessor; import er.extensions.eof.ERXEC; import er.extensions.eof.ERXEOGlobalIDUtilities; import er.extensions.foundation.ERXStringUtilities; /** * ERAttachmentRequestHandler is the request handler that is used for loading * any proxied attachment. To control security, you can set the delegate of this * request handler in your application constructor. By default, all proxied * attachments are visible. * * @author mschrag */ public class ERAttachmentRequestHandler extends WORequestHandler { public static final String REQUEST_HANDLER_KEY = "attachments"; /** * The delegate definition for this request handler. */ public static interface Delegate { /** * Called prior to displaying a proxied attachment to a user and can be used to implement * security on top of attachments. * * @param attachment the attachment that was requested * @param request the current request * @param context the current context * @return true if the current user is allowed to view this attachment */ public boolean attachmentVisible(ERAttachment attachment, WORequest request, WOContext context); } private ERAttachmentRequestHandler.Delegate _delegate; /** * Sets the delegate for this request handler. * * @param delegate the delegate for this request handler */ public void setDelegate(ERAttachmentRequestHandler.Delegate delegate) { _delegate = delegate; } @Override public WOResponse handleRequest(WORequest request) { int bufferSize = 16384; WOApplication application = WOApplication.application(); application.awake(); try { WOContext context = application.createContextForRequest(request); WOResponse response = application.createResponseInContext(context); String sessionIdKey = application.sessionIdKey(); String sessionId = (String) request.formValueForKey(sessionIdKey); if (sessionId == null) { sessionId = request.cookieValueForKey(sessionIdKey); } context._setRequestSessionID(sessionId); if (context._requestSessionID() != null) { application.restoreSessionWithID(sessionId, context); } try { final WODynamicURL url = request._uriDecomposed(); final String requestPath = url.requestHandlerPath(); final Matcher idMatcher = Pattern.compile("^id/(\\d+)/").matcher(requestPath); final Integer requestedAttachmentID; String requestedWebPath; final boolean requestedPathContainsAnAttachmentID = idMatcher.find(); if (requestedPathContainsAnAttachmentID) { requestedAttachmentID = Integer.valueOf(idMatcher.group(1)); requestedWebPath = idMatcher.replaceFirst("/"); } else { // MS: This is kind of goofy because we lookup by path, your web path needs to // have a leading slash on it. requestedWebPath = "/" + requestPath; requestedAttachmentID = null; } try { InputStream attachmentInputStream; String mimeType; String fileName; long length; String queryString = url.queryString(); boolean proxyAsAttachment = (queryString != null && queryString.contains("attachment=true")); EOEditingContext editingContext = ERXEC.newEditingContext(); editingContext.lock(); try { ERAttachment attachment = fetchAttachmentFor(editingContext, requestedAttachmentID, requestedWebPath); if (_delegate != null && !_delegate.attachmentVisible(attachment, request, context)) { throw new SecurityException("You are not allowed to view the requested attachment."); } mimeType = attachment.mimeType(); length = attachment.size().longValue(); fileName = attachment.originalFileName(); ERAttachmentProcessor<ERAttachment> attachmentProcessor = ERAttachmentProcessor.processorForType(attachment); if (!proxyAsAttachment) { proxyAsAttachment = attachmentProcessor.proxyAsAttachment(attachment); } InputStream rawAttachmentInputStream = attachmentProcessor.attachmentInputStream(attachment); attachmentInputStream = new BufferedInputStream(rawAttachmentInputStream, bufferSize); } finally { editingContext.unlock(); } response.setHeader(mimeType, "Content-Type"); response.setHeader(String.valueOf(length), "Content-Length"); if (proxyAsAttachment) { response.setHeader("attachment; filename=\"" + fileName + "\"", "Content-Disposition"); } response.setStatus(200); response.setContentStream(attachmentInputStream, bufferSize, length); } catch (SecurityException e) { NSLog.out.appendln(e); response.setContent(e.getMessage()); response.setStatus(403); } catch (NoSuchElementException e) { NSLog.out.appendln(e); response.setContent(e.getMessage()); response.setStatus(404); } catch (FileNotFoundException e) { NSLog.out.appendln(e); response.setContent(e.getMessage()); response.setStatus(404); } catch (IOException e) { NSLog.out.appendln(e); response.setContent(e.getMessage()); response.setStatus(500); } return response; } finally { if (context._requestSessionID() != null) { WOApplication.application().saveSessionForContext(context); } } } finally { application.sleep(); } } /** * * * @param editingContext * the {@link EOEditingContext} that the result will be inserted into * @param attachmentPrimaryKey * the primaryKey value of an existing ERAttachment in the database * @param requestedWebPath * a URL-encoded portion of the requested ERAttachment path including the file name of the attachment * @return an attachment that matches either both the {@code attachmentPrimaryKey} and the {@code requestedWebPath}, * or just the {@code reqestedWebPath}. If it is null then we throw a SecurityException. * * @author davendasora * @since Apr 25, 2014 */ public static ERAttachment fetchAttachmentFor(final EOEditingContext editingContext, final Integer attachmentPrimaryKey, final String requestedWebPath) { ERAttachment attachment; if (attachmentPrimaryKey != null) { final EOGlobalID gid = EOKeyGlobalID.globalIDWithEntityName(ERAttachment.ENTITY_NAME, new Object[] {(attachmentPrimaryKey)}); attachment = (ERAttachment) ERXEOGlobalIDUtilities.fetchObjectWithGlobalID(editingContext, gid); /* * Ensure the attachment request is a legitimate one by comparing the attachment's webPath to the * requestedWebPath. */ final boolean requestedWebPathIsInvalid = !ERAttachmentRequestHandler.requestedWebPathIsForAttachment(requestedWebPath, attachment); if (requestedWebPathIsInvalid) { throw new SecurityException("You are not allowed to view the requested attachment."); } } else { /* * Aaron Rosenzweig April 25, 2014 * WARNING: This is partially broken on an edge case and no easy fix is available. Any true fix would break * current WOnder users of ERAttachment. Short version: We cannot URLDecode the column in the database efficiently. * See details below: * * If the webPath value that is stored in the database (actualWebPath) contains "%20" (or any other %nn * code) the following fetch will not find it because the requestedWebPath needs to be decoded, which will * remove any occurrences of "%20" from it, causing it to no longer match the actualWebPath value. */ String decodedRequestedWebPath; try { decodedRequestedWebPath = new URI(requestedWebPath).getPath(); attachment = ERAttachment.fetchRequiredAttachmentWithWebPath(editingContext, decodedRequestedWebPath); } catch (URISyntaxException exception) { attachment = null; exception.printStackTrace(); } if (attachment == null) { throw new SecurityException("You are not allowed to view the requested attachment."); } } return attachment; } /** * Takes into account potential URL encoding differences between the {@code requestedWebPath} and the * {@code attachment}'s {@link ERAttachment#webPath() webPath()} attribute. e.g., "/the/web/path/My Attachment.jpg" * will match "/the/web/path/My%20Attachment.jpg" * * @param requestedWebPath * a String to compare to the {@code attachment} parameter's * @param attachment * an ERAttachment * @return {@code true} if the {@code requestedWebPath} matches {@code attachment.webPath()}. {@code false} if not. * * @author davendasora * @since Apr 25, 2014 */ public static boolean requestedWebPathIsForAttachment(final String requestedWebPath, final ERAttachment attachment) { final String actualWebPath = attachment.webPath(); /* * We are using the form-data decoder (URLDecoder.decode(String)) instead of the more appropriate URI#getPath() * because we only need to ensure that both webPath values decode identically. We can't use URI.getPath() here * because the webPath value stored in the database (actualWebPath) may contain illegal values (spaces, etc.) */ final String decodedActualWebPath = ERXStringUtilities.urlDecode(actualWebPath); final String decodedRequestedWebPath = ERXStringUtilities.urlDecode(requestedWebPath); /* * Aaron Rosenzweig - April 24, 2014 - Because the attachment may have been originally uploaded with "%20" (or * another %nn value) already in the file name, we need to compare both the stored decoded (actualWebPath) * against the decoded requested value (requestedWebPath) otherwise we could incorrectly throw a SecurityException. */ final boolean requestedWebPathMatchesTheAttachmentWebPath = decodedRequestedWebPath.equals(decodedActualWebPath); return requestedWebPathMatchesTheAttachmentWebPath; } }