diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 6f9eaab..f4a7d87 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -208,6 +208,9 @@ Runs are recorded in `schedule_runs` and executed through `jobs`. hard-coded hex. Self-hosted Bagel Fat One and Figtree. - Tests: Vitest, Testing Library, jsdom. - Offline cache for the current plan, shopping list and recipes. +- Logout (`POST /auth/logout`) answers with `Clear-Site-Data: "storage", + "cache"`, wiping the offline marker, the service worker and its caches; the + worker leaves that request to the browser, as it does every network-only one. | Route | Screen | | --- | --- | diff --git a/go.mod b/go.mod index 39a6ce9..c5a9dce 100644 --- a/go.mod +++ b/go.mod @@ -6,6 +6,7 @@ require ( github.com/coreos/go-oidc/v3 v3.21.0 github.com/go-chi/chi/v5 v5.3.1 github.com/oklog/ulid/v2 v2.1.2 + golang.org/x/image v0.45.0 golang.org/x/oauth2 v0.36.0 modernc.org/sqlite v1.59.0 ) diff --git a/go.sum b/go.sum index ba3f090..c8f3175 100644 --- a/go.sum +++ b/go.sum @@ -21,6 +21,8 @@ github.com/oklog/ulid/v2 v2.1.2/go.mod h1:rcEKHmBBKfef9DhnvX7y1HZBYxjXb0cP5ExxNs github.com/pborman/getopt v0.0.0-20170112200414-7148bc3a4c30/go.mod h1:85jBQOZwpVEaDAr341tbn15RS4fCAsIst0qp7i8ex1o= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +golang.org/x/image v0.45.0 h1:FMb1nTbH5H9vF55SriQHgFw5GnNL9Jg6L25BwXKzhB0= +golang.org/x/image v0.45.0/go.mod h1:n62x/7RqlwXDvGsSU4u6IUTUf6KghUZ9Bt7cG/T9Fx4= golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk= golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40= golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= diff --git a/internal/auth/auth.go b/internal/auth/auth.go index d82d33d..d227ad8 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -327,8 +327,13 @@ func (a *Auth) callback(w http.ResponseWriter, r *http.Request) { // logout ends the session, clears the cookie and sends the browser to the // provider's end_session_endpoint to end the provider session as well. +// Clear-Site-Data wipes this origin's storage and caches — the PWA's +// verified-session marker in localStorage and the service worker's cached API +// and media responses — so an offline visit after logout cannot reopen +// household data. func (a *Auth) logout(w http.ResponseWriter, r *http.Request) { w.Header().Set("Cache-Control", "no-store") + w.Header().Set("Clear-Site-Data", `"storage", "cache"`) if err := a.sessions.EndSession(w, r); err != nil { a.logger.ErrorContext(r.Context(), "end session", "error", err) http.Error(w, "internal error", http.StatusInternalServerError) diff --git a/internal/auth/session_test.go b/internal/auth/session_test.go index 3f86f02..aff9f76 100644 --- a/internal/auth/session_test.go +++ b/internal/auth/session_test.go @@ -440,6 +440,9 @@ func TestLogout(t *testing.T) { if !strings.HasPrefix(raw, SessionCookieName+"=;") || !strings.Contains(raw, "Max-Age=0") { t.Errorf("logout Set-Cookie = %q, want the cookie cleared", raw) } + if got := resp.Header.Get("Clear-Site-Data"); got != `"storage", "cache"` { + t.Errorf("logout Clear-Site-Data = %q, want the PWA's storage and caches cleared", got) + } rows := h.rows(t) if len(rows) != 1 || rows[0].idHash != sha256Hex(other.Value) { @@ -461,6 +464,9 @@ func TestLogoutWithoutSession(t *testing.T) { t.Errorf("logout = %d to %q, want 303 to the end session endpoint", resp.StatusCode, resp.Header.Get("Location")) } assertSessionCookieAttributes(t, sessionSetCookie(t, resp)) + if got := resp.Header.Get("Clear-Site-Data"); got != `"storage", "cache"` { + t.Errorf("logout Clear-Site-Data = %q, want the PWA's storage and caches cleared", got) + } } func TestLogoutIsPostOnly(t *testing.T) { diff --git a/web/public/apple-touch-icon.png b/web/public/apple-touch-icon.png index 9f3f144..136ee87 100644 Binary files a/web/public/apple-touch-icon.png and b/web/public/apple-touch-icon.png differ diff --git a/web/public/icons/icon-192.png b/web/public/icons/icon-192.png index f930f8f..7baf625 100644 Binary files a/web/public/icons/icon-192.png and b/web/public/icons/icon-192.png differ diff --git a/web/public/icons/icon-512.png b/web/public/icons/icon-512.png index 4de7450..5c7b9d2 100644 Binary files a/web/public/icons/icon-512.png and b/web/public/icons/icon-512.png differ diff --git a/web/public/icons/icon-maskable-512.png b/web/public/icons/icon-maskable-512.png index ca9dd42..47d1da1 100644 Binary files a/web/public/icons/icon-maskable-512.png and b/web/public/icons/icon-maskable-512.png differ diff --git a/web/scripts/generate-icons.py b/web/scripts/generate-icons.py deleted file mode 100644 index f32adf7..0000000 --- a/web/scripts/generate-icons.py +++ /dev/null @@ -1,59 +0,0 @@ -"""Generates the PWA icons in web/public from the MealQuest logo. - -Run from the repository root with Pillow installed: - - python3 web/scripts/generate-icons.py - -The logo fills the regular icons edge to edge. The maskable icon shrinks it -onto the colour of its own parchment, so the banner stays inside -the circular safe zone (radius 40% of the icon) that launchers may crop to. -""" - -from pathlib import Path - -from PIL import Image, ImageDraw, ImageFilter, ImageStat - -ROOT = Path(__file__).resolve().parents[2] -SOURCE = ROOT / "design" / "logos" / "logo_v1_2Oct2026.jpg" -PUBLIC = ROOT / "web" / "public" - -MASKABLE_SCALE = 0.66 - - -def resized(logo: Image.Image, size: int) -> Image.Image: - return logo.resize((size, size), Image.Resampling.LANCZOS) - - -def maskable(logo: Image.Image, size: int) -> Image.Image: - # The padding is the median colour of the logo's top edge, which is bare - # parchment; a feathered edge hides the seam. - top_edge = ImageStat.Stat(logo.crop((0, 0, logo.width, logo.height // 40))) - canvas = Image.new("RGB", (size, size), tuple(round(c) for c in top_edge.median)) - inner = round(size * MASKABLE_SCALE) - offset = (size - inner) // 2 - feather = Image.new("L", (inner, inner), 0) - margin = inner // 24 - ImageDraw.Draw(feather).rectangle((margin, margin, inner - margin, inner - margin), fill=255) - feather = feather.filter(ImageFilter.GaussianBlur(margin / 2)) - canvas.paste(resized(logo, inner), (offset, offset), feather) - return canvas - - -def main() -> None: - logo = Image.open(SOURCE).convert("RGB") - if logo.width != logo.height: - raise SystemExit(f"{SOURCE} is {logo.width}x{logo.height}; the logo must be square") - outputs = { - PUBLIC / "icons" / "icon-192.png": resized(logo, 192), - PUBLIC / "icons" / "icon-512.png": resized(logo, 512), - PUBLIC / "icons" / "icon-maskable-512.png": maskable(logo, 512), - PUBLIC / "apple-touch-icon.png": resized(logo, 180), - } - for path, image in outputs.items(): - path.parent.mkdir(parents=True, exist_ok=True) - image.save(path, optimize=True) - print(f"wrote {path.relative_to(ROOT)} ({image.width}x{image.height})") - - -if __name__ == "__main__": - main() diff --git a/web/scripts/generate-icons/main.go b/web/scripts/generate-icons/main.go new file mode 100644 index 0000000..9734c33 --- /dev/null +++ b/web/scripts/generate-icons/main.go @@ -0,0 +1,186 @@ +// Command generate-icons writes the PWA icons in web/public from the +// MealQuest logo. Run it from the repository root: +// +// go run ./web/scripts/generate-icons +// +// The logo fills the regular icons edge to edge. The maskable icon shrinks it +// onto the colour of its own parchment, so the banner stays inside the +// circular safe zone (radius 40% of the icon) that launchers may crop to. +package main + +import ( + "fmt" + "image" + "image/color" + "image/png" + "math" + "os" + "path/filepath" + "slices" + + "golang.org/x/image/draw" +) + +const ( + source = "design/logos/logo_v1_2Oct2026.jpg" + public = "web/public" + maskableScale = 0.66 +) + +func main() { + if err := run(); err != nil { + fmt.Fprintln(os.Stderr, "generate-icons:", err) + os.Exit(1) + } +} + +func run() error { + logo, err := load(source) + if err != nil { + return err + } + if b := logo.Bounds(); b.Dx() != b.Dy() { + return fmt.Errorf("%s is %dx%d; the logo must be square", source, b.Dx(), b.Dy()) + } + outputs := []struct { + path string + image *image.RGBA + }{ + {"icons/icon-192.png", resized(logo, 192)}, + {"icons/icon-512.png", resized(logo, 512)}, + {"icons/icon-maskable-512.png", maskable(logo, 512)}, + {"apple-touch-icon.png", resized(logo, 180)}, + } + for _, out := range outputs { + path := filepath.Join(public, out.path) + if err := save(path, out.image); err != nil { + return err + } + fmt.Printf("wrote %s (%dx%d)\n", path, out.image.Rect.Dx(), out.image.Rect.Dy()) + } + return nil +} + +// load decodes the logo. The file is a PNG despite its .jpg name, so the +// format is sniffed rather than taken from the extension. +func load(path string) (image.Image, error) { + f, err := os.Open(path) + if err != nil { + return nil, err + } + defer f.Close() + img, _, err := image.Decode(f) + if err != nil { + return nil, fmt.Errorf("decode %s: %w", path, err) + } + return img, nil +} + +func save(path string, img image.Image) error { + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return err + } + f, err := os.Create(path) + if err != nil { + return err + } + enc := png.Encoder{CompressionLevel: png.BestCompression} + if err := enc.Encode(f, img); err != nil { + f.Close() + return fmt.Errorf("encode %s: %w", path, err) + } + return f.Close() +} + +func resized(logo image.Image, size int) *image.RGBA { + dst := image.NewRGBA(image.Rect(0, 0, size, size)) + draw.CatmullRom.Scale(dst, dst.Rect, logo, logo.Bounds(), draw.Src, nil) + return dst +} + +func maskable(logo image.Image, size int) *image.RGBA { + // The padding is the median colour of the logo's top edge, which is bare + // parchment; a feathered edge hides the seam. + b := logo.Bounds() + canvas := image.NewRGBA(image.Rect(0, 0, size, size)) + draw.Draw(canvas, canvas.Rect, image.NewUniform(medianColour(logo, image.Rect(b.Min.X, b.Min.Y, b.Max.X, b.Min.Y+b.Dy()/40))), image.Point{}, draw.Src) + inner := int(math.Round(float64(size) * maskableScale)) + offset := (size - inner) / 2 + margin := inner / 24 + at := image.Rect(offset, offset, offset+inner, offset+inner) + draw.DrawMask(canvas, at, resized(logo, inner), image.Point{}, feather(inner, margin), image.Point{}, draw.Over) + return canvas +} + +// medianColour is the per-channel median of the pixels in r. +func medianColour(img image.Image, r image.Rectangle) color.RGBA { + var channels [3][]uint8 + for y := r.Min.Y; y < r.Max.Y; y++ { + for x := r.Min.X; x < r.Max.X; x++ { + c := color.RGBAModel.Convert(img.At(x, y)).(color.RGBA) + channels[0] = append(channels[0], c.R) + channels[1] = append(channels[1], c.G) + channels[2] = append(channels[2], c.B) + } + } + var median [3]uint8 + for i, values := range channels { + slices.Sort(values) + median[i] = values[len(values)/2] + } + return color.RGBA{R: median[0], G: median[1], B: median[2], A: 0xff} +} + +// feather is an opaque square inset by margin on a size×size mask, its edge +// softened by a Gaussian blur with a standard deviation of margin/2. +func feather(size, margin int) *image.Alpha { + values := make([]float64, size*size) + for y := margin; y <= size-margin && y < size; y++ { + for x := margin; x <= size-margin && x < size; x++ { + values[y*size+x] = 255 + } + } + kernel := gaussian(float64(margin) / 2) + values = blur(values, size, kernel, 1, size) + values = blur(values, size, kernel, size, 1) + mask := image.NewAlpha(image.Rect(0, 0, size, size)) + for i, v := range values { + mask.Pix[i] = uint8(math.Round(min(max(v, 0), 255))) + } + return mask +} + +// gaussian is a normalised kernel reaching three standard deviations. +func gaussian(sigma float64) []float64 { + radius := int(math.Ceil(3 * sigma)) + kernel := make([]float64, 2*radius+1) + var sum float64 + for i := range kernel { + d := float64(i - radius) + kernel[i] = math.Exp(-d * d / (2 * sigma * sigma)) + sum += kernel[i] + } + for i := range kernel { + kernel[i] /= sum + } + return kernel +} + +// blur convolves each line of a size×size grid with kernel: step is the +// distance between neighbours along a line and stride between lines. Samples +// beyond the edge repeat the edge. +func blur(values []float64, size int, kernel []float64, step, stride int) []float64 { + radius := len(kernel) / 2 + out := make([]float64, len(values)) + for line := range size { + for i := range size { + var v float64 + for k, w := range kernel { + j := min(max(i+k-radius, 0), size-1) + v += w * values[line*stride+j*step] + } + out[line*stride+i*step] = v + } + } + return out +} diff --git a/web/src/sw.ts b/web/src/sw.ts index 81ebbb9..ca712ff 100644 --- a/web/src/sw.ts +++ b/web/src/sw.ts @@ -1,6 +1,10 @@ // MealQuest service worker. vite-plugin-pwa replaces self.__WB_MANIFEST with // the build's precache list (shell, JS, CSS and fonts, each with a revision). // Every other request goes through the route src/sw/routes.ts decides. +// A network-only request is left to the browser: the worker never answers it, +// so it reaches the network exactly as the page sent it. That matters for +// POST /auth/logout, whose Clear-Site-Data header wipes the worker and its +// caches — Chrome stalls that navigation when the worker answers it. // Fonts are precached too, but their route is registered ahead of the // precache route, so a font fetch goes through the expiring CacheFirst // strategy and falls back to the precached copy when the network fails. @@ -46,18 +50,17 @@ const strategies = { ], }), } -const networkOnly = new NetworkOnly() const navigation = new NetworkOnly({ plugins: [new PrecacheFallbackPlugin({ fallbackURL: 'index.html' })], }) -function strategyFor(route: Route): Strategy { +type AnsweredRoute = Exclude + +function strategyFor(route: AnsweredRoute): Strategy { switch (route.strategy) { case 'network-first': case 'cache-first': return strategies[route.cacheName] - case 'network-only': - return networkOnly case 'navigation-fallback': return navigation } @@ -67,8 +70,17 @@ function routeOf({ request, url }: { request: Request; url: URL }): Route { return routeFor({ method: request.method, url, mode: request.mode }, self.location.origin) } +// answered matches every request the worker answers: all but network-only. +function answered(options: { request: Request; url: URL }): boolean { + return routeOf(options).strategy !== 'network-only' +} + function handle({ request, url, event }: RouteHandlerCallbackOptions): Promise { - return strategyFor(routeOf({ request, url })).handle({ request, event }) + const route = routeOf({ request, url }) + if (route.strategy === 'network-only') { + throw new Error(`the worker never answers network-only ${request.method} ${url.href}`) + } + return strategyFor(route).handle({ request, event }) } function isFont(options: { request: Request; url: URL }): boolean { @@ -82,9 +94,9 @@ registerRoute(isFont, handle, 'GET') precacheAndRoute(self.__WB_MANIFEST) // Workbox routes are per method; every method goes through routeFor, which -// sends every non-GET to the network only. +// sends every non-GET to the network only, so only GETs are ever answered. for (const method of ['GET', 'HEAD', 'POST', 'PUT', 'PATCH', 'DELETE'] as const) { - registerRoute(() => true, handle, method) + registerRoute(answered, handle, method) } self.addEventListener('activate', (event) => { diff --git a/web/src/sw/apiStrategy.test.ts b/web/src/sw/apiStrategy.test.ts index c7e60b4..46a7f5e 100644 --- a/web/src/sw/apiStrategy.test.ts +++ b/web/src/sw/apiStrategy.test.ts @@ -2,6 +2,9 @@ // storage: jsdom has none, and the worker itself needs service worker globals. import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { RouteHandlerCallbackOptions } from 'workbox-core/types' +import { createApiClient, createVerifiedSession } from '../api/index.ts' +import { createNetworkStatus } from '../pwa/networkStatus.ts' +import { fakeLocation, jsonResponse } from '../test/fetchStub.ts' import { createApiStrategy } from './apiStrategy' import { cacheFallbackHeader, cacheNames, routeFor } from './routes' @@ -52,8 +55,11 @@ afterEach(() => { // handle sends a GET through the API strategy, as src/sw.ts does for a // network-first route. async function handle(path: string): Promise { - const request = new Request(new URL(path, origin)) - expect(routeFor({ method: 'GET', url: new URL(request.url), mode: 'cors' }, origin)).toEqual({ + return answer(new Request(new URL(path, origin))) +} + +async function answer(request: Request): Promise { + expect(routeFor({ method: request.method, url: new URL(request.url), mode: 'cors' }, origin)).toEqual({ strategy: 'network-first', cacheName: cacheNames.api, }) @@ -94,3 +100,66 @@ describe('API strategy', () => { await expect(handle('/api/v1/recipes/01J9ZS')).rejects.toThrow() }) }) + +describe('previously viewed plan and recipe reads', () => { + const plan = { week: '2026-W40', days: [{ date: '2026-10-05', dinner: 'Chicken tikka masala' }] } + const recipe = { id: '01J9ZR', name: 'Chicken tikka masala', steps: ['Marinate', 'Simmer'] } + const server: Record = { '/api/v1/plans/2026-W40': plan, '/api/v1/recipes/01J9ZR': recipe } + let reachable: boolean + + // The page's fetch goes through the worker's API strategy; the strategy's + // own fetch (always a Request) is the network. The API client fetches a + // path string, so the two are told apart by the argument's type. As in a + // browser, a worker that cannot answer is a network error to the page. + beforeEach(() => { + reachable = true + vi.spyOn(navigator, 'onLine', 'get').mockImplementation(() => reachable) + vi.stubGlobal('fetch', async (input: RequestInfo | URL, init?: RequestInit) => { + if (!(input instanceof Request)) { + return answer(new Request(new URL(String(input), origin), init)).catch((error: unknown) => { + throw new TypeError('Failed to fetch', { cause: error }) + }) + } + if (!reachable) throw new TypeError('Failed to fetch') + const body = server[new URL(input.url).pathname] + if (body === undefined) throw new Error(`unexpected network fetch: ${input.url}`) + return jsonResponse(200, body) + }) + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + function client() { + return createApiClient(fakeLocation('/plan'), createNetworkStatus(window), createVerifiedSession(localStorage)) + } + + it('serves the plan and recipe fetched online once the network is gone', async () => { + const online = client() + expect(await online.apiFetch('/api/v1/plans/2026-W40')).toEqual(plan) + expect(await online.apiFetch('/api/v1/recipes/01J9ZR')).toEqual(recipe) + expect(online.network.offline()).toBe(false) + + reachable = false + const offline = client() + expect(await offline.apiFetch('/api/v1/plans/2026-W40')).toEqual(plan) + expect(await offline.apiFetch('/api/v1/recipes/01J9ZR')).toEqual(recipe) + expect(offline.network.offline()).toBe(true) + }) + + it('shows the offline indicator when the cached copy answers while the browser reports online', async () => { + await client().apiFetch('/api/v1/plans/2026-W40') + + vi.spyOn(navigator, 'onLine', 'get').mockReturnValue(true) + reachable = false + const api = client() + expect(await api.apiFetch('/api/v1/plans/2026-W40')).toEqual(plan) + expect(api.network.offline()).toBe(true) + }) + + it('fails offline for a plan never fetched', async () => { + reachable = false + await expect(client().apiFetch('/api/v1/plans/2026-W41')).rejects.toThrow(TypeError) + }) +}) diff --git a/web/src/sw/routes.ts b/web/src/sw/routes.ts index 46daa72..895fd40 100644 --- a/web/src/sw/routes.ts +++ b/web/src/sw/routes.ts @@ -13,7 +13,7 @@ export type Route = | { strategy: 'network-first'; cacheName: typeof cacheNames.api } // Cached response when there is one, else the network; entries expire. | { strategy: 'cache-first'; cacheName: typeof cacheNames.media | typeof cacheNames.fonts } - // Network only, nothing cached. + // Network only, nothing cached: the worker leaves the request to the browser. | { strategy: 'network-only' } // Network only, nothing cached; offline, the precached index.html. | { strategy: 'navigation-fallback' } diff --git a/web/src/sw/worker.test.ts b/web/src/sw/worker.test.ts index 4b207b6..215b9cb 100644 --- a/web/src/sw/worker.test.ts +++ b/web/src/sw/worker.test.ts @@ -1,7 +1,7 @@ // The service worker itself needs worker globals, so these tests read its // source: they pin the parts routes.ts cannot express — the API cache's -// strategy, the font strategy's expiration and the font route's place ahead of -// the precache route. +// strategy, the font strategy's expiration, the font route's place ahead of +// the precache route and network-only requests left to the browser. import { describe, expect, it } from 'vitest' import source from '../sw.ts?raw' @@ -27,6 +27,16 @@ describe('service worker source', () => { expect(precache).toBeGreaterThan(fontRoute) }) + it('leaves network-only requests to the browser instead of answering them', () => { + // Chrome stalls a navigation whose Clear-Site-Data response (POST + // /auth/logout) the worker answered, so the worker must not match it. + expect(source).toMatch( + /function answered\(options: \{ request: Request; url: URL \}\): boolean \{\s*return routeOf\(options\)\.strategy !== 'network-only'/, + ) + expect(source).toContain('registerRoute(answered, handle, method)') + expect(source).not.toContain('registerRoute(() => true') + }) + it('activates a waiting worker when the update prompt sends SKIP_WAITING', () => { expect(source).toMatch( /self\.addEventListener\('message', \(event\) => \{\s*if \(event\.data\?\.type === 'SKIP_WAITING'\) void self\.skipWaiting\(\)/,