marky

Lightweight bookmarking + plaintext archive
Log | Files | Refs | README | LICENSE

static.go (3342B)


      1 package internal
      2 
      3 import (
      4 	"bytes"
      5 	"crypto/sha256"
      6 	"embed"
      7 	"fmt"
      8 	"io/fs"
      9 	"net/http"
     10 	"path"
     11 	"strings"
     12 )
     13 
     14 //go:embed web/*
     15 var webAssets embed.FS
     16 
     17 // StaticHandler returns an http.Handler serving the embedded web UI files only.
     18 // Unknown paths return 404 (no SPA fallback).
     19 func StaticHandler() (http.Handler, error) { return StaticHandlerWithConfig(HandlerConfig{}) }
     20 
     21 // StaticHandlerWithConfig serves the embedded UI, injecting runtime config.
     22 func StaticHandlerWithConfig(cfg HandlerConfig) (http.Handler, error) {
     23 	sub, err := fs.Sub(webAssets, "web")
     24 	if err != nil {
     25 		return nil, err
     26 	}
     27 	fsys := http.FS(sub)
     28 	fileServer := http.FileServer(fsys)
     29 	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
     30 		p := strings.TrimPrefix(r.URL.Path, "/")
     31 		// Root should serve the login page (index)
     32 		if p == "" {
     33 			if html, err := RenderPage("login.html", cfg); err == nil {
     34 				html = applyCacheBust(html)
     35 				w.Header().Set("Content-Type", "text/html; charset=utf-8")
     36 				_, _ = w.Write(html)
     37 				return
     38 			}
     39 		}
     40 		// Try to stat the requested file
     41 		if f, err := fs.Stat(sub, path.Clean(p)); err == nil && !f.IsDir() {
     42 			fileServer.ServeHTTP(w, r)
     43 			return
     44 		}
     45 		// Unknown file: 404
     46 		http.NotFound(w, r)
     47 	}), nil
     48 }
     49 
     50 // RenderPage reads an embedded HTML page under internal/web and injects the
     51 // shared header and optional runtime config.
     52 func RenderPage(name string, cfg HandlerConfig) ([]byte, error) {
     53 	sub, err := fs.Sub(webAssets, "web")
     54 	if err != nil {
     55 		return nil, err
     56 	}
     57 	pageBytes, err := fs.ReadFile(sub, name)
     58 	if err != nil {
     59 		return nil, err
     60 	}
     61 	if hb, err := fs.ReadFile(sub, "header.html"); err == nil {
     62 		if bytes.Contains(pageBytes, []byte("{{HEADER}}")) {
     63 			pageBytes = bytes.ReplaceAll(pageBytes, []byte("{{HEADER}}"), hb)
     64 		}
     65 	}
     66 	if cfg.DisableSignup {
     67 		marker := []byte("<script src=\"/app.js\"></script>")
     68 		if i := bytes.Index(pageBytes, marker); i >= 0 {
     69 			inject := []byte("<script>window.MARKY_CFG={disableSignup:true}</script>\n")
     70 			nb := make([]byte, 0, len(pageBytes)+len(inject))
     71 			nb = append(nb, pageBytes[:i]...)
     72 			nb = append(nb, inject...)
     73 			nb = append(nb, pageBytes[i:]...)
     74 			pageBytes = nb
     75 		}
     76 	}
     77 	return pageBytes, nil
     78 }
     79 
     80 // applyCacheBust appends a short content hash to known asset URLs in HTML,
     81 // e.g., /app.js?v=abcd1234. This helps CDNs like Cloudflare pick up changes
     82 // without manual purges.
     83 func applyCacheBust(html []byte) []byte {
     84 	sub, err := fs.Sub(webAssets, "web")
     85 	if err != nil {
     86 		return html
     87 	}
     88 	// Build fingerprint map for assets we serve locally
     89 	assets := []string{
     90 		"app.js",
     91 		"styles.css",
     92 		"favicon.svg",
     93 		"apple-touch-icon.png",
     94 		"favicon-32x32.png",
     95 		"favicon-16x16.png",
     96 		"vendor/vue.global.prod.js",
     97 		"vendor/marked.min.js",
     98 		"vendor/purify.min.js",
     99 	}
    100 	bust := map[string]string{}
    101 	for _, p := range assets {
    102 		if b, err := fs.ReadFile(sub, p); err == nil {
    103 			sum := sha256.Sum256(b)
    104 			bust[p] = fmt.Sprintf("%x", sum)[:10]
    105 		}
    106 	}
    107 	// Replace src/href occurrences
    108 	out := html
    109 	for p, v := range bust {
    110 		// src="/path"
    111 		from := []byte("src=\"/" + p + "\"")
    112 		to := []byte("src=\"/" + p + "?v=" + v + "\"")
    113 		out = bytes.ReplaceAll(out, from, to)
    114 		// href="/path"
    115 		fromH := []byte("href=\"/" + p + "\"")
    116 		toH := []byte("href=\"/" + p + "?v=" + v + "\"")
    117 		out = bytes.ReplaceAll(out, fromH, toH)
    118 	}
    119 	return out
    120 }