Skip to content

Commit

Permalink
Add support for global/project level webhooks (#10)
Browse files Browse the repository at this point in the history
* Add support for global/project level webhooks

* Check for invalid scope, repository, and project

* Improve exception handling

* HMAC signing + JSON endpoint

* Update src/main/java/com/sourcegraph/webhook/Dispatcher.java

Co-Authored-By: Nick Snyder <[email protected]>

* Address comments

* Fix ending newlines

* Clean imports

* Add ReopenedEvent

* Support webhook delete by name + change header signature name

* Exclude secret from being serialized
  • Loading branch information
kzh authored Nov 13, 2019
1 parent 26c0021 commit fe2491f
Show file tree
Hide file tree
Showing 11 changed files with 648 additions and 4 deletions.
19 changes: 17 additions & 2 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,11 @@
</dependencyManagement>

<dependencies>
<dependency>
<groupId>javax.inject</groupId>
<artifactId>javax.inject</artifactId>
<version>1</version>
</dependency>
<dependency>
<groupId>com.atlassian.bitbucket.server</groupId>
<artifactId>bitbucket-api</artifactId>
Expand All @@ -62,6 +67,12 @@
<scope>provided</scope>
<version>2.0</version>
</dependency>
<dependency>
<groupId>com.atlassian.activeobjects</groupId>
<artifactId>activeobjects-plugin</artifactId>
<version>1.2.3</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>com.atlassian.plugin</groupId>
<artifactId>atlassian-spring-scanner-annotation</artifactId>
Expand Down Expand Up @@ -90,7 +101,11 @@
<artifactId>sal-api</artifactId>
<scope>provided</scope>
</dependency>

<dependency>
<groupId>com.atlassian.bitbucket.server</groupId>
<artifactId>bitbucket-spi</artifactId>
<scope>provided</scope>
</dependency>
</dependencies>

<build>
Expand Down Expand Up @@ -152,7 +167,7 @@
<artifactId>atlassian-spring-scanner-external-jar</artifactId>
</dependency>
</scannedDependencies>
<verbose>false</verbose>
<verbose>true</verbose>
</configuration>
</plugin>
</plugins>
Expand Down
106 changes: 106 additions & 0 deletions src/main/java/com/sourcegraph/webhook/Dispatcher.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
package com.sourcegraph.webhook;

import com.atlassian.plugin.spring.scanner.annotation.imports.ComponentImport;
import com.atlassian.sal.api.net.Request;
import com.atlassian.sal.api.net.RequestFactory;
import com.atlassian.sal.api.net.Response;
import com.atlassian.sal.api.net.ResponseException;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import com.google.gson.JsonObject;
import com.sourcegraph.webhook.registry.Webhook;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;

import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.security.InvalidKeyException;
import java.security.NoSuchAlgorithmException;
import java.util.Base64;
import java.util.concurrent.ExecutorService;

@Component
public class Dispatcher {
private static final Logger log = LoggerFactory.getLogger(Dispatcher.class);
private static final int RETRY_DELAY = 10 * 1000;
private static final int MAX_ATTEMPTS = 5;
@ComponentImport
private static ExecutorService executor;
@ComponentImport
private static RequestFactory requestFactory;

@Autowired
public Dispatcher(ExecutorService executor, RequestFactory<?> requestFactory) {
Dispatcher.executor = executor;
Dispatcher.requestFactory = requestFactory;
}

private static String sign(String secret, String data) throws NoSuchAlgorithmException, InvalidKeyException {
Mac mac = Mac.getInstance("HmacSHA1");
SecretKeySpec secretKeySpec = new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA1");
mac.init(secretKeySpec);
return Base64.getEncoder().encodeToString(mac.doFinal(data.getBytes(StandardCharsets.UTF_8)));
}

private static Request createRequest(Webhook hook, EventSerializer serializer) {
Request request = requestFactory.createRequest(Request.MethodType.POST, hook.endpoint);
request.setHeader("X-Event-Key", serializer.getName());
request.setHeader("X-Hook-ID", String.valueOf(hook.id));
request.setHeader("X-Hook-Name", hook.name);

JsonObject payload = serializer.serialize();
Gson gson = new GsonBuilder()
.excludeFieldsWithoutExposeAnnotation()
.create();
String json = gson.toJson(payload);
request.setRequestBody(json);

try {
request.setHeader("X-Hub-Signature", sign(hook.secret, json));
} catch (InvalidKeyException | NoSuchAlgorithmException e) {
log.error(e.toString());
return null;
}

return request;
}

public static void dispatch(Webhook hook, EventSerializer serializer) {
executor.submit(() -> {
Request request = createRequest(hook, serializer);
if (request == null) {
return;
}

int attempt = 0;
while (true) {
request.setHeader("X-Attempt-Number", String.valueOf(attempt));
try {
Response response = (Response) request.executeAndReturn((resp) -> resp);
if (response.isSuccessful()) {
log.debug("Dispatching webhook (" + serializer.getName() + ") data to URL: [" + hook.endpoint + "] succeeded.");
break;
}
} catch (ResponseException e) {
log.debug("Dispatching webhook data (" + serializer.getName() + ") to URL: [" + hook.endpoint + "] failed with error:\n" + e);
}
attempt++;

if (attempt == MAX_ATTEMPTS) {
log.warn("Dispatching webhook data (" + serializer.getName() + ") to URL: [" + hook.endpoint + "] failed after " + attempt + " attempts..");
break;
}

try {
Thread.sleep(RETRY_DELAY);
} catch (InterruptedException e) {
log.debug("Dispatching webhook data (" + serializer.getName() + ") to URL: [" + hook.endpoint + "] was interrupted.");
break;
}
}
});
}
}
114 changes: 114 additions & 0 deletions src/main/java/com/sourcegraph/webhook/EventSerializer.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
package com.sourcegraph.webhook;

import com.atlassian.bitbucket.event.ApplicationEvent;
import com.atlassian.bitbucket.event.pull.*;
import com.atlassian.bitbucket.json.JsonRenderer;
import com.atlassian.plugin.spring.scanner.annotation.imports.ComponentImport;
import com.google.gson.*;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;

import java.text.SimpleDateFormat;
import java.util.HashMap;
import java.util.Map;

@Component
public class EventSerializer {
private static final SimpleDateFormat RFC3339 = new SimpleDateFormat("yyyy-MM-dd'T'h:m:ssZZZZZ");
private static Map<Class<?>, Adapter> adapters = new HashMap<>();
private static JsonRenderer renderer;

private ApplicationEvent event;
private String name;
private JsonObject payload;

private static JsonElement render(Object o) {
HashMap<String, Object> options = new HashMap<>();
String raw = renderer.render(o, options);
return raw == null ? null : new JsonParser().parse(raw);
}

private static Adapter<PullRequestUpdatedEvent> PullRequestUpdatedEventAdapter = (element, event) -> {
element.addProperty("previousTitle", event.getPreviousTitle());
element.addProperty("previousDescription", event.getPreviousDescription());
element.add("previousTarget", render(event.getPreviousToBranch()));
};

private static Adapter<PullRequestReviewersUpdatedEvent> PullRequestReviewersUpdatedEventAdapter = (element, event) -> {
element.add("removedReviewers", render(event.getRemovedReviewers()));
element.add("addedReviewers", render(event.getAddedReviewers()));
};

private static Adapter<PullRequestParticipantStatusUpdatedEvent> PullRequestParticipantStatusUpdatedEventAdapter = (element, event) -> {
element.add("participant", render(event.getParticipant()));
element.addProperty("previousStatus", event.getPreviousStatus().name());
};

private static Adapter<PullRequestCommentEvent> PullRequestCommentEventAdapter = (element, event) -> {
element.add("comment", render(event.getComment()));
if (event.getParent() != null) {
element.addProperty("commentParentId", event.getParent().getId());
}
};

private static Adapter<PullRequestCommentEditedEvent> PullRequestCommentEditedEventAdapter = (element, event) -> {
PullRequestCommentEventAdapter.apply(element, event);
element.addProperty("previousComment", event.getPreviousText());
};

static {
adapters.put(PullRequestUpdatedEvent.class, PullRequestUpdatedEventAdapter);
adapters.put(PullRequestReviewersUpdatedEvent.class, PullRequestReviewersUpdatedEventAdapter);
adapters.put(PullRequestParticipantApprovedEvent.class, PullRequestParticipantStatusUpdatedEventAdapter);
adapters.put(PullRequestParticipantUnapprovedEvent.class, PullRequestParticipantStatusUpdatedEventAdapter);
adapters.put(PullRequestParticipantReviewedEvent.class, PullRequestParticipantStatusUpdatedEventAdapter);
adapters.put(PullRequestCommentAddedEvent.class, PullRequestCommentEventAdapter);
adapters.put(PullRequestCommentDeletedEvent.class, PullRequestCommentEventAdapter);
adapters.put(PullRequestCommentEditedEvent.class, PullRequestCommentEditedEventAdapter);
}

@Autowired
public EventSerializer(@ComponentImport JsonRenderer renderer) {
EventSerializer.renderer = renderer;
}

public EventSerializer(String name, ApplicationEvent event) {
payload = new JsonObject();
this.name = name;
this.event = event;
}

public JsonObject serialize() {
buildApplicationEvent(this.event);
if (event instanceof PullRequestEvent) {
buildPullRequestEvent((PullRequestEvent) event);
}

Adapter adapter = adapters.get(event.getClass());
if (adapter != null) {
adapter.apply(payload, event);
}
return payload;
}

public String getName() {
return this.name;
}

public ApplicationEvent getEvent() {
return this.event;
}

private void buildApplicationEvent(ApplicationEvent event) {
payload.addProperty("date", RFC3339.format(event.getDate()));
payload.add("actor", render(event.getUser()));
}

private void buildPullRequestEvent(PullRequestEvent event) {
payload.add("pullRequest", render(event.getPullRequest()));
}

private interface Adapter<T> {
void apply(JsonObject element, T event);
}
}
71 changes: 71 additions & 0 deletions src/main/java/com/sourcegraph/webhook/WebhookListener.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
package com.sourcegraph.webhook;

import com.atlassian.bitbucket.event.ApplicationEvent;
import com.atlassian.bitbucket.event.pull.*;
import com.atlassian.bitbucket.repository.Repository;
import com.atlassian.event.api.AsynchronousPreferred;
import com.atlassian.event.api.EventListener;
import com.sourcegraph.webhook.registry.Webhook;
import com.sourcegraph.webhook.registry.WebhookRegistry;

import javax.inject.Named;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

@AsynchronousPreferred
@Named("WebhookListener")
public class WebhookListener {
private static Map<Class<?>, List<String>> triggers = new HashMap<>();

private static void register(Class<?> type, String key) {
// Enumerate all prefixes (or super/parent events) of event key.
// "pr:comment:added" -> ["pr", "pr:comment", "pr:comment:added"]
String[] split = key.split(":");
List<String> prefixes = new ArrayList<>();
for (int i = 0, index = 0; i < split.length; i++) {
index += split[i].length();
prefixes.add(key.substring(0, index));
index++;
}
triggers.put(type, prefixes);
}

static {
register(PullRequestReopenedEvent.class, "pr:opened");
register(PullRequestOpenedEvent.class, "pr:opened");
register(PullRequestUpdatedEvent.class, "pr:modified");
register(PullRequestReviewersUpdatedEvent.class, "pr:reviewer:updated");
register(PullRequestParticipantApprovedEvent.class, "pr:reviewer:approved");
register(PullRequestParticipantUnapprovedEvent.class, "pr:reviewer:unapproved");
register(PullRequestParticipantReviewedEvent.class, "pr:reviewer:needs_work");
register(PullRequestMergeActivityEvent.class, "pr:merged");
register(PullRequestDeclinedEvent.class, "pr:declined");
register(PullRequestDeletedEvent.class, "pr:deleted");
register(PullRequestCommentAddedEvent.class, "pr:comment:added");
register(PullRequestCommentEditedEvent.class, "pr:comment:edited");
register(PullRequestCommentDeletedEvent.class, "pr:comment:deleted");
}

@EventListener
public void onPullRequestEvent(PullRequestEvent event) {
handle(event, event.getPullRequest().getToRef().getRepository());
}

private void handle(ApplicationEvent event, Repository repository) {
List<String> keys = triggers.get(event.getClass());
if (keys == null || keys.isEmpty()) {
return;
}

List<Webhook> hooks = WebhookRegistry.getWebhooks(keys, repository);
if (hooks.isEmpty()) {
return;
}

String key = keys.get(keys.size() - 1);
EventSerializer serializer = new EventSerializer(key, event);
hooks.forEach(hook -> Dispatcher.dispatch(hook, serializer));
}
}
Loading

0 comments on commit fe2491f

Please sign in to comment.