/**
* This file is part of alf.io.
*
* alf.io is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* alf.io is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with alf.io. If not, see <http://www.gnu.org/licenses/>.
*/
package alfio.manager;
import alfio.controller.support.TemplateProcessor;
import alfio.manager.support.CustomMessageManager;
import alfio.manager.support.PDFTemplateGenerator;
import alfio.manager.support.PartialTicketTextGenerator;
import alfio.manager.support.TextTemplateGenerator;
import alfio.manager.system.ConfigurationManager;
import alfio.manager.system.Mailer;
import alfio.model.*;
import alfio.model.Event;
import alfio.model.system.Configuration;
import alfio.model.system.ConfigurationKeys;
import alfio.model.user.Organization;
import alfio.repository.EmailMessageRepository;
import alfio.repository.EventDescriptionRepository;
import alfio.repository.EventRepository;
import alfio.repository.TicketReservationRepository;
import alfio.repository.user.OrganizationRepository;
import alfio.util.Json;
import alfio.util.TemplateManager;
import com.fasterxml.jackson.core.type.TypeReference;
import com.github.benmanes.caffeine.cache.Cache;
import com.github.benmanes.caffeine.cache.Caffeine;
import com.google.gson.*;
import com.ryantenney.passkit4j.Pass;
import com.ryantenney.passkit4j.PassResource;
import com.ryantenney.passkit4j.PassSerializer;
import com.ryantenney.passkit4j.model.*;
import com.ryantenney.passkit4j.model.Color;
import com.ryantenney.passkit4j.model.TextField;
import com.ryantenney.passkit4j.sign.PassSigner;
import com.ryantenney.passkit4j.sign.PassSignerImpl;
import com.ryantenney.passkit4j.sign.PassSigningException;
import lombok.extern.log4j.Log4j2;
import org.apache.commons.lang3.StringUtils;
import org.apache.commons.lang3.time.DateUtils;
import org.apache.commons.lang3.tuple.Triple;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.MessageSource;
import org.springframework.core.io.ClassPathResource;
import org.springframework.security.crypto.codec.Hex;
import org.springframework.stereotype.Component;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.support.TransactionTemplate;
import javax.imageio.ImageIO;
import java.awt.*;
import java.awt.image.BufferedImage;
import java.io.*;
import java.lang.reflect.Type;
import java.nio.charset.StandardCharsets;
import java.security.*;
import java.security.cert.CertificateException;
import java.time.Clock;
import java.time.ZonedDateTime;
import java.util.*;
import java.util.List;
import java.util.concurrent.TimeUnit;
import java.util.function.Function;
import java.util.stream.Collectors;
import static alfio.model.EmailMessage.Status.*;
@Component
@Log4j2
public class NotificationManager {
public static final Clock UTC = Clock.systemUTC();
private final Mailer mailer;
private final MessageSource messageSource;
private final EmailMessageRepository emailMessageRepository;
private final TransactionTemplate tx;
private final EventRepository eventRepository;
private final OrganizationRepository organizationRepository;
private final ConfigurationManager configurationManager;
private final Gson gson;
private final Cache<String, Optional<byte[]>> passbookLogoCache = Caffeine.newBuilder()
.maximumSize(20)
.expireAfterWrite(20, TimeUnit.MINUTES)
.build();
private final EnumMap<Mailer.AttachmentIdentifier, Function<Map<String, String>, byte[]>> attachmentTransformer;
@Autowired
public NotificationManager(Mailer mailer,
MessageSource messageSource,
PlatformTransactionManager transactionManager,
EmailMessageRepository emailMessageRepository,
EventRepository eventRepository,
EventDescriptionRepository eventDescriptionRepository,
OrganizationRepository organizationRepository,
ConfigurationManager configurationManager,
FileUploadManager fileUploadManager,
TemplateManager templateManager,
TicketReservationRepository ticketReservationRepository) {
this.messageSource = messageSource;
this.mailer = mailer;
this.emailMessageRepository = emailMessageRepository;
this.eventRepository = eventRepository;
this.organizationRepository = organizationRepository;
this.tx = new TransactionTemplate(transactionManager);
this.configurationManager = configurationManager;
GsonBuilder builder = new GsonBuilder();
builder.registerTypeAdapter(Mailer.Attachment.class, new AttachmentConverter());
this.gson = builder.create();
attachmentTransformer = new EnumMap<>(Mailer.AttachmentIdentifier.class);
attachmentTransformer.put(Mailer.AttachmentIdentifier.CALENDAR_ICS, (model) -> {
Event event;
Locale locale;
if(model.containsKey("eventId")) {
//legacy branch, now we generate the ics as a reinterpreted ticket
event = eventRepository.findById(Integer.valueOf(model.get("eventId"), 10));
locale = Json.fromJson(model.get("locale"), Locale.class);
} else {
Ticket ticket = Json.fromJson(model.get("ticket"), Ticket.class);
event = eventRepository.findById(ticket.getEventId());
locale = Locale.forLanguageTag(ticket.getUserLanguage());
}
String description = eventDescriptionRepository.findDescriptionByEventIdTypeAndLocale(event.getId(), EventDescription.EventDescriptionType.DESCRIPTION, locale.getLanguage()).orElse("");
return event.getIcal(description).orElse(null);
});
attachmentTransformer.put(Mailer.AttachmentIdentifier.RECEIPT_PDF, receiptOrInvoiceFactory(
payload -> TemplateProcessor.buildReceiptPdf(payload.getLeft(),
fileUploadManager,
payload.getMiddle(),
templateManager,
payload.getRight())));
attachmentTransformer.put(Mailer.AttachmentIdentifier.INVOICE_PDF, receiptOrInvoiceFactory(
payload -> TemplateProcessor.buildInvoicePdf(payload.getLeft(),
fileUploadManager,
payload.getMiddle(),
templateManager,
payload.getRight())));
attachmentTransformer.put(Mailer.AttachmentIdentifier.PASSBOOK, (model) -> {
Ticket ticket = Json.fromJson(model.get("ticket"), Ticket.class);
int eventId = ticket.getEventId();
Event event = eventRepository.findById(eventId);
Organization organization = organizationRepository.getById(Integer.valueOf(model.get("organizationId"), 10));
int organizationId = organization.getId();
Function<ConfigurationKeys, Configuration.ConfigurationPathKey> partial = Configuration.from(organizationId, eventId);
Map<ConfigurationKeys, Optional<String>> pbookConf = configurationManager.getStringConfigValueFrom(
partial.apply(ConfigurationKeys.PASSBOOK_TYPE_IDENTIFIER),
partial.apply(ConfigurationKeys.PASSBOOK_KEYSTORE),
partial.apply(ConfigurationKeys.PASSBOOK_KEYSTORE_PASSWORD),
partial.apply(ConfigurationKeys.PASSBOOK_TEAM_IDENTIFIER));
//check if all are set
if(pbookConf.values().stream().anyMatch(o -> !o.isPresent())) {
log.warn("Missing configuration keys, check if all 4 are presents");
return null;
}
//
String teamIdentifier = pbookConf.get(ConfigurationKeys.PASSBOOK_TEAM_IDENTIFIER).orElseThrow(IllegalStateException::new);
String typeIdentifier = pbookConf.get(ConfigurationKeys.PASSBOOK_TYPE_IDENTIFIER).orElseThrow(IllegalStateException::new);
byte[] keystoreRaw = Base64.getDecoder().decode(pbookConf.get(ConfigurationKeys.PASSBOOK_KEYSTORE).orElseThrow(IllegalStateException::new));
String keystorePwd = pbookConf.get(ConfigurationKeys.PASSBOOK_KEYSTORE_PASSWORD).orElseThrow(IllegalStateException::new);
//ugly, find an alternative way?
Optional<KeyStore> ksJks = loadKeyStore(keystoreRaw, "jks");
Optional<KeyStore> ksP12 = loadKeyStore(keystoreRaw, "pkcs12");
if(!ksJks.isPresent() && !ksP12.isPresent()) {
log.warn("Not able to load keystore, check");
return null;
}
KeyStore keyStore = ksJks.orElseGet(() -> ksP12.orElseThrow(IllegalStateException::new));
// from example: https://github.com/ryantenney/passkit4j/blob/master/src/test/java/com/ryantenney/passkit4j/EventTicketExample.java
Pass pass = new Pass()
.teamIdentifier(teamIdentifier)
.passTypeIdentifier(typeIdentifier)
.organizationName(event.getDisplayName())
.description(event.getDisplayName())
.serialNumber(ticket.getUuid())
.relevantDate(Date.from(event.getBegin().toInstant()))
.expirationDate(Date.from(event.getEnd().toInstant()))
.locations(
new Location(Double.parseDouble(event.getLatitude()), Double.parseDouble(event.getLongitude()))
)
.barcode(new Barcode(BarcodeFormat.QR, ticket.ticketCode(event.getPrivateKey())))
.foregroundColor(Color.WHITE)
.backgroundColor(Color.WHITE)
.passInformation(
new EventTicket()
.primaryFields(new TextField("event", "EVENT", event.getDisplayName()))
.secondaryFields(new TextField("loc", "LOCATION", event.getLocation()))
);
if(event.getFileBlobIdIsPresent()) {
}
fileUploadManager.findMetadata(event.getFileBlobId()).ifPresent(metadata -> {
if(metadata.getContentType().equals("image/png") || metadata.getContentType().equals("image/jpeg")) {
Optional<byte[]> cachedLogo = passbookLogoCache.get(event.getFileBlobId(), (id) -> {
ByteArrayOutputStream baos = new ByteArrayOutputStream();
fileUploadManager.outputFile(event.getFileBlobId(), baos);
return readAndConvertImage(baos);
});
cachedLogo.ifPresent(logo -> {
pass.files(new PassResource("logo.png", logo));
});
}
});
try(ByteArrayOutputStream baos = new ByteArrayOutputStream();
InputStream appleCert = new ClassPathResource("/alfio/certificates/AppleWWDRCA.cer").getInputStream()) {
PassSigner signer = PassSignerImpl.builder()
.keystore(keyStore, keystorePwd)
.intermediateCertificate(appleCert)
.build();
PassSerializer.writePkPassArchive(pass, signer, baos);
//PassSerializer.writePkPassArchive(pass, signer, new FileOutputStream("/tmp/Passbook.pkpass"));
return baos.toByteArray();
} catch (IOException | PassSigningException e) {
log.warn("was not able to generate the passbook file", e);
return null;
}
});
attachmentTransformer.put(Mailer.AttachmentIdentifier.TICKET_PDF, (model) -> {
ByteArrayOutputStream baos = new ByteArrayOutputStream();
Ticket ticket = Json.fromJson(model.get("ticket"), Ticket.class);
try {
TicketReservation reservation = ticketReservationRepository.findReservationById(ticket.getTicketsReservationId());
TicketCategory ticketCategory = Json.fromJson(model.get("ticketCategory"), TicketCategory.class);
Event event = eventRepository.findById(ticket.getEventId());
Organization organization = organizationRepository.getById(Integer.valueOf(model.get("organizationId"), 10));
PDFTemplateGenerator pdfTemplateGenerator = TemplateProcessor.buildPDFTicket(Locale.forLanguageTag(ticket.getUserLanguage()), event, reservation,
ticket, ticketCategory, organization, templateManager, fileUploadManager, configurationManager.getShortReservationID(event, ticket.getTicketsReservationId()));
pdfTemplateGenerator.generate().createPDF(baos);
} catch (IOException e) {
log.warn("was not able to generate ticket pdf for ticket with id" + ticket.getId(), e);
}
return baos.toByteArray();
});
}
//"jks" "pkcs12"
private static Optional<KeyStore> loadKeyStore(byte[] k, String type) {
try {
KeyStore ks = KeyStore.getInstance(type);
ks.load(new ByteArrayInputStream(k), null);
return Optional.of(ks);
} catch (GeneralSecurityException | IOException e) {
return Optional.empty();
}
}
private static Optional<byte[]> readAndConvertImage(ByteArrayOutputStream baos) {
try {
BufferedImage sourceImage = ImageIO.read(new ByteArrayInputStream(baos.toByteArray()));
boolean isWider = sourceImage.getWidth() > sourceImage.getHeight();
// as defined in https://developer.apple.com/library/content/documentation/UserExperience/Conceptual/PassKit_PG/Creating.html#//apple_ref/doc/uid/TP40012195-CH4-SW8
// logo max 160*50
int finalWidth = isWider ? 160 : -1;
int finalHeight = !isWider ? 50 : -1;
Image thumb = sourceImage.getScaledInstance(finalWidth, finalHeight, Image.SCALE_SMOOTH);
BufferedImage bufferedThumbnail = new BufferedImage(thumb.getWidth(null), thumb.getHeight(null), BufferedImage.TYPE_INT_RGB);
bufferedThumbnail.getGraphics().drawImage(thumb, 0, 0, null);
ByteArrayOutputStream logoPng = new ByteArrayOutputStream();
ImageIO.write(bufferedThumbnail, "png", logoPng);
return Optional.of(logoPng.toByteArray());
} catch (IOException e) {
return Optional.empty();
}
}
public Function<Map<String, String>, byte[]> receiptOrInvoiceFactory(Function<Triple<Event, Locale, Map<String, Object>>, Optional<byte[]>> pdfGenerator) {
return (model) -> {
String reservationId = model.get("reservationId");
Event event = eventRepository.findById(Integer.valueOf(model.get("eventId"), 10));
Locale language = Json.fromJson(model.get("language"), Locale.class);
Map<String, Object> reservationEmailModel = Json.fromJson(model.get("reservationEmailModel"), new TypeReference<Map<String, Object>>() {});
//FIXME hack: reservationEmailModel should be a minimal and typed container
reservationEmailModel.put("event", event);
Optional<byte[]> receipt = pdfGenerator.apply(Triple.of(event, language, reservationEmailModel));
if(!receipt.isPresent()) {
log.warn("was not able to generate the receipt for reservation id " + reservationId + " for locale " + language);
}
return receipt.orElse(null);
};
}
public void sendTicketByEmail(Ticket ticket, Event event, Locale locale, PartialTicketTextGenerator textBuilder, TicketReservation reservation, TicketCategory ticketCategory) throws IOException {
Organization organization = organizationRepository.getById(event.getOrganizationId());
List<Mailer.Attachment> attachments = new ArrayList<>();
attachments.add(CustomMessageManager.generateTicketAttachment(ticket, reservation, ticketCategory, organization));
String encodedAttachments = encodeAttachments(attachments.toArray(new Mailer.Attachment[attachments.size()]));
String subject = messageSource.getMessage("ticket-email-subject", new Object[]{event.getDisplayName()}, locale);
String text = textBuilder.generate(ticket);
String checksum = calculateChecksum(ticket.getEmail(), encodedAttachments, subject, text);
String recipient = ticket.getEmail();
//TODO handle HTML
tx.execute(status -> emailMessageRepository.insert(event.getId(), recipient, subject, text, encodedAttachments, checksum, ZonedDateTime.now(UTC)));
}
public void sendSimpleEmail(Event event, String recipient, String subject, TextTemplateGenerator textBuilder) {
sendSimpleEmail(event, recipient, subject, textBuilder, Collections.emptyList());
}
public void sendSimpleEmail(Event event, String recipient, String subject, TextTemplateGenerator textBuilder, List<Mailer.Attachment> attachments) {
String encodedAttachments = attachments.isEmpty() ? null : encodeAttachments(attachments.toArray(new Mailer.Attachment[attachments.size()]));
String text = textBuilder.generate();
String checksum = calculateChecksum(recipient, encodedAttachments, subject, text);
//in order to minimize the database size, it is worth checking if there is already another message in the table
Optional<EmailMessage> existing = emailMessageRepository.findByEventIdAndChecksum(event.getId(), checksum);
if(!existing.isPresent()) {
emailMessageRepository.insert(event.getId(), recipient, subject, text, encodedAttachments, checksum, ZonedDateTime.now(UTC));
} else {
emailMessageRepository.updateStatus(event.getId(), WAITING.name(), existing.get().getId());
}
}
public List<LightweightMailMessage> loadAllMessagesForEvent(int eventId) {
return emailMessageRepository.findByEventId(eventId);
}
public Optional<EmailMessage> loadSingleMessageForEvent(int eventId, int messageId) {
return emailMessageRepository.findByEventIdAndMessageId(eventId, messageId);
}
void sendWaitingMessages() {
Date now = new Date();
eventRepository.findAllActiveIds(ZonedDateTime.now(UTC))
.stream()
.flatMap(id -> emailMessageRepository.loadIdsWaitingForProcessing(id, now).stream())
.distinct()
.forEach(this::processMessage);
}
private void processMessage(int messageId) {
EmailMessage message = emailMessageRepository.findById(messageId);
int eventId = message.getEventId();
int organizationId = eventRepository.findOrganizationIdByEventId(eventId);
if(message.getAttempts() >= configurationManager.getIntConfigValue(Configuration.from(organizationId, eventId, ConfigurationKeys.MAIL_ATTEMPTS_COUNT), 10)) {
tx.execute(status -> emailMessageRepository.updateStatusAndAttempts(messageId, ERROR.name(), message.getAttempts(), Arrays.asList(IN_PROCESS.name(), WAITING.name(), RETRY.name())));
log.warn("Message with id " + messageId + " will be discarded");
return;
}
try {
int result = tx.execute(status -> emailMessageRepository.updateStatus(message.getEventId(), message.getChecksum(), IN_PROCESS.name(), Arrays.asList(WAITING.name(), RETRY.name())));
if(result > 0) {
tx.execute(status -> {
sendMessage(message);
return null;
});
} else {
log.debug("no messages have been updated on DB for the following criteria: eventId: {}, checksum: {}", message.getEventId(), message.getChecksum());
}
} catch(Exception e) {
tx.execute(status -> emailMessageRepository.updateStatusAndAttempts(message.getId(), RETRY.name(), DateUtils.addMinutes(new Date(), message.getAttempts() + 1), message.getAttempts() + 1, Arrays.asList(IN_PROCESS.name(), WAITING.name(), RETRY.name())));
log.warn("could not send message: ",e);
}
}
private void sendMessage(EmailMessage message) {
Event event = eventRepository.findById(message.getEventId());
mailer.send(event, message.getRecipient(), message.getSubject(), message.getMessage(), Optional.empty(), decodeAttachments(message.getAttachments()));
emailMessageRepository.updateStatusToSent(message.getEventId(), message.getChecksum(), ZonedDateTime.now(UTC), Collections.singletonList(IN_PROCESS.name()));
}
private String encodeAttachments(Mailer.Attachment... files) {
return gson.toJson(files);
}
private Mailer.Attachment[] decodeAttachments(String input) {
if(StringUtils.isBlank(input)) {
return new Mailer.Attachment[0];
}
Mailer.Attachment[] attachments = gson.fromJson(input, Mailer.Attachment[].class);
Set<Mailer.AttachmentIdentifier> alreadyPresents = Arrays.stream(attachments).map(Mailer.Attachment::getIdentifier).filter(Objects::nonNull).collect(Collectors.toSet());
//
List<Mailer.Attachment> toReinterpret = Arrays.stream(attachments)
.filter(attachment -> attachment.getIdentifier() != null && !attachment.getIdentifier().reinterpretAs().isEmpty())
.collect(Collectors.toList());
List<Mailer.Attachment> generated = new ArrayList<>(Arrays.stream(attachments)
.map(attachment -> this.transformAttachment(attachment, attachment.getIdentifier()))
.filter(Objects::nonNull)
.collect(Collectors.toList()));
List<Mailer.Attachment> reinterpreted = new ArrayList<>();
toReinterpret.forEach(attachment ->
attachment.getIdentifier().reinterpretAs().stream()
.filter(identifier -> !alreadyPresents.contains(identifier))
.forEach(identifier -> reinterpreted.add(this.transformAttachment(attachment, identifier))
)
);
generated.addAll(reinterpreted.stream().filter(Objects::nonNull).collect(Collectors.toList()));
return generated.toArray(new Mailer.Attachment[generated.size()]);
}
private Mailer.Attachment transformAttachment(Mailer.Attachment attachment, Mailer.AttachmentIdentifier identifier) {
if(identifier != null) {
byte[] result = attachmentTransformer.get(identifier).apply(attachment.getModel());
return result == null ? null : new Mailer.Attachment(identifier.fileName(attachment.getFilename()), result, identifier.contentType(attachment.getContentType()), null, null);
} else {
return attachment;
}
}
private static String calculateChecksum(String recipient, String attachments, String subject, String text) {
try {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
digest.update(recipient.getBytes(StandardCharsets.UTF_8));
digest.update(subject.getBytes(StandardCharsets.UTF_8));
Optional.ofNullable(attachments).ifPresent(v -> digest.update(v.getBytes(StandardCharsets.UTF_8)));
digest.update(text.getBytes(StandardCharsets.UTF_8));
return new String(Hex.encode(digest.digest()));
} catch (NoSuchAlgorithmException e) {
throw new IllegalStateException(e);
}
}
private static final class AttachmentConverter implements JsonSerializer<Mailer.Attachment>, JsonDeserializer<Mailer.Attachment> {
@Override
public JsonElement serialize(Mailer.Attachment src, Type typeOfSrc, JsonSerializationContext context) {
JsonObject obj = new JsonObject();
obj.addProperty("filename", src.getFilename());
obj.addProperty("source", src.getSource() != null ? Base64.getEncoder().encodeToString(src.getSource()) : null);
obj.addProperty("contentType", src.getContentType());
obj.addProperty("identifier", src.getIdentifier() != null ? src.getIdentifier().name() : null);
obj.addProperty("model", src.getModel() != null ? Json.toJson(src.getModel()) : null);
return obj;
}
@Override
public Mailer.Attachment deserialize(JsonElement json, Type typeOfT, JsonDeserializationContext context) throws JsonParseException {
JsonObject jsonObject = json.getAsJsonObject();
String filename = jsonObject.getAsJsonPrimitive("filename").getAsString();
byte[] source = jsonObject.has("source") ? Base64.getDecoder().decode(jsonObject.getAsJsonPrimitive("source").getAsString()) : null;
String contentType = jsonObject.getAsJsonPrimitive("contentType").getAsString();
Mailer.AttachmentIdentifier identifier = jsonObject.has("identifier") ? Mailer.AttachmentIdentifier.valueOf(jsonObject.getAsJsonPrimitive("identifier").getAsString()) : null;
Map<String, String> model = jsonObject.has("model") ? Json.fromJson(jsonObject.getAsJsonPrimitive("model").getAsString(), new TypeReference<Map<String, String>>() {}) : null;
return new Mailer.Attachment(filename, source, contentType, model, identifier);
}
}
}