[tor-commits] [metrics-web/master] Update Java package structure of website classes.

karsten at torproject.org karsten at torproject.org
Wed Jan 22 16:09:29 UTC 2014


commit bd8adfa21554696be51ce16e4cfa60fb8ba939cb
Author: Karsten Loesing <karsten.loesing at gmx.net>
Date:   Tue Jan 21 17:27:14 2014 +0100

    Update Java package structure of website classes.
    
     - org.torproject.ernie.status.doctor -> org.torproject.metrics.web.status
     - org.torproject.ernie.status.exonerator -> org.torproject.metrics.web.status
     - org.torproject.ernie.status.relaysearch -> org.torproject.metrics.web.status
     - org.torproject.ernie.web -> org.torproject.metrics.web
     - org.torproject.ernie.web.graphs -> org.torproject.metrics.web.graphs
     - org.torproject.ernie.web.research -> org.torproject.metrics.web.research
---
 website/etc/web.xml                                |   34 +-
 .../status/doctor/ConsensusHealthServlet.java      |   57 ---
 .../ernie/status/exonerator/ExoneraTorServlet.java |   24 -
 .../status/relaysearch/RelaySearchServlet.java     |  535 --------------------
 .../org/torproject/ernie/web/GraphsServlet.java    |   24 -
 .../src/org/torproject/ernie/web/IndexServlet.java |   24 -
 .../org/torproject/ernie/web/ResearchServlet.java  |   24 -
 .../org/torproject/ernie/web/StatusServlet.java    |   24 -
 .../ernie/web/graphs/BubblesServlet.java           |   24 -
 .../org/torproject/ernie/web/graphs/Countries.java |  285 -----------
 .../torproject/ernie/web/graphs/CsvServlet.java    |   97 ----
 .../ernie/web/graphs/GraphImageServlet.java        |   76 ---
 .../ernie/web/graphs/GraphParameterChecker.java    |  280 ----------
 .../ernie/web/graphs/GraphsSubpagesServlet.java    |  162 ------
 .../org/torproject/ernie/web/graphs/RObject.java   |   23 -
 .../ernie/web/graphs/RObjectGenerator.java         |  398 ---------------
 .../ernie/web/graphs/TableParameterChecker.java    |  120 -----
 .../ernie/web/research/ResearchDataServlet.java    |  260 ----------
 .../ernie/web/research/ResearchFormatsServlet.java |   24 -
 .../ernie/web/research/ResearchPapersServlet.java  |   24 -
 .../ernie/web/research/ResearchStatsServlet.java   |  132 -----
 .../ernie/web/research/ResearchToolsServlet.java   |   24 -
 .../org/torproject/metrics/web/GraphsServlet.java  |   24 +
 .../org/torproject/metrics/web/IndexServlet.java   |   24 +
 .../torproject/metrics/web/ResearchServlet.java    |   24 +
 .../org/torproject/metrics/web/StatusServlet.java  |   24 +
 .../metrics/web/graphs/BubblesServlet.java         |   24 +
 .../torproject/metrics/web/graphs/Countries.java   |  285 +++++++++++
 .../torproject/metrics/web/graphs/CsvServlet.java  |   97 ++++
 .../metrics/web/graphs/GraphImageServlet.java      |   76 +++
 .../metrics/web/graphs/GraphParameterChecker.java  |  280 ++++++++++
 .../metrics/web/graphs/GraphsSubpagesServlet.java  |  162 ++++++
 .../org/torproject/metrics/web/graphs/RObject.java |   23 +
 .../metrics/web/graphs/RObjectGenerator.java       |  398 +++++++++++++++
 .../metrics/web/graphs/TableParameterChecker.java  |  120 +++++
 .../metrics/web/research/ResearchDataServlet.java  |  260 ++++++++++
 .../web/research/ResearchFormatsServlet.java       |   24 +
 .../web/research/ResearchPapersServlet.java        |   24 +
 .../metrics/web/research/ResearchStatsServlet.java |  132 +++++
 .../metrics/web/research/ResearchToolsServlet.java |   24 +
 .../metrics/web/status/ConsensusHealthServlet.java |   57 +++
 .../metrics/web/status/ExoneraTorServlet.java      |   24 +
 .../metrics/web/status/RelaySearchServlet.java     |  535 ++++++++++++++++++++
 43 files changed, 2658 insertions(+), 2658 deletions(-)

diff --git a/website/etc/web.xml b/website/etc/web.xml
index 992de93..f85d500 100644
--- a/website/etc/web.xml
+++ b/website/etc/web.xml
@@ -9,7 +9,7 @@
   <servlet>
     <servlet-name>Index</servlet-name>
     <servlet-class>
-      org.torproject.ernie.web.IndexServlet
+      org.torproject.metrics.web.IndexServlet
     </servlet-class>
   </servlet>
   <servlet-mapping>
@@ -20,7 +20,7 @@
   <servlet>
     <servlet-name>Graphs</servlet-name>
     <servlet-class>
-      org.torproject.ernie.web.GraphsServlet
+      org.torproject.metrics.web.GraphsServlet
     </servlet-class>
   </servlet>
   <servlet-mapping>
@@ -31,7 +31,7 @@
   <servlet>
     <servlet-name>GraphsSubpages</servlet-name>
     <servlet-class>
-      org.torproject.ernie.web.graphs.GraphsSubpagesServlet
+      org.torproject.metrics.web.graphs.GraphsSubpagesServlet
     </servlet-class>
   </servlet>
   <servlet-mapping>
@@ -54,7 +54,7 @@
   <servlet>
     <servlet-name>Bubbles</servlet-name>
     <servlet-class>
-      org.torproject.ernie.web.graphs.BubblesServlet
+      org.torproject.metrics.web.graphs.BubblesServlet
     </servlet-class>
   </servlet>
   <servlet-mapping>
@@ -65,7 +65,7 @@
   <servlet>
     <servlet-name>Research</servlet-name>
     <servlet-class>
-      org.torproject.ernie.web.ResearchServlet
+      org.torproject.metrics.web.ResearchServlet
     </servlet-class>
   </servlet>
   <servlet-mapping>
@@ -76,7 +76,7 @@
   <servlet>
     <servlet-name>ResearchData</servlet-name>
     <servlet-class>
-      org.torproject.ernie.web.research.ResearchDataServlet
+      org.torproject.metrics.web.research.ResearchDataServlet
     </servlet-class>
     <init-param>
       <param-name>localDataDir</param-name>
@@ -93,7 +93,7 @@
   <servlet>
     <servlet-name>ResearchPapers</servlet-name>
     <servlet-class>
-      org.torproject.ernie.web.research.ResearchPapersServlet
+      org.torproject.metrics.web.research.ResearchPapersServlet
     </servlet-class>
   </servlet>
   <servlet-mapping>
@@ -104,7 +104,7 @@
   <servlet>
     <servlet-name>ResearchTools</servlet-name>
     <servlet-class>
-      org.torproject.ernie.web.research.ResearchToolsServlet
+      org.torproject.metrics.web.research.ResearchToolsServlet
     </servlet-class>
   </servlet>
   <servlet-mapping>
@@ -115,7 +115,7 @@
   <servlet>
     <servlet-name>ResearchFormats</servlet-name>
     <servlet-class>
-      org.torproject.ernie.web.research.ResearchFormatsServlet
+      org.torproject.metrics.web.research.ResearchFormatsServlet
     </servlet-class>
   </servlet>
   <servlet-mapping>
@@ -126,7 +126,7 @@
   <servlet>
     <servlet-name>ResearchStats</servlet-name>
     <servlet-class>
-      org.torproject.ernie.web.research.ResearchStatsServlet
+      org.torproject.metrics.web.research.ResearchStatsServlet
     </servlet-class>
     <init-param>
       <param-name>statsDir</param-name>
@@ -144,7 +144,7 @@
   <servlet>
     <servlet-name>Status</servlet-name>
     <servlet-class>
-      org.torproject.ernie.web.StatusServlet
+      org.torproject.metrics.web.StatusServlet
     </servlet-class>
   </servlet>
   <servlet-mapping>
@@ -155,7 +155,7 @@
   <servlet>
     <servlet-name>RelaySearch</servlet-name>
     <servlet-class>
-      org.torproject.ernie.status.relaysearch.RelaySearchServlet
+      org.torproject.metrics.web.status.RelaySearchServlet
     </servlet-class>
   </servlet>
   <servlet-mapping>
@@ -166,7 +166,7 @@
   <servlet>
     <servlet-name>GraphImage</servlet-name>
     <servlet-class>
-      org.torproject.ernie.web.graphs.GraphImageServlet
+      org.torproject.metrics.web.graphs.GraphImageServlet
     </servlet-class>
   </servlet>
   <servlet-mapping>
@@ -233,7 +233,7 @@
   <servlet>
     <servlet-name>Csv</servlet-name>
     <servlet-class>
-      org.torproject.ernie.web.graphs.CsvServlet
+      org.torproject.metrics.web.graphs.CsvServlet
     </servlet-class>
   </servlet>
   <servlet-mapping>
@@ -244,7 +244,7 @@
   <servlet>
     <servlet-name>ExoneraTor</servlet-name>
     <servlet-class>
-      org.torproject.ernie.status.exonerator.ExoneraTorServlet
+      org.torproject.metrics.web.status.ExoneraTorServlet
     </servlet-class>
   </servlet>
   <servlet-mapping>
@@ -255,7 +255,7 @@
   <servlet>
     <servlet-name>ConsensusHealthServlet</servlet-name>
     <servlet-class>
-      org.torproject.ernie.status.doctor.ConsensusHealthServlet
+      org.torproject.metrics.web.status.ConsensusHealthServlet
     </servlet-class>
   </servlet>
   <servlet-mapping>
@@ -312,7 +312,7 @@
 
   <listener>
     <listener-class>
-      org.torproject.ernie.web.graphs.RObjectGenerator
+      org.torproject.metrics.web.graphs.RObjectGenerator
     </listener-class>
   </listener>
 
