Skip to content

Commit 2b7e97d

Browse files
authored
Merge pull request #175 from aodn/feature/6486-update-data-downloading-email-designs
💄 update data downloading emails to new designs
2 parents e7e1289 + 3436ef1 commit 2b7e97d

15 files changed

Lines changed: 1370 additions & 24 deletions

File tree

Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
package au.org.aodn.ogcapi.server.core.util;
2+
3+
import au.org.aodn.ogcapi.features.model.MultipolygonGeoJSON;
4+
import com.fasterxml.jackson.databind.ObjectMapper;
5+
import lombok.extern.slf4j.Slf4j;
6+
import org.locationtech.jts.geom.Envelope;
7+
import org.locationtech.jts.geom.MultiPolygon;
8+
import org.locationtech.jts.geom.Polygon;
9+
10+
import java.io.IOException;
11+
import java.io.InputStream;
12+
import java.math.BigDecimal;
13+
import java.util.List;
14+
import java.util.Map;
15+
16+
/**
17+
* Utility for email-related operations
18+
*/
19+
@Slf4j
20+
public class EmailUtils {
21+
22+
/**
23+
* Read a base64 encoded image from resources
24+
* @param filename - the filename in /img/ directory
25+
* @return base64 encoded image as data URL
26+
* @throws IOException if resource not found
27+
*/
28+
public static String readBase64Image(String filename) throws IOException {
29+
InputStream is = EmailUtils.class.getResourceAsStream("/img/" + filename);
30+
if (is == null) {
31+
throw new IOException("Resource not found: /img/" + filename);
32+
}
33+
return "data:image/png;base64," + new String(is.readAllBytes()).trim();
34+
}
35+
36+
/**
37+
* Generate HTML content for bounding box section in email
38+
* @param multipolygon - the multipolygon object
39+
* @param objectMapper - Jackson ObjectMapper for JSON processing
40+
* @return HTML string for bbox section
41+
*/
42+
public static String generateBboxHtml(Object multipolygon, ObjectMapper objectMapper) {
43+
try {
44+
if (multipolygon == null) {
45+
return buildBboxSection("0", "0", "0", "0", 0);
46+
}
47+
48+
// Extract coordinates directly from the object
49+
List<List<List<List<BigDecimal>>>> coordinates = extractCoordinates(multipolygon, objectMapper);
50+
51+
if (coordinates == null || coordinates.isEmpty()) {
52+
return buildBboxSection("0", "0", "0", "0", 0);
53+
}
54+
55+
StringBuilder html = new StringBuilder();
56+
int bboxCounter = 0;
57+
58+
// Process each polygon separately
59+
for (List<List<List<BigDecimal>>> polygon : coordinates) {
60+
// Find min/max for THIS polygon only
61+
double minLon = Double.MAX_VALUE;
62+
double maxLon = Double.MIN_VALUE;
63+
double minLat = Double.MAX_VALUE;
64+
double maxLat = Double.MIN_VALUE;
65+
66+
for (List<List<BigDecimal>> ring : polygon) {
67+
for (List<BigDecimal> point : ring) {
68+
if (point.size() >= 2) {
69+
double lon = point.get(0).doubleValue();
70+
double lat = point.get(1).doubleValue();
71+
minLon = Math.min(minLon, lon);
72+
maxLon = Math.max(maxLon, lon);
73+
minLat = Math.min(minLat, lat);
74+
maxLat = Math.max(maxLat, lat);
75+
}
76+
}
77+
}
78+
79+
// Use BboxUtils to normalize this polygon's bbox
80+
MultiPolygon normalizedBbox = BboxUtils.normalizeBbox(minLon, maxLon, minLat, maxLat);
81+
82+
// Build HTML for each normalized bbox
83+
for (int i = 0; i < normalizedBbox.getNumGeometries(); i++) {
84+
Polygon normalizedPolygon = (Polygon) normalizedBbox.getGeometryN(i);
85+
Envelope envelope = normalizedPolygon.getEnvelopeInternal();
86+
87+
String north = "" + envelope.getMaxY();
88+
String south = "" + envelope.getMinY();
89+
String west = "" + envelope.getMinX();
90+
String east = "" + envelope.getMaxX();
91+
92+
// Add spacing between multiple bboxes
93+
if (bboxCounter > 0) {
94+
html.append("<tr><td style=\"font-size:0;padding:0;word-break:break-word;\">")
95+
.append("<div style=\"height:24px;line-height:24px;\">&#8202;</div>")
96+
.append("</td></tr>");
97+
}
98+
99+
bboxCounter++;
100+
int displayIndex = (coordinates.size() > 1 || normalizedBbox.getNumGeometries() > 1) ? bboxCounter : 0;
101+
html.append(buildBboxSection(north, south, west, east, displayIndex));
102+
}
103+
}
104+
105+
return html.toString();
106+
107+
} catch (Exception e) {
108+
log.error("Error generating bbox HTML", e);
109+
return buildBboxSection("0", "0", "0", "0", 0);
110+
}
111+
}
112+
113+
/**
114+
* Extract coordinates from multipolygon object (handles both MultipolygonGeoJSON and Map)
115+
*/
116+
private static List<List<List<List<BigDecimal>>>> extractCoordinates(Object multipolygon, ObjectMapper objectMapper) throws Exception {
117+
if (multipolygon instanceof MultipolygonGeoJSON) {
118+
return ((MultipolygonGeoJSON) multipolygon).getCoordinates();
119+
}
120+
121+
if (multipolygon instanceof Map) {
122+
Map<String, Object> map = (Map<String, Object>) multipolygon;
123+
Object coords = map.get("coordinates");
124+
125+
if (coords != null) {
126+
String coordsJson = objectMapper.writeValueAsString(coords);
127+
return objectMapper.readValue(coordsJson,
128+
objectMapper.getTypeFactory().constructParametricType(List.class,
129+
objectMapper.getTypeFactory().constructParametricType(List.class,
130+
objectMapper.getTypeFactory().constructParametricType(List.class,
131+
objectMapper.getTypeFactory().constructParametricType(List.class, BigDecimal.class)))));
132+
}
133+
}
134+
135+
return null;
136+
}
137+
138+
protected static String buildBboxSection(String north, String south, String west, String east, int index) {
139+
String title = index > 0 ? "Bounding Box " + index : "Bounding Box Selection";
140+
141+
return "<tr>" +
142+
"<td align=\"center\" class=\"tr-0\" style=\"background:transparent;font-size:0;padding:0;word-break:break-word;\">" +
143+
"<table cellpadding=\"0\" cellspacing=\"0\" width=\"100%\" border=\"0\" style=\"color:#000000;line-height:normal;table-layout:fixed;width:100%;border:none;\">" +
144+
"<tr><td align=\"left\" class=\"u\" style=\"padding:0;height:auto;word-wrap:break-word;vertical-align:middle;\" width=\"32\">" +
145+
"<table border=\"0\" cellpadding=\"0\" cellspacing=\"0\" width=\"100%\"><tr><td align=\"left\" width=\"100%\">" +
146+
"<img alt width=\"32\" style=\"display:block;width:32px;height:32px;\" src=\"{{BBOX_IMG}}\"></td></tr></table></td>" +
147+
"<td style=\"vertical-align:middle;color:transparent;font-size:0;\" width=\"16\">&#8203;</td>" +
148+
"<td align=\"left\" class=\"u\" style=\"padding:0;height:auto;word-wrap:break-word;vertical-align:middle;\" width=\"auto\">" +
149+
"<table border=\"0\" cellpadding=\"0\" cellspacing=\"0\" width=\"100%\"><tr><td align=\"left\" width=\"100%\">" +
150+
"<div style=\"font-family: 'Open Sans', 'Arial', sans-serif; font-size: 14px; font-weight: 500; line-height: 157%; text-align: left; color: #090c02\">" +
151+
"<p style=\"Margin:0;mso-line-height-alt:22px;font-size:14px;line-height:157%;\">" + title + "</p></div></td></tr></table></td></tr>" +
152+
"</table></td></tr>" +
153+
"<tr><td style=\"font-size:0;padding:0;word-break:break-word;\"><div style=\"height:8px;line-height:8px;\">&#8202;</div></td></tr>" +
154+
"<tr><td align=\"center\" class=\"tr-0\" style=\"background:transparent;font-size:0;padding:0px 48px 0px 48px;word-break:break-word;\">" +
155+
"<table cellpadding=\"0\" cellspacing=\"0\" width=\"100%\" border=\"0\" style=\"color:#000000;line-height:normal;table-layout:fixed;width:100%;border:none;\">" +
156+
"<tr><td align=\"left\" class=\"u\" style=\"padding:0;height:auto;word-wrap:break-word;vertical-align:middle;\" width=\"500\">" +
157+
"<table border=\"0\" cellpadding=\"0\" cellspacing=\"0\" width=\"100%\"><tr><td align=\"left\" width=\"100%\">" +
158+
"<div style=\"font-family: 'Open Sans', 'Arial', sans-serif; font-size: 14px; font-weight: 400; line-height: 157%; text-align: left; color: #3c3c3c\">" +
159+
"<p style=\"Margin:0;mso-line-height-alt:22px;font-size:14px;line-height:157%;\">N: " + north + "</p></div></td></tr></table></td></tr>" +
160+
"<tr><td style=\"font-size:0;padding:0;padding-bottom:0;word-break:break-word;color:transparent;\" aria-hidden=\"true\">" +
161+
"<div style=\"height:8px;line-height:8px;\">&#8203;</div></td></tr>" +
162+
"<tr><td align=\"left\" class=\"u\" style=\"padding:0;height:auto;word-wrap:break-word;vertical-align:middle;\" width=\"500\">" +
163+
"<table border=\"0\" cellpadding=\"0\" cellspacing=\"0\" width=\"100%\"><tr><td align=\"left\" width=\"100%\">" +
164+
"<div style=\"font-family: 'Open Sans', 'Arial', sans-serif; font-size: 14px; font-weight: 400; line-height: 157%; text-align: left; color: #3c3c3c\">" +
165+
"<p style=\"Margin:0;mso-line-height-alt:22px;font-size:14px;line-height:157%;\">S: " + south + "</p></div></td></tr></table></td></tr>" +
166+
"<tr><td style=\"font-size:0;padding:0;padding-bottom:0;word-break:break-word;color:transparent;\" aria-hidden=\"true\">" +
167+
"<div style=\"height:8px;line-height:8px;\">&#8203;</div></td></tr>" +
168+
"<tr><td align=\"left\" class=\"u\" style=\"padding:0;height:auto;word-wrap:break-word;vertical-align:middle;\" width=\"500\">" +
169+
"<table border=\"0\" cellpadding=\"0\" cellspacing=\"0\" width=\"100%\"><tr><td align=\"left\" width=\"100%\">" +
170+
"<div style=\"font-family: 'Open Sans', 'Arial', sans-serif; font-size: 14px; font-weight: 400; line-height: 157%; text-align: left; color: #3c3c3c\">" +
171+
"<p style=\"Margin:0;mso-line-height-alt:22px;font-size:14px;line-height:157%;\">W: " + west + "</p></div></td></tr></table></td></tr>" +
172+
"<tr><td style=\"font-size:0;padding:0;padding-bottom:0;word-break:break-word;color:transparent;\" aria-hidden=\"true\">" +
173+
"<div style=\"height:8px;line-height:8px;\">&#8203;</div></td></tr>" +
174+
"<tr><td align=\"left\" class=\"u\" style=\"padding:0;height:auto;word-wrap:break-word;vertical-align:middle;\" width=\"500\">" +
175+
"<table border=\"0\" cellpadding=\"0\" cellspacing=\"0\" width=\"100%\"><tr><td align=\"left\" width=\"100%\">" +
176+
"<div style=\"font-family: 'Open Sans', 'Arial', sans-serif; font-size: 14px; font-weight: 400; line-height: 157%; text-align: left; color: #3c3c3c\">" +
177+
"<p style=\"Margin:0;mso-line-height-alt:22px;font-size:14px;line-height:157%;\">E: " + east + "</p></div></td></tr></table></td></tr>" +
178+
"</table></td></tr>" +
179+
"</tr>";
180+
}
181+
}

