Merge "Documentation: commit message hook file should be executable"
diff --git a/Documentation/config-gerrit.txt b/Documentation/config-gerrit.txt
index 5606768..cf32d88 100644
--- a/Documentation/config-gerrit.txt
+++ b/Documentation/config-gerrit.txt
@@ -2431,6 +2431,7 @@
 ----
 [auth]
   registerEmailPrivateKey = 2zHNrXE2bsoylzUqDxZp0H1cqUmjgWb6
+  restTokenPrivateKey = 7e40PzCjlUKOnXATvcBNXH6oyiu+r0dFk2c=
 
 [database]
   username = webuser
diff --git a/Documentation/licenses.txt b/Documentation/licenses.txt
index 4186026..7787fe8 100644
--- a/Documentation/licenses.txt
+++ b/Documentation/licenses.txt
@@ -28,7 +28,7 @@
 |Apache Commons Pool        | <<apache2,Apache License 2.0>>
 |Apache Log4J               | <<apache2,Apache License 2.0>>
 |Apache MINA                | <<apache2,Apache License 2.0>>
-|Apache Tomact Servlet API  | <<apache2,Apache License 2.0>>
+|Apache Tomcat Servlet API  | <<apache2,Apache License 2.0>>
 |Apache SSHD                | <<apache2,Apache License 2.0>>, see also <<sshd,NOTICE>>
 |Apache Velocity            | <<apache2,Apache License 2.0>>
 |Apache Xerces              | <<apache2,Apache License 2.0>>
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/CommitMessageBlock.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/CommitMessageBlock.java
index 6a364e0..eff3cd5 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/CommitMessageBlock.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/CommitMessageBlock.java
@@ -104,6 +104,8 @@
       SafeHtml commitBodyLinkified = new SafeHtmlBuilder().append(commitBody);
       commitBodyLinkified = commitBodyLinkified.linkify();
       commitBodyLinkified = CommentLinkProcessor.apply(commitBodyLinkified);
+      commitBodyLinkified = commitBodyLinkified.replaceAll("\n\n", "<p></p>");
+      commitBodyLinkified = commitBodyLinkified.replaceAll("\n", "<br />");
       commitBodyPre.setInnerHTML(commitBodyLinkified.asString());
     }
   }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/CommitMessageBlock.ui.xml b/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/CommitMessageBlock.ui.xml
index 16d1da5..ca81537 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/CommitMessageBlock.ui.xml
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/CommitMessageBlock.ui.xml
@@ -59,8 +59,8 @@
       font-weight: bold;
     }
 
