Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions django_admin_react/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,21 @@
# nothing the static bundle wouldn't, and every data API call still
# returns 403 until the user authenticates.
"REACT_LOGIN": False,
# PWA (Issue #86) — all optional; sane defaults make the manifest
# work with zero config. See ``django_admin_react/pwa.py`` +
# ``docs/ux/pwa.md``.
#
# ``PWA_NAME`` — installed-app name. ``None`` (default) falls
# back to the AdminSite ``site_header``, then
# ``"Django admin"``.
# ``PWA_SHORT_NAME`` — home-screen label. Defaults to ``"Admin"``.
# ``PWA_ICONS`` — list of ``{src, sizes, type[, purpose]}``
# dicts. ``None`` (default) uses the shipped
# 192/512/maskable set under
# ``static/dar/icons/``.
"PWA_NAME": None,
"PWA_SHORT_NAME": None,
"PWA_ICONS": None,
}


Expand All @@ -72,6 +87,9 @@ class _PackageSettings:
BRAND_TITLE: str | None = DEFAULTS["BRAND_TITLE"]
BRAND_LOGO_URL: str | None = DEFAULTS["BRAND_LOGO_URL"]
REACT_LOGIN: bool = DEFAULTS["REACT_LOGIN"]
PWA_NAME: str | None = DEFAULTS["PWA_NAME"]
PWA_SHORT_NAME: str | None = DEFAULTS["PWA_SHORT_NAME"]
PWA_ICONS: list[dict[str, str]] | None = DEFAULTS["PWA_ICONS"]


def _load() -> _PackageSettings:
Expand Down
152 changes: 152 additions & 0 deletions django_admin_react/pwa.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
"""PWA surface: web app manifest + service worker (Issue #86).

Wire/UX contract: ``docs/ux/pwa.md``. The Security lane owns this
surface because its load-bearing properties are security ones:

- The **manifest** (``<mount>/web.manifest``) is served unauthenticated
(the install prompt fires before login) and is computed at request
time, but it carries **no per-user data** — only static/global values
(mount-derived ``start_url``/``scope``, the AdminSite header, icons,
theme colours from the client hint). An anonymous reader learns
nothing they couldn't get from the static bundle.
- The **service worker** (``<mount>/sw.js``) is served with
``Service-Worker-Allowed: <mount>`` so its scope is exactly the mount
and **never** sibling Django views. It honours ``Cache-Control:
no-store`` (so the package's no-store API reads are never cached),
never caches non-GET requests (mutation safety), and exposes a
cache-purge message used on logout so read-cached payloads can't
outlive the session (``pwa.md`` §5 — defense-in-depth atop session
expiry).

Both views live **outside** ``api/`` because they're served at the
mount root, not under ``api/v1/``, and the manifest is intentionally
anonymous (unlike every API endpoint, which is staff-gated).
"""

from __future__ import annotations

from typing import Any

from django.http import HttpRequest
from django.http import HttpResponse
from django.http import JsonResponse
from django.shortcuts import render
from django.views.generic import View

from django_admin_react import conf as dar_conf
from django_admin_react.api.registry import get_admin_site

# Theme colours keyed by the resolved colour scheme. Kept here (not in
# the SPA's CSS-var system) because the manifest is rendered server-side
# before any CSS loads; these are the install-banner / splash colours
# Android uses, and they only need to *approximate* the SPA theme.
_THEME_COLOURS = {
"light": {"background": "#ffffff", "theme": "#2563eb"},
"dark": {"background": "#0b0f19", "theme": "#3b82f6"},
}

_DEFAULT_ICONS: list[dict[str, str]] = [
{"src": "static/dar/icons/icon-192.png", "sizes": "192x192", "type": "image/png"},
{"src": "static/dar/icons/icon-512.png", "sizes": "512x512", "type": "image/png"},
{
"src": "static/dar/icons/icon-512-maskable.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable",
},
]


