Skip to content

Commit f86c8da

Browse files
feat(pwa): manifest + service worker backend serving — #86 (backend half)
Serving layer for the installable PWA (Issue #86), built as new files to avoid colliding with the active frontend churn. Security lane owns this surface because its load-bearing properties are security ones. - django_admin_react/pwa.py — ManifestView (`<mount>/web.manifest`) + ServiceWorkerView (`<mount>/sw.js`). * Manifest: anonymous (install prompt is pre-login), request-time computed, NO per-user data — only mount-/header-derived + static fields; theme colours from `Sec-CH-Prefers-Color-Scheme` with a `Vary` header. * SW: served with `Service-Worker-Allowed: <mount>` (scope = mount, never sibling Django views) + `Cache-Control: no-cache` (revalidate so a deploy ships a new SW; not no-store, which would break SW update checks). - templates/admin_react/sw.js — hand-rolled SW (no Workbox / no new npm dep) honoring the contract: scope ≤ mount (pass-through otherwise), never cache non-GET (mutation safety), never cache a `no-store` response (the package's API reads stay uncached), and a `dar:purge` message handler for cache-on-logout (read payloads can't outlive the session). Mount injected via `escapejs` (crafted-path safe). - conf.py — optional PWA_NAME / PWA_SHORT_NAME / PWA_ICONS (sane zero-config defaults). - urls.py — `web.manifest` + `sw.js` routes before the SPA catch-all. Tests (tests/test_pwa.py, 8): manifest anonymous + no-per-user-data + mount + theme-hint + short_name override; SW scope header + no-cache + embeds mount + no-store/mutation/purge guards. All pass. Frontend follow-up (needs browser verification): SW registration in main.tsx + the install-prompt affordance + the logout `dar:purge` postMessage. The cache-on-logout *contract* is enforced server-side here via the SW; the SPA hook that fires it is the frontend slot. Tier 5 — adds top-level URL patterns + conf defaults. Human review welcome. Backend half of #86; read/UX contract is docs/ux/pwa.md. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent f05edcb commit f86c8da

5 files changed

Lines changed: 437 additions & 0 deletions

File tree

django_admin_react/conf.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,21 @@
4141
# URL or a path under your ``STATIC_URL``.
4242
"BRAND_TITLE": None,
4343
"BRAND_LOGO_URL": None,
44+
# PWA (Issue #86) — all optional; sane defaults make the manifest
45+
# work with zero config. See ``django_admin_react/pwa.py`` +
46+
# ``docs/ux/pwa.md``.
47+
#
48+
# ``PWA_NAME`` — installed-app name. ``None`` (default) falls
49+
# back to the AdminSite ``site_header``, then
50+
# ``"Django admin"``.
51+
# ``PWA_SHORT_NAME`` — home-screen label. Defaults to ``"Admin"``.
52+
# ``PWA_ICONS`` — list of ``{src, sizes, type[, purpose]}``
53+
# dicts. ``None`` (default) uses the shipped
54+
# 192/512/maskable set under
55+
# ``static/dar/icons/``.
56+
"PWA_NAME": None,
57+
"PWA_SHORT_NAME": None,
58+
"PWA_ICONS": None,
4459
}
4560

4661

@@ -58,6 +73,9 @@ class _PackageSettings:
5873
ENABLE_PROFILING: bool = DEFAULTS["ENABLE_PROFILING"]
5974
BRAND_TITLE: str | None = DEFAULTS["BRAND_TITLE"]
6075
BRAND_LOGO_URL: str | None = DEFAULTS["BRAND_LOGO_URL"]
76+
PWA_NAME: str | None = DEFAULTS["PWA_NAME"]
77+
PWA_SHORT_NAME: str | None = DEFAULTS["PWA_SHORT_NAME"]
78+
PWA_ICONS: list[dict[str, str]] | None = DEFAULTS["PWA_ICONS"]
6179

6280

6381
def _load() -> _PackageSettings:

django_admin_react/pwa.py

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
"""PWA surface: web app manifest + service worker (Issue #86).
2+
3+
Wire/UX contract: ``docs/ux/pwa.md``. The Security lane owns this
4+
surface because its load-bearing properties are security ones:
5+
6+
- The **manifest** (``<mount>/web.manifest``) is served unauthenticated
7+
(the install prompt fires before login) and is computed at request
8+
time, but it carries **no per-user data** — only static/global values
9+
(mount-derived ``start_url``/``scope``, the AdminSite header, icons,
10+
theme colours from the client hint). An anonymous reader learns
11+
nothing they couldn't get from the static bundle.
12+
- The **service worker** (``<mount>/sw.js``) is served with
13+
``Service-Worker-Allowed: <mount>`` so its scope is exactly the mount
14+
and **never** sibling Django views. It honours ``Cache-Control:
15+
no-store`` (so the package's no-store API reads are never cached),
16+
never caches non-GET requests (mutation safety), and exposes a
17+
cache-purge message used on logout so read-cached payloads can't
18+
outlive the session (``pwa.md`` §5 — defense-in-depth atop session
19+
expiry).
20+
21+
Both views live **outside** ``api/`` because they're served at the
22+
mount root, not under ``api/v1/``, and the manifest is intentionally
23+
anonymous (unlike every API endpoint, which is staff-gated).
24+
"""
25+
26+
from __future__ import annotations
27+
28+
from typing import Any
29+
30+
from django.http import HttpRequest
31+
from django.http import HttpResponse
32+
from django.http import JsonResponse
33+
from django.shortcuts import render
34+
from django.views.generic import View
35+
36+
from django_admin_react import conf as dar_conf
37+
from django_admin_react.api.registry import get_admin_site
38+
39+
# Theme colours keyed by the resolved colour scheme. Kept here (not in
40+
# the SPA's CSS-var system) because the manifest is rendered server-side
41+
# before any CSS loads; these are the install-banner / splash colours
42+
# Android uses, and they only need to *approximate* the SPA theme.
43+
_THEME_COLOURS = {
44+
"light": {"background": "#ffffff", "theme": "#2563eb"},
45+
"dark": {"background": "#0b0f19", "theme": "#3b82f6"},
46+
}
47+
48+
_DEFAULT_ICONS: list[dict[str, str]] = [
49+
{"src": "static/dar/icons/icon-192.png", "sizes": "192x192", "type": "image/png"},
50+
{"src": "static/dar/icons/icon-512.png", "sizes": "512x512", "type": "image/png"},
51+
{
52+
"src": "static/dar/icons/icon-512-maskable.png",
53+
"sizes": "512x512",
54+
"type": "image/png",
55+
"purpose": "maskable",
56+
},
57+
]
58+
59+
60+
def _mount(request: HttpRequest, suffix: str) -> str:
61+
"""Reconstruct the consumer's mount prefix from ``request.path``.
62+
63+
The view is routed at ``<mount>/<suffix>`` (e.g. ``web.manifest``),
64+
so stripping the known suffix off ``request.path`` yields the mount.
65+
Mirrors ``views._mount_from_request`` but is local so this module
66+
has no import dependency on the SPA index view.
67+
"""
68+
path = request.path
69+
idx = path.rfind(suffix)
70+
if idx == -1:
71+
return "/"
72+
return path[:idx] or "/"
73+
74+
75+
def _resolved_scheme(request: HttpRequest) -> str:
76+
"""Resolve light/dark from the ``Sec-CH-Prefers-Color-Scheme`` hint.
77+
78+
Pairs with the theming client-hint path (``theming.md`` §2). Any
79+
value other than a case-insensitive ``"dark"`` resolves to light —
80+
the safe, neutral default when the hint is absent or unexpected.
81+
"""
82+
hint = (request.headers.get("Sec-CH-Prefers-Color-Scheme") or "").strip().lower()
83+
return "dark" if hint == "dark" else "light"
84+
85+
86+
class ManifestView(View):
87+
"""``GET <mount>/web.manifest`` — the PWA web app manifest.
88+
89+
Unauthenticated by design (the install prompt needs it pre-login).
90+
Carries no per-user data; every field is static or mount-/header-
91+
derived. ``Cache-Control: no-store`` is **not** set — the manifest
92+
is deliberately cacheable/network-first (``pwa.md`` §2.1).
93+
"""
94+
95+
http_method_names = ["get"]
96+
97+
def get(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse:
98+
mount = _mount(request, "web.manifest")
99+
scheme = _resolved_scheme(request)
100+
colours = _THEME_COLOURS[scheme]
101+
102+
admin_site = get_admin_site()
103+
site_header = getattr(admin_site, "site_header", None)
104+
name = dar_conf.PWA_NAME or (str(site_header) if site_header else "Django admin")
105+
short_name = dar_conf.PWA_SHORT_NAME or "Admin"
106+
icons = dar_conf.PWA_ICONS or _DEFAULT_ICONS
107+
108+
manifest = {
109+
"name": name,
110+
"short_name": short_name,
111+
"start_url": mount,
112+
"scope": mount,
113+
"display": "standalone",
114+
"orientation": "any",
115+
"background_color": colours["background"],
116+
"theme_color": colours["theme"],
117+
"icons": icons,
118+
}
119+
response = JsonResponse(manifest, content_type="application/manifest+json")
120+
# Vary on the client hint so a light/dark cache entry doesn't
121+
# serve the wrong splash colours to the other scheme.
122+
response["Vary"] = "Sec-CH-Prefers-Color-Scheme"
123+
return response
124+
125+
126+
class ServiceWorkerView(View):
127+
"""``GET <mount>/sw.js`` — the hand-rolled service worker.
128+
129+
Served with ``Service-Worker-Allowed: <mount>`` so the SW can claim
130+
the whole mount as its scope (a SW's default scope is its own path;
131+
the header widens it to the mount root). The JS is rendered from a
132+
template with the mount injected so the SW's fetch interception is
133+
bounded to the mount and never touches sibling Django views.
134+
"""
135+
136+
http_method_names = ["get"]
137+
138+
def get(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse:
139+
mount = _mount(request, "sw.js")
140+
response = render(
141+
request,
142+
"admin_react/sw.js",
143+
{"mount": mount},
144+
content_type="application/javascript",
145+
)
146+
# Allow the SW to control the entire mount, not just ``<mount>/sw.js``.
147+
response["Service-Worker-Allowed"] = mount
148+
# The SW script itself should not be cached aggressively — a new
149+
# deploy must be able to ship a new SW. ``no-cache`` (revalidate)
150+
# not ``no-store`` so the browser's SW update check still works.
151+
response["Cache-Control"] = "no-cache"
152+
return response
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
/* django-admin-react service worker (Issue #86).
2+
*
3+
* Hand-rolled — no Workbox (keeps the audited surface small + zero new
4+
* npm dep). Contract: docs/ux/pwa.md §2. Security-critical invariants,
5+
* each load-bearing:
6+
*
7+
* - SCOPE: only intercept requests under the SPA mount. Sibling Django
8+
* views (admin login, project pages) pass straight through.
9+
* - NO-STORE: never cache a response whose Cache-Control says no-store.
10+
* The package emits no-store on every API read, so those are never
11+
* cached — which is the point (SECURITY.md §4.7).
12+
* - MUTATION SAFETY: never cache or replay non-GET requests.
13+
* - CACHE-ON-LOGOUT: a "dar:purge" message deletes every dar:v1:*
14+
* cache so read-cached payloads can't outlive the session.
15+
*
16+
* `MOUNT` is injected server-side by ServiceWorkerView so the scope
17+
* check is exact for the consumer's chosen mount.
18+
*/
19+
'use strict';
20+
21+
const MOUNT = "{{ mount|escapejs }}";
22+
const CACHE_PREFIX = 'dar:v1:';
23+
const SHELL_CACHE = CACHE_PREFIX + 'shell';
24+
25+
// --- lifecycle ------------------------------------------------------------
26+
self.addEventListener('install', (event) => {
27+
// No heavy precache: the shell assets are hash-named and cached on
28+
// first fetch (stale-while-revalidate below). Activate immediately.
29+
event.waitUntil(self.skipWaiting());
30+
});
31+
32+
self.addEventListener('activate', (event) => {
33+
// Claim open clients and drop any stale-versioned dar caches.
34+
event.waitUntil(
35+
(async () => {
36+
const keys = await caches.keys();
37+
await Promise.all(
38+
keys
39+
.filter((k) => k.startsWith(CACHE_PREFIX) && k !== SHELL_CACHE)
40+
.map((k) => caches.delete(k)),
41+
);
42+
await self.clients.claim();
43+
})(),
44+
);
45+
});
46+
47+
// --- cache purge on logout (contract §5) ----------------------------------
48+
self.addEventListener('message', (event) => {
49+
if (event.data && event.data.type === 'dar:purge') {
50+
event.waitUntil(
51+
(async () => {
52+
const keys = await caches.keys();
53+
await Promise.all(
54+
keys.filter((k) => k.startsWith(CACHE_PREFIX)).map((k) => caches.delete(k)),
55+
);
56+
})(),
57+
);
58+
}
59+
});
60+
61+
// --- fetch routing --------------------------------------------------------
62+
self.addEventListener('fetch', (event) => {
63+
const request = event.request;
64+
const url = new URL(request.url);
65+
66+
// SCOPE guarantee: only handle same-origin requests under the mount.
67+
// Everything else passes through untouched.
68+
if (url.origin !== self.location.origin || !url.pathname.startsWith(MOUNT)) {
69+
return;
70+
}
71+
72+
// MUTATION SAFETY: writes (POST/PATCH/DELETE/...) must hit the network
73+
// and are never cached or replayed.
74+
if (request.method !== 'GET') {
75+
return;
76+
}
77+
78+
event.respondWith(handleGet(request, url));
79+
});
80+
81+
async function handleGet(request, url) {
82+
const isApi = url.pathname.startsWith(MOUNT + 'api/v1/');
83+
if (isApi) {
84+
// Network-first; fall back to last-good cache only if one exists.
85+
// (Per contract, API reads are no-store, so the cache is normally
86+
// empty — this still degrades gracefully if a consumer ever opts
87+
// into a cacheable read policy.)
88+
return networkFirst(request);
89+
}
90+
// Shell + static assets: stale-while-revalidate.
91+
return staleWhileRevalidate(request);
92+
}
93+
94+
async function networkFirst(request) {
95+
try {
96+
const response = await fetch(request);
97+
await maybeCache(request, response);
98+
return response;
99+
} catch (err) {
100+
const cached = await caches.match(request);
101+
if (cached) return cached;
102+
throw err;
103+
}
104+
}
105+
106+
async function staleWhileRevalidate(request) {
107+
const cached = await caches.match(request);
108+
const network = fetch(request)
109+
.then((response) => {
110+
maybeCache(request, response);
111+
return response;
112+
})
113+
.catch(() => cached);
114+
return cached || network;
115+
}
116+
117+
// Cache a response ONLY when its Cache-Control does not forbid it.
118+
async function maybeCache(request, response) {
119+
if (!response || !response.ok || response.type === 'opaque') return;
120+
const cc = (response.headers.get('Cache-Control') || '').toLowerCase();
121+
// NO-STORE invariant: never persist a response the server marked
122+
// no-store (every API read in this package is no-store).
123+
if (cc.includes('no-store')) return;
124+
const cache = await caches.open(SHELL_CACHE);
125+
// Clone before the body is consumed by the caller.
126+
await cache.put(request, response.clone());
127+
}

django_admin_react/urls.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
from django.urls import path
2525
from django.urls import re_path
2626

27+
from django_admin_react import pwa
2728
from django_admin_react import views
2829

2930
app_name = "django_admin_react"
@@ -42,6 +43,13 @@
4243
# literal ``login/`` / ``logout/`` segments aren't swallowed.
4344
path("login/", views.DarLoginView.as_view(), name="login"),
4445
path("logout/", views.DarLogoutView.as_view(), name="logout"),
46+
# PWA surface (Issue #86). Literal segments, declared before the
47+
# SPA catch-all so they aren't swallowed by it. The manifest is
48+
# intentionally anonymous (the install prompt fires pre-login); the
49+
# SW is served with a Service-Worker-Allowed header scoped to the
50+
# mount. See ``django_admin_react/pwa.py``.
51+
path("web.manifest", pwa.ManifestView.as_view(), name="pwa_manifest"),
52+
path("sw.js", pwa.ServiceWorkerView.as_view(), name="pwa_service_worker"),
4553
# SPA fallback. The catch-all is intentionally last so any
4654
# server-rendered route above takes precedence.
4755
re_path(r"^.*$", views.SpaIndexView.as_view(), name="spa_index"),

0 commit comments

Comments
 (0)