server/src/main/java/au/org/aodn/ogcapi/server/processes/RestApi.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ public ResponseEntity<InlineResponse200> execute(
5858
var recipient = (String) body.getInputs().get(DatasetDownloadEnums.Parameter.RECIPIENT.getValue());
5959

6060
// move the notify user email from data-access-service to here to make the first email faster
61-
restServices.notifyUser(recipient, uuid, startDate, endDate);
61+
restServices.notifyUser(recipient, uuid, startDate, endDate, multiPolygon);
6262

6363
var response = restServices.downloadData(uuid, startDate, endDate, multiPolygon, recipient);
6464

server/src/main/java/au/org/aodn/ogcapi/server/processes/RestServices.java

Lines changed: 42 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import au.org.aodn.ogcapi.server.core.exception.wfs.WfsErrorHandler;
44
import au.org.aodn.ogcapi.server.core.model.enumeration.DatasetDownloadEnums;
55
import au.org.aodn.ogcapi.server.core.service.wfs.DownloadWfsDataService;
6+
import au.org.aodn.ogcapi.server.core.util.EmailUtils;
67
import com.fasterxml.jackson.core.JsonProcessingException;
78
import com.fasterxml.jackson.databind.ObjectMapper;
89
import lombok.extern.slf4j.Slf4j;
@@ -15,6 +16,9 @@
1516
import software.amazon.awssdk.services.ses.SesClient;
1617
import software.amazon.awssdk.services.ses.model.*;
1718

19+
import java.io.InputStream;
20+
import java.io.IOException;
21+
import java.nio.charset.StandardCharsets;
1822
import java.util.HashMap;
1923
import java.util.List;
2024
import java.util.Map;
@@ -36,16 +40,16 @@ public RestServices(BatchClient batchClient, ObjectMapper objectMapper) {
3640
this.objectMapper = objectMapper;
3741
}
3842

39-
public void notifyUser(String recipient, String uuid, String startDate, String endDate) {
43+
public void notifyUser(String recipient, String uuid, String startDate, String endDate, Object multiPolygon) {
4044

4145
String aodnInfoSender = "no.reply@aodn.org.au";
4246

4347
try (SesClient ses = SesClient.builder().build()) {
4448
var subject = Content.builder().data("Start processing data file whose uuid is: " + uuid).build();
45-
var content = Content.builder().data(generateStartedEmailContent(startDate, endDate)).build();
49+
var content = Content.builder().data(generateStartedEmailContent(uuid, startDate, endDate, multiPolygon)).build();
4650
var destination = Destination.builder().toAddresses(recipient).build();
4751

48-
var body = Body.builder().text(content).build();
52+
var body = Body.builder().html(content).build();
4953
var message = Message.builder()
5054
.subject(subject)
5155
.body(body)
@@ -106,29 +110,44 @@ private String submitJob(String jobName, String jobQueue, String jobDefinition,
106110
return submitJobResponse.jobId();
107111
}
108112

113+
private String generateStartedEmailContent(String uuid, String startDate, String endDate, Object multipolygon) {
114+
try (InputStream inputStream = getClass().getResourceAsStream("/job-started-email.html")) {
109115

110-
private String generateStartedEmailContent(String startDate, String endDate) {
116+
if (inputStream == null) {
117+
log.error("Email template not found");
118+
throw new RuntimeException("Email template not found");
119+
}
111120

112-
// only include non-empty date conditions
113-
var startDateCondition = "";
114-
var endDateCondition = "";
115-
var dateRangeCondition = "";
116-
if (startDate != null && !startDate.equals("non-specified")) {
117-
startDateCondition = " Start Date: " + startDate + ".";
118-
}
119-
if (endDate != null && !endDate.equals("non-specified")) {
120-
endDateCondition = " End Date: " + endDate + ".";
121-
}
122-
if (!startDateCondition.isBlank() || !endDateCondition.isBlank()) {
123-
dateRangeCondition = "Date range: " + startDateCondition + endDateCondition;
121+
String template = new String(inputStream.readAllBytes(), StandardCharsets.UTF_8);
122+
123+
// Handle dates - only show if not "non-specified"
124+
String displayStartDate = (startDate != null && !startDate.equals("non-specified")) ? startDate.replace("-", "/") : "";
125+
String displayEndDate = (endDate != null && !endDate.equals("non-specified")) ? endDate.replace("-", "/") : "";
126+
127+
// Generate dynamic bbox HTML
128+
String bboxHtml = EmailUtils.generateBboxHtml(multipolygon, objectMapper);
129+
130+
// Replace all variables in one chain
131+
return template
132+
.replace("{{uuid}}", uuid)
133+
.replace("{{startDate}}", displayStartDate)
134+
.replace("{{endDate}}", displayEndDate)
135+
.replace("{{bboxContent}}", bboxHtml)
136+
.replace("{{HEADER_IMG}}", EmailUtils.readBase64Image("header.txt"))
137+
.replace("{{DOWNLOAD_ICON}}", EmailUtils.readBase64Image("download.txt"))
138+
.replace("{{BBOX_IMG}}", EmailUtils.readBase64Image("bbox.txt"))
139+
.replace("{{TIME_RANGE_IMG}}", EmailUtils.readBase64Image("time-range.txt"))
140+
.replace("{{ATTRIBUTES_IMG}}", EmailUtils.readBase64Image("attributes.txt"))
141+
.replace("{{FACEBOOK_IMG}}", EmailUtils.readBase64Image("facebook.txt"))
142+
.replace("{{INSTAGRAM_IMG}}", EmailUtils.readBase64Image("instagram.txt"))
143+
.replace("{{X_IMG}}", EmailUtils.readBase64Image("x.txt"))
144+
.replace("{{CONTACT_IMG}}", EmailUtils.readBase64Image("email.txt"))
145+
.replace("{{LINKEDIN_IMG}}", EmailUtils.readBase64Image("linkedin.txt"));
146+
147+
} catch (IOException e) {
148+
log.error("Failed to load email template", e);
149+
throw new RuntimeException("Failed to load email template", e);
124150
}
125-
126-
var conditionTitle = dateRangeCondition.isBlank() ? "" : "The conditions of your request include";
127-
128-
return "Your request has been received. " + conditionTitle+ dateRangeCondition +
129-
". Please wait for the result. " +
130-
"After the process is completed, you will receive an email " +
131-
"with the download link.";
132151
}
133152

134153
public SseEmitter downloadWfsDataWithSse(
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
iVBORw0KGgoAAAANSUhEUgAAAEAAAABABAMAAABYR2ztAAAABGdBTUEAALGPC/xhBQAAAAFzUkdCAK7OHOkAAAAJcEhZcwAAFiUAABYlAUlSJPAAAAAkUExURUdwTGKJp2KMpWWKrWSLpmKLpWKMpWKMpWOMpmKLpWKLpWKLpb74VJIAAAALdFJOUwAg3xA4fKPvxVuQCZPLOwAAAddJREFUSMellb1PwkAYxq8WDehCNBqDiyQwmC5VQ1w7kGjCYsKAiYvExKWTMUwsmLCxEFgcMXEwYZJgbXj+Oe+uvX4cdxXwHUi599fex/s+zxGyZhSGf+StWSaRs4AswhiAhlfU5c178PjWEdc02R/Rn4oGoBOcEUIJTwO46NiEETrguGrzpd69kvWj9i6PlFIjte5MBiy0k3nAlgC623Yy7y1vCbiNC/DTlIFtVhaHP16p8oQc0U+88KcGsLAVlaGTfAmAHbEcLSQBlJeOFWlgIQNToBsBZXd5m1N4FyFwglNjsDTFvu9sxYC6QCsDewIwP3lD555sGegFAzvw6dnmBuJ/PgR2MRQDtOUNeoAhUMAkeLgU9WGiaMVVJOfyqkaBLvr6zuNEJ6M1mbY6dqZ6ASe7vYHiBsCBDgj3Hx0Uk10SKHQnYbF6aiCvqGYKUJY7qUElcFPZoGEa/wDmqwLPOluch4tMmklK3nMhfxXBbCWQv8GsoqnKhwbCzcRXWFBsK8xMxIvj2MQek+sRJXD9yAb7Kts0Iz8sfaivlOyerHPArGtvNFQZ0IqkJwcVbxV4g642XLw8HnTCc4O8Z+uvVZafOVkXLz3gzKv30FL5euob43Vvs19kne9Se2XY1QAAAABJRU5ErkJggg==
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
iVBORw0KGgoAAAANSUhEUgAAAEAAAABAAgMAAADXB5lNAAAABGdBTUEAALGPC/xhBQAAAAFzUkdCAK7OHOkAAAAJcEhZcwAAFiUAABYlAUlSJPAAAAAMUExURXGSqUdwTEd2lTtuj4sunxQAAAADdFJOU00AqFo58ZcAAABUSURBVDjLYwhFAwwYAvGH/3P/4/+j/8P+w/4H/w9/xSLQjCxw6CtDAAMKYMUUiD+AzGf+ShUBwtZGNSDzmZZSxVoMQ0e9P+r9Qeh9jGxKOGcTLB0AcqanOyPT1xcAAAAASUVORK5CYII=
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
iVBORw0KGgoAAAANSUhEUgAAACwAAAAsBAMAAADsqkcyAAAABGdBTUEAALGPC/xhBQAAAAFzUkdCAK7OHOkAAAAJcEhZcwAAFiUAABYlAUlSJPAAAAAtUExURUdwTP///////////////////////////////////////////////////////81e3QIAAAAOdFJOUwBLix7f7oAyxHFCfmCosRnbLAAAALBJREFUKM9jYKAqMBQUXIwpyhz37t0TAwxhtndAkIAhzAQSVhjWwuzb0jJAwm1p2cjBwvIODgKQhDkQwg7IoeoHE0UNWxOYsDNqJPhhU8zAYIpNMdCJcSDRpwXo3ikFCYdj+JILJLwAM+qPvnv3EEs64Xz3bgK29HPuJdZkxV7AQGXAGfcOAzzZwLDvHRbwksEPm/BThndYAUMcdtV92ITfIqILGTQzMCwUxADi1PE7AEkbCZHrk71RAAAAAElFTkSuQmCC
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
iVBORw0KGgoAAAANSUhEUgAAAC8AAAAvBAMAAACBCY6fAAAABGdBTUEAALGPC/xhBQAAAAFzUkdCAK7OHOkAAAAJcEhZcwAAFiUAABYlAUlSJPAAAAAtUExURUdwTP///////////////////////////////////////////////////////81e3QIAAAAOdFJOUwAggPC/jzA4mD/eDtRdxh3/xwAAASZJREFUOMvVkzFuwkAQRQeCbVyA4AZWDoCogtysaCMhlBNYSBa9JS5AldJCouUASeOzcATEghS5+GfIrs1mvYNJnUy5T3//7Mxfov9W45bS5we01CeRj9bKqIfy+a4E5vQEvHHXLjClYYkrBxOIEw0vG0TuuYd4qUEgzi5I5K4CtHdduoipBqG4MsENkOPiYUYGBE1JIrMfoFy2VvBCFgTiywpWDUCpaayvHRrAN5K8EligJFvr4IJKknPgC2hJH2BXpXgXl1FnKde1xABfvTCFFOo0d9rV3XYKoBzd+nVGMlh8HNlINnYizhBDO5D6jRmZRUVsszVge1JZkFkF9jw/ykWDEmcenwTFSQcu4sDTgetB3ke0UBEN2kO9evANXn/7OH+xvgEJevCx3pIXnAAAAABJRU5ErkJggg==
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
iVBORw0KGgoAAAANSUhEUgAAADAAAAAwBAMAAAClLOS0AAAABGdBTUEAALGPC/xhBQAAAAFzUkdCAK7OHOkAAAAJcEhZcwAAFiUAABYlAUlSJPAAAAAbUExURUdwTAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAGnhwewAAAAIdFJOUwDfZp+FuhAgBHVy3QAAAFhJREFUOMtjYECAJEOJDiBgQAdMYGEsEhYd2CVYOnBIVOCS0MAlEYFLQgKXRAd5Em4MmAAsUYBLgoGGEh1IAJdEIy6JVpIlmnFJtOCSaCJOgh5hNSoxgiQARLytLnjnKiwAAAAASUVORK5CYII=

0 commit comments

Comments
 (0)