def _mount(request: HttpRequest, suffix: str) -> str:
"""Reconstruct the consumer's mount prefix from ``request.path``.

The view is routed at ``<mount>/<suffix>`` (e.g. ``web.manifest``),
so stripping the known suffix off ``request.path`` yields the mount.
Mirrors ``views._mount_from_request`` but is local so this module
has no import dependency on the SPA index view.
"""
path = request.path
idx = path.rfind(suffix)
if idx == -1:
return "/"
return path[:idx] or "/"


def _resolved_scheme(request: HttpRequest) -> str:
"""Resolve light/dark from the ``Sec-CH-Prefers-Color-Scheme`` hint.

Pairs with the theming client-hint path (``theming.md`` §2). Any
value other than a case-insensitive ``"dark"`` resolves to light —
the safe, neutral default when the hint is absent or unexpected.
"""
hint = (request.headers.get("Sec-CH-Prefers-Color-Scheme") or "").strip().lower()
return "dark" if hint == "dark" else "light"


class ManifestView(View):
"""``GET <mount>/web.manifest`` — the PWA web app manifest.

Unauthenticated by design (the install prompt needs it pre-login).
Carries no per-user data; every field is static or mount-/header-
derived. ``Cache-Control: no-store`` is **not** set — the manifest
is deliberately cacheable/network-first (``pwa.md`` §2.1).
"""

http_method_names = ["get"]

