/*
* Copyright 2017 the original author or authors.
*
* 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 org.glowroot.ui;
import java.io.IOException;
import java.io.PrintWriter;
import java.io.StringWriter;
import java.lang.annotation.Annotation;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.net.URL;
import java.sql.SQLException;
import java.util.Date;
import java.util.List;
import java.util.Map;
import java.util.Map.Entry;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import javax.annotation.Nullable;
import com.fasterxml.jackson.core.JsonGenerator;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.google.common.base.Charsets;
import com.google.common.base.Joiner;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableMap;
import com.google.common.collect.Lists;
import com.google.common.io.CharStreams;
import com.google.common.io.Resources;
import com.google.common.net.MediaType;
import io.netty.buffer.Unpooled;
import io.netty.handler.codec.http.DefaultHttpHeaders;
import io.netty.handler.codec.http.HttpHeaderNames;
import io.netty.handler.codec.http.HttpHeaders;
import io.netty.handler.codec.http.HttpResponseStatus;
import org.immutables.value.Value;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.glowroot.agent.api.Glowroot;
import org.glowroot.common.util.Clock;
import org.glowroot.common.util.ObjectMappers;
import org.glowroot.ui.HttpSessionManager.Authentication;
import static com.google.common.base.Preconditions.checkNotNull;
import static com.google.common.base.Preconditions.checkState;
import static io.netty.handler.codec.http.HttpResponseStatus.BAD_REQUEST;
import static io.netty.handler.codec.http.HttpResponseStatus.FORBIDDEN;
import static io.netty.handler.codec.http.HttpResponseStatus.INTERNAL_SERVER_ERROR;
import static io.netty.handler.codec.http.HttpResponseStatus.NOT_FOUND;
import static io.netty.handler.codec.http.HttpResponseStatus.NOT_MODIFIED;
import static io.netty.handler.codec.http.HttpResponseStatus.OK;
import static io.netty.handler.codec.http.HttpResponseStatus.REQUEST_TIMEOUT;
import static io.netty.handler.codec.http.HttpResponseStatus.UNAUTHORIZED;
import static java.util.concurrent.TimeUnit.DAYS;
import static java.util.concurrent.TimeUnit.MINUTES;
public class CommonHandler {
private static final Logger logger = LoggerFactory.getLogger(CommonHandler.class);
private static final Logger auditLogger = LoggerFactory.getLogger("audit");
private static final ObjectMapper mapper = ObjectMappers.create();
private static final long TEN_YEARS = DAYS.toMillis(365 * 10);
private static final long ONE_DAY = DAYS.toMillis(1);
private static final long FIVE_MINUTES = MINUTES.toMillis(5);
private static final String RESOURCE_BASE = "org/glowroot/ui/app-dist";
// only null when running tests with glowroot.ui.skip=true (e.g. travis "deploy" build)
private static final @Nullable String RESOURCE_BASE_URL_PREFIX;
private static final ImmutableMap<String, MediaType> mediaTypes =
ImmutableMap.<String, MediaType>builder()
.put("html", MediaType.HTML_UTF_8)
.put("js", MediaType.JAVASCRIPT_UTF_8)
.put("css", MediaType.CSS_UTF_8)
.put("ico", MediaType.ICO)
.put("woff", MediaType.WOFF)
.put("woff2", MediaType.create("application", "font-woff2"))
.put("swf", MediaType.create("application", "vnd.adobe.flash-movie"))
.put("map", MediaType.JSON_UTF_8)
.build();
// this constant is from org.h2.api.ErrorCode.STATEMENT_WAS_CANCELED
// (but h2 jar is not a dependency of glowroot-ui)
private static final int H2_STATEMENT_WAS_CANCELED = 57014;
static {
URL resourceBaseUrl = getUrlForPath(RESOURCE_BASE);
if (resourceBaseUrl == null) {
RESOURCE_BASE_URL_PREFIX = null;
} else {
RESOURCE_BASE_URL_PREFIX = resourceBaseUrl.toExternalForm();
}
}
private final LayoutService layoutService;
private final ImmutableMap<Pattern, HttpService> httpServices;
private final ImmutableList<JsonServiceMapping> jsonServiceMappings;
private final HttpSessionManager httpSessionManager;
private final Clock clock;
public CommonHandler(LayoutService layoutService, Map<Pattern, HttpService> httpServices,
HttpSessionManager httpSessionManager, List<Object> jsonServices, Clock clock) {
this.layoutService = layoutService;
this.httpServices = ImmutableMap.copyOf(httpServices);
this.httpSessionManager = httpSessionManager;
this.clock = clock;
List<JsonServiceMapping> jsonServiceMappings = Lists.newArrayList();
for (Object jsonService : jsonServices) {
for (Method method : jsonService.getClass().getDeclaredMethods()) {
GET annotationGET = method.getAnnotation(GET.class);
if (annotationGET != null) {
jsonServiceMappings.add(build(HttpMethod.GET, annotationGET.path(),
annotationGET.permission(), jsonService, method));
}
POST annotationPOST = method.getAnnotation(POST.class);
if (annotationPOST != null) {
jsonServiceMappings.add(build(HttpMethod.POST, annotationPOST.path(),
annotationPOST.permission(), jsonService, method));
}
}
}
this.jsonServiceMappings = ImmutableList.copyOf(jsonServiceMappings);
}
public CommonResponse handle(CommonRequest request) throws Exception {
logger.debug("handleRequest(): path={}", request.getPath());
CommonResponse response = handleIfLoginOrLogoutRequest(request);
if (response != null) {
return response;
}
boolean autoRefresh = isAutoRefresh(request.getParameters("auto-refresh"));
boolean touchSession = !autoRefresh && !request.getPath().equals("/backend/layout");
Authentication authentication =
httpSessionManager.getAuthentication(request, touchSession);
Glowroot.setTransactionUser(authentication.caseAmbiguousUsername());
response = handleRequest(request, authentication);
if (request.getPath().startsWith("/backend/")
&& !request.getPath().equals("/backend/layout")) {
response.setHeader("Glowroot-Layout-Version",
layoutService.getLayoutVersion(authentication));
}
return response;
}
private @Nullable CommonResponse handleIfLoginOrLogoutRequest(CommonRequest request)
throws Exception {
String path = request.getPath();
if (path.equals("/backend/login")) {
String content = request.getContent();
Credentials credentials = mapper.readValue(content, ImmutableCredentials.class);
Glowroot.setTransactionUser(credentials.username());
return httpSessionManager.login(credentials.username(), credentials.password());
}
if (path.equals("/backend/sign-out")) {
httpSessionManager.signOut(request);
Authentication authentication = httpSessionManager.getAnonymousAuthentication();
Glowroot.setTransactionUser(authentication.caseAmbiguousUsername());
String anonymousLayout = layoutService.getLayoutJson(authentication);
CommonResponse response = new CommonResponse(OK, MediaType.JSON_UTF_8, anonymousLayout);
httpSessionManager.deleteSessionCookie(response);
return response;
}
if (path.equals("/backend/check-layout")) {
Authentication authentication = httpSessionManager.getAuthentication(request, false);
CommonResponse response = new CommonResponse(OK);
response.setHeader("Glowroot-Layout-Version",
layoutService.getLayoutVersion(authentication));
return response;
}
if (path.equals("/backend/layout")) {
Authentication authentication = httpSessionManager.getAuthentication(request, false);
return new CommonResponse(OK, MediaType.JSON_UTF_8,
layoutService.getLayoutJson(authentication));
}
return null;
}
private CommonResponse handleRequest(CommonRequest request, Authentication authentication)
throws Exception {
String path = request.getPath();
HttpService httpService = getHttpService(path);
if (httpService != null) {
return handleHttpService(request, httpService, authentication);
}
JsonServiceMapping jsonServiceMapping = getJsonServiceMapping(request, path);
if (jsonServiceMapping != null) {
return handleJsonServiceMappings(request, jsonServiceMapping, authentication);
}
return handleStaticResource(path, request);
}
private @Nullable HttpService getHttpService(String path) throws Exception {
for (Entry<Pattern, HttpService> entry : httpServices.entrySet()) {
Matcher matcher = entry.getKey().matcher(path);
if (matcher.matches()) {
return entry.getValue();
}
}
return null;
}
private CommonResponse handleHttpService(CommonRequest request, HttpService httpService,
Authentication authentication) throws Exception {
String permission = httpService.getPermission();
if (permission.equals("")) {
// service does not require any permission
return httpService.handleRequest(request, authentication);
}
List<String> agentRollupIds = request.getParameters("agent-rollup-id");
String agentRollupId = agentRollupIds.isEmpty() ? "" : agentRollupIds.get(0);
if (!authentication.isPermitted(agentRollupId, permission)) {
return handleNotAuthorized(request, authentication);
}
return httpService.handleRequest(request, authentication);
}
private @Nullable JsonServiceMapping getJsonServiceMapping(CommonRequest request,
String path) {
for (JsonServiceMapping jsonServiceMapping : jsonServiceMappings) {
if (!jsonServiceMapping.httpMethod().name().equals(request.getMethod())) {
continue;
}
if (jsonServiceMapping.path().equals(path)) {
return jsonServiceMapping;
}
}
return null;
}
private CommonResponse handleJsonServiceMappings(CommonRequest request,
JsonServiceMapping jsonServiceMapping, Authentication authentication) throws Exception {
List<Class<?>> parameterTypes = Lists.newArrayList();
List<Object> parameters = Lists.newArrayList();
Map<String, List<String>> queryParameters = request.getParameters();
boolean permitted;
if (jsonServiceMapping.bindAgentId()) {
List<String> values = queryParameters.get("agent-id");
if (values == null) {
throw new JsonServiceException(BAD_REQUEST, "missing agent-id query parameter");
}
String agentId = values.get(0);
parameterTypes.add(String.class);
parameters.add(agentId);
queryParameters.remove("agent-id");
permitted = authentication.isAgentPermitted(agentId, jsonServiceMapping.permission());
} else if (jsonServiceMapping.bindAgentRollup()) {
List<String> agentRollupIds = queryParameters.get("agent-rollup-id");
if (agentRollupIds == null) {
throw new JsonServiceException(BAD_REQUEST,
"missing agent-rollup-id query parameter");
}
String agentRollupId = agentRollupIds.get(0);
parameterTypes.add(String.class);
parameters.add(agentRollupId);
queryParameters.remove("agent-rollup-id");
permitted =
authentication.isAgentPermitted(agentRollupId, jsonServiceMapping.permission());
} else {
permitted = jsonServiceMapping.permission().isEmpty()
|| authentication.isAdminPermitted(jsonServiceMapping.permission());
}
if (!permitted) {
return handleNotAuthorized(request, authentication);
}
Object responseObject;
try {
responseObject = callMethod(jsonServiceMapping, parameterTypes, parameters,
queryParameters, authentication, request);
} catch (Exception e) {
return newHttpResponseFromException(request, authentication, e);
}
return buildJsonResponse(responseObject);
}
CommonResponse newHttpResponseFromException(CommonRequest request,
Authentication authentication, Exception exception) throws Exception {
Exception e = exception;
if (e instanceof InvocationTargetException) {
Throwable cause = e.getCause();
if (cause instanceof Exception) {
e = (Exception) cause;
}
}
if (e instanceof JsonServiceException) {
// this is an "expected" exception, no need to log
JsonServiceException jsonServiceException = (JsonServiceException) e;
if (jsonServiceException.getStatus() == FORBIDDEN) {
return handleNotAuthorized(request, authentication);
} else {
return newHttpResponseWithMessage(jsonServiceException.getStatus(),
jsonServiceException.getMessage());
}
}
logger.error(e.getMessage(), e);
if (e instanceof SQLException
&& ((SQLException) e).getErrorCode() == H2_STATEMENT_WAS_CANCELED) {
return newHttpResponseWithMessage(REQUEST_TIMEOUT,
"Query timed out (timeout is configurable under Configuration > Advanced)");
}
return newHttpResponseWithStackTrace(e, INTERNAL_SERVER_ERROR, null);
}
private CommonResponse buildJsonResponse(@Nullable Object responseObject) {
if (responseObject == null) {
return new CommonResponse(OK, MediaType.JSON_UTF_8, "");
} else if (responseObject instanceof CommonResponse) {
return (CommonResponse) responseObject;
} else if (responseObject instanceof String) {
return new CommonResponse(OK, MediaType.JSON_UTF_8, (String) responseObject);
} else {
logger.warn("unexpected type of json service response: {}",
responseObject.getClass().getName());
return new CommonResponse(INTERNAL_SERVER_ERROR);
}
}
private CommonResponse handleNotAuthorized(CommonRequest request,
Authentication authentication) throws Exception {
if (authentication.anonymous()) {
if (httpSessionManager.getSessionId(request) != null) {
return new CommonResponse(UNAUTHORIZED, MediaType.JSON_UTF_8,
"{\"timedOut\":true}");
} else {
return new CommonResponse(UNAUTHORIZED);
}
} else {
return new CommonResponse(FORBIDDEN);
}
}
private CommonResponse handleStaticResource(String path, CommonRequest request)
throws IOException {
URL url = getSecureUrlForPath(RESOURCE_BASE + path);
if (url == null) {
// log at debug only since this is typically just exploit bot spam
logger.debug("unexpected path: {}", path);
return new CommonResponse(NOT_FOUND);
}
Date expires = getExpiresForPath(path);
if (request.getHeader(HttpHeaderNames.IF_MODIFIED_SINCE) != null && expires == null) {
// all static resources without explicit expires are versioned and can be safely
// cached forever
return new CommonResponse(NOT_MODIFIED);
}
int extensionStartIndex = path.lastIndexOf('.');
checkState(extensionStartIndex != -1, "found path under %s with no extension: %s",
RESOURCE_BASE, path);
String extension = path.substring(extensionStartIndex + 1);
MediaType mediaType = mediaTypes.get(extension);
checkNotNull(mediaType, "found extension under %s with no media type: %s", RESOURCE_BASE,
extension);
CommonResponse response = new CommonResponse(OK, mediaType, url);
if (expires != null) {
response.setHeader(HttpHeaderNames.EXPIRES, expires);
} else {
response.setHeader(HttpHeaderNames.LAST_MODIFIED, new Date(0));
response.setHeader(HttpHeaderNames.EXPIRES,
new Date(clock.currentTimeMillis() + TEN_YEARS));
}
return response;
}
private @Nullable Date getExpiresForPath(String path) {
if (path.startsWith("org/glowroot/ui/app-dist/favicon.")) {
return new Date(clock.currentTimeMillis() + ONE_DAY);
} else if (path.endsWith(".js.map") || path.startsWith("/sources/")) {
// javascript source maps and source files are not versioned
return new Date(clock.currentTimeMillis() + FIVE_MINUTES);
} else {
return null;
}
}
private static JsonServiceMapping build(HttpMethod httpMethod, String path,
String permission, Object jsonService, Method method) {
boolean bindAgentId = false;
boolean bindAgentRollup = false;
Class<?> bindRequest = null;
boolean bindAutoRefresh = false;
boolean bindAuthentication = false;
for (int i = 0; i < method.getParameterAnnotations().length; i++) {
Annotation[] parameterAnnotations = method.getParameterAnnotations()[i];
for (Annotation annotation : parameterAnnotations) {
if (annotation.annotationType() == BindAgentId.class) {
bindAgentId = true;
} else if (annotation.annotationType() == BindAgentRollupId.class) {
bindAgentRollup = true;
} else if (annotation.annotationType() == BindRequest.class) {
bindRequest = method.getParameterTypes()[i];
} else if (annotation.annotationType() == BindAutoRefresh.class) {
bindAutoRefresh = true;
} else if (annotation.annotationType() == BindAuthentication.class) {
bindAuthentication = true;
}
}
}
return ImmutableJsonServiceMapping.builder()
.httpMethod(httpMethod)
.path(path)
.permission(permission)
.service(jsonService)
.method(method)
.bindAgentId(bindAgentId)
.bindAgentRollup(bindAgentRollup)
.bindRequest(bindRequest)
.bindAutoRefresh(bindAutoRefresh)
.bindAuthentication(bindAuthentication)
.build();
}
private static @Nullable URL getSecureUrlForPath(String path) {
URL url = getUrlForPath(path);
if (url != null && RESOURCE_BASE_URL_PREFIX != null
&& url.toExternalForm().startsWith(RESOURCE_BASE_URL_PREFIX)) {
return url;
}
return null;
}
private static @Nullable URL getUrlForPath(String path) {
ClassLoader classLoader = HttpServerHandler.class.getClassLoader();
if (classLoader == null) {
return ClassLoader.getSystemResource(path);
} else {
return classLoader.getResource(path);
}
}
private static CommonResponse newHttpResponseWithMessage(HttpResponseStatus status,
@Nullable String message) {
// this is an "expected" exception, no need to send back stack trace
StringBuilder sb = new StringBuilder();
try {
JsonGenerator jg = mapper.getFactory().createGenerator(CharStreams.asWriter(sb));
jg.writeStartObject();
jg.writeStringField("message", message);
jg.writeEndObject();
jg.close();
} catch (IOException f) {
logger.error(f.getMessage(), f);
return new CommonResponse(INTERNAL_SERVER_ERROR);
}
return new CommonResponse(status, MediaType.JSON_UTF_8, sb.toString());
}
static CommonResponse newHttpResponseWithStackTrace(Exception e,
HttpResponseStatus status, @Nullable String simplifiedMessage) {
try {
return new CommonResponse(status, MediaType.JSON_UTF_8,
getHttpResponseWithStackTrace(e, simplifiedMessage));
} catch (IOException f) {
logger.error(f.getMessage(), f);
return new CommonResponse(INTERNAL_SERVER_ERROR);
}
}
static String getHttpResponseWithStackTrace(Exception e, @Nullable String simplifiedMessage)
throws IOException {
StringWriter sw = new StringWriter();
e.printStackTrace(new PrintWriter(sw));
StringBuilder sb = new StringBuilder();
JsonGenerator jg = mapper.getFactory().createGenerator(CharStreams.asWriter(sb));
jg.writeStartObject();
String message;
if (simplifiedMessage == null) {
Throwable cause = e;
Throwable childCause = cause.getCause();
while (childCause != null) {
cause = childCause;
childCause = cause.getCause();
}
message = cause.getMessage();
} else {
message = simplifiedMessage;
}
jg.writeStringField("message", message);
jg.writeStringField("stackTrace", sw.toString());
jg.writeEndObject();
jg.close();
return sb.toString();
}
private static @Nullable Object callMethod(JsonServiceMapping jsonServiceMapping,
List<Class<?>> parameterTypes, List<Object> parameters,
Map<String, List<String>> queryParameters, Authentication authentication,
CommonRequest request) throws Exception {
List<String> autoRefreshParams = queryParameters.remove("auto-refresh");
boolean autoRefresh = isAutoRefresh(autoRefreshParams);
Class<?> bindRequest = jsonServiceMapping.bindRequest();
if (bindRequest != null) {
parameterTypes.add(bindRequest);
if (jsonServiceMapping.httpMethod() == HttpMethod.GET) {
parameters.add(QueryStrings.decode(queryParameters, bindRequest));
} else {
String content = request.getContent();
auditLogger.info("{} - POST {} - {}", authentication.caseAmbiguousUsername(),
request.getUri(), content);
if (bindRequest == String.class) {
parameters.add(content);
} else {
// TODO report checker framework issue that occurs without this suppression
@SuppressWarnings("argument.type.incompatible")
Object param = checkNotNull(
mapper.readValue(content, QueryStrings.getImmutableClass(bindRequest)));
parameters.add(param);
}
}
}
if (jsonServiceMapping.bindAutoRefresh()) {
parameterTypes.add(boolean.class);
parameters.add(autoRefresh);
}
if (jsonServiceMapping.bindAuthentication()) {
parameterTypes.add(Authentication.class);
parameters.add(authentication);
}
Object service = jsonServiceMapping.service();
if (logger.isDebugEnabled()) {
String params = Joiner.on(", ").join(parameters);
logger.debug("{}.{}(): {}", service.getClass().getSimpleName(),
jsonServiceMapping.method().getName(), params);
}
return jsonServiceMapping.method().invoke(service,
parameters.toArray(new Object[parameters.size()]));
}
private static boolean isAutoRefresh(@Nullable List<String> autoRefreshParams) {
return autoRefreshParams != null && autoRefreshParams.size() == 1
&& Boolean.valueOf(autoRefreshParams.get(0));
}
@Value.Immutable
interface Credentials {
String username();
String password();
}
@Value.Immutable
interface JsonServiceMapping {
HttpMethod httpMethod();
String path();
String permission();
Object service();
Method method();
boolean bindAgentId();
boolean bindAgentRollup();
@Nullable
Class<?> bindRequest();
boolean bindAutoRefresh();
boolean bindAuthentication();
}
enum HttpMethod {
GET, POST
}
public interface CommonRequest {
String getMethod();
// includes context path
String getUri();
String getContextPath();
// does not include context path
String getPath();
@Nullable
String getHeader(CharSequence name);
Map<String, List<String>> getParameters();
List<String> getParameters(String name);
String getContent() throws IOException;
}
public static class CommonResponse {
private final HttpResponseStatus status;
private final HttpHeaders headers = new DefaultHttpHeaders();
private final Object content;
private @Nullable String zipFileName;
private boolean closeConnectionAfterPortChange;
CommonResponse(HttpResponseStatus status, MediaType mediaType, String content) {
this(status, mediaType, Unpooled.copiedBuffer(content, Charsets.UTF_8), true);
}
CommonResponse(HttpResponseStatus status, MediaType mediaType, ChunkSource content) {
this(status, mediaType, content, true);
}
CommonResponse(HttpResponseStatus status) {
this(status, null, Unpooled.buffer(0), true);
}
private CommonResponse(HttpResponseStatus status, MediaType mediaType, URL url)
throws IOException {
this(status, mediaType, Unpooled.copiedBuffer(Resources.toByteArray(url)), false);
}
private CommonResponse(HttpResponseStatus status, @Nullable MediaType mediaType,
Object content, boolean preventCaching) {
this.status = status;
this.content = content;
if (mediaType != null) {
headers.set(HttpHeaderNames.CONTENT_TYPE, mediaType);
}
if (preventCaching) {
// prevent caching of dynamic json data, using 'definitive' minimum set of headers
// from http://stackoverflow.com/questions/49547/
// making-sure-a-web-page-is-not-cached-across-all-browsers/2068407#2068407
headers.set(HttpHeaderNames.CACHE_CONTROL, "no-cache, no-store, must-revalidate");
headers.set(HttpHeaderNames.PRAGMA, "no-cache");
headers.set(HttpHeaderNames.EXPIRES, new Date(0));
}
}
void setHeader(CharSequence name, Object value) {
headers.set(name, value);
}
void setZipFileName(String zipFileName) {
this.zipFileName = zipFileName;
}
void setCloseConnectionAfterPortChange() {
closeConnectionAfterPortChange = true;
}
public HttpResponseStatus getStatus() {
return status;
}
public HttpHeaders getHeaders() {
return headers;
}
// returns ByteBuf or ChunkSource
public Object getContent() {
return content;
}
public @Nullable String getZipFileName() {
return zipFileName;
}
boolean isCloseConnectionAfterPortChange() {
return closeConnectionAfterPortChange;
}
}
}