feat: POC for webhooks

This commit is contained in:
MiniDigger | Martin 2024-07-06 12:24:23 +02:00
parent feee3c0514
commit dd2a610b02
15 changed files with 673 additions and 5 deletions

View File

@ -0,0 +1,18 @@
package io.papermc.hangar.components.webhook.controller;
import org.springframework.stereotype.Controller;
@Controller
public class WebhookController {
// TODO create webhook
// TODO editing webhook
// TODO deleting webhook
// TODO list webhooks
// TODO test trigger webhook
// TODO create webhook template
// TODO editing webhook template
// TODO deleting webhook template
// TODO list webhook templates
}

View File

@ -0,0 +1,15 @@
package io.papermc.hangar.components.webhook.dao;
import io.papermc.hangar.components.webhook.model.WebhookTable;
import java.util.List;
import org.jdbi.v3.spring5.JdbiRepository;
import org.jdbi.v3.sqlobject.config.RegisterConstructorMapper;
import org.jdbi.v3.sqlobject.statement.SqlQuery;
@JdbiRepository
@RegisterConstructorMapper(WebhookTable.class)
public interface WebhookDAO {
@SqlQuery("SELECT * FROM webhooks WHERE (:event = ANY(events) or '*' = ANY(events)) AND scope = 'global'")
List<WebhookTable> getGlobalWebhooksForType(String event);
}

View File

@ -0,0 +1,28 @@
package io.papermc.hangar.components.webhook.model;
import com.fasterxml.jackson.annotation.JsonRawValue;
import java.util.List;
public record DiscordWebhook(
String username,
String avatarUrl,
@JsonRawValue String embeds,
AllowedMentions allowedMentions
) {
public DiscordWebhook(final String embeds) {
this("HangarBot", "", embeds, new AllowedMentions());
}
record AllowedMentions(
List<String> parse,
List<Long> roles,
List<Long> users,
boolean repliedUser
) {
AllowedMentions() {
this(List.of(), null, null, false);
}
}
}

View File

@ -0,0 +1,111 @@
package io.papermc.hangar.components.webhook.model;
import io.papermc.hangar.model.db.Table;
import java.time.OffsetDateTime;
import java.util.Objects;
import java.util.StringJoiner;
public class WebhookTable extends Table {
private String name;
private String url;
private String secret;
private boolean active;
private String type;
private String[] events;
private String scope;
public WebhookTable(final OffsetDateTime createdAt, final long id, final String name, final String url, final String secret, final boolean active, final String type, final String[] events, final String scope) {
super(createdAt, id);
this.name = name;
this.url = url;
this.secret = secret;
this.active = active;
this.type = type;
this.scope = scope;
}
public String getName() {
return this.name;
}
public void setName(final String name) {
this.name = name;
}
public String getUrl() {
return this.url;
}
public void setUrl(final String url) {
this.url = url;
}
public String getSecret() {
return this.secret;
}
public void setSecret(final String secret) {
this.secret = secret;
}
public boolean isActive() {
return this.active;
}
public void setActive(final boolean active) {
this.active = active;
}
public String getType() {
return this.type;
}
public void setType(final String type) {
this.type = type;
}
public String[] getEvents() {
return this.events;
}
public void setEvents(final String[] events) {
this.events = events;
}
public String getScope() {
return this.scope;
}
public void setScope(final String scope) {
this.scope = scope;
}
@Override
public boolean equals(final Object o) {
if (this == o) return true;
if (o == null || this.getClass() != o.getClass()) return false;
if (!super.equals(o)) return false;
final WebhookTable that = (WebhookTable) o;
return this.active == that.active && Objects.equals(this.name, that.name) && Objects.equals(this.url, that.url) && Objects.equals(this.secret, that.secret) && Objects.equals(this.type, that.type) && Objects.equals(this.scope, that.scope);
}
@Override
public int hashCode() {
return Objects.hash(super.hashCode(), this.name, this.url, this.secret, this.active, this.type, this.scope);
}
@Override
public String toString() {
return new StringJoiner(", ", WebhookTable.class.getSimpleName() + "[", "]")
.add("name='" + this.name + "'")
.add("url='" + this.url + "'")
.add("secret='" + this.secret + "'")
.add("active=" + this.active)
.add("type='" + this.type + "'")
.add("scope='" + this.scope + "'")
.add("createdAt=" + this.createdAt)
.add("id=" + this.id)
.toString();
}
}

