You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 

13 KiB

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:


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):

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:

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.)
  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).

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.

<VirtualHost *:443>
    ServerName pnspsdev.gld.gov.hk
    # ... SSLEngine, certificates ...

    DocumentRoot /var/www/pnsps/build

    <Directory /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
    </Directory>

    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
</VirtualHost>

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.


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):

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:

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)

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)

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

curl -sS -D - -o /dev/null \
  "https://pnspsdev.gld.gov.hk/static/js/main.<hash>.chunk.js"

Use a real hash from the deployed index.html. Expect 200.

4. Other client routes

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

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