[tor-commits] [exonerator/master] Move Java sources to src/main/java/.

karsten at torproject.org karsten at torproject.org
Thu Jul 7 11:46:26 UTC 2016


commit 172e1498d328a62d0915ab1796ec63202bcdd12e
Author: Karsten Loesing <karsten.loesing at gmx.net>
Date:   Wed Jul 6 10:59:08 2016 +0200

    Move Java sources to src/main/java/.
---
 build.xml                                          |   2 +-
 .../exonerator/ExoneraTorDatabaseImporter.java     | 564 +++++++++++++
 .../torproject/exonerator/ExoneraTorServlet.java   | 911 +++++++++++++++++++++
 .../exonerator/ExoneraTorDatabaseImporter.java     | 564 -------------
 .../torproject/exonerator/ExoneraTorServlet.java   | 911 ---------------------
 5 files changed, 1476 insertions(+), 1476 deletions(-)

diff --git a/build.xml b/build.xml
index 0326520..2e4f74d 100644
--- a/build.xml
+++ b/build.xml
@@ -1,7 +1,7 @@
 <project default="run" name="exonerator" basedir=".">
 
   <!-- Define build paths. -->
-  <property name="sources" value="src"/>
+  <property name="sources" value="src/main/java"/>
   <property name="resources" value="res"/>
   <property name="classes" value="classes"/>
   <property name="libs" value="lib"/>
