Add Open Graph and Twitter Card meta-tags for link previews
All checks were successful
CI / backend-test (push) Successful in 1m9s
CI / frontend-test (push) Successful in 23s
CI / frontend-e2e (push) Successful in 1m12s
CI / build-and-publish (push) Has been skipped

Replace PathResourceResolver SPA fallback with SpaController that
injects OG/Twitter meta-tags into cached index.html template.
Event pages get event-specific tags (title, date, location),
all other pages get generic fete branding. Includes og-image.png
brand asset and forward-headers-strategy for proxy support.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-09 20:25:39 +01:00
parent fa34223c10
commit 751201617d
16 changed files with 1270 additions and 28 deletions

View File

@@ -0,0 +1,188 @@
package de.fete.adapter.in.web;
import de.fete.domain.model.Event;
import de.fete.domain.model.EventToken;
import de.fete.domain.port.in.GetEventUseCase;
import jakarta.annotation.PostConstruct;
import jakarta.servlet.http.HttpServletRequest;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.time.ZonedDateTime;
import java.time.format.DateTimeFormatter;
import java.util.LinkedHashMap;
import java.util.Locale;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
import org.springframework.core.io.ClassPathResource;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.ResponseBody;
import org.springframework.web.servlet.support.ServletUriComponentsBuilder;
/** Serves the SPA index.html with injected Open Graph and Twitter Card meta-tags. */
@Controller
public class SpaController {
private static final String PLACEHOLDER = "<!-- OG_META_TAGS -->";
private static final int MAX_TITLE_LENGTH = 70;
private static final int MAX_DESCRIPTION_LENGTH = 200;
private static final String GENERIC_TITLE = "fete";
private static final String GENERIC_DESCRIPTION =
"Privacy-focused event planning. Create and share events without accounts.";
private static final DateTimeFormatter DATE_FORMAT =
DateTimeFormatter.ofPattern("EEEE, MMMM d, yyyy 'at' h:mm a", Locale.ENGLISH);
private final GetEventUseCase getEventUseCase;
private String htmlTemplate;
/** Creates a new SpaController. */
public SpaController(GetEventUseCase getEventUseCase) {
this.getEventUseCase = getEventUseCase;
}
/** Loads and caches the index.html template at startup. */
@PostConstruct
void loadTemplate() throws IOException {
var resource = new ClassPathResource("/static/index.html");
if (resource.exists()) {
htmlTemplate = resource.getContentAsString(StandardCharsets.UTF_8);
}
}
/** Serves SPA HTML with generic meta-tags for non-event routes. */
@GetMapping(
value = {"/", "/create", "/events"},
produces = MediaType.TEXT_HTML_VALUE
)
@ResponseBody
public String serveGenericPage(HttpServletRequest request) {
if (htmlTemplate == null) {
return "";
}
String baseUrl = getBaseUrl(request);
return htmlTemplate.replace(PLACEHOLDER, renderTags(buildGenericMeta(baseUrl)));
}
/** Serves SPA HTML with event-specific meta-tags. */
@GetMapping(
value = "/events/{token}",
produces = MediaType.TEXT_HTML_VALUE
)
@ResponseBody
public String serveEventPage(@PathVariable String token,
HttpServletRequest request) {
if (htmlTemplate == null) {
return "";
}
String baseUrl = getBaseUrl(request);
Map<String, String> meta = resolveEventMeta(token, baseUrl);
return htmlTemplate.replace(PLACEHOLDER, renderTags(meta));
}
// --- Meta-tag composition ---
private Map<String, String> buildEventMeta(Event event, String baseUrl) {
var tags = new LinkedHashMap<String, String>();
String title = truncateTitle(event.getTitle());
String description = formatDescription(event);
tags.put("og:title", title);
tags.put("og:description", description);
tags.put("og:url", baseUrl + "/events/" + event.getEventToken().value());
tags.put("og:type", "website");
tags.put("og:site_name", GENERIC_TITLE);
tags.put("og:image", baseUrl + "/og-image.png");
tags.put("twitter:card", "summary");
tags.put("twitter:title", title);
tags.put("twitter:description", description);
return tags;
}
private Map<String, String> buildGenericMeta(String baseUrl) {
var tags = new LinkedHashMap<String, String>();
tags.put("og:title", GENERIC_TITLE);
tags.put("og:description", GENERIC_DESCRIPTION);
tags.put("og:url", baseUrl);
tags.put("og:type", "website");
tags.put("og:site_name", GENERIC_TITLE);
tags.put("og:image", baseUrl + "/og-image.png");
tags.put("twitter:card", "summary");
tags.put("twitter:title", GENERIC_TITLE);
tags.put("twitter:description", GENERIC_DESCRIPTION);
return tags;
}
private Map<String, String> resolveEventMeta(String token, String baseUrl) {
try {
UUID uuid = UUID.fromString(token);
Optional<Event> event =
getEventUseCase.getByEventToken(new EventToken(uuid));
if (event.isPresent()) {
return buildEventMeta(event.get(), baseUrl);
}
} catch (IllegalArgumentException ignored) {
// Invalid UUID — fall back to generic
}
return buildGenericMeta(baseUrl);
}
// --- Description formatting ---
private String truncateTitle(String title) {
if (title.length() <= MAX_TITLE_LENGTH) {
return title;
}
return title.substring(0, MAX_TITLE_LENGTH - 3) + "...";
}
private String formatDescription(Event event) {
ZonedDateTime zoned = event.getDateTime().atZoneSameInstant(event.getTimezone());
var sb = new StringBuilder();
sb.append("📅 ").append(zoned.format(DATE_FORMAT));
if (event.getLocation() != null && !event.getLocation().isBlank()) {
sb.append(" · 📍 ").append(event.getLocation());
}
if (event.getDescription() != null && !event.getDescription().isBlank()) {
sb.append("").append(event.getDescription());
}
String result = sb.toString();
if (result.length() > MAX_DESCRIPTION_LENGTH) {
return result.substring(0, MAX_DESCRIPTION_LENGTH - 3) + "...";
}
return result;
}
// --- HTML rendering ---
private String renderTags(Map<String, String> tags) {
var sb = new StringBuilder();
for (var entry : tags.entrySet()) {
String key = entry.getKey();
String value = escapeHtml(entry.getValue());
String attr = key.startsWith("twitter:") ? "name" : "property";
sb.append("<meta ").append(attr).append("=\"").append(key)
.append("\" content=\"").append(value).append("\">\n");
}
return sb.toString().stripTrailing();
}
private String escapeHtml(String input) {
return input
.replace("&", "&amp;")
.replace("\"", "&quot;")
.replace("<", "&lt;")
.replace(">", "&gt;");
}
private String getBaseUrl(HttpServletRequest request) {
return ServletUriComponentsBuilder.fromRequestUri(request)
.replacePath("")
.build()
.toUriString();
}
}