def get(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse:
mount = _mount(request, "web.manifest")
scheme = _resolved_scheme(request)
colours = _THEME_COLOURS[scheme]

admin_site = get_admin_site()
site_header = getattr(admin_site, "site_header", None)
name = dar_conf.PWA_NAME or (str(site_header) if site_header else "Django admin")
short_name = dar_conf.PWA_SHORT_NAME or "Admin"
icons = dar_conf.PWA_ICONS or _DEFAULT_ICONS

manifest = {
"name": name,
"short_name": short_name,
"start_url": mount,
"scope": mount,
"display": "standalone",
"orientation": "any",
"background_color": colours["background"],
"theme_color": colours["theme"],
"icons": icons,
}
response = JsonResponse(manifest, content_type="application/manifest+json")
# Vary on the client hint so a light/dark cache entry doesn't
# serve the wrong splash colours to the other scheme.
response["Vary"] = "Sec-CH-Prefers-Color-Scheme"
return response


class ServiceWorkerView(View):
"""``GET <mount>/sw.js`` — the hand-rolled service worker.

Served with ``Service-Worker-Allowed: <mount>`` so the SW can claim
the whole mount as its scope (a SW's default scope is its own path;
the header widens it to the mount root). The JS is rendered from a
template with the mount injected so the SW's fetch interception is
bounded to the mount and never touches sibling Django views.
"""

http_method_names = ["get"]

def get(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse:
mount = _mount(request, "sw.js")
response = render(
request,
"admin_react/sw.js",
{"mount": mount},
content_type="application/javascript",
)
# Allow the SW to control the entire mount, not just ``<mount>/sw.js``.
response["Service-Worker-Allowed"] = mount
# The SW script itself should not be cached aggressively — a new
# deploy must be able to ship a new SW. ``no-cache`` (revalidate)
# not ``no-store`` so the browser's SW update check still works.
response["Cache-Control"] = "no-cache"
return response
127 changes: 127 additions & 0 deletions django_admin_react/templates/admin_react/sw.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
/* django-admin-react service worker (Issue #86).
*
* Hand-rolled — no Workbox (keeps the audited surface small + zero new
* npm dep). Contract: docs/ux/pwa.md §2. Security-critical invariants,
* each load-bearing:
*
* - SCOPE: only intercept requests under the SPA mount. Sibling Django
* views (admin login, project pages) pass straight through.
* - NO-STORE: never cache a response whose Cache-Control says no-store.
* The package emits no-store on every API read, so those are never
* cached — which is the point (SECURITY.md §4.7).
* - MUTATION SAFETY: never cache or replay non-GET requests.
* - CACHE-ON-LOGOUT: a "dar:purge" message deletes every dar:v1:*
* cache so read-cached payloads can't outlive the session.
*
* `MOUNT` is injected server-side by ServiceWorkerView so the scope
* check is exact for the consumer's chosen mount.
*/
'use strict';

const MOUNT = "{{ mount|escapejs }}";
const CACHE_PREFIX = 'dar:v1:';
const SHELL_CACHE = CACHE_PREFIX + 'shell';

// --- lifecycle ------------------------------------------------------------
self.addEventListener('install', (event) => {
// No heavy precache: the shell assets are hash-named and cached on
// first fetch (stale-while-revalidate below). Activate immediately.
event.waitUntil(self.skipWaiting());
});

self.addEventListener('activate', (event) => {
// Claim open clients and drop any stale-versioned dar caches.
event.waitUntil(
(async () => {
const keys = await caches.keys();
await Promise.all(
keys
.filter((k) => k.startsWith(CACHE_PREFIX) && k !== SHELL_CACHE)
.map((k) => caches.delete(k)),
);
await self.clients.claim();
})(),
);
});

// --- cache purge on logout (contract §5) ----------------------------------
self.addEventListener('message', (event) => {

Check warning

Code scanning / CodeQL

Missing origin verification in `postMessage` handler Medium

Postmessage handler has no origin check.
if (event.data && event.data.type === 'dar:purge') {
event.waitUntil(
(async () => {
const keys = await caches.keys();
await Promise.all(
keys.filter((k) => k.startsWith(CACHE_PREFIX)).map((k) => caches.delete(k)),
);
})(),
);
}
});

// --- fetch routing --------------------------------------------------------
self.addEventListener('fetch', (event) => {
const request = event.request;
const url = new URL(request.url);

// SCOPE guarantee: only handle same-origin requests under the mount.
// Everything else passes through untouched.
if (url.origin !== self.location.origin || !url.pathname.startsWith(MOUNT)) {
return;
}

// MUTATION SAFETY: writes (POST/PATCH/DELETE/...) must hit the network
// and are never cached or replayed.
if (request.method !== 'GET') {
return;
}

event.respondWith(handleGet(request, url));
});

async function handleGet(request, url) {
const isApi = url.pathname.startsWith(MOUNT + 'api/v1/');
if (isApi) {
// Network-first; fall back to last-good cache only if one exists.
// (Per contract, API reads are no-store, so the cache is normally
// empty — this still degrades gracefully if a consumer ever opts
// into a cacheable read policy.)
return networkFirst(request);
}
// Shell + static assets: stale-while-revalidate.
return staleWhileRevalidate(request);
}

async function networkFirst(request) {
try {
const response = await fetch(request);
await maybeCache(request, response);
return response;
} catch (err) {
const cached = await caches.match(request);
if (cached) return cached;
throw err;
}
}

async function staleWhileRevalidate(request) {
const cached = await caches.match(request);
const network = fetch(request)
.then((response) => {
maybeCache(request, response);
return response;
})
.catch(() => cached);
return cached || network;
}

// Cache a response ONLY when its Cache-Control does not forbid it.
async function maybeCache(request, response) {
if (!response || !response.ok || response.type === 'opaque') return;
const cc = (response.headers.get('Cache-Control') || '').toLowerCase();
// NO-STORE invariant: never persist a response the server marked
// no-store (every API read in this package is no-store).
if (cc.includes('no-store')) return;
const cache = await caches.open(SHELL_CACHE);
// Clone before the body is consumed by the caller.
await cache.put(request, response.clone());
}
8 changes: 8 additions & 0 deletions django_admin_react/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
from django.urls import path
from django.urls import re_path

from django_admin_react import pwa
from django_admin_react import views

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