-    .commitBody {
-      margin-top: 10px;
+    .commitBody p {
+      padding-top: 0px;
     }
 
     .starPanel {
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/PatchSetsBlock.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/PatchSetsBlock.java
index 005423f..b9ed4e7 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/PatchSetsBlock.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/PatchSetsBlock.java
@@ -89,6 +89,12 @@
     for (final PatchSet ps : patchSets) {
       final PatchSetComplexDisclosurePanel p =
           new PatchSetComplexDisclosurePanel(ps, ps == currps);
+      if (diffBaseId != null) {
+        p.setDiffBaseId(diffBaseId);
+        if (ps == currps) {
+          p.refresh();
+        }
+      }
       add(p);
       patchSetPanelsList.add(p);
     }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/rpc/RestApi.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/rpc/RestApi.java
index 4e9488c..650cacd 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/rpc/RestApi.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/rpc/RestApi.java
@@ -42,6 +42,73 @@
    */
   private static final String JSON_MAGIC = ")]}'\n";
 
+  private class MyRequestCallback<T extends JavaScriptObject> implements
+      RequestCallback {
+    private final boolean wasGet;
+    private final AsyncCallback<T> cb;
+
+    public MyRequestCallback(boolean wasGet, AsyncCallback<T> cb) {
+      this.wasGet = wasGet;
+      this.cb = cb;
+    }
+
+    @Override
+    public void onResponseReceived(Request req, Response res) {
+      int status = res.getStatusCode();
+      if (status != 200) {
+        RpcStatus.INSTANCE.onRpcComplete();
+        if ((400 <= status && status < 600) && isTextBody(res)) {
+          cb.onFailure(new RemoteJsonException(res.getText(), status, null));
+        } else {
+          cb.onFailure(new StatusCodeException(status, res.getStatusText()));
+        }
+        return;
+      }
+
+      if (!isJsonBody(res)) {
+        RpcStatus.INSTANCE.onRpcComplete();
+        cb.onFailure(new RemoteJsonException("Invalid JSON"));
+        return;
+      }
+
+      String json = res.getText();
+      if (!json.startsWith(JSON_MAGIC)) {
+        RpcStatus.INSTANCE.onRpcComplete();
+        cb.onFailure(new RemoteJsonException("Invalid JSON"));
+        return;
+      }
+      json = json.substring(JSON_MAGIC.length());
+
+      if (wasGet && json.startsWith("{\"_authkey\":")) {
+        RestApi.this.resendPost(cb, json);
+        return;
+      }
+
+      T data;
+      try {
+        // javac generics bug
+        data = Natives.<T> parseJSON(json);
+      } catch (RuntimeException e) {
+        RpcStatus.INSTANCE.onRpcComplete();
+        cb.onFailure(new RemoteJsonException("Invalid JSON"));
+        return;
+      }
+
+      cb.onSuccess(data);
+      RpcStatus.INSTANCE.onRpcComplete();
+    }
+
+    @Override
+    public void onError(Request req, Throwable err) {
+      RpcStatus.INSTANCE.onRpcComplete();
+      if (err.getMessage().contains("XmlHttpRequest.status")) {
+        cb.onFailure(new ServerUnavailableException());
+      } else {
+        cb.onFailure(err);
+      }
+    }
+  }
+
   private StringBuilder url;
   private boolean hasQueryParams;
 
@@ -101,53 +168,7 @@
   public <T extends JavaScriptObject> void send(final AsyncCallback<T> cb) {
     RequestBuilder req = new RequestBuilder(RequestBuilder.GET, url.toString());
     req.setHeader("Accept", JsonConstants.JSON_TYPE);
-    req.setCallback(new RequestCallback() {
-      @Override
-      public void onResponseReceived(Request req, Response res) {
-        RpcStatus.INSTANCE.onRpcComplete();
-        int status = res.getStatusCode();
-        if (status != 200) {
-          if ((400 <= status && status < 500) && isTextBody(res)) {
-            cb.onFailure(new RemoteJsonException(res.getText(), status, null));
-          } else {
-            cb.onFailure(new StatusCodeException(status, res.getStatusText()));
-          }
-          return;
-        }
-
-        if (!isJsonBody(res)) {
-          cb.onFailure(new RemoteJsonException("Invalid JSON"));
-          return;
-        }
-
-        String json = res.getText();
-        if (!json.startsWith(JSON_MAGIC)) {
-          cb.onFailure(new RemoteJsonException("Invalid JSON"));
-          return;
-        }
-
-        T data;
-        try {
-          // javac generics bug
-          data = Natives.<T>parseJSON(json.substring(JSON_MAGIC.length()));
-        } catch (RuntimeException e) {
-          cb.onFailure(new RemoteJsonException("Invalid JSON"));
-          return;
-        }
-
-        cb.onSuccess(data);
-      }
-
-      @Override
-      public void onError(Request req, Throwable err) {
-        RpcStatus.INSTANCE.onRpcComplete();
-        if (err.getMessage().contains("XmlHttpRequest.status")) {
-          cb.onFailure(new ServerUnavailableException());
-        } else {
-          cb.onFailure(err);
-        }
-      }
-    });
+    req.setCallback(new MyRequestCallback<T>(true, cb));
     try {
       RpcStatus.INSTANCE.onRpcStart();
       req.send();
@@ -157,6 +178,21 @@
     }
   }
 
+  private <T extends JavaScriptObject> void resendPost(
+      final AsyncCallback<T> cb, String token) {
+    RequestBuilder req = new RequestBuilder(RequestBuilder.POST, url.toString());
+    req.setHeader("Accept", JsonConstants.JSON_TYPE);
+    req.setHeader("Content-Type", JsonConstants.JSON_TYPE);
+    req.setRequestData(token);
+    req.setCallback(new MyRequestCallback<T>(false, cb));
+    try {
+      req.send();
+    } catch (RequestException e) {
+      RpcStatus.INSTANCE.onRpcComplete();
+      cb.onFailure(e);
+    }
+  }
+
   private static boolean isJsonBody(Response res) {
     return isContentType(res, JsonConstants.JSON_TYPE);
   }
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/RestApiServlet.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/RestApiServlet.java
index a4217cf..99db2f0 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/RestApiServlet.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/RestApiServlet.java
@@ -14,6 +14,9 @@
 
 package com.google.gerrit.httpd;
 
+import static javax.servlet.http.HttpServletResponse.SC_FORBIDDEN;
+import static javax.servlet.http.HttpServletResponse.SC_INTERNAL_SERVER_ERROR;
+
 import com.google.common.base.Objects;
 import com.google.common.base.Strings;
 import com.google.gerrit.extensions.annotations.RequiresCapability;
@@ -38,6 +41,7 @@
 import java.util.Map;
 import java.util.Set;
 
+import javax.annotation.Nullable;
 import javax.servlet.ServletException;
 import javax.servlet.http.HttpServlet;
 import javax.servlet.http.HttpServletRequest;
@@ -80,18 +84,20 @@
   @Override
   protected void service(HttpServletRequest req, HttpServletResponse res)
       throws ServletException, IOException {
-    noCache(res);
+    res.setHeader("Expires", "Fri, 01 Jan 1980 00:00:00 GMT");
+    res.setHeader("Pragma", "no-cache");
+    res.setHeader("Cache-Control", "no-cache, must-revalidate");
+    res.setHeader("Content-Disposition", "attachment");
+
     try {
       checkRequiresCapability();
       super.service(req, res);
     } catch (RequireCapabilityException err) {
-      res.setStatus(HttpServletResponse.SC_FORBIDDEN);
-      noCache(res);
-      sendText(req, res, err.getMessage());
+      sendError(res, SC_FORBIDDEN, err.getMessage());
     } catch (Error err) {
-      handleError(err, req, res);
+      handleException(err, req, res);
     } catch (RuntimeException err) {
-      handleError(err, req, res);
+      handleException(err, req, res);
     }
   }
 
@@ -114,16 +120,8 @@
     }
   }
 
