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>
Preventive, not incident-driven. No LUPMIS host is known to have been
compromised; these rules exist so the most common automated attack against a
PHP deployment cannot succeed.
Scanners probe every public host for /.env (database passwords, API keys) and
/.git/. The second is the more damaging: given /.git/index together with
/.git/objects an attacker can reconstruct the whole repository, including its
history — so any credential ever committed is exposed even after rotation.
That is directly relevant here, because the minio-uploads integration has had
access keys written into its PHP files and those files are tracked in Gitea.
- public/.htaccess: deny hidden paths and files that should never be served.
- docs/apache-hardening.conf: the same rules for vhost/server config, which is
where they belong — .htaccess depends on AllowOverride. Also covers logging
the real client address through the openresty proxy (mod_remoteip), and
blocking at the proxy so requests never reach the application.
Two details that are easy to get wrong, and are handled:
- <FilesMatch "^\."> does NOT stop /.git/config. FilesMatch tests the basename
only, and there the basename is "config". Blocking a hidden directory needs a
rule that sees the whole path: mod_rewrite in .htaccess, <DirectoryMatch> in
server config.
- The SPA fallback only rewrites paths that do not exist (!-f), so a real .env
on disk skips it and is served as a plain file. The deny rules therefore run
before the fallback, not after.
.well-known is exempt, or ACME certificate renewal would silently break.
Verified against a live Apache instance with a planted .env and .git tree:
/.env and /./.env return 403; /.git/config, /.git/HEAD, /.git/objects/... and
/backup.sql return 404; /.well-known/acme-challenge/..., /assets/*.js and
/manifest.json still return 200. Nothing in the document root is caught — the
only dotfile there is .htaccess, which Apache never serves.
These rules are damage limitation. The fix is to keep .git and .env out of a
document root: deploy build output rather than a working copy, and hold secrets
in environment variables outside the served tree.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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>
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>
Major:
- GPS trail recording: reusable, dependency-free engine in src/geotracker/
(GeoTracker + geo-utils) with pluggable storage/sync adapters; LUPMIS
wiring in src/geotracker-lupmis.js. Expandable My Location control
(Locate Me + Record Trail), live navbar GPS readout, on-map trail/position
rendering, gps_trails/gps_trail_points SQLocal tables, and store-and-forward
sync via pushGpsTrail() -> save_gps_trail.php (server side documented, not
yet built).
- SSO authentication: public/index.php entry point validates the LUSPA SSO
cookie and injects window.LUPMIS_SESSION; remotedb district_id is now a
session-resolved getter. Adds public/.htaccess (DirectoryIndex).
- Account menu offcanvas (navbar burger) with sign-in/out states.
UI / fixes:
- LayerSwitcher modernisation; base-map "None" option in picker + settings.
- Mobile drawing toolbar wraps to two rows below 576px and shows only in
Draw mode; second row right-aligned and clears the Select option bar.
- Safari bottom-dock clipping fixed (app-container 100dvh -> 100svh).
- Rename public/icons -> app-icons to dodge Apache's default /icons/ alias.
- Service Worker bumped to v8 (network-first HTML, per-provider tile clear).
Docs: reusable-mapping and OSM-3D-buildings concept notes; ignore Office
lock files (~$*). Rebuilt dist/.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>