diff --git a/server/server.go b/server/server.go index 82057082..4ae85ebb 100644 --- a/server/server.go +++ b/server/server.go @@ -79,6 +79,7 @@ var ( webConfigPath = "/config.js" webManifestPath = "/manifest.webmanifest" + webRootHTMLPath = "/app.html" webServiceWorkerPath = "/sw.js" accountPath = "/account" matrixPushPath = "/_matrix/push/v1/notify" @@ -434,8 +435,6 @@ func (s *Server) handleInternal(w http.ResponseWriter, r *http.Request, v *visit return s.ensureWebEnabled(s.handleWebConfig)(w, r, v) } else if r.Method == http.MethodGet && r.URL.Path == webManifestPath { return s.ensureWebEnabled(s.handleWebManifest)(w, r, v) - } else if r.Method == http.MethodGet && r.URL.Path == webServiceWorkerPath { - return s.ensureWebEnabled(s.handleStatic)(w, r, v) } else if r.Method == http.MethodGet && r.URL.Path == apiUsersPath { return s.ensureAdmin(s.handleUsersGet)(w, r, v) } else if r.Method == http.MethodPut && r.URL.Path == apiUsersPath { @@ -502,7 +501,7 @@ func (s *Server) handleInternal(w http.ResponseWriter, r *http.Request, v *visit return s.handleMatrixDiscovery(w) } else if r.Method == http.MethodGet && r.URL.Path == metricsPath && s.metricsHandler != nil { return s.handleMetrics(w, r, v) - } else if r.Method == http.MethodGet && staticRegex.MatchString(r.URL.Path) { + } else if r.Method == http.MethodGet && (staticRegex.MatchString(r.URL.Path) || r.URL.Path == webServiceWorkerPath || r.URL.Path == webRootHTMLPath) { return s.ensureWebEnabled(s.handleStatic)(w, r, v) } else if r.Method == http.MethodGet && docsRegex.MatchString(r.URL.Path) { return s.ensureWebEnabled(s.handleDocs)(w, r, v) @@ -590,9 +589,29 @@ func (s *Server) handleWebConfig(w http.ResponseWriter, _ *http.Request, _ *visi return err } -func (s *Server) handleWebManifest(w http.ResponseWriter, r *http.Request, v *visitor) error { +func (s *Server) handleWebManifest(w http.ResponseWriter, _ *http.Request, _ *visitor) error { + response := &webManifestResponse{ + Name: "ntfy web", + Description: "ntfy lets you send push notifications via scripts from any computer or phone. Made with ❤ by Philipp C. Heckel, Apache License 2.0, source at https://heckel.io/ntfy.", + ShortName: "ntfy", + Scope: "/", + StartURL: s.config.WebRoot, + Display: "standalone", + BackgroundColor: "#ffffff", + ThemeColor: "#317f6f", + Icons: []webManifestIcon{ + {SRC: "/static/images/pwa-192x192.png", Sizes: "192x192", Type: "image/png"}, + {SRC: "/static/images/pwa-512x512.png", Sizes: "512x512", Type: "image/png"}, + }, + } + + err := s.writeJSON(w, response) + if err != nil { + return err + } + w.Header().Set("Content-Type", "application/manifest+json") - return s.handleStatic(w, r, v) + return nil } // handleMetrics returns Prometheus metrics. This endpoint is only called if enable-metrics is set, diff --git a/server/server_test.go b/server/server_test.go index 91d7d7c5..d4f266ea 100644 --- a/server/server_test.go +++ b/server/server_test.go @@ -245,6 +245,9 @@ func TestServer_WebEnabled(t *testing.T) { rr = request(t, s, "GET", "/sw.js", "", nil) require.Equal(t, 404, rr.Code) + rr = request(t, s, "GET", "/app.html", "", nil) + require.Equal(t, 404, rr.Code) + rr = request(t, s, "GET", "/static/css/home.css", "", nil) require.Equal(t, 404, rr.Code) @@ -264,6 +267,9 @@ func TestServer_WebEnabled(t *testing.T) { rr = request(t, s2, "GET", "/sw.js", "", nil) require.Equal(t, 200, rr.Code) + + rr = request(t, s2, "GET", "/app.html", "", nil) + require.Equal(t, 200, rr.Code) } func TestServer_PublishLargeMessage(t *testing.T) { diff --git a/server/types.go b/server/types.go index d6a15cea..39d355bd 100644 --- a/server/types.go +++ b/server/types.go @@ -518,3 +518,22 @@ func (w *webPushSubscription) Context() log.Context { "web_push_subscription_endpoint": w.Endpoint, } } + +// https://developer.mozilla.org/en-US/docs/Web/Manifest +type webManifestResponse struct { + Name string `json:"name"` + Description string `json:"description"` + ShortName string `json:"short_name"` + Scope string `json:"scope"` + StartURL string `json:"start_url"` + Display string `json:"display"` + BackgroundColor string `json:"background_color"` + ThemeColor string `json:"theme_color"` + Icons []webManifestIcon `json:"icons"` +} + +type webManifestIcon struct { + SRC string `json:"src"` + Sizes string `json:"sizes"` + Type string `json:"type"` +} diff --git a/web/public/sw.js b/web/public/sw.js index 98ae3d8f..386821b8 100644 --- a/web/public/sw.js +++ b/web/public/sw.js @@ -227,9 +227,28 @@ precacheAndRoute( // Delete any cached old dist files from previous service worker versions cleanupOutdatedCaches(); -if (import.meta.env.MODE !== "development") { - // since the manifest only includes `/index.html`, this manually adds the root route `/` - registerRoute(new NavigationRoute(createHandlerBoundToURL("/"))); +if (!import.meta.env.DEV) { + // we need the app_root setting, so we import the config.js file from the go server + // this does NOT include the same base_url as the web app running in a window, + // since we don't have access to `window` like in `src/app/config.js` + self.importScripts("/config.js"); + + // this is the fallback single-page-app route, matching vite.config.js PWA config, + // and is served by the go web server. It is needed for the single-page-app to work. + // https://developer.chrome.com/docs/workbox/modules/workbox-routing/#how-to-register-a-navigation-route + registerRoute( + new NavigationRoute(createHandlerBoundToURL("/app.html"), { + allowlist: [ + // the app root itself, could be /, or not + new RegExp(`^${config.app_root}$`), + // any route starting with `/`, but not `/` itself. + // this is so we don't respond to `/` UNLESS it's the app root itself, defined above + /^\/.+$/, + ], + denylist: [/^\/docs\/?$/], + }) + ); + // the manifest excludes config.js (see vite.config.js) since the dist-file differs from the // actual config served by the go server. this adds it back with `NetworkFirst`, so that the // most recent config from the go server is cached, but the app still works if the network diff --git a/web/vite.config.js b/web/vite.config.js index 4089b032..e3f7c67f 100644 --- a/web/vite.config.js +++ b/web/vite.config.js @@ -25,15 +25,18 @@ export default defineConfig(() => ({ navigateFallback: "index.html", }, injectManifest: { - globPatterns: ["**/*.{js,css,html,mp3,png,svg,json}"], + globPatterns: ["**/*.{js,css,html,mp3,ico,png,svg,json}"], globIgnores: ["config.js"], manifestTransforms: [ (entries) => ({ manifest: entries.map((entry) => + // this matches the build step in the Makefile. + // since ntfy needs the ability to serve another page on /index.html, + // it's renamed and served from server.go as app.html as well. entry.url === "index.html" ? { ...entry, - url: "/", + url: "app.html", } : entry ),