-  private static void noCache(HttpServletResponse res) {
-    res.setHeader("Expires", "Fri, 01 Jan 1980 00:00:00 GMT");
-    res.setHeader("Pragma", "no-cache");
-    res.setHeader("Cache-Control", "no-cache, must-revalidate");
-    res.setHeader("Content-Disposition", "attachment");
-  }
-
-  private static void handleError(
-      Throwable err, HttpServletRequest req, HttpServletResponse res)
-      throws IOException {
+  private static void handleException(Throwable err, HttpServletRequest req,
+      HttpServletResponse res) throws IOException {
     String uri = req.getRequestURI();
     if (!Strings.isNullOrEmpty(req.getQueryString())) {
       uri += "?" + req.getQueryString();
@@ -132,12 +130,16 @@
 
     if (!res.isCommitted()) {
       res.reset();
-      res.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
-      noCache(res);
-      sendText(req, res, "Internal Server Error");
+      sendError(res, SC_INTERNAL_SERVER_ERROR, "Internal Server Error");
     }
   }
 
+  protected static void sendError(HttpServletResponse res,
+      int statusCode, String msg) throws IOException {
+    res.setStatus(statusCode);
+    sendText(null, res, msg);
+  }
+
   protected static boolean acceptsJson(HttpServletRequest req) {
     String accept = req.getHeader("Accept");
     if (accept == null) {
@@ -155,16 +157,17 @@
     return false;
   }
 
-  protected static void sendText(HttpServletRequest req,
+  protected static void sendText(@Nullable HttpServletRequest req,
       HttpServletResponse res, String data) throws IOException {
     res.setContentType("text/plain");
     res.setCharacterEncoding("UTF-8");
     send(req, res, data.getBytes("UTF-8"));
   }
 
-  protected static void send(HttpServletRequest req, HttpServletResponse res,
-      byte[] data) throws IOException {
-    if (data.length > 256 && RPCServletUtils.acceptsGzipEncoding(req)) {
+  protected static void send(@Nullable HttpServletRequest req,
+      HttpServletResponse res, byte[] data) throws IOException {
+    if (data.length > 256 && req != null
+        && RPCServletUtils.acceptsGzipEncoding(req)) {
       res.setHeader("Content-Encoding", "gzip");
       data = HtmlDomUtil.compress(data);
     }
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/RestTokenVerifier.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/RestTokenVerifier.java
new file mode 100644
index 0000000..783ebc7
--- /dev/null
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/RestTokenVerifier.java
@@ -0,0 +1,58 @@
+// Copyright (C) 2012 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.httpd;
+
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.server.mail.RegisterNewEmailSender;
+
+/** Verifies the token sent by {@link RegisterNewEmailSender}. */
+public interface RestTokenVerifier {
+  /**
+   * Construct a token to verify a REST PUT request.
+   *
+   * @param user the caller that wants to make a PUT request
+   * @param url the URL being requested
+   * @return an unforgeable string to send to the user as the body of a GET
+   *         request. Presenting the string in a follow-up POST request provides
+   *         proof the user has the ability to read messages sent to thier
+   *         browser and they likely aren't making the request via XSRF.
+   */
+  public String sign(Account.Id user, String url);
+
+  /**
+   * Decode a token previously created.
+   *
+   * @param user the user making the verify request.
+   * @param url the url user is attempting to access.
+   * @param token the string created by sign.
+   * @throws InvalidTokenException the token is invalid, expired, malformed,
+   *         etc.
+   */
+  public void verify(Account.Id user, String url, String token)
+      throws InvalidTokenException;
+
+  /** Exception thrown when a token does not parse correctly. */
+  public static class InvalidTokenException extends Exception {
+    private static final long serialVersionUID = 1L;
+
+    public InvalidTokenException() {
+      super("Invalid token");
+    }
+
+    public InvalidTokenException(Throwable cause) {
+      super("Invalid token", cause);
+    }
+  }
+}
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/SignedTokenRestTokenVerifier.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/SignedTokenRestTokenVerifier.java
new file mode 100644
index 0000000..83d6caa
--- /dev/null
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/SignedTokenRestTokenVerifier.java
@@ -0,0 +1,97 @@
+// Copyright (C) 2012 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.httpd;
+
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.server.config.AuthConfig;
+import com.google.gwtjsonrpc.server.SignedToken;
+import com.google.gwtjsonrpc.server.ValidToken;
+import com.google.gwtjsonrpc.server.XsrfException;
+import com.google.inject.AbstractModule;
+import com.google.inject.Inject;
+
+import org.eclipse.jgit.util.Base64;
+
+import java.io.UnsupportedEncodingException;
+
+/** Verifies the token sent by {@link RestApiServlet}. */
+public class SignedTokenRestTokenVerifier implements RestTokenVerifier {
+  private final SignedToken restToken;
+
+  public static class Module extends AbstractModule {
+    @Override
+    protected void configure() {
+      bind(RestTokenVerifier.class).to(SignedTokenRestTokenVerifier.class);
+    }
+  }
+
+  @Inject
+  SignedTokenRestTokenVerifier(AuthConfig config) {
+    restToken = config.getRestToken();
+  }
+
+  @Override
+  public String sign(Account.Id user, String url) {
+    try {
+      String payload = String.format("%s:%s", user, url);
+      byte[] utf8 = payload.getBytes("UTF-8");
+      String base64 = Base64.encodeBytes(utf8);
+      return restToken.newToken(base64);
+    } catch (XsrfException e) {
+      throw new IllegalArgumentException(e);
+    } catch (UnsupportedEncodingException e) {
+      throw new IllegalArgumentException(e);
+    }
+  }
+
+  @Override
+  public void verify(Account.Id user, String url, String tokenString)
+      throws InvalidTokenException {
+    ValidToken token;
+    try {
+      token = restToken.checkToken(tokenString, null);
+    } catch (XsrfException err) {
+      throw new InvalidTokenException(err);
+    }
+    if (token == null || token.getData() == null || token.getData().isEmpty()) {
+      throw new InvalidTokenException();
+    }
+
+    String payload;
+    try {
+      payload = new String(Base64.decode(token.getData()), "UTF-8");
+    } catch (UnsupportedEncodingException err) {
+      throw new InvalidTokenException(err);
+    }
+
+    int colonPos = payload.indexOf(':');
+    if (colonPos == -1) {
+      throw new InvalidTokenException();
+    }
+
+    Account.Id tokenUser;
+    try {
+      tokenUser = Account.Id.parse(payload.substring(0, colonPos));
+    } catch (IllegalArgumentException err) {
+      throw new InvalidTokenException(err);
+    }
+
+    String tokenUrl = payload.substring(colonPos+1);
+
+    if (!tokenUser.equals(user) || !tokenUrl.equals(url)) {
+      throw new InvalidTokenException();
+    }
+  }
+}
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/TokenVerifiedRestApiServlet.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/TokenVerifiedRestApiServlet.java
new file mode 100644
index 0000000..98a1b57
--- /dev/null
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/TokenVerifiedRestApiServlet.java
@@ -0,0 +1,263 @@
+// Copyright (C) 2012 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.httpd;
+
+import static javax.servlet.http.HttpServletResponse.SC_BAD_REQUEST;
+import static javax.servlet.http.HttpServletResponse.SC_UNAUTHORIZED;
+
+import com.google.common.base.Strings;
+import com.google.common.collect.Iterators;
+import com.google.common.collect.Maps;
+import com.google.gerrit.httpd.RestTokenVerifier.InvalidTokenException;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.OutputFormat;
+import com.google.gson.Gson;
+import com.google.gson.JsonElement;
+import com.google.gson.JsonObject;
+import com.google.gson.JsonParseException;
+import com.google.gson.JsonParser;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.net.URLDecoder;
+import java.net.URLEncoder;
+import java.util.Enumeration;
+import java.util.Map;
+
+import javax.annotation.Nullable;
+import javax.servlet.ServletException;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletRequestWrapper;
+import javax.servlet.http.HttpServletResponse;
+
+public abstract class TokenVerifiedRestApiServlet extends RestApiServlet {
+  private static final long serialVersionUID = 1L;
+  private static final String FORM_ENCODED = "application/x-www-form-urlencoded";
+  private static final String UTF_8 = "UTF-8";
+  private static final String AUTHKEY_NAME = "_authkey";
+  private static final String AUTHKEY_HEADER = "X-authkey";
+
+  private final Gson gson;
+  private final Provider<CurrentUser> userProvider;
+  private final RestTokenVerifier verifier;
+
+  @Inject
+  protected TokenVerifiedRestApiServlet(Provider<CurrentUser> userProvider,
+      RestTokenVerifier verifier) {
+    super(userProvider);
+    this.gson = OutputFormat.JSON_COMPACT.newGson();
+    this.userProvider = userProvider;
+    this.verifier = verifier;
+  }
+
+  /**
+   * Process the (possibly state changing) request.
+   *
+   * @param req incoming HTTP request.
+   * @param res outgoing response.
+   * @param requestData JSON object representing the HTTP request parameters.
+   *        Null if the request body was not supplied in JSON format.
+   * @throws IOException
+   * @throws ServletException
+   */
+  protected abstract void doRequest(HttpServletRequest req,
+      HttpServletResponse res,
+      @Nullable JsonObject requestData) throws IOException, ServletException;
+
+  @Override
+  protected final void doGet(HttpServletRequest req, HttpServletResponse res)
+      throws ServletException, IOException {
+    CurrentUser user = userProvider.get();
+    if (!(user instanceof IdentifiedUser)) {
+      sendError(res, SC_UNAUTHORIZED, "API requires authentication");
+      return;
+    }
+
+    TokenInfo info = new TokenInfo();
+    info._authkey = verifier.sign(
+        ((IdentifiedUser) user).getAccountId(),
+        computeUrl(req));
+
+    ByteArrayOutputStream buf = new ByteArrayOutputStream();
+    String type;
+    buf.write(JSON_MAGIC);
+    if (acceptsJson(req)) {
+      type = JSON_TYPE;
+      buf.write(gson.toJson(info).getBytes(UTF_8));
+    } else {
+      type = FORM_ENCODED;
+      buf.write(String.format("%s=%s",
+          AUTHKEY_NAME,
+          URLEncoder.encode(info._authkey, UTF_8)).getBytes(UTF_8));
+    }
+
+    res.setContentType(type);
+    res.setCharacterEncoding(UTF_8);
+    res.setHeader("Content-Disposition", "attachment");
+    send(req, res, buf.toByteArray());
+  }
+
+  @Override
+  protected final void doPost(HttpServletRequest req, HttpServletResponse res)
+      throws IOException, ServletException {
+    CurrentUser user = userProvider.get();
+    if (!(user instanceof IdentifiedUser)) {
+      sendError(res, SC_UNAUTHORIZED, "API requires authentication");
+      return;
+    }
+
+    ParsedBody body;
+    if (JSON_TYPE.equals(req.getContentType())) {
+      body = parseJson(req, res);
+    } else if (FORM_ENCODED.equals(req.getContentType())) {
+      body = parseForm(req, res);
+    } else {
+      sendError(res, SC_BAD_REQUEST, String.format(
+          "Expected Content-Type: %s or %s",
+          JSON_TYPE, FORM_ENCODED));
+      return;
+    }
+
+    if (body == null) {
+      return;
+    }
+
+    if (Strings.isNullOrEmpty(body._authkey)) {
+      String h = req.getHeader(AUTHKEY_HEADER);
+      if (Strings.isNullOrEmpty(h)) {
+        sendError(res, SC_BAD_REQUEST, String.format(
+            "Expected %s in request body or %s in HTTP headers",
+            AUTHKEY_NAME, AUTHKEY_HEADER));
+        return;
+      }
+      body._authkey = URLDecoder.decode(h, UTF_8);
+    }
+
+    try {
+      verifier.verify(
+          ((IdentifiedUser) user).getAccountId(),
+          computeUrl(req),
+          body._authkey);
+    } catch (InvalidTokenException err) {
+      sendError(res, SC_BAD_REQUEST,
+          String.format("Invalid or expired %s", AUTHKEY_NAME));
+      return;
+    }
+
+    doRequest(body.req, res, body.json);
+  }
+
+  private static ParsedBody parseJson(HttpServletRequest req,
+      HttpServletResponse res) throws IOException {
+    try {
+      JsonElement element = new JsonParser().parse(req.getReader());
+      if (!element.isJsonObject()) {
+        sendError(res, SC_BAD_REQUEST, "Expected JSON object in request body");
+        return null;
+      }
+
+      ParsedBody body = new ParsedBody();
+      body.req = req;
+      body.json = (JsonObject) element;
+      JsonElement authKey = body.json.remove(AUTHKEY_NAME);
+      if (authKey != null
+          && authKey.isJsonPrimitive()
+          && authKey.getAsJsonPrimitive().isString()) {
+        body._authkey = authKey.getAsString();
+      }
+      return body;
+    } catch (JsonParseException e) {
+      sendError(res, SC_BAD_REQUEST, "Invalid JSON object in request body");
+      return null;
+    }
+  }
+
+  private static ParsedBody parseForm(HttpServletRequest req,
+      HttpServletResponse res) throws IOException {
+    ParsedBody body = new ParsedBody();
+    body.req = new WrappedRequest(req);
+    body._authkey = req.getParameter(AUTHKEY_NAME);
+    return body;
+  }
+
+  private static String computeUrl(HttpServletRequest req) {
+    StringBuffer url = req.getRequestURL();
+    String qs = req.getQueryString();
+    if (!Strings.isNullOrEmpty(qs)) {
+      url.append('?').append(qs);
+    }
+    return url.toString();
+  }
+
+  private static class TokenInfo {
+    String _authkey;
+  }
+
+  private static class ParsedBody {
+    HttpServletRequest req;
+    String _authkey;
+    JsonObject json;
+  }
+
+  private static class WrappedRequest extends HttpServletRequestWrapper {
+    @SuppressWarnings("rawtypes")
+    private Map parameters;
+
+    WrappedRequest(HttpServletRequest req) {
+      super(req);
+    }
+
+    @Override
+    public String getParameter(String name) {
+      if (AUTHKEY_NAME.equals(name)) {
+        return null;
+      }
+      return super.getParameter(name);
+    }
+
+    @Override
+    public String[] getParameterValues(String name) {
+      if (AUTHKEY_NAME.equals(name)) {
+        return null;
+      }
+      return super.getParameterValues(name);
+    }
+
+    @SuppressWarnings({"rawtypes", "unchecked"})
+    @Override
+    public Map getParameterMap() {
+      Map m = parameters;
+      if (m == null) {
+        m = super.getParameterMap();
+        if (m.containsKey(AUTHKEY_NAME)) {
+          m = Maps.newHashMap(m);
+          m.remove(AUTHKEY_NAME);
+        }
+        parameters = m;
+      }
+      return m;
+    }
+
+    @SuppressWarnings({"rawtypes", "unchecked"})
+    @Override
+    public Enumeration getParameterNames() {
+      return Iterators.asEnumeration(getParameterMap().keySet().iterator());
+    }
+  }
+}
+
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/Daemon.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/Daemon.java
index 7d27482..c164d48 100644
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/Daemon.java
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/Daemon.java
@@ -22,6 +22,7 @@
 import com.google.gerrit.httpd.GitOverHttpModule;
 import com.google.gerrit.httpd.HttpCanonicalWebUrlProvider;
 import com.google.gerrit.httpd.RequestContextFilter;
+import com.google.gerrit.httpd.SignedTokenRestTokenVerifier;
 import com.google.gerrit.httpd.WebModule;
 import com.google.gerrit.httpd.WebSshGlueModule;
 import com.google.gerrit.httpd.auth.openid.OpenIdModule;
@@ -294,6 +295,7 @@
     modules.add(new DefaultCacheFactory.Module());
     modules.add(new SmtpEmailSender.Module());
     modules.add(new SignedTokenEmailTokenVerifier.Module());
+    modules.add(new SignedTokenRestTokenVerifier.Module());
     modules.add(new PluginModule());
     if (httpd) {
       modules.add(new CanonicalWebUrlModule() {
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/InitAuth.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/InitAuth.java
index f809c73..fa4dc14 100644
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/InitAuth.java
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/InitAuth.java
@@ -85,5 +85,9 @@
     if (auth.getSecure("registerEmailPrivateKey") == null) {
       auth.setSecure("registerEmailPrivateKey", SignedToken.generateRandomKey());
     }
+
+    if (auth.getSecure("restTokenPrivateKey") == null) {
+      auth.setSecure("restTokenPrivateKey", SignedToken.generateRandomKey());
+    }
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/AuthConfig.java b/gerrit-server/src/main/java/com/google/gerrit/server/config/AuthConfig.java
index dc36988..9916257 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/config/AuthConfig.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/config/AuthConfig.java
@@ -45,6 +45,7 @@
   private final String cookiePath;
   private final boolean cookieSecure;
   private final SignedToken emailReg;
+  private final SignedToken restToken;
 
   private final boolean allowGoogleAccountUpgrade;
 
@@ -75,6 +76,15 @@
       emailReg = null;
     }
 
+    key = cfg.getString("auth", null, "restTokenPrivateKey");
+    if (key != null && !key.isEmpty()) {
+      int age = (int) ConfigUtil.getTimeUnit(cfg,
+          "auth", null, "maxRestTokenAge", 60, TimeUnit.SECONDS);
+      restToken = new SignedToken(age, key);
+    } else {
+      restToken = null;
+    }
+
     if (authType == AuthType.OPENID) {
       allowGoogleAccountUpgrade =
           cfg.getBoolean("auth", "allowgoogleaccountupgrade", false);
@@ -129,6 +139,10 @@
     return emailReg;
   }
 
+  public SignedToken getRestToken() {
+    return restToken;
+  }
+
   public boolean isAllowGoogleAccountUpgrade() {
     return allowGoogleAccountUpgrade;
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/ReceiveCommits.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/ReceiveCommits.java
index 4d80193..ccf7c2d 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/ReceiveCommits.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/ReceiveCommits.java
@@ -613,10 +613,10 @@
       if (replace.inputCommand == newChange) {
         replaceCount++;
 
-        if (replace.cmd.getResult() == OK) {
+        if (replace.cmd != null && replace.cmd.getResult() == OK) {
           okToInsert++;
         }
-      } else if (replace.cmd.getResult() == OK) {
+      } else if (replace.cmd != null && replace.cmd.getResult() == OK) {
         try {
           if (replace.insertPatchSet().checkedGet() != null) {
             replace.inputCommand.setResult(OK);
@@ -1318,6 +1318,9 @@
       final List<FooterLine> footerLines = commit.getFooterLines();
       for (final FooterLine footerLine : footerLines) {
         try {
+          if (ps.isDraft()) {
+            continue;
+          }
           if (isReviewer(footerLine)) {
             reviewers.add(toAccountId(footerLine.getValue().trim()));
           } else if (footerLine.matches(FooterKey.CC)) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/SchemaVersion.java b/gerrit-server/src/main/java/com/google/gerrit/server/schema/SchemaVersion.java
index eeb0937..5ab7b6d 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/schema/SchemaVersion.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/schema/SchemaVersion.java
@@ -32,7 +32,7 @@
 /** A version of the database schema. */
 public abstract class SchemaVersion {
   /** The current schema version. */
-  public static final Class<Schema_71> C = Schema_71.class;
+  public static final Class<Schema_72> C = Schema_72.class;
 
   public static class Module extends AbstractModule {
     @Override
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_72.java b/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_72.java
new file mode 100644
index 0000000..748837b
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_72.java
@@ -0,0 +1,25 @@
+// Copyright (C) 2012 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.schema;
+
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+
+public class Schema_72 extends SchemaVersion {
+  @Inject
+  Schema_72(Provider<Schema_71> prior) {
+    super(prior);
+  }
+}
diff --git a/gerrit-war/src/main/java/com/google/gerrit/httpd/WebAppInitializer.java b/gerrit-war/src/main/java/com/google/gerrit/httpd/WebAppInitializer.java
index cc85e1e..1a556c2 100644
--- a/gerrit-war/src/main/java/com/google/gerrit/httpd/WebAppInitializer.java
+++ b/gerrit-war/src/main/java/com/google/gerrit/httpd/WebAppInitializer.java
@@ -203,6 +203,7 @@
     modules.add(new DefaultCacheFactory.Module());
     modules.add(new SmtpEmailSender.Module());
     modules.add(new SignedTokenEmailTokenVerifier.Module());
+    modules.add(new SignedTokenRestTokenVerifier.Module());
     modules.add(new PluginModule());
     modules.add(new CanonicalWebUrlModule() {
       @Override