View File

@ -0,0 +1,67 @@
package io.papermc.hangar.components.webhook.model.event;
import java.util.List;
import java.util.Objects;
import java.util.StringJoiner;
public abstract class ProjectEvent extends WebhookEvent {
private final String author;
private final String name;
private final String avatar;
private final String url;
private final List<String> platforms;
protected ProjectEvent(final String type, final String author, final String name, final String avatar, final String url, final List<String> platforms) {
super(type);
this.author = author;
this.name = name;
this.avatar = avatar;
this.url = url;
this.platforms = platforms;
}
public String getAuthor() {
return this.author;
}
public String getName() {
return this.name;
}
public String getAvatar() {
return this.avatar;
}
public String getUrl() {
return this.url;
}
public List<String> getPlatforms() {
return this.platforms;
}
@Override
public boolean equals(final Object o) {
if (this == o) return true;
if (o == null || this.getClass() != o.getClass()) return false;
final ProjectEvent that = (ProjectEvent) o;
return Objects.equals(this.author, that.author) && Objects.equals(this.name, that.name) && Objects.equals(this.avatar, that.avatar) && Objects.equals(this.url, that.url);
}
@Override
public int hashCode() {
return Objects.hash(this.author, this.name, this.avatar, this.url);
}
@Override
public String toString() {
return new StringJoiner(", ", ProjectEvent.class.getSimpleName() + "[", "]")
.add("author='" + this.author + "'")
.add("name='" + this.name + "'")
.add("avatar='" + this.avatar + "'")
.add("url='" + this.url + "'")
.add("platforms='" + this.platforms + "'")
.toString();
}
}

View File

@ -0,0 +1,42 @@
package io.papermc.hangar.components.webhook.model.event;
import java.util.List;
import java.util.Objects;
import java.util.StringJoiner;
public class ProjectPublishedEvent extends ProjectEvent {
public static final String TYPE = "project_published";
private final String description;
public ProjectPublishedEvent(final String author, final String name, final String avatar, final String url, final String description, final List<String> platforms) {
super(TYPE, author, name, avatar, url, platforms);
this.description = description;
}
public String getDescription() {
return this.description;
}
@Override
public boolean equals(final Object o) {
if (this == o) return true;
if (o == null || this.getClass() != o.getClass()) return false;
if (!super.equals(o)) return false;
final ProjectPublishedEvent that = (ProjectPublishedEvent) o;
return Objects.equals(this.description, that.description);
}
@Override
public int hashCode() {
return Objects.hash(super.hashCode(), this.description);
}
@Override
public String toString() {
return new StringJoiner(", ", ProjectPublishedEvent.class.getSimpleName() + "[", "]")
.add("description='" + this.description + "'")
.toString();
}
}

View File

@ -0,0 +1,48 @@
package io.papermc.hangar.components.webhook.model.event;
import java.util.List;
import java.util.Objects;
import java.util.StringJoiner;
public class VersionPublishedEvent extends ProjectEvent {
public static final String TYPE = "version_published";
private final String version;
private final String description;
public VersionPublishedEvent(final String author, final String name, final String avatar, final String url, final String version, final String description, final List<String> platforms) {
super(TYPE, author, name, avatar, url, platforms);
this.description = description;
this.version = version;
}
public String getVersion() {
return this.version;
}
public String getDescription() {
return this.description;
}
@Override
public boolean equals(final Object o) {
if (this == o) return true;
if (o == null || this.getClass() != o.getClass()) return false;
final VersionPublishedEvent that = (VersionPublishedEvent) o;
return Objects.equals(this.version, that.version);
}
@Override
public int hashCode() {
return Objects.hashCode(this.version);
}
@Override
public String toString() {
return new StringJoiner(", ", VersionPublishedEvent.class.getSimpleName() + "[", "]")
.add("version='" + this.version + "'")
.add("description='" + this.description + "'")
.toString();
}
}

View File

