org.basinmc.irc.bridge.github.GitHubServerHandler.java Source code

Java tutorial

Introduction

Here is the source code for org.basinmc.irc.bridge.github.GitHubServerHandler.java

Source

/*
 * Copyright 2016 Johannes Donath <johannesd@torchmind.com>
 * and other copyright owners as documented in the project's IP log.
 *
 * 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.basinmc.irc.bridge.github;

import com.google.common.io.ByteStreams;

import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.ObjectReader;

import org.apache.commons.codec.DecoderException;
import org.apache.commons.codec.binary.Hex;
import org.apache.commons.lang3.text.StrLookup;
import org.apache.commons.lang3.text.StrSubstitutor;
import org.basinmc.irc.bridge.Bridge;
import org.eclipse.jetty.server.Request;
import org.eclipse.jetty.server.handler.AbstractHandler;
import org.pircbotx.Colors;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;
import java.security.InvalidKeyException;
import java.security.NoSuchAlgorithmException;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashMap;
import java.util.Map;

import javax.annotation.Nonnull;
import javax.annotation.Nullable;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

/**
 * Handles webhook requests made by GitHub and turns them into human readable informational messages
 * to be sent to all servers the bridge is currently connected to.
 *
 * @author <a href="mailto:johannesd@torchmind.com">Johannes Donath</a>
 */
public class GitHubServerHandler extends AbstractHandler {
    public static final String SIGNATURE_ALGORITHM = "HmacSHA1";
    private static final Logger logger = LoggerFactory.getLogger(GitHubServerHandler.class);
    private static final Map<String, String> COLOR_MAP;

    static {
        Map<String, String> colorMap = new HashMap<>();
        Colors.COLORS_TABLE.forEach((k, v) -> {
            colorMap.put(k.toLowerCase(), v);
        });
        COLOR_MAP = Collections.unmodifiableMap(colorMap);
    }

    private final Bridge bridge;
    private final ObjectReader reader;
    private final SecretKeySpec secret;
    private final Map<String, ?> messageMap;

