blob: 8072d75e1d4f344562dcef3e3c84eb6d1bbc13c9 [file] [log] [blame]
/*
* 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.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>
*/
// This file was copied from GWT project and adjusted to run against
// Jetty 9.2.2. The original diff can be found here:
// https://gwt-review.googlesource.com/#/c/7857/13/dev/codeserver/java/com/google/gwt/dev/codeserver/WebServer.java
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 = "Fri, 01 Jan 1990 00:00:00 GMT";
private final SourceHandler handler;
private final JsonExporter jsonExporter;
private final OutboxTable outboxes;
private final JobRunner runner;
private final JobEventTable eventTable;
private final String bindAddress;
private final int port;
private Server server;
WebServer(SourceHandler handler, JsonExporter jsonExporter, OutboxTable outboxes,
JobRunner runner, JobEventTable eventTable, String bindAddress, int port) {
this.handler = handler;
this.jsonExporter = jsonExporter;
this.outboxes = outboxes;
this.runner = runner;
this.eventTable = eventTable;
this.bindAddress = bindAddress;
this.port = port;
}
@SuppressWarnings("serial")
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 outboxes.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 = outboxes.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("/log/")) {
String moduleName = target.substring("/log/".length());
Outbox box = outboxes.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 handler.handle(target, request, logger);
}
matcher = SAFE_FILE_PATH.matcher(target);
if (matcher.matches()) {
if (SourceHandler.isSourceMapRequest(target)) {
return handler.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 = outboxes.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 = outboxes.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 : outboxes.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 = outboxes.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<>();
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);
}
}