Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions frontend/src/lib/validation/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,13 @@

import { z } from "zod/v4";

// The deployed CSP (`app.content-security-policy`) has no 'unsafe-eval', so
// Zod's JIT fast path can never activate; without `jitless` its cached
// `allowsEval` probe calls `new Function("")` once per page load, which the
// browser reports as a `securitypolicyviolation` even though Zod swallows the
// throw (zod/v4/core/util.cjs `allowsEval`).
z.config({ jitless: true });

// =============================================================================
// SSE Stream Event Schemas
// =============================================================================
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,14 @@
import org.springframework.stereotype.Component;

/**
* Injects or removes Clicky analytics script tags from server-rendered HTML documents.
* Injects or removes the Clicky analytics loader in server-rendered HTML documents.
*
* <p>When Clicky analytics is enabled, this component appends the site-ID initializer
* and the async script loader to the document {@code <head>}. When disabled, it strips
* any existing Clicky tags to prevent double-injection from cached templates.
* <p>The site ID rides on the loader tag's {@code data-id} attribute instead of an
* inline initializer script: the deployed CSP restricts {@code script-src} to
* {@code 'self'} plus allowlisted origins, so any inline script is blocked and would
* leave Clicky without a site ID. Clicky's loader reads the attribute itself via
* {@code document.currentScript.getAttribute("data-id")} and pushes it into
* {@code clicky_site_ids} (verified against https://static.getclicky.com/js).
*
* <p>Owns all Clicky-specific DOM mutations so that controllers remain free of
* analytics concerns.
Expand All @@ -20,8 +23,7 @@
public class ClickyAnalyticsInjector {

private static final String CLICKY_SCRIPT_URL = "https://static.getclicky.com/js";
private static final String CLICKY_INITIALIZER_TEMPLATE =
"var clicky_site_ids = clicky_site_ids || []; clicky_site_ids.push(%d);";
private static final String CLICKY_SITE_ID_ATTRIBUTE = "data-id";

private final boolean clickyEnabled;
private final long clickySiteId;
Expand All @@ -37,34 +39,38 @@ public ClickyAnalyticsInjector(AppProperties appProperties) {
}

/**
* Applies Clicky analytics to the document: injects tags when enabled, removes them when disabled.
* Applies Clicky analytics to the document: ensures a CSP-safe loader tag when enabled,
* removes all Clicky tags when disabled. Legacy inline {@code clicky_site_ids}
* initializers are stripped unconditionally because the CSP blocks them either way.
*
* @param document the Jsoup document whose {@code <head>} will be modified in place
*/
public void applyTo(Document document) {
removeLegacyInitializers(document);
Element existingClickyLoader = document.head().selectFirst("script[src=\"" + CLICKY_SCRIPT_URL + "\"]");

if (!clickyEnabled) {
removeClickyTags(document, existingClickyLoader);
if (existingClickyLoader != null) {
existingClickyLoader.remove();
}
return;
}

if (existingClickyLoader != null) {
existingClickyLoader.attr(CLICKY_SITE_ID_ATTRIBUTE, Long.toString(clickySiteId));
return;
}

String initializer = String.format(CLICKY_INITIALIZER_TEMPLATE, clickySiteId);
document.head().appendElement("script").text(initializer);
document.head().appendElement("script").attr("async", "").attr("src", CLICKY_SCRIPT_URL);
document.head()
.appendElement("script")
.attr("async", "")
.attr(CLICKY_SITE_ID_ATTRIBUTE, Long.toString(clickySiteId))
.attr("src", CLICKY_SCRIPT_URL);
}

private void removeClickyTags(Document document, Element existingLoader) {
if (existingLoader != null) {
existingLoader.remove();
}
private void removeLegacyInitializers(Document document) {
document.head().select("script").forEach(scriptTag -> {
String scriptBody = scriptTag.html();
if (scriptBody != null && scriptBody.contains("clicky_site_ids")) {
if (scriptTag.html().contains("clicky_site_ids")) {
scriptTag.remove();
}
});
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
package com.williamcallahan.javachat.web;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
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;
Expand Down Expand Up @@ -95,6 +97,24 @@ void serves_contact_with_specific_metadata() throws Exception {
assertMetaContent(htmlDocument, "property", "og:url", "https://example.com/contact");
}

/**
* The deployed CSP blocks inline scripts, so the Clicky site ID must travel on the
* loader tag's {@code data-id} attribute — never as an inline initializer script.
*/
@Test
void injects_clicky_loader_without_inline_initializer() throws Exception {
Document htmlDocument = loadSeoDocument("/");

Element clickyLoader = htmlDocument.head().selectFirst("script[src=\"https://static.getclicky.com/js\"]");
assertNotNull(clickyLoader, "Missing Clicky loader script tag");
assertEquals("101501246", clickyLoader.attr("data-id"));
assertTrue(clickyLoader.hasAttr("async"), "Clicky loader must load asynchronously");

boolean inlineInitializerPresent = htmlDocument.head().select("script").stream()
.anyMatch(scriptTag -> scriptTag.html().contains("clicky_site_ids"));
assertFalse(inlineInitializerPresent, "Inline Clicky initializer violates the CSP");
}

private Document loadSeoDocument(String path) throws Exception {
MvcResult mvcOutcome = mvc.perform(get(path)).andExpect(status().isOk()).andReturn();
return Jsoup.parse(mvcOutcome.getResponse().getContentAsString());
Expand Down
Loading