package org.webpieces.webserver.impl; import java.io.ByteArrayOutputStream; import java.io.IOException; import java.io.OutputStream; import java.io.StringWriter; import java.nio.charset.Charset; import java.util.Set; import java.util.concurrent.CompletableFuture; import javax.inject.Inject; import org.webpieces.ctx.api.RouterRequest; import org.webpieces.data.api.BufferPool; import org.webpieces.data.api.DataWrapper; import org.webpieces.data.api.DataWrapperGenerator; import org.webpieces.data.api.DataWrapperGeneratorFactory; import org.webpieces.httpcommon.api.RequestId; import org.webpieces.httpcommon.api.ResponseSender; import org.webpieces.httpcommon.api.exceptions.HttpException; import org.webpieces.httpparser.api.common.Header; import org.webpieces.httpparser.api.common.KnownHeaderName; import org.webpieces.httpparser.api.dto.HttpRequest; import org.webpieces.httpparser.api.dto.HttpResponse; import org.webpieces.httpparser.api.dto.HttpResponseStatus; import org.webpieces.httpparser.api.dto.HttpResponseStatusLine; import org.webpieces.httpparser.api.dto.KnownStatusCode; import org.webpieces.router.api.ResponseStreamer; import org.webpieces.router.api.dto.RedirectResponse; import org.webpieces.router.api.dto.RenderContentResponse; import org.webpieces.router.api.dto.RenderResponse; import org.webpieces.router.api.dto.RenderStaticResponse; import org.webpieces.router.api.dto.View; import org.webpieces.router.api.exceptions.IllegalReturnValueException; import org.webpieces.router.impl.compression.Compression; import org.webpieces.router.impl.compression.CompressionLookup; import org.webpieces.templating.api.TemplateService; import org.webpieces.templating.api.TemplateUtil; import org.webpieces.templating.impl.tags.BootstrapModalTag; import org.webpieces.util.logging.Logger; import org.webpieces.util.logging.LoggerFactory; import org.webpieces.webserver.api.WebServerConfig; import org.webpieces.webserver.impl.ResponseCreator.ResponseEncodingTuple; import groovy.lang.MissingPropertyException; public class ProxyResponse implements ResponseStreamer { private static final Logger log = LoggerFactory.getLogger(ProxyResponse.class); //TODO: Actually should inject this so it is swappable.... (never have statics...it's annoying as hell when customizing)... private static final DataWrapperGenerator wrapperFactory = DataWrapperGeneratorFactory.createDataWrapperGenerator(); @Inject private TemplateService templatingService; @Inject private WebServerConfig config; @Inject private StaticFileReader reader; @Inject private CompressionLookup compressionLookup; @Inject private ResponseCreator responseCreator; @Inject private ChannelCloser channelCloser; private ResponseOverrideSender responseSender; //private HttpRequest request; private BufferPool pool; private RouterRequest routerRequest; private HttpRequest request; private RequestId requestId; public void init(RouterRequest req, ResponseSender responseSender, BufferPool pool, RequestId requestId) { this.routerRequest = req; this.request = (HttpRequest) req.orginalRequest; this.responseSender = new ResponseOverrideSender(responseSender); this.pool = pool; this.requestId = requestId; } public void sendRedirectAndClearCookie(RouterRequest req, String badCookieName) { RedirectResponse httpResponse = new RedirectResponse(false, req.isHttps, req.domain, req.port, req.relativePath); HttpResponse response = createRedirect(httpResponse); responseCreator.addDeleteCookie(response, badCookieName); log.info("sending REDIRECT(due to bad cookie) response responseSender="+ responseSender); responseSender.sendResponse(response, request, requestId, true); channelCloser.closeIfNeeded(request, responseSender); } @Override public void sendRedirect(RedirectResponse httpResponse) { log.debug(() -> "Sending redirect response. req="+request); HttpResponse response = createRedirect(httpResponse); log.info("sending REDIRECT response responseSender="+ responseSender); responseSender.sendResponse(response, request, requestId, true); channelCloser.closeIfNeeded(request, responseSender); } private HttpResponse createRedirect(RedirectResponse httpResponse) { HttpResponseStatus status = new HttpResponseStatus(); if(httpResponse.isAjaxRedirect) { status.setCode(BootstrapModalTag.AJAX_REDIRECT_CODE); status.setReason("Ajax redirect"); } else status.setKnownStatus(KnownStatusCode.HTTP_303_SEEOTHER); HttpResponseStatusLine statusLine = new HttpResponseStatusLine(); statusLine.setStatus(status); HttpResponse response = new HttpResponse(); response.setStatusLine(statusLine); String url = httpResponse.redirectToPath; if(url.startsWith("http")) { //do nothing } else if(httpResponse.domain != null && httpResponse.isHttps != null) { String prefix = "http://"; if(httpResponse.isHttps) prefix = "https://"; String portPostfix = ""; if(httpResponse.port != 443 && httpResponse.port != 80) portPostfix = ":"+httpResponse.port; url = prefix + httpResponse.domain + portPostfix + httpResponse.redirectToPath; } else if(httpResponse.domain != null) { throw new IllegalReturnValueException("Controller is returning a domain without returning isHttps=true or" + " isHttps=false so we can form the entire redirect. Either drop the domain or set isHttps"); } else if(httpResponse.isHttps != null) { throw new IllegalReturnValueException("Controller is returning isHttps="+httpResponse.isHttps+" but there is" + "no domain set so we can't form the full redirect. Either drop setting isHttps or set the domain"); } Header location = new Header(KnownHeaderName.LOCATION, url); response.addHeader(location ); responseCreator.addCommonHeaders(request, response, true); //Firefox requires a content length of 0 on redirect(chrome doesn't)!!!... response.addHeader(new Header(KnownHeaderName.CONTENT_LENGTH, 0+"")); return response; } @Override public void sendRenderHtml(RenderResponse resp) { log.debug(() -> "Sending render html response. req="+request); View view = resp.view; String packageStr = view.getPackageName(); //For this type of View, the template is the name of the method.. String templateClassName = view.getRelativeOrAbsolutePath(); int lastIndexOf = templateClassName.lastIndexOf("."); String extension = null; if(lastIndexOf > 0) { extension = templateClassName.substring(lastIndexOf+1); } String templatePath = templateClassName; if(!templatePath.startsWith("/")) { //relative path so need to form absolute path... if(lastIndexOf > 0) { templateClassName = templateClassName.substring(0, lastIndexOf); } templatePath = getTemplatePath(packageStr, templateClassName, extension); } //TODO: stream this out with chunked response instead??.... StringWriter out = new StringWriter(); try { templatingService.loadAndRunTemplate(templatePath, out, resp.pageArgs); } catch(MissingPropertyException e) { Set<String> keys = resp.pageArgs.keySet(); throw new ControllerPageArgsException("Controller.method="+view.getControllerName()+"."+view.getMethodName()+" did\nnot" + " return enough arguments for the template ="+templatePath+". specifically, the method\nreturned these" + " arguments="+keys+" There is a chance in your html you forgot the '' around a variable name\n" + "such as #{set 'key'}# but you put #{set key}# which is 'usually' not the correct way\n" + "The missing properties are as follows....\n"+e.getMessage(), e); } String content = out.toString(); KnownStatusCode statusCode = KnownStatusCode.HTTP_200_OK; switch(resp.routeType) { case HTML: statusCode = KnownStatusCode.HTTP_200_OK; break; case NOT_FOUND: statusCode = KnownStatusCode.HTTP_404_NOTFOUND; break; case INTERNAL_SERVER_ERROR: statusCode = KnownStatusCode.HTTP_500_INTERNAL_SVR_ERROR; break; default: throw new IllegalStateException("did add case for state="+resp.routeType); } //NOTE: These are ALL String templates, so default the mimeType to text/plain if(extension == null) { extension = "txt"; } createResponseAndSend(statusCode, content, extension, "text/plain"); } @Override public CompletableFuture<Void> sendRenderStatic(RenderStaticResponse renderStatic) { log.debug(() -> "Sending render static html response. req="+request); RequestInfo requestInfo = new RequestInfo(routerRequest, request, requestId, pool, responseSender); return reader.sendRenderStatic(requestInfo, renderStatic); } private String getTemplatePath(String packageStr, String templateClassName, String extension) { String className = templateClassName; if(!"".equals(packageStr)) className = packageStr+"."+className; if(!"".equals(extension)) className = className+"_"+extension; return TemplateUtil.convertTemplateClassToPath(className); } @Override public void sendRenterContent(RenderContentResponse resp) { ResponseEncodingTuple tuple = responseCreator.createContentResponse(request, resp.getStatusCode(), false, resp.getMimeType()); maybeCompressAndSend(null, tuple, resp.getPayload()); } private void createResponseAndSend(KnownStatusCode statusCode, String content, String extension, String defaultMime) { if(content == null) throw new IllegalArgumentException("content cannot be null"); ResponseEncodingTuple tuple = responseCreator.createResponse(request, statusCode, extension, defaultMime, true); log.debug(()->"content about to be sent back="+content); Charset encoding = tuple.mimeType.htmlResponsePayloadEncoding; byte[] bytes = content.getBytes(encoding); maybeCompressAndSend(extension, tuple, bytes); } private void maybeCompressAndSend(String extension, ResponseEncodingTuple tuple, byte[] bytes) { Compression compression = compressionLookup.createCompressionStream(routerRequest.encodings, extension, tuple.mimeType); HttpResponse resp = tuple.response; //This is a cheat sort of since compression can go from 28235 to 4,785 and we are looking at the //non-compressed size so stuff like 16k may be sent chunked even though it is only 3k on the outbound path //(not really a big deal though) if(bytes.length < config.getMaxBodySize()) { sendFullResponse(resp, bytes, compression); return; } sendChunkedResponse(resp, bytes, compression); } private void sendChunkedResponse(HttpResponse resp, byte[] bytes, final Compression compression) { log.info("sending CHUNKED RENDERHTML response. size="+bytes.length+" code="+resp.getStatusLine().getStatus()+" for domain="+routerRequest.domain+" path"+routerRequest.relativePath+" responseSender="+ responseSender); // we shouldn't have to add chunked because the responseSender will add chunked for us // if isComplete is false // resp.addHeader(new Header(KnownHeaderName.TRANSFER_ENCODING, "chunked")); boolean compressed = false; Compression usingCompression; if(compression == null) { usingCompression = new NoCompression(); } else { usingCompression = compression; compressed = true; resp.addHeader(new Header(KnownHeaderName.CONTENT_ENCODING, usingCompression.getCompressionType())); } boolean isCompressed = compressed; // Send the headers and get the responseid. responseSender.sendResponse(resp, request, requestId, false).thenAccept(responseId -> { OutputStream chunkedStream = new ChunkedStream(responseSender, config.getMaxBodySize(), isCompressed, responseId); try(OutputStream chainStream = usingCompression.createCompressionStream(chunkedStream)) { //IF wrapped in compression above(ie. not NoCompression), sending the WHOLE byte[] in comes out in //pieces that get sent out as it is being compressed //and http chunks are sent under the covers(in ChunkedStream) chainStream.write(bytes); } catch (IOException e) { throw new RuntimeException(e); } }); } private void sendFullResponse(HttpResponse resp, byte[] bytes, Compression compression) { if(compression != null) { resp.addHeader(new Header(KnownHeaderName.CONTENT_ENCODING, compression.getCompressionType())); bytes = synchronousCompress(compression, bytes); } resp.addHeader(new Header(KnownHeaderName.CONTENT_LENGTH, bytes.length+"")); DataWrapper data = wrapperFactory.wrapByteArray(bytes); resp.setBody(data); log.info("sending FULL RENDERHTML response. code="+resp.getStatusLine().getStatus()+" for domain="+routerRequest.domain+" path="+routerRequest.relativePath+" responseSender="+ responseSender); responseSender.sendResponse(resp, request, requestId, true); channelCloser.closeIfNeeded(request, responseSender); } private byte[] synchronousCompress(Compression compression, byte[] bytes) { ByteArrayOutputStream str = new ByteArrayOutputStream(bytes.length); try(OutputStream stream = compression.createCompressionStream(str)) { stream.write(bytes); } catch(IOException e) { throw new RuntimeException(e); } return str.toByteArray(); } @Override public void failureRenderingInternalServerErrorPage(Throwable e) { log.debug(() -> "Sending failure html response. req="+request); //TODO: IF instance of HttpException with a KnownStatusCode, we should actually send that status code //TODO: we should actually just render our own internalServerError.html page with styling and we could do that. //This is a final failure so we send a webpieces page next (in the future, we should just use a customer static html file if set) //This is only if the webapp 500 html page fails as many times it is a template and they could have another bug in that template. String html = "<html><head></head><body>This website had a bug, " + "then when rendering the page explaining the bug, well, they hit another bug. " + "The webpieces platform saved them from sending back an ugly stack trace. Contact website owner " + "with a screen shot of this page</body></html>"; createResponseAndSend(KnownStatusCode.HTTP_500_INTERNAL_SVR_ERROR, html, "html", "text/html"); } public void sendFailure(HttpException exc) { log.debug(() -> "Sending failure response. req="+request); createResponseAndSend(exc.getStatusCode(), "Something went wrong(are you hacking the system?)", "txt", "text/plain"); } }