Browse Source

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YNHsWYTXPThURz6TbVS1j3
master
Nicolas Massé 1 month ago
parent
commit
6a66776392
  1. 222
      layouts/search.html
  2. 34
      netlify.toml
  3. 167
      static/js/search.js

222
layouts/search.html

@ -18,197 +18,41 @@
</div> </div>
{{- end }} {{- end }}
</div> </div>
<script type="text/javascript"> {{- /*
var searchResults = document.getElementById('searchResults'); // targets the <div> above -- The search data are embedded in a JSON data block, used by /js/search.js
var searchInput = document.getElementById('searchInput'); // input box for search */ -}}
var lastSearch = ""; {{- /* Taxonomies */ -}}
{{- $.Store.Set "taxonomies" dict -}}
// Helper function to create an element with attributes {{- range $name, $taxonomy := .Site.Taxonomies -}}
function tag(name, attrs) { {{- $.Store.Set "taxonomy" dict -}}
var el = document.createElement(name.toString()); {{- range (index $.Site.Taxonomies $name) -}}
{{- $.Store.SetInMap "taxonomy" .Page.Title (dict "href" .Page.RelPermalink "title" .Page.Title) -}}
!!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 = {{ 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 = {{ 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;
}
// Fill-in details about taxonomies
{{- $.Store.Set "taxonomies" dict -}}
{{- range $name, $taxonomy := .Site.Taxonomies -}}
{{- $.Store.Set "taxonomy" dict -}}
{{- range (index $.Site.Taxonomies $name) -}}
{{- $.Store.SetInMap "taxonomy" .Page.Title (dict "href" .Page.RelPermalink "title" .Page.Title) -}}
{{- end -}}
{{- $.Store.SetInMap "taxonomies" $name ($.Store.Get "taxonomy") -}}
{{- end }}
var taxonomies = {{- $.Store.Get "taxonomies" | jsonify | safeJS -}};
// Fill-in the Search DB with site's content
{{- $.Store.Set "index" slice -}}
{{- range .Site.RegularPages -}}
{{- $.Store.Set "reading_time" nil -}}
{{- if eq .Type "blog" -}}
{{- $minutes := .ReadingTime -}}
{{- if gt $minutes 0 -}}
{{- $.Store.Set "reading_time" (i18n "minutes" $minutes) -}}
{{- end -}}
{{- end -}}
{{- $.Store.Set "date" nil -}}
{{- if ne .Type "page" -}}
{{- $.Store.Set "date" (.Date.Format (i18n "date_format")) -}}
{{- end -}} {{- end -}}
{{- $.Store.SetInMap "taxonomies" $name ($.Store.Get "taxonomy") -}}
{{- end }}
{{- /* Search index */ -}}
{{- $.Store.Set "index" slice -}}
{{- range .Site.RegularPages -}}
{{- $.Store.Set "reading_time" nil -}}
{{- if eq .Type "blog" -}}
{{- $minutes := .ReadingTime -}}
{{- if gt $minutes 0 -}}
{{- $.Store.Set "reading_time" (i18n "minutes" $minutes) -}}
{{- end -}}
{{- end -}}
{{ .Store.Set "en_article_in_fr_site" false }} {{- $.Store.Set "date" nil -}}
{{- partial "lang-detection.html" . -}} {{- if ne .Type "page" -}}
{{- $.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)) -}} {{- $.Store.Set "date" (.Date.Format (i18n "date_format")) -}}
{{- end }} {{- end -}}
var searchDb = {{- $.Store.Get "index" | jsonify | safeJS -}};
// 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);
}
</script> {{ .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")) }}
<script type="application/json" id="search-data">{{ $data | jsonify | safeJS }}</script>
<script src="/js/search.js"></script>
</article> </article>
{{ end }} {{ end }}

34
netlify.toml

@ -16,6 +16,40 @@ command = "hugo --buildDrafts --buildFuture --baseURL $DEPLOY_PRIME_URL"
[context.deploy-preview.environment] [context.deploy-preview.environment]
HUGO_ENABLEGITINFO = "true" 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 # Matrix homeserver discovery
[[headers]] [[headers]]
for = "/.well-known/matrix/*" for = "/.well-known/matrix/*"

167
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 <div> 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);
}
Loading…
Cancel
Save