All Downloads are FREE. Search and download functionalities are using the official Maven repository.

sirius.web.http.Response Maven / Gradle / Ivy

/*
 * Made with all the love in the world
 * by scireum in Remshalden, Germany
 *
 * Copyright by scireum GmbH
 * http://www.scireum.de - [email protected]
 */

package sirius.web.http;

import com.google.common.base.Charsets;
import com.google.common.collect.Sets;
import com.ning.http.client.AsyncHandler;
import com.ning.http.client.AsyncHttpClient;
import com.ning.http.client.AsyncHttpClientConfig;
import com.ning.http.client.FluentCaseInsensitiveStringsMap;
import com.ning.http.client.HttpResponseBodyPart;
import com.ning.http.client.HttpResponseHeaders;
import io.netty.buffer.ByteBuf;
import io.netty.buffer.Unpooled;
import io.netty.channel.ChannelFuture;
import io.netty.channel.ChannelFutureListener;
import io.netty.channel.ChannelHandlerContext;
import io.netty.channel.DefaultFileRegion;
import io.netty.handler.codec.http.DefaultFullHttpResponse;
import io.netty.handler.codec.http.DefaultHttpContent;
import io.netty.handler.codec.http.DefaultHttpHeaders;
import io.netty.handler.codec.http.DefaultHttpResponse;
import io.netty.handler.codec.http.DefaultLastHttpContent;
import io.netty.handler.codec.http.FullHttpResponse;
import io.netty.handler.codec.http.HttpChunkedInput;
import io.netty.handler.codec.http.HttpHeaders;
import io.netty.handler.codec.http.HttpMethod;
import io.netty.handler.codec.http.HttpResponse;
import io.netty.handler.codec.http.HttpResponseStatus;
import io.netty.handler.codec.http.HttpVersion;
import io.netty.handler.codec.http.LastHttpContent;
import io.netty.handler.codec.http.cookie.Cookie;
import io.netty.handler.codec.http.cookie.ServerCookieEncoder;
import io.netty.handler.ssl.SslHandler;
import io.netty.handler.stream.ChunkedFile;
import io.netty.handler.stream.ChunkedStream;
import io.netty.handler.stream.ChunkedWriteHandler;
import org.rythmengine.Rythm;
import sirius.kernel.Sirius;
import sirius.kernel.async.CallContext;
import sirius.kernel.commons.MultiMap;
import sirius.kernel.commons.Strings;
import sirius.kernel.commons.Tuple;
import sirius.kernel.di.std.Part;
import sirius.kernel.health.Exceptions;
import sirius.kernel.health.HandledException;
import sirius.kernel.health.Microtiming;
import sirius.kernel.nls.NLS;
import sirius.kernel.xml.XMLStructuredOutput;
import sirius.web.services.JSONStructuredOutput;
import sirius.web.templates.Resource;
import sirius.web.templates.Resources;
import sirius.web.templates.rythm.RythmConfig;

import javax.annotation.Nullable;
import java.io.File;
import java.io.IOException;
import java.io.OutputStream;
import java.io.RandomAccessFile;
import java.net.SocketException;
import java.net.URLConnection;
import java.nio.channels.ClosedChannelException;
import java.text.SimpleDateFormat;
import java.util.Calendar;
import java.util.Collection;
import java.util.Date;
import java.util.GregorianCalendar;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.TimeZone;
import java.util.concurrent.TimeUnit;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

