/** * Copyright 2015 Google Inc. All Rights Reserved. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS-IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.google.apphosting.vmruntime; import com.google.appengine.repackaged.com.google.common.collect.ImmutableList; import java.io.IOException; import java.io.OutputStreamWriter; import java.io.PrintWriter; import java.util.Arrays; import java.util.Collection; import javax.servlet.ServletOutputStream; import javax.servlet.http.HttpServletResponse; import javax.servlet.http.HttpServletResponseWrapper; /** * Wraps a {@link HttpServletResponse} so that no user actions can trigger the response to be * committed before any cleanup tasks can be completed by the VmRuntimeWebAppContext. * * <p>Several AppEngine APIs requires the security ticket associated with an active request. Because * of the Content-Length header it is possible for the user to "finish" the response before all API * calls are completed. As soon as the proxy sees the last byte of the response, it sends it off to * the appserver. The appserver then immediately sends it to the user. At that point the ticket used * to identify API requests becomes invalid. * * <p>See meta_app.py for the corresponding Python implementation. * */ public class CommitDelayingResponse extends HttpServletResponseWrapper { protected static final String CONTENT_LENGTH = "Content-Length"; private interface PendingCall { void commit() throws IOException; } /** * The current output mode of the response. Either getOutputStream or getWriter can be used to * write the body of the response but not both. See {@link javax.servlet.ServletResponse}. */ private enum OutputMode { NEW, // No output state set. WRITER, // The Writer returned by getWriter is used to write the response body. OUTPUT_STREAM; // The OutputStream returned by getOutputStream is used to write the response. } private OutputMode mode = OutputMode.NEW; private PrintWriter writer = null; // Any pending actions that have to be delayed until the request completes. private PendingCall pending = null; /** * Subclasses may access this object to read content length information stored in it. */ protected final CommitDelayingOutputStream output; /** * Create a new @code{CommitDelayingResponse} wrapping the provided @code{HttpServletResponse}. * * @param response The response to forward operations to. * @throws IOException */ public CommitDelayingResponse(HttpServletResponse response) throws IOException { super(response); this.output = new CommitDelayingOutputStream(super.getOutputStream()); } /** * Commit any pending changes to the wrapped response. * * @throws IOException */ public void commit() throws IOException { if (pending != null) { pending.commit(); return; } if (output.hasContentLength()) { super.setHeader(CONTENT_LENGTH, Long.toString(output.getContentLength())); } output.flushIfFlushed(); if (writer != null) { writer.close(); } output.closeIfClosed(); } /** * Override flushBuffer from HttpServletResponse. Instead on immediately flushing the buffer the * action is recorded and executed when @code{CommitDelayingResponse#commit()} is called. */ @Override public void flushBuffer() throws IOException { output.flush(); } /* * @see javax.servlet.ServletResponse#getBufferSize() */ @Override public int getBufferSize() { return output.getBufferSize(); } /** * Returns a ServletOutputStream where all writes are forwarded on to the OutputStream of the * wrapped response. The important difference is that calls to close() and flush() are not * forwarded but recorded and executed when @code{CommitDelayingResponse#commit()} is called. * * @see javax.servlet.ServletResponse#getOutputStream() */ @Override public ServletOutputStream getOutputStream() throws IOException { if (mode == OutputMode.WRITER) { throw new IllegalStateException("WRITER"); } mode = OutputMode.OUTPUT_STREAM; return output; } /** * Returns a PrintWriter where all writes are encoded using the encoding returned by * {@link #getCharacterEncoding} and forwarded on to the OutputStream returned by * {@link #getOutputStream}. All calls to close() or flush() are not forwarded but recorded and * executed when @code{CommitDelayingResponse#commit()} is called. * * @see javax.servlet.ServletResponse#getWriter() */ @Override public PrintWriter getWriter() throws IOException { if (mode == OutputMode.OUTPUT_STREAM) { throw new IllegalStateException("STREAM"); } mode = OutputMode.WRITER; if (writer == null) { // Jetty is implementing its own UTF-8 and ISO-8859-1 character encoder in HttpWriter.java. // We will use the default one from OutputStreamWriter. If we notice any discrepancies we // should consider refactoring this to instead use HttpWriter/HttpOutput from Jetty 8. writer = new PrintWriter(new OutputStreamWriter(output, getCharacterEncoding())); } return writer; } /** * Returns true if an operation was made that would have committed the response, false otherwise. */ @Override public boolean isCommitted() { return pending != null || output.isCommitted(); } /* * @see javax.servlet.ServletResponse#reset() */ @Override public void reset() { if (isCommitted()) { throw new IllegalStateException("Committed"); } writer = null; mode = OutputMode.NEW; output.reset(); super.reset(); } /* * @see javax.servlet.ServletResponse#resetBuffer() */ @Override public void resetBuffer() { if (isCommitted()) { throw new IllegalStateException("Committed"); } output.reset(); super.resetBuffer(); } /** * Convenience method equivalent of sendError(sc, null). */ @Override public void sendError(int sc) { sendError(sc, null); } /** * Override sendError from HttpServletResponse. Instead on immediately sending the error the * action is recorded and executed when @code{CommitDelayingResponse#commit()} is called. */ @Override public void sendError(final int sc, final String msg) { if (isCommitted()) { throw new IllegalStateException("Committed"); } pending = new PendingCall() { @Override public void commit() throws IOException { CommitDelayingResponse.super.sendError(sc, msg); } }; } /** * Override sendRedirect from HttpServletResponse. Instead on immediately sending the redirect the * action is recorded and executed when @code{CommitDelayingResponse#commit()} is called. */ @Override public void sendRedirect(final String location) { if (isCommitted()) { throw new IllegalStateException("Committed"); } pending = new PendingCall() { @Override public void commit() throws IOException { CommitDelayingResponse.super.sendRedirect(location); } }; } /** * Override setBufferSize from ServletResponse. We are not forwarding this operation to the * wrapped response as it might trigger a flush. Instead it is reported to the output buffer * so it can use the information to determine when a response would have been committed. * * @see javax.servlet.ServletResponse#setBufferSize(int) */ @Override public void setBufferSize(int size) { output.setBufferSize(size); } /* * Jetty's response object will commit the response if setContentLength(length) is called and the * number of bytes written is equal or greater than the length specified. This means we need to * interpose on each way the user can set setting content length, including calls to set/add * header when the name parameter is Content-Length. */ @Override public void setContentLength(int len) { output.setContentLength(len); } private void handleContentLengthHeader(String value) { if (value == null) { output.clearContentLength(); return; } output.setContentLength(Long.parseLong(value)); } @Override public void setHeader(String name, String value) { if (CONTENT_LENGTH.equalsIgnoreCase(name)) { handleContentLengthHeader(value); return; } super.setHeader(name, value); } @Override public void addHeader(String name, String value) { if (CONTENT_LENGTH.equalsIgnoreCase(name)) { handleContentLengthHeader(value); return; } super.addHeader(name, value); } @Override public void setIntHeader(String name, int value) { if (CONTENT_LENGTH.equalsIgnoreCase(name)) { setContentLength(value); return; } super.setIntHeader(name, value); } @Override public void addIntHeader(String name, int value) { if (CONTENT_LENGTH.equalsIgnoreCase(name)) { setContentLength(value); return; } super.addIntHeader(name, value); } @Override public boolean containsHeader(String name) { return CONTENT_LENGTH.equalsIgnoreCase(name) ? output.hasContentLength() : super.containsHeader(name); } @Override public String getHeader(String name) { if (name.equals(CONTENT_LENGTH)) { return output.hasContentLength() ? Long.toString(output.getContentLength()) : null; } return super.getHeader(name); } @Override public Collection<String> getHeaders(String name) { if (name.equals(CONTENT_LENGTH) && output.hasContentLength()) { return Arrays.asList(new String[] {Long.toString(output.getContentLength())}); } return super.getHeaders(name); } @Override public Collection<String> getHeaderNames() { if (output.hasContentLength()) { // "Any changes to the returned Collection must not affect this HttpServletResponse." ImmutableList.Builder<String> builder = ImmutableList.builder(); builder.addAll(super.getHeaderNames()); if (output.hasContentLength()) { builder.add(CONTENT_LENGTH); } return builder.build(); } return super.getHeaderNames(); } }