rastrillo / pwa Public

Clone
git clone https://amadan.net/rastrillo/pwa

Plain git — no account needed to clone.

Download

Download this file

1// Package pwa supplies installability and offline-fallback assets for a web app.
2// The app owns its manifest identity, worker entrypoint and update UI.
3package pwa
4
5import (
6 "embed"
7 "encoding/json"
8 "fmt"
9 "io/fs"
10 "net/http"
11 "net/url"
12 "strings"
13)
14
15//go:embed js/client.mjs js/worker.js
16var assets embed.FS
17
18// Assets serves client.mjs and worker.js. Mount with http.StripPrefix at
19// an app-owned path; keep sw.js at the scope it should control.
20func Assets() http.Handler {
21 f, _ := fs.Sub(assets, "js")
22 files := http.FileServer(http.FS(f))
23 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
24 if r.Method != http.MethodGet && r.Method != http.MethodHead {
25 w.Header().Set("Allow", "GET, HEAD")
26 http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
27 return
28 }
29 if r.URL.Path != "/client.mjs" && r.URL.Path != "/worker.js" {
30 http.NotFound(w, r)
31 return
32 }
33 w.Header().Set("Content-Type", "text/javascript; charset=utf-8")
34 w.Header().Set("Cache-Control", "no-cache")
35 w.Header().Set("X-Content-Type-Options", "nosniff")
36 files.ServeHTTP(w, r)
37 })
38}
39
40type Icon struct {
41 Src string `json:"src"`
42 Sizes string `json:"sizes"`
43 Type string `json:"type"`
44 Purpose string `json:"purpose,omitempty"`
45}
46
47// Manifest is application identity. Keep ID stable across start URL changes,
48// or browsers may treat an update as a different installed app.
49type Manifest struct {
50 ID string `json:"id"`
51 Name string `json:"name"`
52 ShortName string `json:"short_name,omitempty"`
53 StartURL string `json:"start_url"`
54 Scope string `json:"scope"`
55 Display string `json:"display"`
56 ThemeColor string `json:"theme_color,omitempty"`
57 BackgroundColor string `json:"background_color,omitempty"`
58 Icons []Icon `json:"icons"`
59}
60
61// Handler validates the manifest at boot so an invalid scope cannot silently
62// turn an installed app's first launch into a browser navigation.
63func (m Manifest) Handler() (http.Handler, error) {
64 if strings.TrimSpace(m.Name) == "" {
65 return nil, fmt.Errorf("pwa: name is required")
66 }
67 if !localPath(m.ID) || !localPath(m.StartURL) || !localPath(m.Scope) ||
68 !strings.HasSuffix(m.Scope, "/") || strings.ContainsAny(m.Scope, "?#") ||
69 !strings.HasPrefix(m.StartURL, m.Scope) {
70 return nil, fmt.Errorf("pwa: use root-relative paths and a start URL within a scope ending in /")
71 }
72 if m.Display == "" {
73 m.Display = "standalone"
74 }
75 switch m.Display {
76 case "standalone", "minimal-ui", "fullscreen", "browser":
77 default:
78 return nil, fmt.Errorf("pwa: unsupported display mode")
79 }
80 if len(m.Icons) == 0 {
81 return nil, fmt.Errorf("pwa: supply app icons, including 192x192 and 512x512 PNGs")
82 }
83 for _, icon := range m.Icons {
84 if !localPath(icon.Src) || icon.Sizes == "" || icon.Type == "" {
85 return nil, fmt.Errorf("pwa: icons need a root-relative src, sizes and type")
86 }
87 }
88 body, err := json.Marshal(m)
89 if err != nil {
90 return nil, err
91 }
92 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
93 if r.Method != http.MethodGet && r.Method != http.MethodHead {
94 w.Header().Set("Allow", "GET, HEAD")
95 http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
96 return
97 }
98 w.Header().Set("Content-Type", "application/manifest+json")
99 w.Header().Set("Cache-Control", "no-cache")
100 w.Header().Set("X-Content-Type-Options", "nosniff")
101 if r.Method != http.MethodHead {
102 _, _ = w.Write(body)
103 }
104 }), nil
105}
106
107func localPath(raw string) bool {
108 if !strings.HasPrefix(raw, "/") || strings.HasPrefix(raw, "//") || strings.ContainsAny(raw, "\\\r\n\t#") {
109 return false
110 }
111 u, err := url.Parse(raw)
112 if err != nil || u.Host != "" || u.Scheme != "" || u.Opaque != "" {
113 return false
114 }
115 // Browsers normalize escaped dots and backslashes before scope matching.
116 // Refuse ambiguous forms so Go and the browser cannot disagree about scope.
117 if strings.Contains(u.Path, "\\") || strings.Contains(u.Path, "//") {
118 return false
119 }
120 for _, part := range strings.Split(u.Path, "/") {
121 if part == "." || part == ".." {
122 return false
123 }
124 }
125 return true
126}
127