/* * Copyright 2011 Google Inc. * * 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.gwt.dev.codeserver; import com.google.gwt.core.ext.TreeLogger; import com.google.gwt.core.ext.TreeLogger.Type; import com.google.gwt.core.ext.UnableToCompleteException; import com.google.gwt.dev.codeserver.CompileDir.PolicyFile; import com.google.gwt.dev.codeserver.Pages.ErrorPage; import com.google.gwt.dev.json.JsonObject; import org.eclipse.jetty.http.MimeTypes; import org.eclipse.jetty.server.HttpConnection; import org.eclipse.jetty.server.Request; import org.eclipse.jetty.server.Server; import org.eclipse.jetty.server.ServerConnector; import org.eclipse.jetty.servlet.ServletContextHandler; import org.eclipse.jetty.servlet.ServletHolder; import org.eclipse.jetty.servlets.GzipFilter; import java.io.BufferedReader; import java.io.File; import java.io.FileReader; import java.io.IOException; import java.io.InputStream; import java.util.Date; import java.util.EnumSet; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.concurrent.ExecutionException; import java.util.regex.Matcher; import java.util.regex.Pattern; import javax.servlet.DispatcherType; import javax.servlet.ServletException; import javax.servlet.http.HttpServlet; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; /** * The web server for Super Dev Mode, also known as the code server. The URLs handled include: * <ul> * <li>HTML pages for the front page and module pages</li> * <li>JavaScript that implementing the bookmarklets</li> * <li>The web API for recompiling a GWT app</li> * <li>The output files and log files from the GWT compiler</li> * <li>Java source code (for source-level debugging)</li> * </ul> * * <p>EXPERIMENTAL. There is no authentication, encryption, or XSS protection, so this server is * only safe to run on localhost.</p> */ public class WebServer { private static final Pattern SAFE_DIRECTORY = Pattern.compile("([a-zA-Z0-9_-]+\\.)*[a-zA-Z0-9_-]+"); // no extension needed private static final Pattern SAFE_FILENAME = Pattern.compile("([a-zA-Z0-9_-]+\\.)+[a-zA-Z0-9_-]+"); // an extension is required private static final Pattern SAFE_MODULE_PATH = Pattern.compile("/(" + SAFE_DIRECTORY + ")/$"); static final Pattern SAFE_DIRECTORY_PATH = Pattern.compile("/(" + SAFE_DIRECTORY + "/)+$"); /* visible for testing */ static final Pattern SAFE_FILE_PATH = Pattern.compile("/(" + SAFE_DIRECTORY + "/)+" + SAFE_FILENAME + "$"); static final Pattern STRONG_NAME = Pattern.compile("[\\dA-F]{32}"); private static final Pattern CACHE_JS_FILE = Pattern.compile("/(" + STRONG_NAME + ").cache.js$"); private static final MimeTypes MIME_TYPES = new MimeTypes(); private static final String TIME_IN_THE_PAST = "Mon, 01 Jan 1990 00:00:00 GMT"; private final SourceHandler sourceHandler; private final SymbolMapHandler symbolMapHandler; private final JsonExporter jsonExporter; private final OutboxTable outboxTable; private final JobRunner runner; private final JobEventTable eventTable; private final String bindAddress; private final int port; private Server server; WebServer(SourceHandler handler, SymbolMapHandler symbolMapHandler, JsonExporter jsonExporter, OutboxTable outboxTable, JobRunner runner, JobEventTable eventTable, String bindAddress, int port) { this.sourceHandler = handler; this.symbolMapHandler = symbolMapHandler; this.jsonExporter = jsonExporter; this.outboxTable = outboxTable; this.runner = runner; this.eventTable = eventTable; this.bindAddress = bindAddress; this.port = port; } void start(final TreeLogger logger) throws UnableToCompleteException { Server newServer = new Server(); ServerConnector connector = new ServerConnector(newServer); connector.setHost(bindAddress); connector.setPort(port); connector.setReuseAddress(false); connector.setSoLingerTime(0); newServer.addConnector(connector); ServletContextHandler newHandler = new ServletContextHandler(ServletContextHandler.SESSIONS); newHandler.setContextPath("/"); newHandler.addServlet(new ServletHolder(new HttpServlet() { @Override protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { handleRequest(request.getPathInfo(), request, response, logger); } }), "/*"); newHandler.addFilter(GzipFilter.class, "/*", EnumSet.allOf(DispatcherType.class)); newServer.setHandler(newHandler); try { newServer.start(); } catch (Exception e) { logger.log(TreeLogger.ERROR, "cannot start web server", e); throw new UnableToCompleteException(); } this.server = newServer; } public int getPort() { return port; } public void stop() throws Exception { server.stop(); server = null; } /** * Returns the location of the compiler output. (Changes after every recompile.) * @param outputModuleName the module name that the GWT compiler used in its output. */ public File getCurrentWarDir(String outputModuleName) { return outboxTable.findByOutputModuleName(outputModuleName).getWarDir(); } private void handleRequest(String target, HttpServletRequest request, HttpServletResponse response, TreeLogger parentLogger) throws IOException { if (request.getMethod().equalsIgnoreCase("get")) { TreeLogger logger = parentLogger.branch(Type.TRACE, "GET " + target); Response page = doGet(target, request, logger); if (page == null) { logger.log(Type.WARN, "not handled: " + target); return; } setHandled(request); if (!target.endsWith(".cache.js")) { // Make sure IE9 doesn't cache any pages. // (Nearly all pages may change on server restart.) response.setHeader("Cache-Control", "no-cache, no-store, max-age=0, must-revalidate"); response.setHeader("Pragma", "no-cache"); response.setHeader("Expires", TIME_IN_THE_PAST); response.setDateHeader("Date", new Date().getTime()); } page.send(request, response, logger); } } /** * Returns the page that should be sent in response to a GET request, or null for no response. */ private Response doGet(String target, HttpServletRequest request, TreeLogger logger) throws IOException { if (target.equals("/")) { JsonObject json = jsonExporter.exportFrontPageVars(); return Pages.newHtmlPage("config", json, "frontpage.html"); } if (target.equals("/dev_mode_on.js")) { JsonObject json = jsonExporter.exportDevModeOnVars(); return Responses.newJavascriptResponse("__gwt_codeserver_config", json, "dev_mode_on.js"); } // Recompile on request from the bookmarklet. // This is a GET because a bookmarklet can call it from a different origin (JSONP). if (target.startsWith("/recompile/")) { String moduleName = target.substring("/recompile/".length()); Outbox box = outboxTable.findByOutputModuleName(moduleName); if (box == null) { return new ErrorPage("No such module: " + moduleName); } // We are passing properties from an unauthenticated GET request directly to the compiler. // This should be safe, but only because these are binding properties. For each binding // property, you can only choose from a set of predefined values. So all an attacker can do is // cause a spurious recompile, resulting in an unexpected permutation being loaded later. // // It would be unsafe to allow a configuration property to be changed. Job job = box.makeJob(getBindingProperties(request), logger); runner.submit(job); Job.Result result = job.waitForResult(); JsonObject json = jsonExporter.exportRecompileResponse(result); return Responses.newJsonResponse(json); } if (target.startsWith("/clean")) { JsonObject json = null; try { runner.clean(logger, outboxTable); json = jsonExporter.exportOk("Cleaned disk caches."); } catch (ExecutionException e) { json = jsonExporter.exportError(e.getMessage()); } return Responses.newJsonResponse(json); } // GET the Js that knows how to request the specific permutation recompile. if (target.startsWith("/recompile-requester/")) { String moduleName = target.substring("/recompile-requester/".length()); Outbox box = outboxTable.findByOutputModuleName(moduleName); if (box == null) { return new ErrorPage("No such module: " + moduleName); } try { String recompileJs = runner.getRecompileJs(logger, box); return Responses.newJavascriptResponse(recompileJs); } catch (ExecutionException e) { // Already logged. return new ErrorPage("Failed to generate the Js recompile requester."); } } if (target.startsWith("/log/")) { String moduleName = target.substring("/log/".length()); Outbox box = outboxTable.findByOutputModuleName(moduleName); if (box == null) { return new ErrorPage("No such module: " + moduleName); } else if (box.containsStubCompile()) { return new ErrorPage("This module hasn't been compiled yet."); } else { return makeLogPage(box); } } if (target.equals("/favicon.ico")) { InputStream faviconStream = getClass().getResourceAsStream("favicon.ico"); if (faviconStream == null) { return new ErrorPage("icon not found"); } // IE8 will not load the favicon in an img tag with the default MIME type, // so use "image/x-icon" instead. return Responses.newBinaryStreamResponse("image/x-icon", faviconStream); } if (target.equals("/policies/")) { return makePolicyIndexPage(); } if (target.equals("/progress")) { // TODO: return a list of progress objects here, one for each job. JobEvent event = eventTable.getCompilingJobEvent(); JsonObject json; if (event == null) { json = new JsonObject(); json.put("status", "idle"); } else { json = jsonExporter.exportProgressResponse(event); } return Responses.newJsonResponse(json); } Matcher matcher = SAFE_MODULE_PATH.matcher(target); if (matcher.matches()) { return makeModulePage(matcher.group(1)); } matcher = SAFE_DIRECTORY_PATH.matcher(target); if (matcher.matches() && SourceHandler.isSourceMapRequest(target)) { return sourceHandler.handle(target, request, logger); } matcher = SAFE_FILE_PATH.matcher(target); if (matcher.matches()) { if (SourceHandler.isSourceMapRequest(target)) { return sourceHandler.handle(target, request, logger); } if (SymbolMapHandler.isSymbolMapRequest(target)) { return symbolMapHandler.handle(target, request, logger); } if (target.startsWith("/policies/")) { return makePolicyFilePage(target); } return makeCompilerOutputPage(target); } logger.log(TreeLogger.WARN, "ignored get request: " + target); return null; // not handled } /** * Returns a file that the compiler wrote to its war directory. */ private Response makeCompilerOutputPage(String target) { int secondSlash = target.indexOf('/', 1); String moduleName = target.substring(1, secondSlash); Outbox box = outboxTable.findByOutputModuleName(moduleName); if (box == null) { return new ErrorPage("No such module: " + moduleName); } final String contentEncoding; File file = box.getOutputFile(target); if (!file.isFile()) { // perhaps it's compressed file = box.getOutputFile(target + ".gz"); if (!file.isFile()) { return new ErrorPage("not found: " + file.toString()); } contentEncoding = "gzip"; } else { contentEncoding = null; } final String sourceMapUrl; Matcher match = CACHE_JS_FILE.matcher(target); if (match.matches()) { String strongName = match.group(1); String template = SourceHandler.sourceMapLocationTemplate(moduleName); sourceMapUrl = template.replace("__HASH__", strongName); } else { sourceMapUrl = null; } String mimeType = guessMimeType(target); final Response barePage = Responses.newFileResponse(mimeType, file); // Wrap the response to send the extra headers. return new Response() { @Override public void send(HttpServletRequest request, HttpServletResponse response, TreeLogger logger) throws IOException { // TODO: why do we need this? Looks like Ray added it a long time ago. response.setHeader("Access-Control-Allow-Origin", "*"); if (sourceMapUrl != null) { response.setHeader("X-SourceMap", sourceMapUrl); response.setHeader("SourceMap", sourceMapUrl); } if (contentEncoding != null) { if (!request.getHeader("Accept-Encoding").contains("gzip")) { response.sendError(HttpServletResponse.SC_NOT_IMPLEMENTED); logger.log(TreeLogger.WARN, "client doesn't accept gzip; bailing"); return; } response.setHeader("Content-Encoding", "gzip"); } barePage.send(request, response, logger); } }; } private Response makeModulePage(String moduleName) { Outbox box = outboxTable.findByOutputModuleName(moduleName); if (box == null) { return new ErrorPage("No such module: " + moduleName); } JsonObject json = jsonExporter.exportModulePageVars(box); return Pages.newHtmlPage("config", json, "modulepage.html"); } private Response makePolicyIndexPage() { return new Response() { @Override public void send(HttpServletRequest request, HttpServletResponse response, TreeLogger logger) throws IOException { response.setContentType("text/html"); HtmlWriter out = new HtmlWriter(response.getWriter()); out.startTag("html").nl(); out.startTag("head").nl(); out.startTag("title").text("Policy Files").endTag("title").nl(); out.endTag("head"); out.startTag("body"); out.startTag("h1").text("Policy Files").endTag("h1").nl(); for (Outbox box : outboxTable.getOutboxes()) { List<PolicyFile> policies = box.readRpcPolicyManifest(); if (!policies.isEmpty()) { out.startTag("h2").text(box.getOutputModuleName()).endTag("h2").nl(); out.startTag("table").nl(); for (PolicyFile policy : policies) { out.startTag("tr"); out.startTag("td"); out.startTag("a", "href=", policy.getServiceSourceUrl()); out.text(policy.getServiceName()); out.endTag("a"); out.endTag("td"); out.startTag("td"); out.startTag("a", "href=", policy.getUrl()); out.text(policy.getName()); out.endTag("a"); out.endTag("td"); out.endTag("tr").nl(); } out.endTag("table").nl(); } } out.endTag("body").nl(); out.endTag("html").nl(); } }; } private Response makePolicyFilePage(String target) { int secondSlash = target.indexOf('/', 1); if (secondSlash < 1) { return new ErrorPage("invalid URL for policy file: " + target); } String rest = target.substring(secondSlash + 1); if (rest.contains("/") || !rest.endsWith(".gwt.rpc")) { return new ErrorPage("invalid name for policy file: " + rest); } File fileToSend = outboxTable.findPolicyFile(rest); if (fileToSend == null) { return new ErrorPage("Policy file not found: " + rest); } return Responses.newFileResponse("text/plain", fileToSend); } /** * Sends the log file as html with errors highlighted in red. */ private Response makeLogPage(final Outbox box) { final File file = box.getCompileLog(); if (!file.isFile()) { return new ErrorPage("log file not found"); } return new Response() { @Override public void send(HttpServletRequest request, HttpServletResponse response, TreeLogger logger) throws IOException { BufferedReader reader = new BufferedReader(new FileReader(file)); response.setStatus(HttpServletResponse.SC_OK); response.setContentType("text/html"); response.setHeader("Content-Style-Type", "text/css"); HtmlWriter out = new HtmlWriter(response.getWriter()); out.startTag("html").nl(); out.startTag("head").nl(); out.startTag("title").text(box.getOutputModuleName() + " compile log").endTag("title").nl(); out.startTag("style").nl(); out.text(".error { color: red; font-weight: bold; }").nl(); out.endTag("style").nl(); out.endTag("head").nl(); out.startTag("body").nl(); sendLogAsHtml(reader, out); out.endTag("body").nl(); out.endTag("html").nl(); } }; } private static final Pattern ERROR_PATTERN = Pattern.compile("\\[ERROR\\]"); /** * Copies in to out line by line, escaping each line for html characters and highlighting * error lines. Closes <code>in</code> when done. */ private static void sendLogAsHtml(BufferedReader in, HtmlWriter out) throws IOException { try { out.startTag("pre").nl(); String line = in.readLine(); while (line != null) { Matcher m = ERROR_PATTERN.matcher(line); boolean error = m.find(); if (error) { out.startTag("span", "class=", "error"); } out.text(line); if (error) { out.endTag("span"); } out.nl(); // the readLine doesn't include the newline. line = in.readLine(); } out.endTag("pre").nl(); } finally { in.close(); } } /* visible for testing */ static String guessMimeType(String filename) { String mimeType = MIME_TYPES.getMimeByExtension(filename); return mimeType != null ? mimeType : ""; } /** * Returns the binding properties from the web page where dev mode is being used. (As passed in * by dev_mode_on.js in a JSONP request to "/recompile".) */ private Map<String, String> getBindingProperties(HttpServletRequest request) { Map<String, String> result = new HashMap<String, String>(); for (Object key : request.getParameterMap().keySet()) { String propName = (String) key; if (!propName.equals("_callback")) { result.put(propName, request.getParameter(propName)); } } return result; } private static void setHandled(HttpServletRequest request) { Request baseRequest = (request instanceof Request) ? (Request) request : HttpConnection.getCurrentConnection().getHttpChannel().getRequest(); baseRequest.setHandled(true); } }