From 6a66776392f334ddd51da21cd96abb7cd6b8422f Mon Sep 17 00:00:00 2001 From: Nicolas MASSE Date: Sun, 4 Oct 2026 11:32:31 +0200 Subject: [PATCH] add security and cache headers - Content-Security-Policy without inline script: the search page now embeds its data in a JSON data block, used by /js/search.js - X-Content-Type-Options, X-Frame-Options, Referrer-Policy - cache the fingerprinted CSS forever and the fonts for 30 days HSTS is already set by Netlify. Refs #11 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01YNHsWYTXPThURz6TbVS1j3 --- layouts/search.html | 222 +++++++------------------------------------- netlify.toml | 34 +++++++ static/js/search.js | 167 +++++++++++++++++++++++++++++++++ 3 files changed, 234 insertions(+), 189 deletions(-) create mode 100644 static/js/search.js diff --git a/layouts/search.html b/layouts/search.html index 723faf9..e1e1855 100644 --- a/layouts/search.html +++ b/layouts/search.html @@ -18,197 +18,41 @@ {{- end }} - + {{ .Store.Set "en_article_in_fr_site" false }} + {{- partial "lang-detection.html" . -}} + {{- $.Store.Add "index" (dict "title" .Title "opensource" .Params.opensource "topics" .Params.topics "permalink" .Permalink "hasEnFlag" (.Store.Get "en_article_in_fr_site") "type" .Type "type_label" (i18n (print "type_" .Type)) "date" ($.Store.Get "date") "reading_time" ($.Store.Get "reading_time") "summary" (.Summary | plainify | htmlUnescape)) -}} + {{- end }} + {{- $data := dict "taxonomies" ($.Store.Get "taxonomies") "index" ($.Store.Get "index") "i18n" (dict "published_on" (i18n "published_on") "continue_reading" (i18n "continue_reading")) }} + + {{ end }} diff --git a/netlify.toml b/netlify.toml index e61353f..e7c7052 100644 --- a/netlify.toml +++ b/netlify.toml @@ -16,6 +16,40 @@ command = "hugo --buildDrafts --buildFuture --baseURL $DEPLOY_PRIME_URL" [context.deploy-preview.environment] HUGO_ENABLEGITINFO = "true" +# Security headers. +# +# HSTS is already set by Netlify (max-age=31536000). It does not include +# the subdomains of itix.fr on purpose. +# +# Content Security Policy: +# - no inline script (the search page uses a JSON data block + /js/search.js) +# - inline styles are allowed (style attributes generated by Hugo) +# - YouTube videos are embedded through iframes +# - if Plausible is enabled again, add https://plausible.io to script-src +# and connect-src +[[headers]] + for = "/*" + + [headers.values] + Content-Security-Policy = "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self'; font-src 'self'; media-src 'self'; frame-src https://www.youtube.com https://www.youtube-nocookie.com; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'; upgrade-insecure-requests" + X-Content-Type-Options = "nosniff" + X-Frame-Options = "DENY" + Referrer-Policy = "strict-origin-when-cross-origin" + +# The CSS file name contains a fingerprint of its content: it can be cached forever. +[[headers]] + for = "/css/*" + + [headers.values] + Cache-Control = "public, max-age=31536000, immutable" + +# The fonts are not fingerprinted but they almost never change. +[[headers]] + for = "/fonts/*" + + [headers.values] + Cache-Control = "public, max-age=2592000" + # Matrix homeserver discovery [[headers]] for = "/.well-known/matrix/*" diff --git a/static/js/search.js b/static/js/search.js new file mode 100644 index 0000000..3f706cc --- /dev/null +++ b/static/js/search.js @@ -0,0 +1,167 @@ +// +// Search engine of the "search" page. +// +// The search index, the taxonomies and the translated strings are generated +// by Hugo in a JSON data block (see layouts/search.html), so that no inline +// script is needed (Content Security Policy). +// +var searchData = JSON.parse(document.getElementById('search-data').textContent); +var taxonomies = searchData.taxonomies; +var searchDb = searchData.index; + +var searchResults = document.getElementById('searchResults'); // targets the
above +var searchInput = document.getElementById('searchInput'); // input box for search +var lastSearch = ""; + +// Helper function to create an element with attributes +function tag(name, attrs) { + var el = document.createElement(name.toString()); + + !!attrs && Object.keys(attrs).forEach(function(key) { + el.setAttribute(key, attrs[key]); + }); + + return el; +} + +function printSearchResult(result) { + var section = tag("section"); + + // + // Article headline + // + var a = tag("a", { href: result.item.permalink }); + a.textContent = result.item.title; + if (result.item.hasEnFlag) { + var uk_flag_svg = document.getElementById("embedded-svg-en"); + if (uk_flag_svg != null) { + var img = uk_flag_svg.firstChild.cloneNode(true); + a.appendChild(img); + } + } + var h4 = tag("h4"); + h4.textContent = result.item.type_label + " >> "; + h4.appendChild(a); + section.appendChild(h4); + + // + // Metadata + // + if (result.item.type != "page") { + var div = tag("div", { 'class': 'post-metadata'}); + var span_date = tag("span", { 'class': 'publication-date' }); + span_date.textContent = searchData.i18n.published_on + " " + result.item.date; + div.appendChild(span_date); + if (result.item.topics != null) { + result.item.topics.forEach(function (v) { + var topic_detail = taxonomies.topics[v]; + if (topic_detail == null) { + return; + } + var span = tag("span", { 'class': 'topic' }); + var link_svg = document.getElementById("embedded-svg-link"); + if (link_svg != null) { + var img = link_svg.firstChild.cloneNode(true); + span.appendChild(img); + } + var a = tag("a", { href: topic_detail.href }); + a.textContent = topic_detail.title; + span.appendChild(a); + div.appendChild(span); + }); + } + if (result.item.opensource != null) { + result.item.opensource.forEach(function (v) { + var opensource_detail = taxonomies.opensource[v]; + if (opensource_detail == null) { + return; + } + var span = tag("span", { 'class': 'opensource' }); + var label_svg = document.getElementById("embedded-svg-label"); + if (label_svg != null) { + var img = label_svg.firstChild.cloneNode(true); + span.appendChild(img); + } + var a = tag("a", { href: opensource_detail.href }); + a.textContent = opensource_detail.title; + span.appendChild(a); + div.appendChild(span); + }); + } + if (result.item.reading_time != null) { + var span = tag("span", { 'class': 'reading-time' }); + var schedule_svg = document.getElementById("embedded-svg-schedule"); + if (schedule_svg != null) { + var img = schedule_svg.firstChild.cloneNode(true); + span.appendChild(img); + } + var readingTime = document.createTextNode(result.item.reading_time); + span.appendChild(readingTime); + div.appendChild(span); + } + section.appendChild(div); + } + + var p = tag("p", {}); + p.textContent = result.item.summary; + var a = tag("a", { href: result.item.permalink, 'class': 'continue-reading' }); + a.textContent = searchData.i18n.continue_reading; + p.appendChild(a); + section.appendChild(p); + + searchResults.appendChild(section); +} + +function clearSearchResults() { + var child = searchResults.lastElementChild; + while (child) { + searchResults.removeChild(child); + child = searchResults.lastElementChild; + } +} + +// +// load the search index +// +function fuseInit(searchDb) { + var options = { // fuse.js options; check fuse.js website for details + shouldSort: true, + ignoreLocation: true, + threshold: 0.4, + minMatchCharLength: 3, + keys: [ + 'title', + 'permalink', + 'summary', + 'topics', + 'opensource' + ] + }; + fuse = new Fuse(searchDb, options); // build the index from the json file +} + +// +// using the index loaded previously, run a search query (for "term") +// every time a letter is typed in the search box +// +function executeSearch(term) { + if (lastSearch == term) { + // Don't query Fuse if the search terms have not changed + return; + } + + clearSearchResults(); + let results = fuse.search(term); + results.slice(0, 20).forEach(printSearchResult); + lastSearch = term; +} + +// Initialize the Fuse.io framework with the Search DB +fuseInit(searchDb); + +// +// execute search as each character is typed +// +searchInput.onkeyup = function(e) { + executeSearch(this.value); +}