    @SuppressWarnings("unchecked")
    public GitHubServerHandler(@Nonnull Bridge bridge, @Nullable String secret) {
        this.bridge = bridge;

        if (secret == null || secret.isEmpty()) {
            this.secret = null;
        } else {
            this.secret = new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), SIGNATURE_ALGORITHM);
        }

        ObjectMapper mapper = (new ObjectMapper()).findAndRegisterModules();
        this.reader = mapper.readerFor(Map.class);

        try {
            this.messageMap = mapper.readValue(GitHubServerHandler.class.getResourceAsStream("/github.json"),
                    Map.class);
        } catch (IOException e) {
            throw new RuntimeException("Cannot read GitHub messages: " + e.getMessage());
        }
    }

    /**
     * {@inheritDoc}
     */
    @Override
    public void handle(String target, Request baseRequest, HttpServletRequest request, HttpServletResponse response)
            throws IOException, ServletException {
        // only handle requests to /
        if (!target.equals("/webhook")) {
            return;
        }

        // verify whether the call comes directly from GitHub using the X-GitHub-Event,
        // X-Hub-Signature and X-GitHub-Delivery headers
        String eventType = request.getHeader("X-GitHub-Event");
        String signature = request.getHeader("X-Hub-Signature");
        String deliveryId = request.getHeader("X-GitHub-Delivery");

        if (eventType == null || eventType.isEmpty()
                || (this.secret != null && (signature == null || signature.isEmpty())) || deliveryId == null
                || deliveryId.isEmpty()) {
            response.sendError(HttpServletResponse.SC_BAD_REQUEST);
            baseRequest.setHandled(true);
            return;
        }

        if (signature != null) {
            // strip sha1=
            // TODO: Decide upon signature method based on this parameter
            signature = signature.substring(5);
        }

        logger.info("Processing GitHub request " + deliveryId + ".");

        // decode the data passed in the request body
        String data;
        try (InputStream inputStream = request.getInputStream()) {
            data = new String(ByteStreams.toByteArray(inputStream),
                    Charset.forName(request.getCharacterEncoding()));
        }

        // verify the signature supplied to us (as long as a secret key was configured)
        try {
            if (!verifySignature(data, signature)) {
                response.sendError(HttpServletResponse.SC_FORBIDDEN);
                baseRequest.setHandled(true);
                return;
            }
        } catch (IllegalStateException ex) {
            response.sendError(HttpServletResponse.SC_BAD_REQUEST);
            baseRequest.setHandled(true);
            return;
        }

        // find correct event message
        eventType = eventType.replace('_', '.');

        // de-serialize and handle event data
        Map<String, Object> context = new HashMap<>();
        context.put("color", COLOR_MAP);
        context.put("event", reader.readValue(data));

        String message = this.getMessage(eventType, context);

        if (message != null) {
            this.bridge.sendMessage(message);
        }

        // answer with 204 at all times
        response.setStatus(HttpServletResponse.SC_NO_CONTENT);
        baseRequest.setHandled(true);
    }

    /**
     * Retrieves the message for a certain event type and metadata.
     *
     * @param eventType an event type.
     * @param eventData a set of decoded event data.
     * @return a message or, if no message was found, null.
     */
    @Nullable
    @SuppressWarnings("unchecked")
    private String getMessage(@Nonnull String eventType, @Nonnull Map eventData) {
        StrSubstitutor substitutor = new StrSubstitutor(StrLookup.mapLookup(this.flatMap(eventData)));
        Object handler = this.messageMap.get(eventType);
        String message = null;

        if (handler instanceof String) {
            message = substitutor.replace(handler);
        } else if (handler instanceof Map) {
            Map group = (Map) handler;
            String condition = (String) group.get("condition");
            Object prefix = group.get("prefix");
            Object suffix = group.get("suffix");

            if (prefix instanceof String) {
                prefix = substitutor.replace(prefix);
            } else {
                prefix = null;
            }

            if (suffix instanceof String) {
                suffix = substitutor.replace(suffix);
            } else {
                suffix = null;
            }

            if (condition == null) {
                logger.error("Group " + eventType + " does not have a condition");
            } else {
                condition = substitutor.replace(condition);
                Object childHandler = group.get(condition);

                if (childHandler instanceof String) {
                    return (prefix != null ? prefix : "") + substitutor.replace(childHandler)
                            + (suffix != null ? suffix : "");
                } else if (childHandler != null) {
                    logger.error("Invalid child handler type: " + childHandler.getClass());
                }
            }
        } else if (handler != null) {
            logger.error("Invalid handler type: " + handler.getClass());
        }

        return message;
    }

    /**
     * Converts a nested map into a flat map consisting of dot separated property keys.
     *
     * @param original a nested map.
     * @return a flat map.
     */
    @Nonnull
    @SuppressWarnings("unchecked")
    private Map flatMap(@Nonnull Map original) {
        Map flat = new HashMap<>();

        original.forEach((k, v) -> {
            if (v instanceof Map) {
                this.flatMap((Map) v).forEach((j, t) -> flat.put(k + "." + j, t));
            } else {
                flat.put(k, v);
            }
        });

        return flat;
    }

    /**
     * Verifies a request signature.
     *
     * @param data      a payload.
     * @param signature a signature.
     * @return true if valid, false otherwise.
     */
    private boolean verifySignature(@Nonnull String data, @Nonnull String signature) {
        if (this.secret == null) {
            logger.warn("No secret key specified. Signature checks will be skipped!");
            return true;
        }

        try {
            Mac mac = Mac.getInstance(SIGNATURE_ALGORITHM);
            mac.init(this.secret);

            byte[] expected = mac.doFinal(data.getBytes(StandardCharsets.UTF_8));
            return Arrays.equals(expected, Hex.decodeHex(signature.toCharArray()));
        } catch (InvalidKeyException | NoSuchAlgorithmException ex) {
            logger.error("Could not verify signature: " + ex.getMessage(), ex);
        } catch (DecoderException ex) {
            logger.warn("Could not decode signature: " + ex.getMessage(), ex);
        }

        throw new IllegalStateException("Could not verify signature");
    }
}