about summary refs log tree commit diff stats
path: root/internal
diff options
context:
space:
mode:
authorAlan Pearce2024-06-13 20:51:49 +0200
committerAlan Pearce2024-06-13 20:52:16 +0200
commitf690e8cb7a820b0685b98f83a6761cfc169487e4 (patch)
tree3102375c3c3c9461776a79082f4db9898c1eee5e /internal
parent6b1697144e6261c09f1d2c254cf33d39f02ce92e (diff)
downloadwebsite-f690e8cb7a820b0685b98f83a6761cfc169487e4.tar.lz
website-f690e8cb7a820b0685b98f83a6761cfc169487e4.tar.zst
website-f690e8cb7a820b0685b98f83a6761cfc169487e4.zip
hash style elements during build step
Diffstat (limited to 'internal')
-rw-r--r--internal/builder/builder.go92
-rw-r--r--internal/builder/hasher.go12
-rw-r--r--internal/builder/template.go42
-rw-r--r--internal/server/server.go20
4 files changed, 120 insertions, 46 deletions
diff --git a/internal/builder/builder.go b/internal/builder/builder.go
index b17fbc2..bb6f40d 100644
--- a/internal/builder/builder.go
+++ b/internal/builder/builder.go
@@ -25,6 +25,10 @@ type IOConfig struct {
 	Development bool `conf:"default:false,flag:dev"`
 }
 
