pwaLUPMIS2/dist/index.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

183 lines
11 KiB
PHP

<?php
/**
* LUPMIS2 PWA — Authenticated entry point
*
* This file replaces a plain index.html as the directory index in production.
* It:
* 1. Picks up the LUSPA SSO cookie (sso_auth_token) set by the central
* login at https://lupmis4luspa.org/sso/.
* 2. Validates the token server-side against the SSO endpoint.
* 3. Populates a PHP session with the authenticated user's profile
* (user_id, district_id, region_id, full_name, ua_id, …).
* 4. Reads the built index.html that Vite produces and injects the
* session payload as a JavaScript global `window.LUPMIS_SESSION` —
* the PWA reads this on startup (see src/remotedb.js) to scope every
* API call to the logged-in user's district.
*
* In local development (Vite serves index.html directly without PHP) the
* global is absent and the PWA falls back to a hard-coded district for
* testing. See remotedb.js getApiCredentials().
*
* Adapted from auth code provided by the LUSPA authentication team
* (FromKwesi / 20260527 / index.php).
*/
session_start();
// ────────────────────────────────────────────────────────────────────────────
// Logout — end the PWA's OWN session, then hand off to the central SSO logout
// ────────────────────────────────────────────────────────────────────────────
// The PWA keeps its own PHP session (PHPSESSID on this host), independent of
// the central SSO portal, so the previous user's fields (incl. district_id)
// persist until this session is destroyed. We do that here, then redirect to
// the portal's `/user-logout`, which performs the FULL SSO logout (invalidates
// the token server-side and clears the sso_auth_token cookie itself).
//
// Important: we deliberately do NOT clear `sso_auth_token` here. `/user-logout`
// needs that cookie to identify which SSO session to terminate — expiring it
// first would leave the SSO session alive on the server. Redirecting to the
// bare landing page (the old behaviour) only dropped the user on a page that
// then blocked them; it never logged them out of SSO.
//
// Triggered by `/?logout=1` from the in-app menu.
if (isset($_GET['logout'])) {
$_SESSION = [];
// Expire the PWA's own session cookie (PHPSESSID).
if (ini_get('session.use_cookies')) {
$cp = session_get_cookie_params();
setcookie(session_name(), '', time() - 42000,
$cp['path'], $cp['domain'], $cp['secure'], $cp['httponly']);
}
session_destroy();
// Hand off to the central SSO logout (full logout: invalidates the token
// and clears sso_auth_token). Keep the cookie intact so it can do so.
header('Location: https://lupmis4luspa.org/user-logout', true, 302);
exit;
}
// ────────────────────────────────────────────────────────────────────────────
// SSO authentication — validate once per session, at login
// ────────────────────────────────────────────────────────────────────────────
// A user's district changes only when they are transferred — a rare event — so
// the validated fields (district_id, region_id, name, …) are valid for the
// whole session. We therefore validate the SSO token only when there is no
// session yet (i.e. at login) and do NOT re-poll SSO on every load.
//
// A transfer is picked up the next time the user logs out and back in: the
// Logout endpoint above destroys the PHP session, so the subsequent login has
// no session and re-validates, fetching the new district_id. This is correct
// precisely because logout now tears the session down (previously it only
// 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'];
// 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'])
) {
// Copy all returned user fields into the session
foreach ($data['logged_in_user'] as $key => $value) {
$_SESSION[$key] = $value;
}
}
} else {
// Token rejected by the SSO server — clear the stale cookie so the
// browser stops sending it. Domain `.lupmis4luspa.org` covers all
// subdomains (so SSO logout works from the PWA too).
setcookie('sso_auth_token', '', time() - 3600, '/', '.lupmis4luspa.org');
}
}
// ────────────────────────────────────────────────────────────────────────────
// Production access guard
// ────────────────────────────────────────────────────────────────────────────
// On the public production host, calling this file without a valid SSO session
// is not allowed — bounce the visitor to the central LUSPA login portal. On
// local development (any host that is not *.lupmis4luspa.org, e.g. localhost,
// 127.0.0.1, a developer's .local hostname) the guard is bypassed so the PHP
// entry point can still be exercised directly during testing.
$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;
}
// ────────────────────────────────────────────────────────────────────────────
// Build the payload exposed to the PWA as window.LUPMIS_SESSION
// ────────────────────────────────────────────────────────────────────────────
$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,
];
}
// ────────────────────────────────────────────────────────────────────────────
// Read the built index.html and inject the session as a JS global
// ────────────────────────────────────────────────────────────────────────────
$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 from this deployment.</p></body></html>';
// Encode safely for inline <script> — the JSON flags below escape
// characters that could break the HTML parser (<, >, &, ', ").
$sessionJson = json_encode(
$payload,
JSON_UNESCAPED_SLASHES | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT
);
$inject = "<script>window.LUPMIS_SESSION = {$sessionJson};</script>";
// Insert right after the opening <head> tag
$html = preg_replace('/<head\b[^>]*>/i', '$0' . "\n " . $inject, $html, 1);
// ────────────────────────────────────────────────────────────────────────────
// Serve
// ────────────────────────────────────────────────────────────────────────────
header('Content-Type: text/html; charset=utf-8');
// Don't let intermediaries cache an authenticated response — the next visit
// might be a different user. Asset hashes still let static files be cached.
header('Cache-Control: no-store, must-revalidate');
header('Pragma: no-cache');
echo $html;