// Package pwa supplies installability and offline-fallback assets for a web app. // The app owns its manifest identity, worker entrypoint and update UI. package pwa import ( "embed" "encoding/json" "fmt" "io/fs" "net/http" "net/url" "strings" ) //go:embed js/client.mjs js/worker.js var assets embed.FS // Assets serves client.mjs and worker.js. Mount with http.StripPrefix at // an app-owned path; keep sw.js at the scope it should control. func Assets() http.Handler { f, _ := fs.Sub(assets, "js") files := http.FileServer(http.FS(f)) return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet && r.Method != http.MethodHead { w.Header().Set("Allow", "GET, HEAD") http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } if r.URL.Path != "/client.mjs" && r.URL.Path != "/worker.js" { http.NotFound(w, r) return } w.Header().Set("Content-Type", "text/javascript; charset=utf-8") w.Header().Set("Cache-Control", "no-cache") w.Header().Set("X-Content-Type-Options", "nosniff") files.ServeHTTP(w, r) }) } type Icon struct { Src string `json:"src"` Sizes string `json:"sizes"` Type string `json:"type"` Purpose string `json:"purpose,omitempty"` } // Manifest is application identity. Keep ID stable across start URL changes, // or browsers may treat an update as a different installed app. type Manifest struct { ID string `json:"id"` Name string `json:"name"` ShortName string `json:"short_name,omitempty"` StartURL string `json:"start_url"` Scope string `json:"scope"` Display string `json:"display"` ThemeColor string `json:"theme_color,omitempty"` BackgroundColor string `json:"background_color,omitempty"` Icons []Icon `json:"icons"` } // Handler validates the manifest at boot so an invalid scope cannot silently // turn an installed app's first launch into a browser navigation. func (m Manifest) Handler() (http.Handler, error) { if strings.TrimSpace(m.Name) == "" { return nil, fmt.Errorf("pwa: name is required") } if !localPath(m.ID) || !localPath(m.StartURL) || !localPath(m.Scope) || !strings.HasSuffix(m.Scope, "/") || strings.ContainsAny(m.Scope, "?#") || !strings.HasPrefix(m.StartURL, m.Scope) { return nil, fmt.Errorf("pwa: use root-relative paths and a start URL within a scope ending in /") } if m.Display == "" { m.Display = "standalone" } switch m.Display { case "standalone", "minimal-ui", "fullscreen", "browser": default: return nil, fmt.Errorf("pwa: unsupported display mode") } if len(m.Icons) == 0 { return nil, fmt.Errorf("pwa: supply app icons, including 192x192 and 512x512 PNGs") } for _, icon := range m.Icons { if !localPath(icon.Src) || icon.Sizes == "" || icon.Type == "" { return nil, fmt.Errorf("pwa: icons need a root-relative src, sizes and type") } } body, err := json.Marshal(m) if err != nil { return nil, err } return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet && r.Method != http.MethodHead { w.Header().Set("Allow", "GET, HEAD") http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } w.Header().Set("Content-Type", "application/manifest+json") w.Header().Set("Cache-Control", "no-cache") w.Header().Set("X-Content-Type-Options", "nosniff") if r.Method != http.MethodHead { _, _ = w.Write(body) } }), nil } func localPath(raw string) bool { if !strings.HasPrefix(raw, "/") || strings.HasPrefix(raw, "//") || strings.ContainsAny(raw, "\\\r\n\t#") { return false } u, err := url.Parse(raw) if err != nil || u.Host != "" || u.Scheme != "" || u.Opaque != "" { return false } // Browsers normalize escaped dots and backslashes before scope matching. // Refuse ambiguous forms so Go and the browser cannot disagree about scope. if strings.Contains(u.Path, "\\") || strings.Contains(u.Path, "//") { return false } for _, part := range strings.Split(u.Path, "/") { if part == "." || part == ".." { return false } } return true }