pwaLUPMIS2/dist/embed.php
ekke 4952c92e42 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>
2026-10-20 15:11:01 +02:00

248 lines
13 KiB
PHP

<?php
/**
* LUPMIS2 PWA — Iframe embed entry point
*
* Same SSO model as index.php (cookie validate → session → inject
* window.LUPMIS_SESSION), with three things added for the iframe channel
* defined by LUPMIS2_Reusable_Mapping_Concept.docx §3.2 / §4 and the
* Permit Map Integration document §2:
*
* 1. URL parameters (mode, lon, lat, zoom, upn, basemap, application_code)
* are parsed and exposed as `window.LUPMIS_EMBED` so the PWA can
* run in a slim, single-purpose mode (see main.js + src/embed-bridge.js).
* 2. `Content-Security-Policy: frame-ancestors` restricts who may embed
* this page — never `*`. The list comes from EMBED_ALLOWED_PARENTS
* below and should be tightened to the real permitting host once it
* is confirmed.
* 3. A body class hint (`embed embed-mode-permit`) is set client-side
* via the inline script so CSS can hide the unrelated chrome (navbar,
* bottom dock, account offcanvas, etc.).
*/
session_start();
// Strip any inherited X-Frame-Options header so CSP frame-ancestors (set
// below) is the only iframe-policy header in the response. Apache often
// sets `X-Frame-Options: SAMEORIGIN` as a default security header for
// every response; without this removal Safari (and other browsers) honour
// X-Frame-Options over CSP frame-ancestors, blocking the legitimate
// embedder. .htaccess also unsets it at the Apache level — this PHP call
// is defence in depth in case mod_headers is unavailable.
header_remove('X-Frame-Options');
// ────────────────────────────────────────────────────────────────────────────
// SSO authentication — validate the cookie if we don't already have a session
// (same logic as index.php)
// ────────────────────────────────────────────────────────────────────────────
if (!isset($_SESSION['user_id']) && isset($_COOKIE['sso_auth_token'])) {
$plainToken = $_COOKIE['sso_auth_token'];
// 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, [
CURLOPT_URL => $validate_url,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_ENCODING => "",
CURLOPT_MAXREDIRS => 10,
CURLOPT_TIMEOUT => 30,
CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,
CURLOPT_CUSTOMREQUEST => "GET",
CURLOPT_HTTPHEADER => [ "Content-Type: application/xml" ],
]);
$response = curl_exec($curl);
$httpCode = curl_getinfo($curl, CURLINFO_HTTP_CODE);
curl_close($curl);
if ($httpCode === 200) {
$data = json_decode($response, true);
if (
is_array($data)
&& isset($data['valid']) && $data['valid'] === true
&& isset($data['logged_in_user']) && is_array($data['logged_in_user'])
) {
foreach ($data['logged_in_user'] as $key => $value) {
$_SESSION[$key] = $value;
}
}
} else {
setcookie('sso_auth_token', '', time() - 3600, '/', '.lupmis4luspa.org');
}
}
// ────────────────────────────────────────────────────────────────────────────
// Production access guard — render an in-iframe "Sign in required" page
// instead of a 302 redirect.
//
// Why not redirect like index.php does? A 302 inside an <iframe> reloads
// the IFRAME, not the parent window — and the LUSPA login page typically
// refuses to be framed (X-Frame-Options), so the embedder ends up with a
// blank / "refused to connect" iframe with no clear way for the user to
// authenticate. The HTML page below uses target="_top" on its sign-in
// button so the click navigates the parent window to the SSO portal,
// preserving the user's path back to the permits page after login.
// ────────────────────────────────────────────────────────────────────────────
$host = $_SERVER['HTTP_HOST'] ?? '';
$isProduction = (bool) preg_match('/(^|\.)lupmis4luspa\.org$/i', $host);
if ($isProduction && !isset($_SESSION['user_id'])) {
// CSP / frame-ancestors must still match the embed's policy so the
// permits page can display this auth-required page in the iframe.
$EMBED_ALLOWED_PARENTS = [
'https://lupmis4luspa.org',
];
$frameAncestors = "'self' " . implode(' ', $EMBED_ALLOWED_PARENTS);
header("Content-Security-Policy: frame-ancestors {$frameAncestors}");
header('Content-Type: text/html; charset=utf-8');
header('Cache-Control: no-store, must-revalidate');
header('X-Embed-Auth-Status: required'); // diagnostic for the permits team
http_response_code(401);
?>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>LUPMIS2 — Sign in required</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Arial, sans-serif;
margin: 0; padding: 16px;
background: #f8fafc;
color: #1e1a4b;
display: flex; align-items: center; justify-content: center;
min-height: 100vh; min-height: 100svh;
box-sizing: border-box;
}
.card {
max-width: 420px; width: 100%;
padding: 32px 28px;
background: #fff;
border: 1px solid #e5e7eb;
border-radius: 12px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.06);
text-align: center;
}
.icon { font-size: 38px; line-height: 1; margin-bottom: 10px; }
h1 { margin: 0 0 12px; font-size: 1.25rem; color: #1e1a4b; }
p { margin: 0 0 16px; color: #4b5563; line-height: 1.5; }
a.cta {
display: inline-block; margin-top: 4px;
padding: 10px 18px; border-radius: 8px;
background: #005eb8; color: #fff;
text-decoration: none; font-weight: 600;
}
a.cta:hover { background: #004a92; }
.hint { font-size: 0.85rem; color: #6b7280; margin-top: 16px; }
</style>
</head>
<body>
<div class="card">
<div class="icon" aria-hidden="true">🔒</div>
<h1>Sign in to view the map</h1>
<p>The LUPMIS2 map can only be loaded after you sign in to the LUSPA portal.</p>
<a href="https://lupmis4luspa.org/" target="_top" class="cta" rel="noopener">
Open LUSPA sign-in
</a>
<p class="hint">After signing in, return to this page and refresh to load the map.</p>
</div>
</body>
</html>
<?php
exit;
}
// ────────────────────────────────────────────────────────────────────────────
// Session payload (same shape as index.php)
// ────────────────────────────────────────────────────────────────────────────
$payload = [];
if (isset($_SESSION['user_id'])) {
$payload = [
'user_id' => $_SESSION['user_id'] ?? null,
'ua_id' => $_SESSION['ua_id'] ?? null,
'username' => $_SESSION['username'] ?? null,
'title' => $_SESSION['title'] ?? null,
'full_name' => $_SESSION['full_name'] ?? null,
'email' => $_SESSION['email'] ?? null,
'user_type' => $_SESSION['user_type'] ?? null,
'phone' => $_SESSION['phone'] ?? null,
'ua_position' => $_SESSION['ua_position'] ?? null,
'region_id' => $_SESSION['region_id'] ?? null,
'district_id' => $_SESSION['district_id'] ?? null,
];
}
// ────────────────────────────────────────────────────────────────────────────
// Embed config — parse the contract's URL parameters (see Permit Map
// Integration doc §2.1). Strict whitelisting + type coercion keeps invalid
// input from reaching the PWA.
// ────────────────────────────────────────────────────────────────────────────
$validBasemaps = ['topo','osm','satellite','googlesat','carto-light','carto-dark','none'];
$validModes = ['permit'];
$mode = isset($_GET['mode']) ? (string)$_GET['mode'] : 'permit';
$basemap = isset($_GET['basemap']) ? (string)$_GET['basemap'] : null;
$upn = isset($_GET['upn']) ? (string)$_GET['upn'] : null;
$appCode = isset($_GET['application_code']) ? (string)$_GET['application_code'] : null;
$lon = isset($_GET['lon']) && is_numeric($_GET['lon']) ? (float)$_GET['lon'] : null;
$lat = isset($_GET['lat']) && is_numeric($_GET['lat']) ? (float)$_GET['lat'] : null;
$zoom = isset($_GET['zoom']) && is_numeric($_GET['zoom']) ? (int)$_GET['zoom'] : null;
$embed = [
'mode' => in_array($mode, $validModes, true) ? $mode : 'permit',
'lon' => $lon,
'lat' => $lat,
'zoom' => $zoom,
'upn' => $upn,
'basemap' => ($basemap && in_array($basemap, $validBasemaps, true)) ? $basemap : null,
'application_code' => $appCode,
];
// ────────────────────────────────────────────────────────────────────────────
// Read the built index.html and inject the session + embed config
// ────────────────────────────────────────────────────────────────────────────
$indexPath = __DIR__ . '/index.html';
$html = is_readable($indexPath)
? file_get_contents($indexPath)
: '<!DOCTYPE html><html><body><h1>LUPMIS2 PWA</h1><p>index.html is missing.</p></body></html>';
$jsonFlags = JSON_UNESCAPED_SLASHES | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT;
$sessionJson = json_encode($payload, $jsonFlags);
$embedJson = json_encode($embed, $jsonFlags);
$inject =
"<script>window.LUPMIS_SESSION = {$sessionJson};" .
"window.LUPMIS_EMBED = {$embedJson};" .
// Set the body class as early as possible so CSS rules apply on first paint.
"document.addEventListener('DOMContentLoaded',function(){" .
"document.body.classList.add('embed','embed-mode-' + (window.LUPMIS_EMBED.mode || 'permit'));" .
"});</script>";
$html = preg_replace('/<head\b[^>]*>/i', '$0' . "\n " . $inject, $html, 1);
// ────────────────────────────────────────────────────────────────────────────
// Headers — frame-ancestors restricts who may embed; tighten this list once
// the real permitting host is confirmed. NEVER use `frame-ancestors *`.
// ────────────────────────────────────────────────────────────────────────────
$EMBED_ALLOWED_PARENTS = [
'https://lupmis4luspa.org',
// Add local dev parents here if needed, e.g. 'http://localhost:8000'.
];
$frameAncestors = "'self' " . implode(' ', $EMBED_ALLOWED_PARENTS);
header("Content-Security-Policy: frame-ancestors {$frameAncestors}");
header('Content-Type: text/html; charset=utf-8');
header('Cache-Control: no-store, must-revalidate');
header('Pragma: no-cache');
header('X-Content-Type-Options: nosniff');
header('Referrer-Policy: strict-origin-when-cross-origin');
echo $html;