mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
fix: ( payments ) allow telebirr checkout to bounce through /go
This commit is contained in:
@@ -3,36 +3,47 @@
|
|||||||
import { Suspense, useEffect, useMemo } from "react";
|
import { Suspense, useEffect, useMemo } from "react";
|
||||||
import { useSearchParams } from "next/navigation";
|
import { useSearchParams } from "next/navigation";
|
||||||
|
|
||||||
import { isDMoneyCheckoutUrl } from "@/lib/payment-redirect";
|
import { isBounceableCheckoutUrl } from "@/lib/payment-redirect";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* /go — payment redirect bounce page for D-Money web checkout.
|
* /go — payment redirect bounce page for provider web checkout (D-Money, telebirr).
|
||||||
*
|
*
|
||||||
* The redirect is done CLIENT-SIDE on purpose: the navigation must originate
|
* The redirect is done CLIENT-SIDE on purpose: the navigation must originate
|
||||||
* from the loaded https://edrpassenger.triaplc.com/go document so the browser
|
* from the loaded https://edrpassenger.triaplc.com/go document so the browser
|
||||||
* sends `Referer: https://edrpassenger.triaplc.com` to D-Money. D-Money only
|
* sends `Referer: https://edrpassenger.triaplc.com` to the paygate. D-Money only
|
||||||
* whitelists that origin, so a server-side 307 (whose referrer on the redirect
|
* whitelists that origin, so a server-side 307 (whose referrer on the redirect
|
||||||
* hop is browser-dependent and can be stripped) must NOT be used here.
|
* hop is browser-dependent and can be stripped) must NOT be used here.
|
||||||
*
|
*
|
||||||
|
* Telebirr's paygate has the same requirement — its checkout URL opens when the
|
||||||
|
* portal navigates to it but returns "the required parameter of the request is
|
||||||
|
* empty" when opened with no Referer at all (address-bar paste, or a native
|
||||||
|
* WebView's `loadRequest`). Native clients therefore bounce telebirr through
|
||||||
|
* here too. The portal's own web flow does NOT: it already navigates from the
|
||||||
|
* payment page, so it supplies a Referer without the extra hop.
|
||||||
|
*
|
||||||
* It fires immediately (no delay) and paints a bare white full-screen cover
|
* It fires immediately (no delay) and paints a bare white full-screen cover
|
||||||
* above the sidebar/tab bar (z-40) — no portal chrome, no text on the happy
|
* above the sidebar/tab bar (z-40) — no portal chrome, no text on the happy
|
||||||
* path. A short message shows only when the link is missing/untrusted.
|
* path. A short message shows only when the link is missing/untrusted.
|
||||||
*
|
*
|
||||||
* `?url=` MUST be percent-encoded by the caller (Uri.encodeComponent() in the
|
* `?url=` MUST be percent-encoded by the caller (Uri.encodeComponent() in the
|
||||||
* Flutter app / encodeURIComponent() on web); otherwise the query parser
|
* Flutter app / encodeURIComponent() on web); otherwise the query parser
|
||||||
* truncates the D-Money URL at its first `&` and merch_code/sign are lost.
|
* truncates the checkout URL at its first `&`. For telebirr this also matters
|
||||||
* See scripts/test-go-redirect.mjs.
|
* for the base64 `sign`, whose `+` would otherwise decode to a space.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
function RedirectView() {
|
function RedirectView() {
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const raw = searchParams.get("url");
|
const raw = searchParams.get("url");
|
||||||
const target = useMemo(() => (isDMoneyCheckoutUrl(raw) ? raw : null), [raw]);
|
const target = useMemo(
|
||||||
|
() => (isBounceableCheckoutUrl(raw) ? raw : null),
|
||||||
|
[raw],
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!target) return;
|
if (!target) return;
|
||||||
// Navigate from this document so the D-Money request carries
|
// Navigate from this document so the paygate request carries
|
||||||
// Referer: https://edrpassenger.triaplc.com (the origin D-Money whitelists).
|
// Referer: https://edrpassenger.triaplc.com (the origin D-Money whitelists,
|
||||||
|
// and the one telebirr's paygate needs present).
|
||||||
window.location.replace(target);
|
window.location.replace(target);
|
||||||
}, [target]);
|
}, [target]);
|
||||||
|
|
||||||
|
|||||||
@@ -8,17 +8,60 @@
|
|||||||
* Every place that follows a `clientAction.url` from `/payments/initiate` runs
|
* Every place that follows a `clientAction.url` from `/payments/initiate` runs
|
||||||
* it through `resolvePaymentRedirectUrl()`; non-D-Money providers (Telebirr,
|
* it through `resolvePaymentRedirectUrl()`; non-D-Money providers (Telebirr,
|
||||||
* Waafi, card) pass through untouched.
|
* Waafi, card) pass through untouched.
|
||||||
|
*
|
||||||
|
* Telebirr's paygate turns out to behave the same way — the checkout URL loads
|
||||||
|
* when the portal navigates to it with `window.location.href` but fails with
|
||||||
|
* "the required parameter of the request is empty" when opened with no Referer
|
||||||
|
* (address-bar paste, or a native WebView's `loadRequest`). It is therefore
|
||||||
|
* accepted by `/go` (see `isBounceableCheckoutUrl`) so non-browser clients can
|
||||||
|
* borrow the portal's origin.
|
||||||
|
*
|
||||||
|
* On web, telebirr is deliberately NOT auto-wrapped: `resolvePaymentRedirectUrl`
|
||||||
|
* still returns telebirr URLs unchanged. The browser flow already supplies a
|
||||||
|
* Referer for free by navigating from the payment page, it is live, and putting
|
||||||
|
* an extra hop in front of it buys nothing.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const ALLOWED_HOSTS = (
|
/** Comma-separated env list → normalized, non-empty, lowercase hosts. */
|
||||||
|
function parseHosts(raw: string): string[] {
|
||||||
|
return raw
|
||||||
|
.split(",")
|
||||||
|
.map((h) => h.trim().toLowerCase())
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ALLOWED_HOSTS = parseHosts(
|
||||||
// Default allows both D-Money environments:
|
// Default allows both D-Money environments:
|
||||||
// test/sandbox → pgtest.d-money.dj (base domain d-money.dj)
|
// test/sandbox → pgtest.d-money.dj (base domain d-money.dj)
|
||||||
// production → pg.d-moneyservice.dj (base domain d-moneyservice.dj)
|
// production → pg.d-moneyservice.dj (base domain d-moneyservice.dj)
|
||||||
process.env.NEXT_PUBLIC_DMONEY_ALLOWED_HOSTS ?? "d-money.dj,d-moneyservice.dj"
|
process.env.NEXT_PUBLIC_DMONEY_ALLOWED_HOSTS ?? "d-money.dj,d-moneyservice.dj",
|
||||||
)
|
);
|
||||||
.split(",")
|
|
||||||
.map((h) => h.trim().toLowerCase())
|
const TELEBIRR_ALLOWED_HOSTS = parseHosts(
|
||||||
.filter(Boolean);
|
// Default allows both telebirr environments:
|
||||||
|
// test/sandbox → developerportal.ethiotelebirr.et (base domain ethiotelebirr.et)
|
||||||
|
// production → superapp.ethiomobilemoney.et (base domain ethiomobilemoney.et)
|
||||||
|
// Both serve the paygate on port 38443; the port is not part of the match.
|
||||||
|
process.env.NEXT_PUBLIC_TELEBIRR_ALLOWED_HOSTS ??
|
||||||
|
"ethiotelebirr.et,ethiomobilemoney.et",
|
||||||
|
);
|
||||||
|
|
||||||
|
/** True for https URLs whose host is (or is a subdomain of) one of `allowed`. */
|
||||||
|
function isHttpsHostMatch(
|
||||||
|
raw: string | null | undefined,
|
||||||
|
allowed: string[],
|
||||||
|
): raw is string {
|
||||||
|
if (!raw) return false;
|
||||||
|
let parsed: URL;
|
||||||
|
try {
|
||||||
|
parsed = new URL(raw);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (parsed.protocol !== "https:") return false;
|
||||||
|
const host = parsed.hostname.toLowerCase();
|
||||||
|
return allowed.some((a) => host === a || host.endsWith(`.${a}`));
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Origin the `/go` bounce page is served from — must be the exact origin D-Money
|
* Origin the `/go` bounce page is served from — must be the exact origin D-Money
|
||||||
@@ -34,18 +77,27 @@ const BOUNCE_ORIGIN = (
|
|||||||
|
|
||||||
/** True only for https URLs whose host is (or is a subdomain of) an allowlisted D-Money host. */
|
/** True only for https URLs whose host is (or is a subdomain of) an allowlisted D-Money host. */
|
||||||
export function isDMoneyCheckoutUrl(raw: string | null | undefined): raw is string {
|
export function isDMoneyCheckoutUrl(raw: string | null | undefined): raw is string {
|
||||||
if (!raw) return false;
|
return isHttpsHostMatch(raw, ALLOWED_HOSTS);
|
||||||
let parsed: URL;
|
}
|
||||||
try {
|
|
||||||
parsed = new URL(raw);
|
/** True only for https URLs whose host is (or is a subdomain of) an allowlisted telebirr host. */
|
||||||
} catch {
|
export function isTelebirrCheckoutUrl(
|
||||||
return false;
|
raw: string | null | undefined,
|
||||||
}
|
): raw is string {
|
||||||
if (parsed.protocol !== "https:") return false;
|
return isHttpsHostMatch(raw, TELEBIRR_ALLOWED_HOSTS);
|
||||||
const host = parsed.hostname.toLowerCase();
|
}
|
||||||
return ALLOWED_HOSTS.some(
|
|
||||||
(allowed) => host === allowed || host.endsWith(`.${allowed}`),
|
/**
|
||||||
);
|
* Providers whose paygate `/go` is allowed to bounce to.
|
||||||
|
*
|
||||||
|
* This is the allowlist that keeps `/go` from being an open redirect, so it must
|
||||||
|
* stay tight: only hosts that (a) we actually initiate payments against and
|
||||||
|
* (b) need a portal-origin `Referer` to open.
|
||||||
|
*/
|
||||||
|
export function isBounceableCheckoutUrl(
|
||||||
|
raw: string | null | undefined,
|
||||||
|
): raw is string {
|
||||||
|
return isDMoneyCheckoutUrl(raw) || isTelebirrCheckoutUrl(raw);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user