+type Result struct {
+	Hashes []string
+}
+
 func mkdirp(dirs ...string) error {
 	err := os.MkdirAll(path.Join(dirs...), 0755)
 
@@ -61,16 +65,19 @@ func writerToFile(writer io.WriterTo, filename ...string) error {
 	return nil
 }
 
-func build(outDir string, config config.Config) error {
+func build(outDir string, config config.Config) (*Result, error) {
 	log.Debug("output", "dir", outDir)
+	r := &Result{
+		Hashes: make([]string, 0),
+	}
 	assetsOnce = sync.Once{}
 	privateDir := path.Join(outDir, "private")
 	if err := mkdirp(privateDir); err != nil {
-		return errors.WithMessage(err, "could not create private directory")
+		return nil, errors.WithMessage(err, "could not create private directory")
 	}
 	publicDir := path.Join(outDir, "public")
 	if err := mkdirp(publicDir); err != nil {
-		return errors.WithMessage(err, "could not create public directory")
+		return nil, errors.WithMessage(err, "could not create public directory")
 	}
 
 	err := cp.Copy("static", publicDir, cp.Options{
@@ -78,16 +85,16 @@ func build(outDir string, config config.Config) error {
 		PermissionControl: cp.AddPermission(0755),
 	})
 	if err != nil {
-		return errors.WithMessage(err, "could not copy static files")
+		return nil, errors.WithMessage(err, "could not copy static files")
 	}
 
 	if err := mkdirp(publicDir, "post"); err != nil {
-		return errors.WithMessage(err, "could not create post output directory")
+		return nil, errors.WithMessage(err, "could not create post output directory")
 	}
 	log.Debug("reading posts")
 	posts, tags, err := readPosts("content", "post", publicDir)
 	if err != nil {
-		return err
+		return nil, err
 	}
 
 	sm := NewSitemap(config)
@@ -98,7 +105,7 @@ func build(outDir string, config config.Config) error {
 
 	for _, post := range posts {
 		if err := mkdirp(publicDir, "post", post.Basename); err != nil {
-			return errors.WithMessage(err, "could not create directory for post")
+			return nil, errors.WithMessage(err, "could not create directory for post")
 		}
 		log.Debug("rendering post", "post", post.Basename)
 		sm.Add(&sitemap.URL{
@@ -107,23 +114,23 @@ func build(outDir string, config config.Config) error {
 		})
 		output, err := renderPost(post, config)
 		if err != nil {
-			return errors.WithMessagef(err, "could not render post %s", post.Input)
+			return nil, errors.WithMessagef(err, "could not render post %s", post.Input)
 		}
 		if err := outputToFile(output, post.Output); err != nil {
-			return err
+			return nil, err
 		}
 	}
 
 	if err := mkdirp(publicDir, "tags"); err != nil {
-		return errors.WithMessage(err, "could not create directory for tags")
+		return nil, errors.WithMessage(err, "could not create directory for tags")
 	}
 	log.Debug("rendering tags list")
 	output, err := renderTags(tags, config, "/tags")
 	if err != nil {
-		return errors.WithMessage(err, "could not render tags")
+		return nil, errors.WithMessage(err, "could not render tags")
 	}
 	if err := outputToFile(output, publicDir, "tags", "index.html"); err != nil {
-		return err
+		return nil, err
 	}
 	sm.Add(&sitemap.URL{
 		Loc:     "/tags/",
@@ -138,16 +145,16 @@ func build(outDir string, config config.Config) error {
 			}
 		}
 		if err := mkdirp(publicDir, "tags", tag); err != nil {
-			return errors.WithMessage(err, "could not create directory")
+			return nil, errors.WithMessage(err, "could not create directory")
 		}
 		log.Debug("rendering tags page", "tag", tag)
 		url := "/tags/" + tag
 		output, err := renderListPage(tag, config, matchingPosts, url)
 		if err != nil {
-			return errors.WithMessage(err, "could not render tag page")
+			return nil, errors.WithMessage(err, "could not render tag page")
 		}
 		if err := outputToFile(output, publicDir, "tags", tag, "index.html"); err != nil {
-			return err
+			return nil, err
 		}
 		sm.Add(&sitemap.URL{
 			Loc:     url,
@@ -155,27 +162,27 @@ func build(outDir string, config config.Config) error {
 		})
 
 		log.Debug("rendering tags feed", "tag", tag)
-		output, err = renderFeed(
+		feed, err := renderFeed(
 			fmt.Sprintf("%s - %s", config.Title, tag),
 			config,
 			matchingPosts,
 			tag,
 		)
 		if err != nil {
-			return errors.WithMessage(err, "could not render tag feed page")
+			return nil, errors.WithMessage(err, "could not render tag feed page")
 		}
-		if err := outputToFile(output, publicDir, "tags", tag, "atom.xml"); err != nil {
-			return err
+		if err := outputToFile(feed, publicDir, "tags", tag, "atom.xml"); err != nil {
+			return nil, err
 		}
 	}
 
 	log.Debug("rendering list page")
 	listPage, err := renderListPage("", config, posts, "/post")
 	if err != nil {
-		return errors.WithMessage(err, "could not render list page")
+		return nil, errors.WithMessage(err, "could not render list page")
 	}
 	if err := outputToFile(listPage, publicDir, "post", "index.html"); err != nil {
-		return err
+		return nil, err
 	}
 	sm.Add(&sitemap.URL{
 		Loc:     "/post/",
@@ -185,28 +192,37 @@ func build(outDir string, config config.Config) error {
 	log.Debug("rendering feed")
 	feed, err := renderFeed(config.Title, config, posts, "feed")
 	if err != nil {
-		return errors.WithMessage(err, "could not render feed")
+		return nil, errors.WithMessage(err, "could not render feed")
 	}
 	if err := outputToFile(feed, publicDir, "atom.xml"); err != nil {
-		return err
+		return nil, err
 	}
 
 	log.Debug("rendering feed styles")
 	feedStyles, err := renderFeedStyles()
 	if err != nil {
-		return errors.WithMessage(err, "could not render feed styles")
+		return nil, errors.WithMessage(err, "could not render feed styles")
 	}
 	if err := outputToFile(feedStyles, publicDir, "feed-styles.xsl"); err != nil {
-		return err
+		return nil, err
+	}
+	_, err = feedStyles.Seek(0, 0)
+	if err != nil {
+		return nil, err
+	}
+	h, err := getFeedStylesHash(feedStyles)
+	if err != nil {
+		return nil, err
 	}
+	r.Hashes = append(r.Hashes, h)
 
 	log.Debug("rendering homepage")
 	homePage, err := renderHomepage(config, posts, "/")
 	if err != nil {
-		return errors.WithMessage(err, "could not render homepage")
+		return nil, errors.WithMessage(err, "could not render homepage")
 	}
 	if err := outputToFile(homePage, publicDir, "index.html"); err != nil {
-		return err
+		return nil, err
 	}
 	// it would be nice to set LastMod here, but using the latest post
 	// date would be wrong as the homepage has its own content file
@@ -214,51 +230,53 @@ func build(outDir string, config config.Config) error {
 	sm.Add(&sitemap.URL{
 		Loc: "/",
 	})
+	h, err = getHTMLStyleHash(publicDir, "index.html")
+	r.Hashes = append(r.Hashes, h)
 
 	log.Debug("rendering 404 page")
 	notFound, err := render404(config, "/404.html")
 	if err != nil {
-		return errors.WithMessage(err, "could not render 404 page")
+		return nil, errors.WithMessage(err, "could not render 404 page")
 	}
 	if err := outputToFile(notFound, publicDir, "404.html"); err != nil {
-		return err
+		return nil, err
 	}
 
 	log.Debug("rendering sitemap")
 	if err := writerToFile(sm, publicDir, "sitemap.xml"); err != nil {
-		return err
+		return nil, err
 	}
 
 	log.Debug("rendering robots.txt")
 	rob, err := renderRobotsTXT(config)
 	if err != nil {
-		return err
+		return nil, err
 	}
 	if err := outputToFile(rob, publicDir, "robots.txt"); err != nil {
-		return err
+		return nil, err
 	}
 
-	return nil
+	return r, nil
 }
 
-func BuildSite(ioConfig IOConfig) error {
+func BuildSite(ioConfig IOConfig) (*Result, error) {
 	config, err := config.GetConfig()
 	if err != nil {
-		return errors.WithMessage(err, "could not get config")
+		return nil, errors.WithMessage(err, "could not get config")
 	}
 	config.InjectLiveReload = ioConfig.Development
 
 	if ioConfig.BaseURL.URL != nil {
 		config.BaseURL.URL, err = url.Parse(ioConfig.BaseURL.String())
 		if err != nil {
-			return errors.WithMessage(err, "could not re-parse base URL")
+			return nil, errors.WithMessage(err, "could not re-parse base URL")
 		}
 	}
 
 	if ioConfig.Development && ioConfig.Destination != "website" {
 		err = os.RemoveAll(ioConfig.Destination)
 		if err != nil {
-			return errors.WithMessage(err, "could not remove destination directory")
+			return nil, errors.WithMessage(err, "could not remove destination directory")
 		}
 	}
 
diff --git a/internal/builder/hasher.go b/internal/builder/hasher.go
new file mode 100644
index 0000000..dbc29f8
--- /dev/null
+++ b/internal/builder/hasher.go
@@ -0,0 +1,12 @@
+package builder
+
+import (
+	"crypto/sha256"
+	"encoding/base64"
+)
+
+func hash(s string) string {
+	shasum := sha256.New()
+	shasum.Write([]byte(s))
+	return "sha256-" + base64.StdEncoding.EncodeToString(shasum.Sum(nil))
+}
diff --git a/internal/builder/template.go b/internal/builder/template.go
index ab36c85..bc31ad1 100644
--- a/internal/builder/template.go
+++ b/internal/builder/template.go
@@ -6,6 +6,7 @@ import (
 	"io"
 	"net/url"
 	"os"
+	"path/filepath"
 	"strings"
 	"sync"
 	"text/template"
@@ -29,6 +30,11 @@ var (
 	countHTML      *goquery.Document
 	liveReloadHTML *goquery.Document
 	templates      = make(map[string]*os.File)
+	nsMap          = map[string]string{
+		"xsl":   "http://www.w3.org/1999/XSL/Transform",
+		"atom":  "http://www.w3.org/2005/Atom",
+		"xhtml": "http://www.w3.org/1999/xhtml",
+	}
 )
 
 func loadTemplate(path string) (file *os.File, err error) {
@@ -391,7 +397,7 @@ func renderFeed(
 	return strings.NewReader(doc.OutputXML(true)), nil
 }
 
-func renderFeedStyles() (io.Reader, error) {
+func renderFeedStyles() (*strings.Reader, error) {
 	reader, err := loadTemplate("templates/feed-styles.xsl")
 	if err != nil {
 		return nil, err
@@ -402,11 +408,6 @@ func renderFeedStyles() (io.Reader, error) {
 			panic("could not reset reader: " + err.Error())
 		}
 	}()
-	nsMap := map[string]string{
-		"xsl":   "http://www.w3.org/1999/XSL/Transform",
-		"atom":  "http://www.w3.org/2005/Atom",
-		"xhtml": "http://www.w3.org/1999/xhtml",
-	}
 	doc, err := xmlquery.Parse(reader)
 	if err != nil {
 		return nil, errors.Wrap(err, "could not parse XML")
@@ -424,6 +425,35 @@ func renderFeedStyles() (io.Reader, error) {
 	return strings.NewReader(doc.OutputXML(true)), nil
 }
 
+func getFeedStylesHash(r *strings.Reader) (string, error) {
+	doc, err := xmlquery.Parse(r)
+	if err != nil {
+		return "", err
+	}
+	expr, err := xpath.CompileWithNS("//xhtml:style", nsMap)
+	if err != nil {
+		return "", errors.Wrap(err, "could not parse XPath")
+	}
+	style := xmlquery.QuerySelector(doc, expr)
+
+	return hash(style.InnerText()), nil
+}
+
+func getHTMLStyleHash(filenames ...string) (string, error) {
+	fn := filepath.Join(filenames...)
+	f, err := os.Open(fn)
+	if err != nil {
+		return "", err
+	}
+	defer f.Close()
+	doc, err := NewDocumentFromReader(f)
+	if err != nil {
+		return "", err
+	}
+	html := doc.Find("head > style").Text()
+	return hash(html), nil
+}
+
 func renderHTML(doc *goquery.Document) io.Reader {
 	r, w := io.Pipe()
 
diff --git a/internal/server/server.go b/internal/server/server.go
index 77905f8..d2939ca 100644
--- a/internal/server/server.go
+++ b/internal/server/server.go
@@ -39,7 +39,6 @@ type Server struct {
 }
 
 func applyDevModeOverrides(config *cfg.Config, listenAddress string) {
-	config.CSP.StyleSrc = slices.Insert(config.CSP.StyleSrc, 0, "'unsafe-inline'")
 	config.CSP.ScriptSrc = slices.Insert(config.CSP.ScriptSrc, 0, "'unsafe-inline'")
 	config.CSP.ConnectSrc = slices.Insert(config.CSP.ConnectSrc, 0, "'self'")
 	config.BaseURL = cfg.URL{
@@ -50,6 +49,13 @@ func applyDevModeOverrides(config *cfg.Config, listenAddress string) {
 	}
 }
 
+func updateCSPHashes(config *cfg.Config, r *builder.Result) {
+	clear(config.CSP.StyleSrc)
+	for i, h := range r.Hashes {
+		config.CSP.StyleSrc[i] = fmt.Sprintf("'%s'", h)
+	}
+}
+
 func serverHeaderHandler(wrappedHandler http.Handler) http.Handler {
 	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
 		if r.ProtoMajor >= 2 && r.Header.Get("Host") != "" {
@@ -81,7 +87,11 @@ func New(runtimeConfig *Config) (*Server, error) {
 			BaseURL:     config.BaseURL,
 			Development: true,
 		}
-		builder.BuildSite(builderConfig)
+		r, err := builder.BuildSite(builderConfig)
+		if err != nil {
+			return nil, errors.WithMessage(err, "could not build site")
+		}
+		updateCSPHashes(config, r)
 
 		liveReload := livereload.New()
 		top.Handle("/_/reload", liveReload)
@@ -102,7 +112,11 @@ func New(runtimeConfig *Config) (*Server, error) {
 		}
 		go fw.Start(func(filename string) {
 			log.Debug("file updated", "filename", filename)
-			builder.BuildSite(builderConfig)
+			r, err := builder.BuildSite(builderConfig)
+			if err != nil {
+				log.Error("could not build site", "error", err)
+			}
+			updateCSPHashes(config, r)
 			liveReload.Reload()
 		})
 	}