diff --git a/website/src/org/torproject/ernie/status/doctor/ConsensusHealthServlet.java b/website/src/org/torproject/ernie/status/doctor/ConsensusHealthServlet.java
deleted file mode 100644
index 330708f..0000000
--- a/website/src/org/torproject/ernie/status/doctor/ConsensusHealthServlet.java
+++ /dev/null
@@ -1,57 +0,0 @@
-/* Copyright 2011, 2012 The Tor Project
- * See LICENSE for licensing information */
-package org.torproject.ernie.status.doctor;
-
-import java.io.BufferedInputStream;
-import java.io.BufferedOutputStream;
-import java.io.File;
-import java.io.FileInputStream;
-import java.io.IOException;
-
-import javax.servlet.ServletException;
-import javax.servlet.http.HttpServlet;
-import javax.servlet.http.HttpServletRequest;
-import javax.servlet.http.HttpServletResponse;
-
-public class ConsensusHealthServlet extends HttpServlet {
-
-  private static final long serialVersionUID = -5230032733057814869L;
-
-  public void doGet(HttpServletRequest request,
-      HttpServletResponse response) throws IOException,
-      ServletException {
-
-    /* Read file from disk and write it to response. */
-    BufferedInputStream input = null;
-    BufferedOutputStream output = null;
-    try {
-      File f = new File("/srv/metrics.torproject.org/ernie/website/"
-          + "consensus-health.html");
-      if (!f.exists()) {
-        response.sendError(HttpServletResponse.SC_NOT_FOUND);
-        return;
-      }
-      response.setContentType(this.getServletContext().getMimeType(f.getName()));
-      response.setHeader("Content-Length", String.valueOf(
-          f.length()));
-      response.setHeader("Content-Disposition",
-          "inline; filename=\"" + f.getName() + "\"");
-      input = new BufferedInputStream(new FileInputStream(f),
-          1024);
-      output = new BufferedOutputStream(response.getOutputStream(), 1024);
-      byte[] buffer = new byte[1024];
-      int length;
-      while ((length = input.read(buffer)) > 0) {
-          output.write(buffer, 0, length);
-      }
-    } finally {
-      if (output != null) {
-        output.close();
-      }
-      if (input != null) {
-        input.close();
-      }
-    }
-  }
-}
-
diff --git a/website/src/org/torproject/ernie/status/exonerator/ExoneraTorServlet.java b/website/src/org/torproject/ernie/status/exonerator/ExoneraTorServlet.java
deleted file mode 100644
index d37b9a8..0000000
--- a/website/src/org/torproject/ernie/status/exonerator/ExoneraTorServlet.java
+++ /dev/null
@@ -1,24 +0,0 @@
-/* Copyright 2011, 2012 The Tor Project
- * See LICENSE for licensing information */
-package org.torproject.ernie.status.exonerator;
-
-import java.io.IOException;
-
-import javax.servlet.ServletException;
-import javax.servlet.http.HttpServlet;
-import javax.servlet.http.HttpServletRequest;
-import javax.servlet.http.HttpServletResponse;
-
-public class ExoneraTorServlet extends HttpServlet {
-
-  private static final long serialVersionUID = -6227541092325776626L;
-
-  public void doGet(HttpServletRequest request,
-      HttpServletResponse response) throws IOException, ServletException {
-
-    /* Forward the request to the JSP that does all the hard work. */
-    request.getRequestDispatcher("WEB-INF/exonerator.jsp").forward(
-        request, response);
-  }
-}
-
diff --git a/website/src/org/torproject/ernie/status/relaysearch/RelaySearchServlet.java b/website/src/org/torproject/ernie/status/relaysearch/RelaySearchServlet.java
deleted file mode 100644
index b7c8291..0000000
--- a/website/src/org/torproject/ernie/status/relaysearch/RelaySearchServlet.java
+++ /dev/null
@@ -1,535 +0,0 @@
-/* Copyright 2011, 2012 The Tor Project
- * See LICENSE for licensing information */
-package org.torproject.ernie.status.relaysearch;
-
-import java.io.IOException;
-import java.io.UnsupportedEncodingException;
-import java.math.BigInteger;
-import java.sql.Connection;
-import java.sql.ResultSet;
-import java.sql.SQLException;
-import java.sql.Statement;
-import java.text.ParseException;
-import java.text.SimpleDateFormat;
-import java.util.ArrayList;
-import java.util.Calendar;
-import java.util.Collections;
-import java.util.HashMap;
-import java.util.List;
-import java.util.Map;
-import java.util.SortedMap;
-import java.util.SortedSet;
-import java.util.TimeZone;
-import java.util.TreeMap;
-import java.util.TreeSet;
-import java.util.logging.Level;
-import java.util.logging.Logger;
-import java.util.regex.Pattern;
-
-import javax.naming.Context;
-import javax.naming.InitialContext;
-import javax.naming.NamingException;
-import javax.servlet.ServletException;
-import javax.servlet.http.HttpServlet;
-import javax.servlet.http.HttpServletRequest;
-import javax.servlet.http.HttpServletResponse;
-import javax.sql.DataSource;
-
-import org.apache.commons.codec.binary.Base64;
-
-/**
- * Web page that allows users to search for relays in the descriptor
- * archives.
- *
- * Possible search terms for testing:
- * - gabelmoo
- * - gabelmoo 2010-09
- * - gabelmoo 2010-09-18
- * - gabelmoo $F2044413DAC2E02E3D6BCF4735A19BCA1DE97281
- * - gabelmoo 80.190.246
- * - gabelmoo $F2044413DAC2E02E3D6BCF4735A19BCA1DE97281 80.190.246
- * - 5898549205 dc737cc9dca16af6 79.212.74.45
- * - 5898549205 dc737cc9dca16af6
- * - 80.190.246.100
- * - $F2044413DAC2E02E3D6BCF4735A19BCA1DE97281
- * - $F2044413DAC2E02E3D6BCF4735A19BCA1DE97281 80.190.246
- * - 58985492
- * - 58985492 79.212.74.45
- */
-public class RelaySearchServlet extends HttpServlet {
-
-  private static final long serialVersionUID = -1772662230310611805L;
-
-  private Pattern alphaNumDotDashDollarSpacePattern =
-      Pattern.compile("[A-Za-z0-9\\.\\-$ ]+");
-
-  private Pattern numPattern = Pattern.compile("[0-9]+");
-
-  private Pattern hexPattern = Pattern.compile("[A-Fa-f0-9]+");
-
-  private Pattern alphaNumPattern = Pattern.compile("[A-Za-z0-9]+");
-
-  private SimpleDateFormat dayFormat = new SimpleDateFormat("yyyy-MM-dd");
-
-  private SimpleDateFormat monthFormat = new SimpleDateFormat("yyyy-MM");
-
-  private SimpleDateFormat dateTimeFormat =
-      new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
-
-  private long minValidAfterMillis;
-
-  private DataSource ds;
-
-  private Logger logger;
-
-  public void init() {
-
-    /* Initialize logger. */
-    this.logger = Logger.getLogger(RelaySearchServlet.class.toString());
-
-    /* Initialize date format parsers. */
-    dayFormat.setTimeZone(TimeZone.getTimeZone("UTC"));
-    monthFormat.setTimeZone(TimeZone.getTimeZone("UTC"));
-    dateTimeFormat.setTimeZone(TimeZone.getTimeZone("UTC"));
-
-    /* Look up data source. */
-    try {
-      Context cxt = new InitialContext();
-      this.ds = (DataSource) cxt.lookup("java:comp/env/jdbc/tordir");
-      this.logger.info("Successfully looked up data source.");
-    } catch (NamingException e) {
-      this.logger.log(Level.WARNING, "Could not look up data source", e);
-    }
-
-    /* Look up first consensus in the database. */
-    try {
-      long requestedConnection = System.currentTimeMillis();
-      Connection conn = this.ds.getConnection();
-      String query = "SELECT MIN(validafter) AS first FROM consensus";
-      Statement statement = conn.createStatement();
-      ResultSet rs = statement.executeQuery(query);
-      if (rs.next()) {
-        this.minValidAfterMillis = rs.getTimestamp(1).getTime();
-      }
-      rs.close();
-      statement.close();
-      conn.close();
-      this.logger.info("Returned a database connection to the pool "
-          + "after " + (System.currentTimeMillis()
-          - requestedConnection) + " millis.");
-    } catch (SQLException e) {
-      this.logger.log(Level.WARNING, "Could not look up first consensus "
-          + "valid-after time in the database.", e);
-    }
-  }
-
-  public void doGet(HttpServletRequest request,
-      HttpServletResponse response) throws IOException,
-      ServletException {
-
-    /* Read search parameter. If we don't have a search parameter, we're
-     * done here. */
-    String searchParameter = request.getParameter("search");
-    if (searchParameter == null || searchParameter.length() == 0) {
-      request.getRequestDispatcher("WEB-INF/relay-search.jsp").forward(
-          request, response);
-      return;
-    }
-
-    /* Parse search parameter to identify what nickname, fingerprint,
-     * and/or IP address to search for. A valid query contains no more
-     * than one identifier for each of the fields. As a special case,
-     * there are search terms consisting of 8 to 19 hex characters that
-     * can be either a nickname or a fingerprint. */
-    String searchNickname = "";
-    String searchFingerprint = "";
-    String searchIPAddress = "";
-    SortedSet<String> searchDays = new TreeSet<String>();
-    SortedSet<String> searchMonths = new TreeSet<String>();
-    SortedSet<Long> searchDayTimestamps = new TreeSet<Long>();
-    SortedSet<Long> searchMonthTimestamps = new TreeSet<Long>();
-    boolean validQuery = false;
-
-    /* Only parse search parameter if it contains nothing else than
-     * alphanumeric characters, dots, and spaces. */
-    if (alphaNumDotDashDollarSpacePattern.matcher(searchParameter).
-        matches()) {
-      SortedSet<String> searchTerms = new TreeSet<String>();
-      if (searchParameter.trim().contains(" ")) {
-        String[] split = searchParameter.trim().split(" ");
-        for (int i = 0; i < split.length; i++) {
-          if (split[i].length() > 0) {
-            searchTerms.add(split[i]);
-          }
-        }
-      } else {
-        searchTerms.add(searchParameter.trim());
-      }
-
-      /* Parse each search term separately. */
-      for (String searchTerm : searchTerms) {
-
-        /* If the search term contains a dot, it can only be an IP
-         * address. */
-        if (searchTerm.contains(".") && !searchTerm.startsWith(".")) {
-          String[] octets = searchTerm.split("\\.");
-          if (searchIPAddress.length() > 0 || octets.length < 2 ||
-              octets.length > 4) {
-            validQuery = false;
-            break;
-          }
-          boolean invalidOctet = false;
-          StringBuilder sb = new StringBuilder();
-          for (int i = 0; i < octets.length; i++) {
-            if (!numPattern.matcher(octets[i]).matches() ||
-                octets[i].length() > 3 ||
-                Integer.parseInt(octets[i]) > 255) {
-              invalidOctet = true;
-              break;
-            } else {
-              sb.append("." + Integer.parseInt(octets[i]));
-            }
-          }
-          if (invalidOctet) {
-            validQuery = false;
-            break;
-          }
-          if (octets.length < 4) {
-            sb.append(".");
-          }
-          searchIPAddress = sb.toString().substring(1);
-          validQuery = true;
-        }
-
-        /* If the search term contains hyphens, it must be a month or a
-         * day. */
-        else if (searchTerm.contains("-") &&
-            searchTerm.startsWith("20")) {
-          try {
-            if (searchTerm.length() == 10) {
-              searchDayTimestamps.add(dayFormat.parse(searchTerm).
-                  getTime());
-              searchDays.add(searchTerm);
-            } else if (searchTerm.length() == 7) {
-              searchMonthTimestamps.add(monthFormat.parse(searchTerm).
-                  getTime());
-              searchMonths.add(searchTerm);
-            } else {
-              validQuery = false;
-              break;
-            }
-          } catch (ParseException e) {
-            validQuery = false;
-            break;
-          }
-        }
-
-        /* If the search term starts with a $ followed by 8 to 40 hex
-         * characters, it must be a fingerprint. */
-        else if ((searchTerm.length() >= 9 && searchTerm.length() <= 41 &&
-            searchTerm.startsWith("$") &&
-            hexPattern.matcher(searchTerm.substring(1)).matches()) ||
-            (searchTerm.length() > 19 && searchTerm.length() <= 40 &&
-            !searchTerm.startsWith("$") &&
-            hexPattern.matcher(searchTerm).matches())) {
-          if (searchFingerprint.length() > 0) {
-            validQuery = false;
-            break;
-          }
-          searchFingerprint = searchTerm.substring(
-              (searchTerm.startsWith("$") ? 1 : 0));
-          validQuery = true;
-        }
-
-        /* If the search term contains up to 19 alphanumerical characters,
-         * it must be a nickname. */
-        else if (searchTerm.length() <= 19 &&
-            alphaNumPattern.matcher(searchTerm).matches()) {
-          if (searchNickname.length() > 0) {
-            validQuery = false;
-            break;
-          }
-          searchNickname = searchTerm;
-          validQuery = true;
-        }
-
-        /* We didn't recognize this search term. */
-        else {
-          validQuery = false;
-          break;
-        }
-      }
-    }
-
-    /* We only accept at most one month or three days, but not both, or
-     * people could accidentally keep the database busy. */
-    if (searchDays.size() > 3 || searchMonths.size() > 1 ||
-        (searchMonths.size() == 1 && searchDays.size() > 0)) {
-      validQuery = false;
-    }
-
-    /* If the query is invalid, stop here. */
-    if (!validQuery) {
-      request.setAttribute("invalidQuery", "Query is invalid.");
-      request.getRequestDispatcher("WEB-INF/relay-search.jsp").
-          forward(request, response);
-      return;
-    }
-
-    /* Look up last consensus in the database. */
-    long maxValidAfterMillis = -1L;
-    try {
-      long requestedConnection = System.currentTimeMillis();
-      Connection conn = this.ds.getConnection();
-      String query = "SELECT MAX(validafter) AS last FROM consensus";
-      Statement statement = conn.createStatement();
-      ResultSet rs = statement.executeQuery(query);
-      if (rs.next()) {
-        maxValidAfterMillis = rs.getTimestamp(1).getTime();
-      }
-      rs.close();
-      statement.close();
-      conn.close();
-      this.logger.info("Returned a database connection to the pool "
-          + "after " + (System.currentTimeMillis()
-          - requestedConnection) + " millis.");
-    } catch (SQLException e) {
-      this.logger.log(Level.WARNING, "Could not look up last consensus "
-          + "valid-after time in the database.", e);
-    }
-
-    /* Prepare a string that says what we're searching for. */
-    List<String> recognizedSearchTerms = new ArrayList<String>();
-    if (searchNickname.length() > 0) {
-      recognizedSearchTerms.add("nickname <b>" + searchNickname + "</b>");
-    }
-    if (searchFingerprint.length() > 0) {
-      recognizedSearchTerms.add("fingerprint <b>" + searchFingerprint
-          + "</b>");
-    }
-    if (searchIPAddress.length() > 0) {
-      recognizedSearchTerms.add("IP address <b>" + searchIPAddress
-          + "</b>");
-    }
-    List<String> recognizedIntervals = new ArrayList<String>();
-    for (String searchTerm : searchMonths) {
-      recognizedIntervals.add("in <b>" + searchTerm + "</b>");
-    }
-    for (String searchTerm : searchDays) {
-      recognizedIntervals.add("on <b>" + searchTerm + "</b>");
-    }
-    StringBuilder searchNoticeBuilder = new StringBuilder();
-    if (maxValidAfterMillis > 0L) {
-      searchNoticeBuilder.append("Most recent consensus in database is "
-          + "from " + dateTimeFormat.format(maxValidAfterMillis)
-          + ".</p><p>");
-    }
-    searchNoticeBuilder.append("Searching for relays with ");
-    if (recognizedSearchTerms.size() == 1) {
-      searchNoticeBuilder.append(recognizedSearchTerms.get(0));
-    } else if (recognizedSearchTerms.size() == 2) {
-      searchNoticeBuilder.append(recognizedSearchTerms.get(0) + " and "
-          + recognizedSearchTerms.get(1));
-    } else {
-      for (int i = 0; i < recognizedSearchTerms.size() - 1; i++) {
-        searchNoticeBuilder.append(recognizedSearchTerms.get(i) + ", ");
-      }
-      searchNoticeBuilder.append("and " + recognizedSearchTerms.get(
-          recognizedSearchTerms.size() - 1));
-    }
-    if (recognizedIntervals.size() == 1) {
-      searchNoticeBuilder.append(" running "
-          + recognizedIntervals.get(0));
-    } else if (recognizedIntervals.size() == 2) {
-      searchNoticeBuilder.append(" running " + recognizedIntervals.get(0)
-          + " and/or " + recognizedIntervals.get(1));
-    } else if (recognizedIntervals.size() > 2) {
-      searchNoticeBuilder.append(" running ");
-      for (int i = 0; i < recognizedIntervals.size() - 1; i++) {
-      searchNoticeBuilder.append(recognizedIntervals.get(i) + ", ");
-      }
-      searchNoticeBuilder.append("and/or " + recognizedIntervals.get(
-          recognizedIntervals.size() - 1));
-    }
-    searchNoticeBuilder.append(" ...");
-    String searchNotice = searchNoticeBuilder.toString();
-    request.setAttribute("searchNotice", searchNotice);
-
-    /* Prepare the query string. */
-    StringBuilder conditionBuilder = new StringBuilder();
-    boolean addAnd = false;
-    if (searchNickname.length() > 0) {
-      conditionBuilder.append((addAnd ? "AND " : "")
-          + "LOWER(nickname) LIKE '" + searchNickname.toLowerCase()
-          + "%' ");
-      addAnd = true;
-    }
-    if (searchFingerprint.length() > 0) {
-      conditionBuilder.append((addAnd ? "AND " : "")
-          + "fingerprint LIKE '" + searchFingerprint.toLowerCase()
-          + "%' ");
-      addAnd = true;
-    }
-    if (searchIPAddress.length() > 0) {
-      conditionBuilder.append((addAnd ? "AND " : "")
-          + "address LIKE '" + searchIPAddress + "%' ");
-      addAnd = true;
-    }
-    List<String> timeIntervals = new ArrayList<String>();
-    if (searchDayTimestamps.size() > 0 ||
-        searchMonthTimestamps.size() > 0) {
-      StringBuilder timeIntervalBuilder = new StringBuilder();
-      boolean addOr = false;
-      timeIntervalBuilder.append("AND (");
-      for (long searchTimestamp : searchDayTimestamps) {
-        if (searchTimestamp < this.minValidAfterMillis) {
-          request.setAttribute("outsideInterval", "Returned search "
-              + "results may be incomplete, as our data only dates back "
-              + "to " + dateTimeFormat.format(this.minValidAfterMillis)
-              + ".  Older archives are not available.");
-        }
-        timeIntervalBuilder.append((addOr ? "OR " : "")
-            + "(validafter >= '"
-            + dateTimeFormat.format(searchTimestamp) + "' AND "
-            + "validafter < '" + dateTimeFormat.format(searchTimestamp
-            + 24L * 60L * 60L * 1000L) + "') ");
-        addOr = true;
-      }
-      for (long searchTimestamp : searchMonthTimestamps) {
-        if (searchTimestamp < this.minValidAfterMillis) {
-          request.setAttribute("outsideInterval", "Returned search "
-              + "results may be incomplete, as our data only dates back "
-              + "to " + dateTimeFormat.format(this.minValidAfterMillis)
-              + ".  Older archives are not available.");
-        }
-        Calendar firstOfNextMonth = Calendar.getInstance(
-            TimeZone.getTimeZone("UTC"));
-        firstOfNextMonth.setTimeInMillis(searchTimestamp);
-        firstOfNextMonth.add(Calendar.MONTH, 1);
-        timeIntervalBuilder.append((addOr ? "OR " : "")
-            + "(validafter >= '"
-            + dateTimeFormat.format(searchTimestamp) + "' AND "
-            + "validafter < '" + dateTimeFormat.format(
-            firstOfNextMonth.getTimeInMillis()) + "') ");
-        addOr = true;
-      }
-      timeIntervalBuilder.append(") ");
-      timeIntervals.add(timeIntervalBuilder.toString());
-    } else {
-      timeIntervals.add("AND validafter >= '"
-          + dateTimeFormat.format(System.currentTimeMillis()
-          - 4L * 24L * 60L * 60L * 1000L) + "' ");
-      timeIntervals.add("AND validafter >= '"
-          + dateTimeFormat.format(System.currentTimeMillis()
-          - 30L * 24L * 60L * 60L * 1000L) + "' ");
-    }
-    List<String> queries = new ArrayList<String>();
-    for (String timeInterval : timeIntervals) {
-      StringBuilder queryBuilder = new StringBuilder();
-      queryBuilder.append("SELECT validafter, fingerprint, descriptor, "
-          + "rawdesc FROM statusentry WHERE validafter IN (SELECT "
-          + "validafter FROM statusentry WHERE ");
-      queryBuilder.append(conditionBuilder.toString());
-      queryBuilder.append(timeInterval);
-      queryBuilder.append("ORDER BY validafter DESC LIMIT 31) AND ");
-      queryBuilder.append(conditionBuilder.toString());
-      queryBuilder.append(timeInterval);
-      queries.add(queryBuilder.toString());
-    }
-
-    /* Actually execute the query. */
-    long startedQuery = System.currentTimeMillis();
-    SortedMap<String, SortedSet<String>> foundDescriptors =
-        new TreeMap<String, SortedSet<String>>(
-        Collections.reverseOrder());
-    Map<String, String> rawValidAfterLines =
-        new HashMap<String, String>();
-    Map<String, String> rawStatusEntries = new HashMap<String, String>();
-    String query = null;
-    int matches = 0;
-    try {
-      long requestedConnection = System.currentTimeMillis();
-      Connection conn = this.ds.getConnection();
-      while (!queries.isEmpty()) {
-        query = queries.remove(0);
-        this.logger.info("Running query '" + query + "'.");
-        Statement statement = conn.createStatement();
-        ResultSet rs = statement.executeQuery(query);
-        while (rs.next()) {
-          matches++;
-          String validAfter = rs.getTimestamp(1).toString().
-              substring(0, 19);
-          String fingerprint = rs.getString(2);
-          if (!foundDescriptors.containsKey(validAfter)) {
-            foundDescriptors.put(validAfter, new TreeSet<String>());
-          }
-          foundDescriptors.get(validAfter).add(validAfter + " "
-              + fingerprint);
-          if (!rawValidAfterLines.containsKey(validAfter)) {
-            rawValidAfterLines.put(validAfter, "<tt>valid-after "
-                + "<a href=\"https://exonerator.torproject.org/"
-                + "consensus?valid-after="
-                + validAfter.replaceAll(":", "-").replaceAll(" ", "-")
-                + "\" target=\"_blank\">" + validAfter + "</a></tt><br>");
-          }
-          byte[] rawStatusEntry = rs.getBytes(4);
-          String statusEntryLines = null;
-          try {
-            statusEntryLines = new String(rawStatusEntry, "US-ASCII");
-          } catch (UnsupportedEncodingException e) {
-            /* This shouldn't happen, because we know that ASCII is
-             * supported. */
-          }
-          StringBuilder rawStatusEntryBuilder = new StringBuilder();
-          String[] lines = statusEntryLines.split("\n");
-          for (String line : lines) {
-            if (line.startsWith("r ")) {
-              String[] parts = line.split(" ");
-              String descriptorBase64 = String.format("%040x",
-                  new BigInteger(1, Base64.decodeBase64(parts[3]
-                  + "==")));
-              rawStatusEntryBuilder.append("<tt>r " + parts[1] + " "
-                  + parts[2] + " <a href=\""
-                  + "https://exonerator.torproject.org/"
-                  + "serverdesc?desc-id="
-                  + descriptorBase64 + "\" target=\"_blank\">" + parts[3]
-                  + "</a> " + parts[4] + " " + parts[5] + " " + parts[6]
-                  + " " + parts[7] + " " + parts[8] + "</tt><br>");
-            } else {
-              rawStatusEntryBuilder.append("<tt>" + line + "</tt><br>");
-            }
-          }
-          rawStatusEntries.put(validAfter + " " + fingerprint,
-                rawStatusEntryBuilder.toString());
-        }
-        rs.close();
-        statement.close();
-        if (matches >= 31) {
-          queries.clear();
-        }
-      }
-      conn.close();
-      this.logger.info("Returned a database connection to the pool "
-          + "after " + (System.currentTimeMillis()
-          - requestedConnection) + " millis.");
-    } catch (SQLException e) {
-
-      /* Tell the user we have a database problem. */
-      response.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR,
-          "Database problem");
-      return;
-    }
-    request.setAttribute("query", query);
-    request.setAttribute("queryTime", System.currentTimeMillis()
-        - startedQuery);
-    request.setAttribute("foundDescriptors", foundDescriptors);
-    request.setAttribute("rawValidAfterLines", rawValidAfterLines);
-    request.setAttribute("rawStatusEntries", rawStatusEntries);
-    request.setAttribute("matches", matches);
-
-    /* We're done. Let the JSP do the rest. */
-    request.getRequestDispatcher("WEB-INF/relay-search.jsp").forward(
-        request, response);
-  }
-}
-
diff --git a/website/src/org/torproject/ernie/web/GraphsServlet.java b/website/src/org/torproject/ernie/web/GraphsServlet.java
deleted file mode 100644
index 111aa0f..0000000
--- a/website/src/org/torproject/ernie/web/GraphsServlet.java
+++ /dev/null
@@ -1,24 +0,0 @@
-/* Copyright 2011, 2012 The Tor Project
- * See LICENSE for licensing information */
-package org.torproject.ernie.web;
-
-import java.io.IOException;
-
-import javax.servlet.ServletException;
-import javax.servlet.http.HttpServlet;
-import javax.servlet.http.HttpServletRequest;
-import javax.servlet.http.HttpServletResponse;
-
-public class GraphsServlet extends HttpServlet {
-
-  private static final long serialVersionUID = 7615715032362498151L;
-
-  public void doGet(HttpServletRequest request,
-      HttpServletResponse response) throws IOException, ServletException {
-
-    /* Forward the request to the JSP that does all the hard work. */
-    request.getRequestDispatcher("WEB-INF/graphs.jsp").forward(request,
-        response);
-  }
-}
-
diff --git a/website/src/org/torproject/ernie/web/IndexServlet.java b/website/src/org/torproject/ernie/web/IndexServlet.java
deleted file mode 100644
index 11aff7c..0000000
--- a/website/src/org/torproject/ernie/web/IndexServlet.java
+++ /dev/null
@@ -1,24 +0,0 @@
-/* Copyright 2011, 2012 The Tor Project
- * See LICENSE for licensing information */
-package org.torproject.ernie.web;
-
-import java.io.IOException;
-
-import javax.servlet.ServletException;
-import javax.servlet.http.HttpServlet;
-import javax.servlet.http.HttpServletRequest;
-import javax.servlet.http.HttpServletResponse;
-
-public class IndexServlet extends HttpServlet {
-
-  private static final long serialVersionUID = 7871368999788994664L;
-
-  public void doGet(HttpServletRequest request,
-      HttpServletResponse response) throws IOException, ServletException {
-
-    /* Forward the request to the JSP that does all the hard work. */
-    request.getRequestDispatcher("WEB-INF/index.jsp").forward(request,
-        response);
-  }
-}
-
diff --git a/website/src/org/torproject/ernie/web/ResearchServlet.java b/website/src/org/torproject/ernie/web/ResearchServlet.java
deleted file mode 100644
index c78be69..0000000
--- a/website/src/org/torproject/ernie/web/ResearchServlet.java
+++ /dev/null
@@ -1,24 +0,0 @@
-/* Copyright 2011, 2012 The Tor Project
- * See LICENSE for licensing information */
-package org.torproject.ernie.web;
-
-import java.io.IOException;
-
-import javax.servlet.ServletException;
-import javax.servlet.http.HttpServlet;
-import javax.servlet.http.HttpServletRequest;
-import javax.servlet.http.HttpServletResponse;
-
-public class ResearchServlet extends HttpServlet {
-
-  private static final long serialVersionUID = -9151727188925700665L;
-
-  public void doGet(HttpServletRequest request,
-      HttpServletResponse response) throws IOException, ServletException {
-
-    /* Forward the request to the JSP that does all the hard work. */
-    request.getRequestDispatcher("WEB-INF/research.jsp").forward(request,
-        response);
-  }
-}
-
diff --git a/website/src/org/torproject/ernie/web/StatusServlet.java b/website/src/org/torproject/ernie/web/StatusServlet.java
deleted file mode 100644
index 07790ec..0000000
--- a/website/src/org/torproject/ernie/web/StatusServlet.java
+++ /dev/null
@@ -1,24 +0,0 @@
-/* Copyright 2011, 2012 The Tor Project
- * See LICENSE for licensing information */
-package org.torproject.ernie.web;
-
-import java.io.IOException;
-
-import javax.servlet.ServletException;
-import javax.servlet.http.HttpServlet;
-import javax.servlet.http.HttpServletRequest;
-import javax.servlet.http.HttpServletResponse;
-
-public class StatusServlet extends HttpServlet {
-
-  private static final long serialVersionUID = -7249872082399236981L;
-
-  public void doGet(HttpServletRequest request,
-      HttpServletResponse response) throws IOException, ServletException {
-
-    /* Forward the request to the JSP that does all the hard work. */
-    request.getRequestDispatcher("WEB-INF/status.jsp").forward(request,
-        response);
-  }
-}
-
diff --git a/website/src/org/torproject/ernie/web/graphs/BubblesServlet.java b/website/src/org/torproject/ernie/web/graphs/BubblesServlet.java
deleted file mode 100644
index 6f66413..0000000
--- a/website/src/org/torproject/ernie/web/graphs/BubblesServlet.java
+++ /dev/null
@@ -1,24 +0,0 @@
-/* Copyright 2013 The Tor Project
- * See LICENSE for licensing information */
-package org.torproject.ernie.web.graphs;
-
-import java.io.IOException;
-
-import javax.servlet.ServletException;
-import javax.servlet.http.HttpServlet;
-import javax.servlet.http.HttpServletRequest;
-import javax.servlet.http.HttpServletResponse;
-
-public class BubblesServlet extends HttpServlet {
-
-  private static final long serialVersionUID = -6011833075497881033L;
-
-  public void doGet(HttpServletRequest request,
-      HttpServletResponse response) throws IOException, ServletException {
-
-    /* Forward the request to the JSP that does all the hard work. */
-    request.getRequestDispatcher("WEB-INF/bubbles.jsp").forward(request,
-        response);
-  }
-}
-
diff --git a/website/src/org/torproject/ernie/web/graphs/Countries.java b/website/src/org/torproject/ernie/web/graphs/Countries.java
deleted file mode 100644
index 93dac4c..0000000
--- a/website/src/org/torproject/ernie/web/graphs/Countries.java
+++ /dev/null
@@ -1,285 +0,0 @@
-/* Copyright 2011, 2012 The Tor Project
- * See LICENSE for licensing information */
-package org.torproject.ernie.web.graphs;
-
-import java.util.ArrayList;
-import java.util.List;
-
-public class Countries {
-
-  private static Countries instance = new Countries();
-
-  public static Countries getInstance() {
-    return Countries.instance;
-  }
-
-  /* List of arrays of length 2, containing country codes at [0] and
-   * country names at [1], alphabetically ordered by country names. */
-  private List<String[]> knownCountries;
-
-  private Countries() {
-    this.knownCountries = new ArrayList<String[]>();
-    this.knownCountries.add("af;Afghanistan".split(";"));
-    this.knownCountries.add("ax;Aland Islands".split(";"));
-    this.knownCountries.add("al;Albania".split(";"));
-    this.knownCountries.add("dz;Algeria".split(";"));
-    this.knownCountries.add("as;American Samoa".split(";"));
-    this.knownCountries.add("ad;Andorra".split(";"));
-    this.knownCountries.add("ao;Angola".split(";"));
-    this.knownCountries.add("ai;Anguilla".split(";"));
-    this.knownCountries.add("aq;Antarctica".split(";"));
-    this.knownCountries.add("ag;Antigua and Barbuda".split(";"));
-    this.knownCountries.add("ar;Argentina".split(";"));
-    this.knownCountries.add("am;Armenia".split(";"));
-    this.knownCountries.add("aw;Aruba".split(";"));
-    this.knownCountries.add("au;Australia".split(";"));
-    this.knownCountries.add("at;Austria".split(";"));
-    this.knownCountries.add("az;Azerbaijan".split(";"));
-    this.knownCountries.add("bs;Bahamas".split(";"));
-    this.knownCountries.add("bh;Bahrain".split(";"));
-    this.knownCountries.add("bd;Bangladesh".split(";"));
-    this.knownCountries.add("bb;Barbados".split(";"));
-    this.knownCountries.add("by;Belarus".split(";"));
-    this.knownCountries.add("be;Belgium".split(";"));
-    this.knownCountries.add("bz;Belize".split(";"));
-    this.knownCountries.add("bj;Benin".split(";"));
-    this.knownCountries.add("bm;Bermuda".split(";"));
-    this.knownCountries.add("bt;Bhutan".split(";"));
-    this.knownCountries.add("bo;Bolivia".split(";"));
-    this.knownCountries.add("ba;Bosnia and Herzegovina".split(";"));
-    this.knownCountries.add("bw;Botswana".split(";"));
-    this.knownCountries.add("bv;Bouvet Island".split(";"));
-    this.knownCountries.add("br;Brazil".split(";"));
-    this.knownCountries.add("io;British Indian Ocean Territory".
-        split(";"));
-    this.knownCountries.add("bn;Brunei".split(";"));
-    this.knownCountries.add("bg;Bulgaria".split(";"));
-    this.knownCountries.add("bf;Burkina Faso".split(";"));
-    this.knownCountries.add("mm;Burma".split(";"));
-    this.knownCountries.add("bi;Burundi".split(";"));
-    this.knownCountries.add("kh;Cambodia".split(";"));
-    this.knownCountries.add("cm;Cameroon".split(";"));
-    this.knownCountries.add("ca;Canada".split(";"));
-    this.knownCountries.add("cv;Cape Verde".split(";"));
-    this.knownCountries.add("ky;Cayman Islands".split(";"));
-    this.knownCountries.add("cf;Central African Republic".split(";"));
-    this.knownCountries.add("td;Chad".split(";"));
-    this.knownCountries.add("cl;Chile".split(";"));
-    this.knownCountries.add("cn;China".split(";"));
-    this.knownCountries.add("cx;Christmas Island".split(";"));
-    this.knownCountries.add("cc;Cocos (Keeling) Islands".split(";"));
-    this.knownCountries.add("co;Colombia".split(";"));
-    this.knownCountries.add("km;Comoros".split(";"));
-    this.knownCountries.add("cd;Congo, The Democratic Republic of the".
-        split(";"));
-    this.knownCountries.add("cg;Congo".split(";"));
-    this.knownCountries.add("ck;Cook Islands".split(";"));
-    this.knownCountries.add("cr;Costa Rica".split(";"));
-    this.knownCountries.add("ci:Côte d'Ivoire".split(":"));
-    this.knownCountries.add("hr;Croatia".split(";"));
-    this.knownCountries.add("cu;Cuba".split(";"));
-    this.knownCountries.add("cy;Cyprus".split(";"));
-    this.knownCountries.add("cz;Czech Republic".split(";"));
-    this.knownCountries.add("dk;Denmark".split(";"));
-    this.knownCountries.add("dj;Djibouti".split(";"));
-    this.knownCountries.add("dm;Dominica".split(";"));
-    this.knownCountries.add("do;Dominican Republic".split(";"));
-    this.knownCountries.add("ec;Ecuador".split(";"));
-    this.knownCountries.add("eg;Egypt".split(";"));
-    this.knownCountries.add("sv;El Salvador".split(";"));
-    this.knownCountries.add("gq;Equatorial Guinea".split(";"));
-    this.knownCountries.add("er;Eritrea".split(";"));
-    this.knownCountries.add("ee;Estonia".split(";"));
-    this.knownCountries.add("et;Ethiopia".split(";"));
-    this.knownCountries.add("fk;Falkland Islands (Malvinas)".split(";"));
-    this.knownCountries.add("fo;Faroe Islands".split(";"));
-    this.knownCountries.add("fj;Fiji".split(";"));
-    this.knownCountries.add("fi;Finland".split(";"));
-    this.knownCountries.add("fx;France, Metropolitan".split(";"));
-    this.knownCountries.add("fr;France".split(";"));
-    this.knownCountries.add("gf;French Guiana".split(";"));
-    this.knownCountries.add("pf;French Polynesia".split(";"));
-    this.knownCountries.add("tf;French Southern Territories".split(";"));
-    this.knownCountries.add("ga;Gabon".split(";"));
-    this.knownCountries.add("gm;Gambia".split(";"));
-    this.knownCountries.add("ge;Georgia".split(";"));
-    this.knownCountries.add("de;Germany".split(";"));
-    this.knownCountries.add("gh;Ghana".split(";"));
-    this.knownCountries.add("gi;Gibraltar".split(";"));
-    this.knownCountries.add("gr;Greece".split(";"));
-    this.knownCountries.add("gl;Greenland".split(";"));
-    this.knownCountries.add("gd;Grenada".split(";"));
-    this.knownCountries.add("gp;Guadeloupe".split(";"));
-    this.knownCountries.add("gu;Guam".split(";"));
-    this.knownCountries.add("gt;Guatemala".split(";"));
-    this.knownCountries.add("gg;Guernsey".split(";"));
-    this.knownCountries.add("gn;Guinea".split(";"));
-    this.knownCountries.add("gw;Guinea-Bissau".split(";"));
-    this.knownCountries.add("gy;Guyana".split(";"));
-    this.knownCountries.add("ht;Haiti".split(";"));
-    this.knownCountries.add("hm;Heard Island and McDonald Islands".
-        split(";"));
-    this.knownCountries.add("va;Vatican City".split(";"));
-    this.knownCountries.add("hn;Honduras".split(";"));
-    this.knownCountries.add("hk;Hong Kong".split(";"));
-    this.knownCountries.add("hu;Hungary".split(";"));
-    this.knownCountries.add("is;Iceland".split(";"));
-    this.knownCountries.add("in;India".split(";"));
-    this.knownCountries.add("id;Indonesia".split(";"));
-    this.knownCountries.add("ir;Iran".split(";"));
-    this.knownCountries.add("iq;Iraq".split(";"));
-    this.knownCountries.add("ie;Ireland".split(";"));
-    this.knownCountries.add("im;Isle of Man".split(";"));
-    this.knownCountries.add("il;Israel".split(";"));
-    this.knownCountries.add("it;Italy".split(";"));
-    this.knownCountries.add("jm;Jamaica".split(";"));
-    this.knownCountries.add("jp;Japan".split(";"));
-    this.knownCountries.add("je;Jersey".split(";"));
-    this.knownCountries.add("jo;Jordan".split(";"));
-    this.knownCountries.add("kz;Kazakhstan".split(";"));
-    this.knownCountries.add("ke;Kenya".split(";"));
-    this.knownCountries.add("ki;Kiribati".split(";"));
-    this.knownCountries.add("kp;North Korea".split(";"));
-    this.knownCountries.add("kr;Korea, Republic of".split(";"));
-    this.knownCountries.add("kw;Kuwait".split(";"));
-    this.knownCountries.add("kg;Kyrgyzstan".split(";"));
-    this.knownCountries.add("la;Laos".split(";"));
-    this.knownCountries.add("lv;Latvia".split(";"));
-    this.knownCountries.add("lb;Lebanon".split(";"));
-    this.knownCountries.add("ls;Lesotho".split(";"));
-    this.knownCountries.add("lr;Liberia".split(";"));
-    this.knownCountries.add("ly;Libya".split(";"));
-    this.knownCountries.add("li;Liechtenstein".split(";"));
-    this.knownCountries.add("lt;Lithuania".split(";"));
-    this.knownCountries.add("lu;Luxembourg".split(";"));
-    this.knownCountries.add("mo;Macau".split(";"));
-    this.knownCountries.add("mk;Macedonia".split(";"));
-    this.knownCountries.add("mg;Madagascar".split(";"));
-    this.knownCountries.add("mw;Malawi".split(";"));
-    this.knownCountries.add("my;Malaysia".split(";"));
-    this.knownCountries.add("mv;Maldives".split(";"));
-    this.knownCountries.add("ml;Mali".split(";"));
-    this.knownCountries.add("mt;Malta".split(";"));
-    this.knownCountries.add("mh;Marshall Islands".split(";"));
-    this.knownCountries.add("mq;Martinique".split(";"));
-    this.knownCountries.add("mr;Mauritania".split(";"));
-    this.knownCountries.add("mu;Mauritius".split(";"));
-    this.knownCountries.add("yt;Mayotte".split(";"));
-    this.knownCountries.add("mx;Mexico".split(";"));
-    this.knownCountries.add("fm;Micronesia, Federated States of".
-        split(";"));
-    this.knownCountries.add("md;Moldova, Republic of".split(";"));
-    this.knownCountries.add("mc;Monaco".split(";"));
-    this.knownCountries.add("mn;Mongolia".split(";"));
-    this.knownCountries.add("me;Montenegro".split(";"));
-    this.knownCountries.add("ms;Montserrat".split(";"));
-    this.knownCountries.add("ma;Morocco".split(";"));
-    this.knownCountries.add("mz;Mozambique".split(";"));
-    this.knownCountries.add("mm;Burma".split(";"));
-    this.knownCountries.add("na;Namibia".split(";"));
-    this.knownCountries.add("nr;Nauru".split(";"));
-    this.knownCountries.add("np;Nepal".split(";"));
-    this.knownCountries.add("an;Netherlands Antilles".split(";"));
-    this.knownCountries.add("nl;Netherlands".split(";"));
-    this.knownCountries.add("nc;New Caledonia".split(";"));
-    this.knownCountries.add("nz;New Zealand".split(";"));
-    this.knownCountries.add("ni;Nicaragua".split(";"));
-    this.knownCountries.add("ne;Niger".split(";"));
-    this.knownCountries.add("ng;Nigeria".split(";"));
-    this.knownCountries.add("nu;Niue".split(";"));
-    this.knownCountries.add("nf;Norfolk Island".split(";"));
-    this.knownCountries.add("mp;Northern Mariana Islands".split(";"));
-    this.knownCountries.add("no;Norway".split(";"));
-    this.knownCountries.add("om;Oman".split(";"));
-    this.knownCountries.add("pk;Pakistan".split(";"));
-    this.knownCountries.add("pw;Palau".split(";"));
-    this.knownCountries.add("ps;Palestinian Territory".split(";"));
-    this.knownCountries.add("pa;Panama".split(";"));
-    this.knownCountries.add("pg;Papua New Guinea".split(";"));
-    this.knownCountries.add("py;Paraguay".split(";"));
-    this.knownCountries.add("pe;Peru".split(";"));
-    this.knownCountries.add("ph;Philippines".split(";"));
-    this.knownCountries.add("pn;Pitcairn Islands".split(";"));
-    this.knownCountries.add("pl;Poland".split(";"));
-    this.knownCountries.add("pt;Portugal".split(";"));
-    this.knownCountries.add("pr;Puerto Rico".split(";"));
-    this.knownCountries.add("qa;Qatar".split(";"));
-    this.knownCountries.add("re;Reunion".split(";"));
-    this.knownCountries.add("ro;Romania".split(";"));
-    this.knownCountries.add("ru;Russia".split(";"));
-    this.knownCountries.add("rw;Rwanda".split(";"));
-    this.knownCountries.add("bl;Saint Bartelemey".split(";"));
-    this.knownCountries.add("sh;Saint Helena".split(";"));
-    this.knownCountries.add("kn;Saint Kitts and Nevis".split(";"));
-    this.knownCountries.add("lc;Saint Lucia".split(";"));
-    this.knownCountries.add("mf;Saint Martin".split(";"));
-    this.knownCountries.add("pm;Saint Pierre and Miquelon".split(";"));
-    this.knownCountries.add("vc;Saint Vincent and the Grenadines".
-        split(";"));
-    this.knownCountries.add("ws;Samoa".split(";"));
-    this.knownCountries.add("sm;San Marino".split(";"));
-    this.knownCountries.add("st:São Tomé and Príncipe".
-        split(":"));
-    this.knownCountries.add("sa;Saudi Arabia".split(";"));
-    this.knownCountries.add("sn;Senegal".split(";"));
-    this.knownCountries.add("rs;Serbia".split(";"));
-    this.knownCountries.add("sc;Seychelles".split(";"));
-    this.knownCountries.add("sl;Sierra Leone".split(";"));
-    this.knownCountries.add("sg;Singapore".split(";"));
-    this.knownCountries.add("sk;Slovakia".split(";"));
-    this.knownCountries.add("si;Slovenia".split(";"));
-    this.knownCountries.add("sb;Solomon Islands".split(";"));
-    this.knownCountries.add("so;Somalia".split(";"));
-    this.knownCountries.add("za;South Africa".split(";"));
-    this.knownCountries.add(("gs;South Georgia and the South Sandwich "
-        + "Islands").split(";"));
-    this.knownCountries.add("ss;South Sudan".split(";"));
-    this.knownCountries.add("es;Spain".split(";"));
-    this.knownCountries.add("lk;Sri Lanka".split(";"));
-    this.knownCountries.add("sd;Sudan".split(";"));
-    this.knownCountries.add("sr;Suriname".split(";"));
-    this.knownCountries.add("sj;Svalbard and Jan Mayen".split(";"));
-    this.knownCountries.add("sz;Swaziland".split(";"));
-    this.knownCountries.add("se;Sweden".split(";"));
-    this.knownCountries.add("ch;Switzerland".split(";"));
-    this.knownCountries.add("sy;Syrian Arab Republic".split(";"));
-    this.knownCountries.add("tw;Taiwan".split(";"));
-    this.knownCountries.add("tj;Tajikistan".split(";"));
-    this.knownCountries.add("tz;Tanzania, United Republic of".split(";"));
-    this.knownCountries.add("th;Thailand".split(";"));
-    this.knownCountries.add("tl;East Timor".split(";"));
-    this.knownCountries.add("tg;Togo".split(";"));
-    this.knownCountries.add("tk;Tokelau".split(";"));
-    this.knownCountries.add("to;Tonga".split(";"));
-    this.knownCountries.add("tt;Trinidad and Tobago".split(";"));
-    this.knownCountries.add("tn;Tunisia".split(";"));
-    this.knownCountries.add("tr;Turkey".split(";"));
-    this.knownCountries.add("tm;Turkmenistan".split(";"));
-    this.knownCountries.add("tc;Turks and Caicos Islands".split(";"));
-    this.knownCountries.add("tv;Tuvalu".split(";"));
-    this.knownCountries.add("ug;Uganda".split(";"));
-    this.knownCountries.add("ua;Ukraine".split(";"));
-    this.knownCountries.add("ae;United Arab Emirates".split(";"));
-    this.knownCountries.add("gb;United Kingdom".split(";"));
-    this.knownCountries.add("um;United States Minor Outlying Islands".
-        split(";"));
-    this.knownCountries.add("us;United States".split(";"));
-    this.knownCountries.add("uy;Uruguay".split(";"));
-    this.knownCountries.add("uz;Uzbekistan".split(";"));
-    this.knownCountries.add("vu;Vanuatu".split(";"));
-    this.knownCountries.add("ve;Venezuela".split(";"));
-    this.knownCountries.add("vn;Vietnam".split(";"));
-    this.knownCountries.add("vg;Virgin Islands, British".split(";"));
-    this.knownCountries.add("vi;Virgin Islands, U.S.".split(";"));
-    this.knownCountries.add("wf;Wallis and Futuna".split(";"));
-    this.knownCountries.add("eh;Western Sahara".split(";"));
-    this.knownCountries.add("ye;Yemen".split(";"));
-    this.knownCountries.add("zm;Zambia".split(";"));
-    this.knownCountries.add("zw;Zimbabwe".split(";"));
-  }
-
-  public List<String[]> getCountryList() {
-    return this.knownCountries;
-  }
-}
-
diff --git a/website/src/org/torproject/ernie/web/graphs/CsvServlet.java b/website/src/org/torproject/ernie/web/graphs/CsvServlet.java
deleted file mode 100644
index 40e3bea..0000000
--- a/website/src/org/torproject/ernie/web/graphs/CsvServlet.java
+++ /dev/null
@@ -1,97 +0,0 @@
-/* Copyright 2011, 2012 The Tor Project
- * See LICENSE for licensing information */
-package org.torproject.ernie.web.graphs;
-
-import java.io.IOException;
-import java.util.SortedSet;
-import java.util.logging.Logger;
-
-import javax.servlet.ServletException;
-import javax.servlet.http.HttpServlet;
-import javax.servlet.http.HttpServletRequest;
-import javax.servlet.http.HttpServletResponse;
-
-/**
- * Servlet that reads an HTTP request for a comma-separated value file,
- * asks the GraphGenerator to generate this file, and returns it to the
- * client.
- */
-public class CsvServlet extends HttpServlet {
-
-  private static final long serialVersionUID = 7501442926823719958L;
-
-  private RObjectGenerator rObjectGenerator;
-
-  /* Available CSV files. */
-  private SortedSet<String> availableCsvFiles;
-
-  private Logger logger;
-
-  public void init() {
-
-    /* Initialize logger. */
-    this.logger = Logger.getLogger(CsvServlet.class.toString());
-
-    /* Get a reference to the R object generator that we need to generate
-     * CSV files. */
-    this.rObjectGenerator = (RObjectGenerator) getServletContext().
-        getAttribute("RObjectGenerator");
-    this.availableCsvFiles = rObjectGenerator.getAvailableCsvFiles();
-  }
-
-  public void doGet(HttpServletRequest request,
-      HttpServletResponse response) throws IOException,
-      ServletException {
-
-    /* Check if the directory listing was requested. */
-    String requestURI = request.getRequestURI();
-    if (requestURI.equals("/ernie/csv/")) {
-      request.setAttribute("directory", "/csv");
-      request.setAttribute("extension", ".csv");
-      request.setAttribute("files", this.availableCsvFiles);
-      request.getRequestDispatcher("/WEB-INF/dir.jsp").forward(request,
-          response);
-      return;
-    }
-
-    /* Find out which CSV file was requested and make sure we know this
-     * CSV file type. */
-    String requestedCsvFile = requestURI;
-    if (requestedCsvFile.endsWith(".csv")) {
-      requestedCsvFile = requestedCsvFile.substring(0,
-          requestedCsvFile.length() - ".csv".length());
-    }
-    if (requestedCsvFile.contains("/")) {
-      requestedCsvFile = requestedCsvFile.substring(requestedCsvFile.
-          lastIndexOf("/") + 1);
-    }
-    if (!availableCsvFiles.contains(requestedCsvFile)) {
-      logger.info("Did not recognize requested .csv file from request "
-          + "URI: '" + requestURI + "'. Responding with 404 Not Found.");
-      response.sendError(HttpServletResponse.SC_NOT_FOUND);
-      return;
-    }
-    logger.fine("CSV file '" + requestedCsvFile + ".csv' requested.");
-
-    /* Request CSV file from R object generator, which asks Rserve to
-     * generate it. */
-    RObject csvFile = this.rObjectGenerator.generateCsv(
-        requestedCsvFile, true);
-
-    /* Make sure that we have a .csv file to return. */
-    if (csvFile == null) {
-      response.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
-      return;
-    }
-
-    /* Write CSV file to response. */
-    String csvFileContent = new String(csvFile.getBytes());
-    response.setContentType("text/csv");
-    response.setHeader("Content-Length", String.valueOf(
-        csvFileContent.length()));
-    response.setHeader("Content-Disposition",
-        "inline; filename=\"" + requestedCsvFile + ".csv\"");
-    response.getWriter().print(csvFileContent);
-  }
-}
-
diff --git a/website/src/org/torproject/ernie/web/graphs/GraphImageServlet.java b/website/src/org/torproject/ernie/web/graphs/GraphImageServlet.java
deleted file mode 100644
index b7d0b17..0000000
--- a/website/src/org/torproject/ernie/web/graphs/GraphImageServlet.java
+++ /dev/null
@@ -1,76 +0,0 @@
-/* Copyright 2011, 2012 The Tor Project
- * See LICENSE for licensing information */
-package org.torproject.ernie.web.graphs;
-
-import java.io.BufferedOutputStream;
-import java.io.IOException;
-
-import javax.servlet.ServletException;
-import javax.servlet.http.HttpServlet;
-import javax.servlet.http.HttpServletRequest;
-import javax.servlet.http.HttpServletResponse;
-
-/**
- * Servlet that reads an HTTP request for a graph image, asks the
- * RObjectGenerator to generate this graph if it's not in the cache, and
- * returns the image bytes to the client.
- */
-public class GraphImageServlet extends HttpServlet {
-
-  private static final long serialVersionUID = -7356818641689744288L;
-
-  private RObjectGenerator rObjectGenerator;
-
-  public void init() {
-
-    /* Get a reference to the R object generator that we need to generate
-     * graph images. */
-    this.rObjectGenerator = (RObjectGenerator) getServletContext().
-        getAttribute("RObjectGenerator");
-  }
-
-  public void doGet(HttpServletRequest request,
-      HttpServletResponse response) throws IOException,
-      ServletException {
-
-    /* Find out which graph type was requested and make sure we know this
-     * graph type and file type. */
-    String requestedGraph = request.getRequestURI();
-    String fileType = null;
-    if (requestedGraph.endsWith(".png") ||
-        requestedGraph.endsWith(".pdf") ||
-        requestedGraph.endsWith(".svg")) {
-      fileType = requestedGraph.substring(requestedGraph.length() - 3);
-      requestedGraph = requestedGraph.substring(0, requestedGraph.length()
-          - 4);
-    }
-    if (requestedGraph.contains("/")) {
-      requestedGraph = requestedGraph.substring(requestedGraph.
-          lastIndexOf("/") + 1);
-    }
-
-    /* Request graph from R object generator, which either returns it from
-     * its cache or asks Rserve to generate it. */
-    RObject graph = rObjectGenerator.generateGraph(requestedGraph,
-        fileType, request.getParameterMap(), true);
-
-    /* Make sure that we have a graph to return. */
-    if (graph == null || graph.getBytes() == null || fileType == null) {
-      response.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
-      return;
-    }
-
-    /* Write graph bytes to response. */
-    BufferedOutputStream output = null;
-    response.setContentType("image/" + fileType);
-    response.setHeader("Content-Length",
-        String.valueOf(graph.getBytes().length));
-    response.setHeader("Content-Disposition",
-        "inline; filename=\"" + graph.getFileName() + "\"");
-    output = new BufferedOutputStream(response.getOutputStream(), 1024);
-    output.write(graph.getBytes(), 0, graph.getBytes().length);
-    output.flush();
-    output.close();
-  }
-}
-
diff --git a/website/src/org/torproject/ernie/web/graphs/GraphParameterChecker.java b/website/src/org/torproject/ernie/web/graphs/GraphParameterChecker.java
deleted file mode 100644
index 74ca6f9..0000000
--- a/website/src/org/torproject/ernie/web/graphs/GraphParameterChecker.java
+++ /dev/null
@@ -1,280 +0,0 @@
-/* Copyright 2011, 2012 The Tor Project
- * See LICENSE for licensing information */
-package org.torproject.ernie.web.graphs;
-
-import java.text.ParseException;
-import java.text.SimpleDateFormat;
-import java.util.Arrays;
-import java.util.HashMap;
-import java.util.HashSet;
-import java.util.List;
-import java.util.Map;
-import java.util.Set;
-import java.util.TimeZone;
-import java.util.regex.Pattern;
-
-/**
- * Checks request parameters passed to graph-generating servlets.
- */
-public class GraphParameterChecker {
-
-  /**
-   * Singleton instance of this class.
-   */
-  private static GraphParameterChecker instance =
-      new GraphParameterChecker();
-
-  /**
-   * Returns the singleton instance of this class.
-   */
-  public static GraphParameterChecker getInstance() {
-    return instance;
-  }
-
-  /* Date format for parsing start and end dates. */
-  private SimpleDateFormat dateFormat;
-
-  /* Available graphs with corresponding parameter lists. */
-  private Map<String, String> availableGraphs;
-
-  /* Known parameters and parameter values. */
-  private Map<String, String> knownParameterValues;
-
-  /**
-   * Initializes map with valid parameters for each of the graphs.
-   */
-  public GraphParameterChecker() {
-    this.dateFormat = new SimpleDateFormat("yyyy-MM-dd");
-    this.dateFormat.setTimeZone(TimeZone.getTimeZone("UTC"));
-
-    this.knownParameterValues = new HashMap<String, String>();
-    this.knownParameterValues.put("flag",
-        "Running,Exit,Guard,Fast,Stable,HSDir");
-    StringBuilder sb = new StringBuilder("all");
-    for (String[] country : Countries.getInstance().getCountryList()) {
-      sb.append("," + country[0]);
-    }
-    this.knownParameterValues.put("country", sb.toString());
-    this.knownParameterValues.put("events", "on,off,points");
-    this.knownParameterValues.put("source", "all,siv,moria,torperf");
-    this.knownParameterValues.put("filesize", "50kb,1mb,5mb");
-    this.knownParameterValues.put("transport",
-        "obfs2,obfs3,websocket,<OR>,<??>");
-    this.knownParameterValues.put("version", "v4,v6");
-  }
-
-  public void setAvailableGraphs(Map<String, String> availableGraphs) {
-    this.availableGraphs = availableGraphs;
-  }
-
-  /**
-   * Checks request parameters for the given graph type and returns a map
-   * of recognized parameters, or null if the graph type doesn't exist or
-   * the parameters are invalid.
-   */
-  public Map<String, String[]> checkParameters(String graphType,
-      Map requestParameters) {
-
-    /* Check if the graph type exists. */
-    if (graphType == null ||
-        !this.availableGraphs.containsKey(graphType)) {
-      return null;
-    }
-
-    /* Find out which other parameters are supported by this graph type
-     * and parse them if they are given. */
-    Set<String> supportedGraphParameters = new HashSet<String>(Arrays.
-        asList(this.availableGraphs.get(graphType).split(",")));
-    Map<String, String[]> recognizedGraphParameters =
-        new HashMap<String, String[]>();
-
-    /* Parse start and end dates if supported by the graph type. If no end
-     * date is provided, set it to today. If no start date is provided,
-     * set it to 90 days before the end date. Make sure that start date
-     * precedes end date. */
-    if (supportedGraphParameters.contains("start") ||
-        supportedGraphParameters.contains("end")) {
-      String[] startParameter = (String[]) requestParameters.get("start");
-      String[] endParameter = (String[]) requestParameters.get("end");
-      long endTimestamp = System.currentTimeMillis();
-      if (endParameter != null && endParameter.length > 0 &&
-          endParameter[0].length() > 0) {
-        try {
-          endTimestamp = dateFormat.parse(endParameter[0]).getTime();
-        } catch (ParseException e)  {
-          return null;
-        }
-        if (!endParameter[0].startsWith("20")) {
-          return null;
-        }
-      }
-      endParameter = new String[] { dateFormat.format(endTimestamp) };
-      long startTimestamp = endTimestamp - 90L * 24L * 60L * 60L * 1000L;
-      if (startParameter != null && startParameter.length > 0 &&
-          startParameter[0].length() > 0) {
-        try {
-          startTimestamp = dateFormat.parse(startParameter[0]).getTime();
-        } catch (ParseException e)  {
-          return null;
-        }
-        if (!startParameter[0].startsWith("20")) {
-          return null;
-        }
-      }
-      startParameter = new String[] { dateFormat.format(startTimestamp) };
-      if (startTimestamp > endTimestamp) {
-       return null;
-      }
-      recognizedGraphParameters.put("start", startParameter);
-      recognizedGraphParameters.put("end", endParameter);
-    }
-
-    /* Parse relay flags if supported by the graph type. If no relay flags
-     * are passed or none of them have been recognized, use the set of all
-     * known flags as default. */
-    if (supportedGraphParameters.contains("flag")) {
-      String[] flagParameters = (String[]) requestParameters.get("flag");
-      List<String> knownFlags = Arrays.asList(
-          this.knownParameterValues.get("flag").split(","));
-      if (flagParameters != null) {
-        for (String flag : flagParameters) {
-          if (flag == null || flag.length() == 0 ||
-              !knownFlags.contains(flag)) {
-            return null;
-          }
-        }
-      } else {
-        flagParameters = "Running,Exit,Guard,Fast,Stable".split(",");
-      }
-      recognizedGraphParameters.put("flag", flagParameters);
-    }
-
-    /* Parse country codes if supported by the graph type. If no countries
-     * are passed, use country code "all" (all countries) as default. */
-    if (supportedGraphParameters.contains("country")) {
-      String[] countryParameters = (String[]) requestParameters.get(
-          "country");
-      List<String> knownCountries = Arrays.asList(
-          this.knownParameterValues.get("country").split(","));
-      if (countryParameters != null) {
-        for (String country : countryParameters) {
-          if (country == null || country.length() == 0 ||
-              !knownCountries.contains(country)) {
-            return null;
-          }
-        }
-      } else {
-        countryParameters = new String[] { "all" };
-      }
-      recognizedGraphParameters.put("country", countryParameters);
-    }
-
-    /* Parse whether the estimated min/max range shall be displayed if
-     * supported by the graph type. This parameter can either be "on" or
-     * "off," where "off" is the default. */
-    if (supportedGraphParameters.contains("events")) {
-      String[] eventsParameter = (String[]) requestParameters.get(
-          "events");
-      List<String> knownRanges = Arrays.asList(
-          this.knownParameterValues.get("events").split(","));
-      if (eventsParameter != null) {
-        if (eventsParameter.length != 1 ||
-            eventsParameter[0].length() == 0 ||
-            !knownRanges.contains(eventsParameter[0])) {
-          return null;
-        }
-      } else {
-        eventsParameter = new String[] { "off" };
-      }
-      recognizedGraphParameters.put("events", eventsParameter);
-    }
-
-    /* Parse torperf data source if supported by the graph type. Only a
-     * single source can be passed. If no source is passed, use "torperf"
-     * as default. */
-    if (supportedGraphParameters.contains("source")) {
-      String[] sourceParameter = (String[]) requestParameters.get(
-          "source");
-      List<String> knownSources = Arrays.asList(
-          this.knownParameterValues.get("source").split(","));
-      if (sourceParameter != null) {
-        if (sourceParameter.length != 1) {
-          return null;
-        }
-        if (sourceParameter[0].length() == 0 ||
-            !knownSources.contains(sourceParameter[0])) {
-          return null;
-        }
-      } else {
-        sourceParameter = new String[] { "all" };
-      }
-      recognizedGraphParameters.put("source", sourceParameter);
-    }
-
-    /* Parse torperf file size if supported by the graph type. Only a
-     * single file size can be passed. If no file size is passed, use
-     * "50kb" as default. */
-    if (supportedGraphParameters.contains("filesize")) {
-      String[] filesizeParameter = (String[]) requestParameters.get(
-          "filesize");
-      List<String> knownFilesizes = Arrays.asList(
-          this.knownParameterValues.get("filesize").split(","));
-      if (filesizeParameter != null) {
-        if (filesizeParameter.length != 1) {
-          return null;
-        }
-        if (filesizeParameter[0].length() == 0 ||
-            !knownFilesizes.contains(filesizeParameter[0])) {
-          return null;
-        }
-      } else {
-        filesizeParameter = new String[] { "50kb" };
-      }
-      recognizedGraphParameters.put("filesize", filesizeParameter);
-    }
-
-    /* Parse transports if supported by the graph type. If no transports
-     * are passed, use "<OR>" as default. */
-    if (supportedGraphParameters.contains("transport")) {
-      String[] transportParameters = (String[]) requestParameters.get(
-          "transport");
-      List<String> knownTransports = Arrays.asList(
-          this.knownParameterValues.get("transport").split(","));
-      if (transportParameters != null) {
-        for (String transport : transportParameters) {
-          if (transport == null || transport.length() == 0 ||
-              !knownTransports.contains(transport)) {
-            return null;
-          }
-        }
-      } else {
-        transportParameters = new String[] { "<OR>" };
-      }
-      recognizedGraphParameters.put("transport", transportParameters);
-    }
-
-    /* Parse versions if supported by the graph type. If no versions
-     * are passed, use "v4" as default. */
-    if (supportedGraphParameters.contains("version")) {
-      String[] versionParameters = (String[]) requestParameters.get(
-          "version");
-      List<String> knownVersions = Arrays.asList(
-          this.knownParameterValues.get("version").split(","));
-      if (versionParameters != null) {
-        for (String version : versionParameters) {
-          if (version == null || version.length() == 0 ||
-              !knownVersions.contains(version)) {
-            return null;
-          }
-        }
-      } else {
-        versionParameters = new String[] { "v4" };
-      }
-      recognizedGraphParameters.put("version", versionParameters);
-    }
-
-    /* We now have a map with all required graph parameters. Return it. */
-    return recognizedGraphParameters;
-  }
-}
-
diff --git a/website/src/org/torproject/ernie/web/graphs/GraphsSubpagesServlet.java b/website/src/org/torproject/ernie/web/graphs/GraphsSubpagesServlet.java
deleted file mode 100644
index 94d7340..0000000
--- a/website/src/org/torproject/ernie/web/graphs/GraphsSubpagesServlet.java
+++ /dev/null
@@ -1,162 +0,0 @@
-/* Copyright 2011, 2012 The Tor Project
- * See LICENSE for licensing information */
-package org.torproject.ernie.web.graphs;
-
-import java.io.IOException;
-import java.text.SimpleDateFormat;
-import java.util.Arrays;
-import java.util.Date;
-import java.util.HashMap;
-import java.util.HashSet;
-import java.util.List;
-import java.util.Map;
-import java.util.Set;
-import java.util.TimeZone;
-
-import javax.servlet.ServletException;
-import javax.servlet.http.HttpServlet;
-import javax.servlet.http.HttpServletRequest;
-import javax.servlet.http.HttpServletResponse;
-
-public class GraphsSubpagesServlet extends HttpServlet {
-
-  private static final long serialVersionUID = -5959829347747628403L;
-
-  /* Available graphs subpages with corresponding JSP to which requests
-   * are forwarded. */
-  private Map<String, String> availableGraphsSubpages;
-
-  /* Available tables on graphs subpages. */
-  private Map<String, Set<String>> availableGraphsSubpageTables;
-
-  /* Country codes and names for per-country graphs. */
-  private List<String[]> knownCountries;
-
-  /* R object generator for generating table data. */
-  private RObjectGenerator rObjectGenerator;
-
-  public GraphsSubpagesServlet() {
-    this.availableGraphsSubpages = new HashMap<String, String>();
-    this.availableGraphsSubpages.put("network.html",
-        "WEB-INF/network.jsp");
-    this.availableGraphsSubpages.put("fast-exits.html",
-        "WEB-INF/fast-exits.jsp");
-    this.availableGraphsSubpages.put("users.html", "WEB-INF/users.jsp");
-    this.availableGraphsSubpages.put("performance.html",
-        "WEB-INF/performance.jsp");
-
-    this.availableGraphsSubpageTables =
-        new HashMap<String, Set<String>>();
-    this.availableGraphsSubpageTables.put("users.html",
-        new HashSet<String>(Arrays.asList((
-        "direct-users,censorship-events,bridge-users,userstats-relay,"
-        + "userstats-censorship-events,userstats-bridge").split(","))));
-
-    this.knownCountries = Countries.getInstance().getCountryList();
-  }
-
-  public void init() {
-    /* Get a reference to the R object generator that we need to generate
-     * table data. */
-    this.rObjectGenerator = (RObjectGenerator) getServletContext().
-        getAttribute("RObjectGenerator");
-  }
-
-  public void doGet(HttpServletRequest request,
-      HttpServletResponse response) throws IOException, ServletException {
-
-    /* Find out which graph subpage was requested and look up which JSP
-     * handles this subpage. */
-    String requestedPage = request.getRequestURI();
-    if (requestedPage == null) {
-      response.sendError(HttpServletResponse.SC_BAD_REQUEST);
-      return;
-    }
-    if (requestedPage.contains("/")) {
-      requestedPage = requestedPage.substring(requestedPage.
-          lastIndexOf("/") + 1);
-    }
-    if (!availableGraphsSubpages.containsKey(requestedPage)) {
-      response.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
-      return;
-    }
-    String jsp = availableGraphsSubpages.get(requestedPage);
-
-    /* Find out which graph or table type was requested, if any. */
-    String requestedGraph = request.getParameter("graph");
-    String requestedTable = request.getParameter("table");
-    if (requestedGraph != null) {
-
-      /* Check if the passed parameters are valid. */
-      Map<String, String[]> checkedParameters = GraphParameterChecker.
-          getInstance().checkParameters(requestedGraph,
-          request.getParameterMap());
-      if (checkedParameters != null) {
-
-        /* Set the graph's attributes to the appropriate values, so that
-         * we can display the correct graph and prepopulate the form. */
-        StringBuilder urlBuilder = new StringBuilder();
-        for (Map.Entry<String, String[]> param :
-            checkedParameters.entrySet()) {
-          request.setAttribute(requestedGraph.replaceAll("-", "_") + "_"
-              + param.getKey(), param.getValue());
-          for (String paramValue : param.getValue()) {
-            urlBuilder.append("&" + param.getKey() + "=" + paramValue);
-          }
-        }
-        String url = "?" + urlBuilder.toString().substring(1);
-        request.setAttribute(requestedGraph.replaceAll("-", "_") + "_url",
-            url);
-      }
-    }
-    if (requestedTable != null) {
-
-      /* Check if the passed parameters are valid. */
-      Map<String, String[]> checkedParameters = TableParameterChecker.
-          getInstance().checkParameters(requestedTable,
-          request.getParameterMap());
-      if (checkedParameters != null) {
-
-        /* Set the table's attributes to the appropriate values, so that
-         * we can prepopulate the form. */
-        for (Map.Entry<String, String[]> param :
-            checkedParameters.entrySet()) {
-          request.setAttribute(requestedTable.replaceAll("-", "_") + "_"
-              + param.getKey(), param.getValue());
-        }
-      }
-    }
-
-    /* Generate table data if the graphs subpage has any tables,
-     * regardless of whether a table update was requested, and add the
-     * table data as request attribute. */
-    if (this.availableGraphsSubpageTables.containsKey(requestedPage)) {
-      for (String tableName :
-          this.availableGraphsSubpageTables.get(requestedPage)) {
-        List<Map<String, String>> tableData = rObjectGenerator.
-            generateTable(tableName, requestedTable,
-            request.getParameterMap(), true);
-        request.setAttribute(tableName.replaceAll("-", "_")
-              + "_tabledata", tableData);
-      }
-    }
-
-    /* Pass list of known countries in case we want to display them. */
-    request.setAttribute("countries", this.knownCountries);
-
-    /* Pass the default start and end dates. */
-    SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd");
-    dateFormat.setTimeZone(TimeZone.getTimeZone("UTC"));
-    Date defaultEndDate = new Date();
-    Date defaultStartDate = new Date(defaultEndDate.getTime()
-        - 90L * 24L * 60L * 60L * 1000L);
-    request.setAttribute("default_start_date",
-        dateFormat.format(defaultStartDate));
-    request.setAttribute("default_end_date",
-        dateFormat.format(defaultEndDate));
-
-    /* Forward the request to the JSP that does all the hard work. */
-    request.getRequestDispatcher(jsp).forward(request, response);
-  }
-}
-
diff --git a/website/src/org/torproject/ernie/web/graphs/RObject.java b/website/src/org/torproject/ernie/web/graphs/RObject.java
deleted file mode 100644
index cfab819..0000000
--- a/website/src/org/torproject/ernie/web/graphs/RObject.java
+++ /dev/null
@@ -1,23 +0,0 @@
-/* Copyright 2011, 2012 The Tor Project
- * See LICENSE for licensing information */
-package org.torproject.ernie.web.graphs;
-
-public class RObject {
-  private byte[] bytes;
-  private String fileName;
-  private long lastModified;
-  public RObject(byte[] bytes, String fileName, long lastModified) {
-    this.bytes = bytes;
-    this.fileName = fileName;
-    this.lastModified = lastModified;
-  }
-  public String getFileName() {
-    return this.fileName;
-  }
-  public byte[] getBytes() {
-    return this.bytes;
-  }
-  public long getLastModified() {
-    return this.lastModified;
-  }
-}
diff --git a/website/src/org/torproject/ernie/web/graphs/RObjectGenerator.java b/website/src/org/torproject/ernie/web/graphs/RObjectGenerator.java
deleted file mode 100644
index 927b5c4..0000000
--- a/website/src/org/torproject/ernie/web/graphs/RObjectGenerator.java
+++ /dev/null
@@ -1,398 +0,0 @@
-/* Copyright 2011, 2012 The Tor Project
- * See LICENSE for licensing information */
-package org.torproject.ernie.web.graphs;
-
-import java.io.BufferedInputStream;
-import java.io.BufferedReader;
-import java.io.ByteArrayInputStream;
-import java.io.ByteArrayOutputStream;
-import java.io.File;
-import java.io.FileInputStream;
-import java.io.IOException;
-import java.io.InputStreamReader;
-import java.util.ArrayList;
-import java.util.Arrays;
-import java.util.HashMap;
-import java.util.HashSet;
-import java.util.List;
-import java.util.Map;
-import java.util.Set;
-import java.util.SortedSet;
-import java.util.TreeSet;
-
-import javax.servlet.ServletContext;
-import javax.servlet.ServletContextEvent;
-import javax.servlet.ServletContextListener;
-
-import org.rosuda.REngine.Rserve.RConnection;
-import org.rosuda.REngine.Rserve.RserveException;
-
-public class RObjectGenerator implements ServletContextListener {
-
-  /* Host and port where Rserve is listening. */
-  private String rserveHost;
-  private int rservePort;
-
-  /* Some parameters for our cache of graph images. */
-  private String cachedGraphsDirectory;
-  private long maxCacheAge;
-
-  private SortedSet<String> availableCsvFiles;
-  private Map<String, String> availableTables;
-  private Map<String, String> availableGraphs;
-  private Set<String> availableGraphFileTypes;
-
-  public void contextInitialized(ServletContextEvent event) {
-
-    /* Initialize using context parameters. */
-    ServletContext servletContext = event.getServletContext();
-    this.rserveHost = servletContext.getInitParameter("rserveHost");
-    this.rservePort = Integer.parseInt(servletContext.getInitParameter(
-        "rservePort"));
-    this.maxCacheAge = Long.parseLong(servletContext.getInitParameter(
-        "maxCacheAge"));
-    this.cachedGraphsDirectory = servletContext.getInitParameter(
-        "cachedGraphsDir");
-
-    /* Initialize map of available CSV files. */
-    this.availableCsvFiles = new TreeSet<String>();
-    this.availableCsvFiles.add("bandwidth");
-    this.availableCsvFiles.add("bandwidth-flags");
-    this.availableCsvFiles.add("bwhist-flags");
-    this.availableCsvFiles.add("connbidirect");
-    this.availableCsvFiles.add("cloudbridges");
-    this.availableCsvFiles.add("dirbytes");
-    this.availableCsvFiles.add("monthly-userstats-average");
-    this.availableCsvFiles.add("monthly-userstats-peak");
-    this.availableCsvFiles.add("networksize");
-    this.availableCsvFiles.add("platforms");
-    this.availableCsvFiles.add("relaycountries");
-    this.availableCsvFiles.add("relayflags");
-    this.availableCsvFiles.add("torperf");
-    this.availableCsvFiles.add("torperf-failures");
-    this.availableCsvFiles.add("userstats");
-    this.availableCsvFiles.add("userstats-detector");
-    this.availableCsvFiles.add("versions");
-
-    this.availableTables = new HashMap<String, String>();
-    this.availableTables.put("userstats-relay", "start,end,filename");
-    this.availableTables.put("userstats-bridge", "start,end,filename");
-    this.availableTables.put("userstats-censorship-events",
-        "start,end,filename");
-    TableParameterChecker.getInstance().setAvailableTables(
-        availableTables);
-
-    this.availableGraphs = new HashMap<String, String>();
-    this.availableGraphs.put("networksize", "start,end,filename");
-    this.availableGraphs.put("cloudbridges", "start,end,filename");
-    this.availableGraphs.put("relaycountries",
-        "start,end,country,filename");
-    this.availableGraphs.put("relayflags", "start,end,flag,filename");
-    this.availableGraphs.put("versions", "start,end,filename");
-    this.availableGraphs.put("platforms", "start,end,filename");
-    this.availableGraphs.put("bandwidth", "start,end,filename");
-    this.availableGraphs.put("bandwidth-flags", "start,end,filename");
-    this.availableGraphs.put("bwhist-flags", "start,end,filename");
-    this.availableGraphs.put("dirbytes", "start,end,filename");
-    this.availableGraphs.put("torperf",
-         "start,end,source,filesize,filename");
-    this.availableGraphs.put("torperf-failures",
-         "start,end,source,filesize,filename");
-    this.availableGraphs.put("connbidirect", "start,end,filename");
-    this.availableGraphs.put("fast-exits", "start,end,filename");
-    this.availableGraphs.put("almost-fast-exits", "start,end,filename");
-    this.availableGraphs.put("userstats-relay-country",
-        "start,end,country,events,filename");
-    this.availableGraphs.put("userstats-bridge-country",
-        "start,end,country,filename");
-    this.availableGraphs.put("userstats-bridge-transport",
-        "start,end,transport,filename");
-    this.availableGraphs.put("userstats-bridge-version",
-        "start,end,version,filename");
-    this.availableGraphFileTypes = new HashSet<String>(Arrays.asList(
-        "png,pdf,svg".split(",")));
-    GraphParameterChecker.getInstance().setAvailableGraphs(
-        availableGraphs);
-
-    /* Register ourself, so that servlets can use us. */
-    servletContext.setAttribute("RObjectGenerator", this);
-
-    /* Periodically generate R objects with default parameters. */
-    new Thread() {
-      public void run() {
-        long lastUpdated = 0L, sleep;
-        while (true) {
-          while ((sleep = maxCacheAge * 1000L / 2L + lastUpdated
-              - System.currentTimeMillis()) > 0L) {
-            try {
-              Thread.sleep(sleep);
-            } catch (InterruptedException e) {
-            }
-          }
-          for (String csvFile : availableCsvFiles) {
-            generateCsv(csvFile, false);
-          }
-          for (String tableName : availableTables.keySet()) {
-            generateTable(tableName, tableName, new HashMap(), false);
-          }
-          for (String graphName : availableGraphs.keySet()) {
-            for (String fileType : availableGraphFileTypes) {
-              generateGraph(graphName, fileType, new HashMap(), false);
-            }
-          }
-          lastUpdated = System.currentTimeMillis();
-        }
-      };
-    }.start();
-  }
-
-  public void contextDestroyed(ServletContextEvent event) {
-    /* Nothing to do. */
-  }
-
-  public RObject generateGraph(String requestedGraph, String fileType,
-      Map parameterMap, boolean checkCache) {
-    Map<String, String[]> checkedParameters = GraphParameterChecker.
-        getInstance().checkParameters(requestedGraph, parameterMap);
-    if (checkedParameters == null) {
-      /* TODO We're going to take the blame by sending an internal server
-       * error to the client, but really the user is to blame. */
-      return null;
-    }
-    StringBuilder rQueryBuilder = new StringBuilder("plot_"
-        + requestedGraph.replaceAll("-", "_") + "("),
-        imageFilenameBuilder = new StringBuilder(requestedGraph);
-    for (Map.Entry<String, String[]> parameter :
-        checkedParameters.entrySet()) {
-      String parameterName = parameter.getKey();
-      String[] parameterValues = parameter.getValue();
-      for (String param : parameterValues) {
-        imageFilenameBuilder.append("-" + param);
-      }
-      if (parameterValues.length < 2) {
-        rQueryBuilder.append(parameterName + " = '" + parameterValues[0]
-            + "', ");
-      } else {
-        rQueryBuilder.append(parameterName + " = c(");
-        for (int i = 0; i < parameterValues.length - 1; i++) {
-          rQueryBuilder.append("'" + parameterValues[i] + "', ");
-        }
-        rQueryBuilder.append("'" + parameterValues[
-            parameterValues.length - 1] + "'), ");
-      }
-    }
-    imageFilenameBuilder.append("." + fileType);
-    String imageFilename = imageFilenameBuilder.toString();
-    rQueryBuilder.append("path = '%s')");
-    String rQuery = rQueryBuilder.toString();
-    File imageFile = new File(this.cachedGraphsDirectory + "/"
-        + imageFilename);
-    return this.generateRObject(rQuery, imageFile, imageFilename,
-        checkCache);
-  }
-
-  public SortedSet<String> getAvailableCsvFiles() {
-    return this.availableCsvFiles;
-  }
-
-  public RObject generateCsv(String requestedCsvFile,
-      boolean checkCache) {
-    /* Prepare filename and R query string. */
-    String rQuery = "export_" + requestedCsvFile.replaceAll("-", "_")
-        + "(path = '%s')";
-    String csvFilename = requestedCsvFile + ".csv";
-
-    /* See if we need to generate this .csv file. */
-    File csvFile = new File(this.cachedGraphsDirectory + "/"
-        + csvFilename);
-    return this.generateRObject(rQuery, csvFile, csvFilename, checkCache);
-  }
-
-  public List<Map<String, String>> generateTable(String tableName,
-      String requestedTable, Map parameterMap, boolean checkCache) {
-
-    Map<String, String[]> checkedParameters = null;
-    if (tableName.equals(requestedTable)) {
-      checkedParameters = TableParameterChecker.
-          getInstance().checkParameters(requestedTable,
-          parameterMap);
-    } else {
-      checkedParameters = TableParameterChecker.
-          getInstance().checkParameters(tableName, null);
-    }
-    if (checkedParameters == null) {
-      /* TODO We're going to take the blame by sending an internal server
-       * error to the client, but really the user is to blame. */
-      return null;
-    }
-    StringBuilder rQueryBuilder = new StringBuilder("write_"
-        + tableName.replaceAll("-", "_") + "("),
-        tableFilenameBuilder = new StringBuilder(tableName);
-
-    for (Map.Entry<String, String[]> parameter :
-        checkedParameters.entrySet()) {
-      String parameterName = parameter.getKey();
-      String[] parameterValues = parameter.getValue();
-      for (String param : parameterValues) {
-        tableFilenameBuilder.append("-" + param);
-      }
-      if (parameterValues.length < 2) {
-        rQueryBuilder.append(parameterName + " = '"
-            + parameterValues[0] + "', ");
-      } else {
-        rQueryBuilder.append(parameterName + " = c(");
-        for (int i = 0; i < parameterValues.length - 1; i++) {
-          rQueryBuilder.append("'" + parameterValues[i] + "', ");
-        }
-        rQueryBuilder.append("'" + parameterValues[
-            parameterValues.length - 1] + "'), ");
-      }
-    }
-    tableFilenameBuilder.append(".tbl");
-    String tableFilename = tableFilenameBuilder.toString();
-    rQueryBuilder.append("path = '%s')");
-    String rQuery = rQueryBuilder.toString();
-    return this.generateTable(rQuery, tableFilename, checkCache);
-  }
-
-  /* Generate table data using the given R query and filename or read
-   * previously generated table data from disk if it's not too old and
-   * return table data. */
-  private List<Map<String, String>> generateTable(String rQuery,
-      String tableFilename, boolean checkCache) {
-
-    /* See if we need to generate this table. */
-    File tableFile = new File(this.cachedGraphsDirectory + "/"
-        + tableFilename);
-    byte[] tableBytes = this.generateRObject(rQuery, tableFile,
-        tableFilename, checkCache).getBytes();
-
-    /* Write the table content to a map. */
-    List<Map<String, String>> result = null;
-    try {
-      result = new ArrayList<Map<String, String>>();
-      BufferedReader br = new BufferedReader(new InputStreamReader(
-          new ByteArrayInputStream(tableBytes)));
-      String line = br.readLine();
-      if (line != null) {
-        List<String> headers = new ArrayList<String>(Arrays.asList(
-            line.split(",")));
-        while ((line = br.readLine()) != null) {
-          String[] parts = line.split(",");
-          if (headers.size() != parts.length) {
-            return null;
-          }
-          Map<String, String> row = new HashMap<String, String>();
-          for (int i = 0; i < headers.size(); i++) {
-            row.put(headers.get(i), parts[i]);
-          }
-          result.add(row);
-        }
-      }
-    } catch (IOException e) {
-      return null;
-    }
-
-    /* Return table values. */
-    return result;
-  }
-
-  /* Generate an R object in a separate worker thread, or wait for an
-   * already running worker thread to finish and get its result. */
-  private RObject generateRObject(String rQuery, File rObjectFile,
-      String fileName, boolean checkCache) {
-    RObjectGeneratorWorker worker = null;
-    synchronized (this.rObjectGeneratorThreads) {
-      if (this.rObjectGeneratorThreads.containsKey(rQuery)) {
-        worker = this.rObjectGeneratorThreads.get(rQuery);
-      } else {
-        worker = new RObjectGeneratorWorker(rQuery, rObjectFile,
-            fileName, checkCache);
-        this.rObjectGeneratorThreads.put(rQuery, worker);
-        worker.start();
-      }
-    }
-    try {
-      worker.join();
-    } catch (InterruptedException e) {
-    }
-    synchronized (this.rObjectGeneratorThreads) {
-      if (this.rObjectGeneratorThreads.containsKey(rQuery) &&
-          this.rObjectGeneratorThreads.get(rQuery) == worker) {
-        this.rObjectGeneratorThreads.remove(rQuery);
-      }
-    }
-    return worker.getRObject();
-  }
-
-  private Map<String, RObjectGeneratorWorker> rObjectGeneratorThreads =
-      new HashMap<String, RObjectGeneratorWorker>();
-
-  private class RObjectGeneratorWorker extends Thread {
-
-    private String rQuery;
-    private File rObjectFile;
-    private String fileName;
-    private boolean checkCache;
-    private RObject result = null;
-
-    public RObjectGeneratorWorker(String rQuery, File rObjectFile,
-        String fileName, boolean checkCache) {
-      this.rQuery = rQuery;
-      this.rObjectFile = rObjectFile;
-      this.fileName = fileName;
-      this.checkCache = checkCache;
-    }
-
-    public void run() {
-
-      /* See if we need to generate this R object. */
-      long now = System.currentTimeMillis();
-      if (!this.checkCache || !this.rObjectFile.exists() ||
-          this.rObjectFile.lastModified() < now - maxCacheAge * 1000L) {
-
-        /* We do. Update the R query to contain the absolute path to the
-         * file to be generated, create a connection to Rserve, run the R
-         * query, and close the connection. The generated object will be
-         * on disk. */
-        this.rQuery = String.format(this.rQuery,
-            this.rObjectFile.getAbsolutePath());
-        try {
-          RConnection rc = new RConnection(rserveHost, rservePort);
-          rc.eval(this.rQuery);
-          rc.close();
-        } catch (RserveException e) {
-          return;
-        }
-
-        /* Check that we really just generated the R object. */
-        if (!this.rObjectFile.exists() || this.rObjectFile.lastModified()
-            < now - maxCacheAge * 1000L) {
-          return;
-        }
-      }
-
-      /* Read the R object from disk and write it to a byte array. */
-      long lastModified = this.rObjectFile.lastModified();
-      try {
-        BufferedInputStream bis = new BufferedInputStream(
-            new FileInputStream(this.rObjectFile), 1024);
-        ByteArrayOutputStream baos = new ByteArrayOutputStream();
-        byte[] buffer = new byte[1024];
-        int length;
-        while ((length = bis.read(buffer)) > 0) {
-          baos.write(buffer, 0, length);
-        }
-        bis.close();
-        this.result = new RObject(baos.toByteArray(), this.fileName,
-            lastModified);
-      } catch (IOException e) {
-        return;
-      }
-    }
-
-    public RObject getRObject() {
-      return this.result;
-    }
-  }
-}
diff --git a/website/src/org/torproject/ernie/web/graphs/TableParameterChecker.java b/website/src/org/torproject/ernie/web/graphs/TableParameterChecker.java
deleted file mode 100644
index 02a0c85..0000000
--- a/website/src/org/torproject/ernie/web/graphs/TableParameterChecker.java
+++ /dev/null
@@ -1,120 +0,0 @@
-/* Copyright 2011, 2012 The Tor Project
- * See LICENSE for licensing information */
-package org.torproject.ernie.web.graphs;
-
-import java.text.ParseException;
-import java.text.SimpleDateFormat;
-import java.util.Arrays;
-import java.util.HashMap;
-import java.util.HashSet;
-import java.util.Map;
-import java.util.Set;
-import java.util.TimeZone;
-
-/**
- * Checks request parameters passed to generate tables.
- */
-public class TableParameterChecker {
-
-  /**
-   * Singleton instance of this class.
-   */
-  private static TableParameterChecker instance =
-      new TableParameterChecker();
-
-  /**
-   * Returns the singleton instance of this class.
-   */
-  public static TableParameterChecker getInstance() {
-    return instance;
-  }
-
-  /* Date format for parsing start and end dates. */
-  private SimpleDateFormat dateFormat;
-
-  /* Available tables with corresponding parameter lists. */
-  private Map<String, String> availableTables;
-
-  /**
-   * Initializes map with valid parameters for each of the graphs.
-   */
-  public TableParameterChecker() {
-    this.dateFormat = new SimpleDateFormat("yyyy-MM-dd");
-    this.dateFormat.setTimeZone(TimeZone.getTimeZone("UTC"));
-  }
-
-  public void setAvailableTables(Map<String, String> availableTables) {
-    this.availableTables = availableTables;
-  }
-
-  /**
-   * Checks request parameters for the given table type and returns a map
-   * of recognized parameters, or null if the table type doesn't exist or
-   * the parameters are invalid.
-   */
-  public Map<String, String[]> checkParameters(String tableType,
-      Map requestParameters) {
-
-    /* Check if the table type exists. */
-    if (tableType == null ||
-        !this.availableTables.containsKey(tableType)) {
-      return null;
-    }
-
-    /* Find out which other parameters are supported by this table type
-     * and parse them if they are given. */
-    Set<String> supportedTableParameters = new HashSet<String>(Arrays.
-        asList(this.availableTables.get(tableType).split(",")));
-    Map<String, String[]> recognizedTableParameters =
-        new HashMap<String, String[]>();
-
-    /* Parse start and end dates if supported by the table type. If no end
-     * date is provided, set it to today. If no start date is provided,
-     * set it to 90 days before the end date. Make sure that start date
-     * precedes end date. */
-    if (supportedTableParameters.contains("start") ||
-        supportedTableParameters.contains("end")) {
-      String[] startParameter = null;
-      String[] endParameter = null;
-      if (requestParameters != null) {
-        startParameter = (String[]) requestParameters.get("start");
-        endParameter = (String[]) requestParameters.get("end");
-      }
-      long endTimestamp = System.currentTimeMillis();
-      if (endParameter != null && endParameter.length > 0 &&
-          endParameter[0].length() > 0) {
-        try {
-          endTimestamp = dateFormat.parse(endParameter[0]).getTime();
-        } catch (ParseException e)  {
-          return null;
-        }
-        if (!endParameter[0].startsWith("20")) {
-          return null;
-        }
-      }
-      endParameter = new String[] { dateFormat.format(endTimestamp) };
-      long startTimestamp = endTimestamp - 90L * 24L * 60L * 60L * 1000L;
-      if (startParameter != null && startParameter.length > 0 &&
-          startParameter[0].length() > 0) {
-        try {
-          startTimestamp = dateFormat.parse(startParameter[0]).getTime();
-        } catch (ParseException e)  {
-          return null;
-        }
-        if (!startParameter[0].startsWith("20")) {
-          return null;
-        }
-      }
-      startParameter = new String[] { dateFormat.format(startTimestamp) };
-      if (startTimestamp > endTimestamp) {
-       return null;
-      }
-      recognizedTableParameters.put("start", startParameter);
-      recognizedTableParameters.put("end", endParameter);
-    }
-
-    /* We now have a map with all required table parameters. Return it. */
-    return recognizedTableParameters;
-  }
-}
-
diff --git a/website/src/org/torproject/ernie/web/research/ResearchDataServlet.java b/website/src/org/torproject/ernie/web/research/ResearchDataServlet.java
deleted file mode 100644
index 6c24e0d..0000000
--- a/website/src/org/torproject/ernie/web/research/ResearchDataServlet.java
+++ /dev/null
@@ -1,260 +0,0 @@
-/* Copyright 2011, 2012 The Tor Project
- * See LICENSE for licensing information */
-package org.torproject.ernie.web.research;
-
-import java.io.File;
-import java.io.IOException;
-import java.text.ParseException;
-import java.text.SimpleDateFormat;
-import java.util.ArrayList;
-import java.util.Arrays;
-import java.util.Date;
-import java.util.HashMap;
-import java.util.List;
-import java.util.Map;
-import java.util.SortedMap;
-import java.util.TreeMap;
-
-import javax.servlet.ServletException;
-import javax.servlet.http.HttpServlet;
-import javax.servlet.http.HttpServletRequest;
-import javax.servlet.http.HttpServletResponse;
-
-/**
- * Controller servlet for the Data page. Prepares the various lists of
- * downloadable metrics data files by parsing a file with URLs on other
- * servers and looking at a local directory with files served by local
- * Apache HTTP server. The file with URLs on other servers may contain
- * comment lines starting with #. Recognizes metrics data file types from
- * the file names.
- */
-public class ResearchDataServlet extends HttpServlet {
-
-  private static final long serialVersionUID = -5168280373350515577L;
-
-  public void doGet(HttpServletRequest request,
-      HttpServletResponse response) throws IOException, ServletException {
-
-    /* Read local directory with files served by the local Apache HTTP
-     * server and add the URLs to the list. */
-    List<String> dataFileUrls = new ArrayList<String>();
-    String localDataDir = getServletConfig().getInitParameter(
-        "localDataDir");
-    if (localDataDir != null) {
-      try {
-        File localDataDirFile = new File(localDataDir);
-        if (localDataDirFile.exists() && localDataDirFile.isDirectory()) {
-          for (File localDataFile : localDataDirFile.listFiles()) {
-            if (!localDataFile.isDirectory()) {
-              dataFileUrls.add("/data/" + localDataFile.getName());
-            }
-          }
-        }
-      } catch (SecurityException e) {
-        /* We're not permitted to read the directory with metrics data
-         * files. Ignore. */
-      }
-    }
-
-    /* Prepare data structures that we're going to pass to the JSP. All
-     * data structures are (nested) maps with the map keys being used for
-     * displaying the files in tables and map values being 2-element
-     * arrays containing the file url and optional signature file. */
-    SortedMap<Date, Map<String, String[]>> relayDescriptors =
-        new TreeMap<Date, Map<String, String[]>>(
-        java.util.Collections.reverseOrder());
-    String[] certs = new String[2];
-    SortedMap<Date, String[]> bridgeDescriptors =
-        new TreeMap<Date, String[]>(java.util.Collections.reverseOrder());
-    String[] relayStatistics = new String[2];
-    SortedMap<Date, String[]> torperfTarballs =
-        new TreeMap<Date, String[]>(java.util.Collections.reverseOrder());
-    SortedMap<String, Map<String, String[]>> torperfData =
-        new TreeMap<String, Map<String, String[]>>();
-    SortedMap<Date, String[]> exitLists =
-        new TreeMap<Date, String[]>(java.util.Collections.reverseOrder());
-    SortedMap<Date, String[]> torperfExperiments =
-        new TreeMap<Date, String[]>();
-    SortedMap<Date, String[]> bridgePoolAssignments =
-        new TreeMap<Date, String[]>(java.util.Collections.reverseOrder());
-
-    /* Prepare rewriting Torperf sources. */
-    Map<String, String> torperfSources = new HashMap<String, String>();
-    torperfSources.put("torperffast", "torperf, fastest");
-    torperfSources.put("torperffastratio", "torperf, best ratio");
-    torperfSources.put("torperfslow", "torperf, slowest");
-    torperfSources.put("torperfslowratio", "torperf, worst ratio");
-
-    /* Go through the file list, decide for each file what metrics data
-     * type it is, and put it in the appropriate map. */
-    SimpleDateFormat monthFormat = new SimpleDateFormat("yyyy-MM");
-    SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd");
-    List<String> torperfFilesizes = Arrays.asList("50kb,1mb,5mb".
-        split(","));
-    for (String url : dataFileUrls) {
-      if (!url.contains("/")) {
-        continue;
-      }
-      String filename = url.substring(url.lastIndexOf("/") + 1);
-
-      /* URL contains relay descriptors. */
-      if (filename.startsWith("tor-20") ||
-          filename.startsWith("statuses-20") ||
-          filename.startsWith("server-descriptors-20") ||
-          filename.startsWith("extra-infos-20") ||
-          filename.startsWith("votes-20") ||
-          filename.startsWith("consensuses-20")) {
-        String type = filename.substring(0, filename.indexOf("-20"));
-        String yearMonth = filename.substring(filename.indexOf("20"));
-        yearMonth = yearMonth.substring(0, 7);
-        Date month = null;
-        try {
-          month = monthFormat.parse(yearMonth);
-        } catch (ParseException e) {
-          /* Ignore this URL. */
-          continue;
-        }
-        int index = filename.endsWith(".asc") ? 1 : 0;
-        if (!relayDescriptors.containsKey(month)) {
-          relayDescriptors.put(month, new HashMap<String, String[]>());
-        }
-        if (!relayDescriptors.get(month).containsKey(type)) {
-          relayDescriptors.get(month).put(type, new String[2]);
-        }
-        relayDescriptors.get(month).get(type)[index] = url;
-
-      /* URL contains v3 certificates. */
-      } else if (filename.startsWith("certs.tar")) {
-        int index = filename.endsWith(".asc") ? 1 : 0;
-        certs[index] = url;
-
-      /* URL contains bridge descriptors. */
-      } else if (filename.startsWith("bridge-descriptors-20")) {
-        String yearMonth = filename.substring(filename.indexOf("20"));
-        yearMonth = yearMonth.substring(0, 7);
-        Date month = null;
-        try {
-          month = monthFormat.parse(yearMonth);
-        } catch (ParseException e) {
-          /* Ignore this URL. */
-          continue;
-        }
-        int index = filename.endsWith(".asc") ? 1 : 0;
-        if (!bridgeDescriptors.containsKey(month)) {
-          bridgeDescriptors.put(month, new String[2]);
-        }
-        bridgeDescriptors.get(month)[index] = url;
-
-      /* URL contains relay statistics. */
-      } else if (filename.startsWith("relay-statistics.tar.bz2")) {
-        int index = filename.endsWith(".asc") ? 1 : 0;
-        relayStatistics[index] = url;
-
-      /* URL contains Torperf tarball. */
-      } else if (filename.startsWith("torperf-20")) {
-        String yearMonth = filename.substring(filename.indexOf("20"));
-        yearMonth = yearMonth.substring(0, 7);
-        Date month = null;
-        try {
-          month = monthFormat.parse(yearMonth);
-        } catch (ParseException e) {
-          /* Ignore this URL. */
-          continue;
-        }
-        if (!torperfTarballs.containsKey(month)) {
-          torperfTarballs.put(month, new String[2]);
-        }
-        torperfTarballs.get(month)[0] = url;
-
-      /* URL contains Torperf data file. */
-      } else if (filename.endsWith("b.data") ||
-          filename.endsWith("b.extradata")) {
-        boolean isExtraData = filename.endsWith("b.extradata");
-        String[] parts = filename.split("-");
-        if (parts.length != 2) {
-          continue;
-        }
-        String source = parts[0];
-        if (torperfSources.containsKey(source)) {
-          source = torperfSources.get(source);
-        }
-        String filesize = parts[1];
-        filesize = filesize.substring(0, filesize.length()
-            - (isExtraData ? 10 : 5));
-        if (!torperfFilesizes.contains(filesize)) {
-          continue;
-        }
-        if (!torperfData.containsKey(source)) {
-          torperfData.put(source, new HashMap<String, String[]>());
-        }
-        if (!torperfData.get(source).containsKey(filesize)) {
-          torperfData.get(source).put(filesize, new String[2]);
-        }
-        torperfData.get(source).get(filesize)[isExtraData ? 1 : 0] = url;
-
-      /* URL contains Torperf experiment tarball. */
-      } else if (filename.startsWith("torperf-experiment-20")) {
-        String dateString = filename.substring(filename.indexOf("20"));
-        dateString = dateString.substring(0, 10);
-        Date date = null;
-        try {
-          date = dateFormat.parse(dateString);
-        } catch (ParseException e) {
-          /* Ignore this URL. */
-          continue;
-        }
-        if (!torperfExperiments.containsKey(date)) {
-          torperfExperiments.put(date, new String[2]);
-        }
-        torperfExperiments.get(date)[0] = url;
-
-      /* URL contains exit list. */
-      } else if (filename.startsWith("exit-list-20")) {
-        String yearMonth = filename.substring(filename.indexOf("20"));
-        yearMonth = yearMonth.substring(0, 7);
-        Date month = null;
-        try {
-          month = monthFormat.parse(yearMonth);
-        } catch (ParseException e) {
-          /* Ignore this URL. */
-          continue;
-        }
-        if (!exitLists.containsKey(month)) {
-          exitLists.put(month, new String[2]);
-        }
-        exitLists.get(month)[0] = url;
-
-      /* URL contains bridge pool assignments. */
-      } else if (filename.startsWith("bridge-pool-assignments-20")) {
-        String yearMonth = filename.substring(filename.indexOf("20"));
-        yearMonth = yearMonth.substring(0, 7);
-        Date month = null;
-        try {
-          month = monthFormat.parse(yearMonth);
-        } catch (ParseException e) {
-          /* Ignore this URL. */
-          continue;
-        }
-        if (!bridgePoolAssignments.containsKey(month)) {
-          bridgePoolAssignments.put(month, new String[2]);
-        }
-        bridgePoolAssignments.get(month)[0] = url;
-      }
-    }
-
-    /* Add the maps to the request and forward it to the JSP to display
-     * the page. */
-    request.setAttribute("relayDescriptors", relayDescriptors);
-    request.setAttribute("certs", certs);
-    request.setAttribute("bridgeDescriptors", bridgeDescriptors);
-    request.setAttribute("relayStatistics", relayStatistics);
-    request.setAttribute("torperfData", torperfData);
-    request.setAttribute("exitLists", exitLists);
-    request.setAttribute("torperfTarballs", torperfTarballs);
-    request.setAttribute("torperfExperiments", torperfExperiments);
-    request.setAttribute("bridgePoolAssignments", bridgePoolAssignments);
-    request.getRequestDispatcher("WEB-INF/data.jsp").forward(request,
-        response);
-  }
-}
-
diff --git a/website/src/org/torproject/ernie/web/research/ResearchFormatsServlet.java b/website/src/org/torproject/ernie/web/research/ResearchFormatsServlet.java
deleted file mode 100644
index 3b70ca3..0000000
--- a/website/src/org/torproject/ernie/web/research/ResearchFormatsServlet.java
+++ /dev/null
@@ -1,24 +0,0 @@
-/* Copyright 2011, 2012 The Tor Project
- * See LICENSE for licensing information */
-package org.torproject.ernie.web.research;
-
-import java.io.IOException;
-
-import javax.servlet.ServletException;
-import javax.servlet.http.HttpServlet;
-import javax.servlet.http.HttpServletRequest;
-import javax.servlet.http.HttpServletResponse;
-
-public class ResearchFormatsServlet extends HttpServlet {
-
-  private static final long serialVersionUID = 5666493868675314116L;
-
-  public void doGet(HttpServletRequest request,
-      HttpServletResponse response) throws IOException, ServletException {
-
-    /* Forward the request to the JSP that does all the hard work. */
-    request.getRequestDispatcher("WEB-INF/formats.jsp").forward(request,
-        response);
-  }
-}
-
diff --git a/website/src/org/torproject/ernie/web/research/ResearchPapersServlet.java b/website/src/org/torproject/ernie/web/research/ResearchPapersServlet.java
deleted file mode 100644
index a63eef0..0000000
--- a/website/src/org/torproject/ernie/web/research/ResearchPapersServlet.java
+++ /dev/null
@@ -1,24 +0,0 @@
-/* Copyright 2011, 2012 The Tor Project
- * See LICENSE for licensing information */
-package org.torproject.ernie.web.research;
-
-import java.io.IOException;
-
-import javax.servlet.ServletException;
-import javax.servlet.http.HttpServlet;
-import javax.servlet.http.HttpServletRequest;
-import javax.servlet.http.HttpServletResponse;
-
-public class ResearchPapersServlet extends HttpServlet {
-
-  private static final long serialVersionUID = -8135459207158536268L;
-
-  public void doGet(HttpServletRequest request,
-      HttpServletResponse response) throws IOException, ServletException {
-
-    /* Forward the request to the JSP that does all the hard work. */
-    request.getRequestDispatcher("WEB-INF/papers.jsp").forward(request,
-        response);
-  }
-}
-
diff --git a/website/src/org/torproject/ernie/web/research/ResearchStatsServlet.java b/website/src/org/torproject/ernie/web/research/ResearchStatsServlet.java
deleted file mode 100644
index 2ca93a3..0000000
--- a/website/src/org/torproject/ernie/web/research/ResearchStatsServlet.java
+++ /dev/null
@@ -1,132 +0,0 @@
-/* Copyright 2013 The Tor Project
- * See LICENSE for licensing information */
-package org.torproject.ernie.web.research;
-
-import java.io.BufferedInputStream;
-import java.io.ByteArrayOutputStream;
-import java.io.File;
-import java.io.FileInputStream;
-import java.io.IOException;
-import java.util.SortedSet;
-import java.util.TreeSet;
-
-import javax.servlet.ServletConfig;
-import javax.servlet.ServletException;
-import javax.servlet.http.HttpServlet;
-import javax.servlet.http.HttpServletRequest;
-import javax.servlet.http.HttpServletResponse;
-
-public class ResearchStatsServlet extends HttpServlet {
-
-  private static final long serialVersionUID = 3346710354297653810L;
-
-  private File statsDir;
-
-  private SortedSet<String> availableStatisticsFiles;
-
-  public void init(ServletConfig config) throws ServletException {
-    super.init(config);
-    this.statsDir = new File(config.getInitParameter("statsDir"));
-    this.availableStatisticsFiles = new TreeSet<String>();
-    this.availableStatisticsFiles.add("servers");
-    this.availableStatisticsFiles.add("bandwidth");
-    this.availableStatisticsFiles.add("fast-exits");
-    this.availableStatisticsFiles.add("clients");
-    this.availableStatisticsFiles.add("torperf");
-    this.availableStatisticsFiles.add("connbidirect");
-  }
-
-  public long getLastModified(HttpServletRequest request) {
-    File statsFile = this.determineStatsFile(request);
-    if (statsFile == null || !statsFile.exists()) {
-      return -1L;
-    } else {
-      return statsFile.lastModified();
-    }
-  }
-
-  public void doGet(HttpServletRequest request,
-      HttpServletResponse response) throws IOException, ServletException {
-    String requestURI = request.getRequestURI();
-    if (requestURI.equals("/ernie/stats/")) {
-      this.writeDirectoryListing(request, response);
-    } else if (requestURI.equals("/ernie/stats.html")) {
-      this.writeStatisticsPage(request, response);
-    } else {
-      File statsFile = this.determineStatsFile(request);
-      if (statsFile == null) {
-        response.sendError(HttpServletResponse.SC_NOT_FOUND);
-        return;
-      } else if (!this.writeStatsFile(statsFile, response)) {
-        response.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
-      }
-    }
-  }
-
-  private void writeDirectoryListing(HttpServletRequest request,
-      HttpServletResponse response) throws IOException, ServletException {
-    request.setAttribute("directory", "/stats");
-    request.setAttribute("extension", ".csv");
-    request.setAttribute("files", this.availableStatisticsFiles);
-    request.getRequestDispatcher("/WEB-INF/dir.jsp").forward(request,
-        response);
-  }
-
-  private void writeStatisticsPage(HttpServletRequest request,
-      HttpServletResponse response) throws IOException, ServletException {
-    request.getRequestDispatcher("/WEB-INF/stats.jsp").forward(request,
-        response);
-  }
-
-  private File determineStatsFile(HttpServletRequest request) {
-    String requestedStatsFile = request.getRequestURI();
-    if (requestedStatsFile.equals("/ernie/stats/") ||
-        requestedStatsFile.equals("/ernie/stats.html")) {
-      return null;
-    }
-    if (requestedStatsFile.endsWith(".csv")) {
-      requestedStatsFile = requestedStatsFile.substring(0,
-          requestedStatsFile.length() - ".csv".length());
-    }
-    if (requestedStatsFile.contains("/")) {
-      requestedStatsFile = requestedStatsFile.substring(
-          requestedStatsFile.lastIndexOf("/") + 1);
-    }
-    if (!availableStatisticsFiles.contains(requestedStatsFile)) {
-      return null;
-    } else {
-      return new File(this.statsDir, requestedStatsFile + ".csv");
-    }
-  }
-
-  private boolean writeStatsFile(File statsFile,
-      HttpServletResponse response) throws IOException, ServletException {
-    if (!statsFile.exists()) {
-      return false;
-    }
-    byte[] statsFileBytes;
-    try {
-      BufferedInputStream bis = new BufferedInputStream(
-          new FileInputStream(statsFile), 1024);
-      ByteArrayOutputStream baos = new ByteArrayOutputStream();
-      byte[] buffer = new byte[1024];
-      int length;
-      while ((length = bis.read(buffer)) > 0) {
-        baos.write(buffer, 0, length);
-      }
-      bis.close();
-      statsFileBytes = baos.toByteArray();
-    } catch (IOException e) {
-      return false;
-    }
-    String statsFileContent = new String(statsFileBytes);
-    response.setContentType("text/csv");
-    response.setHeader("Content-Length", String.valueOf(
-        statsFileContent.length()));
-    response.setHeader("Content-Disposition",
-        "inline; filename=\"" + statsFile.getName() + "\"");
-    response.getWriter().print(statsFileContent);
-    return true;
-  }
-}
-
diff --git a/website/src/org/torproject/ernie/web/research/ResearchToolsServlet.java b/website/src/org/torproject/ernie/web/research/ResearchToolsServlet.java
deleted file mode 100644
index 173a1da..0000000
--- a/website/src/org/torproject/ernie/web/research/ResearchToolsServlet.java
+++ /dev/null
@@ -1,24 +0,0 @@
-/* Copyright 2011, 2012 The Tor Project
- * See LICENSE for licensing information */
-package org.torproject.ernie.web.research;
-
-import java.io.IOException;
-
-import javax.servlet.ServletException;
-import javax.servlet.http.HttpServlet;
-import javax.servlet.http.HttpServletRequest;
-import javax.servlet.http.HttpServletResponse;
-
-public class ResearchToolsServlet extends HttpServlet {
-
-  private static final long serialVersionUID = -3344204426180358872L;
-
-  public void doGet(HttpServletRequest request,
-      HttpServletResponse response) throws IOException, ServletException {
-
-    /* Forward the request to the JSP that does all the hard work. */
-    request.getRequestDispatcher("WEB-INF/tools.jsp").forward(request,
-        response);
-  }
-}
-
diff --git a/website/src/org/torproject/metrics/web/GraphsServlet.java b/website/src/org/torproject/metrics/web/GraphsServlet.java
new file mode 100644
index 0000000..eb5eaf4
--- /dev/null
+++ b/website/src/org/torproject/metrics/web/GraphsServlet.java
@@ -0,0 +1,24 @@
+/* Copyright 2011, 2012 The Tor Project
+ * See LICENSE for licensing information */
+package org.torproject.metrics.web;
+
+import java.io.IOException;
+
+import javax.servlet.ServletException;
+import javax.servlet.http.HttpServlet;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+
+public class GraphsServlet extends HttpServlet {
+
+  private static final long serialVersionUID = 7615715032362498151L;
+
+  public void doGet(HttpServletRequest request,
+      HttpServletResponse response) throws IOException, ServletException {
+
+    /* Forward the request to the JSP that does all the hard work. */
+    request.getRequestDispatcher("WEB-INF/graphs.jsp").forward(request,
+        response);
+  }
+}
+
diff --git a/website/src/org/torproject/metrics/web/IndexServlet.java b/website/src/org/torproject/metrics/web/IndexServlet.java
new file mode 100644
index 0000000..c42b0e9
--- /dev/null
+++ b/website/src/org/torproject/metrics/web/IndexServlet.java
@@ -0,0 +1,24 @@
+/* Copyright 2011, 2012 The Tor Project
+ * See LICENSE for licensing information */
+package org.torproject.metrics.web;
+
+import java.io.IOException;
+
+import javax.servlet.ServletException;
+import javax.servlet.http.HttpServlet;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+
+public class IndexServlet extends HttpServlet {
+
+  private static final long serialVersionUID = 7871368999788994664L;
+
+  public void doGet(HttpServletRequest request,
+      HttpServletResponse response) throws IOException, ServletException {
+
+    /* Forward the request to the JSP that does all the hard work. */
+    request.getRequestDispatcher("WEB-INF/index.jsp").forward(request,
+        response);
+  }
+}
+
diff --git a/website/src/org/torproject/metrics/web/ResearchServlet.java b/website/src/org/torproject/metrics/web/ResearchServlet.java
new file mode 100644
index 0000000..f69c45d
--- /dev/null
+++ b/website/src/org/torproject/metrics/web/ResearchServlet.java
@@ -0,0 +1,24 @@
+/* Copyright 2011, 2012 The Tor Project
+ * See LICENSE for licensing information */
+package org.torproject.metrics.web;
+
+import java.io.IOException;
+
+import javax.servlet.ServletException;
+import javax.servlet.http.HttpServlet;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+
+public class ResearchServlet extends HttpServlet {
+
+  private static final long serialVersionUID = -9151727188925700665L;
+
+  public void doGet(HttpServletRequest request,
+      HttpServletResponse response) throws IOException, ServletException {
+
+    /* Forward the request to the JSP that does all the hard work. */
+    request.getRequestDispatcher("WEB-INF/research.jsp").forward(request,
+        response);
+  }
+}
+
diff --git a/website/src/org/torproject/metrics/web/StatusServlet.java b/website/src/org/torproject/metrics/web/StatusServlet.java
new file mode 100644
index 0000000..ec831df
--- /dev/null
+++ b/website/src/org/torproject/metrics/web/StatusServlet.java
@@ -0,0 +1,24 @@
+/* Copyright 2011, 2012 The Tor Project
+ * See LICENSE for licensing information */
+package org.torproject.metrics.web;
+
+import java.io.IOException;
+
+import javax.servlet.ServletException;
+import javax.servlet.http.HttpServlet;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+
+public class StatusServlet extends HttpServlet {
+
+  private static final long serialVersionUID = -7249872082399236981L;
+
+  public void doGet(HttpServletRequest request,
+      HttpServletResponse response) throws IOException, ServletException {
+
+    /* Forward the request to the JSP that does all the hard work. */
+    request.getRequestDispatcher("WEB-INF/status.jsp").forward(request,
+        response);
+  }
+}
+
diff --git a/website/src/org/torproject/metrics/web/graphs/BubblesServlet.java b/website/src/org/torproject/metrics/web/graphs/BubblesServlet.java
new file mode 100644
index 0000000..f273194
--- /dev/null
+++ b/website/src/org/torproject/metrics/web/graphs/BubblesServlet.java
@@ -0,0 +1,24 @@
+/* Copyright 2013 The Tor Project
+ * See LICENSE for licensing information */
+package org.torproject.metrics.web.graphs;
+
+import java.io.IOException;
+
+import javax.servlet.ServletException;
+import javax.servlet.http.HttpServlet;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+
+public class BubblesServlet extends HttpServlet {
+
+  private static final long serialVersionUID = -6011833075497881033L;
+
+  public void doGet(HttpServletRequest request,
+      HttpServletResponse response) throws IOException, ServletException {
+
+    /* Forward the request to the JSP that does all the hard work. */
+    request.getRequestDispatcher("WEB-INF/bubbles.jsp").forward(request,
+        response);
+  }
+}
+
diff --git a/website/src/org/torproject/metrics/web/graphs/Countries.java b/website/src/org/torproject/metrics/web/graphs/Countries.java
new file mode 100644
index 0000000..7a99a46
--- /dev/null
+++ b/website/src/org/torproject/metrics/web/graphs/Countries.java
@@ -0,0 +1,285 @@
+/* Copyright 2011, 2012 The Tor Project
+ * See LICENSE for licensing information */
+package org.torproject.metrics.web.graphs;
+
+import java.util.ArrayList;
+import java.util.List;
+
+public class Countries {
+
+  private static Countries instance = new Countries();
+
+  public static Countries getInstance() {
+    return Countries.instance;
+  }
+
+  /* List of arrays of length 2, containing country codes at [0] and
+   * country names at [1], alphabetically ordered by country names. */
+  private List<String[]> knownCountries;
+
+  private Countries() {
+    this.knownCountries = new ArrayList<String[]>();
+    this.knownCountries.add("af;Afghanistan".split(";"));
+    this.knownCountries.add("ax;Aland Islands".split(";"));
+    this.knownCountries.add("al;Albania".split(";"));
+    this.knownCountries.add("dz;Algeria".split(";"));
+    this.knownCountries.add("as;American Samoa".split(";"));
+    this.knownCountries.add("ad;Andorra".split(";"));
+    this.knownCountries.add("ao;Angola".split(";"));
+    this.knownCountries.add("ai;Anguilla".split(";"));
+    this.knownCountries.add("aq;Antarctica".split(";"));
+    this.knownCountries.add("ag;Antigua and Barbuda".split(";"));
+    this.knownCountries.add("ar;Argentina".split(";"));
+    this.knownCountries.add("am;Armenia".split(";"));
+    this.knownCountries.add("aw;Aruba".split(";"));
+    this.knownCountries.add("au;Australia".split(";"));
+    this.knownCountries.add("at;Austria".split(";"));
+    this.knownCountries.add("az;Azerbaijan".split(";"));
+    this.knownCountries.add("bs;Bahamas".split(";"));
+    this.knownCountries.add("bh;Bahrain".split(";"));
+    this.knownCountries.add("bd;Bangladesh".split(";"));
+    this.knownCountries.add("bb;Barbados".split(";"));
+    this.knownCountries.add("by;Belarus".split(";"));
+    this.knownCountries.add("be;Belgium".split(";"));
+    this.knownCountries.add("bz;Belize".split(";"));
+    this.knownCountries.add("bj;Benin".split(";"));
+    this.knownCountries.add("bm;Bermuda".split(";"));
+    this.knownCountries.add("bt;Bhutan".split(";"));
+    this.knownCountries.add("bo;Bolivia".split(";"));
+    this.knownCountries.add("ba;Bosnia and Herzegovina".split(";"));
+    this.knownCountries.add("bw;Botswana".split(";"));
+    this.knownCountries.add("bv;Bouvet Island".split(";"));
+    this.knownCountries.add("br;Brazil".split(";"));
+    this.knownCountries.add("io;British Indian Ocean Territory".
+        split(";"));
+    this.knownCountries.add("bn;Brunei".split(";"));
+    this.knownCountries.add("bg;Bulgaria".split(";"));
+    this.knownCountries.add("bf;Burkina Faso".split(";"));
+    this.knownCountries.add("mm;Burma".split(";"));
+    this.knownCountries.add("bi;Burundi".split(";"));
+    this.knownCountries.add("kh;Cambodia".split(";"));
+    this.knownCountries.add("cm;Cameroon".split(";"));
+    this.knownCountries.add("ca;Canada".split(";"));
+    this.knownCountries.add("cv;Cape Verde".split(";"));
+    this.knownCountries.add("ky;Cayman Islands".split(";"));
+    this.knownCountries.add("cf;Central African Republic".split(";"));
+    this.knownCountries.add("td;Chad".split(";"));
+    this.knownCountries.add("cl;Chile".split(";"));
+    this.knownCountries.add("cn;China".split(";"));
+    this.knownCountries.add("cx;Christmas Island".split(";"));
+    this.knownCountries.add("cc;Cocos (Keeling) Islands".split(";"));
+    this.knownCountries.add("co;Colombia".split(";"));
+    this.knownCountries.add("km;Comoros".split(";"));
+    this.knownCountries.add("cd;Congo, The Democratic Republic of the".
+        split(";"));
+    this.knownCountries.add("cg;Congo".split(";"));
+    this.knownCountries.add("ck;Cook Islands".split(";"));
+    this.knownCountries.add("cr;Costa Rica".split(";"));
+    this.knownCountries.add("ci:Côte d'Ivoire".split(":"));
+    this.knownCountries.add("hr;Croatia".split(";"));
+    this.knownCountries.add("cu;Cuba".split(";"));
+    this.knownCountries.add("cy;Cyprus".split(";"));
+    this.knownCountries.add("cz;Czech Republic".split(";"));
+    this.knownCountries.add("dk;Denmark".split(";"));
+    this.knownCountries.add("dj;Djibouti".split(";"));
+    this.knownCountries.add("dm;Dominica".split(";"));
+    this.knownCountries.add("do;Dominican Republic".split(";"));
+    this.knownCountries.add("ec;Ecuador".split(";"));
+    this.knownCountries.add("eg;Egypt".split(";"));
+    this.knownCountries.add("sv;El Salvador".split(";"));
+    this.knownCountries.add("gq;Equatorial Guinea".split(";"));
+    this.knownCountries.add("er;Eritrea".split(";"));
+    this.knownCountries.add("ee;Estonia".split(";"));
+    this.knownCountries.add("et;Ethiopia".split(";"));
+    this.knownCountries.add("fk;Falkland Islands (Malvinas)".split(";"));
+    this.knownCountries.add("fo;Faroe Islands".split(";"));
+    this.knownCountries.add("fj;Fiji".split(";"));
+    this.knownCountries.add("fi;Finland".split(";"));
+    this.knownCountries.add("fx;France, Metropolitan".split(";"));
+    this.knownCountries.add("fr;France".split(";"));
+    this.knownCountries.add("gf;French Guiana".split(";"));
+    this.knownCountries.add("pf;French Polynesia".split(";"));
+    this.knownCountries.add("tf;French Southern Territories".split(";"));
+    this.knownCountries.add("ga;Gabon".split(";"));
+    this.knownCountries.add("gm;Gambia".split(";"));
+    this.knownCountries.add("ge;Georgia".split(";"));
+    this.knownCountries.add("de;Germany".split(";"));
+    this.knownCountries.add("gh;Ghana".split(";"));
+    this.knownCountries.add("gi;Gibraltar".split(";"));
+    this.knownCountries.add("gr;Greece".split(";"));
+    this.knownCountries.add("gl;Greenland".split(";"));
+    this.knownCountries.add("gd;Grenada".split(";"));
+    this.knownCountries.add("gp;Guadeloupe".split(";"));
+    this.knownCountries.add("gu;Guam".split(";"));
+    this.knownCountries.add("gt;Guatemala".split(";"));
+    this.knownCountries.add("gg;Guernsey".split(";"));
+    this.knownCountries.add("gn;Guinea".split(";"));
+    this.knownCountries.add("gw;Guinea-Bissau".split(";"));
+    this.knownCountries.add("gy;Guyana".split(";"));
+    this.knownCountries.add("ht;Haiti".split(";"));
+    this.knownCountries.add("hm;Heard Island and McDonald Islands".
+        split(";"));
+    this.knownCountries.add("va;Vatican City".split(";"));
+    this.knownCountries.add("hn;Honduras".split(";"));
+    this.knownCountries.add("hk;Hong Kong".split(";"));
+    this.knownCountries.add("hu;Hungary".split(";"));
+    this.knownCountries.add("is;Iceland".split(";"));
+    this.knownCountries.add("in;India".split(";"));
+    this.knownCountries.add("id;Indonesia".split(";"));
+    this.knownCountries.add("ir;Iran".split(";"));
+    this.knownCountries.add("iq;Iraq".split(";"));
+    this.knownCountries.add("ie;Ireland".split(";"));
+    this.knownCountries.add("im;Isle of Man".split(";"));
+    this.knownCountries.add("il;Israel".split(";"));
+    this.knownCountries.add("it;Italy".split(";"));
+    this.knownCountries.add("jm;Jamaica".split(";"));
+    this.knownCountries.add("jp;Japan".split(";"));
+    this.knownCountries.add("je;Jersey".split(";"));
+    this.knownCountries.add("jo;Jordan".split(";"));
+    this.knownCountries.add("kz;Kazakhstan".split(";"));
+    this.knownCountries.add("ke;Kenya".split(";"));
+    this.knownCountries.add("ki;Kiribati".split(";"));
+    this.knownCountries.add("kp;North Korea".split(";"));
+    this.knownCountries.add("kr;Korea, Republic of".split(";"));
+    this.knownCountries.add("kw;Kuwait".split(";"));
+    this.knownCountries.add("kg;Kyrgyzstan".split(";"));
+    this.knownCountries.add("la;Laos".split(";"));
+    this.knownCountries.add("lv;Latvia".split(";"));
+    this.knownCountries.add("lb;Lebanon".split(";"));
+    this.knownCountries.add("ls;Lesotho".split(";"));
+    this.knownCountries.add("lr;Liberia".split(";"));
+    this.knownCountries.add("ly;Libya".split(";"));
+    this.knownCountries.add("li;Liechtenstein".split(";"));
+    this.knownCountries.add("lt;Lithuania".split(";"));
+    this.knownCountries.add("lu;Luxembourg".split(";"));
+    this.knownCountries.add("mo;Macau".split(";"));
+    this.knownCountries.add("mk;Macedonia".split(";"));
+    this.knownCountries.add("mg;Madagascar".split(";"));
+    this.knownCountries.add("mw;Malawi".split(";"));
+    this.knownCountries.add("my;Malaysia".split(";"));
+    this.knownCountries.add("mv;Maldives".split(";"));
+    this.knownCountries.add("ml;Mali".split(";"));
+    this.knownCountries.add("mt;Malta".split(";"));
+    this.knownCountries.add("mh;Marshall Islands".split(";"));
+    this.knownCountries.add("mq;Martinique".split(";"));
+    this.knownCountries.add("mr;Mauritania".split(";"));
+    this.knownCountries.add("mu;Mauritius".split(";"));
+    this.knownCountries.add("yt;Mayotte".split(";"));
+    this.knownCountries.add("mx;Mexico".split(";"));
+    this.knownCountries.add("fm;Micronesia, Federated States of".
+        split(";"));
+    this.knownCountries.add("md;Moldova, Republic of".split(";"));
+    this.knownCountries.add("mc;Monaco".split(";"));
+    this.knownCountries.add("mn;Mongolia".split(";"));
+    this.knownCountries.add("me;Montenegro".split(";"));
+    this.knownCountries.add("ms;Montserrat".split(";"));
+    this.knownCountries.add("ma;Morocco".split(";"));
+    this.knownCountries.add("mz;Mozambique".split(";"));
+    this.knownCountries.add("mm;Burma".split(";"));
+    this.knownCountries.add("na;Namibia".split(";"));
+    this.knownCountries.add("nr;Nauru".split(";"));
+    this.knownCountries.add("np;Nepal".split(";"));
+    this.knownCountries.add("an;Netherlands Antilles".split(";"));
+    this.knownCountries.add("nl;Netherlands".split(";"));
+    this.knownCountries.add("nc;New Caledonia".split(";"));
+    this.knownCountries.add("nz;New Zealand".split(";"));
+    this.knownCountries.add("ni;Nicaragua".split(";"));
+    this.knownCountries.add("ne;Niger".split(";"));
+    this.knownCountries.add("ng;Nigeria".split(";"));
+    this.knownCountries.add("nu;Niue".split(";"));
+    this.knownCountries.add("nf;Norfolk Island".split(";"));
+    this.knownCountries.add("mp;Northern Mariana Islands".split(";"));
+    this.knownCountries.add("no;Norway".split(";"));
+    this.knownCountries.add("om;Oman".split(";"));
+    this.knownCountries.add("pk;Pakistan".split(";"));
+    this.knownCountries.add("pw;Palau".split(";"));
+    this.knownCountries.add("ps;Palestinian Territory".split(";"));
+    this.knownCountries.add("pa;Panama".split(";"));
+    this.knownCountries.add("pg;Papua New Guinea".split(";"));
+    this.knownCountries.add("py;Paraguay".split(";"));
+    this.knownCountries.add("pe;Peru".split(";"));
+    this.knownCountries.add("ph;Philippines".split(";"));
+    this.knownCountries.add("pn;Pitcairn Islands".split(";"));
+    this.knownCountries.add("pl;Poland".split(";"));
+    this.knownCountries.add("pt;Portugal".split(";"));
+    this.knownCountries.add("pr;Puerto Rico".split(";"));
+    this.knownCountries.add("qa;Qatar".split(";"));
+    this.knownCountries.add("re;Reunion".split(";"));
+    this.knownCountries.add("ro;Romania".split(";"));
+    this.knownCountries.add("ru;Russia".split(";"));
+    this.knownCountries.add("rw;Rwanda".split(";"));
+    this.knownCountries.add("bl;Saint Bartelemey".split(";"));
+    this.knownCountries.add("sh;Saint Helena".split(";"));
+    this.knownCountries.add("kn;Saint Kitts and Nevis".split(";"));
+    this.knownCountries.add("lc;Saint Lucia".split(";"));
+    this.knownCountries.add("mf;Saint Martin".split(";"));
+    this.knownCountries.add("pm;Saint Pierre and Miquelon".split(";"));
+    this.knownCountries.add("vc;Saint Vincent and the Grenadines".
+        split(";"));
+    this.knownCountries.add("ws;Samoa".split(";"));
+    this.knownCountries.add("sm;San Marino".split(";"));
+    this.knownCountries.add("st:São Tomé and Príncipe".
+        split(":"));
+    this.knownCountries.add("sa;Saudi Arabia".split(";"));
+    this.knownCountries.add("sn;Senegal".split(";"));
+    this.knownCountries.add("rs;Serbia".split(";"));
+    this.knownCountries.add("sc;Seychelles".split(";"));
+    this.knownCountries.add("sl;Sierra Leone".split(";"));
+    this.knownCountries.add("sg;Singapore".split(";"));
+    this.knownCountries.add("sk;Slovakia".split(";"));
+    this.knownCountries.add("si;Slovenia".split(";"));
+    this.knownCountries.add("sb;Solomon Islands".split(";"));
+    this.knownCountries.add("so;Somalia".split(";"));
+    this.knownCountries.add("za;South Africa".split(";"));
+    this.knownCountries.add(("gs;South Georgia and the South Sandwich "
+        + "Islands").split(";"));
+    this.knownCountries.add("ss;South Sudan".split(";"));
+    this.knownCountries.add("es;Spain".split(";"));
+    this.knownCountries.add("lk;Sri Lanka".split(";"));
+    this.knownCountries.add("sd;Sudan".split(";"));
+    this.knownCountries.add("sr;Suriname".split(";"));
+    this.knownCountries.add("sj;Svalbard and Jan Mayen".split(";"));
+    this.knownCountries.add("sz;Swaziland".split(";"));
+    this.knownCountries.add("se;Sweden".split(";"));
+    this.knownCountries.add("ch;Switzerland".split(";"));
+    this.knownCountries.add("sy;Syrian Arab Republic".split(";"));
+    this.knownCountries.add("tw;Taiwan".split(";"));
+    this.knownCountries.add("tj;Tajikistan".split(";"));
+    this.knownCountries.add("tz;Tanzania, United Republic of".split(";"));
+    this.knownCountries.add("th;Thailand".split(";"));
+    this.knownCountries.add("tl;East Timor".split(";"));
+    this.knownCountries.add("tg;Togo".split(";"));
+    this.knownCountries.add("tk;Tokelau".split(";"));
+    this.knownCountries.add("to;Tonga".split(";"));
+    this.knownCountries.add("tt;Trinidad and Tobago".split(";"));
+    this.knownCountries.add("tn;Tunisia".split(";"));
+    this.knownCountries.add("tr;Turkey".split(";"));
+    this.knownCountries.add("tm;Turkmenistan".split(";"));
+    this.knownCountries.add("tc;Turks and Caicos Islands".split(";"));
+    this.knownCountries.add("tv;Tuvalu".split(";"));
+    this.knownCountries.add("ug;Uganda".split(";"));
+    this.knownCountries.add("ua;Ukraine".split(";"));
+    this.knownCountries.add("ae;United Arab Emirates".split(";"));
+    this.knownCountries.add("gb;United Kingdom".split(";"));
+    this.knownCountries.add("um;United States Minor Outlying Islands".
+        split(";"));
+    this.knownCountries.add("us;United States".split(";"));
+    this.knownCountries.add("uy;Uruguay".split(";"));
+    this.knownCountries.add("uz;Uzbekistan".split(";"));
+    this.knownCountries.add("vu;Vanuatu".split(";"));
+    this.knownCountries.add("ve;Venezuela".split(";"));
+    this.knownCountries.add("vn;Vietnam".split(";"));
+    this.knownCountries.add("vg;Virgin Islands, British".split(";"));
+    this.knownCountries.add("vi;Virgin Islands, U.S.".split(";"));
+    this.knownCountries.add("wf;Wallis and Futuna".split(";"));
+    this.knownCountries.add("eh;Western Sahara".split(";"));
+    this.knownCountries.add("ye;Yemen".split(";"));
+    this.knownCountries.add("zm;Zambia".split(";"));
+    this.knownCountries.add("zw;Zimbabwe".split(";"));
+  }
+
+  public List<String[]> getCountryList() {
+    return this.knownCountries;
+  }
+}
+
diff --git a/website/src/org/torproject/metrics/web/graphs/CsvServlet.java b/website/src/org/torproject/metrics/web/graphs/CsvServlet.java
new file mode 100644
index 0000000..96e5f6a
--- /dev/null
+++ b/website/src/org/torproject/metrics/web/graphs/CsvServlet.java
@@ -0,0 +1,97 @@
+/* Copyright 2011, 2012 The Tor Project
+ * See LICENSE for licensing information */
+package org.torproject.metrics.web.graphs;
+
+import java.io.IOException;
+import java.util.SortedSet;
+import java.util.logging.Logger;
+
+import javax.servlet.ServletException;
+import javax.servlet.http.HttpServlet;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+
+/**
+ * Servlet that reads an HTTP request for a comma-separated value file,
+ * asks the GraphGenerator to generate this file, and returns it to the
+ * client.
+ */
+public class CsvServlet extends HttpServlet {
+
+  private static final long serialVersionUID = 7501442926823719958L;
+
+  private RObjectGenerator rObjectGenerator;
+
+  /* Available CSV files. */
+  private SortedSet<String> availableCsvFiles;
+
+  private Logger logger;
+
+  public void init() {
+
+    /* Initialize logger. */
+    this.logger = Logger.getLogger(CsvServlet.class.toString());
+
+    /* Get a reference to the R object generator that we need to generate
+     * CSV files. */
+    this.rObjectGenerator = (RObjectGenerator) getServletContext().
+        getAttribute("RObjectGenerator");
+    this.availableCsvFiles = rObjectGenerator.getAvailableCsvFiles();
+  }
+
+  public void doGet(HttpServletRequest request,
+      HttpServletResponse response) throws IOException,
+      ServletException {
+
+    /* Check if the directory listing was requested. */
+    String requestURI = request.getRequestURI();
+    if (requestURI.equals("/ernie/csv/")) {
+      request.setAttribute("directory", "/csv");
+      request.setAttribute("extension", ".csv");
+      request.setAttribute("files", this.availableCsvFiles);
+      request.getRequestDispatcher("/WEB-INF/dir.jsp").forward(request,
+          response);
+      return;
+    }
+
+    /* Find out which CSV file was requested and make sure we know this
+     * CSV file type. */
+    String requestedCsvFile = requestURI;
+    if (requestedCsvFile.endsWith(".csv")) {
+      requestedCsvFile = requestedCsvFile.substring(0,
+          requestedCsvFile.length() - ".csv".length());
+    }
+    if (requestedCsvFile.contains("/")) {
+      requestedCsvFile = requestedCsvFile.substring(requestedCsvFile.
+          lastIndexOf("/") + 1);
+    }
+    if (!availableCsvFiles.contains(requestedCsvFile)) {
+      logger.info("Did not recognize requested .csv file from request "
+          + "URI: '" + requestURI + "'. Responding with 404 Not Found.");
+      response.sendError(HttpServletResponse.SC_NOT_FOUND);
+      return;
+    }
+    logger.fine("CSV file '" + requestedCsvFile + ".csv' requested.");
+
+    /* Request CSV file from R object generator, which asks Rserve to
+     * generate it. */
+    RObject csvFile = this.rObjectGenerator.generateCsv(
+        requestedCsvFile, true);
+
+    /* Make sure that we have a .csv file to return. */
+    if (csvFile == null) {
+      response.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
+      return;
+    }
+
+    /* Write CSV file to response. */
+    String csvFileContent = new String(csvFile.getBytes());
+    response.setContentType("text/csv");
+    response.setHeader("Content-Length", String.valueOf(
+        csvFileContent.length()));
+    response.setHeader("Content-Disposition",
+        "inline; filename=\"" + requestedCsvFile + ".csv\"");
+    response.getWriter().print(csvFileContent);
+  }
+}
+
diff --git a/website/src/org/torproject/metrics/web/graphs/GraphImageServlet.java b/website/src/org/torproject/metrics/web/graphs/GraphImageServlet.java
new file mode 100644
index 0000000..08f256a
--- /dev/null
+++ b/website/src/org/torproject/metrics/web/graphs/GraphImageServlet.java
@@ -0,0 +1,76 @@
+/* Copyright 2011, 2012 The Tor Project
+ * See LICENSE for licensing information */
+package org.torproject.metrics.web.graphs;
+
+import java.io.BufferedOutputStream;
+import java.io.IOException;
+
+import javax.servlet.ServletException;
+import javax.servlet.http.HttpServlet;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+
+/**
+ * Servlet that reads an HTTP request for a graph image, asks the
+ * RObjectGenerator to generate this graph if it's not in the cache, and
+ * returns the image bytes to the client.
+ */
+public class GraphImageServlet extends HttpServlet {
+
+  private static final long serialVersionUID = -7356818641689744288L;
+
+  private RObjectGenerator rObjectGenerator;
+
+  public void init() {
+
+    /* Get a reference to the R object generator that we need to generate
+     * graph images. */
+    this.rObjectGenerator = (RObjectGenerator) getServletContext().
+        getAttribute("RObjectGenerator");
+  }
+
+  public void doGet(HttpServletRequest request,
+      HttpServletResponse response) throws IOException,
+      ServletException {
+
+    /* Find out which graph type was requested and make sure we know this
+     * graph type and file type. */
+    String requestedGraph = request.getRequestURI();
+    String fileType = null;
+    if (requestedGraph.endsWith(".png") ||
+        requestedGraph.endsWith(".pdf") ||
+        requestedGraph.endsWith(".svg")) {
+      fileType = requestedGraph.substring(requestedGraph.length() - 3);
+      requestedGraph = requestedGraph.substring(0, requestedGraph.length()
+          - 4);
+    }
+    if (requestedGraph.contains("/")) {
+      requestedGraph = requestedGraph.substring(requestedGraph.
+          lastIndexOf("/") + 1);
+    }
+
+    /* Request graph from R object generator, which either returns it from
+     * its cache or asks Rserve to generate it. */
+    RObject graph = rObjectGenerator.generateGraph(requestedGraph,
+        fileType, request.getParameterMap(), true);
+
+    /* Make sure that we have a graph to return. */
+    if (graph == null || graph.getBytes() == null || fileType == null) {
+      response.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
+      return;
+    }
+
+    /* Write graph bytes to response. */
+    BufferedOutputStream output = null;
+    response.setContentType("image/" + fileType);
+    response.setHeader("Content-Length",
+        String.valueOf(graph.getBytes().length));
+    response.setHeader("Content-Disposition",
+        "inline; filename=\"" + graph.getFileName() + "\"");
+    output = new BufferedOutputStream(response.getOutputStream(), 1024);
+    output.write(graph.getBytes(), 0, graph.getBytes().length);
+    output.flush();
+    output.close();
+  }
+}
+
diff --git a/website/src/org/torproject/metrics/web/graphs/GraphParameterChecker.java b/website/src/org/torproject/metrics/web/graphs/GraphParameterChecker.java
new file mode 100644
index 0000000..098d908
--- /dev/null
+++ b/website/src/org/torproject/metrics/web/graphs/GraphParameterChecker.java
@@ -0,0 +1,280 @@
+/* Copyright 2011, 2012 The Tor Project
+ * See LICENSE for licensing information */
+package org.torproject.metrics.web.graphs;
+
+import java.text.ParseException;
+import java.text.SimpleDateFormat;
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.TimeZone;
+import java.util.regex.Pattern;
+
+/**
+ * Checks request parameters passed to graph-generating servlets.
+ */
+public class GraphParameterChecker {
+
+  /**
+   * Singleton instance of this class.
+   */
+  private static GraphParameterChecker instance =
+      new GraphParameterChecker();
+
+  /**
+   * Returns the singleton instance of this class.
+   */
+  public static GraphParameterChecker getInstance() {
+    return instance;
+  }
+
+  /* Date format for parsing start and end dates. */
+  private SimpleDateFormat dateFormat;
+
+  /* Available graphs with corresponding parameter lists. */
+  private Map<String, String> availableGraphs;
+
+  /* Known parameters and parameter values. */
+  private Map<String, String> knownParameterValues;
+
+  /**
+   * Initializes map with valid parameters for each of the graphs.
+   */
+  public GraphParameterChecker() {
+    this.dateFormat = new SimpleDateFormat("yyyy-MM-dd");
+    this.dateFormat.setTimeZone(TimeZone.getTimeZone("UTC"));
+
+    this.knownParameterValues = new HashMap<String, String>();
+    this.knownParameterValues.put("flag",
+        "Running,Exit,Guard,Fast,Stable,HSDir");
+    StringBuilder sb = new StringBuilder("all");
+    for (String[] country : Countries.getInstance().getCountryList()) {
+      sb.append("," + country[0]);
+    }
+    this.knownParameterValues.put("country", sb.toString());
+    this.knownParameterValues.put("events", "on,off,points");
+    this.knownParameterValues.put("source", "all,siv,moria,torperf");
+    this.knownParameterValues.put("filesize", "50kb,1mb,5mb");
+    this.knownParameterValues.put("transport",
+        "obfs2,obfs3,websocket,<OR>,<??>");
+    this.knownParameterValues.put("version", "v4,v6");
+  }
+
+  public void setAvailableGraphs(Map<String, String> availableGraphs) {
+    this.availableGraphs = availableGraphs;
+  }
+
+  /**
+   * Checks request parameters for the given graph type and returns a map
+   * of recognized parameters, or null if the graph type doesn't exist or
+   * the parameters are invalid.
+   */
+  public Map<String, String[]> checkParameters(String graphType,
+      Map requestParameters) {
+
+    /* Check if the graph type exists. */
+    if (graphType == null ||
+        !this.availableGraphs.containsKey(graphType)) {
+      return null;
+    }
+
+    /* Find out which other parameters are supported by this graph type
+     * and parse them if they are given. */
+    Set<String> supportedGraphParameters = new HashSet<String>(Arrays.
+        asList(this.availableGraphs.get(graphType).split(",")));
+    Map<String, String[]> recognizedGraphParameters =
+        new HashMap<String, String[]>();
+
+    /* Parse start and end dates if supported by the graph type. If no end
+     * date is provided, set it to today. If no start date is provided,
+     * set it to 90 days before the end date. Make sure that start date
+     * precedes end date. */
+    if (supportedGraphParameters.contains("start") ||
+        supportedGraphParameters.contains("end")) {
+      String[] startParameter = (String[]) requestParameters.get("start");
+      String[] endParameter = (String[]) requestParameters.get("end");
+      long endTimestamp = System.currentTimeMillis();
+      if (endParameter != null && endParameter.length > 0 &&
+          endParameter[0].length() > 0) {
+        try {
+          endTimestamp = dateFormat.parse(endParameter[0]).getTime();
+        } catch (ParseException e)  {
+          return null;
+        }
+        if (!endParameter[0].startsWith("20")) {
+          return null;
+        }
+      }
+      endParameter = new String[] { dateFormat.format(endTimestamp) };
+      long startTimestamp = endTimestamp - 90L * 24L * 60L * 60L * 1000L;
+      if (startParameter != null && startParameter.length > 0 &&
+          startParameter[0].length() > 0) {
+        try {
+          startTimestamp = dateFormat.parse(startParameter[0]).getTime();
+        } catch (ParseException e)  {
+          return null;
+        }
+        if (!startParameter[0].startsWith("20")) {
+          return null;
+        }
+      }
+      startParameter = new String[] { dateFormat.format(startTimestamp) };
+      if (startTimestamp > endTimestamp) {
+       return null;
+      }
+      recognizedGraphParameters.put("start", startParameter);
+      recognizedGraphParameters.put("end", endParameter);
+    }
+
+    /* Parse relay flags if supported by the graph type. If no relay flags
+     * are passed or none of them have been recognized, use the set of all
+     * known flags as default. */
+    if (supportedGraphParameters.contains("flag")) {
+      String[] flagParameters = (String[]) requestParameters.get("flag");
+      List<String> knownFlags = Arrays.asList(
+          this.knownParameterValues.get("flag").split(","));
+      if (flagParameters != null) {
+        for (String flag : flagParameters) {
+          if (flag == null || flag.length() == 0 ||
+              !knownFlags.contains(flag)) {
+            return null;
+          }
+        }
+      } else {
+        flagParameters = "Running,Exit,Guard,Fast,Stable".split(",");
+      }
+      recognizedGraphParameters.put("flag", flagParameters);
+    }
+
+    /* Parse country codes if supported by the graph type. If no countries
+     * are passed, use country code "all" (all countries) as default. */
+    if (supportedGraphParameters.contains("country")) {
+      String[] countryParameters = (String[]) requestParameters.get(
+          "country");
+      List<String> knownCountries = Arrays.asList(
+          this.knownParameterValues.get("country").split(","));
+      if (countryParameters != null) {
+        for (String country : countryParameters) {
+          if (country == null || country.length() == 0 ||
+              !knownCountries.contains(country)) {
+            return null;
+          }
+        }
+      } else {
+        countryParameters = new String[] { "all" };
+      }
+      recognizedGraphParameters.put("country", countryParameters);
+    }
+
+    /* Parse whether the estimated min/max range shall be displayed if
+     * supported by the graph type. This parameter can either be "on" or
+     * "off," where "off" is the default. */
+    if (supportedGraphParameters.contains("events")) {
+      String[] eventsParameter = (String[]) requestParameters.get(
+          "events");
+      List<String> knownRanges = Arrays.asList(
+          this.knownParameterValues.get("events").split(","));
+      if (eventsParameter != null) {
+        if (eventsParameter.length != 1 ||
+            eventsParameter[0].length() == 0 ||
+            !knownRanges.contains(eventsParameter[0])) {
+          return null;
+        }
+      } else {
+        eventsParameter = new String[] { "off" };
+      }
+      recognizedGraphParameters.put("events", eventsParameter);
+    }
+
+    /* Parse torperf data source if supported by the graph type. Only a
+     * single source can be passed. If no source is passed, use "torperf"
+     * as default. */
+    if (supportedGraphParameters.contains("source")) {
+      String[] sourceParameter = (String[]) requestParameters.get(
+          "source");
+      List<String> knownSources = Arrays.asList(
+          this.knownParameterValues.get("source").split(","));
+      if (sourceParameter != null) {
+        if (sourceParameter.length != 1) {
+          return null;
+        }
+        if (sourceParameter[0].length() == 0 ||
+            !knownSources.contains(sourceParameter[0])) {
+          return null;
+        }
+      } else {
+        sourceParameter = new String[] { "all" };
+      }
+      recognizedGraphParameters.put("source", sourceParameter);
+    }
+
+    /* Parse torperf file size if supported by the graph type. Only a
+     * single file size can be passed. If no file size is passed, use
+     * "50kb" as default. */
+    if (supportedGraphParameters.contains("filesize")) {
+      String[] filesizeParameter = (String[]) requestParameters.get(
+          "filesize");
+      List<String> knownFilesizes = Arrays.asList(
+          this.knownParameterValues.get("filesize").split(","));
+      if (filesizeParameter != null) {
+        if (filesizeParameter.length != 1) {
+          return null;
+        }
+        if (filesizeParameter[0].length() == 0 ||
+            !knownFilesizes.contains(filesizeParameter[0])) {
+          return null;
+        }
+      } else {
+        filesizeParameter = new String[] { "50kb" };
+      }
+      recognizedGraphParameters.put("filesize", filesizeParameter);
+    }
+
+    /* Parse transports if supported by the graph type. If no transports
+     * are passed, use "<OR>" as default. */
+    if (supportedGraphParameters.contains("transport")) {
+      String[] transportParameters = (String[]) requestParameters.get(
+          "transport");
+      List<String> knownTransports = Arrays.asList(
+          this.knownParameterValues.get("transport").split(","));
+      if (transportParameters != null) {
+        for (String transport : transportParameters) {
+          if (transport == null || transport.length() == 0 ||
+              !knownTransports.contains(transport)) {
+            return null;
+          }
+        }
+      } else {
+        transportParameters = new String[] { "<OR>" };
+      }
+      recognizedGraphParameters.put("transport", transportParameters);
+    }
+
+    /* Parse versions if supported by the graph type. If no versions
+     * are passed, use "v4" as default. */
+    if (supportedGraphParameters.contains("version")) {
+      String[] versionParameters = (String[]) requestParameters.get(
+          "version");
+      List<String> knownVersions = Arrays.asList(
+          this.knownParameterValues.get("version").split(","));
+      if (versionParameters != null) {
+        for (String version : versionParameters) {
+          if (version == null || version.length() == 0 ||
+              !knownVersions.contains(version)) {
+            return null;
+          }
+        }
+      } else {
+        versionParameters = new String[] { "v4" };
+      }
+      recognizedGraphParameters.put("version", versionParameters);
+    }
+
+    /* We now have a map with all required graph parameters. Return it. */
+    return recognizedGraphParameters;
+  }
+}
+
diff --git a/website/src/org/torproject/metrics/web/graphs/GraphsSubpagesServlet.java b/website/src/org/torproject/metrics/web/graphs/GraphsSubpagesServlet.java
new file mode 100644
index 0000000..35a6a7a
--- /dev/null
+++ b/website/src/org/torproject/metrics/web/graphs/GraphsSubpagesServlet.java
@@ -0,0 +1,162 @@
+/* Copyright 2011, 2012 The Tor Project
+ * See LICENSE for licensing information */
+package org.torproject.metrics.web.graphs;
+
+import java.io.IOException;
+import java.text.SimpleDateFormat;
+import java.util.Arrays;
+import java.util.Date;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.TimeZone;
+
+import javax.servlet.ServletException;
+import javax.servlet.http.HttpServlet;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+
+public class GraphsSubpagesServlet extends HttpServlet {
+
+  private static final long serialVersionUID = -5959829347747628403L;
+
+  /* Available graphs subpages with corresponding JSP to which requests
+   * are forwarded. */
+  private Map<String, String> availableGraphsSubpages;
+
+  /* Available tables on graphs subpages. */
+  private Map<String, Set<String>> availableGraphsSubpageTables;
+
+  /* Country codes and names for per-country graphs. */
+  private List<String[]> knownCountries;
+
+  /* R object generator for generating table data. */
+  private RObjectGenerator rObjectGenerator;
+
+  public GraphsSubpagesServlet() {
+    this.availableGraphsSubpages = new HashMap<String, String>();
+    this.availableGraphsSubpages.put("network.html",
+        "WEB-INF/network.jsp");
+    this.availableGraphsSubpages.put("fast-exits.html",
+        "WEB-INF/fast-exits.jsp");
+    this.availableGraphsSubpages.put("users.html", "WEB-INF/users.jsp");
+    this.availableGraphsSubpages.put("performance.html",
+        "WEB-INF/performance.jsp");
+
+    this.availableGraphsSubpageTables =
+        new HashMap<String, Set<String>>();
+    this.availableGraphsSubpageTables.put("users.html",
+        new HashSet<String>(Arrays.asList((
+        "direct-users,censorship-events,bridge-users,userstats-relay,"
+        + "userstats-censorship-events,userstats-bridge").split(","))));
+
+    this.knownCountries = Countries.getInstance().getCountryList();
+  }
+
+  public void init() {
+    /* Get a reference to the R object generator that we need to generate
+     * table data. */
+    this.rObjectGenerator = (RObjectGenerator) getServletContext().
+        getAttribute("RObjectGenerator");
+  }
+
+  public void doGet(HttpServletRequest request,
+      HttpServletResponse response) throws IOException, ServletException {
+
+    /* Find out which graph subpage was requested and look up which JSP
+     * handles this subpage. */
+    String requestedPage = request.getRequestURI();
+    if (requestedPage == null) {
+      response.sendError(HttpServletResponse.SC_BAD_REQUEST);
+      return;
+    }
+    if (requestedPage.contains("/")) {
+      requestedPage = requestedPage.substring(requestedPage.
+          lastIndexOf("/") + 1);
+    }
+    if (!availableGraphsSubpages.containsKey(requestedPage)) {
+      response.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
+      return;
+    }
+    String jsp = availableGraphsSubpages.get(requestedPage);
+
+    /* Find out which graph or table type was requested, if any. */
+    String requestedGraph = request.getParameter("graph");
+    String requestedTable = request.getParameter("table");
+    if (requestedGraph != null) {
+
+      /* Check if the passed parameters are valid. */
+      Map<String, String[]> checkedParameters = GraphParameterChecker.
+          getInstance().checkParameters(requestedGraph,
+          request.getParameterMap());
+      if (checkedParameters != null) {
+
+        /* Set the graph's attributes to the appropriate values, so that
+         * we can display the correct graph and prepopulate the form. */
+        StringBuilder urlBuilder = new StringBuilder();
+        for (Map.Entry<String, String[]> param :
+            checkedParameters.entrySet()) {
+          request.setAttribute(requestedGraph.replaceAll("-", "_") + "_"
+              + param.getKey(), param.getValue());
+          for (String paramValue : param.getValue()) {
+            urlBuilder.append("&" + param.getKey() + "=" + paramValue);
+          }
+        }
+        String url = "?" + urlBuilder.toString().substring(1);
+        request.setAttribute(requestedGraph.replaceAll("-", "_") + "_url",
+            url);
+      }
+    }
+    if (requestedTable != null) {
+
+      /* Check if the passed parameters are valid. */
+      Map<String, String[]> checkedParameters = TableParameterChecker.
+          getInstance().checkParameters(requestedTable,
+          request.getParameterMap());
+      if (checkedParameters != null) {
+
+        /* Set the table's attributes to the appropriate values, so that
+         * we can prepopulate the form. */
+        for (Map.Entry<String, String[]> param :
+            checkedParameters.entrySet()) {
+          request.setAttribute(requestedTable.replaceAll("-", "_") + "_"
+              + param.getKey(), param.getValue());
+        }
+      }
+    }
+
+    /* Generate table data if the graphs subpage has any tables,
+     * regardless of whether a table update was requested, and add the
+     * table data as request attribute. */
+    if (this.availableGraphsSubpageTables.containsKey(requestedPage)) {
+      for (String tableName :
+          this.availableGraphsSubpageTables.get(requestedPage)) {
+        List<Map<String, String>> tableData = rObjectGenerator.
+            generateTable(tableName, requestedTable,
+            request.getParameterMap(), true);
+        request.setAttribute(tableName.replaceAll("-", "_")
+              + "_tabledata", tableData);
+      }
+    }
+
+    /* Pass list of known countries in case we want to display them. */
+    request.setAttribute("countries", this.knownCountries);
+
+    /* Pass the default start and end dates. */
+    SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd");
+    dateFormat.setTimeZone(TimeZone.getTimeZone("UTC"));
+    Date defaultEndDate = new Date();
+    Date defaultStartDate = new Date(defaultEndDate.getTime()
+        - 90L * 24L * 60L * 60L * 1000L);
+    request.setAttribute("default_start_date",
+        dateFormat.format(defaultStartDate));
+    request.setAttribute("default_end_date",
+        dateFormat.format(defaultEndDate));
+
+    /* Forward the request to the JSP that does all the hard work. */
+    request.getRequestDispatcher(jsp).forward(request, response);
+  }
+}
+
diff --git a/website/src/org/torproject/metrics/web/graphs/RObject.java b/website/src/org/torproject/metrics/web/graphs/RObject.java
new file mode 100644
index 0000000..db8f362
--- /dev/null
+++ b/website/src/org/torproject/metrics/web/graphs/RObject.java
@@ -0,0 +1,23 @@
+/* Copyright 2011, 2012 The Tor Project
+ * See LICENSE for licensing information */
+package org.torproject.metrics.web.graphs;
+
+public class RObject {
+  private byte[] bytes;
+  private String fileName;
+  private long lastModified;
+  public RObject(byte[] bytes, String fileName, long lastModified) {
+    this.bytes = bytes;
+    this.fileName = fileName;
+    this.lastModified = lastModified;
+  }
+  public String getFileName() {
+    return this.fileName;
+  }
+  public byte[] getBytes() {
+    return this.bytes;
+  }
+  public long getLastModified() {
+    return this.lastModified;
+  }
+}
diff --git a/website/src/org/torproject/metrics/web/graphs/RObjectGenerator.java b/website/src/org/torproject/metrics/web/graphs/RObjectGenerator.java
new file mode 100644
index 0000000..8b64ff7
--- /dev/null
+++ b/website/src/org/torproject/metrics/web/graphs/RObjectGenerator.java
@@ -0,0 +1,398 @@
+/* Copyright 2011, 2012 The Tor Project
+ * See LICENSE for licensing information */
+package org.torproject.metrics.web.graphs;
+
+import java.io.BufferedInputStream;
+import java.io.BufferedReader;
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.IOException;
+import java.io.InputStreamReader;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.SortedSet;
+import java.util.TreeSet;
+
+import javax.servlet.ServletContext;
+import javax.servlet.ServletContextEvent;
+import javax.servlet.ServletContextListener;
+
+import org.rosuda.REngine.Rserve.RConnection;
+import org.rosuda.REngine.Rserve.RserveException;
+
+public class RObjectGenerator implements ServletContextListener {
+
+  /* Host and port where Rserve is listening. */
+  private String rserveHost;
+  private int rservePort;
+
+  /* Some parameters for our cache of graph images. */
+  private String cachedGraphsDirectory;
+  private long maxCacheAge;
+
+  private SortedSet<String> availableCsvFiles;
+  private Map<String, String> availableTables;
+  private Map<String, String> availableGraphs;
+  private Set<String> availableGraphFileTypes;
+
+  public void contextInitialized(ServletContextEvent event) {
+
+    /* Initialize using context parameters. */
+    ServletContext servletContext = event.getServletContext();
+    this.rserveHost = servletContext.getInitParameter("rserveHost");
+    this.rservePort = Integer.parseInt(servletContext.getInitParameter(
+        "rservePort"));
+    this.maxCacheAge = Long.parseLong(servletContext.getInitParameter(
+        "maxCacheAge"));
+    this.cachedGraphsDirectory = servletContext.getInitParameter(
+        "cachedGraphsDir");
+
+    /* Initialize map of available CSV files. */
+    this.availableCsvFiles = new TreeSet<String>();
+    this.availableCsvFiles.add("bandwidth");
+    this.availableCsvFiles.add("bandwidth-flags");
+    this.availableCsvFiles.add("bwhist-flags");
+    this.availableCsvFiles.add("connbidirect");
+    this.availableCsvFiles.add("cloudbridges");
+    this.availableCsvFiles.add("dirbytes");
+    this.availableCsvFiles.add("monthly-userstats-average");
+    this.availableCsvFiles.add("monthly-userstats-peak");
+    this.availableCsvFiles.add("networksize");
+    this.availableCsvFiles.add("platforms");
+    this.availableCsvFiles.add("relaycountries");
+    this.availableCsvFiles.add("relayflags");
+    this.availableCsvFiles.add("torperf");
+    this.availableCsvFiles.add("torperf-failures");
+    this.availableCsvFiles.add("userstats");
+    this.availableCsvFiles.add("userstats-detector");
+    this.availableCsvFiles.add("versions");
+
+    this.availableTables = new HashMap<String, String>();
+    this.availableTables.put("userstats-relay", "start,end,filename");
+    this.availableTables.put("userstats-bridge", "start,end,filename");
+    this.availableTables.put("userstats-censorship-events",
+        "start,end,filename");
+    TableParameterChecker.getInstance().setAvailableTables(
+        availableTables);
+
+    this.availableGraphs = new HashMap<String, String>();
+    this.availableGraphs.put("networksize", "start,end,filename");
+    this.availableGraphs.put("cloudbridges", "start,end,filename");
+    this.availableGraphs.put("relaycountries",
+        "start,end,country,filename");
+    this.availableGraphs.put("relayflags", "start,end,flag,filename");
+    this.availableGraphs.put("versions", "start,end,filename");
+    this.availableGraphs.put("platforms", "start,end,filename");
+    this.availableGraphs.put("bandwidth", "start,end,filename");
+    this.availableGraphs.put("bandwidth-flags", "start,end,filename");
+    this.availableGraphs.put("bwhist-flags", "start,end,filename");
+    this.availableGraphs.put("dirbytes", "start,end,filename");
+    this.availableGraphs.put("torperf",
+         "start,end,source,filesize,filename");
+    this.availableGraphs.put("torperf-failures",
+         "start,end,source,filesize,filename");
+    this.availableGraphs.put("connbidirect", "start,end,filename");
+    this.availableGraphs.put("fast-exits", "start,end,filename");
+    this.availableGraphs.put("almost-fast-exits", "start,end,filename");
+    this.availableGraphs.put("userstats-relay-country",
+        "start,end,country,events,filename");
+    this.availableGraphs.put("userstats-bridge-country",
+        "start,end,country,filename");
+    this.availableGraphs.put("userstats-bridge-transport",
+        "start,end,transport,filename");
+    this.availableGraphs.put("userstats-bridge-version",
+        "start,end,version,filename");
+    this.availableGraphFileTypes = new HashSet<String>(Arrays.asList(
+        "png,pdf,svg".split(",")));
+    GraphParameterChecker.getInstance().setAvailableGraphs(
+        availableGraphs);
+
+    /* Register ourself, so that servlets can use us. */
+    servletContext.setAttribute("RObjectGenerator", this);
+
+    /* Periodically generate R objects with default parameters. */
+    new Thread() {
+      public void run() {
+        long lastUpdated = 0L, sleep;
+        while (true) {
+          while ((sleep = maxCacheAge * 1000L / 2L + lastUpdated
+              - System.currentTimeMillis()) > 0L) {
+            try {
+              Thread.sleep(sleep);
+            } catch (InterruptedException e) {
+            }
+          }
+          for (String csvFile : availableCsvFiles) {
+            generateCsv(csvFile, false);
+          }
+          for (String tableName : availableTables.keySet()) {
+            generateTable(tableName, tableName, new HashMap(), false);
+          }
+          for (String graphName : availableGraphs.keySet()) {
+            for (String fileType : availableGraphFileTypes) {
+              generateGraph(graphName, fileType, new HashMap(), false);
+            }
+          }
+          lastUpdated = System.currentTimeMillis();
+        }
+      };
+    }.start();
+  }
+
+  public void contextDestroyed(ServletContextEvent event) {
+    /* Nothing to do. */
+  }
+
+  public RObject generateGraph(String requestedGraph, String fileType,
+      Map parameterMap, boolean checkCache) {
+    Map<String, String[]> checkedParameters = GraphParameterChecker.
+        getInstance().checkParameters(requestedGraph, parameterMap);
+    if (checkedParameters == null) {
+      /* TODO We're going to take the blame by sending an internal server
+       * error to the client, but really the user is to blame. */
+      return null;
+    }
+    StringBuilder rQueryBuilder = new StringBuilder("plot_"
+        + requestedGraph.replaceAll("-", "_") + "("),
+        imageFilenameBuilder = new StringBuilder(requestedGraph);
+    for (Map.Entry<String, String[]> parameter :
+        checkedParameters.entrySet()) {
+      String parameterName = parameter.getKey();
+      String[] parameterValues = parameter.getValue();
+      for (String param : parameterValues) {
+        imageFilenameBuilder.append("-" + param);
+      }
+      if (parameterValues.length < 2) {
+        rQueryBuilder.append(parameterName + " = '" + parameterValues[0]
+            + "', ");
+      } else {
+        rQueryBuilder.append(parameterName + " = c(");
+        for (int i = 0; i < parameterValues.length - 1; i++) {
+          rQueryBuilder.append("'" + parameterValues[i] + "', ");
+        }
+        rQueryBuilder.append("'" + parameterValues[
+            parameterValues.length - 1] + "'), ");
+      }
+    }
+    imageFilenameBuilder.append("." + fileType);
+    String imageFilename = imageFilenameBuilder.toString();
+    rQueryBuilder.append("path = '%s')");
+    String rQuery = rQueryBuilder.toString();
+    File imageFile = new File(this.cachedGraphsDirectory + "/"
+        + imageFilename);
+    return this.generateRObject(rQuery, imageFile, imageFilename,
+        checkCache);
+  }
+
+  public SortedSet<String> getAvailableCsvFiles() {
+    return this.availableCsvFiles;
+  }
+
+  public RObject generateCsv(String requestedCsvFile,
+      boolean checkCache) {
+    /* Prepare filename and R query string. */
+    String rQuery = "export_" + requestedCsvFile.replaceAll("-", "_")
+        + "(path = '%s')";
+    String csvFilename = requestedCsvFile + ".csv";
+
+    /* See if we need to generate this .csv file. */
+    File csvFile = new File(this.cachedGraphsDirectory + "/"
+        + csvFilename);
+    return this.generateRObject(rQuery, csvFile, csvFilename, checkCache);
+  }
+
+  public List<Map<String, String>> generateTable(String tableName,
+      String requestedTable, Map parameterMap, boolean checkCache) {
+
+    Map<String, String[]> checkedParameters = null;
+    if (tableName.equals(requestedTable)) {
+      checkedParameters = TableParameterChecker.
+          getInstance().checkParameters(requestedTable,
+          parameterMap);
+    } else {
+      checkedParameters = TableParameterChecker.
+          getInstance().checkParameters(tableName, null);
+    }
+    if (checkedParameters == null) {
+      /* TODO We're going to take the blame by sending an internal server
+       * error to the client, but really the user is to blame. */
+      return null;
+    }
+    StringBuilder rQueryBuilder = new StringBuilder("write_"
+        + tableName.replaceAll("-", "_") + "("),
+        tableFilenameBuilder = new StringBuilder(tableName);
+
+    for (Map.Entry<String, String[]> parameter :
+        checkedParameters.entrySet()) {
+      String parameterName = parameter.getKey();
+      String[] parameterValues = parameter.getValue();
+      for (String param : parameterValues) {
+        tableFilenameBuilder.append("-" + param);
+      }
+      if (parameterValues.length < 2) {
+        rQueryBuilder.append(parameterName + " = '"
+            + parameterValues[0] + "', ");
+      } else {
+        rQueryBuilder.append(parameterName + " = c(");
+        for (int i = 0; i < parameterValues.length - 1; i++) {
+          rQueryBuilder.append("'" + parameterValues[i] + "', ");
+        }
+        rQueryBuilder.append("'" + parameterValues[
+            parameterValues.length - 1] + "'), ");
+      }
+    }
+    tableFilenameBuilder.append(".tbl");
+    String tableFilename = tableFilenameBuilder.toString();
+    rQueryBuilder.append("path = '%s')");
+    String rQuery = rQueryBuilder.toString();
+    return this.generateTable(rQuery, tableFilename, checkCache);
+  }
+
+  /* Generate table data using the given R query and filename or read
+   * previously generated table data from disk if it's not too old and
+   * return table data. */
+  private List<Map<String, String>> generateTable(String rQuery,
+      String tableFilename, boolean checkCache) {
+
+    /* See if we need to generate this table. */
+    File tableFile = new File(this.cachedGraphsDirectory + "/"
+        + tableFilename);
+    byte[] tableBytes = this.generateRObject(rQuery, tableFile,
+        tableFilename, checkCache).getBytes();
+
+    /* Write the table content to a map. */
+    List<Map<String, String>> result = null;
+    try {
+      result = new ArrayList<Map<String, String>>();
+      BufferedReader br = new BufferedReader(new InputStreamReader(
+          new ByteArrayInputStream(tableBytes)));
+      String line = br.readLine();
+      if (line != null) {
+        List<String> headers = new ArrayList<String>(Arrays.asList(
+            line.split(",")));
+        while ((line = br.readLine()) != null) {
+          String[] parts = line.split(",");
+          if (headers.size() != parts.length) {
+            return null;
+          }
+          Map<String, String> row = new HashMap<String, String>();
+          for (int i = 0; i < headers.size(); i++) {
+            row.put(headers.get(i), parts[i]);
+          }
+          result.add(row);
+        }
+      }
+    } catch (IOException e) {
+      return null;
+    }
+
+    /* Return table values. */
+    return result;
+  }
+
+  /* Generate an R object in a separate worker thread, or wait for an
+   * already running worker thread to finish and get its result. */
+  private RObject generateRObject(String rQuery, File rObjectFile,
+      String fileName, boolean checkCache) {
+    RObjectGeneratorWorker worker = null;
+    synchronized (this.rObjectGeneratorThreads) {
+      if (this.rObjectGeneratorThreads.containsKey(rQuery)) {
+        worker = this.rObjectGeneratorThreads.get(rQuery);
+      } else {
+        worker = new RObjectGeneratorWorker(rQuery, rObjectFile,
+            fileName, checkCache);
+        this.rObjectGeneratorThreads.put(rQuery, worker);
+        worker.start();
+      }
+    }
+    try {
+      worker.join();
+    } catch (InterruptedException e) {
+    }
+    synchronized (this.rObjectGeneratorThreads) {
+      if (this.rObjectGeneratorThreads.containsKey(rQuery) &&
+          this.rObjectGeneratorThreads.get(rQuery) == worker) {
+        this.rObjectGeneratorThreads.remove(rQuery);
+      }
+    }
+    return worker.getRObject();
+  }
+
+  private Map<String, RObjectGeneratorWorker> rObjectGeneratorThreads =
+      new HashMap<String, RObjectGeneratorWorker>();
+
+  private class RObjectGeneratorWorker extends Thread {
+
+    private String rQuery;
+    private File rObjectFile;
+    private String fileName;
+    private boolean checkCache;
+    private RObject result = null;
+
+    public RObjectGeneratorWorker(String rQuery, File rObjectFile,
+        String fileName, boolean checkCache) {
+      this.rQuery = rQuery;
+      this.rObjectFile = rObjectFile;
+      this.fileName = fileName;
+      this.checkCache = checkCache;
+    }
+
+    public void run() {
+
+      /* See if we need to generate this R object. */
+      long now = System.currentTimeMillis();
+      if (!this.checkCache || !this.rObjectFile.exists() ||
+          this.rObjectFile.lastModified() < now - maxCacheAge * 1000L) {
+
+        /* We do. Update the R query to contain the absolute path to the
+         * file to be generated, create a connection to Rserve, run the R
+         * query, and close the connection. The generated object will be
+         * on disk. */
+        this.rQuery = String.format(this.rQuery,
+            this.rObjectFile.getAbsolutePath());
+        try {
+          RConnection rc = new RConnection(rserveHost, rservePort);
+          rc.eval(this.rQuery);
+          rc.close();
+        } catch (RserveException e) {
+          return;
+        }
+
+        /* Check that we really just generated the R object. */
+        if (!this.rObjectFile.exists() || this.rObjectFile.lastModified()
+            < now - maxCacheAge * 1000L) {
+          return;
+        }
+      }
+
+      /* Read the R object from disk and write it to a byte array. */
+      long lastModified = this.rObjectFile.lastModified();
+      try {
+        BufferedInputStream bis = new BufferedInputStream(
+            new FileInputStream(this.rObjectFile), 1024);
+        ByteArrayOutputStream baos = new ByteArrayOutputStream();
+        byte[] buffer = new byte[1024];
+        int length;
+        while ((length = bis.read(buffer)) > 0) {
+          baos.write(buffer, 0, length);
+        }
+        bis.close();
+        this.result = new RObject(baos.toByteArray(), this.fileName,
+            lastModified);
+      } catch (IOException e) {
+        return;
+      }
+    }
+
+    public RObject getRObject() {
+      return this.result;
+    }
+  }
+}
diff --git a/website/src/org/torproject/metrics/web/graphs/TableParameterChecker.java b/website/src/org/torproject/metrics/web/graphs/TableParameterChecker.java
new file mode 100644
index 0000000..0be736e
--- /dev/null
+++ b/website/src/org/torproject/metrics/web/graphs/TableParameterChecker.java
@@ -0,0 +1,120 @@
+/* Copyright 2011, 2012 The Tor Project
+ * See LICENSE for licensing information */
+package org.torproject.metrics.web.graphs;
+
+import java.text.ParseException;
+import java.text.SimpleDateFormat;
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.Map;
+import java.util.Set;
+import java.util.TimeZone;
+
+/**
+ * Checks request parameters passed to generate tables.
+ */
+public class TableParameterChecker {
+
+  /**
+   * Singleton instance of this class.
+   */
+  private static TableParameterChecker instance =
+      new TableParameterChecker();
+
+  /**
+   * Returns the singleton instance of this class.
+   */
+  public static TableParameterChecker getInstance() {
+    return instance;
+  }
+
+  /* Date format for parsing start and end dates. */
+  private SimpleDateFormat dateFormat;
+
+  /* Available tables with corresponding parameter lists. */
+  private Map<String, String> availableTables;
+
+  /**
+   * Initializes map with valid parameters for each of the graphs.
+   */
+  public TableParameterChecker() {
+    this.dateFormat = new SimpleDateFormat("yyyy-MM-dd");
+    this.dateFormat.setTimeZone(TimeZone.getTimeZone("UTC"));
+  }
+
+  public void setAvailableTables(Map<String, String> availableTables) {
+    this.availableTables = availableTables;
+  }
+
+  /**
+   * Checks request parameters for the given table type and returns a map
+   * of recognized parameters, or null if the table type doesn't exist or
+   * the parameters are invalid.
+   */
+  public Map<String, String[]> checkParameters(String tableType,
+      Map requestParameters) {
+
+    /* Check if the table type exists. */
+    if (tableType == null ||
+        !this.availableTables.containsKey(tableType)) {
+      return null;
+    }
+
+    /* Find out which other parameters are supported by this table type
+     * and parse them if they are given. */
+    Set<String> supportedTableParameters = new HashSet<String>(Arrays.
+        asList(this.availableTables.get(tableType).split(",")));
+    Map<String, String[]> recognizedTableParameters =
+        new HashMap<String, String[]>();
+
+    /* Parse start and end dates if supported by the table type. If no end
+     * date is provided, set it to today. If no start date is provided,
+     * set it to 90 days before the end date. Make sure that start date
+     * precedes end date. */
+    if (supportedTableParameters.contains("start") ||
+        supportedTableParameters.contains("end")) {
+      String[] startParameter = null;
+      String[] endParameter = null;
+      if (requestParameters != null) {
+        startParameter = (String[]) requestParameters.get("start");
+        endParameter = (String[]) requestParameters.get("end");
+      }
+      long endTimestamp = System.currentTimeMillis();
+      if (endParameter != null && endParameter.length > 0 &&
+          endParameter[0].length() > 0) {
+        try {
+          endTimestamp = dateFormat.parse(endParameter[0]).getTime();
+        } catch (ParseException e)  {
+          return null;
+        }
+        if (!endParameter[0].startsWith("20")) {
+          return null;
+        }
+      }
+      endParameter = new String[] { dateFormat.format(endTimestamp) };
+      long startTimestamp = endTimestamp - 90L * 24L * 60L * 60L * 1000L;
+      if (startParameter != null && startParameter.length > 0 &&
+          startParameter[0].length() > 0) {
+        try {
+          startTimestamp = dateFormat.parse(startParameter[0]).getTime();
+        } catch (ParseException e)  {
+          return null;
+        }
+        if (!startParameter[0].startsWith("20")) {
+          return null;
+        }
+      }
+      startParameter = new String[] { dateFormat.format(startTimestamp) };
+      if (startTimestamp > endTimestamp) {
+       return null;
+      }
+      recognizedTableParameters.put("start", startParameter);
+      recognizedTableParameters.put("end", endParameter);
+    }
+
+    /* We now have a map with all required table parameters. Return it. */
+    return recognizedTableParameters;
+  }
+}
+
diff --git a/website/src/org/torproject/metrics/web/research/ResearchDataServlet.java b/website/src/org/torproject/metrics/web/research/ResearchDataServlet.java
new file mode 100644
index 0000000..8e3788d
--- /dev/null
+++ b/website/src/org/torproject/metrics/web/research/ResearchDataServlet.java
@@ -0,0 +1,260 @@
+/* Copyright 2011, 2012 The Tor Project
+ * See LICENSE for licensing information */
+package org.torproject.metrics.web.research;
+
+import java.io.File;
+import java.io.IOException;
+import java.text.ParseException;
+import java.text.SimpleDateFormat;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Date;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.SortedMap;
+import java.util.TreeMap;
+
+import javax.servlet.ServletException;
+import javax.servlet.http.HttpServlet;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+
+/**
+ * Controller servlet for the Data page. Prepares the various lists of
+ * downloadable metrics data files by parsing a file with URLs on other
+ * servers and looking at a local directory with files served by local
+ * Apache HTTP server. The file with URLs on other servers may contain
+ * comment lines starting with #. Recognizes metrics data file types from
+ * the file names.
+ */
+public class ResearchDataServlet extends HttpServlet {
+
+  private static final long serialVersionUID = -5168280373350515577L;
+
+  public void doGet(HttpServletRequest request,
+      HttpServletResponse response) throws IOException, ServletException {
+
+    /* Read local directory with files served by the local Apache HTTP
+     * server and add the URLs to the list. */
+    List<String> dataFileUrls = new ArrayList<String>();
+    String localDataDir = getServletConfig().getInitParameter(
+        "localDataDir");
+    if (localDataDir != null) {
+      try {
+        File localDataDirFile = new File(localDataDir);
+        if (localDataDirFile.exists() && localDataDirFile.isDirectory()) {
+          for (File localDataFile : localDataDirFile.listFiles()) {
+            if (!localDataFile.isDirectory()) {
+              dataFileUrls.add("/data/" + localDataFile.getName());
+            }
+          }
+        }
+      } catch (SecurityException e) {
+        /* We're not permitted to read the directory with metrics data
+         * files. Ignore. */
+      }
+    }
+
+    /* Prepare data structures that we're going to pass to the JSP. All
+     * data structures are (nested) maps with the map keys being used for
+     * displaying the files in tables and map values being 2-element
+     * arrays containing the file url and optional signature file. */
+    SortedMap<Date, Map<String, String[]>> relayDescriptors =
+        new TreeMap<Date, Map<String, String[]>>(
+        java.util.Collections.reverseOrder());
+    String[] certs = new String[2];
+    SortedMap<Date, String[]> bridgeDescriptors =
+        new TreeMap<Date, String[]>(java.util.Collections.reverseOrder());
+    String[] relayStatistics = new String[2];
+    SortedMap<Date, String[]> torperfTarballs =
+        new TreeMap<Date, String[]>(java.util.Collections.reverseOrder());
+    SortedMap<String, Map<String, String[]>> torperfData =
+        new TreeMap<String, Map<String, String[]>>();
+    SortedMap<Date, String[]> exitLists =
+        new TreeMap<Date, String[]>(java.util.Collections.reverseOrder());
+    SortedMap<Date, String[]> torperfExperiments =
+        new TreeMap<Date, String[]>();
+    SortedMap<Date, String[]> bridgePoolAssignments =
+        new TreeMap<Date, String[]>(java.util.Collections.reverseOrder());
+
+    /* Prepare rewriting Torperf sources. */
+    Map<String, String> torperfSources = new HashMap<String, String>();
+    torperfSources.put("torperffast", "torperf, fastest");
+    torperfSources.put("torperffastratio", "torperf, best ratio");
+    torperfSources.put("torperfslow", "torperf, slowest");
+    torperfSources.put("torperfslowratio", "torperf, worst ratio");
+
+    /* Go through the file list, decide for each file what metrics data
+     * type it is, and put it in the appropriate map. */
+    SimpleDateFormat monthFormat = new SimpleDateFormat("yyyy-MM");
+    SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd");
+    List<String> torperfFilesizes = Arrays.asList("50kb,1mb,5mb".
+        split(","));
+    for (String url : dataFileUrls) {
+      if (!url.contains("/")) {
+        continue;
+      }
+      String filename = url.substring(url.lastIndexOf("/") + 1);
+
+      /* URL contains relay descriptors. */
+      if (filename.startsWith("tor-20") ||
+          filename.startsWith("statuses-20") ||
+          filename.startsWith("server-descriptors-20") ||
+          filename.startsWith("extra-infos-20") ||
+          filename.startsWith("votes-20") ||
+          filename.startsWith("consensuses-20")) {
+        String type = filename.substring(0, filename.indexOf("-20"));
+        String yearMonth = filename.substring(filename.indexOf("20"));
+        yearMonth = yearMonth.substring(0, 7);
+        Date month = null;
+        try {
+          month = monthFormat.parse(yearMonth);
+        } catch (ParseException e) {
+          /* Ignore this URL. */
+          continue;
+        }
+        int index = filename.endsWith(".asc") ? 1 : 0;
+        if (!relayDescriptors.containsKey(month)) {
+          relayDescriptors.put(month, new HashMap<String, String[]>());
+        }
+        if (!relayDescriptors.get(month).containsKey(type)) {
+          relayDescriptors.get(month).put(type, new String[2]);
+        }
+        relayDescriptors.get(month).get(type)[index] = url;
+
+      /* URL contains v3 certificates. */
+      } else if (filename.startsWith("certs.tar")) {
+        int index = filename.endsWith(".asc") ? 1 : 0;
+        certs[index] = url;
+
+      /* URL contains bridge descriptors. */
+      } else if (filename.startsWith("bridge-descriptors-20")) {
+        String yearMonth = filename.substring(filename.indexOf("20"));
+        yearMonth = yearMonth.substring(0, 7);
+        Date month = null;
+        try {
+          month = monthFormat.parse(yearMonth);
+        } catch (ParseException e) {
+          /* Ignore this URL. */
+          continue;
+        }
+        int index = filename.endsWith(".asc") ? 1 : 0;
+        if (!bridgeDescriptors.containsKey(month)) {
+          bridgeDescriptors.put(month, new String[2]);
+        }
+        bridgeDescriptors.get(month)[index] = url;
+
+      /* URL contains relay statistics. */
+      } else if (filename.startsWith("relay-statistics.tar.bz2")) {
+        int index = filename.endsWith(".asc") ? 1 : 0;
+        relayStatistics[index] = url;
+
+      /* URL contains Torperf tarball. */
+      } else if (filename.startsWith("torperf-20")) {
+        String yearMonth = filename.substring(filename.indexOf("20"));
+        yearMonth = yearMonth.substring(0, 7);
+        Date month = null;
+        try {
+          month = monthFormat.parse(yearMonth);
+        } catch (ParseException e) {
+          /* Ignore this URL. */
+          continue;
+        }
+        if (!torperfTarballs.containsKey(month)) {
+          torperfTarballs.put(month, new String[2]);
+        }
+        torperfTarballs.get(month)[0] = url;
+
+      /* URL contains Torperf data file. */
+      } else if (filename.endsWith("b.data") ||
+          filename.endsWith("b.extradata")) {
+        boolean isExtraData = filename.endsWith("b.extradata");
+        String[] parts = filename.split("-");
+        if (parts.length != 2) {
+          continue;
+        }
+        String source = parts[0];
+        if (torperfSources.containsKey(source)) {
+          source = torperfSources.get(source);
+        }
+        String filesize = parts[1];
+        filesize = filesize.substring(0, filesize.length()
+            - (isExtraData ? 10 : 5));
+        if (!torperfFilesizes.contains(filesize)) {
+          continue;
+        }
+        if (!torperfData.containsKey(source)) {
+          torperfData.put(source, new HashMap<String, String[]>());
+        }
+        if (!torperfData.get(source).containsKey(filesize)) {
+          torperfData.get(source).put(filesize, new String[2]);
+        }
+        torperfData.get(source).get(filesize)[isExtraData ? 1 : 0] = url;
+
+      /* URL contains Torperf experiment tarball. */
+      } else if (filename.startsWith("torperf-experiment-20")) {
+        String dateString = filename.substring(filename.indexOf("20"));
+        dateString = dateString.substring(0, 10);
+        Date date = null;
+        try {
+          date = dateFormat.parse(dateString);
+        } catch (ParseException e) {
+          /* Ignore this URL. */
+          continue;
+        }
+        if (!torperfExperiments.containsKey(date)) {
+          torperfExperiments.put(date, new String[2]);
+        }
+        torperfExperiments.get(date)[0] = url;
+
+      /* URL contains exit list. */
+      } else if (filename.startsWith("exit-list-20")) {
+        String yearMonth = filename.substring(filename.indexOf("20"));
+        yearMonth = yearMonth.substring(0, 7);
+        Date month = null;
+        try {
+          month = monthFormat.parse(yearMonth);
+        } catch (ParseException e) {
+          /* Ignore this URL. */
+          continue;
+        }
+        if (!exitLists.containsKey(month)) {
+          exitLists.put(month, new String[2]);
+        }
+        exitLists.get(month)[0] = url;
+
+      /* URL contains bridge pool assignments. */
+      } else if (filename.startsWith("bridge-pool-assignments-20")) {
+        String yearMonth = filename.substring(filename.indexOf("20"));
+        yearMonth = yearMonth.substring(0, 7);
+        Date month = null;
+        try {
+          month = monthFormat.parse(yearMonth);
+        } catch (ParseException e) {
+          /* Ignore this URL. */
+          continue;
+        }
+        if (!bridgePoolAssignments.containsKey(month)) {
+          bridgePoolAssignments.put(month, new String[2]);
+        }
+        bridgePoolAssignments.get(month)[0] = url;
+      }
+    }
+
+    /* Add the maps to the request and forward it to the JSP to display
+     * the page. */
+    request.setAttribute("relayDescriptors", relayDescriptors);
+    request.setAttribute("certs", certs);
+    request.setAttribute("bridgeDescriptors", bridgeDescriptors);
+    request.setAttribute("relayStatistics", relayStatistics);
+    request.setAttribute("torperfData", torperfData);
+    request.setAttribute("exitLists", exitLists);
+    request.setAttribute("torperfTarballs", torperfTarballs);
+    request.setAttribute("torperfExperiments", torperfExperiments);
+    request.setAttribute("bridgePoolAssignments", bridgePoolAssignments);
+    request.getRequestDispatcher("WEB-INF/data.jsp").forward(request,
+        response);
+  }
+}
+
diff --git a/website/src/org/torproject/metrics/web/research/ResearchFormatsServlet.java b/website/src/org/torproject/metrics/web/research/ResearchFormatsServlet.java
new file mode 100644
index 0000000..447ad44
--- /dev/null
+++ b/website/src/org/torproject/metrics/web/research/ResearchFormatsServlet.java
@@ -0,0 +1,24 @@
+/* Copyright 2011, 2012 The Tor Project
+ * See LICENSE for licensing information */
+package org.torproject.metrics.web.research;
+
+import java.io.IOException;
+
+import javax.servlet.ServletException;
+import javax.servlet.http.HttpServlet;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+
+public class ResearchFormatsServlet extends HttpServlet {
+
+  private static final long serialVersionUID = 5666493868675314116L;
+
+  public void doGet(HttpServletRequest request,
+      HttpServletResponse response) throws IOException, ServletException {
+
+    /* Forward the request to the JSP that does all the hard work. */
+    request.getRequestDispatcher("WEB-INF/formats.jsp").forward(request,
+        response);
+  }
+}
+
diff --git a/website/src/org/torproject/metrics/web/research/ResearchPapersServlet.java b/website/src/org/torproject/metrics/web/research/ResearchPapersServlet.java
new file mode 100644
index 0000000..88dc6fe
--- /dev/null
+++ b/website/src/org/torproject/metrics/web/research/ResearchPapersServlet.java
@@ -0,0 +1,24 @@
+/* Copyright 2011, 2012 The Tor Project
+ * See LICENSE for licensing information */
+package org.torproject.metrics.web.research;
+
+import java.io.IOException;
+
+import javax.servlet.ServletException;
+import javax.servlet.http.HttpServlet;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+
+public class ResearchPapersServlet extends HttpServlet {
+
+  private static final long serialVersionUID = -8135459207158536268L;
+
+  public void doGet(HttpServletRequest request,
+      HttpServletResponse response) throws IOException, ServletException {
+
+    /* Forward the request to the JSP that does all the hard work. */
+    request.getRequestDispatcher("WEB-INF/papers.jsp").forward(request,
+        response);
+  }
+}
+
diff --git a/website/src/org/torproject/metrics/web/research/ResearchStatsServlet.java b/website/src/org/torproject/metrics/web/research/ResearchStatsServlet.java
new file mode 100644
index 0000000..e9eaa38
--- /dev/null
+++ b/website/src/org/torproject/metrics/web/research/ResearchStatsServlet.java
@@ -0,0 +1,132 @@
+/* Copyright 2013 The Tor Project
+ * See LICENSE for licensing information */
+package org.torproject.metrics.web.research;
+
+import java.io.BufferedInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.IOException;
+import java.util.SortedSet;
+import java.util.TreeSet;
+
+import javax.servlet.ServletConfig;
+import javax.servlet.ServletException;
+import javax.servlet.http.HttpServlet;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+
+public class ResearchStatsServlet extends HttpServlet {
+
+  private static final long serialVersionUID = 3346710354297653810L;
+
+  private File statsDir;
+
+  private SortedSet<String> availableStatisticsFiles;
+
+  public void init(ServletConfig config) throws ServletException {
+    super.init(config);
+    this.statsDir = new File(config.getInitParameter("statsDir"));
+    this.availableStatisticsFiles = new TreeSet<String>();
+    this.availableStatisticsFiles.add("servers");
+    this.availableStatisticsFiles.add("bandwidth");
+    this.availableStatisticsFiles.add("fast-exits");
+    this.availableStatisticsFiles.add("clients");
+    this.availableStatisticsFiles.add("torperf");
+    this.availableStatisticsFiles.add("connbidirect");
+  }
+
+  public long getLastModified(HttpServletRequest request) {
+    File statsFile = this.determineStatsFile(request);
+    if (statsFile == null || !statsFile.exists()) {
+      return -1L;
+    } else {
+      return statsFile.lastModified();
+    }
+  }
+
+  public void doGet(HttpServletRequest request,
+      HttpServletResponse response) throws IOException, ServletException {
+    String requestURI = request.getRequestURI();
+    if (requestURI.equals("/ernie/stats/")) {
+      this.writeDirectoryListing(request, response);
+    } else if (requestURI.equals("/ernie/stats.html")) {
+      this.writeStatisticsPage(request, response);
+    } else {
+      File statsFile = this.determineStatsFile(request);
+      if (statsFile == null) {
+        response.sendError(HttpServletResponse.SC_NOT_FOUND);
+        return;
+      } else if (!this.writeStatsFile(statsFile, response)) {
+        response.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
+      }
+    }
+  }
+
+  private void writeDirectoryListing(HttpServletRequest request,
+      HttpServletResponse response) throws IOException, ServletException {
+    request.setAttribute("directory", "/stats");
+    request.setAttribute("extension", ".csv");
+    request.setAttribute("files", this.availableStatisticsFiles);
+    request.getRequestDispatcher("/WEB-INF/dir.jsp").forward(request,
+        response);
+  }
+
+  private void writeStatisticsPage(HttpServletRequest request,
+      HttpServletResponse response) throws IOException, ServletException {
+    request.getRequestDispatcher("/WEB-INF/stats.jsp").forward(request,
+        response);
+  }
+
+  private File determineStatsFile(HttpServletRequest request) {
+    String requestedStatsFile = request.getRequestURI();
+    if (requestedStatsFile.equals("/ernie/stats/") ||
+        requestedStatsFile.equals("/ernie/stats.html")) {
+      return null;
+    }
+    if (requestedStatsFile.endsWith(".csv")) {
+      requestedStatsFile = requestedStatsFile.substring(0,
+          requestedStatsFile.length() - ".csv".length());
+    }
+    if (requestedStatsFile.contains("/")) {
+      requestedStatsFile = requestedStatsFile.substring(
+          requestedStatsFile.lastIndexOf("/") + 1);
+    }
+    if (!availableStatisticsFiles.contains(requestedStatsFile)) {
+      return null;
+    } else {
+      return new File(this.statsDir, requestedStatsFile + ".csv");
+    }
+  }
+
+  private boolean writeStatsFile(File statsFile,
+      HttpServletResponse response) throws IOException, ServletException {
+    if (!statsFile.exists()) {
+      return false;
+    }
+    byte[] statsFileBytes;
+    try {
+      BufferedInputStream bis = new BufferedInputStream(
+          new FileInputStream(statsFile), 1024);
+      ByteArrayOutputStream baos = new ByteArrayOutputStream();
+      byte[] buffer = new byte[1024];
+      int length;
+      while ((length = bis.read(buffer)) > 0) {
+        baos.write(buffer, 0, length);
+      }
+      bis.close();
+      statsFileBytes = baos.toByteArray();
+    } catch (IOException e) {
+      return false;
+    }
+    String statsFileContent = new String(statsFileBytes);
+    response.setContentType("text/csv");
+    response.setHeader("Content-Length", String.valueOf(
+        statsFileContent.length()));
+    response.setHeader("Content-Disposition",
+        "inline; filename=\"" + statsFile.getName() + "\"");
+    response.getWriter().print(statsFileContent);
+    return true;
+  }
+}
+
diff --git a/website/src/org/torproject/metrics/web/research/ResearchToolsServlet.java b/website/src/org/torproject/metrics/web/research/ResearchToolsServlet.java
new file mode 100644
index 0000000..a7de0f4
--- /dev/null
+++ b/website/src/org/torproject/metrics/web/research/ResearchToolsServlet.java
@@ -0,0 +1,24 @@
+/* Copyright 2011, 2012 The Tor Project
+ * See LICENSE for licensing information */
+package org.torproject.metrics.web.research;
+
+import java.io.IOException;
+
+import javax.servlet.ServletException;
+import javax.servlet.http.HttpServlet;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+
+public class ResearchToolsServlet extends HttpServlet {
+
+  private static final long serialVersionUID = -3344204426180358872L;
+
+  public void doGet(HttpServletRequest request,
+      HttpServletResponse response) throws IOException, ServletException {
+
+    /* Forward the request to the JSP that does all the hard work. */
+    request.getRequestDispatcher("WEB-INF/tools.jsp").forward(request,
+        response);
+  }
+}
+
diff --git a/website/src/org/torproject/metrics/web/status/ConsensusHealthServlet.java b/website/src/org/torproject/metrics/web/status/ConsensusHealthServlet.java
new file mode 100644
index 0000000..d693349
--- /dev/null
+++ b/website/src/org/torproject/metrics/web/status/ConsensusHealthServlet.java
@@ -0,0 +1,57 @@
+/* Copyright 2011, 2012 The Tor Project
+ * See LICENSE for licensing information */
+package org.torproject.metrics.web.status;
+
+import java.io.BufferedInputStream;
+import java.io.BufferedOutputStream;
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.IOException;
+
+import javax.servlet.ServletException;
+import javax.servlet.http.HttpServlet;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+
+public class ConsensusHealthServlet extends HttpServlet {
+
+  private static final long serialVersionUID = -5230032733057814869L;
+
+  public void doGet(HttpServletRequest request,
+      HttpServletResponse response) throws IOException,
+      ServletException {
+
+    /* Read file from disk and write it to response. */
+    BufferedInputStream input = null;
+    BufferedOutputStream output = null;
+    try {
+      File f = new File("/srv/metrics.torproject.org/ernie/website/"
+          + "consensus-health.html");
+      if (!f.exists()) {
+        response.sendError(HttpServletResponse.SC_NOT_FOUND);
+        return;
+      }
+      response.setContentType(this.getServletContext().getMimeType(f.getName()));
+      response.setHeader("Content-Length", String.valueOf(
+          f.length()));
+      response.setHeader("Content-Disposition",
+          "inline; filename=\"" + f.getName() + "\"");
+      input = new BufferedInputStream(new FileInputStream(f),
+          1024);
+      output = new BufferedOutputStream(response.getOutputStream(), 1024);
+      byte[] buffer = new byte[1024];
+      int length;
+      while ((length = input.read(buffer)) > 0) {
+          output.write(buffer, 0, length);
+      }
+    } finally {
+      if (output != null) {
+        output.close();
+      }
+      if (input != null) {
+        input.close();
+      }
+    }
+  }
+}
+
diff --git a/website/src/org/torproject/metrics/web/status/ExoneraTorServlet.java b/website/src/org/torproject/metrics/web/status/ExoneraTorServlet.java
new file mode 100644
index 0000000..30a2ff8
--- /dev/null
+++ b/website/src/org/torproject/metrics/web/status/ExoneraTorServlet.java
@@ -0,0 +1,24 @@
+/* Copyright 2011, 2012 The Tor Project
+ * See LICENSE for licensing information */
+package org.torproject.metrics.web.status;
+
+import java.io.IOException;
+
+import javax.servlet.ServletException;
+import javax.servlet.http.HttpServlet;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+
+public class ExoneraTorServlet extends HttpServlet {
+
+  private static final long serialVersionUID = -6227541092325776626L;
+
+  public void doGet(HttpServletRequest request,
+      HttpServletResponse response) throws IOException, ServletException {
+
+    /* Forward the request to the JSP that does all the hard work. */
+    request.getRequestDispatcher("WEB-INF/exonerator.jsp").forward(
+        request, response);
+  }
+}
+
diff --git a/website/src/org/torproject/metrics/web/status/RelaySearchServlet.java b/website/src/org/torproject/metrics/web/status/RelaySearchServlet.java
new file mode 100644
index 0000000..49b091e
--- /dev/null
+++ b/website/src/org/torproject/metrics/web/status/RelaySearchServlet.java
@@ -0,0 +1,535 @@
+/* Copyright 2011, 2012 The Tor Project
+ * See LICENSE for licensing information */
+package org.torproject.metrics.web.status;
+
+import java.io.IOException;
+import java.io.UnsupportedEncodingException;
+import java.math.BigInteger;
+import java.sql.Connection;
+import java.sql.ResultSet;
+import java.sql.SQLException;
+import java.sql.Statement;
+import java.text.ParseException;
+import java.text.SimpleDateFormat;
+import java.util.ArrayList;
+import java.util.Calendar;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.SortedMap;
+import java.util.SortedSet;
+import java.util.TimeZone;
+import java.util.TreeMap;
+import java.util.TreeSet;
+import java.util.logging.Level;
+import java.util.logging.Logger;
+import java.util.regex.Pattern;
+
+import javax.naming.Context;
+import javax.naming.InitialContext;
+import javax.naming.NamingException;
+import javax.servlet.ServletException;
+import javax.servlet.http.HttpServlet;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+import javax.sql.DataSource;
+
+import org.apache.commons.codec.binary.Base64;
+
+/**
+ * Web page that allows users to search for relays in the descriptor
+ * archives.
+ *
+ * Possible search terms for testing:
+ * - gabelmoo
+ * - gabelmoo 2010-09
+ * - gabelmoo 2010-09-18
+ * - gabelmoo $F2044413DAC2E02E3D6BCF4735A19BCA1DE97281
+ * - gabelmoo 80.190.246
+ * - gabelmoo $F2044413DAC2E02E3D6BCF4735A19BCA1DE97281 80.190.246
+ * - 5898549205 dc737cc9dca16af6 79.212.74.45
+ * - 5898549205 dc737cc9dca16af6
+ * - 80.190.246.100
+ * - $F2044413DAC2E02E3D6BCF4735A19BCA1DE97281
+ * - $F2044413DAC2E02E3D6BCF4735A19BCA1DE97281 80.190.246
+ * - 58985492
+ * - 58985492 79.212.74.45
+ */
+public class RelaySearchServlet extends HttpServlet {
+
+  private static final long serialVersionUID = -1772662230310611805L;
+
+  private Pattern alphaNumDotDashDollarSpacePattern =
+      Pattern.compile("[A-Za-z0-9\\.\\-$ ]+");
+
+  private Pattern numPattern = Pattern.compile("[0-9]+");
+
+  private Pattern hexPattern = Pattern.compile("[A-Fa-f0-9]+");
+
+  private Pattern alphaNumPattern = Pattern.compile("[A-Za-z0-9]+");
+
+  private SimpleDateFormat dayFormat = new SimpleDateFormat("yyyy-MM-dd");
+
+  private SimpleDateFormat monthFormat = new SimpleDateFormat("yyyy-MM");
+
+  private SimpleDateFormat dateTimeFormat =
+      new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
+
+  private long minValidAfterMillis;
+
+  private DataSource ds;
+
+  private Logger logger;
+
+  public void init() {
+
+    /* Initialize logger. */
+    this.logger = Logger.getLogger(RelaySearchServlet.class.toString());
+
+    /* Initialize date format parsers. */
+    dayFormat.setTimeZone(TimeZone.getTimeZone("UTC"));
+    monthFormat.setTimeZone(TimeZone.getTimeZone("UTC"));
+    dateTimeFormat.setTimeZone(TimeZone.getTimeZone("UTC"));
+
+    /* Look up data source. */
+    try {
+      Context cxt = new InitialContext();
+      this.ds = (DataSource) cxt.lookup("java:comp/env/jdbc/tordir");
+      this.logger.info("Successfully looked up data source.");
+    } catch (NamingException e) {
+      this.logger.log(Level.WARNING, "Could not look up data source", e);
+    }
+
+    /* Look up first consensus in the database. */
+    try {
+      long requestedConnection = System.currentTimeMillis();
+      Connection conn = this.ds.getConnection();
+      String query = "SELECT MIN(validafter) AS first FROM consensus";
+      Statement statement = conn.createStatement();
+      ResultSet rs = statement.executeQuery(query);
+      if (rs.next()) {
+        this.minValidAfterMillis = rs.getTimestamp(1).getTime();
+      }
+      rs.close();
+      statement.close();
+      conn.close();
+      this.logger.info("Returned a database connection to the pool "
+          + "after " + (System.currentTimeMillis()
+          - requestedConnection) + " millis.");
+    } catch (SQLException e) {
+      this.logger.log(Level.WARNING, "Could not look up first consensus "
+          + "valid-after time in the database.", e);
+    }
+  }
+
+  public void doGet(HttpServletRequest request,
+      HttpServletResponse response) throws IOException,
+      ServletException {
+
+    /* Read search parameter. If we don't have a search parameter, we're
+     * done here. */
+    String searchParameter = request.getParameter("search");
+    if (searchParameter == null || searchParameter.length() == 0) {
+      request.getRequestDispatcher("WEB-INF/relay-search.jsp").forward(
+          request, response);
+      return;
+    }
+
+    /* Parse search parameter to identify what nickname, fingerprint,
+     * and/or IP address to search for. A valid query contains no more
+     * than one identifier for each of the fields. As a special case,
+     * there are search terms consisting of 8 to 19 hex characters that
+     * can be either a nickname or a fingerprint. */
+    String searchNickname = "";
+    String searchFingerprint = "";
+    String searchIPAddress = "";
+    SortedSet<String> searchDays = new TreeSet<String>();
+    SortedSet<String> searchMonths = new TreeSet<String>();
+    SortedSet<Long> searchDayTimestamps = new TreeSet<Long>();
+    SortedSet<Long> searchMonthTimestamps = new TreeSet<Long>();
+    boolean validQuery = false;
+
+    /* Only parse search parameter if it contains nothing else than
+     * alphanumeric characters, dots, and spaces. */
+    if (alphaNumDotDashDollarSpacePattern.matcher(searchParameter).
+        matches()) {
+      SortedSet<String> searchTerms = new TreeSet<String>();
+      if (searchParameter.trim().contains(" ")) {
+        String[] split = searchParameter.trim().split(" ");
+        for (int i = 0; i < split.length; i++) {
+          if (split[i].length() > 0) {
+            searchTerms.add(split[i]);
+          }
+        }
+      } else {
+        searchTerms.add(searchParameter.trim());
+      }
+
+      /* Parse each search term separately. */
+      for (String searchTerm : searchTerms) {
+
+        /* If the search term contains a dot, it can only be an IP
+         * address. */
+        if (searchTerm.contains(".") && !searchTerm.startsWith(".")) {
+          String[] octets = searchTerm.split("\\.");
+          if (searchIPAddress.length() > 0 || octets.length < 2 ||
+              octets.length > 4) {
+            validQuery = false;
+            break;
+          }
+          boolean invalidOctet = false;
+          StringBuilder sb = new StringBuilder();
+          for (int i = 0; i < octets.length; i++) {
+            if (!numPattern.matcher(octets[i]).matches() ||
+                octets[i].length() > 3 ||
+                Integer.parseInt(octets[i]) > 255) {
+              invalidOctet = true;
+              break;
+            } else {
+              sb.append("." + Integer.parseInt(octets[i]));
+            }
+          }
+          if (invalidOctet) {
+            validQuery = false;
+            break;
+          }
+          if (octets.length < 4) {
+            sb.append(".");
+          }
+          searchIPAddress = sb.toString().substring(1);
+          validQuery = true;
+        }
+
+        /* If the search term contains hyphens, it must be a month or a
+         * day. */
+        else if (searchTerm.contains("-") &&
+            searchTerm.startsWith("20")) {
+          try {
+            if (searchTerm.length() == 10) {
+              searchDayTimestamps.add(dayFormat.parse(searchTerm).
+                  getTime());
+              searchDays.add(searchTerm);
+            } else if (searchTerm.length() == 7) {
+              searchMonthTimestamps.add(monthFormat.parse(searchTerm).
+                  getTime());
+              searchMonths.add(searchTerm);
+            } else {
+              validQuery = false;
+              break;
+            }
+          } catch (ParseException e) {
+            validQuery = false;
+            break;
+          }
+        }
+
+        /* If the search term starts with a $ followed by 8 to 40 hex
+         * characters, it must be a fingerprint. */
+        else if ((searchTerm.length() >= 9 && searchTerm.length() <= 41 &&
+            searchTerm.startsWith("$") &&
+            hexPattern.matcher(searchTerm.substring(1)).matches()) ||
+            (searchTerm.length() > 19 && searchTerm.length() <= 40 &&
+            !searchTerm.startsWith("$") &&
+            hexPattern.matcher(searchTerm).matches())) {
+          if (searchFingerprint.length() > 0) {
+            validQuery = false;
+            break;
+          }
+          searchFingerprint = searchTerm.substring(
+              (searchTerm.startsWith("$") ? 1 : 0));
+          validQuery = true;
+        }
+
+        /* If the search term contains up to 19 alphanumerical characters,
+         * it must be a nickname. */
+        else if (searchTerm.length() <= 19 &&
+            alphaNumPattern.matcher(searchTerm).matches()) {
+          if (searchNickname.length() > 0) {
+            validQuery = false;
+            break;
+          }
+          searchNickname = searchTerm;
+          validQuery = true;
+        }
+
+        /* We didn't recognize this search term. */
+        else {
+          validQuery = false;
+          break;
+        }
+      }
+    }
+
+    /* We only accept at most one month or three days, but not both, or
+     * people could accidentally keep the database busy. */
+    if (searchDays.size() > 3 || searchMonths.size() > 1 ||
+        (searchMonths.size() == 1 && searchDays.size() > 0)) {
+      validQuery = false;
+    }
+
+    /* If the query is invalid, stop here. */
+    if (!validQuery) {
+      request.setAttribute("invalidQuery", "Query is invalid.");
+      request.getRequestDispatcher("WEB-INF/relay-search.jsp").
+          forward(request, response);
+      return;
+    }
+
+    /* Look up last consensus in the database. */
+    long maxValidAfterMillis = -1L;
+    try {
+      long requestedConnection = System.currentTimeMillis();
+      Connection conn = this.ds.getConnection();
+      String query = "SELECT MAX(validafter) AS last FROM consensus";
+      Statement statement = conn.createStatement();
+      ResultSet rs = statement.executeQuery(query);
+      if (rs.next()) {
+        maxValidAfterMillis = rs.getTimestamp(1).getTime();
+      }
+      rs.close();
+      statement.close();
+      conn.close();
+      this.logger.info("Returned a database connection to the pool "
+          + "after " + (System.currentTimeMillis()
+          - requestedConnection) + " millis.");
+    } catch (SQLException e) {
+      this.logger.log(Level.WARNING, "Could not look up last consensus "
+          + "valid-after time in the database.", e);
+    }
+
+    /* Prepare a string that says what we're searching for. */
+    List<String> recognizedSearchTerms = new ArrayList<String>();
+    if (searchNickname.length() > 0) {
+      recognizedSearchTerms.add("nickname <b>" + searchNickname + "</b>");
+    }
+    if (searchFingerprint.length() > 0) {
+      recognizedSearchTerms.add("fingerprint <b>" + searchFingerprint
+          + "</b>");
+    }
+    if (searchIPAddress.length() > 0) {
+      recognizedSearchTerms.add("IP address <b>" + searchIPAddress
+          + "</b>");
+    }
+    List<String> recognizedIntervals = new ArrayList<String>();
+    for (String searchTerm : searchMonths) {
+      recognizedIntervals.add("in <b>" + searchTerm + "</b>");
+    }
+    for (String searchTerm : searchDays) {
+      recognizedIntervals.add("on <b>" + searchTerm + "</b>");
+    }
+    StringBuilder searchNoticeBuilder = new StringBuilder();
+    if (maxValidAfterMillis > 0L) {
+      searchNoticeBuilder.append("Most recent consensus in database is "
+          + "from " + dateTimeFormat.format(maxValidAfterMillis)
+          + ".</p><p>");
+    }
+    searchNoticeBuilder.append("Searching for relays with ");
+    if (recognizedSearchTerms.size() == 1) {
+      searchNoticeBuilder.append(recognizedSearchTerms.get(0));
+    } else if (recognizedSearchTerms.size() == 2) {
+      searchNoticeBuilder.append(recognizedSearchTerms.get(0) + " and "
+          + recognizedSearchTerms.get(1));
+    } else {
+      for (int i = 0; i < recognizedSearchTerms.size() - 1; i++) {
+        searchNoticeBuilder.append(recognizedSearchTerms.get(i) + ", ");
+      }
+      searchNoticeBuilder.append("and " + recognizedSearchTerms.get(
+          recognizedSearchTerms.size() - 1));
+    }
+    if (recognizedIntervals.size() == 1) {
+      searchNoticeBuilder.append(" running "
+          + recognizedIntervals.get(0));
+    } else if (recognizedIntervals.size() == 2) {
+      searchNoticeBuilder.append(" running " + recognizedIntervals.get(0)
+          + " and/or " + recognizedIntervals.get(1));
+    } else if (recognizedIntervals.size() > 2) {
+      searchNoticeBuilder.append(" running ");
+      for (int i = 0; i < recognizedIntervals.size() - 1; i++) {
+      searchNoticeBuilder.append(recognizedIntervals.get(i) + ", ");
+      }
+      searchNoticeBuilder.append("and/or " + recognizedIntervals.get(
+          recognizedIntervals.size() - 1));
+    }
+    searchNoticeBuilder.append(" ...");
+    String searchNotice = searchNoticeBuilder.toString();
+    request.setAttribute("searchNotice", searchNotice);
+
+    /* Prepare the query string. */
+    StringBuilder conditionBuilder = new StringBuilder();
+    boolean addAnd = false;
+    if (searchNickname.length() > 0) {
+      conditionBuilder.append((addAnd ? "AND " : "")
+          + "LOWER(nickname) LIKE '" + searchNickname.toLowerCase()
+          + "%' ");
+      addAnd = true;
+    }
+    if (searchFingerprint.length() > 0) {
+      conditionBuilder.append((addAnd ? "AND " : "")
+          + "fingerprint LIKE '" + searchFingerprint.toLowerCase()
+          + "%' ");
+      addAnd = true;
+    }
+    if (searchIPAddress.length() > 0) {
+      conditionBuilder.append((addAnd ? "AND " : "")
+          + "address LIKE '" + searchIPAddress + "%' ");
+      addAnd = true;
+    }
+    List<String> timeIntervals = new ArrayList<String>();
+    if (searchDayTimestamps.size() > 0 ||
+        searchMonthTimestamps.size() > 0) {
+      StringBuilder timeIntervalBuilder = new StringBuilder();
+      boolean addOr = false;
+      timeIntervalBuilder.append("AND (");
+      for (long searchTimestamp : searchDayTimestamps) {
+        if (searchTimestamp < this.minValidAfterMillis) {
+          request.setAttribute("outsideInterval", "Returned search "
+              + "results may be incomplete, as our data only dates back "
+              + "to " + dateTimeFormat.format(this.minValidAfterMillis)
+              + ".  Older archives are not available.");
+        }
+        timeIntervalBuilder.append((addOr ? "OR " : "")
+            + "(validafter >= '"
+            + dateTimeFormat.format(searchTimestamp) + "' AND "
+            + "validafter < '" + dateTimeFormat.format(searchTimestamp
+            + 24L * 60L * 60L * 1000L) + "') ");
+        addOr = true;
+      }
+      for (long searchTimestamp : searchMonthTimestamps) {
+        if (searchTimestamp < this.minValidAfterMillis) {
+          request.setAttribute("outsideInterval", "Returned search "
+              + "results may be incomplete, as our data only dates back "
+              + "to " + dateTimeFormat.format(this.minValidAfterMillis)
+              + ".  Older archives are not available.");
+        }
+        Calendar firstOfNextMonth = Calendar.getInstance(
+            TimeZone.getTimeZone("UTC"));
+        firstOfNextMonth.setTimeInMillis(searchTimestamp);
+        firstOfNextMonth.add(Calendar.MONTH, 1);
+        timeIntervalBuilder.append((addOr ? "OR " : "")
+            + "(validafter >= '"
+            + dateTimeFormat.format(searchTimestamp) + "' AND "
+            + "validafter < '" + dateTimeFormat.format(
+            firstOfNextMonth.getTimeInMillis()) + "') ");
+        addOr = true;
+      }
+      timeIntervalBuilder.append(") ");
+      timeIntervals.add(timeIntervalBuilder.toString());
+    } else {
+      timeIntervals.add("AND validafter >= '"
+          + dateTimeFormat.format(System.currentTimeMillis()
+          - 4L * 24L * 60L * 60L * 1000L) + "' ");
+      timeIntervals.add("AND validafter >= '"
+          + dateTimeFormat.format(System.currentTimeMillis()
+          - 30L * 24L * 60L * 60L * 1000L) + "' ");
+    }
+    List<String> queries = new ArrayList<String>();
+    for (String timeInterval : timeIntervals) {
+      StringBuilder queryBuilder = new StringBuilder();
+      queryBuilder.append("SELECT validafter, fingerprint, descriptor, "
+          + "rawdesc FROM statusentry WHERE validafter IN (SELECT "
+          + "validafter FROM statusentry WHERE ");
+      queryBuilder.append(conditionBuilder.toString());
+      queryBuilder.append(timeInterval);
+      queryBuilder.append("ORDER BY validafter DESC LIMIT 31) AND ");
+      queryBuilder.append(conditionBuilder.toString());
+      queryBuilder.append(timeInterval);
+      queries.add(queryBuilder.toString());
+    }
+
+    /* Actually execute the query. */
+    long startedQuery = System.currentTimeMillis();
+    SortedMap<String, SortedSet<String>> foundDescriptors =
+        new TreeMap<String, SortedSet<String>>(
+        Collections.reverseOrder());
+    Map<String, String> rawValidAfterLines =
+        new HashMap<String, String>();
+    Map<String, String> rawStatusEntries = new HashMap<String, String>();
+    String query = null;
+    int matches = 0;
+    try {
+      long requestedConnection = System.currentTimeMillis();
+      Connection conn = this.ds.getConnection();
+      while (!queries.isEmpty()) {
+        query = queries.remove(0);
+        this.logger.info("Running query '" + query + "'.");
+        Statement statement = conn.createStatement();
+        ResultSet rs = statement.executeQuery(query);
+        while (rs.next()) {
+          matches++;
+          String validAfter = rs.getTimestamp(1).toString().
+              substring(0, 19);
+          String fingerprint = rs.getString(2);
+          if (!foundDescriptors.containsKey(validAfter)) {
+            foundDescriptors.put(validAfter, new TreeSet<String>());
+          }
+          foundDescriptors.get(validAfter).add(validAfter + " "
+              + fingerprint);
+          if (!rawValidAfterLines.containsKey(validAfter)) {
+            rawValidAfterLines.put(validAfter, "<tt>valid-after "
+                + "<a href=\"https://exonerator.torproject.org/"
+                + "consensus?valid-after="
+                + validAfter.replaceAll(":", "-").replaceAll(" ", "-")
+                + "\" target=\"_blank\">" + validAfter + "</a></tt><br>");
+          }
+          byte[] rawStatusEntry = rs.getBytes(4);
+          String statusEntryLines = null;
+          try {
+            statusEntryLines = new String(rawStatusEntry, "US-ASCII");
+          } catch (UnsupportedEncodingException e) {
+            /* This shouldn't happen, because we know that ASCII is
+             * supported. */
+          }
+          StringBuilder rawStatusEntryBuilder = new StringBuilder();
+          String[] lines = statusEntryLines.split("\n");
+          for (String line : lines) {
+            if (line.startsWith("r ")) {
+              String[] parts = line.split(" ");
+              String descriptorBase64 = String.format("%040x",
+                  new BigInteger(1, Base64.decodeBase64(parts[3]
+                  + "==")));
+              rawStatusEntryBuilder.append("<tt>r " + parts[1] + " "
+                  + parts[2] + " <a href=\""
+                  + "https://exonerator.torproject.org/"
+                  + "serverdesc?desc-id="
+                  + descriptorBase64 + "\" target=\"_blank\">" + parts[3]
+                  + "</a> " + parts[4] + " " + parts[5] + " " + parts[6]
+                  + " " + parts[7] + " " + parts[8] + "</tt><br>");
+            } else {
+              rawStatusEntryBuilder.append("<tt>" + line + "</tt><br>");
+            }
+          }
+          rawStatusEntries.put(validAfter + " " + fingerprint,
+                rawStatusEntryBuilder.toString());
+        }
+        rs.close();
+        statement.close();
+        if (matches >= 31) {
+          queries.clear();
+        }
+      }
+      conn.close();
+      this.logger.info("Returned a database connection to the pool "
+          + "after " + (System.currentTimeMillis()
+          - requestedConnection) + " millis.");
+    } catch (SQLException e) {
+
+      /* Tell the user we have a database problem. */
+      response.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR,
+          "Database problem");
+      return;
+    }
+    request.setAttribute("query", query);
+    request.setAttribute("queryTime", System.currentTimeMillis()
+        - startedQuery);
+    request.setAttribute("foundDescriptors", foundDescriptors);
+    request.setAttribute("rawValidAfterLines", rawValidAfterLines);
+    request.setAttribute("rawStatusEntries", rawStatusEntries);
+    request.setAttribute("matches", matches);
+
+    /* We're done. Let the JSP do the rest. */
+    request.getRequestDispatcher("WEB-INF/relay-search.jsp").forward(
+        request, response);
+  }
+}
+





More information about the tor-commits mailing list