@ -0,0 +1,29 @@
package io.papermc.hangar.components.webhook.model.event;
import com.fasterxml.jackson.annotation.JsonSubTypes;
import com.fasterxml.jackson.annotation.JsonTypeInfo;
import java.util.concurrent.ThreadLocalRandom;
@JsonTypeInfo(use = JsonTypeInfo.Id.SIMPLE_NAME, include = JsonTypeInfo.As.EXISTING_PROPERTY, property = "type")
@JsonSubTypes({
@JsonSubTypes.Type(value = ProjectPublishedEvent.class, name = ProjectPublishedEvent.TYPE),
@JsonSubTypes.Type(value = VersionPublishedEvent.class, name = VersionPublishedEvent.TYPE),
})
public abstract class WebhookEvent {
private final String id;
private final String type;
protected WebhookEvent(final String type) {
this.id = String.valueOf(ThreadLocalRandom.current().nextInt());
this.type = type;
}
public String getId() {
return this.id;
}
public String getType() {
return this.type;
}
}

View File

@ -0,0 +1,148 @@
package io.papermc.hangar.components.webhook.service;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.papermc.hangar.components.webhook.dao.WebhookDAO;
import io.papermc.hangar.components.webhook.model.DiscordWebhook;
import io.papermc.hangar.components.webhook.model.event.ProjectEvent;
import io.papermc.hangar.components.webhook.model.event.ProjectPublishedEvent;
import io.papermc.hangar.components.webhook.model.event.VersionPublishedEvent;
import io.papermc.hangar.components.webhook.model.event.WebhookEvent;
import io.papermc.hangar.model.internal.job.Job;
import io.papermc.hangar.model.internal.job.SendWebhookJob;
import io.papermc.hangar.service.internal.JobService;
import java.util.List;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestTemplate;
@Service
public class WebhookService {
private static final Logger logger = LoggerFactory.getLogger(WebhookService.class);
// TODO put these into a DB too
private static final String discordProjectTemplate = """
[
{
"id": {{id}},
"title": "{{author}} / {{name}} is now available on Hangar!",
"description": "{{description}}",
"color": 2326507,
"fields": [],
"url": "{{url}}",
"thumbnail": {
"url": "{{avatar}}"
},
"footer": {
"text": "Platforms: {{platforms}}"
}
}
]
""";
private static final String discordVersionTemplate = """
[
{
"id": {{id}},
"title": "Version {{version}} for {{author}} / {{name}} has been released!",
"description": "{{description}}",
"color": 2326507,
"fields": [],
"url": "{{url}}",
"thumbnail": {
"url": "{{avatar}}"
},
"footer": {
"text": "Platforms: {{platforms}}"
}
}
]
""";
private final RestTemplate restTemplate;
private final JobService jobService;
private final ObjectMapper objectMapper;
private final WebhookDAO webhookDAO;
public WebhookService(final RestTemplate restTemplate, final JobService jobService, final ObjectMapper objectMapper, final WebhookDAO webhookDAO) {
this.restTemplate = restTemplate;
this.jobService = jobService;
this.objectMapper = objectMapper;
this.webhookDAO = webhookDAO;
}
public void handleEvent(final WebhookEvent event) {
final String payload;
try {
payload = this.objectMapper.writeValueAsString(event);
} catch (final JsonProcessingException e) {
logger.error("Can't serialize event {}", event, e);
return;
}
this.jobService.schedule(
this.webhookDAO.getGlobalWebhooksForType(event.getType())
.stream().map(webhook -> new SendWebhookJob(String.valueOf(webhook.getId()), webhook.getUrl(), webhook.getType(), webhook.getSecret(), payload))
.toArray(Job[]::new)
);
}
public void sendWebhook(final SendWebhookJob webhook) {
final WebhookEvent event;
try {
event = this.objectMapper.readValue(webhook.getPayload(), WebhookEvent.class);
} catch (final JsonProcessingException e) {
throw new RuntimeException("Failed to deserialize webhook payload", e);
}
final var payload = this.buildPayload(webhook.getType(), event);
final var headers = new HttpHeaders();
headers.set("User-Agent", "HangarWebhook (https://hangar.papermc.io, 1.0)");
headers.setContentType(MediaType.APPLICATION_JSON);
// TODO handle secret
final ResponseEntity<String> response = this.restTemplate.postForEntity(webhook.getUrl(), new HttpEntity<>(payload, headers), String.class);
if (response.getStatusCode().isError()) {
logger.warn("Failed to send webhook {} to {} with status code {}: {}", webhook.getId(), webhook.getUrl(), response.getStatusCode(), response.getBody());
}
}
private Object buildPayload(final String type, final WebhookEvent event) {
// TODO these need to be looked up from the DB/cache
return switch (type) {
case "discord_project" -> new DiscordWebhook(this.fillTemplate(discordProjectTemplate, event));
case "discord_version" -> new DiscordWebhook(this.fillTemplate(discordVersionTemplate, event));
case "rest" -> event;
default -> throw new IllegalArgumentException("Unknown webhook type: " + type);
};
}
// can prolly be improved by regexing thru stuff instead of looping multiple times, but oh well
private String fillTemplate(String template, final WebhookEvent event) {
template = template.replace("{{id}}", event.getId());
if (event instanceof final ProjectEvent projectEvent) {
template = template
.replace("{{author}}", projectEvent.getAuthor())
.replace("{{name}}", projectEvent.getName())
.replace("{{url}}", projectEvent.getUrl())
.replace("{{avatar}}", projectEvent.getAvatar())
.replace("{{platforms}}", String.join(", ", projectEvent.getPlatforms()));
}
if (event instanceof final ProjectPublishedEvent projectPublishedEvent) {
template = template.replace("{{description}}", projectPublishedEvent.getDescription());
} else if (event instanceof final VersionPublishedEvent versionPublishedEvent) {
template = template.replace("{{description}}", versionPublishedEvent.getDescription());
template = template.replace("{{version}}", versionPublishedEvent.getVersion());
}
return template.replace("\n", "");
}
}

