/* * Copyright 2014 GoDataDriven B.V. * * 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 io.divolte.server; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectReader; import com.google.common.base.Strings; import com.google.common.hash.HashFunction; import com.google.common.hash.Hashing; import com.google.common.io.Resources; import io.divolte.server.DivolteEvent.BrowserEventData; import io.divolte.server.mincode.MincodeFactory; import io.divolte.server.processing.Item; import io.undertow.server.HttpHandler; import io.undertow.server.HttpServerExchange; import io.undertow.util.ETag; import io.undertow.util.ETagUtils; import io.undertow.util.Headers; import io.undertow.util.StatusCodes; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import javax.annotation.Nullable; import javax.annotation.ParametersAreNonnullByDefault; import static io.divolte.server.HttpSource.*; import java.io.IOException; import java.io.UncheckedIOException; import java.net.InetSocketAddress; import java.nio.ByteBuffer; import java.nio.charset.StandardCharsets; import java.time.Instant; import java.util.*; import java.util.function.Supplier; @ParametersAreNonnullByDefault public final class ClientSideCookieEventHandler implements HttpHandler { private static final Logger logger = LoggerFactory.getLogger(ClientSideCookieEventHandler.class); private final static ETag SENTINEL_ETAG = new ETag(false, "6b3edc43-20ec-4078-bc47-e965dd76b88a"); private final static String SENTINEL_ETAG_VALUE = SENTINEL_ETAG.toString(); private final ByteBuffer transparentImage; private final IncomingRequestProcessingPool processingPool; private final int sourceIndex; private static final String TRUE_STRING = "t"; private static final String PARTY_ID_QUERY_PARAM = "p"; private static final String NEW_PARTY_ID_QUERY_PARAM = "n"; private static final String SESSION_ID_QUERY_PARAM = "s"; private static final String FIRST_IN_SESSION_QUERY_PARAM = "f"; private static final String EVENT_ID_QUERY_PARAM = "e"; private static final String CLIENT_TIMESTAMP_QUERY_PARAM = "c"; // chronos private static final String CHECKSUM_QUERY_PARAM = "x"; private static final String PAGE_VIEW_ID_QUERY_PARAM = "v"; private static final String EVENT_TYPE_QUERY_PARAM = "t"; private static final String EVENT_PARAMETERS_QUERY_PARAM = "u"; private static final String LOCATION_QUERY_PARAM = "l"; private static final String REFERER_QUERY_PARAM = "r"; private static final String VIEWPORT_PIXEL_WIDTH_QUERY_PARAM = "w"; private static final String VIEWPORT_PIXEL_HEIGHT_QUERY_PARAM = "h"; private static final String SCREEN_PIXEL_WIDTH_QUERY_PARAM = "i"; private static final String SCREEN_PIXEL_HEIGHT_QUERY_PARAM = "j"; private static final String DEVICE_PIXEL_RATIO_QUERY_PARAM = "k"; private static final ObjectReader EVENT_PARAMETERS_READER = new ObjectMapper(new MincodeFactory()).reader(); public ClientSideCookieEventHandler(final IncomingRequestProcessingPool processingPool, final int sourceIndex) { this.sourceIndex = sourceIndex; this.processingPool = Objects.requireNonNull(processingPool); try { this.transparentImage = ByteBuffer.wrap( Resources.toByteArray(Resources.getResource("transparent1x1.gif")) ).asReadOnlyBuffer(); } catch (final IOException e) { throw new UncheckedIOException("Could not load transparent image resource.", e); } } @Override public void handleRequest(final HttpServerExchange exchange) { final InetSocketAddress sourceAddress = captureAndPersistSourceAddress(exchange); /* * Set up the headers that we always send as a response, irrespective of what type it * will be. Note that the client is responsible for ensuring that ensures that each request * is unique. * The cache-related headers are intended to prevent spurious reloads for an event. * (Being a GET request, agents are free to re-issue the request at will. We don't want this.) * As a last resort, we try to detect duplicates via the ETag header. */ exchange.getResponseHeaders() .put(Headers.CONTENT_TYPE, "image/gif") .put(Headers.ETAG, SENTINEL_ETAG_VALUE) .put(Headers.CACHE_CONTROL, "private, no-cache, proxy-revalidate") .put(Headers.PRAGMA, "no-cache") .put(Headers.EXPIRES, "Fri, 14 Apr 1995 11:30:00 GMT"); // If an ETag is present, this is a duplicate event. if (ETagUtils.handleIfNoneMatch(exchange, SENTINEL_ETAG, true)) { // Default status code what we want: 200 OK. // Sending the response before logging the event! exchange.getResponseSender().send(transparentImage.slice()); try { logEvent(exchange); } catch (final IncompleteRequestException ire) { // improper request, could be anything logger.warn("Improper request received from {}.", Optional.ofNullable(exchange.getSourceAddress()).map(InetSocketAddress::getHostString).orElse("<UNKNOWN HOST>")); } } else { if (logger.isDebugEnabled()) { logger.debug("Ignoring duplicate event from {}: {}", sourceAddress, getFullUrl(exchange)); } exchange.setStatusCode(StatusCodes.NOT_MODIFIED); exchange.endExchange(); } } private static String getFullUrl(final HttpServerExchange exchange) { final String queryString = exchange.getQueryString(); final String requestUrl = exchange.getRequestURL(); return Strings.isNullOrEmpty(queryString) ? requestUrl : requestUrl + '?' + queryString; } private void logEvent(final HttpServerExchange exchange) throws IncompleteRequestException { final DivolteIdentifier partyId = queryParamFromExchange(exchange, PARTY_ID_QUERY_PARAM).flatMap(DivolteIdentifier::tryParse).orElseThrow(IncompleteRequestException::new); final UndertowEvent event = new BrowserUndertowEvent(Instant.now(), exchange, partyId); processingPool.enqueue(Item.of(sourceIndex, partyId.value, event)); } private static final class BrowserUndertowEvent extends UndertowEvent { private BrowserUndertowEvent(final Instant requestTime, final HttpServerExchange exchange, final DivolteIdentifier partyId) { super(requestTime, exchange, partyId); } @Override public DivolteEvent parseRequest() throws IncompleteRequestException { final boolean corrupt = !isRequestChecksumCorrect(exchange); final DivolteIdentifier partyId = queryParamFromExchange(exchange, PARTY_ID_QUERY_PARAM).flatMap(DivolteIdentifier::tryParse).orElseThrow(IncompleteRequestException::new); final DivolteIdentifier sessionId = queryParamFromExchange(exchange, SESSION_ID_QUERY_PARAM).flatMap(DivolteIdentifier::tryParse).orElseThrow(IncompleteRequestException::new); final String pageViewId = queryParamFromExchange(exchange, PAGE_VIEW_ID_QUERY_PARAM).orElseThrow(IncompleteRequestException::new); final String eventId = queryParamFromExchange(exchange, EVENT_ID_QUERY_PARAM).orElseThrow(IncompleteRequestException::new); final boolean isNewPartyId = queryParamFromExchange(exchange, NEW_PARTY_ID_QUERY_PARAM).map(TRUE_STRING::equals).orElseThrow(IncompleteRequestException::new); final boolean isFirstInSession = queryParamFromExchange(exchange, FIRST_IN_SESSION_QUERY_PARAM).map(TRUE_STRING::equals).orElseThrow(IncompleteRequestException::new); final Instant clientTimeStamp = Instant.ofEpochMilli(queryParamFromExchange(exchange, CLIENT_TIMESTAMP_QUERY_PARAM).map(ClientSideCookieEventHandler::tryParseBase36Long).orElseThrow(IncompleteRequestException::new)); final DivolteEvent event = DivolteEvent.createBrowserEvent(exchange, corrupt, partyId, sessionId, eventId, requestTime, clientTimeStamp, isNewPartyId, isFirstInSession, queryParamFromExchange(exchange, EVENT_TYPE_QUERY_PARAM), eventParameterSupplier(exchange), browserEventData(exchange, pageViewId)); return event; } } private static Supplier<Optional<JsonNode>> eventParameterSupplier(final HttpServerExchange exchange) { return () -> queryParamFromExchange(exchange, EVENT_PARAMETERS_QUERY_PARAM) .map(encodedParameters -> { try { return EVENT_PARAMETERS_READER.readTree(encodedParameters); } catch (final IOException e) { if (logger.isDebugEnabled()) { logger.debug("Could not parse custom event parameters: " + encodedParameters, e); } return null; } }); } private static BrowserEventData browserEventData(final HttpServerExchange exchange, final String pageViewId) { return new DivolteEvent.BrowserEventData( pageViewId, queryParamFromExchange(exchange, LOCATION_QUERY_PARAM), queryParamFromExchange(exchange, REFERER_QUERY_PARAM), queryParamFromExchange(exchange, VIEWPORT_PIXEL_WIDTH_QUERY_PARAM).map(ClientSideCookieEventHandler::tryParseBase36Int), queryParamFromExchange(exchange, VIEWPORT_PIXEL_HEIGHT_QUERY_PARAM).map(ClientSideCookieEventHandler::tryParseBase36Int), queryParamFromExchange(exchange, SCREEN_PIXEL_WIDTH_QUERY_PARAM).map(ClientSideCookieEventHandler::tryParseBase36Int), queryParamFromExchange(exchange, SCREEN_PIXEL_HEIGHT_QUERY_PARAM).map(ClientSideCookieEventHandler::tryParseBase36Int), queryParamFromExchange(exchange, DEVICE_PIXEL_RATIO_QUERY_PARAM).map(ClientSideCookieEventHandler::tryParseBase36Int)); } private static final HashFunction CHECKSUM_HASH = Hashing.murmur3_32(); private static boolean isRequestChecksumCorrect(final HttpServerExchange exchange) { // This is not intended to be robust against intentional tampering; it is intended to guard // against proxies and the like that may have truncated the request. return queryParamFromExchange(exchange, CHECKSUM_QUERY_PARAM) .map(ClientSideCookieEventHandler::tryParseBase36Long) .map((expectedChecksum) -> { /* * We could optimize this by calculating the checksum directly, instead of building up * the intermediate string representation. For now the debug value of the string exceeds * the benefits of going slightly faster. */ final String canonicalRequestString = buildNormalizedChecksumString(exchange.getQueryParameters()); final int requestChecksum = CHECKSUM_HASH.hashString(canonicalRequestString, StandardCharsets.UTF_8).asInt(); final boolean isRequestChecksumCorrect = expectedChecksum == requestChecksum; if (!isRequestChecksumCorrect && logger.isDebugEnabled()) { logger.debug("Checksum mismatch detected; expected {} but was {} for request string: {}", Long.toString(expectedChecksum, 36), Integer.toString(requestChecksum, 36), canonicalRequestString); } return isRequestChecksumCorrect; }) .orElse(false); } private static String buildNormalizedChecksumString(final Map<String,Deque<String>> queryParameters) { return buildNormalizedChecksumString(queryParameters instanceof SortedMap ? (SortedMap)queryParameters : new TreeMap<>(queryParameters)); } private static String buildNormalizedChecksumString(final SortedMap<String,Deque<String>> queryParameters) { /* * Build up a canonical representation of the query parameters. The canonical order is: * 1) Sort the query parameters by key, preserving multiple values (and their order). * 2) The magic parameter containing the checksum is discarded. * 3) Build up a string. For each parameter: * a) Append the parameter name, followed by a '='. * b) Append each value of the parameter, followed by a ','. * c) Append a ';'. * This is designed to be unambiguous in the face of many edge cases. */ final StringBuilder builder = new StringBuilder(); queryParameters.forEach((name, values) -> { if (!CHECKSUM_QUERY_PARAM.equals(name)) { builder.append(name).append('='); values.forEach((value) -> builder.append(value).append(',')); builder.append(';'); } }); return builder.toString(); } @Nullable static Long tryParseBase36Long(final String input) { try { return Long.parseLong(input, 36); } catch(final NumberFormatException nfe) { return null; } } @Nullable private static Integer tryParseBase36Int(final String input) { try { return Integer.valueOf(input, 36); } catch (final NumberFormatException ignored) { // We expect parsing to fail; signal via null. return null; } } }