pwaLUPMIS2/dist/embed.php
ekke 933bfcf4c0 Permit-map iframe embed, lu_parcels schema, security guards, LayerSwitcher fix
Iframe embed for the Permitting app (LUPMIS2_Reusable_Mapping_Concept §3.2):
- public/embed.php — SSO + production gate + frame-ancestors CSP +
  whitelisted URL params (mode, lon/lat/zoom, upn, basemap,
  application_code); injects window.LUPMIS_SESSION + window.LUPMIS_EMBED.
- public/.htaccess — clean /embed URL (rewrite before the SPA fallback).
- src/embed-bridge.js — postMessage protocol: out ready / parcel:select /
  parcel:cleared / error; in set:view / set:selected / clear:selected /
  set:basemap. Visual highlight via a dedicated VectorLayer; pending-UPN
  queue resolved as parcels stream in.
- main.js — reads window.LUPMIS_EMBED, gates the normal click/dblclick
  handlers in permit mode, exposes parcelsLayer to module scope, makes
  it visible and hands it to the bridge after loadParcels().
- index.html — CSS for body.embed-mode-permit hides navbar/dock/offcanvas
  and lets the map fill the iframe.
- LUPMIS2_Permit_Map_Integration.docx — integration instructions for the
  Permitting team (contract, show.blade.php changes, phasing).

Local lu_parcels structural refactor:
- src/database.js — parcels table now mirrors spatial.lu_parcels with
  explicit columns (upn, style, landuse, zone_code/name, sector, block,
  parcel_no, prop_no, st_name, prop_add, fac_name, min/max_height,
  eff_date, lp_name, locality, mmda, last_update, remarks, geom→geometry_wkt,
  created_at, updated_at, districtid) plus local-only status/fetched_at.
  Drop-and-recreate migration off `upn` presence. saveParcels wraps the
  ~25k inserts in a transaction; numeric coercion via numOrNull.
  updateParcel/insertNewParcel write individual columns.
- main.js parcelsToGeoJSON — handles GeoJSON `geom` object (API) and
  `geometry_wkt` string (local cache); skips housekeeping fields.

Production access guard + no-district overlay:
- public/index.php — on *.lupmis4luspa.org, redirect to the SSO portal
  if no session.
- src/remotedb.js resolveDistrictId — no silent fallback to '1' for an
  authenticated user; dev mode (no session at all) keeps the fallback.
- main.js — blocking overlay if the session lacks district_id; init
  aborts so no API call is made with the wrong scope.

LayerSwitcher ordering fix:
- MapView.initEditBar + MapTools — find the Overlays group by reference
  / title instead of assuming it's the last layer (the GPS layers
  add-layered on top in the constructor broke that assumption).

Service Worker v8 → v9 to evict stale shell/module caches on deploy.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-01 16:20:15 +02:00

158 lines
8.9 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();
// ────────────────────────────────────────────────────────────────────────────
// 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 — same rule as index.php
// ────────────────────────────────────────────────────────────────────────────
$host = $_SERVER['HTTP_HOST'] ?? '';
$isProduction = (bool) preg_match('/(^|\.)lupmis4luspa\.org$/i', $host);
if ($isProduction && !isset($_SESSION['user_id'])) {
header('Location: https://lupmis4luspa.org/', true, 302);
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://permits.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;