feat(web): SvelteKit PWA with the Nordic design system
The interface for phase 1: sign-in and first-run setup, Today, Upcoming, project views, sharing, Cmd+K search and the keyboard shortcuts. Quick add highlights what the parser understood underneath the text as you type — a mirrored layer behind a transparent input, sliced by byte offset because the server counts bytes and JavaScript counts UTF-16 units, which disagree on exactly the Danish letters this app is full of. Every local action is optimistic and reconciled afterwards: ticking a task off strikes it through and animates it out before the request leaves, and rolls back with a message if the server disagrees. The Content-Security-Policy is computed at startup by hashing the inline scripts in the built index.html. Both of them change on every frontend build, so a hard-coded hash would go stale silently and show up only as a blank page in production. The alternative, 'unsafe-inline', would drop the one protection that makes an injected script in a task title inert. Today is derived from the store by date rather than from the ids that came back with the first request — filtering against a captured list meant a task added through quick add never appeared until a reload. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
dff86dd23d
commit
12a9597919
@@ -0,0 +1,86 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"io/fs"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// inlineScript matches a <script> block that has no src attribute — that is, one
|
||||
// whose body is executed inline and therefore needs to be named in the CSP.
|
||||
var inlineScript = regexp.MustCompile(`(?is)<script(?:\s[^>]*)?>(.*?)</script>`)
|
||||
|
||||
// hasSrc distinguishes <script src="..."> from an inline block. Only the latter
|
||||
// needs a hash; the former is already covered by 'self'.
|
||||
var hasSrc = regexp.MustCompile(`(?is)^<script[^>]*\ssrc\s*=`)
|
||||
|
||||
// scriptHashes returns the CSP source expressions for every inline script in the
|
||||
// built index.html.
|
||||
//
|
||||
// This is computed at startup from the shipped file rather than written down as a
|
||||
// constant, because both inline scripts change: SvelteKit's bootstrap is rebuilt on
|
||||
// every frontend build, and the theme script in app.html changes whenever it is
|
||||
// edited. A hard-coded hash would go stale silently and the symptom would be a
|
||||
// blank page in production only — the CSP is not enforced when the page is opened
|
||||
// from a file, and a stale hash never fails a test.
|
||||
//
|
||||
// The alternative, 'unsafe-inline', would drop the one protection that makes an
|
||||
// injected <script> in a task title inert. Two hashes are cheaper than that.
|
||||
func scriptHashes(web fs.FS) []string {
|
||||
if web == nil {
|
||||
return nil
|
||||
}
|
||||
raw, err := fs.ReadFile(web, "index.html")
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var out []string
|
||||
seen := map[string]bool{}
|
||||
for _, match := range inlineScript.FindAllStringSubmatchIndex(string(raw), -1) {
|
||||
tag := string(raw[match[0]:match[1]])
|
||||
if hasSrc.MatchString(tag) {
|
||||
continue
|
||||
}
|
||||
body := string(raw[match[2]:match[3]])
|
||||
if strings.TrimSpace(body) == "" {
|
||||
continue
|
||||
}
|
||||
// The hash is over the script's exact contents, byte for byte — including
|
||||
// leading and trailing whitespace, which is why the body is not trimmed.
|
||||
sum := sha256.Sum256([]byte(body))
|
||||
expr := "'sha256-" + base64.StdEncoding.EncodeToString(sum[:]) + "'"
|
||||
if !seen[expr] {
|
||||
seen[expr] = true
|
||||
out = append(out, expr)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// contentSecurityPolicy assembles the policy served with every response.
|
||||
//
|
||||
// It is strict because verdande loads nothing from anywhere else: no CDN, no
|
||||
// analytics, no webfont. The one concession is style-src 'unsafe-inline', which
|
||||
// Svelte's scoped styles and the inline `style=` on an avatar colour both need;
|
||||
// inline CSS cannot execute, so it is a far smaller allowance than the script
|
||||
// equivalent.
|
||||
func contentSecurityPolicy(hashes []string) string {
|
||||
scriptSrc := append([]string{"'self'"}, hashes...)
|
||||
|
||||
return strings.Join([]string{
|
||||
"default-src 'self'",
|
||||
"script-src " + strings.Join(scriptSrc, " "),
|
||||
"style-src 'self' 'unsafe-inline'",
|
||||
"img-src 'self' data: blob:",
|
||||
"font-src 'self'",
|
||||
// The WebSocket is same-origin, so 'self' covers ws: and wss: to this host.
|
||||
"connect-src 'self'",
|
||||
"form-action 'self'",
|
||||
"frame-ancestors 'none'",
|
||||
"base-uri 'none'",
|
||||
"object-src 'none'",
|
||||
}, "; ")
|
||||
}
|
||||
@@ -36,6 +36,9 @@ type Server struct {
|
||||
loginLimiter *limiter
|
||||
resetLimiter *limiter
|
||||
|
||||
// Computed once from the built index.html; see csp.go.
|
||||
csp string
|
||||
|
||||
router chi.Router
|
||||
}
|
||||
|
||||
@@ -50,6 +53,7 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
|
||||
loginLimiter: newLimiter(10, 15*time.Minute),
|
||||
resetLimiter: newLimiter(5, time.Hour),
|
||||
}
|
||||
s.csp = contentSecurityPolicy(scriptHashes(web))
|
||||
|
||||
r := chi.NewRouter()
|
||||
r.Use(middleware.RequestID)
|
||||
@@ -57,7 +61,7 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
|
||||
r.Use(requestLogger(log))
|
||||
r.Use(middleware.Recoverer)
|
||||
r.Use(middleware.Timeout(30 * time.Second))
|
||||
r.Use(securityHeaders)
|
||||
r.Use(s.securityHeaders)
|
||||
|
||||
// Unauthenticated, and deliberately outside /api/v1: the Rune healthcheck and
|
||||
// Docker both need this to answer before anyone has logged in.
|
||||
@@ -256,25 +260,14 @@ func (s *Server) serveWeb(w http.ResponseWriter, r *http.Request) {
|
||||
http.ServeContent(w, r, name, info.ModTime(), seeker)
|
||||
}
|
||||
|
||||
// securityHeaders sets the headers that are the same on every response. The CSP is
|
||||
// strict because verdande serves only its own assets: there is no CDN to allow, and
|
||||
// nothing in the app loads a script from anywhere else.
|
||||
func securityHeaders(next http.Handler) http.Handler {
|
||||
// securityHeaders sets the headers that are the same on every response.
|
||||
func (s *Server) securityHeaders(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
h := w.Header()
|
||||
h.Set("X-Content-Type-Options", "nosniff")
|
||||
h.Set("X-Frame-Options", "DENY")
|
||||
h.Set("Referrer-Policy", "same-origin")
|
||||
h.Set("Content-Security-Policy", strings.Join([]string{
|
||||
"default-src 'self'",
|
||||
"img-src 'self' data: blob:",
|
||||
"style-src 'self' 'unsafe-inline'",
|
||||
"script-src 'self'",
|
||||
"connect-src 'self'",
|
||||
"frame-ancestors 'none'",
|
||||
"base-uri 'none'",
|
||||
"form-action 'self'",
|
||||
}, "; "))
|
||||
h.Set("Content-Security-Policy", s.csp)
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user