diff --git a/.gitignore b/.gitignore
index 6b029826..01ee2385 100644
--- a/.gitignore
+++ b/.gitignore
@@ -45,7 +45,6 @@ private_key_pkcs8.pem
JSON.md
spieldata
teams-api-calls.md
-.run/
client/locale_parser.js
PlayGroundTest.java
spieldata
diff --git a/.run/BE - InviteServerApplication.run.xml b/.run/BE - InviteServerApplication.run.xml
new file mode 100644
index 00000000..91cbd183
--- /dev/null
+++ b/.run/BE - InviteServerApplication.run.xml
@@ -0,0 +1,15 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.run/BE - MockProvisioningApplication.run.xml b/.run/BE - MockProvisioningApplication.run.xml
new file mode 100644
index 00000000..6b166172
--- /dev/null
+++ b/.run/BE - MockProvisioningApplication.run.xml
@@ -0,0 +1,9 @@
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.run/FE - Client.run.xml b/.run/FE - Client.run.xml
new file mode 100644
index 00000000..c80aff7c
--- /dev/null
+++ b/.run/FE - Client.run.xml
@@ -0,0 +1,12 @@
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/server/src/main/java/invite/mail/ImageEmbedder.java b/server/src/main/java/invite/mail/ImageEmbedder.java
new file mode 100644
index 00000000..195a3391
--- /dev/null
+++ b/server/src/main/java/invite/mail/ImageEmbedder.java
@@ -0,0 +1,69 @@
+package invite.mail;
+
+import org.apache.commons.logging.Log;
+import org.apache.commons.logging.LogFactory;
+
+import java.io.InputStream;
+import java.net.URI;
+import java.net.http.HttpClient;
+import java.net.http.HttpRequest;
+import java.net.http.HttpResponse;
+import java.util.Base64;
+import java.util.Optional;
+
+public class ImageEmbedder {
+
+ private static final Log LOG = LogFactory.getLog(ImageEmbedder.class);
+ private static final String DEFAULT_CONTENT_TYPE = "image/png";
+ private static final HttpClient HTTP_CLIENT = HttpClient.newHttpClient();
+ private static final int MAX_IMAGE_BYTES = 1 * 1024 * 1024; // 1 MegaByte
+
+ private ImageEmbedder() {
+ }
+
+ /**
+ * Fetches a remote image and returns it as a data: URL for use in an HTML.
+ *
+ * @param imageUrl the absolute URL of the image to fetch
+ * @return the data: URL, or empty if the image cannot be fetched
+ */
+ public static Optional fetchAsDataUrl(String imageUrl) {
+ try {
+ HttpRequest request = HttpRequest.newBuilder().uri(URI.create(imageUrl)).build();
+ HttpResponse response = HTTP_CLIENT.send(request, HttpResponse.BodyHandlers.ofInputStream());
+
+ Optional body = readBounded(response.body(), imageUrl);
+ if (body.isEmpty()) {
+ return Optional.empty();
+ }
+
+ String contentType = response.headers()
+ .firstValue("Content-Type")
+ .orElse(DEFAULT_CONTENT_TYPE);
+
+ return Optional.of(toDataUrl(contentType, body.get()));
+ } catch (Exception e) {
+ LOG.warn(String.format("Error fetching image from %s: %s", imageUrl, e.getMessage()));
+ return Optional.empty();
+ }
+ }
+
+ private static Optional readBounded(InputStream source, String imageUrl) {
+ try (InputStream in = source) {
+ byte[] bytes = in.readNBytes(MAX_IMAGE_BYTES + 1);
+ if (bytes.length > MAX_IMAGE_BYTES) {
+ LOG.warn(String.format("Image at %s exceeds maximum size of %d bytes; aborting download",
+ imageUrl, MAX_IMAGE_BYTES));
+ return Optional.empty();
+ }
+ return Optional.of(bytes);
+ } catch (Exception e) {
+ LOG.warn(String.format("Error reading image from %s: %s", imageUrl, e.getMessage()));
+ return Optional.empty();
+ }
+ }
+
+ private static String toDataUrl(String contentType, byte[] body) {
+ return "data:" + contentType + ";base64," + Base64.getEncoder().encodeToString(body);
+ }
+}
diff --git a/server/src/main/java/invite/mail/MailBox.java b/server/src/main/java/invite/mail/MailBox.java
index b1a4772e..c35a894e 100644
--- a/server/src/main/java/invite/mail/MailBox.java
+++ b/server/src/main/java/invite/mail/MailBox.java
@@ -2,11 +2,17 @@
import invite.cron.IdPMetaDataResolver;
import invite.cron.IdentityProvider;
-import invite.model.*;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.github.mustachejava.DefaultMustacheFactory;
import com.github.mustachejava.MustacheFactory;
+import invite.model.Authority;
+import invite.model.GroupedProviders;
+import invite.model.Invitation;
+import invite.model.Language;
+import invite.model.Provisionable;
+import invite.model.User;
+import invite.model.UserRole;
import jakarta.mail.MessagingException;
import jakarta.mail.internet.MimeMessage;
import lombok.SneakyThrows;
@@ -19,7 +25,11 @@
import java.io.IOException;
import java.io.StringWriter;
-import java.util.*;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
import java.util.stream.Collectors;
@SuppressWarnings("unchecked")
@@ -73,7 +83,7 @@ public void sendInviteMail(Provisionable provisionable, Invitation invitation,
.map(idp -> idp.getName())
.orElse(user.getSchacHomeOrganization()));
variables.put("institutionLogoUrl", identityProvider
- .map(idp -> idp.getLogoUrl())
+ .flatMap(idp -> ImageEmbedder.fetchAsDataUrl(idp.getLogoUrl()))
.orElse(null));
} else {
variables.put("institutionName", "SURF");
diff --git a/server/src/test/java/invite/mail/ImageEmbedderTest.java b/server/src/test/java/invite/mail/ImageEmbedderTest.java
new file mode 100644
index 00000000..8c532ec1
--- /dev/null
+++ b/server/src/test/java/invite/mail/ImageEmbedderTest.java
@@ -0,0 +1,70 @@
+package invite.mail;
+
+import com.github.tomakehurst.wiremock.http.Fault;
+import invite.WireMockExtension;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.RegisterExtension;
+
+import java.util.Optional;
+
+import static com.github.tomakehurst.wiremock.client.WireMock.aResponse;
+import static com.github.tomakehurst.wiremock.client.WireMock.get;
+import static com.github.tomakehurst.wiremock.client.WireMock.getRequestedFor;
+import static com.github.tomakehurst.wiremock.client.WireMock.stubFor;
+import static com.github.tomakehurst.wiremock.client.WireMock.urlPathEqualTo;
+import static com.github.tomakehurst.wiremock.client.WireMock.verify;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class ImageEmbedderTest {
+
+ @RegisterExtension
+ WireMockExtension mockServer = new WireMockExtension(8093);
+
+ @Test
+ void fetchAsDataUrl() {
+ // base64 encoding: "iVBORw0KGgo="
+ byte[] pngBytes = {(byte) 0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A};
+ stubFor(get(urlPathEqualTo("/logo.png")).willReturn(aResponse()
+ .withHeader("Content-Type", "image/png")
+ .withBody(pngBytes)));
+
+ Optional dataUrl = ImageEmbedder.fetchAsDataUrl("http://localhost:8093/logo.png");
+
+ assertTrue(dataUrl.isPresent());
+ assertEquals("data:image/png;base64,iVBORw0KGgo=", dataUrl.get());
+ verify(getRequestedFor(urlPathEqualTo("/logo.png")));
+ }
+
+ @Test
+ void fetchAsDataUrlReturnsEmptyWhenImageExceedsMaxSize() {
+ // One byte over the 1 MB MAX_IMAGE_BYTES cap
+ byte[] oversizedBody = new byte[1024 * 1024 + 1];
+ stubFor(get(urlPathEqualTo("/huge.png")).willReturn(aResponse()
+ .withHeader("Content-Type", "image/png")
+ .withBody(oversizedBody)));
+
+ Optional dataUrl = ImageEmbedder.fetchAsDataUrl("http://localhost:8093/huge.png");
+
+ assertTrue(dataUrl.isEmpty());
+ verify(getRequestedFor(urlPathEqualTo("/huge.png")));
+ }
+
+ @Test
+ void fetchAsDataUrlReturnsEmptyOnBodyReadFailure() {
+ stubFor(get(urlPathEqualTo("/broken.png")).willReturn(aResponse()
+ .withFault(Fault.MALFORMED_RESPONSE_CHUNK)));
+
+ Optional dataUrl = ImageEmbedder.fetchAsDataUrl("http://localhost:8093/broken.png");
+
+ assertTrue(dataUrl.isEmpty());
+ verify(getRequestedFor(urlPathEqualTo("/broken.png")));
+ }
+
+ @Test
+ void fetchAsDataUrlReturnsEmptyOnFailure() {
+ Optional dataUrl = ImageEmbedder.fetchAsDataUrl("not a url");
+
+ assertTrue(dataUrl.isEmpty());
+ }
+}