View File

@ -6,6 +6,7 @@ import java.util.List;
import org.jdbi.v3.spring5.JdbiRepository;
import org.jdbi.v3.sqlobject.config.RegisterConstructorMapper;
import org.jdbi.v3.sqlobject.customizer.BindBean;
import org.jdbi.v3.sqlobject.customizer.BindBeanList;
import org.jdbi.v3.sqlobject.customizer.Timestamped;
import org.jdbi.v3.sqlobject.statement.SqlQuery;
import org.jdbi.v3.sqlobject.statement.SqlUpdate;

View File

@ -2,5 +2,7 @@ package io.papermc.hangar.model.internal.job;
public enum JobType {
// email
SEND_EMAIL
SEND_EMAIL,
// webhook
SEND_WEBHOOK,
}

View File

@ -0,0 +1,115 @@
package io.papermc.hangar.model.internal.job;
import io.papermc.hangar.model.db.JobTable;
import java.util.HashMap;
import java.util.Map;
import java.util.Objects;
import java.util.StringJoiner;
public class SendWebhookJob extends Job {
private String id;
private String url;
private String type;
private String secret;
private String payload;
public SendWebhookJob(final String id, final String url, final String type, final String secret, String payload) {
super(JobType.SEND_WEBHOOK);
this.id = id;
this.url = url;
this.type = type;
this.secret = secret;
this.payload = payload;
}
public SendWebhookJob() {
super(JobType.SEND_WEBHOOK);
}
public String getId() {
return this.id;
}
public String getUrl() {
return this.url;
}
public String getType() {
return this.type;
}
public String getSecret() {
return this.secret;
}
public String getPayload() {
return this.payload;
}
@Override
public void loadFromProperties() {
if (this.getJobProperties() == null) {
return;
}
if (this.getJobProperties().containsKey("id")) {
this.id = this.getJobProperties().get("id");
}
if (this.getJobProperties().containsKey("url")) {
this.url = this.getJobProperties().get("url");
}
if (this.getJobProperties().containsKey("type")) {
this.type = this.getJobProperties().get("type");
}
if (this.getJobProperties().containsKey("secret")) {
this.secret = this.getJobProperties().get("secret");
}
if (this.getJobProperties().containsKey("payload")) {
this.payload = this.getJobProperties().get("payload");
}
}
@Override
public void saveIntoProperties() {
final Map<String, String> properties = new HashMap<>();
properties.put("id", this.id);
properties.put("url", this.url);
properties.put("type", this.type);
properties.put("secret", this.secret);
properties.put("payload", this.payload);
this.setJobProperties(properties);
}
public static SendWebhookJob loadFromTable(final JobTable table) {
final SendWebhookJob job = new SendWebhookJob();
job.fromTable(table);
job.setJobProperties(table.getJobProperties().getMap());
job.loadFromProperties();
return job;
}
@Override
public boolean equals(final Object o) {
if (this == o) return true;
if (o == null || this.getClass() != o.getClass()) return false;
if (!super.equals(o)) return false;
final SendWebhookJob that = (SendWebhookJob) o;
return Objects.equals(this.id, that.id) && Objects.equals(this.url, that.url) && Objects.equals(this.type, that.type) && Objects.equals(this.secret, that.secret);
}
@Override
public int hashCode() {
return Objects.hash(super.hashCode(), this.id, this.url, this.type, this.secret);
}
@Override
public String toString() {
return new StringJoiner(", ", SendWebhookJob.class.getSimpleName() + "[", "]")
.add("id='" + this.id + "'")
.add("url='" + this.url + "'")
.add("type='" + this.type + "'")
.add("secret='" + this.secret + "'")
.add("createdAt=" + this.createdAt)
.toString();
}
}

