Reconnect the app to the API, and fix the session that was never injected

Four changes that together get a signed-in officer back to working data.

The SSO validation in index.php and embed.php called the public hostname from
inside the server. The Lands Commission's NAT rule blocks traffic that leaves
the server and tries to come back in, so that curl stalled for its full
30-second timeout and then failed validation, bouncing the user back to the
login. Both now call http://l4lmainbackend/sso/validate, overridable with
LUPMIS_SSO_BASE. Measured from inside the spdrawing container: 25 ms with no
redirect, against a 10-second timeout on the public name.

API_ROOT is now the relative path /api. The PWA host already proxies it to the
ApiEngine container — a deliberate route, not a catch-all: /api answers on pwa.
and api. but not on permits., minio. or minioapi. Same-origin removes the
mixed-content, CORS and CORP constraints that Cross-Origin-Embedder-Policy:
require-corp imposes on a cross-origin call, and no API hostname is compiled
into the bundle any more. The dev server proxies /api to the public host, which
is right there because a developer's machine is outside the server and only
callers inside it are blocked from the public name.

A direct request for /index.html bypassed index.php, which is what injects
window.LUPMIS_SESSION, so the app started with no session at all and said so in
the menu. The LUSPA landing page links to exactly that URL, and the SPA
fallback could not help because its RewriteCond !-f fails precisely when the
file exists. .htaccess now routes it through index.php; index.php reads
index.html from the filesystem, so there is no loop. This one is inert until
AllowOverride is relaxed on the PWA container, where it is currently None.

Service worker v29, and v28 before it, which carries the GIS Tools rename to
devices that already have the app installed. The tile cache stays pinned at
v24, so downloaded map areas survive both updates.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
ekke 2026-10-20 15:11:01 +02:00
parent e82f4cae60
commit 4952c92e42
21 changed files with 139 additions and 30 deletions

8
dist/.htaccess vendored
View File

@ -70,6 +70,14 @@ DirectoryIndex index.php index.html
# index.php. Query strings (?mode=permit&...) pass through automatically.
RewriteRule ^embed/?$ embed.php [L]
# A direct request for /index.html must still go through index.php, which
# is what injects window.LUPMIS_SESSION. Without this rule the SPA fallback
# below cannot help: its RewriteCond !-f fails precisely because index.html
# IS a real file, so Apache serves it raw and the app starts with no
# session — the "No session injected" warning. index.php reads index.html
# from the filesystem (__DIR__), not over HTTP, so there is no loop.
RewriteRule ^index\.html$ index.php [L]
# Common single-page-app behaviour: if a route doesn't map to a real file
# or directory, send the request to index.php so the PWA can handle it
# client-side. Comment out this block if hash-based routing is preferred.

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

1
dist/assets/index-ybgDODsx.js.map vendored Normal file

File diff suppressed because one or more lines are too long

View File