/**
 * Represents a response which is used to reply to a HTTP request.
 * 

* Responses are created by calling {@link sirius.web.http.WebContext#respondWith()}. * * @see WebContext */ public class Response { /** * Default cache duration for responses which can be cached */ public static final int HTTP_CACHE = 60 * 60; /** * Expires value used to indicate that a resource can be infinitely long cached */ public static final int HTTP_CACHE_INFINITE = 60 * 60 * 24 * 356 * 20; /** * Size of the internally used transfer buffers */ public static final int BUFFER_SIZE = 8192; /* * Caches the GMT TimeZone (lookup is synchronized) */ private static final TimeZone TIME_ZONE_GMT = TimeZone.getTimeZone("GMT"); /* * Stores the associated request */ private WebContext wc; /* * Stores the underlying channel */ private ChannelHandlerContext ctx; /* * Stores the outgoing headers to be sent */ private HttpHeaders headers; /* * Stores the max expiration of this response. A null value indicates to use the defaults suggested * by the content creator. */ private Integer cacheSeconds = null; /* * Stores if this response should be considered "private" by intermediate caches and proxies */ private boolean isPrivate = false; /* * Determines if the response should be marked as download */ private boolean download = false; /* * Contains the name of the downloadable file */ private String name; /* * Caches the date formatter used to output http date headers */ private SimpleDateFormat dateFormatter; /* * Determines if the response supports keepalive */ private boolean responseKeepalive = true; /* * Determines if the response is chunked */ private boolean responseChunked = false; @Part private static Resources resources; /** * Creates a new response for the given request. * * @param wc the context representing the request for which this response is created */ protected Response(WebContext wc) { this.wc = wc; this.ctx = wc.getCtx(); } /* * Creates and initializes a HttpResponse with a complete result at hands. * Takes care of the keep alive logic, cookies and other default headers */ private DefaultFullHttpResponse createFullResponse(HttpResponseStatus status, boolean keepalive, ByteBuf buffer) { DefaultFullHttpResponse response = new DefaultFullHttpResponse(HttpVersion.HTTP_1_1, status, buffer); setupResponse(status, keepalive, response); response.headers().set(HttpHeaders.Names.CONTENT_LENGTH, buffer.readableBytes()); return response; } /* * Creates and initializes a HttpResponse which result will follow as byte buffers * Takes care of the keep alive logic, cookies and other default headers */ private DefaultHttpResponse createResponse(HttpResponseStatus status, boolean keepalive) { DefaultHttpResponse response = new DefaultHttpResponse(HttpVersion.HTTP_1_1, status); if (headers == null || !headers.contains(HttpHeaders.Names.CONTENT_LENGTH)) { // We cannot keepalive if the response length is unknown... keepalive = false; } setupResponse(status, keepalive, response); return response; } /* * Creates and initializes a HttpResponse which result will follow as chunks. If the requestor does not * support HTTP 1.1 we fall back to a "normal" response and disable keepalive (as we need to close the * connection to signal the end of the response). Check the responseChunked flag to generate a proper * response. * * Takes care of the keep alive logic, cookies and other default headers */ private DefaultHttpResponse createChunkedResponse(HttpResponseStatus status, boolean keepalive) { if (wc.getRequest().getProtocolVersion() == HttpVersion.HTTP_1_0) { // HTTP 1.0 does not support chunked results... return createResponse(status, keepalive); } DefaultHttpResponse response = new DefaultHttpResponse(HttpVersion.HTTP_1_1, status); response.headers().set(HttpHeaders.Names.TRANSFER_ENCODING, HttpHeaders.Values.CHUNKED); responseChunked = true; setupResponse(status, keepalive, response); return response; } /* * Sets all headers and so on for the response */ private void setupResponse(HttpResponseStatus status, boolean keepalive, DefaultHttpResponse response) { if (status.code() >= 500) { WebServer.serverErrors++; if (WebServer.serverErrors < 0) { WebServer.serverErrors = 0; } } else if (status.code() >= 400) { WebServer.clientErrors++; if (WebServer.clientErrors < 0) { WebServer.clientErrors = 0; } } //Apply headers if (headers != null) { response.headers().add(headers); } // Add keepalive header if required if (responseKeepalive && keepalive && isKeepalive()) { response.headers().set(HttpHeaders.Names.CONNECTION, HttpHeaders.Values.KEEP_ALIVE); } else { responseKeepalive = false; } // Add cookies Collection cookies = wc.getOutCookies(); if (cookies != null && !cookies.isEmpty()) { response.headers().set(HttpHeaders.Names.SET_COOKIE, ServerCookieEncoder.LAX.encode(cookies)); } // Add Server: nodeName as header response.headers() .set(HttpHeaders.Names.SERVER, CallContext.getNodeName() + " (scireum SIRIUS - powered by Netty)"); // Add a P3P-Header. This is used to disable the 3rd-Party auth handling of InternetExplorer // which is pretty broken and not used (google and facebook does the same). if (WebContext.addP3PHeader) { response.headers().set("P3P", "CP=\"This site does not have a p3p policy.\""); } // Add CORS header...: http://enable-cors.org if (WebContext.corsAllowAll && !response.headers().contains(HttpHeaders.Names.ACCESS_CONTROL_ALLOW_ORIGIN)) { String requestedOrigin = wc.getHeader(HttpHeaders.Names.ORIGIN); if (Strings.isFilled(requestedOrigin)) { response.headers().set(HttpHeaders.Names.ACCESS_CONTROL_ALLOW_ORIGIN, requestedOrigin); } } } /* * Boilerplate for commit(response, true) */ private ChannelFuture commit(HttpResponse response) { return commit(response, true); } /* * Commits the response. Once this was called, no other response can be created for this request (WebContext). */ private ChannelFuture commit(HttpResponse response, boolean flush) { if (wc.responseCommitted) { if (response instanceof FullHttpResponse) { ((FullHttpResponse) response).release(); } throw Exceptions.handle() .to(WebServer.LOG) .error(new IllegalStateException()) .withSystemErrorMessage("Response for %s was already committed!", wc.getRequestedURI()) .handle(); } if (WebServer.LOG.isFINE()) { WebServer.LOG.FINE("COMMITTING: " + wc.getRequestedURI()); } wc.responseCommitted = true; return flush ? ctx.writeAndFlush(response) : ctx.write(response); } /** * Disables keep-alive protocol (even if it would have been otherwise supported). * * @return the response itself for fluent method calls */ public Response noKeepalive() { responseKeepalive = false; return this; } /* * Determines if keepalive is requested by the client and wanted by the server */ private boolean isKeepalive() { return HttpHeaders.isKeepAlive(wc.getRequest()) && ((WebServerHandler) ctx.handler()).shouldKeepAlive(); } /* * Completes the response and closes the underlying channel if necessary */ private void complete(ChannelFuture future, final boolean supportKeepalive) { if (wc.responseCompleted) { WebServer.LOG.FINE("Response for %s was already completed!", wc.getRequestedURI()); return; } wc.responseCompleted = true; if (WebServer.LOG.isFINE()) { WebServer.LOG.FINE("COMPLETING: " + wc.getRequestedURI()); } // If we're still confident, that keepalive is supported, and we announced this in the response header, // we'll keep the connection open. Otherwise it will be closed by the server final boolean keepalive = supportKeepalive && responseKeepalive; final CallContext cc = CallContext.getCurrent(); future.addListener(new ChannelFutureListener() { @Override public void operationComplete(ChannelFuture future) throws Exception { if (wc.completionCallback != null) { try { wc.completionCallback.invoke(cc); } catch (Throwable e) { Exceptions.handle(WebServer.LOG, e); } } wc.release(); if (wc.microtimingKey != null && Microtiming.isEnabled()) { cc.getWatch().submitMicroTiming("HTTP", WebServer.microtimingMode.getMicrotimingKey(wc)); } if (!wc.isLongCall() && wc.started > 0) { WebServer.responseTime.addValue(System.currentTimeMillis() - wc.started); } if (!keepalive) { if (WebServer.LOG.isFINE()) { WebServer.LOG.FINE("CLOSING: " + wc.getRequestedURI()); } future.channel().close(); } else { if (WebServer.LOG.isFINE()) { WebServer.LOG.FINE("KEEP-ALIVE: " + wc.getRequestedURI()); } WebServer.keepalives++; if (WebServer.keepalives < 0) { WebServer.keepalives = 0; } } } }); } /* * Completes the response once the given future completed while supporting keepalive (response size must be known * or response must be chunked). */ private void complete(ChannelFuture future) { complete(future, true); } /* * Completes the response once the given future completed without supporting keepalive (which is either unwanted * or the response size is not known yet). */ private void completeAndClose(ChannelFuture future) { complete(future, false); } /* * Determines if the given modified date is past the If-Modified-Since header of the request. If not the * request is auto-completed with a 304 status (NOT_MODIFIED) */ private boolean handleIfModifiedSince(long lastModifiedInMillis) { long ifModifiedSinceDateSeconds = wc.getDateHeader(HttpHeaders.Names.IF_MODIFIED_SINCE) / 1000; if (ifModifiedSinceDateSeconds > 0 && lastModifiedInMillis > 0) { if (ifModifiedSinceDateSeconds >= lastModifiedInMillis / 1000) { setDateAndCacheHeaders(lastModifiedInMillis, cacheSeconds == null ? HTTP_CACHE : cacheSeconds, isPrivate); status(HttpResponseStatus.NOT_MODIFIED); return false; } } return true; } /** * Forces the use of a given name. This is also used to derive the mime type. * * @param name the file name to use * @return this to fluently create the response */ public Response named(String name) { this.name = name; return this; } /** * Instructs the browser to treat the response as download with the given file name. * * @param name the file name to send to the browser. If the given name is null nothing happens (We * won't force a download). * @return this to fluently create the response */ public Response download(@Nullable String name) { if (Strings.isFilled(name)) { this.name = name; this.download = true; } return this; } /** * Instructs the browser to treat the response as inline-download with the given file name. * * @param name the file name to send to the browser * @return this to fluently create the response */ public Response inline(String name) { this.name = name; this.download = false; return this; } /** * Marks this response as not-cachable. * * @return this to fluently create the response */ public Response notCached() { this.cacheSeconds = 0; return this; } /** * Marks this response as only privately cachable (only the browser may cache it, but not a proxy etc.) * * @return this to fluently create the response */ public Response privateCached() { this.isPrivate = true; this.cacheSeconds = HTTP_CACHE; return this; } /** * Marks this response as cachable for the given amount of time. * * @param numberOfSeconds the number of seconds the response might be cached * @return this to fluently create the response */ public Response cachedForSeconds(int numberOfSeconds) { this.isPrivate = false; this.cacheSeconds = numberOfSeconds; return this; } /** * Marks this response as cachable. * * @return this to fluently create the response */ public Response cached() { this.isPrivate = false; this.cacheSeconds = HTTP_CACHE; return this; } /** * Marks this response as infinitely cachable. *

* This suggests that it will never change. * * @return this to fluently create the response */ public Response infinitelyCached() { this.isPrivate = false; this.cacheSeconds = HTTP_CACHE_INFINITE; return this; } /** * Sets the specified header. * * @param name name of the header * @param value value of the header * @return this to fluently create the response */ public Response setHeader(String name, Object value) { headers().set(name, value); return this; } protected HttpHeaders headers() { if (headers == null) { headers = new DefaultHttpHeaders(); } return headers; } /** * Adds the specified header. *

* In contrast to {@link #setHeader(String, Object)} this method can be called multiple times for the same * header and its values will be concatenated as specified in the HTTP protocol. * * @param name name of the header * @param value value of the header * @return this to fluently create the response */ public Response addHeader(String name, Object value) { headers().add(name, value); return this; } /** * Only adds the given header if no header with the given name does exist yet. * * @param name name of the header * @param value value of the header * @return this to fluently create the response */ public Response addHeaderIfNotExists(String name, Object value) { if (!headers().contains(name)) { headers().set(name, value); } return this; } /** * Adds all given headers * * @param inputHeaders headers to add * @return this to fluently create the response */ public Response headers(MultiMap inputHeaders) { for (Map.Entry> e : inputHeaders.getUnderlyingMap().entrySet()) { for (Object value : e.getValue()) { addHeader(e.getKey(), value); } } return this; } /** * Completes this response by sending the given status code without any content * * @param status the HTTP status to sent */ public void status(HttpResponseStatus status) { HttpResponse response = createFullResponse(status, true, Unpooled.EMPTY_BUFFER); complete(commit(response)); } /** * Sends a 307 (temporary redirect) or 302 (found) to the given url as result, depending on the given HTTP * protocol in the request. * * @param url the URL to redirect to */ public void redirectTemporarily(String url) { if (wc.getRequest().getProtocolVersion() == HttpVersion.HTTP_1_0) { // Fallback to HTTP/1.0 code 302 found, which does mostly the same job but has a bad image due to // URL hijacking via faulty search engines. The main difference is that 307 will enforce the browser // to use the same method for the request to the reported location. Where as 302 doesn't specify which // method to use, so a POST might be re-sent as GET to the new location HttpResponse response = createFullResponse(HttpResponseStatus.FOUND, true, Unpooled.EMPTY_BUFFER); response.headers().set(HttpHeaders.Names.LOCATION, url); complete(commit(response)); } else { // Prefer the HTTP/1.1 code 307 as temporary redirect HttpResponse response = createFullResponse(HttpResponseStatus.TEMPORARY_REDIRECT, true, Unpooled.EMPTY_BUFFER); response.headers().set(HttpHeaders.Names.LOCATION, url); complete(commit(response)); } } /** * Sends a 301 (permanent redirect) to the given url as result. * * @param url the URL to redirect to */ public void redirectPermanently(String url) { HttpResponse response = createFullResponse(HttpResponseStatus.MOVED_PERMANENTLY, true, Unpooled.EMPTY_BUFFER); response.headers().set(HttpHeaders.Names.LOCATION, url); complete(commit(response)); } private static final Pattern RANGE_HEADER = Pattern.compile("bytes=(\\d+)?\\-(\\d+)?"); private class SendFile { private File file; private RandomAccessFile raf; private String contentType; private long contentStart; private long expectedContentLength; private Tuple range; void send(File fileToSend) { try { this.file = fileToSend; if (file.isHidden() || !file.exists() || !file.isFile()) { error(HttpResponseStatus.NOT_FOUND); return; } determineContentType(); if (!handleIfModifiedSince(file.lastModified())) { return; } raf = new RandomAccessFile(file, "r"); setDateAndCacheHeaders(file.lastModified(), cacheSeconds == null ? HTTP_CACHE : cacheSeconds, isPrivate); try { parseRangesAndUpdateHeaders(); } catch (IllegalArgumentException e) { error(HttpResponseStatus.REQUESTED_RANGE_NOT_SATISFIABLE, e.getMessage()); return; } if (name != null) { setContentDisposition(name, download); } sendFileResponse(); } catch (Throwable e) { internalServerError(e); } } private void parseRangesAndUpdateHeaders() throws IOException { addHeaderIfNotExists(HttpHeaders.Names.ACCEPT_RANGES, HttpHeaders.Values.BYTES); contentStart = 0; expectedContentLength = raf.length(); range = parseRange(raf.length()); if (range == null) { addHeaderIfNotExists(HttpHeaders.Names.CONTENT_LENGTH, expectedContentLength); } else { contentStart = range.getFirst(); expectedContentLength = range.getSecond() - range.getFirst() + 1; setHeader(HttpHeaders.Names.CONTENT_LENGTH, expectedContentLength); setHeader(HttpHeaders.Names.CONTENT_RANGE, "bytes " + range.getFirst() + "-" + range.getSecond() + "/" + raf.length()); } } private void determineContentType() { contentType = MimeHelper.guessMimeType(name != null ? name : file.getName()); addHeaderIfNotExists(HttpHeaders.Names.CONTENT_TYPE, contentType); } /* * Determines if we're running on SSL */ private boolean isSSL() { return ctx.channel().pipeline().get(SslHandler.class) != null; } private boolean sendFileResponse() throws IOException { HttpResponseStatus responseStatus = range != null ? HttpResponseStatus.PARTIAL_CONTENT : HttpResponseStatus.OK; HttpResponse response; if (canBeCompressed(contentType)) { response = createChunkedResponse(responseStatus, true); } else { response = createResponse(responseStatus, true); } commit(response, false); installChunkedWriteHandler(); ChannelFuture writeFuture = executeChunkedWrite(); writeFuture.addListener(channelFuture -> raf.close()); complete(writeFuture); return false; } private ChannelFuture executeChunkedWrite() throws IOException { if (responseChunked) { // Send chunks of data which can be compressed ctx.write(new HttpChunkedInput(new ChunkedFile(raf, contentStart, expectedContentLength, BUFFER_SIZE))); } else if (isSSL()) { ctx.write(new ChunkedFile(raf, contentStart, expectedContentLength, BUFFER_SIZE)); } else { // Send file using zero copy approach! ctx.write(new DefaultFileRegion(raf.getChannel(), contentStart, expectedContentLength)); } return ctx.writeAndFlush(LastHttpContent.EMPTY_LAST_CONTENT); } private Tuple parseRange(long availableLength) { String header = wc.getHeader(HttpHeaders.Names.RANGE); if (Strings.isEmpty(header)) { return null; } Matcher m = RANGE_HEADER.matcher(header); if (!m.matches()) { throw new IllegalArgumentException(Strings.apply("Range does not match the expected format: %s", header)); } Tuple result = Tuple.create(); if (Strings.isFilled(m.group(1))) { result.setFirst(Long.parseLong(m.group(1))); } else { result.setFirst(availableLength - Long.parseLong(m.group(2))); result.setSecond(availableLength - 1); return result; } result.setFirst(Long.parseLong(m.group(1))); if (Strings.isFilled(m.group(2))) { result.setSecond(Long.parseLong(m.group(2))); } else { result.setSecond(availableLength - 1); } if (result.getSecond() < result.getFirst()) { return null; } if (result.getSecond() >= availableLength) { throw new IllegalArgumentException(Strings.apply("End of range is beyond the end of available data: %s", header)); } return result; } } /* * Determines if the current request should be compressed or not */ private boolean canBeCompressed(String contentType) { String acceptEncoding = wc.getRequest().headers().get(HttpHeaders.Names.ACCEPT_ENCODING); if (acceptEncoding == null || (!acceptEncoding.contains(HttpHeaders.Values.GZIP) && !acceptEncoding.contains( HttpHeaders.Values.DEFLATE))) { return false; } return MimeHelper.isCompressable(contentType); } private void installChunkedWriteHandler() { if (ctx.channel().pipeline().get(ChunkedWriteHandler.class) == null) { ctx.channel().pipeline().addBefore("handler", "chunkedWriter", new ChunkedWriteHandler()); } } /** * Sends the given file as response. *

* Based on the file, full HTTP caching is supported, taking care of If-Modified-Since headers etc. *

* If the request does not use HTTPS, the server tries to support a zero-copy approach leading to maximal * throughput as no copying between user space and kernel space buffers is required. * * @param file the file to send */ public void file(File file) { new SendFile().send(file); } /* * Signals an internal server error if one of the response method fails. */ private void internalServerError(Throwable t) { WebServer.LOG.FINE(t); if (!(t instanceof ClosedChannelException)) { if (t instanceof HandledException) { error(HttpResponseStatus.INTERNAL_SERVER_ERROR, (HandledException) t); } else { String requestUri = "?"; if (wc != null && wc.getRequest() != null) { requestUri = wc.getRequest().getUri(); } Exceptions.handle() .to(WebServer.LOG) .withSystemErrorMessage("An excption occurred while responding to: %s - %s (%s)", requestUri) .handle(); error(HttpResponseStatus.INTERNAL_SERVER_ERROR, Exceptions.handle(WebServer.LOG, t)); } } if (!ctx.channel().isOpen()) { ctx.channel().close(); } } /* * Sets the Date and Cache headers for the HTTP Response */ private void setDateAndCacheHeaders(long lastModifiedMillis, int cacheSeconds, boolean isPrivate) { if (headers().contains(HttpHeaders.Names.EXPIRES) || headers().contains(HttpHeaders.Names.CACHE_CONTROL)) { return; } SimpleDateFormat dateFormatter = getHTTPDateFormat(); if (cacheSeconds > 0) { // Date header Calendar time = new GregorianCalendar(); addHeaderIfNotExists(HttpHeaders.Names.DATE, dateFormatter.format(time.getTime())); // Add cached headers time.add(Calendar.SECOND, cacheSeconds); addHeaderIfNotExists(HttpHeaders.Names.EXPIRES, dateFormatter.format(time.getTime())); if (isPrivate) { addHeaderIfNotExists(HttpHeaders.Names.CACHE_CONTROL, "private, max-age=" + cacheSeconds); } else { addHeaderIfNotExists(HttpHeaders.Names.CACHE_CONTROL, "public, max-age=" + cacheSeconds); } } else { addHeaderIfNotExists(HttpHeaders.Names.CACHE_CONTROL, HttpHeaders.Values.NO_CACHE + ", max-age=0"); } if (lastModifiedMillis > 0 && !headers().contains(HttpHeaders.Names.LAST_MODIFIED)) { addHeaderIfNotExists(HttpHeaders.Names. LAST_MODIFIED, dateFormatter.format(new Date(lastModifiedMillis))); } } /* * Creates a DateFormat to parse HTTP dates. */ private SimpleDateFormat getHTTPDateFormat() { if (dateFormatter == null) { dateFormatter = new SimpleDateFormat(WebContext.HTTP_DATE_FORMAT, Locale.US); dateFormatter.setTimeZone(TIME_ZONE_GMT); } return dateFormatter; } /* * Sets the content disposition header for the HTTP Response */ private void setContentDisposition(String name, boolean download) { String cleanName = name.replaceAll("[^A-Za-z0-9\\-_\\.]", "_"); String utf8Name = Strings.urlEncode(name.replace(" ", "_")); addHeaderIfNotExists("Content-Disposition", (download ? "attachment;" : "inline;") + "filename=\"" + cleanName + "\";filename*=UTF-8''" + utf8Name); } /* * Sets the content type header for the HTTP Response */ private void setContentTypeHeader(String name) { addHeaderIfNotExists(HttpHeaders.Names.CONTENT_TYPE, MimeHelper.guessMimeType(name)); } /** * Tries to resolve the given name into a {@link sirius.web.templates.Resource} using * the {@link sirius.web.templates.Resources} lookup framework. *

* Sends the resource found or a 404 NOT_FOUND otherwise. * * @param name the path of the resource to lookup */ public void sendContent(String name) { Optional res = resources.resolve(name); if (res.isPresent()) { try { if ("file".equals(res.get().getUrl().getProtocol())) { file(new File(res.get().getUrl().toURI())); } else { resource(res.get().getUrl().openConnection()); } } catch (Throwable e) { Exceptions.handle() .to(WebServer.LOG) .withSystemErrorMessage( "An excption occurred while sending content! Content-Name: %s, URL: %s - %s (%s)", name, wc == null || wc.getRequest() == null ? "?" : wc.getRequest().getUri()) .handle(); error(HttpResponseStatus.INTERNAL_SERVER_ERROR, Exceptions.handle(WebServer.LOG, e)); } } else { error(HttpResponseStatus.NOT_FOUND); } } /** * Sends the given resource (potentially from classpath) as result. *

* This will support HTTP caching if enabled (default). * * @param urlConnection the connection to get the data from. */ public void resource(URLConnection urlConnection) { try { long fileLength = urlConnection.getContentLength(); addHeaderIfNotExists(HttpHeaders.Names.CONTENT_LENGTH, fileLength); String contentType = MimeHelper.guessMimeType(name != null ? name : urlConnection.getURL().getFile()); addHeaderIfNotExists(HttpHeaders.Names.CONTENT_TYPE, contentType); setDateAndCacheHeaders(urlConnection.getLastModified(), cacheSeconds == null ? HTTP_CACHE : cacheSeconds, isPrivate); if (name != null) { setContentDisposition(name, download); } DefaultHttpResponse response = canBeCompressed(contentType) ? createChunkedResponse(HttpResponseStatus.OK, true) : createResponse(HttpResponseStatus.OK, true); commit(response); installChunkedWriteHandler(); if (responseChunked) { ctx.write(new HttpChunkedInput(new ChunkedStream(urlConnection.getInputStream(), BUFFER_SIZE))); } else { ctx.write(new ChunkedStream(urlConnection.getInputStream(), BUFFER_SIZE)); } ChannelFuture writeFuture = ctx.writeAndFlush(LastHttpContent.EMPTY_LAST_CONTENT); complete(writeFuture); } catch (Throwable t) { Exceptions.handle() .to(WebServer.LOG) .withSystemErrorMessage( "An excption occurred while sending a resource! Resource: %s, URL: %s - %s (%s)", urlConnection == null ? "null" : urlConnection.getURL().toString(), wc == null || wc.getRequest() == null ? "?" : wc.getRequest().getUri()) .handle(); error(HttpResponseStatus.INTERNAL_SERVER_ERROR, Exceptions.handle(WebServer.LOG, t)); } } /** * Sends an 401 UNAUTHORIZED response with a WWW-Authenticate header for the given realm. *

* This will generally force the client to perform a HTTP Basic authentication. * * @param realm the realm to report to the client. This will be used to select an appropriate username * and password */ public void unauthorized(String realm) { addHeader("WWW-Authenticate", "Basic realm=\"" + realm + "\""); error(HttpResponseStatus.UNAUTHORIZED, "Please specify username and password"); } /** * Sends the given HTTP status as error. *

* If possible a specific template /view/errors/ERRORCODE.html. If not available, /view/errors/default.html * will be rendered. * * @param status the HTTP status to send. */ public void error(HttpResponseStatus status) { error(status, ""); } /** * Sends the given HTTP status as error. Uses the given exception to provide further insight what went wrong. *

* If possible a specific template /view/errors/ERRORCODE.html. If not available, /view/errors/default.html * will be rendered. * * @param status the HTTP status to send * @param t the exception to display. Use {@link sirius.kernel.health.Exceptions} to create a * handled exception. */ public void error(HttpResponseStatus status, HandledException t) { error(status, NLS.toUserString(t)); } /** * Sends the given HTTP status as error. Uses the given message to provide further insight what went wrong. *

* If possible a specific template /view/errors/ERRORCODE.html. If not available, /view/errors/default.html * will be rendered. * * @param status the HTTP status to send * @param message A message or description of what went wrong */ public void error(HttpResponseStatus status, String message) { try { if (wc.responseCommitted) { if (ctx.channel().isOpen()) { ctx.channel().close(); } return; } if (!ctx.channel().isWritable()) { return; } if (wc.getRequest().getMethod() == HttpMethod.HEAD) { status(status); return; } String content = Rythm.renderIfTemplateExists("view/errors/" + status.code() + ".html", status, message); if (Strings.isEmpty(content)) { content = Rythm.renderIfTemplateExists("view/errors/error.html", status, message); } if (Strings.isEmpty(content)) { content = Rythm.renderIfTemplateExists("view/errors/default.html", status, message); } setHeader(HttpHeaders.Names.CONTENT_TYPE, "text/html; charset=UTF-8"); ByteBuf channelBuffer = wrapUTF8String(content); HttpResponse response = createFullResponse(status, false, channelBuffer); completeAndClose(commit(response)); } catch (Throwable e) { Exceptions.handle() .to(WebServer.LOG) .withSystemErrorMessage("An excption occurred while sending an HTTP error! " + "Original Status Code: %s, Original Error: %s, URL: %s - %s (%s)", status == null ? "null" : status.code(), message, wc == null || wc.getRequest() == null ? "?" : wc.getRequest().getUri()) .handle(); if (wc.responseCommitted) { if (ctx.channel().isOpen()) { ctx.channel().close(); } return; } if (!ctx.channel().isWritable()) { return; } ByteBuf channelBuffer = wrapUTF8String(Exceptions.handle(WebServer.LOG, e).getMessage()); HttpResponse response = createFullResponse(HttpResponseStatus.INTERNAL_SERVER_ERROR, false, channelBuffer); response.headers().set(HttpHeaders.Names.CONTENT_TYPE, "text/plain; charset=UTF-8"); HttpHeaders.setContentLength(response, channelBuffer.readableBytes()); completeAndClose(commit(response)); } } /* * Converts a string into a ByteBuf */ private ByteBuf wrapUTF8String(String content) { // Returns a heap buffer - but strings are almost always compressed (HTML templtes etc.) so this // is probably faster return Unpooled.copiedBuffer(content.toCharArray(), Charsets.UTF_8); } /** * Directly sends the given string as response, without any content type. Enable the caller to close the * underlying channel (without caring about keep-alive). This can be used to report errors as JSON result to * AJAX callers. *

* This should only be used when really required (meaning when you really know what you're doing. * The encoding used will be UTF-8). * * @param status the HTTP status to send * @param content the string contents to send. */ public void direct(HttpResponseStatus status, String content) { try { setDateAndCacheHeaders(System.currentTimeMillis(), cacheSeconds == null || Sirius.isDev() ? 0 : cacheSeconds, isPrivate); ByteBuf channelBuffer = wrapUTF8String(content); HttpResponse response = createFullResponse(status, true, channelBuffer); complete(commit(response)); } catch (Throwable e) { internalServerError(e); } } /** * Renders the given Rythm template and sends the output as response. *

* By default caching will be disabled. If the file ends with .html, text/html; charset=UTF-8 will be set * as content type. Otherwise the content type will be guessed from the filename. * * @param name the name of the template to render. It's recommended to use files in /view/... and to place them * in the resources directory. * @param params contains the parameters sent to the template */ public void template(String name, Object... params) { String content = null; wc.enableTiming(null); try { if (params.length == 1 && params[0] instanceof Object[]) { params = (Object[]) params[0]; } content = Rythm.render(name, params); } catch (Throwable e) { throw Exceptions.handle() .to(RythmConfig.LOG) .error(e) .withSystemErrorMessage("Failed to render the template '%s': %s (%s)", name) .handle(); } sendTemplateContent(name, content); } protected void sendTemplateContent(String name, String content) { try { if (name.endsWith("html")) { setHeader(HttpHeaders.Names.CONTENT_TYPE, "text/html; charset=UTF-8"); } else { setContentTypeHeader(name); } setDateAndCacheHeaders(System.currentTimeMillis(), cacheSeconds == null || Sirius.isDev() ? 0 : cacheSeconds, isPrivate); ByteBuf channelBuffer = wrapUTF8String(content); HttpResponse response = createFullResponse(HttpResponseStatus.OK, true, channelBuffer); complete(commit(response)); } catch (Throwable e) { internalServerError(e); } } /** * Tries to find an appropriate Rythm template for the current language and sends the output as response. *

* Based on the given name, name_LANG.html or as fallback name.html will be loaded. As * language, the two-letter language code of {@link sirius.kernel.async.CallContext#getLang()} will be used. *

* By default caching will be disabled. If the file ends with .html, text/html; charset=UTF-8 will be set * as content type. Otherwise the content type will be guessed from the filename. * * @param name the name of the template to render. It's recommended to use files in /view/... and to place them * in the resources directory. * @param params contains the parameters sent to the template */ public void nlsTemplate(String name, Object... params) { String content = null; wc.enableTiming(null); try { if (params.length == 1 && params[0] instanceof Object[]) { params = (Object[]) params[0]; } content = Rythm.renderIfTemplateExists(name + "_" + NLS.getCurrentLang() + ".html", params); if (Strings.isEmpty(content)) { content = Rythm.renderIfTemplateExists(name + "_" + NLS.getDefaultLanguage() + ".html", params); } if (Strings.isEmpty(content)) { content = Rythm.render(name + ".html", params); } } catch (Throwable e) { throw Exceptions.handle() .to(RythmConfig.LOG) .error(e) .withSystemErrorMessage("Failed to render the template '%s': %s (%s)", name) .handle(); } sendTemplateContent(name + ".html", content); } protected static AsyncHttpClient asyncClient; /* * Generates and returns a pooling fully asynchronous HTTP client */ protected static AsyncHttpClient getAsyncClient() { if (asyncClient == null) { asyncClient = new AsyncHttpClient(new AsyncHttpClientConfig.Builder().setAllowPoolingConnections(true) .setAllowPoolingSslConnections(true) .setRequestTimeout(-1) .build()); } return asyncClient; } /* * Closes the async client used to tunnel data (if one was created). */ protected static void closeAsyncClient() { if (asyncClient != null) { asyncClient.close(); } } private static final Set NON_TUNNELLED_HEADERS = Sets.newHashSet(HttpHeaders.Names.TRANSFER_ENCODING, HttpHeaders.Names.SERVER, HttpHeaders.Names.CONTENT_ENCODING, HttpHeaders.Names.EXPIRES, HttpHeaders.Names.CACHE_CONTROL); /** * Tunnels the contents retrieved from the given URL as result of this response. *

* Caching and range headers will be forwarded and adhered. *

* Uses non-blocking APIs in order to maximize throughput. Therefore this can be called in an unforked * dispatcher. * * @param url the url to tunnel through. */ public void tunnel(final String url) { try { AsyncHttpClient.BoundRequestBuilder brb = getAsyncClient().prepareGet(url); // Support caching... long ifModifiedSince = wc.getDateHeader(HttpHeaders.Names.IF_MODIFIED_SINCE); if (ifModifiedSince > 0) { brb.addHeader(HttpHeaders.Names.IF_MODIFIED_SINCE, getHTTPDateFormat().format(ifModifiedSince)); } // Support range requests... String range = wc.getHeader(HttpHeaders.Names.RANGE); if (Strings.isFilled(range)) { brb.addHeader(HttpHeaders.Names.RANGE, range); } if (WebServer.LOG.isFINE()) { WebServer.LOG.FINE("Tunnel START: %s", url); } // Tunnel it through... brb.execute(new TunnelHandler(url)); } catch (Throwable t) { if (!(t instanceof ClosedChannelException)) { // We do not use the message generated by this, to respond to the client, // as we do not want to reveal the backing URL Exceptions.handle() .to(WebServer.LOG) .withSystemErrorMessage( "An excption occurred while tunneling a request! Target-URL: %s, URL: %s - %s (%s)", url, wc == null || wc.getRequest() == null ? "?" : wc.getRequest().getUri()) .handle(); error(HttpResponseStatus.INTERNAL_SERVER_ERROR, Exceptions.handle(WebServer.LOG, t)); } } } private class TunnelHandler implements AsyncHandler { private final String url; private final CallContext cc; private int responseCode = HttpResponseStatus.OK.code(); private boolean contentLengthKnown; private TunnelHandler(String url) { this.url = url; this.cc = CallContext.getCurrent(); } @Override public STATE onHeadersReceived(HttpResponseHeaders h) throws Exception { CallContext.setCurrent(cc); if (wc.responseCommitted) { if (WebServer.LOG.isFINE()) { WebServer.LOG.FINE("Tunnel - BLOCKED HEADERS (already sent) for %s", wc.getRequestedURI()); } return STATE.CONTINUE; } if (WebServer.LOG.isFINE()) { WebServer.LOG.FINE("Tunnel - HEADERS for %s", wc.getRequestedURI()); } FluentCaseInsensitiveStringsMap headers = h.getHeaders(); long lastModified = 0; for (Map.Entry> entry : headers.entrySet()) { if ((Sirius.isDev() || !entry.getKey().startsWith("x-")) && !NON_TUNNELLED_HEADERS.contains(entry.getKey())) { for (String value : entry.getValue()) { if (HttpHeaders.Names.LAST_MODIFIED.equals(entry.getKey())) { try { lastModified = getHTTPDateFormat().parse(value).getTime(); } catch (Throwable e) { Exceptions.ignore(e); } } else { addHeaderIfNotExists(entry.getKey(), value); } } if (HttpHeaders.Names.CONTENT_LENGTH.equals(entry.getKey())) { contentLengthKnown = true; } } } if (!handleIfModifiedSince(lastModified)) { return STATE.ABORT; } if (!headers().contains(HttpHeaders.Names.CONTENT_TYPE)) { setContentTypeHeader(name != null ? name : url); } setDateAndCacheHeaders(lastModified, cacheSeconds == null ? HTTP_CACHE : cacheSeconds, isPrivate); if (name != null) { setContentDisposition(name, download); } return STATE.CONTINUE; } @Override public STATE onBodyPartReceived(HttpResponseBodyPart bodyPart) throws Exception { try { CallContext.setCurrent(cc); if (WebServer.LOG.isFINE()) { WebServer.LOG.FINE("Tunnel - CHUNK: %s for %s (Last: %s)", bodyPart, wc.getRequestedURI(), bodyPart.isLast()); } if (!ctx.channel().isOpen()) { return STATE.ABORT; } ByteBuf data = Unpooled.wrappedBuffer(bodyPart.getBodyByteBuffer()); if (!wc.responseCommitted) { //Send a response first if (bodyPart.isLast()) { HttpResponse response = createFullResponse(HttpResponseStatus.valueOf(responseCode), true, data); HttpHeaders.setContentLength(response, bodyPart.getBodyByteBuffer().remaining()); complete(commit(response)); return STATE.CONTINUE; } else { if (contentLengthKnown) { commit(createResponse(HttpResponseStatus.valueOf(responseCode), true)); } else { commit(createChunkedResponse(HttpResponseStatus.valueOf(responseCode), true)); } } } if (bodyPart.isLast()) { if (responseChunked) { ChannelFuture writeFuture = ctx.writeAndFlush(new DefaultLastHttpContent(data)); complete(writeFuture); } else { ctx.channel().write(data); ChannelFuture writeFuture = ctx.writeAndFlush(LastHttpContent.EMPTY_LAST_CONTENT); complete(writeFuture); } } else { Object msg = responseChunked ? new DefaultHttpContent(data) : data; contentionAwareWrite(msg); } return STATE.CONTINUE; } catch (HandledException e) { Exceptions.ignore(e); return STATE.ABORT; } catch (Throwable e) { Exceptions.handle(e); return STATE.ABORT; } } @Override public STATE onStatusReceived(com.ning.http.client.HttpResponseStatus httpResponseStatus) throws Exception { CallContext.setCurrent(cc); if (WebServer.LOG.isFINE()) { WebServer.LOG.FINE("Tunnel - STATUS %s for %s", httpResponseStatus.getStatusCode(), wc.getRequestedURI()); } if (httpResponseStatus.getStatusCode() >= 200 && httpResponseStatus.getStatusCode() < 300) { responseCode = httpResponseStatus.getStatusCode(); return STATE.CONTINUE; } if (httpResponseStatus.getStatusCode() == HttpResponseStatus.NOT_MODIFIED.code()) { status(HttpResponseStatus.NOT_MODIFIED); return STATE.ABORT; } error(HttpResponseStatus.valueOf(httpResponseStatus.getStatusCode())); return STATE.ABORT; } @Override public String onCompleted() throws Exception { CallContext.setCurrent(cc); if (WebServer.LOG.isFINE()) { WebServer.LOG.FINE("Tunnel - COMPLETE for %s", wc.getRequestedURI()); } if (!wc.responseCommitted) { HttpResponse response = createFullResponse(HttpResponseStatus.valueOf(responseCode), true, Unpooled.EMPTY_BUFFER); HttpHeaders.setContentLength(response, 0); complete(commit(response)); } else if (!wc.responseCompleted) { ChannelFuture writeFuture = ctx.writeAndFlush(LastHttpContent.EMPTY_LAST_CONTENT); complete(writeFuture); } return ""; } @Override public void onThrowable(Throwable t) { CallContext.setCurrent(cc); WebServer.LOG.WARN("Tunnel - ERROR %s for %s", t.getMessage() + " (" + t.getMessage() + ")", wc.getRequestedURI()); if (!(t instanceof ClosedChannelException)) { error(HttpResponseStatus.INTERNAL_SERVER_ERROR, Exceptions.handle(WebServer.LOG, t)); } } } /** * Creates a JSON output which can be used to generate well formed json. *

* By default, caching will be disabled. If the generated JSON is small enough, it will be transmitted in * one go. Otherwise a chunked response will be sent. * * @return a structured output which will be sent as JSON response */ public JSONStructuredOutput json() { String callback = wc.get("callback").getString(); String encoding = wc.get("encoding").first().asString(Charsets.UTF_8.name()); return new JSONStructuredOutput(outputStream(HttpResponseStatus.OK, "application/json;charset=" + encoding), callback, encoding); } /** * Creates a XML output which can be used to generate well formed XML. *

* By default, caching will be disabled. If the generated XML is small enough, it will be transmitted in * one go. Otherwise a chunked response will be sent. * * @return a structured output which will be sent as XML response */ public XMLStructuredOutput xml() { return new XMLStructuredOutput(outputStream(HttpResponseStatus.OK, MimeHelper.TEXT_XML)); } /** * Creates an OutputStream which is sent to the client. *

* If the contents are small enough, everything will be sent in one response. Otherwise a chunked response * will be sent. The size of the underlying buffer will be determined by {@link #BUFFER_SIZE}. *

* WARNING: Do not used this kind of response directly from a {@link WebDispatcher}! You need to fork a * new thread using {@link sirius.kernel.async.Tasks} as the internal workings might block in * {@code OutputStream.write} until the message is fully written to the channel. This might lead to a deadlock * if the kernel buffer needs to be flushed as well (as this needs the worker thread to handle the write which is * blocked internally due to waiting for the chunk to be written). *

* By default, caching will be supported. * * @param status the HTTP status to send * @param contentType the content type to use. If null, we rely on a previously set header. * @return an output stream which will be sent as response */ public OutputStream outputStream(final HttpResponseStatus status, @Nullable final String contentType) { if (wc.responseCommitted) { throw Exceptions.createHandled() .withSystemErrorMessage("Response for %s was already committed!", wc.getRequestedURI()) .handle(); } return new ChunkedOutputStream(contentType, status); } private class ChunkedOutputStream extends OutputStream { private final String contentType; private final HttpResponseStatus status; volatile boolean open; volatile long bytesWritten; ByteBuf buffer; private ChunkedOutputStream(String contentType, HttpResponseStatus status) { this.contentType = contentType; this.status = status; open = true; bytesWritten = 0; buffer = null; } private void ensureCapacity(int length) throws IOException { if (buffer != null && buffer.writableBytes() < length) { flushBuffer(false); } if (buffer == null) { buffer = ctx.alloc().buffer(BUFFER_SIZE); } } private void flushBuffer(boolean last) throws IOException { if ((buffer == null || buffer.readableBytes() == 0) && !last) { if (buffer != null) { buffer.release(); buffer = null; } return; } if (!ctx.channel().isOpen()) { open = false; throw new ClosedChannelException(); } if (!wc.responseCommitted) { createResponse(last); if (last) { return; } } if (last) { if (buffer != null) { complete(ctx.writeAndFlush(new DefaultLastHttpContent(buffer))); } else { complete(ctx.writeAndFlush(LastHttpContent.EMPTY_LAST_CONTENT)); } } else { Object message = new DefaultHttpContent(buffer); contentionAwareWrite(message); } buffer = null; } private void createResponse(boolean last) { if (Strings.isFilled(contentType)) { addHeaderIfNotExists(HttpHeaders.Names.CONTENT_TYPE, contentType); } setDateAndCacheHeaders(System.currentTimeMillis(), cacheSeconds == null || Sirius.isDev() ? 0 : cacheSeconds, isPrivate); if (name != null) { setContentDisposition(name, download); } if (last) { ByteBuf initialBuffer = buffer; if (initialBuffer == null) { initialBuffer = Unpooled.EMPTY_BUFFER; } HttpResponse response = createFullResponse(status, true, initialBuffer); HttpHeaders.setContentLength(response, initialBuffer.readableBytes()); complete(commit(response)); } else { HttpResponse response = createChunkedResponse(HttpResponseStatus.OK, true); commit(response, false); } } @Override public void flush() throws IOException { flushBuffer(false); } @Override public void write(int b) throws IOException { if (!open) { return; } bytesWritten++; ensureCapacity(1); buffer.writeByte(b); } @Override public void write(byte[] b) throws IOException { write(b, 0, b.length); } @Override public void write(byte[] b, int off, int len) throws IOException { if (!open) { return; } if (len <= 0) { return; } // If the given array is larger than our buffer, we repeatedly call write and limit the length to // our buffer size. if (len > BUFFER_SIZE) { write(b, off, BUFFER_SIZE); write(b, off + BUFFER_SIZE, len - BUFFER_SIZE); return; } ensureCapacity(len); bytesWritten += len; buffer.writeBytes(b, off, len); } @Override public void close() throws IOException { if (!open) { return; } open = false; super.close(); if (ctx.channel().isOpen()) { flushBuffer(true); } else if (buffer != null) { buffer.release(); buffer = null; } } } private void contentionAwareWrite(Object message) throws IOException { if (!ctx.channel().isWritable()) { ChannelFuture future = ctx.writeAndFlush(message); while (!ctx.channel().isWritable() && ctx.channel().isOpen()) { try { future.await(5, TimeUnit.SECONDS); } catch (InterruptedException e) { ctx.channel().close(); Exceptions.ignore(e); throw new SocketException("Interrupted while waiting for a chunk to be written"); } } } else { ctx.write(message); } } @Override public String toString() { return "Response for: " + wc.toString(); } }





© 2015 - 2025 Weber Informatics LLC | Privacy Policy