View File

@ -1,11 +1,13 @@
package io.papermc.hangar.service.internal;
import io.papermc.hangar.HangarComponent;
import io.papermc.hangar.components.webhook.service.WebhookService;
import io.papermc.hangar.db.dao.internal.table.JobsDAO;
import io.papermc.hangar.model.db.JobTable;
import io.papermc.hangar.model.internal.job.Job;
import io.papermc.hangar.model.internal.job.JobException;
import io.papermc.hangar.model.internal.job.SendMailJob;
import io.papermc.hangar.model.internal.job.SendWebhookJob;
import io.papermc.hangar.util.ThreadFactory;
import jakarta.annotation.PostConstruct;
import java.util.List;
@ -25,13 +27,15 @@ public class JobService extends HangarComponent {
private final JobsDAO jobsDAO;
private final MailService mailService;
private final WebhookService webhookService;
private ExecutorService executorService;
@Autowired
public JobService(final JobsDAO jobsDAO, @Lazy final MailService mailService) {
public JobService(final JobsDAO jobsDAO, @Lazy final MailService mailService, @Lazy final WebhookService webhookService) {
this.jobsDAO = jobsDAO;
this.mailService = mailService;
this.webhookService = webhookService;
}
@PostConstruct
@ -55,8 +59,11 @@ public class JobService extends HangarComponent {
}
@Transactional
public void schedule(final Job job) {
this.jobsDAO.save(job.toTable());
public void schedule(final Job... jobs) {
for (final Job job : jobs) {
logger.info("Scheduling job: {}", job);
this.jobsDAO.save(job.toTable());
}
}
public void process() {
@ -104,6 +111,11 @@ public class JobService extends HangarComponent {
final SendMailJob sendMailJob = SendMailJob.loadFromTable(job);
this.mailService.sendMail(sendMailJob.getSubject(), sendMailJob.getRecipient(), sendMailJob.getText());
}
// webhook
case SEND_WEBHOOK -> {
final SendWebhookJob sendWebhookJob = SendWebhookJob.loadFromTable(job);
this.webhookService.sendWebhook(sendWebhookJob);
}
default -> throw new JobException("Unknown job type " + job, "unknown_job_type");
}
}

View File

@ -1,6 +1,10 @@
package io.papermc.hangar.service.internal.versions;
import io.papermc.hangar.HangarComponent;
import io.papermc.hangar.components.images.service.AvatarService;
import io.papermc.hangar.components.webhook.model.event.ProjectPublishedEvent;
import io.papermc.hangar.components.webhook.model.event.VersionPublishedEvent;
import io.papermc.hangar.components.webhook.service.WebhookService;
import io.papermc.hangar.controller.extras.pagination.filters.versions.VersionChannelFilter;
import io.papermc.hangar.controller.extras.pagination.filters.versions.VersionPlatformFilter;
import io.papermc.hangar.db.dao.internal.table.versions.ProjectVersionsDAO;
@ -86,9 +90,11 @@ public class VersionFactory extends HangarComponent {
private final FileService fileService;
private final JarScanningService jarScanningService;
private final ReviewService reviewService;
private final WebhookService webhookService;
private final AvatarService avatarService;
@Autowired
public VersionFactory(final ProjectVersionPlatformDependenciesDAO projectVersionPlatformDependencyDAO, final ProjectVersionDependenciesDAO projectVersionDependencyDAO, final ProjectVersionsDAO projectVersionDAO, final ProjectFiles projectFiles, final PluginDataService pluginDataService, final ChannelService channelService, final ProjectVisibilityService projectVisibilityService, final ProjectService projectService, final NotificationService notificationService, final PlatformService platformService, final UsersApiService usersApiService, final ValidationService validationService, final ProjectVersionDownloadsDAO downloadsDAO, final VersionsApiDAO versionsApiDAO, final FileService fileService, final JarScanningService jarScanningService, final ReviewService reviewService) {
public VersionFactory(final ProjectVersionPlatformDependenciesDAO projectVersionPlatformDependencyDAO, final ProjectVersionDependenciesDAO projectVersionDependencyDAO, final ProjectVersionsDAO projectVersionDAO, final ProjectFiles projectFiles, final PluginDataService pluginDataService, final ChannelService channelService, final ProjectVisibilityService projectVisibilityService, final ProjectService projectService, final NotificationService notificationService, final PlatformService platformService, final UsersApiService usersApiService, final ValidationService validationService, final ProjectVersionDownloadsDAO downloadsDAO, final VersionsApiDAO versionsApiDAO, final FileService fileService, final JarScanningService jarScanningService, final ReviewService reviewService, final WebhookService webhookService, final AvatarService avatarService) {
this.projectVersionPlatformDependenciesDAO = projectVersionPlatformDependencyDAO;
this.projectVersionDependenciesDAO = projectVersionDependencyDAO;
this.projectVersionsDAO = projectVersionDAO;
@ -106,6 +112,8 @@ public class VersionFactory extends HangarComponent {
this.fileService = fileService;
this.jarScanningService = jarScanningService;
this.reviewService = reviewService;
this.webhookService = webhookService;
this.avatarService = avatarService;
}
@Transactional
@ -309,6 +317,16 @@ public class VersionFactory extends HangarComponent {
}
this.actionLogger.version(LogAction.VERSION_CREATED.create(VersionContext.of(projectId, projectVersionTable.getId()), "published", ""));
// send webhooks
final List<String> platformString = pendingVersion.getPlatformDependencies().keySet().stream().map(Platform::getName).toList();
final String avatarUrl = this.avatarService.getProjectAvatarUrl(projectTable.getProjectId(), projectTable.getOwnerName());
final String url = this.config.getBaseUrl() + "/" + projectTable.getOwnerName() + "/" + projectTable.getSlug();
if (projectTable.getVisibility() == Visibility.NEW) {
this.webhookService.handleEvent(new ProjectPublishedEvent(projectTable.getOwnerName(), projectTable.getName(), avatarUrl, url, projectTable.getDescription(), platformString));
}
this.webhookService.handleEvent(new VersionPublishedEvent(projectTable.getOwnerName(), projectTable.getName(), avatarUrl, url + "/versions/" + projectVersionTable.getVersionString(), projectVersionTable.getVersionString(), projectVersionTable.getDescription(), platformString));
// change visibility
if (projectTable.getVisibility() == Visibility.NEW) {
this.projectVisibilityService.changeVisibility(projectTable, Visibility.PUBLIC, "First version");
}

View File

@ -0,0 +1,14 @@
CREATE TABLE webhooks
(
id bigserial NOT NULL
CONSTRAINT webhooks_pkey
PRIMARY KEY,
name varchar(255) NOT NULL,
url varchar(255) NOT NULL,
secret varchar(255),
active boolean NOT NULL,
type varchar(255) NOT NULL,
events varchar(255) array NOT NULL,
scope varchar(255) NOT NULL,
created_at timestamp NOT NULL
);