taler-typescript-core

Wallet core logic and WebUIs for various components
Log | Files | Refs | Submodules | README | LICENSE

commit 2bc77f355f74bd98c6ba748c5db1822a243aaa36
parent 75209143aace1fb78caedee35bcabf7eee8814c8
Author: Sebastian <sebasjm@gmail.com>
Date:   Fri, 14 Feb 2025 16:51:05 -0300

fix #9535

Diffstat:
Mpackages/taler-wallet-webextension/src/utils/index.ts | 7++++---
Mpackages/taler-wallet-webextension/src/wallet/QrReader.tsx | 360+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------
2 files changed, 309 insertions(+), 58 deletions(-)

diff --git a/packages/taler-wallet-webextension/src/utils/index.ts b/packages/taler-wallet-webextension/src/utils/index.ts @@ -15,7 +15,7 @@ */ import { createElement, VNode } from "preact"; -import { useCallback, useMemo } from "preact/hooks"; +import { useMemo } from "preact/hooks"; function getJsonIfOk(r: Response): Promise<any> { if (r.ok) { @@ -27,7 +27,8 @@ function getJsonIfOk(r: Response): Promise<any> { } throw new Error( - `Try another server: (${r.status}) ${r.statusText || "internal server error" + `Try another server: (${r.status}) ${ + r.statusText || "internal server error" }`, ); } @@ -104,7 +105,7 @@ export function compose<SType extends { status: string }, PType>( // TheComponent.name = `${name}`; return useMemo(() => { - return TheComponent + return TheComponent; }, [stateHook]); } diff --git a/packages/taler-wallet-webextension/src/wallet/QrReader.tsx b/packages/taler-wallet-webextension/src/wallet/QrReader.tsx @@ -16,12 +16,21 @@ import { assertUnreachable, + HttpStatusCode, parseTalerUri, + TalerCoreBankHttpClient, + TalerError, + TalerExchangeHttpClient, + TalerMerchantInstanceHttpClient, TalerUri, TalerUriAction, TranslatedString, } from "@gnu-taler/taler-util"; -import { useTranslationContext } from "@gnu-taler/web-util/browser"; +import { + BrowserFetchHttpLib, + InternationalizationAPI, + useTranslationContext, +} from "@gnu-taler/web-util/browser"; import { css } from "@linaria/core"; import { styled } from "@linaria/react"; import jsQR, * as pr from "jsqr"; @@ -205,6 +214,29 @@ async function waitUntilReady(video: HTMLVideoElement): Promise<void> { }); } +function debounce(func: typeof testValidUri, wait = 500): typeof testValidUri { + let timeout: ReturnType<typeof setTimeout>; + type Args = Parameters<typeof testValidUri>; + type Ret = ReturnType<typeof testValidUri>; + + function debounced(...args: Args): Promise<Ret> { + clearTimeout(timeout); + return new Promise<Ret>((res, rej) => { + timeout = setTimeout(() => { + func(...args) + .then((msg) => { + res(Promise.resolve(msg)); + }) + .catch(rej); + }, wait); + }); + } + + return debounced as any; +} + +const testValidUriDebounced = debounce(testValidUri); + export function QrReaderPage({ onDetected }: Props): VNode { const videoRef = useRef<HTMLVideoElement>(null); const canvasRef = useRef<HTMLCanvasElement>(null); @@ -214,36 +246,55 @@ export function QrReaderPage({ onDetected }: Props): VNode { const { i18n } = useTranslationContext(); - function onChangeDetect(str: string) { + async function onChangeDetect(str: string) { if (str) { const uri = parseTalerUri(str.toLowerCase()); if (!uri) { setError( i18n.str`URI is not valid. Taler URI should start with "taler://"`, ); - } else { - onDetected(uri); - setError(undefined); + setValue(str); + return; } + setError(i18n.str`checking...`); + const errorMsg = await testValidUriDebounced(uri, i18n); + if (errorMsg) { + setError(errorMsg); + setValue(str); + return; + } + onDetected(uri); + setError(undefined); + setValue(str); } else { setError(undefined); + setValue(str); } - setValue(str); } function onChange(str: string) { if (str) { - if (!parseTalerUri(str.toLowerCase())) { + const uri = parseTalerUri(str.toLowerCase()); + if (!uri) { setError( i18n.str`URI is not valid. Taler URI should start with "taler://"`, ); + setValue(str); } else { - setError(undefined); + setError(i18n.str`checking...`); + setValue(str); + testValidUriDebounced(uri, i18n).then((errorMsg) => { + if (errorMsg) { + setError(errorMsg); + return; + } + setError(undefined); + }); } } else { setError(undefined); + setValue(str); } - setValue(str); } async function startVideo() { @@ -264,14 +315,14 @@ export function QrReaderPage({ onDetected }: Props): VNode { try { const code = await createCanvasFromVideo(video, canvasRef.current); if (code) { - onChangeDetect(code); + await onChangeDetect(code); setShow("canvas"); } stream.getTracks().forEach((e) => { e.stop(); }); } catch (error) { - setError(i18n.str`something unexpected happen: ${error}`); + setError(i18n.str`Unexpected error happen reading the camera: ${error}`); } } @@ -284,13 +335,13 @@ export function QrReaderPage({ onDetected }: Props): VNode { try { const code = await createCanvasFromFile(fileContent, canvasRef.current); if (code) { - onChangeDetect(code); + await onChangeDetect(code); setShow("canvas"); } else { setError(i18n.str`Could not found a QR code in the file`); } } catch (error) { - setError(i18n.str`something unexpected happen: ${error}`); + setError(i18n.str`Unexpected error happen reading the file: ${error}`); } } const uri = parseTalerUri(value.toLowerCase()); @@ -313,53 +364,61 @@ export function QrReaderPage({ onDetected }: Props): VNode { onChange={onChange} /> </div> - {uri && ( - <Button - disabled={!!error} - variant="contained" - color="success" - onClick={async () => { - if (uri) onDetected(uri); - }} - > - {(function (talerUri: TalerUri): VNode { - switch (talerUri.type) { - case TalerUriAction.Pay: - return <i18n.Translate>Pay invoice</i18n.Translate>; - case TalerUriAction.Withdraw: - return ( - <i18n.Translate>Withdrawal from bank</i18n.Translate> - ); - case TalerUriAction.Refund: - return <i18n.Translate>Claim refund</i18n.Translate>; - case TalerUriAction.PayPull: - return <i18n.Translate>Pay invoice</i18n.Translate>; - case TalerUriAction.PayPush: - return <i18n.Translate>Accept payment</i18n.Translate>; - case TalerUriAction.PayTemplate: - return <i18n.Translate>Complete order</i18n.Translate>; - case TalerUriAction.Restore: - return <i18n.Translate>Restore wallet</i18n.Translate>; - case TalerUriAction.DevExperiment: - return <i18n.Translate>Enable experiment</i18n.Translate>; - case TalerUriAction.WithdrawExchange: - return ( - <i18n.Translate>Withdraw from exchange</i18n.Translate> - ); - case TalerUriAction.AddExchange: - return <i18n.Translate>Add exchange</i18n.Translate>; - default: { - assertUnreachable(talerUri); - } - } - })(uri)} - </Button> - )} </div> <Grid container justifyContent="space-around" columns={2}> <Grid item xs={2}> <p>{error && <Alert severity="error">{error}</Alert>}</p> </Grid> + {uri && ( + <Grid item xs={2}> + <p> + <Button + disabled={!!error} + variant="contained" + color="success" + onClick={async () => { + if (uri) onDetected(uri); + }} + > + {(function (talerUri: TalerUri): VNode { + switch (talerUri.type) { + case TalerUriAction.Pay: + return <i18n.Translate>Pay invoice</i18n.Translate>; + case TalerUriAction.Withdraw: + return ( + <i18n.Translate>Withdrawal from bank</i18n.Translate> + ); + case TalerUriAction.Refund: + return <i18n.Translate>Claim refund</i18n.Translate>; + case TalerUriAction.PayPull: + return <i18n.Translate>Pay invoice</i18n.Translate>; + case TalerUriAction.PayPush: + return <i18n.Translate>Accept payment</i18n.Translate>; + case TalerUriAction.PayTemplate: + return <i18n.Translate>Complete order</i18n.Translate>; + case TalerUriAction.Restore: + return <i18n.Translate>Restore wallet</i18n.Translate>; + case TalerUriAction.DevExperiment: + return ( + <i18n.Translate>Enable experiment</i18n.Translate> + ); + case TalerUriAction.WithdrawExchange: + return ( + <i18n.Translate> + Withdraw from exchange + </i18n.Translate> + ); + case TalerUriAction.AddExchange: + return <i18n.Translate>Add exchange</i18n.Translate>; + default: { + assertUnreachable(talerUri); + } + } + })(uri)} + </Button> + </p> + </Grid> + )} <Grid item xs={2}> <p> <Button variant="contained" onClick={startVideo}> @@ -390,3 +449,194 @@ export function QrReaderPage({ onDetected }: Props): VNode { </Container> ); } +const httpFetch: any = new BrowserFetchHttpLib(); + +async function testValidUri( + uri: TalerUri, + i18n: InternationalizationAPI, +): Promise<TranslatedString | undefined> { + switch (uri.type) { + case TalerUriAction.Pay: { + const errorExchange = await checkMerchantUrl(uri.merchantBaseUrl, i18n); + if (errorExchange) { + return errorExchange; + } + return undefined; + } + case TalerUriAction.Withdraw: { + const errorExchange = await checkBankUrl( + uri.bankIntegrationApiBaseUrl, + i18n, + ); + if (errorExchange) { + return errorExchange; + } + return undefined; + } + case TalerUriAction.Refund: { + const errorExchange = await checkMerchantUrl(uri.merchantBaseUrl, i18n); + if (errorExchange) { + return errorExchange; + } + return undefined; + } + case TalerUriAction.PayTemplate: { + const errorExchange = await checkMerchantUrl(uri.merchantBaseUrl, i18n); + if (errorExchange) { + return errorExchange; + } + return undefined; + } + case TalerUriAction.Restore: { + return undefined; + } + case TalerUriAction.DevExperiment: { + return undefined; + } + case TalerUriAction.PayPull: { + const errorExchange = await checkExchangeUrl(uri.exchangeBaseUrl, i18n); + if (errorExchange) { + return errorExchange; + } + return undefined; + } + case TalerUriAction.PayPush: { + const errorExchange = await checkExchangeUrl(uri.exchangeBaseUrl, i18n); + if (errorExchange) { + return errorExchange; + } + return undefined; + } + case TalerUriAction.AddExchange: { + const errorExchange = await checkExchangeUrl(uri.exchangeBaseUrl, i18n); + if (errorExchange) { + return errorExchange; + } + return undefined; + } + case TalerUriAction.WithdrawExchange: { + const errorExchange = await checkExchangeUrl(uri.exchangeBaseUrl, i18n); + if (errorExchange) { + return errorExchange; + } + return undefined; + } + default: { + assertUnreachable(uri); + } + } +} + +async function checkExchangeUrl( + baseUrl: string, + i18n: InternationalizationAPI, +): Promise<TranslatedString | undefined> { + let url: URL | undefined = undefined; + try { + url = new URL("./", baseUrl); + } catch (e) { + return i18n.str`The exchange URL is invalid.`; + } + if (!baseUrl.endsWith("/")) { + return i18n.str`The exchange URL should end with '/'`; + } + try { + const config = await new TalerExchangeHttpClient( + url.href, + httpFetch, + ).getConfig(); + if (config.type === "ok") { + return undefined; + } else { + switch (config.case) { + case HttpStatusCode.NotFound: { + return i18n.str`Couldn't found an exchange in the URL specified.`; + } + default: { + assertUnreachable(config.case); + } + } + } + } catch (e) { + if (e instanceof TalerError && e.errorDetail.detail) { + return i18n.str`HTTP request failed to ${url.href}: ${e.errorDetail.detail}`; + } + return i18n.str`HTTP request failed to ${url.href}`; + } +} + +async function checkMerchantUrl( + baseUrl: string, + i18n: InternationalizationAPI, +): Promise<TranslatedString | undefined> { + let url: URL | undefined = undefined; + try { + url = new URL("./", baseUrl); + } catch (e) { + return i18n.str`The merchant URL is invalid.`; + } + if (!baseUrl.endsWith("/")) { + return i18n.str`The merchant URL should end with '/'`; + } + try { + const config = await new TalerMerchantInstanceHttpClient( + url.href, + httpFetch, + ).getConfig(); + if (config.type === "ok") { + return undefined; + } else { + switch (config.case) { + case HttpStatusCode.NotFound: { + return i18n.str`Couldn't found an merchant in the URL specified.`; + } + default: { + assertUnreachable(config.case); + } + } + } + } catch (e) { + if (e instanceof TalerError && e.errorDetail.detail) { + return i18n.str`HTTP request failed to ${url.href}: ${e.errorDetail.detail}`; + } + return i18n.str`HTTP request failed to ${url.href}`; + } +} + +async function checkBankUrl( + baseUrl: string, + i18n: InternationalizationAPI, +): Promise<TranslatedString | undefined> { + let url: URL | undefined = undefined; + try { + url = new URL("./", baseUrl); + } catch (e) { + return i18n.str`The bank URL is invalid.`; + } + if (!baseUrl.endsWith("/")) { + return i18n.str`The bank URL should end with '/'`; + } + try { + const config = await new TalerCoreBankHttpClient( + url.href, + httpFetch, + ).getConfig(); + if (config.type === "ok") { + return undefined; + } else { + switch (config.case) { + case HttpStatusCode.NotFound: { + return i18n.str`Couldn't found an bank in the URL specified.`; + } + default: { + assertUnreachable(config.case); + } + } + } + } catch (e) { + if (e instanceof TalerError && e.errorDetail.detail) { + return i18n.str`HTTP request failed to ${url.href}: ${e.errorDetail.detail}`; + } + return i18n.str`HTTP request failed to ${url.href}`; + } +}