diff --git a/src/main/java/org/torproject/exonerator/ExoneraTorDatabaseImporter.java b/src/main/java/org/torproject/exonerator/ExoneraTorDatabaseImporter.java
new file mode 100644
index 0000000..41751ca
--- /dev/null
+++ b/src/main/java/org/torproject/exonerator/ExoneraTorDatabaseImporter.java
@@ -0,0 +1,564 @@
+/* Copyright 2011, 2012 The Tor Project
+ * See LICENSE for licensing information */
+package org.torproject.exonerator;
+
+import java.io.BufferedInputStream;
+import java.io.BufferedReader;
+import java.io.BufferedWriter;
+import java.io.ByteArrayOutputStream;
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.FileReader;
+import java.io.FileWriter;
+import java.io.IOException;
+import java.io.StringReader;
+import java.io.UnsupportedEncodingException;
+import java.sql.CallableStatement;
+import java.sql.Connection;
+import java.sql.DriverManager;
+import java.sql.SQLException;
+import java.sql.Timestamp;
+import java.sql.Types;
+import java.text.ParseException;
+import java.text.SimpleDateFormat;
+import java.util.Calendar;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.Map;
+import java.util.Set;
+import java.util.Stack;
+import java.util.TimeZone;
+
+import org.apache.commons.codec.binary.Base64;
+import org.apache.commons.codec.binary.Hex;
+import org.apache.commons.codec.digest.DigestUtils;
+import org.torproject.descriptor.DescriptorCollector;
+import org.torproject.descriptor.DescriptorSourceFactory;
+
+/* Import Tor descriptors into the ExoneraTor database. */
+public class ExoneraTorDatabaseImporter {
+
+  /* Main function controlling the parsing process. */
+  public static void main(String[] args) {
+    readConfiguration();
+    openDatabaseConnection();
+    prepareDatabaseStatements();
+    createLockFile();
+    fetchDescriptors();
+    readImportHistoryToMemory();
+    parseDescriptors();
+    writeImportHistoryToDisk();
+    closeDatabaseConnection();
+    deleteLockFile();
+  }
+
+  /* JDBC string of the ExoneraTor database. */
+  private static String jdbcString;
+
+  /* Directory from which to import descriptors. */
+  private static String importDirString;
+
+  /* Learn JDBC string and directory to parse descriptors from. */
+  private static void readConfiguration() {
+    File configFile = new File("config");
+    if (!configFile.exists()) {
+      System.err.println("Could not find config file.  Exiting.");
+      System.exit(1);
+    }
+    String line = null;
+    try {
+      BufferedReader br = new BufferedReader(new FileReader(configFile));
+      while ((line = br.readLine()) != null) {
+        if (line.startsWith("#") || line.length() < 1) {
+          continue;
+        } else if (line.startsWith("ExoneraTorDatabaseJdbc")) {
+          jdbcString = line.split(" ")[1];
+        } else if (line.startsWith("ExoneraTorImportDirectory")) {
+          importDirString = line.split(" ")[1];
+        } else {
+          /* Ignore unrecognized configuration keys. */
+        }
+      }
+      br.close();
+    } catch (IOException e) {
+      System.err.println("Could not parse config file.  Exiting.");
+      System.exit(1);
+    }
+  }
+
+  /* Database connection. */
+  private static Connection connection;
+
+  /* Open a database connection using the JDBC string in the config. */
+  private static void openDatabaseConnection() {
+    try {
+      connection = DriverManager.getConnection(jdbcString);
+    } catch (SQLException e) {
+      System.out.println("Could not connect to database.  Exiting.");
+      System.exit(1);
+    }
+  }
+
+  /* Callable statements to import data into the database. */
+  private static CallableStatement insertStatusentryStatement;
+  private static CallableStatement insertExitlistentryStatement;
+
+  /* Prepare statements for importing data into the database. */
+  private static void prepareDatabaseStatements() {
+    try {
+      insertStatusentryStatement = connection.prepareCall(
+          "{call insert_statusentry(?, ?, ?, ?, ?, ?, ?)}");
+      insertExitlistentryStatement = connection.prepareCall(
+          "{call insert_exitlistentry(?, ?, ?, ?, ?)}");
+    } catch (SQLException e) {
+      System.out.println("Could not prepare callable statements to "
+          + "import data into the database.  Exiting.");
+      System.exit(1);
+    }
+  }
+
+  /* Create a local lock file to prevent other instances of this import
+   * tool to run concurrently. */
+  private static void createLockFile() {
+    File lockFile = new File("exonerator-lock");
+    try {
+      if (lockFile.exists()) {
+        BufferedReader br = new BufferedReader(new FileReader(lockFile));
+        long runStarted = Long.parseLong(br.readLine());
+        br.close();
+        if (System.currentTimeMillis() - runStarted
+            < 6L * 60L * 60L * 1000L) {
+          System.out.println("File 'exonerator-lock' is less than 6 "
+              + "hours old.  Exiting.");
+          System.exit(1);
+        } else {
+          System.out.println("File 'exonerator-lock' is at least 6 hours "
+              + "old.  Overwriting and executing anyway.");
+        }
+      }
+      BufferedWriter bw = new BufferedWriter(new FileWriter(
+          "exonerator-lock"));
+      bw.append(String.valueOf(System.currentTimeMillis()) + "\n");
+      bw.close();
+    } catch (IOException e) {
+      System.out.println("Could not create 'exonerator-lock' file.  "
+          + "Exiting.");
+      System.exit(1);
+    }
+  }
+
+  /* Fetch recent descriptors from CollecTor. */
+  private static void fetchDescriptors() {
+    DescriptorCollector collector =
+        DescriptorSourceFactory.createDescriptorCollector();
+    collector.collectDescriptors("https://collector.torproject.org",
+        new String[] { "/recent/relay-descriptors/consensuses/",
+        "/recent/exit-lists/" }, 0L, new File(importDirString), true);
+  }
+
+  /* Last and next parse histories containing paths of parsed files and
+   * last modified times. */
+  private static Map<String, Long>
+      lastImportHistory = new HashMap<String, Long>(),
+      nextImportHistory = new HashMap<String, Long>();
+
+  /* Read stats/exonerator-import-history file from disk and remember
+   * locally when files were last parsed. */
+  private static void readImportHistoryToMemory() {
+    File parseHistoryFile = new File("stats",
+        "exonerator-import-history");
+    if (parseHistoryFile.exists()) {
+      try {
+        BufferedReader br = new BufferedReader(new FileReader(
+            parseHistoryFile));
+        String line = null;
+        int lineNumber = 0;
+        while ((line = br.readLine()) != null) {
+          lineNumber++;
+          String[] parts = line.split(",");
+          if (parts.length != 2) {
+            System.out.println("File 'stats/exonerator-import-history' "
+                + "contains a corrupt entry in line " + lineNumber
+                + ".  Ignoring parse history file entirely.");
+            lastImportHistory.clear();
+            br.close();
+            return;
+          }
+          long lastModified = Long.parseLong(parts[0]);
+          String filename = parts[1];
+          lastImportHistory.put(filename, lastModified);
+        }
+        br.close();
+      } catch (IOException e) {
+        System.out.println("Could not read import history.  Ignoring.");
+        lastImportHistory.clear();
+      }
+    }
+  }
+
+  /* Parse descriptors in the import directory and its subdirectories. */
+  private static void parseDescriptors() {
+    File file = new File(importDirString);
+    if (!file.exists()) {
+      System.out.println("File or directory " + importDirString + " does "
+          + "not exist.  Exiting.");
+      return;
+    }
+    Stack<File> files = new Stack<File>();
+    files.add(file);
+    while (!files.isEmpty()) {
+      file = files.pop();
+      if (file.isDirectory()) {
+        for (File f : file.listFiles()) {
+          files.add(f);
+        }
+      } else {
+        parseFile(file);
+      }
+    }
+  }
+
+  /* Import a file if it wasn't imported before, and add it to the import
+   * history for the next execution. */
+  private static void parseFile(File file) {
+    long lastModified = file.lastModified();
+    String filename = file.getName();
+    nextImportHistory.put(filename, lastModified);
+    if (!lastImportHistory.containsKey(filename) ||
+        lastImportHistory.get(filename) < lastModified) {
+      try {
+        FileInputStream fis = new FileInputStream(file);
+        BufferedInputStream bis = new BufferedInputStream(fis);
+        ByteArrayOutputStream baos = new ByteArrayOutputStream();
+        int len;
+        byte[] bytes = new byte[1024];
+        while ((len = bis.read(bytes, 0, 1024)) >= 0) {
+          baos.write(bytes, 0, len);
+        }
+        bis.close();
+        byte[] allBytes = baos.toByteArray();
+        splitFile(file, allBytes);
+      } catch (IOException e) {
+        System.out.println("Could not read '" + file + "' to memory.  "
+            + "Skipping.");
+        nextImportHistory.remove(filename);
+      }
+    }
+  }
+
+  /* Detect what descriptor type is contained in a file and split it to
+   * parse the single descriptors. */
+  private static void splitFile(File file, byte[] bytes) {
+    try {
+      String asciiString = new String(bytes, "US-ASCII");
+      BufferedReader br = new BufferedReader(new StringReader(
+          asciiString));
+      String line = br.readLine();
+      while (line != null && line.startsWith("@")) {
+        line = br.readLine();
+      }
+      if (line == null) {
+        return;
+      }
+      br.close();
+      String startToken = null;
+      if (line.equals("network-status-version 3")) {
+        startToken = "network-status-version 3";
+      } else if (line.startsWith("Downloaded ") ||
+          line.startsWith("ExitNode ")) {
+        startToken = "ExitNode ";
+      } else {
+        System.out.println("Unknown descriptor type in file '" + file
+            + "'.  Ignoring.");
+        return;
+      }
+      String splitToken = "\n" + startToken;
+      int length = bytes.length, start = asciiString.indexOf(startToken);
+      while (start < length) {
+        int end = asciiString.indexOf(splitToken, start);
+        if (end < 0) {
+          end = length;
+        } else {
+          end += 1;
+        }
+        byte[] descBytes = new byte[end - start];
+        System.arraycopy(bytes, start, descBytes, 0, end - start);
+        if (startToken.equals("network-status-version 3")) {
+          parseConsensus(file, descBytes);
+        } else if (startToken.equals("ExitNode ")) {
+          parseExitList(file, descBytes);
+        }
+        start = end;
+      }
+    } catch (IOException e) {
+      System.out.println("Could not parse descriptor '" + file + "'.  "
+          + "Skipping.");
+    }
+  }
+
+  /* Date format to parse UTC timestamps. */
+  private static SimpleDateFormat parseFormat;
+  static {
+    parseFormat = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
+    parseFormat.setTimeZone(TimeZone.getTimeZone("UTC"));
+  }
+
+  /* Parse a consensus. */
+  private static void parseConsensus(File file, byte[] bytes) {
+    try {
+      BufferedReader br = new BufferedReader(new StringReader(new String(
+          bytes, "US-ASCII")));
+      String line, fingerprint = null, descriptor = null;
+      Set<String> orAddresses = new HashSet<String>();
+      long validAfterMillis = -1L;
+      StringBuilder rawStatusentryBuilder = null;
+      boolean isRunning = false;
+      while ((line = br.readLine()) != null) {
+        if (line.startsWith("vote-status ") &&
+            !line.equals("vote-status consensus")) {
+          System.out.println("File '" + file + "' contains network status "
+              + "*votes*, not network status *consensuses*.  Skipping.");
+          return;
+        } else if (line.startsWith("valid-after ")) {
+          String validAfterTime = line.substring("valid-after ".length());
+          try {
+            validAfterMillis = parseFormat.parse(validAfterTime).
+                getTime();
+          } catch (ParseException e) {
+            System.out.println("Could not parse valid-after timestamp in "
+                + "'" + file + "'.  Skipping.");
+            return;
+          }
+        } else if (line.startsWith("r ") ||
+            line.equals("directory-footer")) {
+          if (isRunning) {
+            byte[] rawStatusentry = rawStatusentryBuilder.toString().
+                getBytes();
+            importStatusentry(validAfterMillis, fingerprint, descriptor,
+                orAddresses, rawStatusentry);
+            orAddresses = new HashSet<String>();
+          }
+          if (line.equals("directory-footer")) {
+            return;
+          }
+          rawStatusentryBuilder = new StringBuilder(line + "\n");
+          String[] parts = line.split(" ");
+          if (parts.length < 9) {
+            System.out.println("Could not parse r line '" + line
+                + "'.  Skipping.");
+            return;
+          }
+          fingerprint = Hex.encodeHexString(Base64.decodeBase64(parts[2]
+              + "=")).toLowerCase();
+          descriptor = Hex.encodeHexString(Base64.decodeBase64(parts[3]
+              + "=")).toLowerCase();
+          orAddresses.add(parts[6]);
+        } else if (line.startsWith("a ")) {
+          rawStatusentryBuilder.append(line + "\n");
+          orAddresses.add(line.substring("a ".length(),
+              line.lastIndexOf(":")));
+        } else if (line.startsWith("s ") || line.equals("s")) {
+          rawStatusentryBuilder.append(line + "\n");
+          isRunning = line.contains(" Running");
+        } else if (rawStatusentryBuilder != null) {
+          rawStatusentryBuilder.append(line + "\n");
+        }
+      }
+    } catch (IOException e) {
+      System.out.println("Could not parse consensus.  Skipping.");
+      return;
+    }
+  }
+
+  /* UTC calendar for importing timestamps into the database. */
+  private static Calendar calendarUTC = Calendar.getInstance(
+      TimeZone.getTimeZone("UTC"));
+
+  /* Import a status entry with one or more OR addresses into the
+   * database. */
+  private static void importStatusentry(long validAfterMillis,
+      String fingerprint, String descriptor, Set<String> orAddresses,
+      byte[] rawStatusentry) {
+    try {
+      for (String orAddress : orAddresses) {
+        insertStatusentryStatement.clearParameters();
+        insertStatusentryStatement.setTimestamp(1,
+            new Timestamp(validAfterMillis), calendarUTC);
+        insertStatusentryStatement.setString(2, fingerprint);
+        insertStatusentryStatement.setString(3, descriptor);
+        if (!orAddress.contains(":")) {
+          String[] addressParts = orAddress.split("\\.");
+          byte[] address24Bytes = new byte[3];
+          address24Bytes[0] = (byte) Integer.parseInt(addressParts[0]);
+          address24Bytes[1] = (byte) Integer.parseInt(addressParts[1]);
+          address24Bytes[2] = (byte) Integer.parseInt(addressParts[2]);
+          String orAddress24 = Hex.encodeHexString(address24Bytes);
+          insertStatusentryStatement.setString(4, orAddress24);
+          insertStatusentryStatement.setNull(5, Types.VARCHAR);
+          insertStatusentryStatement.setString(6, orAddress);
+        } else {
+          StringBuilder addressHex = new StringBuilder();
+          int start = orAddress.startsWith("[::") ? 2 : 1;
+          int end = orAddress.length()
+              - (orAddress.endsWith("::]") ? 2 : 1);
+          String[] parts = orAddress.substring(start, end).split(":", -1);
+          for (int i = 0; i < parts.length; i++) {
+            String part = parts[i];
+            if (part.length() == 0) {
+              addressHex.append("x");
+            } else if (part.length() <= 4) {
+              addressHex.append(String.format("%4s", part));
+            } else {
+              addressHex = null;
+              break;
+            }
+          }
+          String orAddress48 = null;
+          if (addressHex != null) {
+            String addressHexString = addressHex.toString();
+            addressHexString = addressHexString.replaceFirst("x",
+                String.format("%" + (33 - addressHexString.length())
+                + "s", "0"));
+            if (!addressHexString.contains("x") &&
+                addressHexString.length() == 32) {
+              orAddress48 = addressHexString.replaceAll(" ", "0").
+                  toLowerCase().substring(0, 12);
+            }
+          }
+          if (orAddress48 != null) {
+            insertStatusentryStatement.setNull(4, Types.VARCHAR);
+            insertStatusentryStatement.setString(5, orAddress48);
+            insertStatusentryStatement.setString(6,
+                orAddress.replaceAll("[\\[\\]]", ""));
+          } else {
+            System.err.println("Could not import status entry with IPv6 "
+                + "address '" + orAddress + "'.  Exiting.");
+            System.exit(1);
+          }
+        }
+        insertStatusentryStatement.setBytes(7, rawStatusentry);
+        insertStatusentryStatement.execute();
+      }
+    } catch (SQLException e) {
+      System.out.println("Could not import status entry.  Exiting.");
+      System.exit(1);
+    }
+  }
+
+  /* Parse an exit list. */
+  private static void parseExitList(File file, byte[] bytes) {
+    try {
+      BufferedReader br = new BufferedReader(new StringReader(new String(
+          bytes, "US-ASCII")));
+      String fingerprint = null;
+      Set<String> exitAddressLines = new HashSet<String>();
+      StringBuilder rawExitlistentryBuilder = new StringBuilder();
+      while (true) {
+        String line = br.readLine();
+        if ((line == null || line.startsWith("ExitNode ")) &&
+            fingerprint != null) {
+          for (String exitAddressLine : exitAddressLines) {
+            String[] parts = exitAddressLine.split(" ");
+            String exitAddress = parts[1];
+            /* TODO Extend the following code for IPv6 once the exit list
+             * format supports it. */
+            String[] exitAddressParts = exitAddress.split("\\.");
+            byte[] exitAddress24Bytes = new byte[3];
+            exitAddress24Bytes[0] = (byte) Integer.parseInt(
+                exitAddressParts[0]);
+            exitAddress24Bytes[1] = (byte) Integer.parseInt(
+                exitAddressParts[1]);
+            exitAddress24Bytes[2] = (byte) Integer.parseInt(
+                exitAddressParts[2]);
+            String exitAddress24 = Hex.encodeHexString(
+                exitAddress24Bytes);
+            String scannedTime = parts[2] + " " + parts[3];
+            long scannedMillis = -1L;
+            try {
+              scannedMillis = parseFormat.parse(scannedTime).getTime();
+            } catch (ParseException e) {
+              System.out.println("Could not parse timestamp in "
+                  + "'" + file + "'.  Skipping.");
+              return;
+            }
+            byte[] rawExitlistentry = rawExitlistentryBuilder.toString().
+                getBytes();
+            importExitlistentry(fingerprint, exitAddress24, exitAddress,
+                scannedMillis, rawExitlistentry);
+          }
+          exitAddressLines.clear();
+          rawExitlistentryBuilder = new StringBuilder();
+        }
+        if (line == null) {
+          break;
+        }
+        rawExitlistentryBuilder.append(line + "\n");
+        if (line.startsWith("ExitNode ")) {
+          fingerprint = line.substring("ExitNode ".length()).
+              toLowerCase();
+        } else if (line.startsWith("ExitAddress ")) {
+          exitAddressLines.add(line);
+        }
+      }
+      br.close();
+    } catch (IOException e) {
+      System.out.println("Could not parse exit list.  Skipping.");
+      return;
+    }
+  }
+
+  /* Import an exit list entry into the database. */
+  private static void importExitlistentry(String fingerprint,
+      String exitAddress24, String exitAddress, long scannedMillis,
+      byte[] rawExitlistentry) {
+    try {
+      insertExitlistentryStatement.clearParameters();
+      insertExitlistentryStatement.setString(1, fingerprint);
+      insertExitlistentryStatement.setString(2, exitAddress24);
+      insertExitlistentryStatement.setString(3, exitAddress);
+      insertExitlistentryStatement.setTimestamp(4,
+          new Timestamp(scannedMillis), calendarUTC);
+      insertExitlistentryStatement.setBytes(5, rawExitlistentry);
+      insertExitlistentryStatement.execute();
+    } catch (SQLException e) {
+      System.out.println("Could not import exit list entry.  Exiting.");
+      System.exit(1);
+    }
+  }
+
+  /* Write parse history from memory to disk for the next execution. */
+  private static void writeImportHistoryToDisk() {
+    File parseHistoryFile = new File("stats/exonerator-import-history");
+    parseHistoryFile.getParentFile().mkdirs();
+    try {
+      BufferedWriter bw = new BufferedWriter(new FileWriter(
+          parseHistoryFile));
+      for (Map.Entry<String, Long> historyEntry :
+          nextImportHistory.entrySet()) {
+        bw.write(String.valueOf(historyEntry.getValue()) + ","
+            + historyEntry.getKey() + "\n");
+      }
+      bw.close();
+    } catch (IOException e) {
+      System.out.println("File 'stats/exonerator-import-history' could "
+          + "not be written.  Ignoring.");
+    }
+  }
+
+  /* Close the database connection. */
+  private static void closeDatabaseConnection() {
+    try {
+      connection.close();
+    } catch (SQLException e) {
+      System.out.println("Could not close database connection.  "
+          + "Ignoring.");
+    }
+  }
+
+  /* Delete the exonerator-lock file to allow the next executing of this
+   * tool. */
+  private static void deleteLockFile() {
+    new File("exonerator-lock").delete();
+  }
+}
+
diff --git a/src/main/java/org/torproject/exonerator/ExoneraTorServlet.java b/src/main/java/org/torproject/exonerator/ExoneraTorServlet.java
new file mode 100644
index 0000000..68d79a3
--- /dev/null
+++ b/src/main/java/org/torproject/exonerator/ExoneraTorServlet.java
@@ -0,0 +1,911 @@
+/* Copyright 2011--2015 The Tor Project
+ * See LICENSE for licensing information */
+package org.torproject.exonerator;
+
+import java.io.IOException;
+import java.io.PrintWriter;
+import java.sql.CallableStatement;
+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.List;
+import java.util.Locale;
+import java.util.ResourceBundle;
+import java.util.SortedSet;
+import java.util.TimeZone;
+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.Hex;
+import org.apache.commons.lang.StringEscapeUtils;
+
+public class ExoneraTorServlet extends HttpServlet {
+
+  private static final long serialVersionUID = 1370088989739567509L;
+
+  private DataSource ds;
+
+  private Logger logger;
+
+  public void init() {
+
+    /* Initialize logger. */
+    this.logger = Logger.getLogger(ExoneraTorServlet.class.toString());
+
+    /* Look up data source. */
+    try {
+      Context cxt = new InitialContext();
+      this.ds = (DataSource) cxt.lookup("java:comp/env/jdbc/exonerator");
+      this.logger.info("Successfully looked up data source.");
+    } catch (NamingException e) {
+      this.logger.log(Level.WARNING, "Could not look up data source", e);
+    }
+  }
+
+  public void doGet(HttpServletRequest request,
+      HttpServletResponse response) throws IOException,
+      ServletException {
+
+    /* Set content type, or the page doesn't render in Chrome. */
+    response.setContentType("text/html");
+
+    /* Start writing response. */
+    PrintWriter out = response.getWriter();
+    this.writeHeader(out);
+
+    /* Find the right resource bundle for the user's locale. */
+    Locale locale = request.getLocale();
+    ResourceBundle rb = ResourceBundle.getBundle("ExoneraTor", locale);
+
+    /* Open a database connection that we'll use to handle the whole
+     * request. */
+    long requestedConnection = System.currentTimeMillis();
+    Connection conn = this.connectToDatabase();
+    if (conn == null) {
+      this.writeSummaryUnableToConnectToDatabase(out, rb);
+      this.writeFooter(out, rb);
+      return;
+    }
+
+    /* Look up first and last date in the database. */
+    long[] firstAndLastDates = this.queryFirstAndLastDatesFromDatabase(
+        conn);
+    if (firstAndLastDates == null) {
+      this.writeSummaryNoData(out, rb);
+      this.writeFooter(out, rb);
+      this.closeDatabaseConnection(conn, requestedConnection);
+    }
+    SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd");
+    dateFormat.setTimeZone(TimeZone.getTimeZone("UTC"));
+    String firstDate = dateFormat.format(firstAndLastDates[0]);
+    String lastDate = dateFormat.format(firstAndLastDates[1]);
+
+    /* Parse parameters. */
+    String ipParameter = request.getParameter("ip");
+    String relayIP = this.parseIpParameter(ipParameter);
+    boolean relayIPHasError = relayIP == null;
+
+    /* Parse timestamp parameter. */
+    String timestampParameter = request.getParameter("timestamp");
+    String timestampStr = this.parseTimestampParameter(
+        timestampParameter);
+    boolean timestampHasError = timestampStr == null;
+
+    /* Check that timestamp is within range. */
+    long timestamp = 0L;
+    boolean timestampOutOfRange = false;
+    if (timestampStr != null && timestampStr.length() > 0) {
+      try {
+        timestamp = dateFormat.parse(timestampParameter).getTime();
+        if (timestamp < firstAndLastDates[0] ||
+            timestamp > firstAndLastDates[1]) {
+          timestampOutOfRange = true;
+        }
+      } catch (ParseException e) {
+        /* Already checked in parseTimestamp(). */
+      }
+    }
+
+    /* Write form. */
+    this.writeForm(out, rb, relayIP, relayIPHasError ||
+        ("".equals(relayIP) && !"".equals(timestampStr)), timestampStr,
+        !relayIPHasError &&
+        !("".equals(relayIP) && !"".equals(timestampStr)) &&
+        (timestampHasError || timestampOutOfRange ||
+        (!"".equals(relayIP) && "".equals(timestampStr))));
+
+    /* If both parameters are empty, don't print any summary and exit.
+     * This is the start page. */
+    if ("".equals(relayIP) && "".equals(timestampStr)) {
+      this.writeFooter(out, rb);
+      this.closeDatabaseConnection(conn, requestedConnection);
+      return;
+    }
+
+    /* If either parameter is empty, print summary with warning message
+     * and exit. */
+    if ("".equals(relayIP) || "".equals(timestampStr)) {
+      if ("".equals(relayIP)) {
+        writeSummaryNoIp(out, rb);
+      } else {
+        writeSummaryNoTimestamp(out, rb);
+      }
+      this.writeFooter(out, rb);
+      this.closeDatabaseConnection(conn, requestedConnection);
+      return;
+    }
+
+    /* If there's a user error, print summary with exit message and
+     * exit. */
+    if (relayIPHasError || timestampHasError || timestampOutOfRange) {
+      if (relayIPHasError) {
+        this.writeSummaryInvalidIp(out, rb, ipParameter);
+      } else if (timestampHasError) {
+        this.writeSummaryInvalidTimestamp(out, rb, timestampParameter);
+      } else if (timestampOutOfRange) {
+        this.writeSummaryTimestampOutsideRange(out, rb, timestampStr,
+            firstDate, lastDate);
+      }
+      this.writeFooter(out, rb);
+      this.closeDatabaseConnection(conn, requestedConnection);
+      return;
+    }
+
+    /* Consider all consensuses published on or within a day of the given
+     * date. */
+    long timestampFrom = timestamp - 24L * 60L * 60L * 1000L;
+    long timestampTo = timestamp + 2 * 24L * 60L * 60L * 1000L - 1L;
+    SimpleDateFormat validAfterTimeFormat = new SimpleDateFormat(
+        "yyyy-MM-dd HH:mm:ss");
+    validAfterTimeFormat.setTimeZone(TimeZone.getTimeZone("UTC"));
+    String fromValidAfter = validAfterTimeFormat.format(timestampFrom);
+    String toValidAfter = validAfterTimeFormat.format(timestampTo);
+    SortedSet<Long> relevantConsensuses =
+        this.queryKnownConsensusValidAfterTimes(conn, fromValidAfter,
+        toValidAfter);
+    if (relevantConsensuses == null || relevantConsensuses.isEmpty()) {
+      this.writeSummaryNoDataForThisInterval(out, rb);
+      this.writeFooter(out, rb);
+      this.closeDatabaseConnection(conn, requestedConnection);
+      return;
+    }
+
+    /* Search for status entries with the given IP address as onion
+     * routing address, plus status entries of relays having an exit list
+     * entry with the given IP address as exit address. */
+    List<String[]> statusEntries = this.queryStatusEntries(conn, relayIP,
+        timestamp, validAfterTimeFormat);
+
+    /* If we didn't find anything, run another query to find out if there
+     * are relays running on other IP addresses in the same /24 or /48
+     * network and tell the user about it. */
+    List<String> addressesInSameNetwork = null;
+    if (statusEntries.isEmpty()) {
+      addressesInSameNetwork = new ArrayList<String>();
+      if (!relayIP.contains(":")) {
+        String address24 = this.convertIPv4ToHex(relayIP).substring(0, 6);
+        if (address24 != null) {
+          addressesInSameNetwork = this.queryAddressesInSame24(conn,
+              address24, timestamp);
+        }
+      } else {
+        String address48 = this.convertIPv6ToHex(relayIP).substring(
+            0, 12);
+        if (address48 != null) {
+          addressesInSameNetwork = this.queryAddressesInSame48(conn,
+              address48, timestamp);
+        }
+      }
+    }
+
+    /* Print out result. */
+    if (!statusEntries.isEmpty()) {
+      this.writeSummaryPositive(out, rb, relayIP, timestampStr);
+      this.writeTechnicalDetails(out, rb, relayIP, timestampStr,
+          statusEntries);
+    } else if (addressesInSameNetwork != null &&
+        !addressesInSameNetwork.isEmpty()) {
+      this.writeSummaryAddressesInSameNetwork(out, rb, relayIP,
+          timestampStr, addressesInSameNetwork);
+    } else {
+      this.writeSummaryNegative(out, rb, relayIP, timestampStr);
+    }
+
+    this.writePermanentLink(out, rb, relayIP, timestampStr);
+
+    this.closeDatabaseConnection(conn, requestedConnection);
+    this.writeFooter(out, rb);
+  }
+
+  /* Helper methods for handling the request. */
+
+  private String parseIpParameter(String passedIpParameter) {
+    String relayIP = null;
+    if (passedIpParameter != null && passedIpParameter.length() > 0) {
+      String ipParameter = passedIpParameter.trim();
+      Pattern ipv4AddressPattern = Pattern.compile(
+          "^([01]?\\d\\d?|2[0-4]\\d|25[0-5])\\." +
+          "([01]?\\d\\d?|2[0-4]\\d|25[0-5])\\." +
+          "([01]?\\d\\d?|2[0-4]\\d|25[0-5])\\." +
+          "([01]?\\d\\d?|2[0-4]\\d|25[0-5])$");
+      Pattern ipv6AddressPattern = Pattern.compile(
+          "^\\[?[0-9a-fA-F:]{3,39}\\]?$");
+      if (ipv4AddressPattern.matcher(ipParameter).matches()) {
+        String[] ipParts = ipParameter.split("\\.");
+        relayIP = Integer.parseInt(ipParts[0]) + "."
+            + Integer.parseInt(ipParts[1]) + "."
+            + Integer.parseInt(ipParts[2]) + "."
+            + Integer.parseInt(ipParts[3]);
+      } else if (ipv6AddressPattern.matcher(ipParameter).matches()) {
+        if (ipParameter.startsWith("[") && ipParameter.endsWith("]")) {
+          ipParameter = ipParameter.substring(1,
+              ipParameter.length() - 1);
+        }
+        StringBuilder addressHex = new StringBuilder();
+        int start = ipParameter.startsWith("::") ? 1 : 0;
+        int end = ipParameter.length()
+            - (ipParameter.endsWith("::") ? 1 : 0);
+        String[] parts = ipParameter.substring(start, end).split(":", -1);
+        for (int i = 0; i < parts.length; i++) {
+          String part = parts[i];
+          if (part.length() == 0) {
+            addressHex.append("x");
+          } else if (part.length() <= 4) {
+            addressHex.append(String.format("%4s", part));
+          } else {
+            addressHex = null;
+            break;
+          }
+        }
+        if (addressHex != null) {
+          String addressHexString = addressHex.toString();
+          addressHexString = addressHexString.replaceFirst("x",
+              String.format("%" + (33 - addressHexString.length()) + "s",
+              "0"));
+          if (!addressHexString.contains("x") &&
+              addressHexString.length() == 32) {
+            relayIP = ipParameter.toLowerCase();
+          }
+        }
+      }
+    } else {
+      relayIP = "";
+    }
+    return relayIP;
+  }
+
+  private String convertIPv4ToHex(String relayIP) {
+    String[] relayIPParts = relayIP.split("\\.");
+    byte[] address24Bytes = new byte[4];
+    for (int i = 0; i < address24Bytes.length; i++) {
+      address24Bytes[i] = (byte) Integer.parseInt(relayIPParts[i]);
+    }
+    String address24 = Hex.encodeHexString(address24Bytes);
+    return address24;
+  }
+
+  private String convertIPv6ToHex(String relayIP) {
+    if (relayIP.startsWith("[") && relayIP.endsWith("]")) {
+      relayIP = relayIP.substring(1, relayIP.length() - 1);
+    }
+    StringBuilder addressHex = new StringBuilder();
+    int start = relayIP.startsWith("::") ? 1 : 0;
+    int end = relayIP.length() - (relayIP.endsWith("::") ? 1 : 0);
+    String[] parts = relayIP.substring(start, end).split(":", -1);
+    for (int i = 0; i < parts.length; i++) {
+      String part = parts[i];
+      if (part.length() == 0) {
+        addressHex.append("x");
+      } else if (part.length() <= 4) {
+        addressHex.append(String.format("%4s", part));
+      } else {
+        addressHex = null;
+        break;
+      }
+    }
+    String address48 = null;
+    if (addressHex != null) {
+      String addressHexString = addressHex.toString();
+      addressHexString = addressHexString.replaceFirst("x",
+          String.format("%" + (33 - addressHexString.length())
+          + "s", "0"));
+      if (!addressHexString.contains("x") &&
+          addressHexString.length() == 32) {
+        address48 = addressHexString.replaceAll(" ", "0").
+            toLowerCase();
+      }
+    }
+    return address48;
+  }
+
+  private String parseTimestampParameter(
+      String passedTimestampParameter) {
+    String timestampStr = "";
+    SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd");
+    dateFormat.setTimeZone(TimeZone.getTimeZone("UTC"));
+    dateFormat.setLenient(false);
+    if (passedTimestampParameter != null &&
+        passedTimestampParameter.length() > 0) {
+      String timestampParameter = passedTimestampParameter.trim();
+      try {
+        long timestamp = dateFormat.parse(timestampParameter).getTime();
+        timestampStr = dateFormat.format(timestamp);
+      } catch (ParseException e) {
+        timestampStr = null;
+      }
+    }
+    return timestampStr;
+  }
+
+  /* Helper methods for querying the database. */
+
+  private Connection connectToDatabase() {
+    Connection conn = null;
+    try {
+      conn = this.ds.getConnection();
+    } catch (SQLException e) {
+    }
+    return conn;
+  }
+
+  private long[] queryFirstAndLastDatesFromDatabase(Connection conn) {
+    long[] firstAndLastDates = null;
+    try {
+      Statement statement = conn.createStatement();
+      String query = "SELECT DATE(MIN(validafter)) AS first, "
+          + "DATE(MAX(validafter)) AS last FROM statusentry";
+      ResultSet rs = statement.executeQuery(query);
+      if (rs.next()) {
+        Calendar utcCalendar = Calendar.getInstance(
+            TimeZone.getTimeZone("UTC"));
+        firstAndLastDates = new long[] {
+            rs.getTimestamp(1, utcCalendar).getTime(),
+            rs.getTimestamp(2, utcCalendar).getTime()
+        };
+      }
+      rs.close();
+      statement.close();
+    } catch (SQLException e) {
+      /* Looks like we don't have any consensuses. */
+      firstAndLastDates = null;
+    }
+    return firstAndLastDates;
+  }
+
+  private SortedSet<Long> queryKnownConsensusValidAfterTimes(
+      Connection conn, String fromValidAfter, String toValidAfter) {
+    SortedSet<Long> relevantConsensuses = new TreeSet<Long>();
+    try {
+      Statement statement = conn.createStatement();
+      String query = "SELECT DISTINCT validafter FROM statusentry "
+          + "WHERE validafter >= '" + fromValidAfter
+          + "' AND validafter <= '" + toValidAfter + "'";
+      ResultSet rs = statement.executeQuery(query);
+      while (rs.next()) {
+        long consensusTime = rs.getTimestamp(1).getTime();
+        relevantConsensuses.add(consensusTime);
+      }
+      rs.close();
+      statement.close();
+    } catch (SQLException e) {
+      /* Looks like we don't have any consensuses in the requested
+       * interval. */
+      relevantConsensuses = null;
+    }
+    return relevantConsensuses;
+  }
+
+  private List<String[]> queryStatusEntries(Connection conn,
+      String relayIP, long timestamp,
+      SimpleDateFormat validAfterTimeFormat) {
+    List<String[]> statusEntries = new ArrayList<String[]>();
+    String addressHex = !relayIP.contains(":")
+        ? this.convertIPv4ToHex(relayIP) : this.convertIPv6ToHex(relayIP);
+    if (addressHex == null) {
+      return null;
+    }
+    String address24Or48Hex = !relayIP.contains(":")
+        ? addressHex.substring(0, 6) : addressHex.substring(0, 12);
+    try {
+      CallableStatement cs;
+      if (!relayIP.contains(":")) {
+        cs = conn.prepareCall("{call search_by_address24_date(?, ?)}");
+      } else {
+        cs = conn.prepareCall("{call search_by_address48_date(?, ?)}");
+      }
+      cs.setString(1, address24Or48Hex);
+      Calendar utcCalendar = Calendar.getInstance(
+          TimeZone.getTimeZone("UTC"));
+      cs.setDate(2, new java.sql.Date(timestamp), utcCalendar);
+      ResultSet rs = cs.executeQuery();
+      while (rs.next()) {
+        byte[] rawstatusentry = rs.getBytes(1);
+        SortedSet<String> addresses = new TreeSet<String>(),
+            addressesHex = new TreeSet<String>();
+        long validafter = rs.getTimestamp(2, utcCalendar).getTime();
+        String validAfterString = validAfterTimeFormat.format(validafter);
+        String fingerprint = rs.getString(3).toUpperCase();
+        String nickname = null;
+        String exit = "U";
+        for (String line : new String(rawstatusentry).split("\n")) {
+          if (line.startsWith("r ")) {
+            String[] parts = line.split(" ");
+            nickname = parts[1];
+            addresses.add(parts[6]);
+            addressesHex.add(this.convertIPv4ToHex(parts[6]));
+          } else if (line.startsWith("a ")) {
+            String address = line.substring("a ".length(),
+                line.lastIndexOf(":"));
+            addresses.add(address);
+            String orAddressHex = !address.contains(":")
+                ? this.convertIPv4ToHex(address)
+                : this.convertIPv6ToHex(address);
+            addressesHex.add(orAddressHex);
+          } else if (line.startsWith("p ")) {
+            exit = line.equals("p reject 1-65535") ? "N" : "Y";
+          }
+        }
+        String exitaddress = rs.getString(4);
+        if (exitaddress != null && exitaddress.length() > 0) {
+          addresses.add(exitaddress);
+          addressesHex.add(this.convertIPv4ToHex(exitaddress));
+        }
+        if (!addressesHex.contains(addressHex)) {
+          continue;
+        }
+        StringBuilder sb = new StringBuilder();
+        int writtenAddresses = 0;
+        for (String address : addresses) {
+          sb.append((writtenAddresses++ > 0 ? ", " : "") + address);
+        }
+        String[] statusEntry = new String[] { validAfterString,
+            sb.toString(), fingerprint, nickname, exit };
+        statusEntries.add(statusEntry);
+      }
+      rs.close();
+      cs.close();
+    } catch (SQLException e) {
+      /* Nothing found. */
+      statusEntries = null;
+    }
+    return statusEntries;
+  }
+
+  private List<String> queryAddressesInSame24(Connection conn,
+      String address24, long timestamp) {
+    List<String> addressesInSameNetwork = new ArrayList<String>();
+    try {
+      CallableStatement cs = conn.prepareCall(
+          "{call search_addresses_in_same_24 (?, ?)}");
+      cs.setString(1, address24);
+      cs.setDate(2, new java.sql.Date(timestamp));
+      ResultSet rs = cs.executeQuery();
+      while (rs.next()) {
+        String address = rs.getString(1);
+        if (!addressesInSameNetwork.contains(address)) {
+          addressesInSameNetwork.add(address);
+        }
+      }
+      rs.close();
+      cs.close();
+    } catch (SQLException e) {
+      /* No other addresses in the same /24 found. */
+      addressesInSameNetwork = null;
+    }
+    return addressesInSameNetwork;
+  }
+
+  private List<String> queryAddressesInSame48(Connection conn,
+      String address48, long timestamp) {
+    List<String> addressesInSameNetwork = new ArrayList<String>();
+    try {
+      CallableStatement cs = conn.prepareCall(
+          "{call search_addresses_in_same_48 (?, ?)}");
+      cs.setString(1, address48);
+      cs.setDate(2, new java.sql.Date(timestamp));
+      ResultSet rs = cs.executeQuery();
+      while (rs.next()) {
+        String address = rs.getString(1);
+        if (!addressesInSameNetwork.contains(address)) {
+          addressesInSameNetwork.add(address);
+        }
+      }
+      rs.close();
+      cs.close();
+    } catch (SQLException e) {
+      /* No other addresses in the same /48 found. */
+      addressesInSameNetwork = null;
+    }
+    return addressesInSameNetwork;
+  }
+
+  private void closeDatabaseConnection(Connection conn,
+      long requestedConnection) {
+    try {
+      conn.close();
+      this.logger.info("Returned a database connection to the pool "
+          + "after " + (System.currentTimeMillis()
+          - requestedConnection) + " millis.");
+    } catch (SQLException e) {
+    }
+    return;
+  }
+
+  /* Helper methods for writing the response. */
+
+  private void writeHeader(PrintWriter out) throws IOException {
+    out.println("<!DOCTYPE html>\n"
+        + "<html lang=\"en\">\n"
+        + "  <head>\n"
+        + "    <meta charset=\"utf-8\">\n"
+        + "    <meta http-equiv=\"X-UA-Compatible\" "
+          + "content=\"IE=edge\">\n"
+        + "    <meta name=\"viewport\" content=\"width=device-width, "
+          + "initial-scale=1\">\n"
+        + "    <title>ExoneraTor</title>\n"
+        + "    <link rel=\"stylesheet\" href=\"css/bootstrap.min.css\">\n"
+        + "    <link rel=\"stylesheet\" href=\"css/exonerator.css\">\n"
+        + "    <link href=\"images/favicon.ico\" type=\"image/x-icon\" "
+          + "rel=\"icon\">\n"
+        + "  </head>\n"
+        + "  <body>\n"
+        + "    <div class=\"container\">\n"
+        + "      <div class=\"row\">\n"
+        + "        <div class=\"col-xs-12\">\n"
+        + "          <div class=\"page-header\">\n"
+        + "            <h1>\n"
+        + "              <div class=\"text-center\">\n"
+        + "                <a href=\"/\">"
+          + "<img src=\"images/exonerator-logo.png\" "
+          + "width=\"334\" height=\"252\" alt=\"ExoneraTor logo\">"
+          + "<img src=\"images/exonerator-wordmark.png\" width=\"428\" "
+          + "height=\"63\" alt=\"ExoneraTor wordmark\"></a>\n"
+        + "              </div><!-- text-center -->\n"
+        + "            </h1>\n"
+        + "          </div><!-- page-header -->\n"
+        + "        </div><!-- col -->\n"
+        + "      </div><!-- row -->\n");
+  }
+
+  private void writeForm(PrintWriter out, ResourceBundle rb,
+      String relayIP, boolean relayIPHasError, String timestampStr,
+      boolean timestampHasError) throws IOException {
+    String ipValue = "";
+    if (relayIP != null && relayIP.length() > 0) {
+      if (relayIP.contains(":")) {
+        ipValue = String.format(" value=\"[%s]\"", relayIP);
+      } else {
+        ipValue = String.format(" value=\"%s\"", relayIP);
+      }
+    }
+    out.printf("      <div class=\"row\">\n"
+        + "        <div class=\"col-xs-12\">\n"
+        + "          <div class=\"text-center\">\n"
+        + "            <div class=\"row vbottom15\">\n"
+        + "              <h4>%s</h4>\n"
+        + "            </div> <!-- row -->\n"
+        + "            <form class=\"form-inline\">\n"
+        + "              <div class=\"form-group%s\">\n"
+        + "                <label for=\"inputIp\" "
+          + "class=\"control-label\">%s</label>\n"
+        + "                <input type=\"text\" class=\"form-control\" "
+          + "name=\"ip\" id=\"inputIp\" placeholder=\"86.59.21.38\"%s "
+          + "required>\n"
+        + "              </div><!-- form-group -->\n"
+        + "              <div class=\"form-group%s\">\n"
+        + "                <label for=\"inputTimestamp\" "
+          + "class=\"control-label\">%s</label>\n"
+        + "                <input type=\"date\" class=\"form-control\" "
+          + "name=\"timestamp\" id=\"inputTimestamp\" "
+          + "placeholder=\"2010-01-01\"%s required>\n"
+        + "              </div><!-- form-group -->\n"
+        + "              <button type=\"submit\" "
+          + "class=\"btn btn-primary\">%s</button>\n"
+        + "            </form>\n"
+        + "          </div><!-- text-center -->\n"
+        + "        </div><!-- col -->\n"
+        + "      </div><!-- row -->\n",
+        rb.getString("form.explanation"),
+        relayIPHasError ? " has-error" : "",
+        rb.getString("form.ip.label"),
+        ipValue,
+        timestampHasError ? " has-error" : "",
+        rb.getString("form.timestamp.label"),
+        timestampStr != null && timestampStr.length() > 0 ?
+            " value=\"" + timestampStr + "\"" : "",
+        rb.getString("form.search.label"));
+  }
+
+  private void writeSummaryUnableToConnectToDatabase(PrintWriter out,
+      ResourceBundle rb) throws IOException {
+    String contactLink =
+        "<a href=\"https://www.torproject.org/about/contact\">"
+        + rb.getString("summary.serverproblem.dbempty.body.link")
+        + "</a>";
+    this.writeSummary(out, rb.getString("summary.heading"),
+        "panel-danger",
+        rb.getString("summary.serverproblem.dbnoconnect.title"), null,
+        rb.getString("summary.serverproblem.dbnoconnect.body.text"),
+        contactLink);
+  }
+
+  private void writeSummaryNoData(PrintWriter out, ResourceBundle rb)
+      throws IOException {
+    String contactLink =
+        "<a href=\"https://www.torproject.org/about/contact\">"
+        + rb.getString("summary.serverproblem.dbempty.body.link")
+        + "</a>";
+    this.writeSummary(out, rb.getString("summary.heading"),
+        "panel-danger",
+        rb.getString("summary.serverproblem.dbempty.title"), null,
+        rb.getString("summary.serverproblem.dbempty.body.text"),
+        contactLink);
+  }
+
+  private void writeSummaryNoTimestamp(PrintWriter out, ResourceBundle rb)
+      throws IOException {
+    this.writeSummary(out, rb.getString("summary.heading"),
+        "panel-danger",
+        rb.getString("summary.invalidparams.notimestamp.title"), null,
+        rb.getString("summary.invalidparams.notimestamp.body"));
+  }
+
+  private void writeSummaryNoIp(PrintWriter out, ResourceBundle rb)
+      throws IOException {
+    this.writeSummary(out, rb.getString("summary.heading"),
+        "panel-danger", rb.getString("summary.invalidparams.noip.title"),
+        null, rb.getString("summary.invalidparams.noip.body"));
+  }
+
+  private void writeSummaryTimestampOutsideRange(PrintWriter out,
+      ResourceBundle rb, String timestampStr, String firstDate,
+      String lastDate) throws IOException {
+    this.writeSummary(out, rb.getString("summary.heading"),
+        "panel-danger",
+        rb.getString("summary.invalidparams.timestamprange.title"), null,
+        rb.getString("summary.invalidparams.timestamprange.body"),
+        timestampStr, firstDate, lastDate);
+  }
+
+  private void writeSummaryInvalidIp(PrintWriter out, ResourceBundle rb,
+      String ipParameter) throws IOException {
+    String escapedIpParameter = ipParameter.length() > 40 ?
+        StringEscapeUtils.escapeHtml(ipParameter.substring(0, 40))
+        + "[...]" : StringEscapeUtils.escapeHtml(ipParameter);
+    this.writeSummary(out, rb.getString("summary.heading"),
+        "panel-danger",
+        rb.getString("summary.invalidparams.invalidip.title"), null,
+        rb.getString("summary.invalidparams.invalidip.body"),
+        escapedIpParameter, "\"a.b.c.d\"", "\"[a:b:c:d:e:f:g:h]\"");
+  }
+
+  private void writeSummaryInvalidTimestamp(PrintWriter out,
+      ResourceBundle rb, String timestampParameter) throws IOException {
+    String escapedTimestampParameter = timestampParameter.length() > 20 ?
+        StringEscapeUtils.escapeHtml(timestampParameter.
+        substring(0, 20)) + "[...]" :
+        StringEscapeUtils.escapeHtml(timestampParameter);
+    this.writeSummary(out, rb.getString("summary.heading"),
+        "panel-danger",
+        rb.getString("summary.invalidparams.invalidtimestamp.title"),
+        null, rb.getString("summary.invalidparams.invalidtimestamp.body"),
+        escapedTimestampParameter, "\"YYYY-MM-DD\"");
+  }
+
+  private void writeSummaryNoDataForThisInterval(PrintWriter out,
+      ResourceBundle rb) throws IOException {
+    String contactLink =
+        "<a href=\"https://www.torproject.org/about/contact\">"
+        + rb.getString("summary.serverproblem.dbempty.body.link")
+        + "</a>";
+    this.writeSummary(out, rb.getString("summary.heading"),
+        "panel-danger",
+        rb.getString("summary.serverproblem.nodata.title"), null,
+        rb.getString("summary.serverproblem.nodata.body.text"),
+        contactLink);
+  }
+
+  private void writeSummaryAddressesInSameNetwork(PrintWriter out,
+      ResourceBundle rb, String relayIP, String timestampStr,
+      List<String> addressesInSameNetwork) throws IOException {
+    Object[][] panelItems = new Object[addressesInSameNetwork.size()][];
+    for (int i = 0; i < addressesInSameNetwork.size(); i++) {
+      String addressInSameNetwork = addressesInSameNetwork.get(i);
+      String link, address;
+      if (addressInSameNetwork.contains(":")) {
+        link = String.format("/?ip=[%s]&timestamp=%s",
+            addressInSameNetwork.replaceAll(":", "%3A"), timestampStr);
+        address = "[" + addressInSameNetwork + "]";
+      } else {
+        link = String.format("/?ip=%s&timestamp=%s",
+            addressInSameNetwork, timestampStr);
+        address = addressInSameNetwork;
+      }
+      panelItems[i] = new Object[] { link, address };
+    }
+    this.writeSummary(out, rb.getString("summary.heading"),
+        "panel-warning",
+        rb.getString("summary.negativesamenetwork.title"), panelItems,
+        rb.getString("summary.negativesamenetwork.body"),
+        relayIP, timestampStr, relayIP.contains(":") ? 48 : 24);
+  }
+
+  private void writeSummaryPositive(PrintWriter out, ResourceBundle rb,
+      String relayIP, String timestampStr) throws IOException {
+    String formattedRelayIP = relayIP.contains(":") ?
+        "[" + relayIP + "]" : relayIP;
+    this.writeSummary(out, rb.getString("summary.heading"),
+        "panel-success", rb.getString("summary.positive.title"), null,
+        rb.getString("summary.positive.body"), formattedRelayIP,
+        timestampStr);
+  }
+
+  private void writeSummaryNegative(PrintWriter out, ResourceBundle rb,
+      String relayIP, String timestampStr) throws IOException {
+    String formattedRelayIP = relayIP.contains(":") ?
+        "[" + relayIP + "]" : relayIP;
+    this.writeSummary(out, rb.getString("summary.heading"),
+        "panel-warning", rb.getString("summary.negative.title"), null,
+        rb.getString("summary.negative.body"), formattedRelayIP,
+        timestampStr);
+  }
+
+  private void writeSummary(PrintWriter out, String heading,
+      String panelContext, String panelTitle, Object[][] panelItems,
+      String panelBodyTemplate, Object... panelBodyArgs)
+      throws IOException {
+    out.printf("      <div class=\"row\">\n"
+        + "        <div class=\"col-xs-12\">\n"
+        + "          <h2>%s</h2>\n"
+        + "          <div class=\"panel %s\">\n"
+        + "            <div class=\"panel-heading\">\n"
+        + "              <h3 class=\"panel-title\">%s</h3>\n"
+        + "            </div><!-- panel-heading -->\n"
+        + "            <div class=\"panel-body\">\n"
+        + "              <p>%s</p>\n", heading, panelContext, panelTitle,
+        String.format(panelBodyTemplate, panelBodyArgs));
+    if (panelItems != null) {
+      out.print("              <ul>\n");
+      for (Object[] panelItem : panelItems) {
+        out.printf("                <li><a href=\"%s\">%s</a></li>\n",
+            panelItem);
+      }
+      out.print("              </ul>\n");
+    }
+    out.print("            </div><!-- panel-body -->\n"
+        + "          </div><!-- panel -->\n"
+        + "        </div><!-- col -->\n"
+        + "      </div><!-- row -->\n");
+  }
+
+  private void writeTechnicalDetails(PrintWriter out, ResourceBundle rb,
+      String relayIP, String timestampStr, List<String[]> tableRows)
+      throws IOException {
+    String formattedRelayIP = relayIP.contains(":") ?
+        "[" + relayIP + "]" : relayIP;
+    out.printf("      <div class=\"row\">\n"
+        + "        <div class=\"col-xs-12\">\n"
+        + "          <h2>%s</h2>\n"
+        + "          <p>%s</p>\n"
+        + "          <table class=\"table\">\n"
+        + "            <thead>\n"
+        + "              <tr>\n"
+        + "                <th>%s</th>\n"
+        + "                <th>%s</th>\n"
+        + "                <th>%s</th>\n"
+        + "                <th>%s</th>\n"
+        + "                <th>%s</th>\n"
+        + "              </tr>\n"
+        + "            </thead>\n"
+        + "            <tbody>\n",
+        rb.getString("technicaldetails.heading"),
+        String.format(rb.getString("technicaldetails.pre"),
+            formattedRelayIP, timestampStr),
+        rb.getString("technicaldetails.colheader.timestamp"),
+        rb.getString("technicaldetails.colheader.ip"),
+        rb.getString("technicaldetails.colheader.fingerprint"),
+        rb.getString("technicaldetails.colheader.nickname"),
+        rb.getString("technicaldetails.colheader.exit"));
+    for (String[] tableRow : tableRows) {
+      out.print("              <tr>");
+      for (int i = 0; i < tableRow.length; i++) {
+        String content = tableRow[i];
+        if (i == 2) {
+          content = content.substring(0, 20) + "​"
+              + content.substring(20, 40);
+        } else if (i == 3 && content == null) {
+          content = "("
+              + rb.getString("technicaldetails.nickname.unknown") + ")";
+        } else if (i == 4) {
+          if (content.equals("U")) {
+            content = rb.getString("technicaldetails.exit.unknown");
+          } else if (content.equals("Y")) {
+            content = rb.getString("technicaldetails.exit.yes");
+          } else {
+            content = rb.getString("technicaldetails.exit.no");
+          }
+        }
+        out.print("                <td>" + content + "</td>");
+      }
+      out.print("              </tr>\n");
+    }
+    out.print("            </tbody>\n"
+        + "          </table>\n"
+        + "        </div><!-- col -->\n"
+        + "      </div><!-- row -->\n");
+  }
+
+  private void writePermanentLink(PrintWriter out, ResourceBundle rb,
+      String relayIP, String timestampStr) throws IOException {
+    String encodedAddress = relayIP.contains(":") ?
+        "[" + relayIP.replaceAll(":", "%3A") + "]" : relayIP;
+    out.printf("      <div class=\"row\">\n"
+        + "        <div class=\"col-xs-12\">\n"
+        + "          <h2>%s</h2>\n"
+        + "          <pre>https://exonerator.torproject.org/?ip=%s&"
+          + "timestamp=%s</pre>\n"
+        + "        </div><!-- col -->\n"
+        + "      </div><!-- row -->\n",
+        rb.getString("permanentlink.heading"),
+        encodedAddress, timestampStr);
+  }
+
+  private void writeFooter(PrintWriter out, ResourceBundle rb)
+      throws IOException {
+    out.printf("    </div><!-- container -->\n"
+        + "    <div class=\"footer\">\n"
+        + "      <div class=\"container\">\n"
+        + "        <div class=\"row\">\n"
+        + "          <div class=\"col-xs-6\">\n"
+        + "            <h3>%s</h3>\n"
+        + "            <p class=\"small\">%s</p>\n"
+        + "          </div><!-- col -->\n",
+        rb.getString("footer.abouttor.heading"),
+        String.format(rb.getString("footer.abouttor.body.text"),
+            "<a href=\"https://www.torproject.org/about/"
+            + "overview#thesolution\">"
+            + rb.getString("footer.abouttor.body.link1") + "</a>",
+            "<a href=\"https://www.torproject.org/about/overview\">"
+            + rb.getString("footer.abouttor.body.link2") + "</a>",
+            "<a href=\"https://www.torproject.org/about/contact\">"
+            + rb.getString("footer.abouttor.body.link3") + "</a>"));
+    out.printf("          <div class=\"col-xs-6\">\n"
+        + "            <h3>%s</h3>\n"
+        + "            <p class=\"small\">%s</p>\n"
+        + "          </div><!-- col -->\n"
+        + "        </div><!-- row -->\n"
+        + "        <div class=\"row\">\n",
+        rb.getString("footer.aboutexonerator.heading"),
+        rb.getString("footer.aboutexonerator.body"));
+    out.printf("          <div class=\"col-xs-12\">\n"
+        + "            <p class=\"text-center small\">%s</p>\n"
+        + "          </div><!-- col -->\n"
+        + "        </div><!-- row -->\n"
+        + "      </div><!-- container -->\n"
+        + "    </div><!-- footer -->\n"
+        + "  </body>\n"
+        + "</html>\n",
+        String.format(rb.getString("footer.trademark.text"),
+            "<a href=\"https://www.torproject.org/docs/"
+            + "trademark-faq.html.en\">"
+            + rb.getString("footer.trademark.link") + "</a>"));
+    out.close();
+  }
+}
+
diff --git a/src/org/torproject/exonerator/ExoneraTorDatabaseImporter.java b/src/org/torproject/exonerator/ExoneraTorDatabaseImporter.java
deleted file mode 100644
index 41751ca..0000000
--- a/src/org/torproject/exonerator/ExoneraTorDatabaseImporter.java
+++ /dev/null
@@ -1,564 +0,0 @@
-/* Copyright 2011, 2012 The Tor Project
- * See LICENSE for licensing information */
-package org.torproject.exonerator;
-
-import java.io.BufferedInputStream;
-import java.io.BufferedReader;
-import java.io.BufferedWriter;
-import java.io.ByteArrayOutputStream;
-import java.io.File;
-import java.io.FileInputStream;
-import java.io.FileReader;
-import java.io.FileWriter;
-import java.io.IOException;
-import java.io.StringReader;
-import java.io.UnsupportedEncodingException;
-import java.sql.CallableStatement;
-import java.sql.Connection;
-import java.sql.DriverManager;
-import java.sql.SQLException;
-import java.sql.Timestamp;
-import java.sql.Types;
-import java.text.ParseException;
-import java.text.SimpleDateFormat;
-import java.util.Calendar;
-import java.util.HashMap;
-import java.util.HashSet;
-import java.util.Map;
-import java.util.Set;
-import java.util.Stack;
-import java.util.TimeZone;
-
-import org.apache.commons.codec.binary.Base64;
-import org.apache.commons.codec.binary.Hex;
-import org.apache.commons.codec.digest.DigestUtils;
-import org.torproject.descriptor.DescriptorCollector;
-import org.torproject.descriptor.DescriptorSourceFactory;
-
-/* Import Tor descriptors into the ExoneraTor database. */
-public class ExoneraTorDatabaseImporter {
-
-  /* Main function controlling the parsing process. */
-  public static void main(String[] args) {
-    readConfiguration();
-    openDatabaseConnection();
-    prepareDatabaseStatements();
-    createLockFile();
-    fetchDescriptors();
-    readImportHistoryToMemory();
-    parseDescriptors();
-    writeImportHistoryToDisk();
-    closeDatabaseConnection();
-    deleteLockFile();
-  }
-
-  /* JDBC string of the ExoneraTor database. */
-  private static String jdbcString;
-
-  /* Directory from which to import descriptors. */
-  private static String importDirString;
-
-  /* Learn JDBC string and directory to parse descriptors from. */
-  private static void readConfiguration() {
-    File configFile = new File("config");
-    if (!configFile.exists()) {
-      System.err.println("Could not find config file.  Exiting.");
-      System.exit(1);
-    }
-    String line = null;
-    try {
-      BufferedReader br = new BufferedReader(new FileReader(configFile));
-      while ((line = br.readLine()) != null) {
-        if (line.startsWith("#") || line.length() < 1) {
-          continue;
-        } else if (line.startsWith("ExoneraTorDatabaseJdbc")) {
-          jdbcString = line.split(" ")[1];
-        } else if (line.startsWith("ExoneraTorImportDirectory")) {
-          importDirString = line.split(" ")[1];
-        } else {
-          /* Ignore unrecognized configuration keys. */
-        }
-      }
-      br.close();
-    } catch (IOException e) {
-      System.err.println("Could not parse config file.  Exiting.");
-      System.exit(1);
-    }
-  }
-
-  /* Database connection. */
-  private static Connection connection;
-
-  /* Open a database connection using the JDBC string in the config. */
-  private static void openDatabaseConnection() {
-    try {
-      connection = DriverManager.getConnection(jdbcString);
-    } catch (SQLException e) {
-      System.out.println("Could not connect to database.  Exiting.");
-      System.exit(1);
-    }
-  }
-
-  /* Callable statements to import data into the database. */
-  private static CallableStatement insertStatusentryStatement;
-  private static CallableStatement insertExitlistentryStatement;
-
-  /* Prepare statements for importing data into the database. */
-  private static void prepareDatabaseStatements() {
-    try {
-      insertStatusentryStatement = connection.prepareCall(
-          "{call insert_statusentry(?, ?, ?, ?, ?, ?, ?)}");
-      insertExitlistentryStatement = connection.prepareCall(
-          "{call insert_exitlistentry(?, ?, ?, ?, ?)}");
-    } catch (SQLException e) {
-      System.out.println("Could not prepare callable statements to "
-          + "import data into the database.  Exiting.");
-      System.exit(1);
-    }
-  }
-
-  /* Create a local lock file to prevent other instances of this import
-   * tool to run concurrently. */
-  private static void createLockFile() {
-    File lockFile = new File("exonerator-lock");
-    try {
-      if (lockFile.exists()) {
-        BufferedReader br = new BufferedReader(new FileReader(lockFile));
-        long runStarted = Long.parseLong(br.readLine());
-        br.close();
-        if (System.currentTimeMillis() - runStarted
-            < 6L * 60L * 60L * 1000L) {
-          System.out.println("File 'exonerator-lock' is less than 6 "
-              + "hours old.  Exiting.");
-          System.exit(1);
-        } else {
-          System.out.println("File 'exonerator-lock' is at least 6 hours "
-              + "old.  Overwriting and executing anyway.");
-        }
-      }
-      BufferedWriter bw = new BufferedWriter(new FileWriter(
-          "exonerator-lock"));
-      bw.append(String.valueOf(System.currentTimeMillis()) + "\n");
-      bw.close();
-    } catch (IOException e) {
-      System.out.println("Could not create 'exonerator-lock' file.  "
-          + "Exiting.");
-      System.exit(1);
-    }
-  }
-
-  /* Fetch recent descriptors from CollecTor. */
-  private static void fetchDescriptors() {
-    DescriptorCollector collector =
-        DescriptorSourceFactory.createDescriptorCollector();
-    collector.collectDescriptors("https://collector.torproject.org",
-        new String[] { "/recent/relay-descriptors/consensuses/",
-        "/recent/exit-lists/" }, 0L, new File(importDirString), true);
-  }
-
-  /* Last and next parse histories containing paths of parsed files and
-   * last modified times. */
-  private static Map<String, Long>
-      lastImportHistory = new HashMap<String, Long>(),
-      nextImportHistory = new HashMap<String, Long>();
-
-  /* Read stats/exonerator-import-history file from disk and remember
-   * locally when files were last parsed. */
-  private static void readImportHistoryToMemory() {
-    File parseHistoryFile = new File("stats",
-        "exonerator-import-history");
-    if (parseHistoryFile.exists()) {
-      try {
-        BufferedReader br = new BufferedReader(new FileReader(
-            parseHistoryFile));
-        String line = null;
-        int lineNumber = 0;
-        while ((line = br.readLine()) != null) {
-          lineNumber++;
-          String[] parts = line.split(",");
-          if (parts.length != 2) {
-            System.out.println("File 'stats/exonerator-import-history' "
-                + "contains a corrupt entry in line " + lineNumber
-                + ".  Ignoring parse history file entirely.");
-            lastImportHistory.clear();
-            br.close();
-            return;
-          }
-          long lastModified = Long.parseLong(parts[0]);
-          String filename = parts[1];
-          lastImportHistory.put(filename, lastModified);
-        }
-        br.close();
-      } catch (IOException e) {
-        System.out.println("Could not read import history.  Ignoring.");
-        lastImportHistory.clear();
-      }
-    }
-  }
-
-  /* Parse descriptors in the import directory and its subdirectories. */
-  private static void parseDescriptors() {
-    File file = new File(importDirString);
-    if (!file.exists()) {
-      System.out.println("File or directory " + importDirString + " does "
-          + "not exist.  Exiting.");
-      return;
-    }
-    Stack<File> files = new Stack<File>();
-    files.add(file);
-    while (!files.isEmpty()) {
-      file = files.pop();
-      if (file.isDirectory()) {
-        for (File f : file.listFiles()) {
-          files.add(f);
-        }
-      } else {
-        parseFile(file);
-      }
-    }
-  }
-
-  /* Import a file if it wasn't imported before, and add it to the import
-   * history for the next execution. */
-  private static void parseFile(File file) {
-    long lastModified = file.lastModified();
-    String filename = file.getName();
-    nextImportHistory.put(filename, lastModified);
-    if (!lastImportHistory.containsKey(filename) ||
-        lastImportHistory.get(filename) < lastModified) {
-      try {
-        FileInputStream fis = new FileInputStream(file);
-        BufferedInputStream bis = new BufferedInputStream(fis);
-        ByteArrayOutputStream baos = new ByteArrayOutputStream();
-        int len;
-        byte[] bytes = new byte[1024];
-        while ((len = bis.read(bytes, 0, 1024)) >= 0) {
-          baos.write(bytes, 0, len);
-        }
-        bis.close();
-        byte[] allBytes = baos.toByteArray();
-        splitFile(file, allBytes);
-      } catch (IOException e) {
-        System.out.println("Could not read '" + file + "' to memory.  "
-            + "Skipping.");
-        nextImportHistory.remove(filename);
-      }
-    }
-  }
-
-  /* Detect what descriptor type is contained in a file and split it to
-   * parse the single descriptors. */
-  private static void splitFile(File file, byte[] bytes) {
-    try {
-      String asciiString = new String(bytes, "US-ASCII");
-      BufferedReader br = new BufferedReader(new StringReader(
-          asciiString));
-      String line = br.readLine();
-      while (line != null && line.startsWith("@")) {
-        line = br.readLine();
-      }
-      if (line == null) {
-        return;
-      }
-      br.close();
-      String startToken = null;
-      if (line.equals("network-status-version 3")) {
-        startToken = "network-status-version 3";
-      } else if (line.startsWith("Downloaded ") ||
-          line.startsWith("ExitNode ")) {
-        startToken = "ExitNode ";
-      } else {
-        System.out.println("Unknown descriptor type in file '" + file
-            + "'.  Ignoring.");
-        return;
-      }
-      String splitToken = "\n" + startToken;
-      int length = bytes.length, start = asciiString.indexOf(startToken);
-      while (start < length) {
-        int end = asciiString.indexOf(splitToken, start);
-        if (end < 0) {
-          end = length;
-        } else {
-          end += 1;
-        }
-        byte[] descBytes = new byte[end - start];
-        System.arraycopy(bytes, start, descBytes, 0, end - start);
-        if (startToken.equals("network-status-version 3")) {
-          parseConsensus(file, descBytes);
-        } else if (startToken.equals("ExitNode ")) {
-          parseExitList(file, descBytes);
-        }
-        start = end;
-      }
-    } catch (IOException e) {
-      System.out.println("Could not parse descriptor '" + file + "'.  "
-          + "Skipping.");
-    }
-  }
-
-  /* Date format to parse UTC timestamps. */
-  private static SimpleDateFormat parseFormat;
-  static {
-    parseFormat = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
-    parseFormat.setTimeZone(TimeZone.getTimeZone("UTC"));
-  }
-
-  /* Parse a consensus. */
-  private static void parseConsensus(File file, byte[] bytes) {
-    try {
-      BufferedReader br = new BufferedReader(new StringReader(new String(
-          bytes, "US-ASCII")));
-      String line, fingerprint = null, descriptor = null;
-      Set<String> orAddresses = new HashSet<String>();
-      long validAfterMillis = -1L;
-      StringBuilder rawStatusentryBuilder = null;
-      boolean isRunning = false;
-      while ((line = br.readLine()) != null) {
-        if (line.startsWith("vote-status ") &&
-            !line.equals("vote-status consensus")) {
-          System.out.println("File '" + file + "' contains network status "
-              + "*votes*, not network status *consensuses*.  Skipping.");
-          return;
-        } else if (line.startsWith("valid-after ")) {
-          String validAfterTime = line.substring("valid-after ".length());
-          try {
-            validAfterMillis = parseFormat.parse(validAfterTime).
-                getTime();
-          } catch (ParseException e) {
-            System.out.println("Could not parse valid-after timestamp in "
-                + "'" + file + "'.  Skipping.");
-            return;
-          }
-        } else if (line.startsWith("r ") ||
-            line.equals("directory-footer")) {
-          if (isRunning) {
-            byte[] rawStatusentry = rawStatusentryBuilder.toString().
-                getBytes();
-            importStatusentry(validAfterMillis, fingerprint, descriptor,
-                orAddresses, rawStatusentry);
-            orAddresses = new HashSet<String>();
-          }
-          if (line.equals("directory-footer")) {
-            return;
-          }
-          rawStatusentryBuilder = new StringBuilder(line + "\n");
-          String[] parts = line.split(" ");
-          if (parts.length < 9) {
-            System.out.println("Could not parse r line '" + line
-                + "'.  Skipping.");
-            return;
-          }
-          fingerprint = Hex.encodeHexString(Base64.decodeBase64(parts[2]
-              + "=")).toLowerCase();
-          descriptor = Hex.encodeHexString(Base64.decodeBase64(parts[3]
-              + "=")).toLowerCase();
-          orAddresses.add(parts[6]);
-        } else if (line.startsWith("a ")) {
-          rawStatusentryBuilder.append(line + "\n");
-          orAddresses.add(line.substring("a ".length(),
-              line.lastIndexOf(":")));
-        } else if (line.startsWith("s ") || line.equals("s")) {
-          rawStatusentryBuilder.append(line + "\n");
-          isRunning = line.contains(" Running");
-        } else if (rawStatusentryBuilder != null) {
-          rawStatusentryBuilder.append(line + "\n");
-        }
-      }
-    } catch (IOException e) {
-      System.out.println("Could not parse consensus.  Skipping.");
-      return;
-    }
-  }
-
-  /* UTC calendar for importing timestamps into the database. */
-  private static Calendar calendarUTC = Calendar.getInstance(
-      TimeZone.getTimeZone("UTC"));
-
-  /* Import a status entry with one or more OR addresses into the
-   * database. */
-  private static void importStatusentry(long validAfterMillis,
-      String fingerprint, String descriptor, Set<String> orAddresses,
-      byte[] rawStatusentry) {
-    try {
-      for (String orAddress : orAddresses) {
-        insertStatusentryStatement.clearParameters();
-        insertStatusentryStatement.setTimestamp(1,
-            new Timestamp(validAfterMillis), calendarUTC);
-        insertStatusentryStatement.setString(2, fingerprint);
-        insertStatusentryStatement.setString(3, descriptor);
-        if (!orAddress.contains(":")) {
-          String[] addressParts = orAddress.split("\\.");
-          byte[] address24Bytes = new byte[3];
-          address24Bytes[0] = (byte) Integer.parseInt(addressParts[0]);
-          address24Bytes[1] = (byte) Integer.parseInt(addressParts[1]);
-          address24Bytes[2] = (byte) Integer.parseInt(addressParts[2]);
-          String orAddress24 = Hex.encodeHexString(address24Bytes);
-          insertStatusentryStatement.setString(4, orAddress24);
-          insertStatusentryStatement.setNull(5, Types.VARCHAR);
-          insertStatusentryStatement.setString(6, orAddress);
-        } else {
-          StringBuilder addressHex = new StringBuilder();
-          int start = orAddress.startsWith("[::") ? 2 : 1;
-          int end = orAddress.length()
-              - (orAddress.endsWith("::]") ? 2 : 1);
-          String[] parts = orAddress.substring(start, end).split(":", -1);
-          for (int i = 0; i < parts.length; i++) {
-            String part = parts[i];
-            if (part.length() == 0) {
-              addressHex.append("x");
-            } else if (part.length() <= 4) {
-              addressHex.append(String.format("%4s", part));
-            } else {
-              addressHex = null;
-              break;
-            }
-          }
-          String orAddress48 = null;
-          if (addressHex != null) {
-            String addressHexString = addressHex.toString();
-            addressHexString = addressHexString.replaceFirst("x",
-                String.format("%" + (33 - addressHexString.length())
-                + "s", "0"));
-            if (!addressHexString.contains("x") &&
-                addressHexString.length() == 32) {
-              orAddress48 = addressHexString.replaceAll(" ", "0").
-                  toLowerCase().substring(0, 12);
-            }
-          }
-          if (orAddress48 != null) {
-            insertStatusentryStatement.setNull(4, Types.VARCHAR);
-            insertStatusentryStatement.setString(5, orAddress48);
-            insertStatusentryStatement.setString(6,
-                orAddress.replaceAll("[\\[\\]]", ""));
-          } else {
-            System.err.println("Could not import status entry with IPv6 "
-                + "address '" + orAddress + "'.  Exiting.");
-            System.exit(1);
-          }
-        }
-        insertStatusentryStatement.setBytes(7, rawStatusentry);
-        insertStatusentryStatement.execute();
-      }
-    } catch (SQLException e) {
-      System.out.println("Could not import status entry.  Exiting.");
-      System.exit(1);
-    }
-  }
-
-  /* Parse an exit list. */
-  private static void parseExitList(File file, byte[] bytes) {
-    try {
-      BufferedReader br = new BufferedReader(new StringReader(new String(
-          bytes, "US-ASCII")));
-      String fingerprint = null;
-      Set<String> exitAddressLines = new HashSet<String>();
-      StringBuilder rawExitlistentryBuilder = new StringBuilder();
-      while (true) {
-        String line = br.readLine();
-        if ((line == null || line.startsWith("ExitNode ")) &&
-            fingerprint != null) {
-          for (String exitAddressLine : exitAddressLines) {
-            String[] parts = exitAddressLine.split(" ");
-            String exitAddress = parts[1];
-            /* TODO Extend the following code for IPv6 once the exit list
-             * format supports it. */
-            String[] exitAddressParts = exitAddress.split("\\.");
-            byte[] exitAddress24Bytes = new byte[3];
-            exitAddress24Bytes[0] = (byte) Integer.parseInt(
-                exitAddressParts[0]);
-            exitAddress24Bytes[1] = (byte) Integer.parseInt(
-                exitAddressParts[1]);
-            exitAddress24Bytes[2] = (byte) Integer.parseInt(
-                exitAddressParts[2]);
-            String exitAddress24 = Hex.encodeHexString(
-                exitAddress24Bytes);
-            String scannedTime = parts[2] + " " + parts[3];
-            long scannedMillis = -1L;
-            try {
-              scannedMillis = parseFormat.parse(scannedTime).getTime();
-            } catch (ParseException e) {
-              System.out.println("Could not parse timestamp in "
-                  + "'" + file + "'.  Skipping.");
-              return;
-            }
-            byte[] rawExitlistentry = rawExitlistentryBuilder.toString().
-                getBytes();
-            importExitlistentry(fingerprint, exitAddress24, exitAddress,
-                scannedMillis, rawExitlistentry);
-          }
-          exitAddressLines.clear();
-          rawExitlistentryBuilder = new StringBuilder();
-        }
-        if (line == null) {
-          break;
-        }
-        rawExitlistentryBuilder.append(line + "\n");
-        if (line.startsWith("ExitNode ")) {
-          fingerprint = line.substring("ExitNode ".length()).
-              toLowerCase();
-        } else if (line.startsWith("ExitAddress ")) {
-          exitAddressLines.add(line);
-        }
-      }
-      br.close();
-    } catch (IOException e) {
-      System.out.println("Could not parse exit list.  Skipping.");
-      return;
-    }
-  }
-
-  /* Import an exit list entry into the database. */
-  private static void importExitlistentry(String fingerprint,
-      String exitAddress24, String exitAddress, long scannedMillis,
-      byte[] rawExitlistentry) {
-    try {
-      insertExitlistentryStatement.clearParameters();
-      insertExitlistentryStatement.setString(1, fingerprint);
-      insertExitlistentryStatement.setString(2, exitAddress24);
-      insertExitlistentryStatement.setString(3, exitAddress);
-      insertExitlistentryStatement.setTimestamp(4,
-          new Timestamp(scannedMillis), calendarUTC);
-      insertExitlistentryStatement.setBytes(5, rawExitlistentry);
-      insertExitlistentryStatement.execute();
-    } catch (SQLException e) {
-      System.out.println("Could not import exit list entry.  Exiting.");
-      System.exit(1);
-    }
-  }
-
-  /* Write parse history from memory to disk for the next execution. */
-  private static void writeImportHistoryToDisk() {
-    File parseHistoryFile = new File("stats/exonerator-import-history");
-    parseHistoryFile.getParentFile().mkdirs();
-    try {
-      BufferedWriter bw = new BufferedWriter(new FileWriter(
-          parseHistoryFile));
-      for (Map.Entry<String, Long> historyEntry :
-          nextImportHistory.entrySet()) {
-        bw.write(String.valueOf(historyEntry.getValue()) + ","
-            + historyEntry.getKey() + "\n");
-      }
-      bw.close();
-    } catch (IOException e) {
-      System.out.println("File 'stats/exonerator-import-history' could "
-          + "not be written.  Ignoring.");
-    }
-  }
-
-  /* Close the database connection. */
-  private static void closeDatabaseConnection() {
-    try {
-      connection.close();
-    } catch (SQLException e) {
-      System.out.println("Could not close database connection.  "
-          + "Ignoring.");
-    }
-  }
-
-  /* Delete the exonerator-lock file to allow the next executing of this
-   * tool. */
-  private static void deleteLockFile() {
-    new File("exonerator-lock").delete();
-  }
-}
-
diff --git a/src/org/torproject/exonerator/ExoneraTorServlet.java b/src/org/torproject/exonerator/ExoneraTorServlet.java
deleted file mode 100644
index 68d79a3..0000000
--- a/src/org/torproject/exonerator/ExoneraTorServlet.java
+++ /dev/null
@@ -1,911 +0,0 @@
-/* Copyright 2011--2015 The Tor Project
- * See LICENSE for licensing information */
-package org.torproject.exonerator;
-
-import java.io.IOException;
-import java.io.PrintWriter;
-import java.sql.CallableStatement;
-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.List;
-import java.util.Locale;
-import java.util.ResourceBundle;
-import java.util.SortedSet;
-import java.util.TimeZone;
-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.Hex;
-import org.apache.commons.lang.StringEscapeUtils;
-
-public class ExoneraTorServlet extends HttpServlet {
-
-  private static final long serialVersionUID = 1370088989739567509L;
-
-  private DataSource ds;
-
-  private Logger logger;
-
-  public void init() {
-
-    /* Initialize logger. */
-    this.logger = Logger.getLogger(ExoneraTorServlet.class.toString());
-
-    /* Look up data source. */
-    try {
-      Context cxt = new InitialContext();
-      this.ds = (DataSource) cxt.lookup("java:comp/env/jdbc/exonerator");
-      this.logger.info("Successfully looked up data source.");
-    } catch (NamingException e) {
-      this.logger.log(Level.WARNING, "Could not look up data source", e);
-    }
-  }
-
-  public void doGet(HttpServletRequest request,
-      HttpServletResponse response) throws IOException,
-      ServletException {
-
-    /* Set content type, or the page doesn't render in Chrome. */
-    response.setContentType("text/html");
-
-    /* Start writing response. */
-    PrintWriter out = response.getWriter();
-    this.writeHeader(out);
-
-    /* Find the right resource bundle for the user's locale. */
-    Locale locale = request.getLocale();
-    ResourceBundle rb = ResourceBundle.getBundle("ExoneraTor", locale);
-
-    /* Open a database connection that we'll use to handle the whole
-     * request. */
-    long requestedConnection = System.currentTimeMillis();
-    Connection conn = this.connectToDatabase();
-    if (conn == null) {
-      this.writeSummaryUnableToConnectToDatabase(out, rb);
-      this.writeFooter(out, rb);
-      return;
-    }
-
-    /* Look up first and last date in the database. */
-    long[] firstAndLastDates = this.queryFirstAndLastDatesFromDatabase(
-        conn);
-    if (firstAndLastDates == null) {
-      this.writeSummaryNoData(out, rb);
-      this.writeFooter(out, rb);
-      this.closeDatabaseConnection(conn, requestedConnection);
-    }
-    SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd");
-    dateFormat.setTimeZone(TimeZone.getTimeZone("UTC"));
-    String firstDate = dateFormat.format(firstAndLastDates[0]);
-    String lastDate = dateFormat.format(firstAndLastDates[1]);
-
-    /* Parse parameters. */
-    String ipParameter = request.getParameter("ip");
-    String relayIP = this.parseIpParameter(ipParameter);
-    boolean relayIPHasError = relayIP == null;
-
-    /* Parse timestamp parameter. */
-    String timestampParameter = request.getParameter("timestamp");
-    String timestampStr = this.parseTimestampParameter(
-        timestampParameter);
-    boolean timestampHasError = timestampStr == null;
-
-    /* Check that timestamp is within range. */
-    long timestamp = 0L;
-    boolean timestampOutOfRange = false;
-    if (timestampStr != null && timestampStr.length() > 0) {
-      try {
-        timestamp = dateFormat.parse(timestampParameter).getTime();
-        if (timestamp < firstAndLastDates[0] ||
-            timestamp > firstAndLastDates[1]) {
-          timestampOutOfRange = true;
-        }
-      } catch (ParseException e) {
-        /* Already checked in parseTimestamp(). */
-      }
-    }
-
-    /* Write form. */
-    this.writeForm(out, rb, relayIP, relayIPHasError ||
-        ("".equals(relayIP) && !"".equals(timestampStr)), timestampStr,
-        !relayIPHasError &&
-        !("".equals(relayIP) && !"".equals(timestampStr)) &&
-        (timestampHasError || timestampOutOfRange ||
-        (!"".equals(relayIP) && "".equals(timestampStr))));
-
-    /* If both parameters are empty, don't print any summary and exit.
-     * This is the start page. */
-    if ("".equals(relayIP) && "".equals(timestampStr)) {
-      this.writeFooter(out, rb);
-      this.closeDatabaseConnection(conn, requestedConnection);
-      return;
-    }
-
-    /* If either parameter is empty, print summary with warning message
-     * and exit. */
-    if ("".equals(relayIP) || "".equals(timestampStr)) {
-      if ("".equals(relayIP)) {
-        writeSummaryNoIp(out, rb);
-      } else {
-        writeSummaryNoTimestamp(out, rb);
-      }
-      this.writeFooter(out, rb);
-      this.closeDatabaseConnection(conn, requestedConnection);
-      return;
-    }
-
-    /* If there's a user error, print summary with exit message and
-     * exit. */
-    if (relayIPHasError || timestampHasError || timestampOutOfRange) {
-      if (relayIPHasError) {
-        this.writeSummaryInvalidIp(out, rb, ipParameter);
-      } else if (timestampHasError) {
-        this.writeSummaryInvalidTimestamp(out, rb, timestampParameter);
-      } else if (timestampOutOfRange) {
-        this.writeSummaryTimestampOutsideRange(out, rb, timestampStr,
-            firstDate, lastDate);
-      }
-      this.writeFooter(out, rb);
-      this.closeDatabaseConnection(conn, requestedConnection);
-      return;
-    }
-
-    /* Consider all consensuses published on or within a day of the given
-     * date. */
-    long timestampFrom = timestamp - 24L * 60L * 60L * 1000L;
-    long timestampTo = timestamp + 2 * 24L * 60L * 60L * 1000L - 1L;
-    SimpleDateFormat validAfterTimeFormat = new SimpleDateFormat(
-        "yyyy-MM-dd HH:mm:ss");
-    validAfterTimeFormat.setTimeZone(TimeZone.getTimeZone("UTC"));
-    String fromValidAfter = validAfterTimeFormat.format(timestampFrom);
-    String toValidAfter = validAfterTimeFormat.format(timestampTo);
-    SortedSet<Long> relevantConsensuses =
-        this.queryKnownConsensusValidAfterTimes(conn, fromValidAfter,
-        toValidAfter);
-    if (relevantConsensuses == null || relevantConsensuses.isEmpty()) {
-      this.writeSummaryNoDataForThisInterval(out, rb);
-      this.writeFooter(out, rb);
-      this.closeDatabaseConnection(conn, requestedConnection);
-      return;
-    }
-
-    /* Search for status entries with the given IP address as onion
-     * routing address, plus status entries of relays having an exit list
-     * entry with the given IP address as exit address. */
-    List<String[]> statusEntries = this.queryStatusEntries(conn, relayIP,
-        timestamp, validAfterTimeFormat);
-
-    /* If we didn't find anything, run another query to find out if there
-     * are relays running on other IP addresses in the same /24 or /48
-     * network and tell the user about it. */
-    List<String> addressesInSameNetwork = null;
-    if (statusEntries.isEmpty()) {
-      addressesInSameNetwork = new ArrayList<String>();
-      if (!relayIP.contains(":")) {
-        String address24 = this.convertIPv4ToHex(relayIP).substring(0, 6);
-        if (address24 != null) {
-          addressesInSameNetwork = this.queryAddressesInSame24(conn,
-              address24, timestamp);
-        }
-      } else {
-        String address48 = this.convertIPv6ToHex(relayIP).substring(
-            0, 12);
-        if (address48 != null) {
-          addressesInSameNetwork = this.queryAddressesInSame48(conn,
-              address48, timestamp);
-        }
-      }
-    }
-
-    /* Print out result. */
-    if (!statusEntries.isEmpty()) {
-      this.writeSummaryPositive(out, rb, relayIP, timestampStr);
-      this.writeTechnicalDetails(out, rb, relayIP, timestampStr,
-          statusEntries);
-    } else if (addressesInSameNetwork != null &&
-        !addressesInSameNetwork.isEmpty()) {
-      this.writeSummaryAddressesInSameNetwork(out, rb, relayIP,
-          timestampStr, addressesInSameNetwork);
-    } else {
-      this.writeSummaryNegative(out, rb, relayIP, timestampStr);
-    }
-
-    this.writePermanentLink(out, rb, relayIP, timestampStr);
-
-    this.closeDatabaseConnection(conn, requestedConnection);
-    this.writeFooter(out, rb);
-  }
-
-  /* Helper methods for handling the request. */
-
-  private String parseIpParameter(String passedIpParameter) {
-    String relayIP = null;
-    if (passedIpParameter != null && passedIpParameter.length() > 0) {
-      String ipParameter = passedIpParameter.trim();
-      Pattern ipv4AddressPattern = Pattern.compile(
-          "^([01]?\\d\\d?|2[0-4]\\d|25[0-5])\\." +
-          "([01]?\\d\\d?|2[0-4]\\d|25[0-5])\\." +
-          "([01]?\\d\\d?|2[0-4]\\d|25[0-5])\\." +
-          "([01]?\\d\\d?|2[0-4]\\d|25[0-5])$");
-      Pattern ipv6AddressPattern = Pattern.compile(
-          "^\\[?[0-9a-fA-F:]{3,39}\\]?$");
-      if (ipv4AddressPattern.matcher(ipParameter).matches()) {
-        String[] ipParts = ipParameter.split("\\.");
-        relayIP = Integer.parseInt(ipParts[0]) + "."
-            + Integer.parseInt(ipParts[1]) + "."
-            + Integer.parseInt(ipParts[2]) + "."
-            + Integer.parseInt(ipParts[3]);
-      } else if (ipv6AddressPattern.matcher(ipParameter).matches()) {
-        if (ipParameter.startsWith("[") && ipParameter.endsWith("]")) {
-          ipParameter = ipParameter.substring(1,
-              ipParameter.length() - 1);
-        }
-        StringBuilder addressHex = new StringBuilder();
-        int start = ipParameter.startsWith("::") ? 1 : 0;
-        int end = ipParameter.length()
-            - (ipParameter.endsWith("::") ? 1 : 0);
-        String[] parts = ipParameter.substring(start, end).split(":", -1);
-        for (int i = 0; i < parts.length; i++) {
-          String part = parts[i];
-          if (part.length() == 0) {
-            addressHex.append("x");
-          } else if (part.length() <= 4) {
-            addressHex.append(String.format("%4s", part));
-          } else {
-            addressHex = null;
-            break;
-          }
-        }
-        if (addressHex != null) {
-          String addressHexString = addressHex.toString();
-          addressHexString = addressHexString.replaceFirst("x",
-              String.format("%" + (33 - addressHexString.length()) + "s",
-              "0"));
-          if (!addressHexString.contains("x") &&
-              addressHexString.length() == 32) {
-            relayIP = ipParameter.toLowerCase();
-          }
-        }
-      }
-    } else {
-      relayIP = "";
-    }
-    return relayIP;
-  }
-
-  private String convertIPv4ToHex(String relayIP) {
-    String[] relayIPParts = relayIP.split("\\.");
-    byte[] address24Bytes = new byte[4];
-    for (int i = 0; i < address24Bytes.length; i++) {
-      address24Bytes[i] = (byte) Integer.parseInt(relayIPParts[i]);
-    }
-    String address24 = Hex.encodeHexString(address24Bytes);
-    return address24;
-  }
-
-  private String convertIPv6ToHex(String relayIP) {
-    if (relayIP.startsWith("[") && relayIP.endsWith("]")) {
-      relayIP = relayIP.substring(1, relayIP.length() - 1);
-    }
-    StringBuilder addressHex = new StringBuilder();
-    int start = relayIP.startsWith("::") ? 1 : 0;
-    int end = relayIP.length() - (relayIP.endsWith("::") ? 1 : 0);
-    String[] parts = relayIP.substring(start, end).split(":", -1);
-    for (int i = 0; i < parts.length; i++) {
-      String part = parts[i];
-      if (part.length() == 0) {
-        addressHex.append("x");
-      } else if (part.length() <= 4) {
-        addressHex.append(String.format("%4s", part));
-      } else {
-        addressHex = null;
-        break;
-      }
-    }
-    String address48 = null;
-    if (addressHex != null) {
-      String addressHexString = addressHex.toString();
-      addressHexString = addressHexString.replaceFirst("x",
-          String.format("%" + (33 - addressHexString.length())
-          + "s", "0"));
-      if (!addressHexString.contains("x") &&
-          addressHexString.length() == 32) {
-        address48 = addressHexString.replaceAll(" ", "0").
-            toLowerCase();
-      }
-    }
-    return address48;
-  }
-
-  private String parseTimestampParameter(
-      String passedTimestampParameter) {
-    String timestampStr = "";
-    SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd");
-    dateFormat.setTimeZone(TimeZone.getTimeZone("UTC"));
-    dateFormat.setLenient(false);
-    if (passedTimestampParameter != null &&
-        passedTimestampParameter.length() > 0) {
-      String timestampParameter = passedTimestampParameter.trim();
-      try {
-        long timestamp = dateFormat.parse(timestampParameter).getTime();
-        timestampStr = dateFormat.format(timestamp);
-      } catch (ParseException e) {
-        timestampStr = null;
-      }
-    }
-    return timestampStr;
-  }
-
-  /* Helper methods for querying the database. */
-
-  private Connection connectToDatabase() {
-    Connection conn = null;
-    try {
-      conn = this.ds.getConnection();
-    } catch (SQLException e) {
-    }
-    return conn;
-  }
-
-  private long[] queryFirstAndLastDatesFromDatabase(Connection conn) {
-    long[] firstAndLastDates = null;
-    try {
-      Statement statement = conn.createStatement();
-      String query = "SELECT DATE(MIN(validafter)) AS first, "
-          + "DATE(MAX(validafter)) AS last FROM statusentry";
-      ResultSet rs = statement.executeQuery(query);
-      if (rs.next()) {
-        Calendar utcCalendar = Calendar.getInstance(
-            TimeZone.getTimeZone("UTC"));
-        firstAndLastDates = new long[] {
-            rs.getTimestamp(1, utcCalendar).getTime(),
-            rs.getTimestamp(2, utcCalendar).getTime()
-        };
-      }
-      rs.close();
-      statement.close();
-    } catch (SQLException e) {
-      /* Looks like we don't have any consensuses. */
-      firstAndLastDates = null;
-    }
-    return firstAndLastDates;
-  }
-
-  private SortedSet<Long> queryKnownConsensusValidAfterTimes(
-      Connection conn, String fromValidAfter, String toValidAfter) {
-    SortedSet<Long> relevantConsensuses = new TreeSet<Long>();
-    try {
-      Statement statement = conn.createStatement();
-      String query = "SELECT DISTINCT validafter FROM statusentry "
-          + "WHERE validafter >= '" + fromValidAfter
-          + "' AND validafter <= '" + toValidAfter + "'";
-      ResultSet rs = statement.executeQuery(query);
-      while (rs.next()) {
-        long consensusTime = rs.getTimestamp(1).getTime();
-        relevantConsensuses.add(consensusTime);
-      }
-      rs.close();
-      statement.close();
-    } catch (SQLException e) {
-      /* Looks like we don't have any consensuses in the requested
-       * interval. */
-      relevantConsensuses = null;
-    }
-    return relevantConsensuses;
-  }
-
-  private List<String[]> queryStatusEntries(Connection conn,
-      String relayIP, long timestamp,
-      SimpleDateFormat validAfterTimeFormat) {
-    List<String[]> statusEntries = new ArrayList<String[]>();
-    String addressHex = !relayIP.contains(":")
-        ? this.convertIPv4ToHex(relayIP) : this.convertIPv6ToHex(relayIP);
-    if (addressHex == null) {
-      return null;
-    }
-    String address24Or48Hex = !relayIP.contains(":")
-        ? addressHex.substring(0, 6) : addressHex.substring(0, 12);
-    try {
-      CallableStatement cs;
-      if (!relayIP.contains(":")) {
-        cs = conn.prepareCall("{call search_by_address24_date(?, ?)}");
-      } else {
-        cs = conn.prepareCall("{call search_by_address48_date(?, ?)}");
-      }
-      cs.setString(1, address24Or48Hex);
-      Calendar utcCalendar = Calendar.getInstance(
-          TimeZone.getTimeZone("UTC"));
-      cs.setDate(2, new java.sql.Date(timestamp), utcCalendar);
-      ResultSet rs = cs.executeQuery();
-      while (rs.next()) {
-        byte[] rawstatusentry = rs.getBytes(1);
-        SortedSet<String> addresses = new TreeSet<String>(),
-            addressesHex = new TreeSet<String>();
-        long validafter = rs.getTimestamp(2, utcCalendar).getTime();
-        String validAfterString = validAfterTimeFormat.format(validafter);
-        String fingerprint = rs.getString(3).toUpperCase();
-        String nickname = null;
-        String exit = "U";
-        for (String line : new String(rawstatusentry).split("\n")) {
-          if (line.startsWith("r ")) {
-            String[] parts = line.split(" ");
-            nickname = parts[1];
-            addresses.add(parts[6]);
-            addressesHex.add(this.convertIPv4ToHex(parts[6]));
-          } else if (line.startsWith("a ")) {
-            String address = line.substring("a ".length(),
-                line.lastIndexOf(":"));
-            addresses.add(address);
-            String orAddressHex = !address.contains(":")
-                ? this.convertIPv4ToHex(address)
-                : this.convertIPv6ToHex(address);
-            addressesHex.add(orAddressHex);
-          } else if (line.startsWith("p ")) {
-            exit = line.equals("p reject 1-65535") ? "N" : "Y";
-          }
-        }
-        String exitaddress = rs.getString(4);
-        if (exitaddress != null && exitaddress.length() > 0) {
-          addresses.add(exitaddress);
-          addressesHex.add(this.convertIPv4ToHex(exitaddress));
-        }
-        if (!addressesHex.contains(addressHex)) {
-          continue;
-        }
-        StringBuilder sb = new StringBuilder();
-        int writtenAddresses = 0;
-        for (String address : addresses) {
-          sb.append((writtenAddresses++ > 0 ? ", " : "") + address);
-        }
-        String[] statusEntry = new String[] { validAfterString,
-            sb.toString(), fingerprint, nickname, exit };
-        statusEntries.add(statusEntry);
-      }
-      rs.close();
-      cs.close();
-    } catch (SQLException e) {
-      /* Nothing found. */
-      statusEntries = null;
-    }
-    return statusEntries;
-  }
-
-  private List<String> queryAddressesInSame24(Connection conn,
-      String address24, long timestamp) {
-    List<String> addressesInSameNetwork = new ArrayList<String>();
-    try {
-      CallableStatement cs = conn.prepareCall(
-          "{call search_addresses_in_same_24 (?, ?)}");
-      cs.setString(1, address24);
-      cs.setDate(2, new java.sql.Date(timestamp));
-      ResultSet rs = cs.executeQuery();
-      while (rs.next()) {
-        String address = rs.getString(1);
-        if (!addressesInSameNetwork.contains(address)) {
-          addressesInSameNetwork.add(address);
-        }
-      }
-      rs.close();
-      cs.close();
-    } catch (SQLException e) {
-      /* No other addresses in the same /24 found. */
-      addressesInSameNetwork = null;
-    }
-    return addressesInSameNetwork;
-  }
-
-  private List<String> queryAddressesInSame48(Connection conn,
-      String address48, long timestamp) {
-    List<String> addressesInSameNetwork = new ArrayList<String>();
-    try {
-      CallableStatement cs = conn.prepareCall(
-          "{call search_addresses_in_same_48 (?, ?)}");
-      cs.setString(1, address48);
-      cs.setDate(2, new java.sql.Date(timestamp));
-      ResultSet rs = cs.executeQuery();
-      while (rs.next()) {
-        String address = rs.getString(1);
-        if (!addressesInSameNetwork.contains(address)) {
-          addressesInSameNetwork.add(address);
-        }
-      }
-      rs.close();
-      cs.close();
-    } catch (SQLException e) {
-      /* No other addresses in the same /48 found. */
-      addressesInSameNetwork = null;
-    }
-    return addressesInSameNetwork;
-  }
-
-  private void closeDatabaseConnection(Connection conn,
-      long requestedConnection) {
-    try {
-      conn.close();
-      this.logger.info("Returned a database connection to the pool "
-          + "after " + (System.currentTimeMillis()
-          - requestedConnection) + " millis.");
-    } catch (SQLException e) {
-    }
-    return;
-  }
-
-  /* Helper methods for writing the response. */
-
-  private void writeHeader(PrintWriter out) throws IOException {
-    out.println("<!DOCTYPE html>\n"
-        + "<html lang=\"en\">\n"
-        + "  <head>\n"
-        + "    <meta charset=\"utf-8\">\n"
-        + "    <meta http-equiv=\"X-UA-Compatible\" "
-          + "content=\"IE=edge\">\n"
-        + "    <meta name=\"viewport\" content=\"width=device-width, "
-          + "initial-scale=1\">\n"
-        + "    <title>ExoneraTor</title>\n"
-        + "    <link rel=\"stylesheet\" href=\"css/bootstrap.min.css\">\n"
-        + "    <link rel=\"stylesheet\" href=\"css/exonerator.css\">\n"
-        + "    <link href=\"images/favicon.ico\" type=\"image/x-icon\" "
-          + "rel=\"icon\">\n"
-        + "  </head>\n"
-        + "  <body>\n"
-        + "    <div class=\"container\">\n"
-        + "      <div class=\"row\">\n"
-        + "        <div class=\"col-xs-12\">\n"
-        + "          <div class=\"page-header\">\n"
-        + "            <h1>\n"
-        + "              <div class=\"text-center\">\n"
-        + "                <a href=\"/\">"
-          + "<img src=\"images/exonerator-logo.png\" "
-          + "width=\"334\" height=\"252\" alt=\"ExoneraTor logo\">"
-          + "<img src=\"images/exonerator-wordmark.png\" width=\"428\" "
-          + "height=\"63\" alt=\"ExoneraTor wordmark\"></a>\n"
-        + "              </div><!-- text-center -->\n"
-        + "            </h1>\n"
-        + "          </div><!-- page-header -->\n"
-        + "        </div><!-- col -->\n"
-        + "      </div><!-- row -->\n");
-  }
-
-  private void writeForm(PrintWriter out, ResourceBundle rb,
-      String relayIP, boolean relayIPHasError, String timestampStr,
-      boolean timestampHasError) throws IOException {
-    String ipValue = "";
-    if (relayIP != null && relayIP.length() > 0) {
-      if (relayIP.contains(":")) {
-        ipValue = String.format(" value=\"[%s]\"", relayIP);
-      } else {
-        ipValue = String.format(" value=\"%s\"", relayIP);
-      }
-    }
-    out.printf("      <div class=\"row\">\n"
-        + "        <div class=\"col-xs-12\">\n"
-        + "          <div class=\"text-center\">\n"
-        + "            <div class=\"row vbottom15\">\n"
-        + "              <h4>%s</h4>\n"
-        + "            </div> <!-- row -->\n"
-        + "            <form class=\"form-inline\">\n"
-        + "              <div class=\"form-group%s\">\n"
-        + "                <label for=\"inputIp\" "
-          + "class=\"control-label\">%s</label>\n"
-        + "                <input type=\"text\" class=\"form-control\" "
-          + "name=\"ip\" id=\"inputIp\" placeholder=\"86.59.21.38\"%s "
-          + "required>\n"
-        + "              </div><!-- form-group -->\n"
-        + "              <div class=\"form-group%s\">\n"
-        + "                <label for=\"inputTimestamp\" "
-          + "class=\"control-label\">%s</label>\n"
-        + "                <input type=\"date\" class=\"form-control\" "
-          + "name=\"timestamp\" id=\"inputTimestamp\" "
-          + "placeholder=\"2010-01-01\"%s required>\n"
-        + "              </div><!-- form-group -->\n"
-        + "              <button type=\"submit\" "
-          + "class=\"btn btn-primary\">%s</button>\n"
-        + "            </form>\n"
-        + "          </div><!-- text-center -->\n"
-        + "        </div><!-- col -->\n"
-        + "      </div><!-- row -->\n",
-        rb.getString("form.explanation"),
-        relayIPHasError ? " has-error" : "",
-        rb.getString("form.ip.label"),
-        ipValue,
-        timestampHasError ? " has-error" : "",
-        rb.getString("form.timestamp.label"),
-        timestampStr != null && timestampStr.length() > 0 ?
-            " value=\"" + timestampStr + "\"" : "",
-        rb.getString("form.search.label"));
-  }
-
-  private void writeSummaryUnableToConnectToDatabase(PrintWriter out,
-      ResourceBundle rb) throws IOException {
-    String contactLink =
-        "<a href=\"https://www.torproject.org/about/contact\">"
-        + rb.getString("summary.serverproblem.dbempty.body.link")
-        + "</a>";
-    this.writeSummary(out, rb.getString("summary.heading"),
-        "panel-danger",
-        rb.getString("summary.serverproblem.dbnoconnect.title"), null,
-        rb.getString("summary.serverproblem.dbnoconnect.body.text"),
-        contactLink);
-  }
-
-  private void writeSummaryNoData(PrintWriter out, ResourceBundle rb)
-      throws IOException {
-    String contactLink =
-        "<a href=\"https://www.torproject.org/about/contact\">"
-        + rb.getString("summary.serverproblem.dbempty.body.link")
-        + "</a>";
-    this.writeSummary(out, rb.getString("summary.heading"),
-        "panel-danger",
-        rb.getString("summary.serverproblem.dbempty.title"), null,
-        rb.getString("summary.serverproblem.dbempty.body.text"),
-        contactLink);
-  }
-
-  private void writeSummaryNoTimestamp(PrintWriter out, ResourceBundle rb)
-      throws IOException {
-    this.writeSummary(out, rb.getString("summary.heading"),
-        "panel-danger",
-        rb.getString("summary.invalidparams.notimestamp.title"), null,
-        rb.getString("summary.invalidparams.notimestamp.body"));
-  }
-
-  private void writeSummaryNoIp(PrintWriter out, ResourceBundle rb)
-      throws IOException {
-    this.writeSummary(out, rb.getString("summary.heading"),
-        "panel-danger", rb.getString("summary.invalidparams.noip.title"),
-        null, rb.getString("summary.invalidparams.noip.body"));
-  }
-
-  private void writeSummaryTimestampOutsideRange(PrintWriter out,
-      ResourceBundle rb, String timestampStr, String firstDate,
-      String lastDate) throws IOException {
-    this.writeSummary(out, rb.getString("summary.heading"),
-        "panel-danger",
-        rb.getString("summary.invalidparams.timestamprange.title"), null,
-        rb.getString("summary.invalidparams.timestamprange.body"),
-        timestampStr, firstDate, lastDate);
-  }
-
-  private void writeSummaryInvalidIp(PrintWriter out, ResourceBundle rb,
-      String ipParameter) throws IOException {
-    String escapedIpParameter = ipParameter.length() > 40 ?
-        StringEscapeUtils.escapeHtml(ipParameter.substring(0, 40))
-        + "[...]" : StringEscapeUtils.escapeHtml(ipParameter);
-    this.writeSummary(out, rb.getString("summary.heading"),
-        "panel-danger",
-        rb.getString("summary.invalidparams.invalidip.title"), null,
-        rb.getString("summary.invalidparams.invalidip.body"),
-        escapedIpParameter, "\"a.b.c.d\"", "\"[a:b:c:d:e:f:g:h]\"");
-  }
-
-  private void writeSummaryInvalidTimestamp(PrintWriter out,
-      ResourceBundle rb, String timestampParameter) throws IOException {
-    String escapedTimestampParameter = timestampParameter.length() > 20 ?
-        StringEscapeUtils.escapeHtml(timestampParameter.
-        substring(0, 20)) + "[...]" :
-        StringEscapeUtils.escapeHtml(timestampParameter);
-    this.writeSummary(out, rb.getString("summary.heading"),
-        "panel-danger",
-        rb.getString("summary.invalidparams.invalidtimestamp.title"),
-        null, rb.getString("summary.invalidparams.invalidtimestamp.body"),
-        escapedTimestampParameter, "\"YYYY-MM-DD\"");
-  }
-
-  private void writeSummaryNoDataForThisInterval(PrintWriter out,
-      ResourceBundle rb) throws IOException {
-    String contactLink =
-        "<a href=\"https://www.torproject.org/about/contact\">"
-        + rb.getString("summary.serverproblem.dbempty.body.link")
-        + "</a>";
-    this.writeSummary(out, rb.getString("summary.heading"),
-        "panel-danger",
-        rb.getString("summary.serverproblem.nodata.title"), null,
-        rb.getString("summary.serverproblem.nodata.body.text"),
-        contactLink);
-  }
-
-  private void writeSummaryAddressesInSameNetwork(PrintWriter out,
-      ResourceBundle rb, String relayIP, String timestampStr,
-      List<String> addressesInSameNetwork) throws IOException {
-    Object[][] panelItems = new Object[addressesInSameNetwork.size()][];
-    for (int i = 0; i < addressesInSameNetwork.size(); i++) {
-      String addressInSameNetwork = addressesInSameNetwork.get(i);
-      String link, address;
-      if (addressInSameNetwork.contains(":")) {
-        link = String.format("/?ip=[%s]&timestamp=%s",
-            addressInSameNetwork.replaceAll(":", "%3A"), timestampStr);
-        address = "[" + addressInSameNetwork + "]";
-      } else {
-        link = String.format("/?ip=%s&timestamp=%s",
-            addressInSameNetwork, timestampStr);
-        address = addressInSameNetwork;
-      }
-      panelItems[i] = new Object[] { link, address };
-    }
-    this.writeSummary(out, rb.getString("summary.heading"),
-        "panel-warning",
-        rb.getString("summary.negativesamenetwork.title"), panelItems,
-        rb.getString("summary.negativesamenetwork.body"),
-        relayIP, timestampStr, relayIP.contains(":") ? 48 : 24);
-  }
-
-  private void writeSummaryPositive(PrintWriter out, ResourceBundle rb,
-      String relayIP, String timestampStr) throws IOException {
-    String formattedRelayIP = relayIP.contains(":") ?
-        "[" + relayIP + "]" : relayIP;
-    this.writeSummary(out, rb.getString("summary.heading"),
-        "panel-success", rb.getString("summary.positive.title"), null,
-        rb.getString("summary.positive.body"), formattedRelayIP,
-        timestampStr);
-  }
-
-  private void writeSummaryNegative(PrintWriter out, ResourceBundle rb,
-      String relayIP, String timestampStr) throws IOException {
-    String formattedRelayIP = relayIP.contains(":") ?
-        "[" + relayIP + "]" : relayIP;
-    this.writeSummary(out, rb.getString("summary.heading"),
-        "panel-warning", rb.getString("summary.negative.title"), null,
-        rb.getString("summary.negative.body"), formattedRelayIP,
-        timestampStr);
-  }
-
-  private void writeSummary(PrintWriter out, String heading,
-      String panelContext, String panelTitle, Object[][] panelItems,
-      String panelBodyTemplate, Object... panelBodyArgs)
-      throws IOException {
-    out.printf("      <div class=\"row\">\n"
-        + "        <div class=\"col-xs-12\">\n"
-        + "          <h2>%s</h2>\n"
-        + "          <div class=\"panel %s\">\n"
-        + "            <div class=\"panel-heading\">\n"
-        + "              <h3 class=\"panel-title\">%s</h3>\n"
-        + "            </div><!-- panel-heading -->\n"
-        + "            <div class=\"panel-body\">\n"
-        + "              <p>%s</p>\n", heading, panelContext, panelTitle,
-        String.format(panelBodyTemplate, panelBodyArgs));
-    if (panelItems != null) {
-      out.print("              <ul>\n");
-      for (Object[] panelItem : panelItems) {
-        out.printf("                <li><a href=\"%s\">%s</a></li>\n",
-            panelItem);
-      }
-      out.print("              </ul>\n");
-    }
-    out.print("            </div><!-- panel-body -->\n"
-        + "          </div><!-- panel -->\n"
-        + "        </div><!-- col -->\n"
-        + "      </div><!-- row -->\n");
-  }
-
-  private void writeTechnicalDetails(PrintWriter out, ResourceBundle rb,
-      String relayIP, String timestampStr, List<String[]> tableRows)
-      throws IOException {
-    String formattedRelayIP = relayIP.contains(":") ?
-        "[" + relayIP + "]" : relayIP;
-    out.printf("      <div class=\"row\">\n"
-        + "        <div class=\"col-xs-12\">\n"
-        + "          <h2>%s</h2>\n"
-        + "          <p>%s</p>\n"
-        + "          <table class=\"table\">\n"
-        + "            <thead>\n"
-        + "              <tr>\n"
-        + "                <th>%s</th>\n"
-        + "                <th>%s</th>\n"
-        + "                <th>%s</th>\n"
-        + "                <th>%s</th>\n"
-        + "                <th>%s</th>\n"
-        + "              </tr>\n"
-        + "            </thead>\n"
-        + "            <tbody>\n",
-        rb.getString("technicaldetails.heading"),
-        String.format(rb.getString("technicaldetails.pre"),
-            formattedRelayIP, timestampStr),
-        rb.getString("technicaldetails.colheader.timestamp"),
-        rb.getString("technicaldetails.colheader.ip"),
-        rb.getString("technicaldetails.colheader.fingerprint"),
-        rb.getString("technicaldetails.colheader.nickname"),
-        rb.getString("technicaldetails.colheader.exit"));
-    for (String[] tableRow : tableRows) {
-      out.print("              <tr>");
-      for (int i = 0; i < tableRow.length; i++) {
-        String content = tableRow[i];
-        if (i == 2) {
-          content = content.substring(0, 20) + "​"
-              + content.substring(20, 40);
-        } else if (i == 3 && content == null) {
-          content = "("
-              + rb.getString("technicaldetails.nickname.unknown") + ")";
-        } else if (i == 4) {
-          if (content.equals("U")) {
-            content = rb.getString("technicaldetails.exit.unknown");
-          } else if (content.equals("Y")) {
-            content = rb.getString("technicaldetails.exit.yes");
-          } else {
-            content = rb.getString("technicaldetails.exit.no");
-          }
-        }
-        out.print("                <td>" + content + "</td>");
-      }
-      out.print("              </tr>\n");
-    }
-    out.print("            </tbody>\n"
-        + "          </table>\n"
-        + "        </div><!-- col -->\n"
-        + "      </div><!-- row -->\n");
-  }
-
-  private void writePermanentLink(PrintWriter out, ResourceBundle rb,
-      String relayIP, String timestampStr) throws IOException {
-    String encodedAddress = relayIP.contains(":") ?
-        "[" + relayIP.replaceAll(":", "%3A") + "]" : relayIP;
-    out.printf("      <div class=\"row\">\n"
-        + "        <div class=\"col-xs-12\">\n"
-        + "          <h2>%s</h2>\n"
-        + "          <pre>https://exonerator.torproject.org/?ip=%s&"
-          + "timestamp=%s</pre>\n"
-        + "        </div><!-- col -->\n"
-        + "      </div><!-- row -->\n",
-        rb.getString("permanentlink.heading"),
-        encodedAddress, timestampStr);
-  }
-
-  private void writeFooter(PrintWriter out, ResourceBundle rb)
-      throws IOException {
-    out.printf("    </div><!-- container -->\n"
-        + "    <div class=\"footer\">\n"
-        + "      <div class=\"container\">\n"
-        + "        <div class=\"row\">\n"
-        + "          <div class=\"col-xs-6\">\n"
-        + "            <h3>%s</h3>\n"
-        + "            <p class=\"small\">%s</p>\n"
-        + "          </div><!-- col -->\n",
-        rb.getString("footer.abouttor.heading"),
-        String.format(rb.getString("footer.abouttor.body.text"),
-            "<a href=\"https://www.torproject.org/about/"
-            + "overview#thesolution\">"
-            + rb.getString("footer.abouttor.body.link1") + "</a>",
-            "<a href=\"https://www.torproject.org/about/overview\">"
-            + rb.getString("footer.abouttor.body.link2") + "</a>",
-            "<a href=\"https://www.torproject.org/about/contact\">"
-            + rb.getString("footer.abouttor.body.link3") + "</a>"));
-    out.printf("          <div class=\"col-xs-6\">\n"
-        + "            <h3>%s</h3>\n"
-        + "            <p class=\"small\">%s</p>\n"
-        + "          </div><!-- col -->\n"
-        + "        </div><!-- row -->\n"
-        + "        <div class=\"row\">\n",
-        rb.getString("footer.aboutexonerator.heading"),
-        rb.getString("footer.aboutexonerator.body"));
-    out.printf("          <div class=\"col-xs-12\">\n"
-        + "            <p class=\"text-center small\">%s</p>\n"
-        + "          </div><!-- col -->\n"
-        + "        </div><!-- row -->\n"
-        + "      </div><!-- container -->\n"
-        + "    </div><!-- footer -->\n"
-        + "  </body>\n"
-        + "</html>\n",
-        String.format(rb.getString("footer.trademark.text"),
-            "<a href=\"https://www.torproject.org/docs/"
-            + "trademark-faq.html.en\">"
-            + rb.getString("footer.trademark.link") + "</a>"));
-    out.close();
-  }
-}
-





More information about the tor-commits mailing list