Java tutorial
/* * 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"); } }