@ -1,4 +1,4 @@
import{d as f,z as d}from"./index-C6kuA4o8.js";import"./vite-helpers-BlTxHScW.js";import"./bootstrap-D1-uvFxm.js";import"./openlayers-D5-VSKzq.js";import"./ol-ext-DV70ljNY.js";function p(e){return String(e).replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;").replace(/'/g,"&apos;").replace(/[\x00-\x08\x0B\x0C\x0E-\x1F]/g,"")}function h(e){let l="",o=e;for(;o>=0;)l=String.fromCharCode(o%26+65)+l,o=Math.floor(o/26)-1;return l}function x(e){return e&&!Array.isArray(e)&&Array.isArray(e.cells)?{cells:e.cells,bold:!!e.bold}:{cells:Array.isArray(e)?e:[e],bold:!1}}function u(e){const l=[];return e.forEach((o,n)=>{const{cells:t,bold:a}=x(o),r=a?' s="1"':"",i=t.map((s,c)=>{const m=`${h(c)}${n+1}`;return s==null||s===""?`<c r="${m}"${r}/>`:typeof s=="number"&&Number.isFinite(s)?`<c r="${m}"${r}><v>${s}</v></c>`:`<c r="${m}"${r} t="inlineStr"><is><t xml:space="preserve">${p(s)}</t></is></c>`}).join("");l.push(`<row r="${n+1}">${i}</row>`)}),`<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
import{d as f,z as d}from"./index-ybgDODsx.js";import"./vite-helpers-BlTxHScW.js";import"./bootstrap-D1-uvFxm.js";import"./openlayers-D5-VSKzq.js";import"./ol-ext-DV70ljNY.js";function p(e){return String(e).replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;").replace(/'/g,"&apos;").replace(/[\x00-\x08\x0B\x0C\x0E-\x1F]/g,"")}function h(e){let l="",o=e;for(;o>=0;)l=String.fromCharCode(o%26+65)+l,o=Math.floor(o/26)-1;return l}function x(e){return e&&!Array.isArray(e)&&Array.isArray(e.cells)?{cells:e.cells,bold:!!e.bold}:{cells:Array.isArray(e)?e:[e],bold:!1}}function u(e){const l=[];return e.forEach((o,n)=>{const{cells:t,bold:a}=x(o),r=a?' s="1"':"",i=t.map((s,c)=>{const m=`${h(c)}${n+1}`;return s==null||s===""?`<c r="${m}"${r}/>`:typeof s=="number"&&Number.isFinite(s)?`<c r="${m}"${r}><v>${s}</v></c>`:`<c r="${m}"${r} t="inlineStr"><is><t xml:space="preserve">${p(s)}</t></is></c>`}).join("");l.push(`<row r="${n+1}">${i}</row>`)}),`<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"><sheetData>${l.join("")}</sheetData></worksheet>`}const y=`<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<styleSheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">
<fonts count="2"><font><sz val="11"/><name val="Calibri"/></font><font><b/><sz val="11"/><name val="Calibri"/></font></fonts>
@ -27,4 +27,4 @@ ${n.map(t=>`<Relationship Id="rId${t.id}" Type="http://schemas.openxmlformats.or
`)}
<Relationship Id="rIdStyles" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/>
</Relationships>`,o["xl/styles.xml"]=y,d(o,"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")}function S(e,l){f(g(e),l)}export{g as buildXlsxBlob,S as exportXlsx};
//# sourceMappingURL=xlsx-CYAxSxVe.js.map
//# sourceMappingURL=xlsx-x-C7b9wf.js.map

File diff suppressed because one or more lines are too long

12
dist/embed.php vendored
View File

@ -35,7 +35,17 @@ header_remove('X-Frame-Options');
// ────────────────────────────────────────────────────────────────────────────
if (!isset($_SESSION['user_id']) && isset($_COOKIE['sso_auth_token'])) {
$plainToken = $_COOKIE['sso_auth_token'];
$validate_url = 'https://lupmis4luspa.org/sso/validate?token=' . urlencode($plainToken);
// The SSO service is reached by its container name on the internal Docker
// network. The public hostname must NOT be used from inside the server:
// the Lands Commission's NAT rule blocks traffic that leaves the server and
// tries to come back in, so a call to https://lupmis4luspa.org/ from here
// hangs until CURLOPT_TIMEOUT and then fails validation, bouncing the user
// back to SSO. Measured from inside the spdrawing container on 2026-10-12:
// http://l4lmainbackend/sso/validate answers in 25 ms with no redirect,
// while the public name times out. Override with LUPMIS_SSO_BASE if the
// container is renamed.
$ssoBase = getenv('LUPMIS_SSO_BASE') ?: 'http://l4lmainbackend';
$validate_url = rtrim($ssoBase, '/') . '/sso/validate?token=' . urlencode($plainToken);
$curl = curl_init();
curl_setopt_array($curl, [

8
dist/index.html vendored
View File

@ -4,7 +4,7 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<meta name="theme-color" content="#005eb8">
<meta name="description" content="LUPMIS2 Drawing Tools">
<meta name="description" content="LUPMIS2 GIS Tools">
<!-- PWA Manifest -->
<link rel="manifest" href="manifest.json">
@ -61,7 +61,7 @@
}
</style>
<title>LUPMIS2 Drawing Tools</title>
<title>LUPMIS2 GIS Tools</title>
<style>
/* LUSPA Design System Tokens
@ -2004,7 +2004,7 @@
}
</style>
<script type="module" crossorigin src="/assets/index-C6kuA4o8.js"></script>
<script type="module" crossorigin src="/assets/index-ybgDODsx.js"></script>
<link rel="modulepreload" crossorigin href="/assets/vite-helpers-BlTxHScW.js">
<link rel="modulepreload" crossorigin href="/assets/bootstrap-D1-uvFxm.js">
<link rel="modulepreload" crossorigin href="/assets/openlayers-D5-VSKzq.js">
@ -2020,7 +2020,7 @@
<nav class="navbar py-2">
<div class="container-fluid">
<span class="navbar-brand mb-0 h1">
<span class="me-2">🌍</span><span class="brand-text">LUPMIS2 Drawing Tools</span>
<span class="me-2">🌍</span><span class="brand-text">LUPMIS2 GIS Tools</span>
</span>
<!-- Live GPS status: lon/lat, accuracy (precision) and satellites.

12
dist/index.php vendored
View File

@ -69,7 +69,17 @@ if (isset($_GET['logout'])) {
// cleared the SSO cookie, which left the stale district pinned).
if (!isset($_SESSION['user_id']) && isset($_COOKIE['sso_auth_token'])) {
$plainToken = $_COOKIE['sso_auth_token'];
$validate_url = 'https://lupmis4luspa.org/sso/validate?token=' . urlencode($plainToken);
// The SSO service is reached by its container name on the internal Docker
// network. The public hostname must NOT be used from inside the server:
// the Lands Commission's NAT rule blocks traffic that leaves the server and
// tries to come back in, so a call to https://lupmis4luspa.org/ from here
// hangs until CURLOPT_TIMEOUT and then fails validation, bouncing the user
// back to SSO. Measured from inside the spdrawing container on 2026-10-12:
// http://l4lmainbackend/sso/validate answers in 25 ms with no redirect,
// while the public name times out. Override with LUPMIS_SSO_BASE if the
// container is renamed.
$ssoBase = getenv('LUPMIS_SSO_BASE') ?: 'http://l4lmainbackend';
$validate_url = rtrim($ssoBase, '/') . '/sso/validate?token=' . urlencode($plainToken);
$curl = curl_init();
curl_setopt_array($curl, [

2
dist/manifest.json vendored
View File

@ -1,5 +1,5 @@
{
"name": "LUPMIS2 Drawing Tools",
"name": "LUPMIS2 GIS Tools",
"short_name": "LUPMIS2",
"description": "Map and GIS functions for Land Use Planning in Ghana",
"start_url": "./",

18
dist/sw.js vendored
View File

@ -175,7 +175,16 @@
// an error. The map, the tiles and the shell all worked, so the app
// looked like it was working offline while nothing could be saved or
// read. The skip now covers only the dev server's own paths.
const CACHE_VERSION = 'v27';
// v28: The module is named GIS Tools, not Drawing Tools. The change reaches
// the installed app through manifest.json, so the shell has to be
// re-precached for a device that already has it on its home screen.
// v29: A direct request for /index.html bypassed index.php, so no session was
// injected and the app reported "No session injected" even for a
// signed-in officer. The LUSPA landing page links to exactly that URL.
// .htaccess now routes it through index.php; the shell still precaches it,
// since it is a real entry point and a navigation that misses the cache
// throws rather than falling back. Re-precache on installed devices.
const CACHE_VERSION = 'v29';
const SHELL_CACHE = `shell-${CACHE_VERSION}`;
const MODULES_CACHE = `modules-${CACHE_VERSION}`;
const API_CACHE = `api-${CACHE_VERSION}`;
@ -248,6 +257,13 @@ const AVG_TILE_BYTES = 30 * 1024;
// ----------------------------------------------------------------------------
// App shell assets — precached on install.
// ----------------------------------------------------------------------------
// Both '/' and '/index.html' are precached, because both are real entry
// points: the installed app opens start_url './' while the LUSPA landing page
// links to '/index.html'. Precaching both is only correct because .htaccess
// routes /index.html through index.php — before that rule, fetching it here
// stored a shell with no session injected. networkFirst() throws when a
// navigation misses the cache, so an un-precached entry point means a cold
// offline start at that URL fails outright.
const SHELL_ASSETS = [
'/',
'/index.html',

View File

@ -4,7 +4,7 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<meta name="theme-color" content="#005eb8">
<meta name="description" content="LUPMIS2 Drawing Tools">
<meta name="description" content="LUPMIS2 GIS Tools">
<!-- PWA Manifest -->
<link rel="manifest" href="manifest.json">
@ -61,7 +61,7 @@
}
</style>
<title>LUPMIS2 Drawing Tools</title>
<title>LUPMIS2 GIS Tools</title>
<style>
/* LUSPA Design System Tokens
@ -2011,7 +2011,7 @@
<nav class="navbar py-2">
<div class="container-fluid">
<span class="navbar-brand mb-0 h1">
<span class="me-2">🌍</span><span class="brand-text">LUPMIS2 Drawing Tools</span>
<span class="me-2">🌍</span><span class="brand-text">LUPMIS2 GIS Tools</span>
</span>
<!-- Live GPS status: lon/lat, accuracy (precision) and satellites.

View File

@ -70,6 +70,14 @@ DirectoryIndex index.php index.html
# index.php. Query strings (?mode=permit&...) pass through automatically.
RewriteRule ^embed/?$ embed.php [L]
# A direct request for /index.html must still go through index.php, which
# is what injects window.LUPMIS_SESSION. Without this rule the SPA fallback
# below cannot help: its RewriteCond !-f fails precisely because index.html
# IS a real file, so Apache serves it raw and the app starts with no
# session — the "No session injected" warning. index.php reads index.html
# from the filesystem (__DIR__), not over HTTP, so there is no loop.
RewriteRule ^index\.html$ index.php [L]
# Common single-page-app behaviour: if a route doesn't map to a real file
# or directory, send the request to index.php so the PWA can handle it
# client-side. Comment out this block if hash-based routing is preferred.

View File

@ -35,7 +35,17 @@ header_remove('X-Frame-Options');
// ────────────────────────────────────────────────────────────────────────────
if (!isset($_SESSION['user_id']) && isset($_COOKIE['sso_auth_token'])) {
$plainToken = $_COOKIE['sso_auth_token'];
$validate_url = 'https://lupmis4luspa.org/sso/validate?token=' . urlencode($plainToken);
// The SSO service is reached by its container name on the internal Docker
// network. The public hostname must NOT be used from inside the server:
// the Lands Commission's NAT rule blocks traffic that leaves the server and
// tries to come back in, so a call to https://lupmis4luspa.org/ from here
// hangs until CURLOPT_TIMEOUT and then fails validation, bouncing the user
// back to SSO. Measured from inside the spdrawing container on 2026-10-12:
// http://l4lmainbackend/sso/validate answers in 25 ms with no redirect,
// while the public name times out. Override with LUPMIS_SSO_BASE if the
// container is renamed.
$ssoBase = getenv('LUPMIS_SSO_BASE') ?: 'http://l4lmainbackend';
$validate_url = rtrim($ssoBase, '/') . '/sso/validate?token=' . urlencode($plainToken);
$curl = curl_init();
curl_setopt_array($curl, [

View File

@ -69,7 +69,17 @@ if (isset($_GET['logout'])) {
// cleared the SSO cookie, which left the stale district pinned).
if (!isset($_SESSION['user_id']) && isset($_COOKIE['sso_auth_token'])) {
$plainToken = $_COOKIE['sso_auth_token'];
$validate_url = 'https://lupmis4luspa.org/sso/validate?token=' . urlencode($plainToken);
// The SSO service is reached by its container name on the internal Docker
// network. The public hostname must NOT be used from inside the server:
// the Lands Commission's NAT rule blocks traffic that leaves the server and
// tries to come back in, so a call to https://lupmis4luspa.org/ from here
// hangs until CURLOPT_TIMEOUT and then fails validation, bouncing the user
// back to SSO. Measured from inside the spdrawing container on 2026-10-12:
// http://l4lmainbackend/sso/validate answers in 25 ms with no redirect,
// while the public name times out. Override with LUPMIS_SSO_BASE if the
// container is renamed.
$ssoBase = getenv('LUPMIS_SSO_BASE') ?: 'http://l4lmainbackend';
$validate_url = rtrim($ssoBase, '/') . '/sso/validate?token=' . urlencode($plainToken);
$curl = curl_init();
curl_setopt_array($curl, [

View File

@ -1,5 +1,5 @@
{
"name": "LUPMIS2 Drawing Tools",
"name": "LUPMIS2 GIS Tools",
"short_name": "LUPMIS2",
"description": "Map and GIS functions for Land Use Planning in Ghana",
"start_url": "./",

View File

@ -175,7 +175,16 @@
// an error. The map, the tiles and the shell all worked, so the app
// looked like it was working offline while nothing could be saved or
// read. The skip now covers only the dev server's own paths.
const CACHE_VERSION = 'v27';
// v28: The module is named GIS Tools, not Drawing Tools. The change reaches
// the installed app through manifest.json, so the shell has to be
// re-precached for a device that already has it on its home screen.
// v29: A direct request for /index.html bypassed index.php, so no session was
// injected and the app reported "No session injected" even for a
// signed-in officer. The LUSPA landing page links to exactly that URL.
// .htaccess now routes it through index.php; the shell still precaches it,
// since it is a real entry point and a navigation that misses the cache
// throws rather than falling back. Re-precache on installed devices.
const CACHE_VERSION = 'v29';
const SHELL_CACHE = `shell-${CACHE_VERSION}`;
const MODULES_CACHE = `modules-${CACHE_VERSION}`;
const API_CACHE = `api-${CACHE_VERSION}`;
@ -248,6 +257,13 @@ const AVG_TILE_BYTES = 30 * 1024;
// ----------------------------------------------------------------------------
// App shell assets — precached on install.
// ----------------------------------------------------------------------------
// Both '/' and '/index.html' are precached, because both are real entry
// points: the installed app opens start_url './' while the LUSPA landing page
// links to '/index.html'. Precaching both is only correct because .htaccess
// routes /index.html through index.php — before that rule, fetching it here
// stored a shell with no session injected. networkFirst() throws when a
// navigation misses the cache, so an un-precached entry point means a cold
// offline start at that URL fails outright.
const SHELL_ASSETS = [
'/',
'/index.html',

View File

@ -17,7 +17,16 @@
// Most endpoints live under the /spatial_planning module, but not all — the
// contours endpoint sits in its own module. Endpoint names containing a slash
// are resolved against API_ROOT instead (see resolveEndpoint below).
const API_ROOT = 'https://api.lupmis4luspa.org/api';
// Same-origin by default: the PWA host already proxies /api to the ApiEngine
// container (verified 2026-10-12 — /api answers on pwa. and api. only, not on
// the other subdomains, so it is a deliberate location, not a catch-all).
// Same-origin matters here: the app runs cross-origin isolated under
// Cross-Origin-Embedder-Policy: require-corp, and a relative path sidesteps
// mixed content, CORS and CORP entirely. It also keeps the browser on the
// outside-in path the Lands Commission NAT rule requires — the proxy makes the
// internal hop to ApiEngine itself, so the bundle never names a host.
// VITE_API_ROOT overrides it; the dev server proxies /api (see vite.config.js).
const API_ROOT = import.meta.env.VITE_API_ROOT ?? '/api';
const API_BASE = `${API_ROOT}/spatial_planning`;
/**

View File

@ -16,6 +16,18 @@ export default defineConfig({
'Cross-Origin-Opener-Policy': 'same-origin',
'Cross-Origin-Embedder-Policy': 'require-corp',
},
// In production the PWA host proxies /api to the ApiEngine container, so
// the app calls the API with a relative path. The dev server is not behind
// that proxy, so it forwards /api to the public host — which is correct
// here because a developer's machine is outside the server, and only
// callers inside it are blocked from using the public name.
proxy: {
'/api': {
target: 'https://api.lupmis4luspa.org',
changeOrigin: true,
secure: true,
},
},
},
// Preview server (npm run preview) configuration