Skip to content

Commit 0c3c141

Browse files
committed
Add controller used to handle Github webhooks
This webhook in turn triggers a repository dispatch on the github actions workflow used to build the site.
1 parent 58818b0 commit 0c3c141

File tree

12 files changed

+800
-5
lines changed

12 files changed

+800
-5
lines changed

build.gradle

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ dependencies {
4242
annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor'
4343
implementation 'org.springframework.boot:spring-boot-starter-actuator'
4444
implementation 'com.azure.spring:spring-cloud-azure-starter-keyvault-secrets'
45+
implementation 'javax.xml.bind:jaxb-api:2.1'
4546
developmentOnly 'org.springframework.boot:spring-boot-devtools'
4647
testImplementation 'org.springframework.boot:spring-boot-starter-test'
4748
testImplementation 'org.springframework.security:spring-security-test'

src/main/java/io/spring/renderer/RendererProperties.java

Lines changed: 67 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,13 @@ public static class Github {
4343
@Pattern(regexp = "([0-9a-zA-Z_]*)?")
4444
private String token;
4545

46+
/**
47+
* Name of the Github organization to fetch guides from.
48+
*/
49+
private String organization = "spring-guides";
50+
51+
private Webhook webhook = new Webhook();
52+
4653
public String getToken() {
4754
return this.token;
4855
}
@@ -51,11 +58,6 @@ public void setToken(String token) {
5158
this.token = token;
5259
}
5360

54-
/**
55-
* Name of the Github organization to fetch guides from.
56-
*/
57-
private String organization = "spring-guides";
58-
5961
public String getOrganization() {
6062
return this.organization;
6163
}
@@ -64,6 +66,66 @@ public void setOrganization(String organization) {
6466
this.organization = organization;
6567
}
6668

69+
public Webhook getWebhook() {
70+
return this.webhook;
71+
}
72+
73+
}
74+
75+
public static class Webhook {
76+
77+
/**
78+
* Token configured in GitHub webhooks for this application.
79+
*/
80+
private String secret = "changeme";
81+
82+
/**
83+
* Org name for dispatching Github Action.
84+
*/
85+
private String actionOrg;
86+
87+
/**
88+
* Repository name for dispatching Github Action.
89+
*/
90+
private String actionRepo;
91+
92+
/**
93+
* Token with repo scope for for dispatching Github Action.
94+
*/
95+
private String dispatchToken;
96+
97+
public String getSecret() {
98+
return this.secret;
99+
}
100+
101+
public void setSecret(String secret) {
102+
this.secret = secret;
103+
}
104+
105+
public String getActionOrg() {
106+
return this.actionOrg;
107+
}
108+
109+
public void setActionOrg(String actionOrg) {
110+
this.actionOrg = actionOrg;
111+
}
112+
113+
public String getActionRepo() {
114+
return this.actionRepo;
115+
}
116+
117+
public void setActionRepo(String actionRepo) {
118+
this.actionRepo = actionRepo;
119+
}
120+
121+
public String getDispatchToken() {
122+
return this.dispatchToken;
123+
}
124+
125+
public void setDispatchToken(String dispatchToken) {
126+
this.dispatchToken = dispatchToken;
127+
}
128+
67129
}
68130

69131
}
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
/*
2+
* Copyright 2022-2023 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package io.spring.renderer;
17+
18+
import org.springframework.context.annotation.Bean;
19+
import org.springframework.context.annotation.Configuration;
20+
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
21+
import org.springframework.security.web.SecurityFilterChain;
22+
23+
/**
24+
* Security configuration for the application.
25+
*
26+
* @author Madhura Bhave
27+
*/
28+
@Configuration
29+
public class SecurityConfiguration {
30+
31+
@Bean
32+
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
33+
http.authorizeHttpRequests((request) -> {
34+
request.mvcMatchers("/webhook/guides").permitAll();
35+
request.anyRequest().authenticated();
36+
});
37+
http.csrf(csrf -> csrf.ignoringAntMatchers("/webhook/**"));
38+
http.httpBasic();
39+
return http.build();
40+
}
41+
42+
}
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
/*
2+
* Copyright 2022-2023 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package io.spring.renderer.guides.webhook;
18+
19+
import org.springframework.boot.web.client.RestTemplateBuilder;
20+
import org.springframework.http.RequestEntity;
21+
import org.springframework.http.ResponseEntity;
22+
import org.springframework.stereotype.Service;
23+
import org.springframework.web.client.HttpClientErrorException;
24+
import org.springframework.web.client.RestTemplate;
25+
26+
/**
27+
* Service to connect to Github Actions.
28+
*
29+
* @author Madhura Bhave
30+
*/
31+
@Service
32+
class GithubActionsService {
33+
34+
private final RestTemplate restTemplate;
35+
36+
private static final String ACCEPT_HEADER = "application/vnd.github+json";
37+
38+
private static final String DISPATCH_PATH_TEMPLATE = "https://api.github.com/repos/{org}/{repo}/dispatches";
39+
40+
GithubActionsService(RestTemplateBuilder builder) {
41+
this.restTemplate = builder.build();
42+
}
43+
44+
void triggerRespositoryDispatch(String org, String repo, String token) {
45+
RequestEntity<String> entity = RequestEntity.post(DISPATCH_PATH_TEMPLATE, org, repo)
46+
.header("Authorization", "Bearer " + token).header("Accept", ACCEPT_HEADER)
47+
.body("{\"event_type\": \"guides\"}");
48+
try {
49+
this.restTemplate.exchange(entity, Void.class);
50+
}
51+
catch (HttpClientErrorException ex) {
52+
throw new RepositoryDispatchFailedException(ex.getRawStatusCode());
53+
}
54+
55+
}
56+
57+
}
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
/*
2+
* Copyright 2022-2023 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package io.spring.renderer.guides.webhook;
18+
19+
import java.io.IOException;
20+
import java.nio.charset.Charset;
21+
import java.nio.charset.StandardCharsets;
22+
import java.security.InvalidKeyException;
23+
import java.security.NoSuchAlgorithmException;
24+
import java.util.Map;
25+
26+
import javax.crypto.Mac;
27+
import javax.crypto.spec.SecretKeySpec;
28+
import javax.xml.bind.DatatypeConverter;
29+
30+
import com.fasterxml.jackson.databind.ObjectMapper;
31+
import io.spring.renderer.RendererProperties;
32+
import org.apache.commons.logging.Log;
33+
import org.apache.commons.logging.LogFactory;
34+
35+
import org.springframework.beans.factory.annotation.Autowired;
36+
import org.springframework.http.HttpStatus;
37+
import org.springframework.http.ResponseEntity;
38+
import org.springframework.web.bind.annotation.ExceptionHandler;
39+
import org.springframework.web.bind.annotation.PostMapping;
40+
import org.springframework.web.bind.annotation.RequestBody;
41+
import org.springframework.web.bind.annotation.RequestHeader;
42+
import org.springframework.web.bind.annotation.RequestMapping;
43+
import org.springframework.web.bind.annotation.RestController;
44+
45+
/**
46+
* Controller that handles requests from GitHub webhook set up at
47+
* <a href="https://github.com/spring-guides/">the org level </a> and triggers the Github
48+
* Action to update the website. Github requests are signed with a shared secret, using an
49+
* HMAC sha-1 algorithm.
50+
*/
51+
@RestController
52+
@RequestMapping("/webhook/")
53+
class GuidesWebhookController {
54+
55+
private static final Log logger = LogFactory.getLog(GuidesWebhookController.class);
56+
57+
private static final Charset CHARSET = StandardCharsets.UTF_8;
58+
59+
private static final String HMAC_ALGORITHM = "HmacSHA1";
60+
61+
private static final String PING_EVENT = "ping";
62+
63+
private final ObjectMapper objectMapper;
64+
65+
private final Mac hmac;
66+
67+
private final GithubActionsService service;
68+
69+
private final RendererProperties properties;
70+
71+
@Autowired
72+
public GuidesWebhookController(ObjectMapper objectMapper, RendererProperties properties,
73+
GithubActionsService service) throws NoSuchAlgorithmException, InvalidKeyException {
74+
this.objectMapper = objectMapper;
75+
this.service = service;
76+
// initialize HMAC with SHA1 algorithm and secret
77+
SecretKeySpec secret = new SecretKeySpec(properties.getGithub().getWebhook().getSecret().getBytes(CHARSET),
78+
HMAC_ALGORITHM);
79+
this.hmac = Mac.getInstance(HMAC_ALGORITHM);
80+
this.hmac.init(secret);
81+
this.properties = properties;
82+
}
83+
84+
@PostMapping(path = "guides", consumes = "application/json", produces = "application/json")
85+
public ResponseEntity<String> processGuidesUpdate(@RequestBody String payload,
86+
@RequestHeader("X-Hub-Signature") String signature,
87+
@RequestHeader(name = "X-GitHub-Event", required = false, defaultValue = "push") String event)
88+
throws IOException {
89+
verifyHmacSignature(payload, signature);
90+
if (PING_EVENT.equals(event)) {
91+
return ResponseEntity.ok("{ \"message\": \"Successfully processed ping event\" }");
92+
}
93+
Map<?, ?> push = this.objectMapper.readValue(payload, Map.class);
94+
logPayload(push);
95+
RendererProperties.Webhook webhook = this.properties.getGithub().getWebhook();
96+
this.service.triggerRespositoryDispatch(webhook.getActionOrg(), webhook.getActionRepo(),
97+
webhook.getDispatchToken());
98+
return ResponseEntity.ok("{ \"message\": \"Successfully processed update\" }");
99+
}
100+
101+
@ExceptionHandler(WebhookAuthenticationException.class)
102+
public ResponseEntity<String> handleWebhookAuthenticationFailure(WebhookAuthenticationException exception) {
103+
logger.error("Webhook authentication failure: " + exception.getMessage());
104+
return ResponseEntity.status(HttpStatus.FORBIDDEN).body("{ \"message\": \"Forbidden\" }");
105+
}
106+
107+
@ExceptionHandler(IOException.class)
108+
public ResponseEntity<String> handlePayloadParsingException(IOException exception) {
109+
logger.error("Payload parsing exception", exception);
110+
return ResponseEntity.badRequest().body("{ \"message\": \"Bad Request\" }");
111+
}
112+
113+
private void verifyHmacSignature(String message, String signature) {
114+
byte[] sig = hmac.doFinal(message.getBytes(CHARSET));
115+
String computedSignature = "sha1=" + DatatypeConverter.printHexBinary(sig);
116+
if (!computedSignature.equalsIgnoreCase(signature)) {
117+
throw new WebhookAuthenticationException(computedSignature, signature);
118+
}
119+
}
120+
121+
private void logPayload(Map<?, ?> push) {
122+
if (push.containsKey("head_commit")) {
123+
final Object headCommit = push.get("head_commit");
124+
if (headCommit != null) {
125+
final Map<?, ?> headCommitMap = (Map<?, ?>) headCommit;
126+
logger.info("Received new webhook payload for push with head_commit message: "
127+
+ headCommitMap.get("message"));
128+
}
129+
}
130+
else {
131+
logger.info("Received new webhook payload for push, but with no head_commit");
132+
}
133+
}
134+
135+
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
/*
2+
* Copyright 2022-2023 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package io.spring.renderer.guides.webhook;
18+
19+
/**
20+
* Exception raised when the request to trigger a repository dispatch event returns
21+
* anything other than an HTTP 2xx status.
22+
*/
23+
class RepositoryDispatchFailedException extends RuntimeException {
24+
25+
RepositoryDispatchFailedException(int statusCode) {
26+
super(String.format("Repository Dispatch failed with status code: '%d'", statusCode));
27+
}
28+
29+
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
/*
2+
* Copyright 2022-2023 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package io.spring.renderer.guides.webhook;
18+
19+
/**
20+
* Exception raised when a github webhook message is received but its HMAC signature does
21+
* not match the one computed with the shared secret.
22+
*/
23+
class WebhookAuthenticationException extends RuntimeException {
24+
25+
WebhookAuthenticationException(String expected, String actual) {
26+
super(String.format("Could not verify signature: '%s', expected '%s'", actual, expected));
27+
}
28+
29+
}

0 commit comments

Comments
 (0)