package sagan.guides.support; import com.fasterxml.jackson.databind.ObjectMapper; import org.apache.commons.logging.Log; import org.apache.commons.logging.LogFactory; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; import javax.crypto.Mac; import javax.crypto.spec.SecretKeySpec; import javax.xml.bind.DatatypeConverter; import java.io.IOException; import java.nio.charset.Charset; import java.security.InvalidKeyException; import java.security.NoSuchAlgorithmException; import java.util.Map; import static org.springframework.web.bind.annotation.RequestMethod.POST; /** * Controller that handles requests from GitHub webhook set up at <a * href="https://github.com/spring-guides/">the guides, tutorials and understanding docs * repository</a> and clears the rendered docs from cache. * <p> * This allows to keep the rendered versions of those docs in cache until new changes * have been pushed to the repository. * <p> * Github requests are signed with a shared secret, using an HMAC sha-1 algorithm. */ @RestController @RequestMapping("/webhook/docs/") class DocsWebhookController { private static final Log logger = LogFactory.getLog(DocsWebhookController.class); private static final Charset CHARSET = Charset.forName("UTF-8"); private static final String HMAC_ALGORITHM = "HmacSHA1"; private static final String PING_EVENT = "ping"; private final ObjectMapper objectMapper; private final Tutorials tutorials; private final UnderstandingDocs understandingDocs; private final GettingStartedGuides gettingStartedGuides; private final Topicals topicals; private final Mac hmac; @Autowired public DocsWebhookController(ObjectMapper objectMapper, Tutorials tutorials, UnderstandingDocs understandingDocs, GettingStartedGuides gettingStartedGuides, Topicals topicals, @Value("${WEBHOOK_ACCESS_TOKEN:default}") String accessToken) throws NoSuchAlgorithmException, InvalidKeyException { this.objectMapper = objectMapper; this.tutorials = tutorials; this.understandingDocs = understandingDocs; this.gettingStartedGuides = gettingStartedGuides; this.topicals = topicals; // initialize HMAC with SHA1 algorithm and secret SecretKeySpec secret = new SecretKeySpec(accessToken.getBytes(CHARSET), HMAC_ALGORITHM); hmac = Mac.getInstance(HMAC_ALGORITHM); hmac.init(secret); } @ExceptionHandler(WebhookAuthenticationException.class) public ResponseEntity<String> handleWebhookAuthenticationFailure(WebhookAuthenticationException exception) { logger.error("Webhook authentication failure: " + exception.getMessage()); return ResponseEntity.status(HttpStatus.FORBIDDEN).body("{ \"message\": \"Forbidden\" }\n"); } @ExceptionHandler(IOException.class) public ResponseEntity<String> handlePayloadParsingException(IOException exception) { logger.error("Payload parsing exception", exception); return ResponseEntity.badRequest().body("{ \"message\": \"Bad Request\" }\n"); } @RequestMapping(value = "guides", method = POST, consumes = "application/json", produces = "application/json") public ResponseEntity<String> processGuidesUpdate(@RequestBody String payload, @RequestHeader("X-Hub-Signature") String signature, @RequestHeader("X-GitHub-Event") String event) throws IOException { verifyHmacSignature(payload, signature); if (PING_EVENT.equals(event)) { return ResponseEntity.ok("{ \"message\": \"Successfully processed ping event\" }\n"); } Map<?, ?> push = this.objectMapper.readValue(payload, Map.class); logPayload(push); String repositoryName = (String) ((Map<?, ?>) push.get("repository")).get("name"); String guideName = this.gettingStartedGuides.parseGuideName(repositoryName); this.gettingStartedGuides.evictFromCache(guideName); return ResponseEntity.ok("{ \"message\": \"Successfully processed update\" }\n"); } @RequestMapping(value = "guides/{repositoryName}", method = POST, consumes = "application/json", produces = "application/json") public ResponseEntity<String> processGuidesUpdate(@RequestBody String payload, @RequestHeader("X-Hub-Signature") String signature, @RequestHeader("X-GitHub-Event") String event, @PathVariable String repositoryName) throws IOException { verifyHmacSignature(payload, signature); if (PING_EVENT.equals(event)) { return ResponseEntity.ok("{ \"message\": \"Successfully processed ping event\" }\n"); } logger.info("Received new webhook payload for push against " + repositoryName); String guideName = this.gettingStartedGuides.parseGuideName(repositoryName); this.gettingStartedGuides.evictFromCache(guideName); return ResponseEntity.ok("{ \"message\": \"Successfully processed update\" }\n"); } @RequestMapping(value = "tutorials", method = POST, consumes = "application/json", produces = "application/json") public ResponseEntity<String> processTutorialsUpdate(@RequestBody String payload, @RequestHeader("X-Hub-Signature") String signature, @RequestHeader("X-GitHub-Event") String event) throws IOException { verifyHmacSignature(payload, signature); if (PING_EVENT.equals(event)) { return ResponseEntity.ok("{ \"message\": \"Successfully processed ping event\" }\n"); } Map<?, ?> push = this.objectMapper.readValue(payload, Map.class); logPayload(push); String repositoryName = (String) ((Map<?, ?>) push.get("repository")).get("name"); String tutorialName = this.tutorials.parseGuideName(repositoryName); this.tutorials.evictFromCache(tutorialName); return ResponseEntity.ok("{ \"message\": \"Successfully processed update\" }\n"); } @RequestMapping(value = "tutorials/{repositoryName}", method = POST, consumes = "application/json", produces = "application/json") public ResponseEntity<String> processTutorialsUpdate(@RequestBody String payload, @RequestHeader("X-Hub-Signature") String signature, @RequestHeader("X-GitHub-Event") String event, @PathVariable String repositoryName) throws IOException { verifyHmacSignature(payload, signature); if (PING_EVENT.equals(event)) { return ResponseEntity.ok("{ \"message\": \"Successfully processed ping event\" }\n"); } logger.info("Received new webhook payload for push against " + repositoryName); String tutorialName = this.tutorials.parseGuideName(repositoryName); this.tutorials.evictFromCache(tutorialName); return ResponseEntity.ok("{ \"message\": \"Successfully processed update\" }\n"); } @RequestMapping(value = "understanding", method = POST, consumes = "application/json", produces = "application/json") public ResponseEntity<String> processUnderstandingUpdate(@RequestBody String payload, @RequestHeader("X-Hub-Signature") String signature, @RequestHeader("X-GitHub-Event") String event) throws IOException { verifyHmacSignature(payload, signature); if (PING_EVENT.equals(event)) { return ResponseEntity.ok("{ \"message\": \"Successfully processed ping event\" }\n"); } Map<?, ?> push = this.objectMapper.readValue(payload, Map.class); logPayload(push); // all understanding docs live under the same repository, so clearing all entries this.understandingDocs.clearCache(); return ResponseEntity.ok("{ \"message\": \"Successfully processed update\" }\n"); } @RequestMapping(value = "topicals/{repositoryName}", method = POST, consumes = "application/json", produces = "application/json") public ResponseEntity<String> processTopicalsUpdate(@RequestBody String payload, @RequestHeader("X-Hub-Signature") String signature, @RequestHeader("X-GitHub-Event") String event, @PathVariable String repositoryName) throws IOException { verifyHmacSignature(payload, signature); if (PING_EVENT.equals(event)) { return ResponseEntity.ok("{ \"message\": \"Successfully processed ping event\" }\n"); } logger.info("Received new webhook payload for push against " + repositoryName); String guideName = this.topicals.parseGuideName(repositoryName); this.topicals.evictFromCache(guideName); return ResponseEntity.ok("{ \"message\": \"Successfully processed update\" }\n"); } protected void verifyHmacSignature(String message, String signature) { byte[] sig = hmac.doFinal(message.getBytes(CHARSET)); String computedSignature = "sha1=" + DatatypeConverter.printHexBinary(sig); if (!computedSignature.equalsIgnoreCase(signature)) { throw new WebhookAuthenticationException(computedSignature, signature); } } private void logPayload(Map<?, ?> push) { if (push.containsKey("head_commit")) { final Object headCommit = push.get("head_commit"); if (headCommit != null) { final Map<?, ?> headCommitMap = (Map<?, ?>) headCommit; logger.info("Received new webhook payload for push with head_commit message: " + headCommitMap.get("message")); } } else { logger.info("Received new webhook payload for push, but with no head_commit"); } } }