pwaLUPMIS2/public/embed.php
ekke 203ca5bc4d Permit-iframe hardening, import UX refinements, workshop deliverables
Permit-iframe hardening:
- public/embed.php — replace the 302 redirect on unauthenticated visits
  with an in-iframe HTML "Sign in to view the map" card (HTTP 401)
  whose primary button uses target="_top" to break the iframe and send
  the parent window to the SSO portal. The 302 was broken UX inside an
  iframe because the LUSPA portal refuses to be framed.
- public/embed.php + public/.htaccess — strip X-Frame-Options at the
  embed endpoint (defence in depth). Apache's <Files "embed.php">
  Header always unset X-Frame-Options + PHP's header_remove() both
  ensure the only iframe-policy header on the response is our CSP
  frame-ancestors (which already allows the permits subdomain). Fixes
  Safari's "Refused to display ... because it set 'X-Frame-Options'
  to 'SAMEORIGIN'" when the container's reverse proxy injects it.

Import UX refinements:
- Spinner overlay (index.html #import-spinner-overlay + main.js
  showImportSpinner/hideImportSpinner) shown during the file-drop →
  mapping-modal gap. Wired at the top of each handle*Import and at
  every error / early-return path; hidden by stageImport() just before
  openImportMappingModal() so it spans both the JS parse and the
  SQLocal staging insert.
- Per-feature client_uuid tagging — each imported OL feature now
  carries _externalImportId + _clientUuid set in stageImport(). These
  tags are the link that lets later edits find the matching staging
  row, and they are passed through to addExternalImportFeatures.
- Geometry-edit persistence — new public callback registry
  MapView.onFeatureModified(cb) fired from a modifyend listener on
  _modifyInteraction. main.js handler writes the new WKT (EPSG:4326)
  back to external_import_features.geometry_wkt via new helper
  updateExternalImportFeatureGeometry(clientUuid, wkt). Non-imported
  features carry no tags, so the handler is a no-op for them.
- Delete persistence — removefeature listener on each imported layer's
  source. New helper deleteExternalImportFeature(clientUuid) runs an
  atomic DELETE + decrement of external_imports.feature_count and
  broadcasts the changes so the LayerSwitcher badge can recount.
- Field-mapping dropdown — sample values + bold field names.
  New helpers sampleSourceValues(fc) in import-detect.js (picks first
  non-empty value per attribute, JSON-stringifies objects, collapses
  whitespace, truncates to 35 chars) and toBoldUnicode(s) in
  import-modal.js (ASCII letters/digits → Mathematical Alphanumeric
  Symbols block). Options now read as "𝐮𝐩𝐧 — [12345-6789]";
  HTML/CSS bold doesn't render inside <option> elements, so Unicode
  bold codepoints are the cross-browser way.

Workshop deliverables:
- LUPMIS2_Improvements_Mar_to_Jun_2026.docx — handout mirroring the
  slide deck one-to-one (160 paragraphs, branded styling).
- LUPMIS2_Workshop_Mar_to_Jun_2026.pptx — 16-slide pptxgenjs deck
  (16:9 widescreen, brand palette, hero + content + closing masters,
  embedded staged-upload diagram on slide 9).
- LUPMIS2_Staged_Upload_Flow.svg + .png — three swim-lane diagram of
  the staged-upload pipeline with a dedicated "Client QA Gate"
  callout. Hand-crafted SVG + 2400 px PNG.

save_gps_trail.php diagnosis (no code change, on the database team):
the reported "CORS" error is a missing endpoint — Apache returns 404
with no CORS headers and the browser surfaces it as access-control.
Once the endpoint is deployed the API server's global CORS handling
attaches the right headers and the GPS-trail sync will work without
client changes.

dist/ rebuilt.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-23 13:16:37 +00:00

238 lines
12 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'];
$validate_url = 'https://lupmis4luspa.org/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;