diff --git a/src/dropwizardWebhooks/java/org/discordbots/webhooks/dropwizard/DBLWebhooks.java b/src/dropwizardWebhooks/java/org/discordbots/webhooks/dropwizard/DBLWebhooks.java new file mode 100644 index 0000000..817c98e --- /dev/null +++ b/src/dropwizardWebhooks/java/org/discordbots/webhooks/dropwizard/DBLWebhooks.java @@ -0,0 +1,121 @@ +package org.discordbots.webhooks.dropwizard; + +import com.fatboyindustrial.gsonjavatime.OffsetDateTimeConverter; +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.JsonIOException; +import com.google.gson.JsonSyntaxException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.WebApplicationException; +import jakarta.ws.rs.core.Context; +import jakarta.ws.rs.core.Response; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.security.InvalidKeyException; +import java.security.NoSuchAlgorithmException; +import java.time.OffsetDateTime; +import java.util.Arrays; +import java.util.HashMap; +import java.util.HexFormat; +import java.util.stream.Collectors; +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; +import org.discordbots.webhooks.payload.IntegrationCreatePayload; +import org.discordbots.webhooks.payload.IntegrationDeletePayload; +import org.discordbots.webhooks.payload.Payload; +import org.discordbots.webhooks.payload.TestPayload; +import org.discordbots.webhooks.payload.VoteCreatePayload; + +public abstract class DBLWebhooks implements DBLWebhooksListener { + private byte[] secret; + private final Gson gson; + + public DBLWebhooks(final String secret) { + this.secret = secret.getBytes(StandardCharsets.UTF_8); + this.gson = + new GsonBuilder() + .registerTypeAdapter(OffsetDateTime.class, new OffsetDateTimeConverter()) + .create(); + } + + public String getSecret() { + return new String(secret, StandardCharsets.UTF_8); + } + + public void setSecret(final String newSecret) { + secret = newSecret.getBytes(StandardCharsets.UTF_8); + } + + @POST + @SuppressWarnings("UseSpecificCatch") + public Response handle(@Context HttpServletRequest request) throws WebApplicationException { + try { + final String signatureHeader = request.getHeader("x-topgg-signature"); + + assert signatureHeader != null; + + final HashMap parsedSignature = + Arrays.stream(signatureHeader.split(",")) + .map(part -> part.split("=", 2)) + .collect( + Collectors.toMap( + part -> part[0].trim(), + part -> part[1].trim(), + (existing, replacement) -> replacement, + HashMap::new)); + + final String signature = parsedSignature.get("v1"); + final String timestamp = parsedSignature.get("t"); + + assert signature != null && timestamp != null; + + final SecretKeySpec key = new SecretKeySpec(secret, "HmacSHA256"); + final Mac hmac = Mac.getInstance("HmacSHA256"); + + hmac.init(key); + + final String body = + new String(request.getInputStream().readAllBytes(), StandardCharsets.UTF_8); + final byte[] digest = + hmac.doFinal(String.format("%s.%s", timestamp, body).getBytes(StandardCharsets.UTF_8)); + + if (!signature.equals(HexFormat.of().formatHex(digest))) { + return Response.status(Response.Status.UNAUTHORIZED) + .entity("Invalid Authorization") + .build(); + } + + final Payload payload = gson.fromJson(body, Payload.class); + final String trace = request.getHeader("x-topgg-trace"); + + try { + return switch (payload.getType()) { + case "integration.create" -> + onIntegrationCreate(payload.getData(gson, IntegrationCreatePayload.class), trace); + case "integration.delete" -> + onIntegrationDelete(payload.getData(gson, IntegrationDeletePayload.class), trace); + case "webhook.test" -> onTest(payload.getData(gson, TestPayload.class), trace); + case "vote.create" -> onVoteCreate(payload.getData(gson, VoteCreatePayload.class), trace); + default -> Response.status(Response.Status.BAD_REQUEST).entity("Bad Request").build(); + }; + } catch (final Throwable ignored) { + return Response.status(Response.Status.INTERNAL_SERVER_ERROR) + .entity("Internal Server Error") + .build(); + } + } catch (final NoSuchAlgorithmException + | InvalidKeyException + | ArrayIndexOutOfBoundsException + | AssertionError + | JsonSyntaxException + | JsonIOException + | IOException error) { + if (error instanceof NoSuchAlgorithmException || error instanceof InvalidKeyException) { + throw new WebApplicationException("Unable to find HMAC SHA-256 algorithm", error); + } else { + return Response.status(Response.Status.BAD_REQUEST).entity("Bad Request").build(); + } + } + } +} diff --git a/src/dropwizardWebhooks/java/org/discordbots/webhooks/dropwizard/DBLWebhooksListener.java b/src/dropwizardWebhooks/java/org/discordbots/webhooks/dropwizard/DBLWebhooksListener.java new file mode 100644 index 0000000..5a76818 --- /dev/null +++ b/src/dropwizardWebhooks/java/org/discordbots/webhooks/dropwizard/DBLWebhooksListener.java @@ -0,0 +1,25 @@ +package org.discordbots.webhooks.dropwizard; + +import jakarta.ws.rs.core.Response; +import org.discordbots.webhooks.payload.IntegrationCreatePayload; +import org.discordbots.webhooks.payload.IntegrationDeletePayload; +import org.discordbots.webhooks.payload.TestPayload; +import org.discordbots.webhooks.payload.VoteCreatePayload; + +public interface DBLWebhooksListener { + default Response onIntegrationCreate(final IntegrationCreatePayload payload, final String trace) { + return Response.status(Response.Status.NO_CONTENT).build(); + } + + default Response onIntegrationDelete(final IntegrationDeletePayload payload, final String trace) { + return Response.status(Response.Status.NO_CONTENT).build(); + } + + default Response onTest(final TestPayload payload, final String trace) { + return Response.status(Response.Status.NO_CONTENT).build(); + } + + default Response onVoteCreate(final VoteCreatePayload payload, final String trace) { + return Response.status(Response.Status.NO_CONTENT).build(); + } +} diff --git a/src/eclipseJettyWebhooks/java/org/discordbots/webhooks/eclipsejetty/DBLWebhooks.java b/src/eclipseJettyWebhooks/java/org/discordbots/webhooks/eclipsejetty/DBLWebhooks.java new file mode 100644 index 0000000..c27d3f7 --- /dev/null +++ b/src/eclipseJettyWebhooks/java/org/discordbots/webhooks/eclipsejetty/DBLWebhooks.java @@ -0,0 +1,128 @@ +package org.discordbots.webhooks.eclipsejetty; + +import com.fatboyindustrial.gsonjavatime.OffsetDateTimeConverter; +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.JsonIOException; +import com.google.gson.JsonSyntaxException; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServlet; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.security.InvalidKeyException; +import java.security.NoSuchAlgorithmException; +import java.time.OffsetDateTime; +import java.util.Arrays; +import java.util.HashMap; +import java.util.HexFormat; +import java.util.stream.Collectors; +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; +import org.discordbots.webhooks.payload.IntegrationCreatePayload; +import org.discordbots.webhooks.payload.IntegrationDeletePayload; +import org.discordbots.webhooks.payload.Payload; +import org.discordbots.webhooks.payload.TestPayload; +import org.discordbots.webhooks.payload.VoteCreatePayload; + +public class DBLWebhooks extends HttpServlet implements DBLWebhooksListener { + private byte[] secret; + private final Gson gson; + + public DBLWebhooks(final String secret) { + this.secret = secret.getBytes(StandardCharsets.UTF_8); + this.gson = + new GsonBuilder() + .registerTypeAdapter(OffsetDateTime.class, new OffsetDateTimeConverter()) + .create(); + } + + public String getSecret() { + return new String(secret, StandardCharsets.UTF_8); + } + + public void setSecret(final String newSecret) { + secret = newSecret.getBytes(StandardCharsets.UTF_8); + } + + @Override + @SuppressWarnings("UseSpecificCatch") + protected void doPost(final HttpServletRequest request, final HttpServletResponse response) + throws IOException, ServletException { + try { + final String signatureHeader = request.getHeader("x-topgg-signature"); + + assert signatureHeader != null; + + final HashMap parsedSignature = + Arrays.stream(signatureHeader.split(",")) + .map(part -> part.split("=", 2)) + .collect( + Collectors.toMap( + part -> part[0].trim(), + part -> part[1].trim(), + (existing, replacement) -> replacement, + HashMap::new)); + + final String signature = parsedSignature.get("v1"); + final String timestamp = parsedSignature.get("t"); + + assert signature != null && timestamp != null; + + final SecretKeySpec key = new SecretKeySpec(secret, "HmacSHA256"); + final Mac hmac = Mac.getInstance("HmacSHA256"); + + hmac.init(key); + + final String body = + new String(request.getInputStream().readAllBytes(), StandardCharsets.UTF_8); + final byte[] digest = + hmac.doFinal(String.format("%s.%s", timestamp, body).getBytes(StandardCharsets.UTF_8)); + + if (!signature.equals(HexFormat.of().formatHex(digest))) { + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + response.getWriter().write("Invalid Authorization"); + + return; + } + + final Payload payload = gson.fromJson(body, Payload.class); + final String trace = request.getHeader("x-topgg-trace"); + + try { + switch (payload.getType()) { + case "integration.create" -> + onIntegrationCreate( + response, payload.getData(gson, IntegrationCreatePayload.class), trace); + case "integration.delete" -> + onIntegrationDelete( + response, payload.getData(gson, IntegrationDeletePayload.class), trace); + case "webhook.test" -> onTest(response, payload.getData(gson, TestPayload.class), trace); + case "vote.create" -> + onVoteCreate(response, payload.getData(gson, VoteCreatePayload.class), trace); + default -> { + response.setStatus(HttpServletResponse.SC_BAD_REQUEST); + response.getWriter().write("Bad Request"); + } + } + } catch (final Throwable ignored) { + response.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR); + response.getWriter().write("Internal Server Error"); + } + } catch (final NoSuchAlgorithmException + | InvalidKeyException + | ArrayIndexOutOfBoundsException + | AssertionError + | JsonSyntaxException + | JsonIOException + | IOException error) { + if (error instanceof NoSuchAlgorithmException || error instanceof InvalidKeyException) { + throw new ServletException("Unable to find HMAC SHA-256 algorithm", error); + } else { + response.setStatus(HttpServletResponse.SC_BAD_REQUEST); + response.getWriter().write("Bad Request"); + } + } + } +} diff --git a/src/eclipseJettyWebhooks/java/org/discordbots/webhooks/eclipsejetty/DBLWebhooksListener.java b/src/eclipseJettyWebhooks/java/org/discordbots/webhooks/eclipsejetty/DBLWebhooksListener.java new file mode 100644 index 0000000..cee3a90 --- /dev/null +++ b/src/eclipseJettyWebhooks/java/org/discordbots/webhooks/eclipsejetty/DBLWebhooksListener.java @@ -0,0 +1,33 @@ +package org.discordbots.webhooks.eclipsejetty; + +import jakarta.servlet.http.HttpServletResponse; +import org.discordbots.webhooks.payload.IntegrationCreatePayload; +import org.discordbots.webhooks.payload.IntegrationDeletePayload; +import org.discordbots.webhooks.payload.TestPayload; +import org.discordbots.webhooks.payload.VoteCreatePayload; + +public interface DBLWebhooksListener { + default void onIntegrationCreate( + final HttpServletResponse response, + final IntegrationCreatePayload payload, + final String trace) { + response.setStatus(HttpServletResponse.SC_NO_CONTENT); + } + + default void onIntegrationDelete( + final HttpServletResponse response, + final IntegrationDeletePayload payload, + final String trace) { + response.setStatus(HttpServletResponse.SC_NO_CONTENT); + } + + default void onTest( + final HttpServletResponse response, final TestPayload payload, final String trace) { + response.setStatus(HttpServletResponse.SC_NO_CONTENT); + } + + default void onVoteCreate( + final HttpServletResponse response, final VoteCreatePayload payload, final String trace) { + response.setStatus(HttpServletResponse.SC_NO_CONTENT); + } +} diff --git a/src/springBootWebhooks/java/org/discordbots/webhooks/springboot/DBLWebhooks.java b/src/springBootWebhooks/java/org/discordbots/webhooks/springboot/DBLWebhooks.java new file mode 100644 index 0000000..e6ebee0 --- /dev/null +++ b/src/springBootWebhooks/java/org/discordbots/webhooks/springboot/DBLWebhooks.java @@ -0,0 +1,105 @@ +package org.discordbots.webhooks.springboot; + +import com.fatboyindustrial.gsonjavatime.OffsetDateTimeConverter; +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.JsonIOException; +import com.google.gson.JsonSyntaxException; +import java.nio.charset.StandardCharsets; +import java.security.InvalidKeyException; +import java.security.NoSuchAlgorithmException; +import java.time.OffsetDateTime; +import java.util.Arrays; +import java.util.HashMap; +import java.util.HexFormat; +import java.util.stream.Collectors; +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; +import org.discordbots.webhooks.payload.IntegrationCreatePayload; +import org.discordbots.webhooks.payload.IntegrationDeletePayload; +import org.discordbots.webhooks.payload.Payload; +import org.discordbots.webhooks.payload.TestPayload; +import org.discordbots.webhooks.payload.VoteCreatePayload; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; + +public class DBLWebhooks implements DBLWebhooksListener { + private byte[] secret; + private final Gson gson; + + public DBLWebhooks(final String secret) { + this.secret = secret.getBytes(StandardCharsets.UTF_8); + this.gson = + new GsonBuilder() + .registerTypeAdapter(OffsetDateTime.class, new OffsetDateTimeConverter()) + .create(); + } + + public String getSecret() { + return new String(secret, StandardCharsets.UTF_8); + } + + public void setSecret(final String newSecret) { + secret = newSecret.getBytes(StandardCharsets.UTF_8); + } + + @SuppressWarnings("UseSpecificCatch") + protected ResponseEntity dispatch( + final String body, final String signatureHeader, final String trace) { + try { + final HashMap parsedSignature = + Arrays.stream(signatureHeader.split(",")) + .map(part -> part.split("=", 2)) + .collect( + Collectors.toMap( + part -> part[0].trim(), + part -> part[1].trim(), + (existing, replacement) -> replacement, + HashMap::new)); + + final String signature = parsedSignature.get("v1"); + final String timestamp = parsedSignature.get("t"); + + assert signature != null && timestamp != null; + + final SecretKeySpec key = new SecretKeySpec(secret, "HmacSHA256"); + final Mac hmac = Mac.getInstance("HmacSHA256"); + + hmac.init(key); + + final byte[] digest = + hmac.doFinal(String.format("%s.%s", timestamp, body).getBytes(StandardCharsets.UTF_8)); + + if (!signature.equals(HexFormat.of().formatHex(digest))) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); + } + + final Payload payload = gson.fromJson(body, Payload.class); + + try { + return switch (payload.getType()) { + case "integration.create" -> + onIntegrationCreate(payload.getData(gson, IntegrationCreatePayload.class), trace); + case "integration.delete" -> + onIntegrationDelete(payload.getData(gson, IntegrationDeletePayload.class), trace); + case "webhook.test" -> onTest(payload.getData(gson, TestPayload.class), trace); + case "vote.create" -> onVoteCreate(payload.getData(gson, VoteCreatePayload.class), trace); + default -> ResponseEntity.status(HttpStatus.BAD_REQUEST).build(); + }; + } catch (final Throwable ignored) { + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build(); + } + } catch (final NoSuchAlgorithmException + | InvalidKeyException + | ArrayIndexOutOfBoundsException + | AssertionError + | JsonSyntaxException + | JsonIOException error) { + return ResponseEntity.status( + (error instanceof NoSuchAlgorithmException || error instanceof InvalidKeyException) + ? HttpStatus.INTERNAL_SERVER_ERROR + : HttpStatus.BAD_REQUEST) + .build(); + } + } +} diff --git a/src/springBootWebhooks/java/org/discordbots/webhooks/springboot/DBLWebhooksListener.java b/src/springBootWebhooks/java/org/discordbots/webhooks/springboot/DBLWebhooksListener.java new file mode 100644 index 0000000..c1f2df6 --- /dev/null +++ b/src/springBootWebhooks/java/org/discordbots/webhooks/springboot/DBLWebhooksListener.java @@ -0,0 +1,28 @@ +package org.discordbots.webhooks.springboot; + +import org.discordbots.webhooks.payload.IntegrationCreatePayload; +import org.discordbots.webhooks.payload.IntegrationDeletePayload; +import org.discordbots.webhooks.payload.TestPayload; +import org.discordbots.webhooks.payload.VoteCreatePayload; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; + +public interface DBLWebhooksListener { + default ResponseEntity onIntegrationCreate( + final IntegrationCreatePayload payload, final String trace) { + return ResponseEntity.status(HttpStatus.NO_CONTENT).build(); + } + + default ResponseEntity onIntegrationDelete( + final IntegrationDeletePayload payload, final String trace) { + return ResponseEntity.status(HttpStatus.NO_CONTENT).build(); + } + + default ResponseEntity onTest(final TestPayload payload, final String trace) { + return ResponseEntity.status(HttpStatus.NO_CONTENT).build(); + } + + default ResponseEntity onVoteCreate(final VoteCreatePayload payload, final String trace) { + return ResponseEntity.status(HttpStatus.NO_CONTENT).build(); + } +} diff --git a/src/webhooks/java/org/discordbots/webhooks/entity/PartialProject.java b/src/webhooks/java/org/discordbots/webhooks/entity/PartialProject.java new file mode 100644 index 0000000..85fff1d --- /dev/null +++ b/src/webhooks/java/org/discordbots/webhooks/entity/PartialProject.java @@ -0,0 +1,30 @@ +package org.discordbots.webhooks.entity; + +import com.google.gson.annotations.SerializedName; + +public class PartialProject { + private String id; + + private ProjectType type; + + private Platform platform; + + @SerializedName("platform_id") + private String platformId; + + public String getId() { + return id; + } + + public ProjectType getType() { + return type; + } + + public Platform getPlatform() { + return platform; + } + + public String getPlatformId() { + return platformId; + } +} diff --git a/src/webhooks/java/org/discordbots/webhooks/entity/Platform.java b/src/webhooks/java/org/discordbots/webhooks/entity/Platform.java new file mode 100644 index 0000000..8cb1ad2 --- /dev/null +++ b/src/webhooks/java/org/discordbots/webhooks/entity/Platform.java @@ -0,0 +1,8 @@ +package org.discordbots.webhooks.entity; + +import com.google.gson.annotations.SerializedName; + +public enum Platform { + @SerializedName("discord") + DISCORD +} diff --git a/src/webhooks/java/org/discordbots/webhooks/entity/ProjectType.java b/src/webhooks/java/org/discordbots/webhooks/entity/ProjectType.java new file mode 100644 index 0000000..675f7df --- /dev/null +++ b/src/webhooks/java/org/discordbots/webhooks/entity/ProjectType.java @@ -0,0 +1,11 @@ +package org.discordbots.webhooks.entity; + +import com.google.gson.annotations.SerializedName; + +public enum ProjectType { + @SerializedName("bot") + DISCORD_BOT, + + @SerializedName("server") + DISCORD_SERVER +} diff --git a/src/webhooks/java/org/discordbots/webhooks/entity/User.java b/src/webhooks/java/org/discordbots/webhooks/entity/User.java new file mode 100644 index 0000000..0be2afb --- /dev/null +++ b/src/webhooks/java/org/discordbots/webhooks/entity/User.java @@ -0,0 +1,31 @@ +package org.discordbots.webhooks.entity; + +import com.google.gson.annotations.SerializedName; + +public class User { + private String id; + + private String name; + + @SerializedName("avatar_url") + private String avatar; + + @SerializedName("platform_id") + private String platformId; + + public String getId() { + return id; + } + + public String getName() { + return name; + } + + public String getAvatar() { + return avatar; + } + + public String getPlatformId() { + return platformId; + } +} diff --git a/src/webhooks/java/org/discordbots/webhooks/payload/IntegrationCreatePayload.java b/src/webhooks/java/org/discordbots/webhooks/payload/IntegrationCreatePayload.java new file mode 100644 index 0000000..99303c1 --- /dev/null +++ b/src/webhooks/java/org/discordbots/webhooks/payload/IntegrationCreatePayload.java @@ -0,0 +1,33 @@ +package org.discordbots.webhooks.payload; + +import com.google.gson.annotations.SerializedName; +import org.discordbots.webhooks.entity.PartialProject; +import org.discordbots.webhooks.entity.User; + +public class IntegrationCreatePayload { + @SerializedName("connection_id") + private String connectionId; + + @SerializedName("webhook_secret") + private String secret; + + private PartialProject project; + + private User user; + + public String getConnectionId() { + return connectionId; + } + + public String getSecret() { + return secret; + } + + public PartialProject getProject() { + return project; + } + + public User getUser() { + return user; + } +} diff --git a/src/webhooks/java/org/discordbots/webhooks/payload/IntegrationDeletePayload.java b/src/webhooks/java/org/discordbots/webhooks/payload/IntegrationDeletePayload.java new file mode 100644 index 0000000..2653cda --- /dev/null +++ b/src/webhooks/java/org/discordbots/webhooks/payload/IntegrationDeletePayload.java @@ -0,0 +1,12 @@ +package org.discordbots.webhooks.payload; + +import com.google.gson.annotations.SerializedName; + +public class IntegrationDeletePayload { + @SerializedName("connection_id") + private String connectionId; + + public String getConnectionId() { + return connectionId; + } +} diff --git a/src/webhooks/java/org/discordbots/webhooks/payload/Payload.java b/src/webhooks/java/org/discordbots/webhooks/payload/Payload.java new file mode 100644 index 0000000..424ea39 --- /dev/null +++ b/src/webhooks/java/org/discordbots/webhooks/payload/Payload.java @@ -0,0 +1,19 @@ +package org.discordbots.webhooks.payload; + +import com.google.gson.Gson; +import com.google.gson.JsonObject; +import com.google.gson.JsonSyntaxException; + +public class Payload { + private String type; + + private JsonObject data; + + public String getType() { + return type; + } + + public T getData(final Gson gson, final Class cls) throws JsonSyntaxException { + return gson.fromJson(data, cls); + } +} diff --git a/src/webhooks/java/org/discordbots/webhooks/payload/TestPayload.java b/src/webhooks/java/org/discordbots/webhooks/payload/TestPayload.java new file mode 100644 index 0000000..6a90483 --- /dev/null +++ b/src/webhooks/java/org/discordbots/webhooks/payload/TestPayload.java @@ -0,0 +1,18 @@ +package org.discordbots.webhooks.payload; + +import org.discordbots.webhooks.entity.PartialProject; +import org.discordbots.webhooks.entity.User; + +public class TestPayload { + private PartialProject project; + + private User user; + + public PartialProject getProject() { + return project; + } + + public User getUser() { + return user; + } +} diff --git a/src/webhooks/java/org/discordbots/webhooks/payload/VoteCreatePayload.java b/src/webhooks/java/org/discordbots/webhooks/payload/VoteCreatePayload.java new file mode 100644 index 0000000..9f8e819 --- /dev/null +++ b/src/webhooks/java/org/discordbots/webhooks/payload/VoteCreatePayload.java @@ -0,0 +1,46 @@ +package org.discordbots.webhooks.payload; + +import com.google.gson.annotations.SerializedName; +import java.time.OffsetDateTime; +import org.discordbots.webhooks.entity.PartialProject; +import org.discordbots.webhooks.entity.User; + +public class VoteCreatePayload { + private String id; + + private int weight; + + @SerializedName("created_at") + private OffsetDateTime votedAt; + + @SerializedName("expires_at") + private OffsetDateTime expiresAt; + + private PartialProject project; + + private User user; + + public String getId() { + return id; + } + + public int getWeight() { + return weight; + } + + public OffsetDateTime getVotedAt() { + return votedAt; + } + + public OffsetDateTime getExpiredAt() { + return expiresAt; + } + + public PartialProject getProject() { + return project; + } + + public User getUser() { + return user; + } +}