// 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.restapi; import static com.google.common.base.Preconditions.checkNotNull; import static com.google.common.base.Preconditions.checkState; import static com.google.common.net.HttpHeaders.ACCESS_CONTROL_ALLOW_CREDENTIALS; import static com.google.common.net.HttpHeaders.ACCESS_CONTROL_ALLOW_HEADERS; import static com.google.common.net.HttpHeaders.ACCESS_CONTROL_ALLOW_METHODS; import static com.google.common.net.HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN; import static com.google.common.net.HttpHeaders.ACCESS_CONTROL_REQUEST_HEADERS; import static com.google.common.net.HttpHeaders.ACCESS_CONTROL_REQUEST_METHOD; import static com.google.common.net.HttpHeaders.ORIGIN; import static com.google.common.net.HttpHeaders.VARY; import static java.math.RoundingMode.CEILING; import static java.nio.charset.StandardCharsets.ISO_8859_1; import static java.nio.charset.StandardCharsets.UTF_8; import static java.util.stream.Collectors.joining; import static javax.servlet.http.HttpServletResponse.SC_ACCEPTED; import static javax.servlet.http.HttpServletResponse.SC_BAD_REQUEST; import static javax.servlet.http.HttpServletResponse.SC_CONFLICT; import static javax.servlet.http.HttpServletResponse.SC_CREATED; import static javax.servlet.http.HttpServletResponse.SC_FORBIDDEN; import static javax.servlet.http.HttpServletResponse.SC_INTERNAL_SERVER_ERROR; import static javax.servlet.http.HttpServletResponse.SC_METHOD_NOT_ALLOWED; import static javax.servlet.http.HttpServletResponse.SC_NOT_FOUND; import static javax.servlet.http.HttpServletResponse.SC_NOT_IMPLEMENTED; import static javax.servlet.http.HttpServletResponse.SC_NOT_MODIFIED; import static javax.servlet.http.HttpServletResponse.SC_NO_CONTENT; import static javax.servlet.http.HttpServletResponse.SC_OK; import static javax.servlet.http.HttpServletResponse.SC_PRECONDITION_FAILED; import com.google.common.base.CharMatcher; import com.google.common.base.Joiner; import com.google.common.base.Splitter; import com.google.common.base.Strings; import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableListMultimap; import com.google.common.collect.ImmutableSet; import com.google.common.collect.Iterables; import com.google.common.collect.ListMultimap; import com.google.common.collect.Lists; import com.google.common.collect.MultimapBuilder; import com.google.common.io.BaseEncoding; import com.google.common.io.CountingOutputStream; import com.google.common.math.IntMath; import com.google.common.net.HttpHeaders; import com.google.gerrit.audit.AuditService; import com.google.gerrit.audit.ExtendedHttpAuditEvent; import com.google.gerrit.common.Nullable; import com.google.gerrit.common.RawInputUtil; import com.google.gerrit.common.TimeUtil; import com.google.gerrit.extensions.registration.DynamicItem; import com.google.gerrit.extensions.registration.DynamicMap; import com.google.gerrit.extensions.restapi.AcceptsCreate; import com.google.gerrit.extensions.restapi.AcceptsDelete; import com.google.gerrit.extensions.restapi.AcceptsPost; import com.google.gerrit.extensions.restapi.AuthException; import com.google.gerrit.extensions.restapi.BadRequestException; import com.google.gerrit.extensions.restapi.BinaryResult; import com.google.gerrit.extensions.restapi.CacheControl; import com.google.gerrit.extensions.restapi.DefaultInput; import com.google.gerrit.extensions.restapi.ETagView; import com.google.gerrit.extensions.restapi.IdString; import com.google.gerrit.extensions.restapi.MethodNotAllowedException; import com.google.gerrit.extensions.restapi.NeedsParams; import com.google.gerrit.extensions.restapi.NotImplementedException; import com.google.gerrit.extensions.restapi.PreconditionFailedException; import com.google.gerrit.extensions.restapi.RawInput; import com.google.gerrit.extensions.restapi.ResourceConflictException; import com.google.gerrit.extensions.restapi.ResourceNotFoundException; import com.google.gerrit.extensions.restapi.Response; import com.google.gerrit.extensions.restapi.RestApiException; import com.google.gerrit.extensions.restapi.RestCollection; import com.google.gerrit.extensions.restapi.RestModifyView; import com.google.gerrit.extensions.restapi.RestReadView; import com.google.gerrit.extensions.restapi.RestResource; import com.google.gerrit.extensions.restapi.RestView; import com.google.gerrit.extensions.restapi.TopLevelResource; import com.google.gerrit.extensions.restapi.UnprocessableEntityException; import com.google.gerrit.httpd.WebSession; import com.google.gerrit.server.AccessPath; import com.google.gerrit.server.AnonymousUser; import com.google.gerrit.server.CurrentUser; import com.google.gerrit.server.OptionUtil; import com.google.gerrit.server.OutputFormat; import com.google.gerrit.server.config.GerritServerConfig; import com.google.gerrit.server.permissions.GlobalPermission; import com.google.gerrit.server.permissions.PermissionBackend; import com.google.gerrit.server.permissions.PermissionBackendException; import com.google.gerrit.util.http.RequestUtil; import com.google.gson.ExclusionStrategy; import com.google.gson.FieldAttributes; import com.google.gson.FieldNamingPolicy; import com.google.gson.Gson; import com.google.gson.GsonBuilder; import com.google.gson.JsonElement; import com.google.gson.JsonParseException; import com.google.gson.JsonPrimitive; import com.google.gson.stream.JsonReader; import com.google.gson.stream.JsonToken; import com.google.gson.stream.JsonWriter; import com.google.gson.stream.MalformedJsonException; import com.google.gwtexpui.server.CacheHeaders; import com.google.inject.Inject; import com.google.inject.Provider; import com.google.inject.TypeLiteral; import com.google.inject.util.Providers; import java.io.BufferedReader; import java.io.BufferedWriter; import java.io.EOFException; import java.io.FilterOutputStream; import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; import java.io.OutputStreamWriter; import java.io.Writer; import java.lang.reflect.Constructor; import java.lang.reflect.Field; import java.lang.reflect.InvocationTargetException; import java.lang.reflect.ParameterizedType; import java.lang.reflect.Type; import java.sql.Timestamp; import java.util.ArrayList; import java.util.Collections; import java.util.HashMap; import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Set; import java.util.TreeMap; import java.util.concurrent.TimeUnit; import java.util.regex.Pattern; import java.util.stream.StreamSupport; import java.util.zip.GZIPOutputStream; import javax.servlet.ServletException; import javax.servlet.http.HttpServlet; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import org.eclipse.jgit.http.server.ServletUtils; import org.eclipse.jgit.lib.Config; import org.eclipse.jgit.util.TemporaryBuffer; import org.eclipse.jgit.util.TemporaryBuffer.Heap; import org.slf4j.Logger; import org.slf4j.LoggerFactory; public class RestApiServlet extends HttpServlet { private static final long serialVersionUID = 1L; private static final Logger log = LoggerFactory.getLogger(RestApiServlet.class); /** MIME type used for a JSON response body. */ private static final String JSON_TYPE = "application/json"; private static final String FORM_TYPE = "application/x-www-form-urlencoded"; // HTTP 422 Unprocessable Entity. // TODO: Remove when HttpServletResponse.SC_UNPROCESSABLE_ENTITY is available private static final int SC_UNPROCESSABLE_ENTITY = 422; private static final String X_REQUESTED_WITH = "X-Requested-With"; private static final ImmutableSet<String> ALLOWED_CORS_REQUEST_HEADERS = ImmutableSet.of(X_REQUESTED_WITH); private static final int HEAP_EST_SIZE = 10 * 8 * 1024; // Presize 10 blocks. /** * Garbage prefix inserted before JSON output to prevent XSSI. * * <p>This prefix is ")]}'\n" and is designed to prevent a web browser from executing the response * body if the resource URI were to be referenced using a <script src="...> HTML tag from * another web site. Clients using the HTTP interface will need to always strip the first line of * response data to remove this magic header. */ public static final byte[] JSON_MAGIC; static { JSON_MAGIC = ")]}'\n".getBytes(UTF_8); } public static class Globals { final Provider<CurrentUser> currentUser; final DynamicItem<WebSession> webSession; final Provider<ParameterParser> paramParser; final PermissionBackend permissionBackend; final AuditService auditService; final RestApiMetrics metrics; final Pattern allowOrigin; @Inject Globals( Provider<CurrentUser> currentUser, DynamicItem<WebSession> webSession, Provider<ParameterParser> paramParser, PermissionBackend permissionBackend, AuditService auditService, RestApiMetrics metrics, @GerritServerConfig Config cfg) { this.currentUser = currentUser; this.webSession = webSession; this.paramParser = paramParser; this.permissionBackend = permissionBackend; this.auditService = auditService; this.metrics = metrics; allowOrigin = makeAllowOrigin(cfg); } private static Pattern makeAllowOrigin(Config cfg) { String[] allow = cfg.getStringList("site", null, "allowOriginRegex"); if (allow.length > 0) { return Pattern.compile(Joiner.on('|').join(allow)); } return null; } } private final Globals globals; private final Provider<RestCollection<RestResource, RestResource>> members; public RestApiServlet( Globals globals, RestCollection<? extends RestResource, ? extends RestResource> members) { this(globals, Providers.of(members)); } public RestApiServlet( Globals globals, Provider<? extends RestCollection<? extends RestResource, ? extends RestResource>> members) { @SuppressWarnings("unchecked") Provider<RestCollection<RestResource, RestResource>> n = (Provider<RestCollection<RestResource, RestResource>>) checkNotNull((Object) members); this.globals = globals; this.members = n; } @Override protected final void service(HttpServletRequest req, HttpServletResponse res) throws ServletException, IOException { final long startNanos = System.nanoTime(); long auditStartTs = TimeUtil.nowMs(); res.setHeader("Content-Disposition", "attachment"); res.setHeader("X-Content-Type-Options", "nosniff"); int status = SC_OK; long responseBytes = -1; Object result = null; ListMultimap<String, String> params = MultimapBuilder.hashKeys().arrayListValues().build(); ListMultimap<String, String> config = MultimapBuilder.hashKeys().arrayListValues().build(); Object inputRequestBody = null; RestResource rsrc = TopLevelResource.INSTANCE; ViewData viewData = null; try { if (isCorsPreflight(req)) { doCorsPreflight(req, res); return; } checkCors(req, res); checkUserSession(req); ParameterParser.splitQueryString(req.getQueryString(), config, params); List<IdString> path = splitPath(req); RestCollection<RestResource, RestResource> rc = members.get(); globals .permissionBackend .user(globals.currentUser) .checkAny(GlobalPermission.fromAnnotation(rc.getClass())); viewData = new ViewData(null, null); if (path.isEmpty()) { if (rc instanceof NeedsParams) { ((NeedsParams) rc).setParams(params); } if (isRead(req)) { viewData = new ViewData(null, rc.list()); } else if (rc instanceof AcceptsPost && "POST".equals(req.getMethod())) { @SuppressWarnings("unchecked") AcceptsPost<RestResource> ac = (AcceptsPost<RestResource>) rc; viewData = new ViewData(null, ac.post(rsrc)); } else { throw new MethodNotAllowedException(); } } else { IdString id = path.remove(0); try { rsrc = rc.parse(rsrc, id); if (path.isEmpty()) { checkPreconditions(req); } } catch (ResourceNotFoundException e) { if (rc instanceof AcceptsCreate && path.isEmpty() && ("POST".equals(req.getMethod()) || "PUT".equals(req.getMethod()))) { @SuppressWarnings("unchecked") AcceptsCreate<RestResource> ac = (AcceptsCreate<RestResource>) rc; viewData = new ViewData(null, ac.create(rsrc, id)); status = SC_CREATED; } else { throw e; } } if (viewData.view == null) { viewData = view(rsrc, rc, req.getMethod(), path); } } checkRequiresCapability(viewData); while (viewData.view instanceof RestCollection<?, ?>) { @SuppressWarnings("unchecked") RestCollection<RestResource, RestResource> c = (RestCollection<RestResource, RestResource>) viewData.view; if (path.isEmpty()) { if (isRead(req)) { viewData = new ViewData(null, c.list()); } else if (c instanceof AcceptsPost && "POST".equals(req.getMethod())) { @SuppressWarnings("unchecked") AcceptsPost<RestResource> ac = (AcceptsPost<RestResource>) c; viewData = new ViewData(null, ac.post(rsrc)); } else if (c instanceof AcceptsDelete && "DELETE".equals(req.getMethod())) { @SuppressWarnings("unchecked") AcceptsDelete<RestResource> ac = (AcceptsDelete<RestResource>) c; viewData = new ViewData(null, ac.delete(rsrc, null)); } else { throw new MethodNotAllowedException(); } break; } IdString id = path.remove(0); try { rsrc = c.parse(rsrc, id); checkPreconditions(req); viewData = new ViewData(null, null); } catch (ResourceNotFoundException e) { if (c instanceof AcceptsCreate && path.isEmpty() && ("POST".equals(req.getMethod()) || "PUT".equals(req.getMethod()))) { @SuppressWarnings("unchecked") AcceptsCreate<RestResource> ac = (AcceptsCreate<RestResource>) c; viewData = new ViewData(viewData.pluginName, ac.create(rsrc, id)); status = SC_CREATED; } else if (c instanceof AcceptsDelete && path.isEmpty() && "DELETE".equals(req.getMethod())) { @SuppressWarnings("unchecked") AcceptsDelete<RestResource> ac = (AcceptsDelete<RestResource>) c; viewData = new ViewData(viewData.pluginName, ac.delete(rsrc, id)); status = SC_NO_CONTENT; } else { throw e; } } if (viewData.view == null) { viewData = view(rsrc, c, req.getMethod(), path); } checkRequiresCapability(viewData); } if (notModified(req, rsrc, viewData.view)) { res.sendError(SC_NOT_MODIFIED); return; } if (!globals.paramParser.get().parse(viewData.view, params, req, res)) { return; } if (viewData.view instanceof RestReadView<?> && isRead(req)) { result = ((RestReadView<RestResource>) viewData.view).apply(rsrc); } else if (viewData.view instanceof RestModifyView<?, ?>) { @SuppressWarnings("unchecked") RestModifyView<RestResource, Object> m = (RestModifyView<RestResource, Object>) viewData.view; Type type = inputType(m); inputRequestBody = parseRequest(req, type); result = m.apply(rsrc, inputRequestBody); consumeRawInputRequestBody(req, type); } else { throw new ResourceNotFoundException(); } if (result instanceof Response) { @SuppressWarnings("rawtypes") Response<?> r = (Response) result; status = r.statusCode(); configureCaching(req, res, rsrc, viewData.view, r.caching()); } else if (result instanceof Response.Redirect) { CacheHeaders.setNotCacheable(res); res.sendRedirect(((Response.Redirect) result).location()); return; } else if (result instanceof Response.Accepted) { CacheHeaders.setNotCacheable(res); res.setStatus(SC_ACCEPTED); res.setHeader(HttpHeaders.LOCATION, ((Response.Accepted) result).location()); return; } else { CacheHeaders.setNotCacheable(res); } res.setStatus(status); if (result != Response.none()) { result = Response.unwrap(result); if (result instanceof BinaryResult) { responseBytes = replyBinaryResult(req, res, (BinaryResult) result); } else { responseBytes = replyJson(req, res, config, result); } } } catch (MalformedJsonException e) { responseBytes = replyError(req, res, status = SC_BAD_REQUEST, "Invalid " + JSON_TYPE + " in request", e); } catch (JsonParseException e) { responseBytes = replyError(req, res, status = SC_BAD_REQUEST, "Invalid " + JSON_TYPE + " in request", e); } catch (BadRequestException e) { responseBytes = replyError( req, res, status = SC_BAD_REQUEST, messageOr(e, "Bad Request"), e.caching(), e); } catch (AuthException e) { responseBytes = replyError(req, res, status = SC_FORBIDDEN, messageOr(e, "Forbidden"), e.caching(), e); } catch (AmbiguousViewException e) { responseBytes = replyError(req, res, status = SC_NOT_FOUND, messageOr(e, "Ambiguous"), e); } catch (ResourceNotFoundException e) { responseBytes = replyError(req, res, status = SC_NOT_FOUND, messageOr(e, "Not Found"), e.caching(), e); } catch (MethodNotAllowedException e) { responseBytes = replyError( req, res, status = SC_METHOD_NOT_ALLOWED, messageOr(e, "Method Not Allowed"), e.caching(), e); } catch (ResourceConflictException e) { responseBytes = replyError(req, res, status = SC_CONFLICT, messageOr(e, "Conflict"), e.caching(), e); } catch (PreconditionFailedException e) { responseBytes = replyError( req, res, status = SC_PRECONDITION_FAILED, messageOr(e, "Precondition Failed"), e.caching(), e); } catch (UnprocessableEntityException e) { responseBytes = replyError( req, res, status = SC_UNPROCESSABLE_ENTITY, messageOr(e, "Unprocessable Entity"), e.caching(), e); } catch (NotImplementedException e) { responseBytes = replyError(req, res, status = SC_NOT_IMPLEMENTED, messageOr(e, "Not Implemented"), e); } catch (Exception e) { status = SC_INTERNAL_SERVER_ERROR; responseBytes = handleException(e, req, res); } finally { String metric = viewData != null && viewData.view != null ? globals.metrics.view(viewData) : "_unknown"; globals.metrics.count.increment(metric); if (status >= SC_BAD_REQUEST) { globals.metrics.errorCount.increment(metric, status); } if (responseBytes != -1) { globals.metrics.responseBytes.record(metric, responseBytes); } globals.metrics.serverLatency.record( metric, System.nanoTime() - startNanos, TimeUnit.NANOSECONDS); globals.auditService.dispatch( new ExtendedHttpAuditEvent( globals.webSession.get().getSessionId(), globals.currentUser.get(), req, auditStartTs, params, inputRequestBody, status, result, rsrc, viewData == null ? null : viewData.view)); } } private void checkCors(HttpServletRequest req, HttpServletResponse res) { String origin = req.getHeader(ORIGIN); if (isRead(req) && !Strings.isNullOrEmpty(origin) && isOriginAllowed(origin)) { res.addHeader(VARY, ORIGIN); setCorsHeaders(res, origin); } } private static boolean isCorsPreflight(HttpServletRequest req) { return "OPTIONS".equals(req.getMethod()) && !Strings.isNullOrEmpty(req.getHeader(ORIGIN)) && !Strings.isNullOrEmpty(req.getHeader(ACCESS_CONTROL_REQUEST_METHOD)); } private void doCorsPreflight(HttpServletRequest req, HttpServletResponse res) throws BadRequestException { CacheHeaders.setNotCacheable(res); res.setHeader( VARY, Joiner.on(", ").join(ImmutableList.of(ORIGIN, ACCESS_CONTROL_REQUEST_METHOD))); String origin = req.getHeader(ORIGIN); if (Strings.isNullOrEmpty(origin) || !isOriginAllowed(origin)) { throw new BadRequestException("CORS not allowed"); } String method = req.getHeader(ACCESS_CONTROL_REQUEST_METHOD); if (!"GET".equals(method) && !"HEAD".equals(method)) { throw new BadRequestException(method + " not allowed in CORS"); } String headers = req.getHeader(ACCESS_CONTROL_REQUEST_HEADERS); if (headers != null) { res.addHeader(VARY, ACCESS_CONTROL_REQUEST_HEADERS); String badHeader = StreamSupport.stream(Splitter.on(',').trimResults().split(headers).spliterator(), false) .filter(h -> !ALLOWED_CORS_REQUEST_HEADERS.contains(h)) .findFirst() .orElse(null); if (badHeader != null) { throw new BadRequestException(badHeader + " not allowed in CORS"); } } res.setStatus(SC_OK); setCorsHeaders(res, origin); res.setContentType("text/plain"); res.setContentLength(0); } private void setCorsHeaders(HttpServletResponse res, String origin) { res.setHeader(ACCESS_CONTROL_ALLOW_ORIGIN, origin); res.setHeader(ACCESS_CONTROL_ALLOW_CREDENTIALS, "true"); res.setHeader(ACCESS_CONTROL_ALLOW_METHODS, "GET, OPTIONS"); res.setHeader(ACCESS_CONTROL_ALLOW_HEADERS, Joiner.on(", ").join(ALLOWED_CORS_REQUEST_HEADERS)); } private boolean isOriginAllowed(String origin) { return globals.allowOrigin != null && globals.allowOrigin.matcher(origin).matches(); } private static String messageOr(Throwable t, String defaultMessage) { if (!Strings.isNullOrEmpty(t.getMessage())) { return t.getMessage(); } return defaultMessage; } @SuppressWarnings({"unchecked", "rawtypes"}) private static boolean notModified( HttpServletRequest req, RestResource rsrc, RestView<RestResource> view) { if (!isRead(req)) { return false; } if (view instanceof ETagView) { String have = req.getHeader(HttpHeaders.IF_NONE_MATCH); if (have != null) { return have.equals(((ETagView) view).getETag(rsrc)); } } if (rsrc instanceof RestResource.HasETag) { String have = req.getHeader(HttpHeaders.IF_NONE_MATCH); if (have != null) { return have.equals(((RestResource.HasETag) rsrc).getETag()); } } if (rsrc instanceof RestResource.HasLastModified) { Timestamp m = ((RestResource.HasLastModified) rsrc).getLastModified(); long d = req.getDateHeader(HttpHeaders.IF_MODIFIED_SINCE); // HTTP times are in seconds, database may have millisecond precision. return d / 1000L == m.getTime() / 1000L; } return false; } private static <R extends RestResource> void configureCaching( HttpServletRequest req, HttpServletResponse res, R rsrc, RestView<R> view, CacheControl c) { if (isRead(req)) { switch (c.getType()) { case NONE: default: CacheHeaders.setNotCacheable(res); break; case PRIVATE: addResourceStateHeaders(res, rsrc, view); CacheHeaders.setCacheablePrivate(res, c.getAge(), c.getUnit(), c.isMustRevalidate()); break; case PUBLIC: addResourceStateHeaders(res, rsrc, view); CacheHeaders.setCacheable(req, res, c.getAge(), c.getUnit(), c.isMustRevalidate()); break; } } else { CacheHeaders.setNotCacheable(res); } } private static <R extends RestResource> void addResourceStateHeaders( HttpServletResponse res, R rsrc, RestView<R> view) { if (view instanceof ETagView) { res.setHeader(HttpHeaders.ETAG, ((ETagView<R>) view).getETag(rsrc)); } else if (rsrc instanceof RestResource.HasETag) { res.setHeader(HttpHeaders.ETAG, ((RestResource.HasETag) rsrc).getETag()); } if (rsrc instanceof RestResource.HasLastModified) { res.setDateHeader( HttpHeaders.LAST_MODIFIED, ((RestResource.HasLastModified) rsrc).getLastModified().getTime()); } } private void checkPreconditions(HttpServletRequest req) throws PreconditionFailedException { if ("*".equals(req.getHeader(HttpHeaders.IF_NONE_MATCH))) { throw new PreconditionFailedException("Resource already exists"); } } private static Type inputType(RestModifyView<RestResource, Object> m) { // MyModifyView implements RestModifyView<SomeResource, MyInput> TypeLiteral<?> typeLiteral = TypeLiteral.get(m.getClass()); // RestModifyView<SomeResource, MyInput> // This is smart enough to resolve even when there are intervening subclasses, even if they have // reordered type arguments. TypeLiteral<?> supertypeLiteral = typeLiteral.getSupertype(RestModifyView.class); Type supertype = supertypeLiteral.getType(); checkState( supertype instanceof ParameterizedType, "supertype of %s is not parameterized: %s", typeLiteral, supertypeLiteral); return ((ParameterizedType) supertype).getActualTypeArguments()[1]; } private Object parseRequest(HttpServletRequest req, Type type) throws IOException, BadRequestException, SecurityException, IllegalArgumentException, NoSuchMethodException, IllegalAccessException, InstantiationException, InvocationTargetException, MethodNotAllowedException { // HTTP/1.1 requires consuming the request body before writing non-error response (less than // 400). Consume the request body for all but raw input request types here. if (isType(JSON_TYPE, req.getContentType())) { try (BufferedReader br = req.getReader(); JsonReader json = new JsonReader(br)) { try { json.setLenient(true); JsonToken first; try { first = json.peek(); } catch (EOFException e) { throw new BadRequestException("Expected JSON object"); } if (first == JsonToken.STRING) { return parseString(json.nextString(), type); } return OutputFormat.JSON.newGson().fromJson(json, type); } finally { // Reader.close won't consume the rest of the input. Explicitly consume the request body. br.skip(Long.MAX_VALUE); } } } else if (rawInputRequest(req, type)) { return parseRawInput(req, type); } else if ("DELETE".equals(req.getMethod()) && hasNoBody(req)) { return null; } else if (hasNoBody(req)) { return createInstance(type); } else if (isType("text/plain", req.getContentType())) { try (BufferedReader br = req.getReader()) { char[] tmp = new char[256]; StringBuilder sb = new StringBuilder(); int n; while (0 < (n = br.read(tmp))) { sb.append(tmp, 0, n); } return parseString(sb.toString(), type); } } else if ("POST".equals(req.getMethod()) && isType(FORM_TYPE, req.getContentType())) { return OutputFormat.JSON.newGson().fromJson(ParameterParser.formToJson(req), type); } else { throw new BadRequestException("Expected Content-Type: " + JSON_TYPE); } } private void consumeRawInputRequestBody(HttpServletRequest req, Type type) throws IOException { if (rawInputRequest(req, type)) { try (InputStream is = req.getInputStream()) { ServletUtils.consumeRequestBody(is); } } } private static boolean rawInputRequest(HttpServletRequest req, Type type) { String method = req.getMethod(); return ("PUT".equals(method) || "POST".equals(method)) && acceptsRawInput(type); } private static boolean hasNoBody(HttpServletRequest req) { int len = req.getContentLength(); String type = req.getContentType(); return (len <= 0 && type == null) || (len == 0 && isType(FORM_TYPE, type)); } @SuppressWarnings("rawtypes") private static boolean acceptsRawInput(Type type) { if (type instanceof Class) { for (Field f : ((Class) type).getDeclaredFields()) { if (f.getType() == RawInput.class) { return true; } } } return false; } private Object parseRawInput(final HttpServletRequest req, Type type) throws SecurityException, NoSuchMethodException, IllegalArgumentException, InstantiationException, IllegalAccessException, InvocationTargetException, MethodNotAllowedException { Object obj = createInstance(type); for (Field f : obj.getClass().getDeclaredFields()) { if (f.getType() == RawInput.class) { f.setAccessible(true); f.set(obj, RawInputUtil.create(req)); return obj; } } throw new MethodNotAllowedException(); } private Object parseString(String value, Type type) throws BadRequestException, SecurityException, NoSuchMethodException, IllegalArgumentException, IllegalAccessException, InstantiationException, InvocationTargetException { if (type == String.class) { return value; } Object obj = createInstance(type); Field[] fields = obj.getClass().getDeclaredFields(); if (fields.length == 0 && Strings.isNullOrEmpty(value)) { return obj; } for (Field f : fields) { if (f.getAnnotation(DefaultInput.class) != null && f.getType() == String.class) { f.setAccessible(true); f.set(obj, value); return obj; } } throw new BadRequestException("Expected JSON object"); } private static Object createInstance(Type type) throws NoSuchMethodException, InstantiationException, IllegalAccessException, InvocationTargetException { if (type instanceof Class) { @SuppressWarnings("unchecked") Class<Object> clazz = (Class<Object>) type; Constructor<Object> c = clazz.getDeclaredConstructor(); c.setAccessible(true); return c.newInstance(); } throw new InstantiationException("Cannot make " + type); } public static long replyJson( @Nullable HttpServletRequest req, HttpServletResponse res, ListMultimap<String, String> config, Object result) throws IOException { TemporaryBuffer.Heap buf = heap(HEAP_EST_SIZE, Integer.MAX_VALUE); buf.write(JSON_MAGIC); Writer w = new BufferedWriter(new OutputStreamWriter(buf, UTF_8)); Gson gson = newGson(config, req); if (result instanceof JsonElement) { gson.toJson((JsonElement) result, w); } else { gson.toJson(result, w); } w.write('\n'); w.flush(); return replyBinaryResult( req, res, asBinaryResult(buf).setContentType(JSON_TYPE).setCharacterEncoding(UTF_8)); } private static Gson newGson( ListMultimap<String, String> config, @Nullable HttpServletRequest req) { GsonBuilder gb = OutputFormat.JSON_COMPACT.newGsonBuilder(); enablePrettyPrint(gb, config, req); enablePartialGetFields(gb, config); return gb.create(); } private static void enablePrettyPrint( GsonBuilder gb, ListMultimap<String, String> config, @Nullable HttpServletRequest req) { String pp = Iterables.getFirst(config.get("pp"), null); if (pp == null) { pp = Iterables.getFirst(config.get("prettyPrint"), null); if (pp == null && req != null) { pp = acceptsJson(req) ? "0" : "1"; } } if ("1".equals(pp) || "true".equals(pp)) { gb.setPrettyPrinting(); } } private static void enablePartialGetFields(GsonBuilder gb, ListMultimap<String, String> config) { final Set<String> want = new HashSet<>(); for (String p : config.get("fields")) { Iterables.addAll(want, OptionUtil.splitOptionValue(p)); } if (!want.isEmpty()) { gb.addSerializationExclusionStrategy( new ExclusionStrategy() { private final Map<String, String> names = new HashMap<>(); @Override public boolean shouldSkipField(FieldAttributes field) { String name = names.get(field.getName()); if (name == null) { // Names are supplied by Gson in terms of Java source. // Translate and cache the JSON lower_case_style used. try { name = FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORES.translateName( // field.getDeclaringClass().getDeclaredField(field.getName())); names.put(field.getName(), name); } catch (SecurityException e) { return true; } catch (NoSuchFieldException e) { return true; } } return !want.contains(name); } @Override public boolean shouldSkipClass(Class<?> clazz) { return false; } }); } } @SuppressWarnings("resource") static long replyBinaryResult( @Nullable HttpServletRequest req, HttpServletResponse res, BinaryResult bin) throws IOException { final BinaryResult appResult = bin; try { if (bin.getAttachmentName() != null) { res.setHeader( "Content-Disposition", "attachment; filename=\"" + bin.getAttachmentName() + "\""); } if (bin.isBase64()) { if (req != null && JSON_TYPE.equals(req.getHeader(HttpHeaders.ACCEPT))) { bin = stackJsonString(res, bin); } else { bin = stackBase64(res, bin); } } if (bin.canGzip() && acceptsGzip(req)) { bin = stackGzip(res, bin); } res.setContentType(bin.getContentType()); long len = bin.getContentLength(); if (0 <= len && len < Integer.MAX_VALUE) { res.setContentLength((int) len); } else if (0 <= len) { res.setHeader("Content-Length", Long.toString(len)); } if (req == null || !"HEAD".equals(req.getMethod())) { try (CountingOutputStream dst = new CountingOutputStream(res.getOutputStream())) { bin.writeTo(dst); return dst.getCount(); } } return 0; } finally { appResult.close(); } } private static BinaryResult stackJsonString(HttpServletResponse res, final BinaryResult src) throws IOException { TemporaryBuffer.Heap buf = heap(HEAP_EST_SIZE, Integer.MAX_VALUE); buf.write(JSON_MAGIC); try (Writer w = new BufferedWriter(new OutputStreamWriter(buf, UTF_8)); JsonWriter json = new JsonWriter(w)) { json.setLenient(true); json.setHtmlSafe(true); json.value(src.asString()); w.write('\n'); } res.setHeader("X-FYI-Content-Encoding", "json"); res.setHeader("X-FYI-Content-Type", src.getContentType()); return asBinaryResult(buf).setContentType(JSON_TYPE).setCharacterEncoding(UTF_8); } private static BinaryResult stackBase64(HttpServletResponse res, final BinaryResult src) throws IOException { BinaryResult b64; long len = src.getContentLength(); if (0 <= len && len <= (7 << 20)) { b64 = base64(src); } else { b64 = new BinaryResult() { @Override public void writeTo(OutputStream out) throws IOException { try (OutputStreamWriter w = new OutputStreamWriter( new FilterOutputStream(out) { @Override public void close() { // Do not close out, but only w and e. } }, ISO_8859_1); OutputStream e = BaseEncoding.base64().encodingStream(w)) { src.writeTo(e); } } }; } res.setHeader("X-FYI-Content-Encoding", "base64"); res.setHeader("X-FYI-Content-Type", src.getContentType()); return b64.setContentType("text/plain").setCharacterEncoding(ISO_8859_1); } private static BinaryResult stackGzip(HttpServletResponse res, final BinaryResult src) throws IOException { BinaryResult gz; long len = src.getContentLength(); if (len < 256) { return src; // Do not compress very small payloads. } else if (len <= (10 << 20)) { gz = compress(src); if (len <= gz.getContentLength()) { return src; } } else { gz = new BinaryResult() { @Override public void writeTo(OutputStream out) throws IOException { GZIPOutputStream gz = new GZIPOutputStream(out); src.writeTo(gz); gz.finish(); gz.flush(); } }; } res.setHeader("Content-Encoding", "gzip"); return gz.setContentType(src.getContentType()); } private ViewData view( RestResource rsrc, RestCollection<RestResource, RestResource> rc, String method, List<IdString> path) throws AmbiguousViewException, RestApiException { DynamicMap<RestView<RestResource>> views = rc.views(); final IdString projection = path.isEmpty() ? IdString.fromUrl("/") : path.remove(0); if (!path.isEmpty()) { // If there are path components still remaining after this projection // is chosen, look for the projection based upon GET as the method as // the client thinks it is a nested collection. method = "GET"; } else if ("HEAD".equals(method)) { method = "GET"; } List<String> p = splitProjection(projection); if (p.size() == 2) { String viewname = p.get(1); if (Strings.isNullOrEmpty(viewname)) { viewname = "/"; } RestView<RestResource> view = views.get(p.get(0), method + "." + viewname); if (view != null) { return new ViewData(p.get(0), view); } view = views.get(p.get(0), "GET." + viewname); if (view != null) { if (view instanceof AcceptsPost && "POST".equals(method)) { @SuppressWarnings("unchecked") AcceptsPost<RestResource> ap = (AcceptsPost<RestResource>) view; return new ViewData(p.get(0), ap.post(rsrc)); } } throw new ResourceNotFoundException(projection); } String name = method + "." + p.get(0); RestView<RestResource> core = views.get("gerrit", name); if (core != null) { return new ViewData(null, core); } core = views.get("gerrit", "GET." + p.get(0)); if (core instanceof AcceptsPost && "POST".equals(method)) { @SuppressWarnings("unchecked") AcceptsPost<RestResource> ap = (AcceptsPost<RestResource>) core; return new ViewData(null, ap.post(rsrc)); } Map<String, RestView<RestResource>> r = new TreeMap<>(); for (String plugin : views.plugins()) { RestView<RestResource> action = views.get(plugin, name); if (action != null) { r.put(plugin, action); } } if (r.size() == 1) { Map.Entry<String, RestView<RestResource>> entry = Iterables.getOnlyElement(r.entrySet()); return new ViewData(entry.getKey(), entry.getValue()); } else if (r.isEmpty()) { throw new ResourceNotFoundException(projection); } else { throw new AmbiguousViewException( String.format( "Projection %s is ambiguous: %s", name, r.keySet().stream().map(in -> in + "~" + projection).collect(joining(", ")))); } } private static List<IdString> splitPath(HttpServletRequest req) { String path = RequestUtil.getEncodedPathInfo(req); if (Strings.isNullOrEmpty(path)) { return Collections.emptyList(); } List<IdString> out = new ArrayList<>(); for (String p : Splitter.on('/').split(path)) { out.add(IdString.fromUrl(p)); } if (out.size() > 0 && out.get(out.size() - 1).isEmpty()) { out.remove(out.size() - 1); } return out; } private static List<String> splitProjection(IdString projection) { List<String> p = Lists.newArrayListWithCapacity(2); Iterables.addAll(p, Splitter.on('~').limit(2).split(projection.get())); return p; } private void checkUserSession(HttpServletRequest req) throws AuthException { CurrentUser user = globals.currentUser.get(); if (isRead(req)) { user.setAccessPath(AccessPath.REST_API); user.setLastLoginExternalIdKey(globals.webSession.get().getLastLoginExternalId()); } else if (user instanceof AnonymousUser) { throw new AuthException("Authentication required"); } else if (!globals.webSession.get().isAccessPathOk(AccessPath.REST_API)) { throw new AuthException( "Invalid authentication method. In order to authenticate, " + "prefix the REST endpoint URL with /a/ (e.g. http://example.com/a/projects/)."); } } private static boolean isRead(HttpServletRequest req) { return "GET".equals(req.getMethod()) || "HEAD".equals(req.getMethod()); } private void checkRequiresCapability(ViewData d) throws AuthException, PermissionBackendException { globals .permissionBackend .user(globals.currentUser) .checkAny(GlobalPermission.fromAnnotation(d.pluginName, d.view.getClass())); } private static long handleException( Throwable err, HttpServletRequest req, HttpServletResponse res) throws IOException { String uri = req.getRequestURI(); if (!Strings.isNullOrEmpty(req.getQueryString())) { uri += "?" + req.getQueryString(); } log.error(String.format("Error in %s %s", req.getMethod(), uri), err); if (!res.isCommitted()) { res.reset(); return replyError(req, res, SC_INTERNAL_SERVER_ERROR, "Internal server error", err); } return 0; } public static long replyError( HttpServletRequest req, HttpServletResponse res, int statusCode, String msg, @Nullable Throwable err) throws IOException { return replyError(req, res, statusCode, msg, CacheControl.NONE, err); } public static long replyError( HttpServletRequest req, HttpServletResponse res, int statusCode, String msg, CacheControl c, @Nullable Throwable err) throws IOException { if (err != null) { RequestUtil.setErrorTraceAttribute(req, err); } configureCaching(req, res, null, null, c); res.setStatus(statusCode); return replyText(req, res, msg); } static long replyText(@Nullable HttpServletRequest req, HttpServletResponse res, String text) throws IOException { if ((req == null || isRead(req)) && isMaybeHTML(text)) { return replyJson(req, res, ImmutableListMultimap.of("pp", "0"), new JsonPrimitive(text)); } if (!text.endsWith("\n")) { text += "\n"; } return replyBinaryResult(req, res, BinaryResult.create(text).setContentType("text/plain")); } private static boolean isMaybeHTML(String text) { return CharMatcher.anyOf("<&").matchesAnyOf(text); } private static boolean acceptsJson(HttpServletRequest req) { return req != null && isType(JSON_TYPE, req.getHeader(HttpHeaders.ACCEPT)); } private static boolean acceptsGzip(HttpServletRequest req) { if (req != null) { String accepts = req.getHeader(HttpHeaders.ACCEPT_ENCODING); return accepts != null && accepts.contains("gzip"); } return false; } private static boolean isType(String expect, String given) { if (given == null) { return false; } else if (expect.equals(given)) { return true; } else if (given.startsWith(expect + ",")) { return true; } for (String p : given.split("[ ,;][ ,;]*")) { if (expect.equals(p)) { return true; } } return false; } private static int base64MaxSize(long n) { return 4 * IntMath.divide((int) n, 3, CEILING); } private static BinaryResult base64(BinaryResult bin) throws IOException { int maxSize = base64MaxSize(bin.getContentLength()); int estSize = Math.min(base64MaxSize(HEAP_EST_SIZE), maxSize); TemporaryBuffer.Heap buf = heap(estSize, maxSize); try (OutputStream encoded = BaseEncoding.base64().encodingStream(new OutputStreamWriter(buf, ISO_8859_1))) { bin.writeTo(encoded); } return asBinaryResult(buf); } private static BinaryResult compress(BinaryResult bin) throws IOException { TemporaryBuffer.Heap buf = heap(HEAP_EST_SIZE, 20 << 20); try (GZIPOutputStream gz = new GZIPOutputStream(buf)) { bin.writeTo(gz); } return asBinaryResult(buf).setContentType(bin.getContentType()); } @SuppressWarnings("resource") private static BinaryResult asBinaryResult(final TemporaryBuffer.Heap buf) { return new BinaryResult() { @Override public void writeTo(OutputStream os) throws IOException { buf.writeTo(os, null); } }.setContentLength(buf.length()); } private static Heap heap(int est, int max) { return new TemporaryBuffer.Heap(est, max); } @SuppressWarnings("serial") private static class AmbiguousViewException extends Exception { AmbiguousViewException(String message) { super(message); } } static class ViewData { String pluginName; RestView<RestResource> view; ViewData(String pluginName, RestView<RestResource> view) { this.pluginName = pluginName; this.view = view; } } }