# Follow-up: SPA routing on Apache (`/paymentPage/callback` and deep links) Payment gateway redirects and bookmarked URLs hit paths like `/paymentPage/callback?TRANSACTION_ID=...&RECEIPT_TOKEN=...`. In Chrome DevTools, the **document** request often shows **404 Not Found**, while the payment callback logic still runs (console logs from `PaymentCallback.js`, success/cancel/fail UI). This document explains why that happens, how it relates to the current Apache vhost (reverse proxy to `192.168.8.x:8080`), and what to change for a proper SPA setup (document **200** + unchanged browser URL and query string). Related docs: - [csp-apache.conf.md](./csp-apache.conf.md) — CSP headers on Apache - [csp-report-review-2026-08-03.md](./csp-report-review-2026-08-03.md) — noted document `status-code: 404` on SPA shell (including `/paymentPage/callback`) - [csp-api-logout-404-follow-up.md](./csp-api-logout-404-follow-up.md) — `/api/*` must not be rewritten to `index.html` --- ## Symptom | Observation | Typical meaning | |---|---| | `GET /paymentPage/callback?...` → **404** | Origin (or Apache) has no file at that path | | Response `Server: GLD Server B`, `Content-Type: text/html` | Apache / front tier answered | | Console still logs transaction data | React loaded (often via `index.html` body with wrong status) | | Network shows red 404 on the **document** | Cosmetic for users if the shell loads; bad for monitoring, SEO, and some gateways | Example URL (card payment return from GCIS): ```text https://pnsps.gld.gov.hk/paymentPage/callback?TRANSACTION_ID=...&RECEIPT_TOKEN=... ``` --- ## This is not a missing React route The client route exists and is wired to the callback page: | Piece | Location | |---|---| | Route `paymentPage/callback` | `src/routes/PublicUserRoutes.js` | | Component | `src/pages/Payment/PaymentCallback.js` | | Card `returnurl` | `src/pages/Payment/Card/index.js` (`/paymentPage/callback`) | React Router only runs **after** the browser loads the SPA shell (`index.html` + JS). The **first** request is a full-page **GET** from the payment gateway (or user bookmark). Apache / Tomcat must serve the shell for that path; the router never sees a request that never delivered HTML. Real callback work is **POST** to the backend after the page loads: | Step | API (via `ApiPathConst.js`) | |---|---| | 1 | `POST /api/payment/api/updatepayment` (`UPDATE_PAYMENT_API`) | | 2 | `POST /api/payment/callback/status` (`PAYMENT_CALLBACK_STATUS_API`) | Backend (`PNSPS-backend`): `PaymentController` — `/api/updatepayment`, `/callback/status` under the payment module (full path includes `server.servlet.contextPath: /api` and controller prefix). --- ## Current Apache pattern (DEV/UAT example) Typical vhost (`httpd-vhosts.conf`) for `pnspsdev.gld.gov.hk`: ```apache ProxyPass /paymentrecon-dept_web/ http://192.168.8.7:18080/paymentrecon-dept_web/ ProxyPass /paymentrecon-portal_web/ http://192.168.8.7:8080/paymentrecon-portal_web/ ProxyPass /api/ http://192.168.8.6:8080/api/ ProxyPass / http://192.168.8.6:8080/ ProxyPassReverse / http://192.168.8.6:8080/ ``` With only this: 1. `GET /paymentPage/callback` is forwarded to `192.168.8.6:8080` as the same path. 2. There is no file at `/paymentPage/callback` on the origin. 3. The origin returns **404** (sometimes still returning `index.html` in the body — “SPA shell 404”). 4. React may boot, read `window.location.search`, and complete payment. So: **logic can work while the document status stays 404.** Spring Boot in this repo is **API-only** (`contextPath: /api`, static resource mappings disabled in `application.yml`). The React build is served separately (commonly Tomcat or static root on `:8080`). SPA fallback must be configured on **Apache** and/or that static tier — not in Spring MVC alone. --- ## Design rules (all options) 1. **`/api/`** — Always proxy to the backend. Never rewrite API paths to `index.html`. (See [csp-api-logout-404-follow-up.md](./csp-api-logout-404-follow-up.md).) 2. **Other proxied apps** — Keep more specific `ProxyPass` prefixes (`/paymentrecon-dept_web/`, `/paymentrecon-portal_web/`, etc.) **before** the catch-all `/`. 3. **Deep links** — For `GET` requests that are not files and not API, serve `index.html` with HTTP **200**. 4. **Query string** — Payment callback needs `?TRANSACTION_ID=...&RECEIPT_TOKEN=...` in the **browser** URL. Use internal rewrite to `index.html`, not an external redirect that drops params. 5. **Avoid** `ErrorDocument 404 /index.html` as the primary SPA strategy: body may be the app while status remains **404** (same noise as today). --- ## Option A (recommended): Apache serves the React build Deploy `npm run build` output to the Apache host. Proxy only API (and other backends). Remove catch-all `ProxyPass /` to the app server for the main site. ```apache ServerName pnspsdev.gld.gov.hk # ... SSLEngine, certificates ... DocumentRoot /var/www/pnsps/build Options -Indexes +FollowSymLinks AllowOverride None Require all granted # Apache 2.4.16+ — unknown paths → index.html, status 200 FallbackResource /index.html ProxyPreserveHost On ProxyPass /paymentrecon-dept_web/ http://192.168.8.7:18080/paymentrecon-dept_web/ ProxyPassReverse /paymentrecon-dept_web/ http://192.168.8.7:18080/paymentrecon-dept_web/ ProxyPass /paymentrecon-portal_web/ http://192.168.8.7:8080/paymentrecon-portal_web/ ProxyPassReverse /paymentrecon-portal_web/ http://192.168.8.7:8080/paymentrecon-portal_web/ ProxyPass /api/ http://192.168.8.6:8080/api/ ProxyPassReverse /api/ http://192.168.8.6:8080/api/ # Do not ProxyPass / to 8080 for the main SPA when using DocumentRoot ``` **Deploy:** After each frontend release, sync `build/` to `/var/www/pnsps/build` (or your chosen path) on the Apache server. **Pros:** Clear split — static on Apache, API on Spring. Easy to reason about 200 vs 404. **Cons:** Requires a deploy path for static files on the Apache tier. --- ## Option B: Keep `ProxyPass /` — rewrite deep links to `index.html` before proxy Use when the React app must stay on `192.168.8.6:8080` and you only control Apache. Rewrite eligible **GET** requests to `/index.html` on the upstream; browser URL and query string stay unchanged (`[PT,QSA]`). Add **inside the same vhost**, after enabling `RewriteEngine` (you may already have TRACE/TRACK rules): ```apache RewriteEngine on # Existing: deny TRACE / TRACK RewriteCond %{REQUEST_METHOD} ^(TRACE|TRACK) RewriteRule .* - [F] # Do not SPA-rewrite API or other proxied applications RewriteCond %{REQUEST_URI} ^/api/ [OR] RewriteCond %{REQUEST_URI} ^/paymentrecon-dept_web/ [OR] RewriteCond %{REQUEST_URI} ^/paymentrecon-portal_web/ RewriteRule ^ - [L] # Pass through static assets (Create React App uses /static/...) RewriteCond %{REQUEST_URI} ^/static/ [OR] RewriteCond %{REQUEST_URI} \.(js|css|png|jpg|jpeg|gif|svg|ico|woff2?|map|json|txt|webmanifest)$ [NC] RewriteRule ^ - [L] # SPA fallback: upstream receives GET /index.html; browser keeps /paymentPage/callback?... RewriteCond %{REQUEST_METHOD} GET RewriteCond %{REQUEST_URI} !^/index\.html$ RewriteRule ^ /index.html [PT,QSA,L] # Existing ProxyPass (specific paths first) ProxyPass /paymentrecon-dept_web/ http://192.168.8.7:18080/paymentrecon-dept_web/ ProxyPassReverse /paymentrecon-dept_web/ http://192.168.8.7:18080/paymentrecon-dept_web/ ProxyPass /paymentrecon-portal_web/ http://192.168.8.7:8080/paymentrecon-portal_web/ ProxyPassReverse /paymentrecon-portal_web/ http://192.168.8.7:8080/paymentrecon-portal_web/ ProxyPass /api/ http://192.168.8.6:8080/api/ ProxyPassReverse /api/ http://192.168.8.6:8080/api/ ProxyPass / http://192.168.8.6:8080/ ProxyPassReverse / http://192.168.8.6:8080/ ``` If rewrite and proxy order behaves differently on your Apache version, try explicit proxy on the last rule: ```apache RewriteRule ^ /index.html [P,QSA,L] ``` (requires `mod_proxy` and correct module load order). **Pros:** No change to where the WAR/static tree lives on 8080. **Cons:** Rewrite + `ProxyPass` interaction must be tested per environment; static file detection is by URL pattern, not upstream filesystem. --- ## Option C: SPA fallback on Tomcat (origin `:8080`) If Apache cannot change, configure the tier that serves `/` on port 8080 (Tomcat `RewriteValve`, filter, or error handling) so unknown **GET** paths return `index.html` with **200**. Same idea as Options A/B; only the layer moves. --- ## Verification checklist Replace host with DEV/UAT/PROD as appropriate (`pnspsdev.gld.gov.hk`, `pnsps.gld.gov.hk`, etc.). ### 1. Payment callback document (main fix) ```bash curl -sS -D - -o /dev/null \ "https://pnspsdev.gld.gov.hk/paymentPage/callback?TRANSACTION_ID=test&RECEIPT_TOKEN=test" ``` | Expected after fix | Before fix (typical) | |---|---| | **200**, `Content-Type: text/html` | **404**, HTML body may still be SPA | ### 2. API still proxied (not rewritten to index) ```bash curl -sS -D - -o /dev/null -X POST \ "https://pnspsdev.gld.gov.hk/api/payment/callback/status" \ -H "Content-Type: application/json" \ -d '{}' ``` Expect a **JSON** error from Spring (4xx), **not** `index.html` HTML. Status is not 404 from a missing static file. ### 3. Static assets ```bash curl -sS -D - -o /dev/null \ "https://pnspsdev.gld.gov.hk/static/js/main..chunk.js" ``` Use a real hash from the deployed `index.html`. Expect **200**. ### 4. Other client routes ```bash curl -sS -D - -o /dev/null "https://pnspsdev.gld.gov.hk/login" curl -sS -D - -o /dev/null "https://pnspsdev.gld.gov.hk/dashboard" ``` Expect **200** HTML shell when logged-out routes are publicly reachable (may redirect in browser; curl shows initial response). ### 5. Browser — card payment end-to-end 1. Start card payment; complete or cancel on GCIS gateway. 2. DevTools → Network: document for `/paymentPage/callback?...` → **200**. 3. Network: `POST .../api/payment/api/updatepayment` and `POST .../api/payment/callback/status` → success responses per business rules. 4. UI: success, cancel, or fail page from `PaymentCallback.js`. ### 6. Apache reload ```bash sudo apachectl configtest sudo systemctl reload apache2 ``` Use `httpd` / `apachectl` as on your GLD hosts. --- ## Application behaviour (after shell loads) ### Auth gate `paymentPage/callback` is under `PublicUserRoutes`, which is registered only when the user is logged in as IND or ORG (`src/routes/index.js`). If the session expires on the gateway, the user may hit `/login` instead of the callback page. That is separate from the document 404; test with a fresh login if callback APIs never fire. ### FPS vs card | Flow | Return path | Route file | |---|---|---| | Card | `/paymentPage/callback` | `PublicUserRoutes.js` | | FPS (PASG) | `/paymentPage/fps/fpscallback` | `PublicUserRoutes.js` (+ test route in `LoginRoutes.js`) | This follow-up focuses on **card** `returnurl` to `/paymentPage/callback`. ### Code fragility (optional app fix, not Apache) `PaymentCallback.js` uses `params.get("RECEIPT_TOKEN").replace("%3D", "=")` without a null check. Missing `RECEIPT_TOKEN` throws before API calls. Harden separately if gateways ever omit the param. --- ## What not to do | Idea | Why | |---|---| | Add only a React route | Does not fix the first GET to Apache/Tomcat | | `Redirect` callback URL to `/` | Loses query params unless carefully preserved | | Rewrite `/api/` to `index.html` | Breaks payment POSTs, logout, login, etc. | | Rely on 404 + `index.html` body | Payment may work; monitoring and CSP reports stay noisy | | Change CSP for this | Not a CSP block; document status is HTTP routing | --- ## Decision summary | Option | Where SPA fallback lives | Remove `ProxyPass /`? | |---|---|---| | A | Apache `DocumentRoot` + `FallbackResource` | Yes (for main site) | | B | Apache `RewriteRule` → `/index.html` + existing proxy | No | | C | Tomcat / origin on 8080 | No Apache change | **Recommended:** Option A if operations can publish `build/` to Apache; Option B if the static tree must remain on `192.168.8.6:8080`. After implementation, `/paymentPage/callback` should show **200** on the document request while behaviour of `PaymentCallback.js` and payment APIs stays the same. --- ## Code map (quick reference) | Piece | Location | |---|---| | Callback route | `PNSPS-frontend-MaterialUI/src/routes/PublicUserRoutes.js` | | Callback UI + API calls | `PNSPS-frontend-MaterialUI/src/pages/Payment/PaymentCallback.js` | | Card return URL | `PNSPS-frontend-MaterialUI/src/pages/Payment/Card/index.js` | | API constants | `PNSPS-frontend-MaterialUI/src/utils/ApiPathConst.js` | | Route auth wiring | `PNSPS-frontend-MaterialUI/src/routes/index.js` | | Payment APIs | `PNSPS-backend/.../payment/web/PaymentController.java` | | API context path | `PNSPS-backend/src/main/resources/application.yml` (`/api`) | --- ## Change log (doc) | Date | Note | |---|---| | 2026-10-14 | Initial follow-up from payment callback 404 investigation and DEV vhost review |