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>
238 lines
12 KiB
PHP
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;
|