From 46b60aac9bbb57602b09e46c88ca0b457f6b5839 Mon Sep 17 00:00:00 2001 From: Dany Sluijk Date: Sun, 28 Jul 2024 00:54:49 +0000 Subject: [PATCH] feat: add support for Google Wallet passes --- build.gradle | 4 + config/application-devcontainer.yml | 9 +- config/application-test.yml | 7 +- config/application.yml.example | 7 +- .../googlewallet/GoogleWalletService.java | 14 ++ .../googlewallet/GoogleWalletServiceImpl.java | 206 ++++++++++++++++++ .../core/service/mail/MailServiceImpl.java | 11 +- .../core/service/ticket/TicketService.java | 7 + .../service/ticket/TicketServiceImpl.java | 19 +- .../controller/WebshopCustomerController.java | 2 - .../WebshopOrderOverviewController.java | 2 - .../controller/WebshopReturnController.java | 16 +- .../controller/WebshopTicketController.java | 25 +++ .../resources/static/css/wisvch-tickets.css | 2 +- .../resources/static/images/google-wallet.svg | 16 ++ src/main/resources/templates/mail/order.html | 46 ++-- .../templates/webshop/overview/index.html | 14 +- .../templates/webshop/return/success.html | 45 +++- .../core/service/TicketServiceTest.java | 7 +- .../core/service/TicketTransferTest.java | 7 +- 20 files changed, 424 insertions(+), 42 deletions(-) create mode 100644 src/main/java/ch/wisv/events/core/service/googlewallet/GoogleWalletService.java create mode 100644 src/main/java/ch/wisv/events/core/service/googlewallet/GoogleWalletServiceImpl.java create mode 100644 src/main/resources/static/images/google-wallet.svg diff --git a/build.gradle b/build.gradle index 22ba892c..e04a7eed 100644 --- a/build.gradle +++ b/build.gradle @@ -96,6 +96,10 @@ dependencies { testImplementation 'com.tngtech.java:junit-dataprovider:1.5.0' implementation 'org.javatuples:javatuples:1.2' + + implementation 'com.auth0:java-jwt:4.4.0' + implementation 'com.google.api-client:google-api-client:2.6.0' + implementation 'com.google.apis:google-api-services-walletobjects:v1-rev20240723-2.0.0' } test { diff --git a/config/application-devcontainer.yml b/config/application-devcontainer.yml index a21987c8..b8cf147b 100644 --- a/config/application-devcontainer.yml +++ b/config/application-devcontainer.yml @@ -89,13 +89,18 @@ wisvch.connect: # CH Events Configuration wisvch.events: - image.path: http://localhost:8080/events/api/v1/documents/ + image.path: http://localhost:8080/api/v1/documents/ # CH mollie api key mollie: apikey: test - clientUri: http://localhost:8080/events + clientUri: http://localhost:8080 links: gtc: https://ch.tudelft.nl passes: https://ch.tudelft.nl/passes + +googleWallet: + issuerId: 3388000000022297569 + origin: http://localhost:8080 + baseUrl: https://ch.tudelft.nl/events \ No newline at end of file diff --git a/config/application-test.yml b/config/application-test.yml index 421b9f24..5b455e12 100644 --- a/config/application-test.yml +++ b/config/application-test.yml @@ -83,4 +83,9 @@ management: links: gtc: https://ch.tudelft.nl/wp-content/uploads/Deelnemersvoorwaarden_versie_12_06_2023.pdf - passes: passes \ No newline at end of file + passes: passes + +googleWallet: + issuerId: 3388000000022297569 + origin: https://ch.tudelft.nl/events + baseUrl: https://ch.tudelft.nl/events \ No newline at end of file diff --git a/config/application.yml.example b/config/application.yml.example index 3402e91d..8cdc9304 100644 --- a/config/application.yml.example +++ b/config/application.yml.example @@ -85,4 +85,9 @@ mollie: links: gtc: https://ch.tudelft.nl/wp-content/uploads/Deelnemersvoorwaarden_versie_12_06_2023.pdf - passes: https://ch.tudelft.nl/passes \ No newline at end of file + passes: https://ch.tudelft.nl/passes + +googleWallet: + issuerId: 3388000000022297569 + origin: https://ch.tudelft.nl/events + baseUrl: https://ch.tudelft.nl/events \ No newline at end of file diff --git a/src/main/java/ch/wisv/events/core/service/googlewallet/GoogleWalletService.java b/src/main/java/ch/wisv/events/core/service/googlewallet/GoogleWalletService.java new file mode 100644 index 00000000..53f91b48 --- /dev/null +++ b/src/main/java/ch/wisv/events/core/service/googlewallet/GoogleWalletService.java @@ -0,0 +1,14 @@ +package ch.wisv.events.core.service.googlewallet; + +import ch.wisv.events.core.exception.normal.TicketPassFailedException; +import ch.wisv.events.core.model.ticket.Ticket; + +public interface GoogleWalletService { + /** + * Get Google Wallet pass for a Ticket. + * @param ticket of type Ticket. + * @return A link the user can use to add the ticket to their wallet. + * @throws TicketPassFailedException when pass is not generated + */ + String getPass(Ticket ticket) throws TicketPassFailedException; +} diff --git a/src/main/java/ch/wisv/events/core/service/googlewallet/GoogleWalletServiceImpl.java b/src/main/java/ch/wisv/events/core/service/googlewallet/GoogleWalletServiceImpl.java new file mode 100644 index 00000000..b830db94 --- /dev/null +++ b/src/main/java/ch/wisv/events/core/service/googlewallet/GoogleWalletServiceImpl.java @@ -0,0 +1,206 @@ +package ch.wisv.events.core.service.googlewallet; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +import ch.wisv.events.core.exception.normal.TicketPassFailedException; +import ch.wisv.events.core.model.event.Event; +import ch.wisv.events.core.model.product.Product; +import ch.wisv.events.core.model.ticket.Ticket; + +import com.auth0.jwt.JWT; +import com.auth0.jwt.algorithms.Algorithm; +import com.google.api.services.walletobjects.WalletobjectsScopes; +import com.google.api.services.walletobjects.model.*; +import com.google.auth.oauth2.ServiceAccountCredentials; + +import jakarta.validation.constraints.NotNull; + +import java.io.IOException; +import java.security.interfaces.RSAPrivateKey; +import java.time.Instant; +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import java.time.format.DateTimeFormatter; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; + +@Service +public class GoogleWalletServiceImpl implements GoogleWalletService { + /** Service account credentials for Google Wallet APIs. */ + public static ServiceAccountCredentials credentials; + + @Value("${googleWallet.issuerId}") + @NotNull + private String issuerId; + + @Value("${googleWallet.baseUrl}") + @NotNull + private String baseUrl; + + @Value("${googleWallet.origin}") + @NotNull + private String origin; + + @Value("${links.gtc}") + @NotNull + private String linkGTC; + + /** + * Get Google Wallet pass for a Ticket. + * + * @param ticket of type Ticket. + * @return A link the user can use to add the ticket to their wallet. + * @throws TicketPassFailedException when pass is not generated + */ + public String getPass(Ticket ticket) throws TicketPassFailedException { + if (credentials == null) { + try { + credentials = (ServiceAccountCredentials) ServiceAccountCredentials + .getApplicationDefault() + .createScoped(List.of(WalletobjectsScopes.WALLET_OBJECT_ISSUER)); + credentials.refresh(); + } catch (IOException e) { + System.out.println("WARN: Failed to authenticate with Google"); + return "https://pay.google.com/gp/v/save/FAILED"; + } + } + + Product product = ticket.getProduct(); + EventTicketClass newClass = this.createClass(product); + EventTicketObject newObject = this.createObject(ticket); + + HashMap claims = new HashMap(); + claims.put("iss", credentials.getClientEmail()); + claims.put("aud", "google"); + claims.put("origins", List.of(origin)); + claims.put("typ", "savetowallet"); + claims.put("iat", Instant.now().getEpochSecond()); + + HashMap payload = new HashMap(); + payload.put("eventTicketClasses", List.of(newClass)); + payload.put("eventTicketObjects", List.of(newObject)); + claims.put("payload", payload); + + Algorithm algorithm = Algorithm.RSA256( + null, (RSAPrivateKey) credentials.getPrivateKey()); + String token = JWT.create().withPayload(claims).sign(algorithm); + + return String.format("https://pay.google.com/gp/v/save/%s", token); + } + + /** + * Create the passes class based on the product. + * + * @param product The product to base it on. + * @return A Google compatible Ticket class. + */ + private EventTicketClass createClass(Product product) { + String homePage = product.getEvent().hasExternalProductUrl() + ? product.getEvent().getExternalProductUrl() + : baseUrl; + Uri tnc = new Uri() + .setUri(linkGTC) + .setDescription("Terms & Conditions") + .setId("LINK_GTC"); + + return new EventTicketClass() + .setId(this.getClassId(product)) + .setIssuerName("Christiaan Huygens") + .setReviewStatus("UNDER_REVIEW") + .setHexBackgroundColor("#1e274a") + .setEventName(this.makeLocalString(this.formatProduct(product))) + .setWideLogo(this.makeImage(String.format("%s/images/ch-logo.png", baseUrl))) + .setLogo(this.makeImage(String.format("%s/icons/apple-touch-icon.png", baseUrl))) + .setLinksModuleData(new LinksModuleData().setUris(Arrays.asList(tnc))) + .setHomepageUri(new Uri() + .setUri(homePage) + .setDescription("Events")) + .setVenue(new EventVenue() + .setName(this.makeLocalString(product.getEvent().getLocation())) + .setAddress(this.makeLocalString("Mekelweg 4, 2628 CD Delft"))) + .setDateTime(new EventDateTime() + .setStart(this.formatDate(product.getEvent().getStart())) + .setEnd(this.formatDate(product.getEvent().getEnding()))); + } + + private EventTicketObject createObject(Ticket ticket) { + Money cost = new Money().setCurrencyCode("EUR").setMicros((long) (ticket.getProduct().cost * 1000000)); + Uri tnc = new Uri() + .setUri(linkGTC) + .setDescription("Terms & Conditions") + .setId("LINK_GTC"); + + return new EventTicketObject() + .setId(this.getObjectId(ticket)) + .setClassId(this.getClassId(ticket.getProduct())) + .setState("ACTIVE") + .setTicketNumber(ticket.getKey()) + .setTicketHolderName(ticket.getOwner().getName()) + .setHexBackgroundColor("#1e274a") + .setFaceValue(cost) + .setBarcode(new Barcode().setType("QR_CODE").setValue(ticket.getUniqueCode())) + .setLinksModuleData(new LinksModuleData().setUris(Arrays.asList(tnc))); + } + + /** + * Get the ID of a product. + * + * @param product The product to derive the ID. + * @return The ID + */ + private String getClassId(Product product) { + return String.format("%s.%s", issuerId, product.getKey()); + } + + /** + * Get the object ID of a ticket. + * + * @param ticket The ticket to get the ID for. + * @return The ID + */ + private String getObjectId(Ticket ticket) { + return String.format("%s-%s", this.getClassId(ticket.getProduct()), ticket.getKey()); + } + + /** + * Format the product name accorting to if they have a second product + * + * @param product + * @return + */ + private String formatProduct(Product product) { + Event event = product.getEvent(); + + if (event.getProducts().size() <= 1) { + return event.getTitle(); + } else { + return String.format("%s - %s", event.getTitle(), product.getTitle()); + } + } + + /** + * Format a local date to the string format Google expects. + * + * @param localDate The Java date object. + * @return The date in string form. + */ + private String formatDate(LocalDateTime localDate) { + DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss.SSX"); + String formattedDate = localDate.atOffset(ZoneOffset.UTC).format(formatter); + return formattedDate; + } + + private LocalizedString makeLocalString(String str) { + TranslatedString defaultLang = new TranslatedString() + .setLanguage("en-US") + .setValue(str); + return new LocalizedString().setDefaultValue(defaultLang); + } + + private Image makeImage(String src) { + ImageUri uri = new ImageUri().setUri(src); + return new Image().setSourceUri(uri); + } +} diff --git a/src/main/java/ch/wisv/events/core/service/mail/MailServiceImpl.java b/src/main/java/ch/wisv/events/core/service/mail/MailServiceImpl.java index 68d79fea..77b0fda7 100644 --- a/src/main/java/ch/wisv/events/core/service/mail/MailServiceImpl.java +++ b/src/main/java/ch/wisv/events/core/service/mail/MailServiceImpl.java @@ -18,7 +18,6 @@ import ch.wisv.events.core.service.ticket.TicketService; import ch.wisv.events.core.util.QrCode; import com.google.zxing.WriterException; -import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; import org.springframework.core.io.ByteArrayResource; import org.springframework.core.io.ClassPathResource; @@ -53,6 +52,11 @@ public class MailServiceImpl implements MailService { @NotNull private String linkGTC; + /** Link to GTC. */ + @Value("${googleWallet.origin}") + @NotNull + private String origin; + /** * MailServiceImpl constructor. * @@ -60,7 +64,6 @@ public class MailServiceImpl implements MailService { * @param templateEngine of type templateEngine * @param ticketService of type TicketService */ - @Autowired public MailServiceImpl(JavaMailSender mailSender, SpringTemplateEngine templateEngine, TicketService ticketService) { this.mailSender = mailSender; this.templateEngine = templateEngine; @@ -80,6 +83,7 @@ public void sendOrderConfirmation(Order order, List tickets) { ctx.setVariable("tickets", tickets); ctx.setVariable("redirectLinks", tickets.stream().anyMatch(ticket -> ticket.getProduct().getRedirectUrl() != null)); ctx.setVariable("linkGTC", linkGTC); + ctx.setVariable("origin", origin); String subject = String.format("Ticket overview %s", order.getPublicReference().substring(0, ORDER_NUMBER_LENGTH)); this.sendMailWithContent(order.getOwner().getEmail(), subject, this.templateEngine.process("mail/order", ctx), tickets); @@ -170,6 +174,9 @@ private void sendMailWithContent(String recipientEmail, String subject, String c message.addInline("ch-logo.png", new ClassPathResource("/static/images/ch-logo.png"), "image/png"); if(tickets != null) { + message.addInline("apple-wallet.svg", new ClassPathResource("/static/images/apple-wallet.svg"), "image/svg+xml"); + message.addInline("google-wallet.svg", new ClassPathResource("/static/images/google-wallet.svg"), "image/svg+xml"); + for (Ticket ticket : tickets) { String uniqueCode = ticket.getUniqueCode(); // Retrieve and return barcode (LEGACY) diff --git a/src/main/java/ch/wisv/events/core/service/ticket/TicketService.java b/src/main/java/ch/wisv/events/core/service/ticket/TicketService.java index 9ec8348d..efb3cb54 100644 --- a/src/main/java/ch/wisv/events/core/service/ticket/TicketService.java +++ b/src/main/java/ch/wisv/events/core/service/ticket/TicketService.java @@ -133,4 +133,11 @@ public interface TicketService { */ byte[] getApplePass(Ticket ticket) throws TicketPassFailedException; + /** + * Get Google Wallet pass for a Ticket. + * @param ticket of type Ticket. + * @return A link the user can use to add the ticket to their wallet. + * @throws TicketPassFailedException when pass is not generated + */ + String getGooglePass(Ticket ticket) throws TicketPassFailedException; } diff --git a/src/main/java/ch/wisv/events/core/service/ticket/TicketServiceImpl.java b/src/main/java/ch/wisv/events/core/service/ticket/TicketServiceImpl.java index 4a50c351..17da9240 100644 --- a/src/main/java/ch/wisv/events/core/service/ticket/TicketServiceImpl.java +++ b/src/main/java/ch/wisv/events/core/service/ticket/TicketServiceImpl.java @@ -18,6 +18,7 @@ import java.util.*; import ch.wisv.events.core.service.event.EventService; +import ch.wisv.events.core.service.googlewallet.GoogleWalletService; import ch.wisv.events.core.util.QrCode; import com.google.zxing.WriterException; import org.springframework.beans.factory.annotation.Value; @@ -41,6 +42,11 @@ public class TicketServiceImpl implements TicketService { */ private final EventService eventService; + /** + * GoogleWalletService. + */ + private final GoogleWalletService googleWalletService; + @Value("${links.passes}") @NotNull private String passesLink; @@ -51,9 +57,10 @@ public class TicketServiceImpl implements TicketService { * @param ticketRepository of type TicketRepository * @param eventService of type EventService */ - public TicketServiceImpl(TicketRepository ticketRepository, EventService eventService) { + public TicketServiceImpl(TicketRepository ticketRepository, EventService eventService, GoogleWalletService googleWalletService) { this.ticketRepository = ticketRepository; this.eventService = eventService; + this.googleWalletService = googleWalletService; } /** @@ -285,4 +292,14 @@ public byte[] getApplePass(Ticket ticket) throws TicketPassFailedException { throw new TicketPassFailedException(e.getMessage()); } } + + /** + * Get Google Wallet pass for a Ticket. + * @param ticket of type Ticket. + * @return A link the user can use to add the ticket to their wallet. + * @throws TicketPassFailedException when pass is not generated + */ + public String getGooglePass(Ticket ticket) throws TicketPassFailedException { + return this.googleWalletService.getPass(ticket); + } } diff --git a/src/main/java/ch/wisv/events/webshop/controller/WebshopCustomerController.java b/src/main/java/ch/wisv/events/webshop/controller/WebshopCustomerController.java index 18f96a30..d9504f42 100644 --- a/src/main/java/ch/wisv/events/webshop/controller/WebshopCustomerController.java +++ b/src/main/java/ch/wisv/events/webshop/controller/WebshopCustomerController.java @@ -11,7 +11,6 @@ import ch.wisv.events.core.service.auth.AuthenticationService; import ch.wisv.events.core.service.customer.CustomerService; import ch.wisv.events.core.service.order.OrderService; -import org.springframework.beans.factory.annotation.Autowired; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.stereotype.Controller; import org.springframework.ui.Model; @@ -48,7 +47,6 @@ public class WebshopCustomerController extends WebshopController { * @param customerService of type CustomerService * @param authenticationService of type AuthenticationService */ - @Autowired public WebshopCustomerController( OrderService orderService, CustomerService customerService, diff --git a/src/main/java/ch/wisv/events/webshop/controller/WebshopOrderOverviewController.java b/src/main/java/ch/wisv/events/webshop/controller/WebshopOrderOverviewController.java index 98ad337b..02faee1b 100644 --- a/src/main/java/ch/wisv/events/webshop/controller/WebshopOrderOverviewController.java +++ b/src/main/java/ch/wisv/events/webshop/controller/WebshopOrderOverviewController.java @@ -10,8 +10,6 @@ import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; -import java.util.Collections; - /** * WebshopOrderOverviewController class. */ diff --git a/src/main/java/ch/wisv/events/webshop/controller/WebshopReturnController.java b/src/main/java/ch/wisv/events/webshop/controller/WebshopReturnController.java index d3d3afff..5265e1ed 100644 --- a/src/main/java/ch/wisv/events/webshop/controller/WebshopReturnController.java +++ b/src/main/java/ch/wisv/events/webshop/controller/WebshopReturnController.java @@ -4,9 +4,11 @@ import ch.wisv.events.core.model.order.Order; import ch.wisv.events.core.model.order.OrderProduct; import ch.wisv.events.core.model.product.Product; +import ch.wisv.events.core.model.ticket.Ticket; import ch.wisv.events.core.service.auth.AuthenticationService; import ch.wisv.events.core.service.order.OrderService; -import ch.wisv.events.core.service.product.ProductService; +import ch.wisv.events.core.service.ticket.TicketService; + import org.springframework.stereotype.Controller; import org.springframework.ui.Model; import org.springframework.web.bind.annotation.GetMapping; @@ -25,6 +27,11 @@ @Controller @RequestMapping("/return/{key}") public class WebshopReturnController extends WebshopController { + /** TicketService. */ + final TicketService ticketService; + + /** Attribute to indicate if the order only contains reservable products. */ + private static final String MODEL_ATTR_TICKETS = "tickets"; /** * WebshopReturnController constructor. @@ -32,8 +39,9 @@ public class WebshopReturnController extends WebshopController { * @param orderService of type OrderService * @param authenticationService of type AuthenticationService */ - public WebshopReturnController(OrderService orderService, AuthenticationService authenticationService) { + public WebshopReturnController(OrderService orderService, AuthenticationService authenticationService, TicketService ticketService) { super(orderService, authenticationService); + this.ticketService = ticketService; } /** @@ -57,7 +65,11 @@ public String returnIndex(Model model, RedirectAttributes redirect, @PathVariabl .filter(product -> Objects.nonNull(product.getRedirectUrl()) && !product.getRedirectUrl().isEmpty()) .collect(Collectors.toList()); + + List tickets = this.ticketService.getAllByOrder(order); + model.addAttribute(MODEL_ATTR_REDIRECT_PRODUCTS, productsWithRedirect); + model.addAttribute(MODEL_ATTR_TICKETS, tickets); switch (order.getStatus()) { case PENDING: diff --git a/src/main/java/ch/wisv/events/webshop/controller/WebshopTicketController.java b/src/main/java/ch/wisv/events/webshop/controller/WebshopTicketController.java index 3e4d05cd..7fb118c2 100644 --- a/src/main/java/ch/wisv/events/webshop/controller/WebshopTicketController.java +++ b/src/main/java/ch/wisv/events/webshop/controller/WebshopTicketController.java @@ -198,4 +198,29 @@ public void getApplePass(HttpServletResponse response, @PathVariable String key response.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR); } } + + /** + * Redirect the user to the Google Pass URL. + */ + @GetMapping("/{key}/googlewallet") + public void getGooglePass(HttpServletResponse response, @PathVariable String key) throws IOException { + Customer customer = authenticationService.getCurrentCustomer(); + try { + Ticket ticket = ticketService.getByKey(key); + + if (!ticket.owner.equals(customer)) { + response.sendError(HttpServletResponse.SC_FORBIDDEN); + return; + } + + String link = ticketService.getGooglePass(ticket); + response.sendRedirect(link); + } + catch (TicketNotFoundException e) { + response.sendError(HttpServletResponse.SC_NOT_FOUND); + } + catch (TicketPassFailedException | IOException e) { + response.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR); + } + } } diff --git a/src/main/resources/static/css/wisvch-tickets.css b/src/main/resources/static/css/wisvch-tickets.css index c4af18ac..92f4c6fa 100644 --- a/src/main/resources/static/css/wisvch-tickets.css +++ b/src/main/resources/static/css/wisvch-tickets.css @@ -103,7 +103,7 @@ and also iPads specifically. (min-device-width: 768px) and (max-device-width: 1024px) { /* Force table to not be like tables anymore */ - thead th, tbody tr, tbody td, thead tr, tbody tr, tfoot tr { + .ticket-overview thead th, .ticket-overview tbody td, .ticket-overview thead tr, .ticket-overview tbody tr, .ticket-overview tfoot tr { display: block; } diff --git a/src/main/resources/static/images/google-wallet.svg b/src/main/resources/static/images/google-wallet.svg new file mode 100644 index 00000000..9fc86837 --- /dev/null +++ b/src/main/resources/static/images/google-wallet.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/src/main/resources/templates/mail/order.html b/src/main/resources/templates/mail/order.html index 6690e5ee..3dbefbeb 100644 --- a/src/main/resources/templates/mail/order.html +++ b/src/main/resources/templates/mail/order.html @@ -29,7 +29,7 @@ } img.qrcode { - widht: 226px; + width: 226px; height: 226px; display: block; } @@ -54,7 +54,6 @@ margin: 0 auto; padding: 0; width: 100%; - !important; } table td { @@ -155,11 +154,6 @@