Reference notes for transforming Accra / Ghana National Grid geometry to WGS 84
in PostGIS: ST_Transform, ST_SetSRID for columns reporting SRID 0, updating a
column in place, and altering a column whose type modifier locks it to 2136.
Relevant wherever data arrives on the national grid while the API and the
application work in 4326 throughout.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two documents, both for work the database team will carry out. No application
code changes.
OSM re-import runbook
---------------------
The roads endpoint returns geom, districtid, osm_id, surface, oneway and name
for district 1 — no `highway`. The road class was discarded at import, which is
what osm2pgsql's default.style does to any tag not on its list, so roads cannot
be styled or filtered by class. A discarded tag cannot be recovered by querying;
the only fix is to import again, and if that is happening anyway it is worth
doing on terms that allow the data to be kept current afterwards.
The runbook covers a flex import with --slim and --extra-attributes, producing
four tables — roads, lines, points, polygons — plus daily replication. Two
decisions are specific to LUPMIS:
* The import goes into its own `osm` schema. osm2pgsql --create drops and
recreates every table it owns, and `spatial` holds lu_parcels; the API is
given views in `spatial` instead, so the existing get_osm_roads.php keeps
working and simply gains `highway`.
* districtid is stamped by a spatial join after each import, since osm2pgsql
has no concept of a district.
Executed against osm2pgsql 2.2.0 and a scratch PostGIS database with a hand-made
extract covering every shape the config handles. All four tables imported
cleanly; updatable and attributes both true; amenities mapped as node, closed
way and multipolygon relation all reached the points table, as did a
healthcare-tagged node with no amenity — three cases the current layout cannot
serve. An --append then applied a diff and left districtid NULL on the changed
row while untouched rows kept theirs, which is the behaviour the incremental
re-stamp in section 5.2 depends on.
Not verified: anything needing the LUPMIS database itself — the current import
state, the district boundary table's real name, row counts. Section 1 is the
set of checks to run first.
External layer tables
---------------------
DDL for storing layers added through the Add External Layer dialog:
spatial.hlp_layer_types available layer types (wms, wfs, xyz, cog)
spatial.es_external_layers one row per layer a user has added
Layer types live in a table rather than a CHECK constraint so a new kind can be
introduced by inserting a row. The rule that WMS and WFS require a layer name
moved there too as requires_layer_name — left in the schema it would have meant
a migration for every new type anyway. Validity is enforced by a foreign key and
the conditional rule by a trigger, since a CHECK cannot read another table; the
trigger raises check_violation so existing API error handling still applies.
Beyond the dialog's four fields the table carries districtid (NULL = every
district), userid and is_shared, plus the columns needed to represent the
external layers the application already hard-codes — style, opacity, z_index,
attribution, legend_url, online_only. Without those, moving the existing
DEAfrica slope layer into the database would lose information the map relies on;
it is included as a seed row to prove the schema can hold it.
Executed against PostgreSQL 16. Nine cases pass, including: a new type added as
data with no DDL and usable immediately, that type enforcing its own
requires_layer_name, a retired type blocked for new layers while existing ones
stay editable, and deletion of a type still in use being refused.
The application still hard-codes the four types and will until
get_layer_types.php exists, so the dialog and hlp_layer_types must be changed
together until then — noted in the file, as the two fail in opposite directions.
Co-Authored-By: Claude Opus 4.8 <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>
Session / district correctness
- public/index.php: add a /?logout=1 endpoint that destroys the PWA's own PHP
session (session_destroy + expire PHPSESSID + clear sso_auth_token, then
redirect to the SSO portal). Logout previously cleared only the SSO cookie,
leaving the PHPSESSID session — and its frozen district_id — intact, which is
why a reassigned user kept loading the old district across logout/login.
- SSO token is validated once per session, at login (unchanged first-login
logic). A district transfer is now picked up on the next logout→login, which
is correct precisely because logout finally tears the session down. No
periodic SSO polling.
- main.js: the menu Logout button routes through /?logout=1 and wipes
district-scoped local caches first. Logout is blocked while offline — a
session can only be created online, so an offline logout would strand the
user with no way back in (and would not actually reach the server).
- main.js: enforceDistrictConsistency() clears district-scoped caches when the
session district changes between loads; the district boundary is cached under
a per-district key (district_boundary_<id>) so one district's geometry can
never be served for another.
GPS coordinate format
- New "GPS Coordinate Format" setting (Lat/Lon · UTM · Both) in the Settings
panel; the navbar read-out renders the chosen format and repaints the current
fix immediately on change. Self-contained WGS84→UTM converter in
geo-utils.js, verified against an independent Redfearn-series implementation.
ol-ext touch cursor
- MapView gates the TouchCursor to genuine touch-only devices via matchMedia
(any-pointer: fine / any-hover: hover); hybrid touchscreen laptops keep the
normal cursor. Reactive to pointer-capability changes.
- Service worker v11 → v12 (new shell). docs/SSO_Session_Refresh_Proposal.md
documents the implemented approach.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>