View File

@@ -1,21 +1,17 @@
package de.fete.config;
import java.io.IOException;
import java.time.Clock;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.io.ClassPathResource;
import org.springframework.core.io.Resource;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.servlet.config.annotation.PathMatchConfigurer;
import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
import org.springframework.web.servlet.resource.PathResourceResolver;
/** Configures API path prefix and SPA static resource serving. */
/** Configures API path prefix. Static resources served by default Spring Boot handler. */
@Configuration
public class WebConfig implements WebMvcConfigurer {
/** Provides a system clock bean for time-dependent services. */
@Bean
Clock clock() {
return Clock.systemDefaultZone();
@@ -25,23 +21,4 @@ public class WebConfig implements WebMvcConfigurer {
public void configurePathMatch(PathMatchConfigurer configurer) {
configurer.addPathPrefix("/api", c -> c.isAnnotationPresent(RestController.class));
}
@Override
public void addResourceHandlers(ResourceHandlerRegistry registry) {
registry.addResourceHandler("/**")
.addResourceLocations("classpath:/static/")
.resourceChain(true)
.addResolver(new PathResourceResolver() {
@Override
protected Resource getResource(String resourcePath,
Resource location) throws IOException {
Resource requested = location.createRelative(resourcePath);
if (requested.exists() && requested.isReadable()) {
return requested;
}
Resource index = new ClassPathResource("/static/index.html");
return (index.exists() && index.isReadable()) ? index : null;
}
});
}
}

View File

@@ -7,6 +7,9 @@ spring.jpa.open-in-view=false
# Liquibase
spring.liquibase.change-log=classpath:db/changelog/db.changelog-master.xml
# Proxy headers
server.forward-headers-strategy=framework
# Actuator
management.endpoints.web.exposure.include=health
management.endpoint.health.show-details=never

View File

@@ -0,0 +1,281 @@
package de.fete.adapter.in.web;
import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import de.fete.TestcontainersConfig;
import de.fete.adapter.out.persistence.EventJpaEntity;
import de.fete.adapter.out.persistence.EventJpaRepository;
import java.time.LocalDate;
import java.time.OffsetDateTime;
import java.time.ZoneOffset;
import java.util.UUID;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.annotation.Import;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
@SpringBootTest
@AutoConfigureMockMvc
@Import(TestcontainersConfig.class)
class SpaControllerTest {
@Autowired
private MockMvc mockMvc;
@Autowired
private EventJpaRepository eventJpaRepository;
// --- Phase 2: Base functionality ---
@Test
void rootServesHtml() throws Exception {
mockMvc.perform(get("/").accept(MediaType.TEXT_HTML))
.andExpect(status().isOk())
.andExpect(content().contentTypeCompatibleWith(MediaType.TEXT_HTML));
}
@Test
void rootHtmlDoesNotContainPlaceholder() throws Exception {
String html = mockMvc.perform(get("/").accept(MediaType.TEXT_HTML))
.andExpect(status().isOk())
.andReturn().getResponse().getContentAsString();
assertThat(html).doesNotContain("<!-- OG_META_TAGS -->");
}
@Test
void createRouteServesHtml() throws Exception {
mockMvc.perform(get("/create").accept(MediaType.TEXT_HTML))
.andExpect(status().isOk())
.andExpect(content().contentTypeCompatibleWith(MediaType.TEXT_HTML));
}
@Test
void eventsRouteServesHtml() throws Exception {
mockMvc.perform(get("/events").accept(MediaType.TEXT_HTML))
.andExpect(status().isOk())
.andExpect(content().contentTypeCompatibleWith(MediaType.TEXT_HTML));
}
// --- Phase 4 (US2): Generic OG meta-tags ---
@Test
void rootContainsGenericOgTitle() throws Exception {
String html = mockMvc.perform(get("/").accept(MediaType.TEXT_HTML))
.andExpect(status().isOk())
.andReturn().getResponse().getContentAsString();
assertThat(html).contains("og:title");
assertThat(html).contains("content=\"fete\"");
}
@Test
void createRouteContainsGenericOgDescription() throws Exception {
String html = mockMvc.perform(get("/create").accept(MediaType.TEXT_HTML))
.andExpect(status().isOk())
.andReturn().getResponse().getContentAsString();
assertThat(html).contains("og:description");
assertThat(html).contains("Privacy-focused event planning");
}
@Test
void unknownRouteReturns404() throws Exception {
mockMvc.perform(get("/unknown/path").accept(MediaType.TEXT_HTML))
.andExpect(status().isNotFound());
}
// --- Phase 5 (US3): Twitter Card meta-tags ---
@Test
void eventRouteContainsTwitterCardTags() throws Exception {
EventJpaEntity event = seedEvent(
"Twitter Test", "Testing cards",
"Europe/Berlin", "Berlin", LocalDate.now().plusDays(30));
String html = mockMvc.perform(
get("/events/" + event.getEventToken()).accept(MediaType.TEXT_HTML))
.andExpect(status().isOk())
.andReturn().getResponse().getContentAsString();
assertThat(html).contains("twitter:card");
assertThat(html).contains("twitter:title");
assertThat(html).contains("twitter:description");
}
@Test
void genericRouteContainsTwitterCardTags() throws Exception {
String html = mockMvc.perform(get("/").accept(MediaType.TEXT_HTML))
.andExpect(status().isOk())
.andReturn().getResponse().getContentAsString();
assertThat(html).contains("twitter:card");
assertThat(html).contains("content=\"summary\"");
}
// --- Phase 3 (US1): Event-specific OG meta-tags ---
@Test
void eventRouteContainsEventSpecificOgTitle() throws Exception {
EventJpaEntity event = seedEvent(
"Birthday Party", "Come celebrate!",
"Europe/Berlin", "Berlin", LocalDate.now().plusDays(30));
String html = mockMvc.perform(
get("/events/" + event.getEventToken()).accept(MediaType.TEXT_HTML))
.andExpect(status().isOk())
.andReturn().getResponse().getContentAsString();
assertThat(html).contains("og:title");
assertThat(html).contains("Birthday Party");
}
@Test
void eventRouteContainsOgDescription() throws Exception {
EventJpaEntity event = seedEvent(
"BBQ", "Bring drinks!",
"Europe/Berlin", "Central Park", LocalDate.now().plusDays(30));
String html = mockMvc.perform(
get("/events/" + event.getEventToken()).accept(MediaType.TEXT_HTML))
.andExpect(status().isOk())
.andReturn().getResponse().getContentAsString();
assertThat(html).contains("og:description");
assertThat(html).contains("Central Park");
assertThat(html).contains("Bring drinks!");
}
@Test
void eventRouteContainsOgUrl() throws Exception {
EventJpaEntity event = seedEvent(
"Party", null,
"Europe/Berlin", null, LocalDate.now().plusDays(30));
String html = mockMvc.perform(
get("/events/" + event.getEventToken()).accept(MediaType.TEXT_HTML))
.andExpect(status().isOk())
.andReturn().getResponse().getContentAsString();
assertThat(html).contains("og:url");
assertThat(html).contains("/events/" + event.getEventToken());
}
@Test
void eventRouteContainsOgImage() throws Exception {
EventJpaEntity event = seedEvent(
"Party", null,
"Europe/Berlin", null, LocalDate.now().plusDays(30));
String html = mockMvc.perform(
get("/events/" + event.getEventToken()).accept(MediaType.TEXT_HTML))
.andExpect(status().isOk())
.andReturn().getResponse().getContentAsString();
assertThat(html).contains("og:image");
assertThat(html).contains("/og-image.png");
}
@Test
void unknownEventTokenFallsBackToGenericMeta() throws Exception {
String html = mockMvc.perform(
get("/events/" + UUID.randomUUID()).accept(MediaType.TEXT_HTML))
.andExpect(status().isOk())
.andReturn().getResponse().getContentAsString();
assertThat(html).contains("og:title");
assertThat(html).contains("content=\"fete\"");
}
// --- HTML escaping ---
@Test
void specialCharactersAreHtmlEscaped() throws Exception {
EventJpaEntity event = seedEvent(
"Tom & Jerry's \"Party\"", "Fun <times> & more",
"Europe/Berlin", "O'Brien's Pub", LocalDate.now().plusDays(30));
String html = mockMvc.perform(
get("/events/" + event.getEventToken()).accept(MediaType.TEXT_HTML))
.andExpect(status().isOk())
.andReturn().getResponse().getContentAsString();
assertThat(html).contains("Tom &amp; Jerry");
assertThat(html).contains("&amp; more");
assertThat(html).contains("&lt;times&gt;");
assertThat(html).doesNotContain("content=\"Tom & Jerry");
}
// --- Title truncation ---
@Test
void longTitleIsTruncatedTo70Chars() throws Exception {
String longTitle = "A".repeat(80);
EventJpaEntity event = seedEvent(
longTitle, "Desc",
"Europe/Berlin", null, LocalDate.now().plusDays(30));
String html = mockMvc.perform(
get("/events/" + event.getEventToken()).accept(MediaType.TEXT_HTML))
.andExpect(status().isOk())
.andReturn().getResponse().getContentAsString();
assertThat(html).contains("A".repeat(67) + "...");
assertThat(html).doesNotContain("A".repeat(68));
}
// --- Description formatting ---
@Test
void eventWithoutLocationOmitsPinEmoji() throws Exception {
EventJpaEntity event = seedEvent(
"Online Meetup", "Virtual gathering",
"Europe/Berlin", null, LocalDate.now().plusDays(30));
String html = mockMvc.perform(
get("/events/" + event.getEventToken()).accept(MediaType.TEXT_HTML))
.andExpect(status().isOk())
.andReturn().getResponse().getContentAsString();
assertThat(html).doesNotContain("📍");
}
@Test
void eventWithoutDescriptionOmitsDash() throws Exception {
EventJpaEntity event = seedEvent(
"Silent Event", null,
"Europe/Berlin", "Berlin", LocalDate.now().plusDays(30));
String html = mockMvc.perform(
get("/events/" + event.getEventToken()).accept(MediaType.TEXT_HTML))
.andExpect(status().isOk())
.andReturn().getResponse().getContentAsString();
assertThat(html).contains("📅");
assertThat(html).contains("Berlin");
assertThat(html).doesNotContain("");
}
private EventJpaEntity seedEvent(
String title, String description, String timezone,
String location, LocalDate expiryDate) {
var entity = new EventJpaEntity();
entity.setEventToken(UUID.randomUUID());
entity.setOrganizerToken(UUID.randomUUID());
entity.setTitle(title);
entity.setDescription(description);
entity.setDateTime(
OffsetDateTime.of(2026, 6, 15, 20, 0, 0, 0, ZoneOffset.ofHours(2)));
entity.setTimezone(timezone);
entity.setLocation(location);
entity.setExpiryDate(expiryDate);
entity.setCreatedAt(OffsetDateTime.now());
return eventJpaRepository.save(entity);
}
}

View File

@@ -29,8 +29,10 @@ class WebConfigTest {
@Test
void apiPrefixNotAccessibleWithoutIt() throws Exception {
// /events without /api prefix should not resolve to the API endpoint
mockMvc.perform(get("/events"))
.andExpect(status().isNotFound());
// /events without /api prefix should not resolve to the REST API endpoint;
// it is served by SpaController as HTML instead
mockMvc.perform(get("/events")
.accept("text/html"))
.andExpect(status().isOk());
}
}

View File

@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
<!-- OG_META_TAGS -->
<title>fete</title>
</head>
<body>
<div id="app"></div>
</body>
</html>