diff options
Diffstat (limited to 'packages/taler-util/src/taleruri.ts')
-rw-r--r-- | packages/taler-util/src/taleruri.ts | 632 |
1 files changed, 570 insertions, 62 deletions
diff --git a/packages/taler-util/src/taleruri.ts b/packages/taler-util/src/taleruri.ts index 09c70682a..b4f9db6ef 100644 --- a/packages/taler-util/src/taleruri.ts +++ b/packages/taler-util/src/taleruri.ts @@ -14,100 +14,249 @@ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ +/** + * @fileoverview + * Construction and parsing of taler:// URIs. + * Specification: https://lsd.gnunet.org/lsd0006/ + */ + +/** + * Imports. + */ +import { Codec, Context, DecodingError, renderContext } from "./codec.js"; import { canonicalizeBaseUrl } from "./helpers.js"; -import { URLSearchParams } from "./url.js"; +import { opFixedSuccess, opKnownTalerFailure } from "./operation.js"; +import { TalerErrorCode } from "./taler-error-codes.js"; +import { AmountString } from "./taler-types.js"; +import { URL, URLSearchParams } from "./url.js"; +/** + * A parsed taler URI. + */ +export type TalerUri = + | PayUriResult + | PayTemplateUriResult + | DevExperimentUri + | PayPullUriResult + | PayPushUriResult + | BackupRestoreUri + | RefundUriResult + | WithdrawUriResult + | WithdrawExchangeUri + | AddExchangeUri; + +declare const __action_str: unique symbol; +export type TalerUriString = string & { [__action_str]: true }; + +export function codecForTalerUriString(): Codec<TalerUriString> { + return { + decode(x: any, c?: Context): TalerUriString { + if (typeof x !== "string") { + throw new DecodingError( + `expected string at ${renderContext(c)} but got ${typeof x}`, + ); + } + if (parseTalerUri(x) === undefined) { + throw new DecodingError( + `invalid taler URI at ${renderContext(c)} but got "${x}"`, + ); + } + return x as TalerUriString; + }, + }; +} export interface PayUriResult { + type: TalerUriAction.Pay; merchantBaseUrl: string; orderId: string; sessionId: string; - claimToken: string | undefined; - noncePriv: string | undefined; + claimToken?: string; + noncePriv?: string; +} + +export type TemplateParams = { + amount?: string; + summary?: string; +}; + +export interface PayTemplateUriResult { + type: TalerUriAction.PayTemplate; + merchantBaseUrl: string; + templateId: string; + templateParams: TemplateParams; } export interface WithdrawUriResult { + type: TalerUriAction.Withdraw; bankIntegrationApiBaseUrl: string; withdrawalOperationId: string; } export interface RefundUriResult { + type: TalerUriAction.Refund; merchantBaseUrl: string; orderId: string; } -export interface TipUriResult { - merchantTipId: string; - merchantBaseUrl: string; +export interface PayPushUriResult { + type: TalerUriAction.PayPush; + exchangeBaseUrl: string; + contractPriv: string; +} + +export interface PayPullUriResult { + type: TalerUriAction.PayPull; + exchangeBaseUrl: string; + contractPriv: string; +} + +export interface DevExperimentUri { + type: TalerUriAction.DevExperiment; + devExperimentId: string; +} + +export interface BackupRestoreUri { + type: TalerUriAction.Restore; + walletRootPriv: string; + providers: Array<string>; +} + +export interface WithdrawExchangeUri { + type: TalerUriAction.WithdrawExchange; + exchangeBaseUrl: string; + exchangePub?: string; + amount?: AmountString; +} + +export interface AddExchangeUri { + type: TalerUriAction.AddExchange; + exchangeBaseUrl: string; } /** * Parse a taler[+http]://withdraw URI. * Return undefined if not passed a valid URI. */ -export function parseWithdrawUri(s: string): WithdrawUriResult | undefined { - const pi = parseProtoInfo(s, "withdraw"); - if (!pi) { - return undefined; +export function parseWithdrawUriWithError(s: string) { + const pi = parseProtoInfoWithError(s, "withdraw"); + if (pi.type === "fail") { + return pi; } - const parts = pi.rest.split("/"); + const parts = pi.body.rest.split("/"); if (parts.length < 2) { - return undefined; + return opKnownTalerFailure(TalerErrorCode.WALLET_TALER_URI_MALFORMED, { + code: TalerErrorCode.WALLET_TALER_URI_MALFORMED, + }); } const host = parts[0].toLowerCase(); const pathSegments = parts.slice(1, parts.length - 1); + /** + * The statement below does not tolerate a slash-ended URI. + * This results in (1) the withdrawalId being passed as the + * empty string, and (2) the bankIntegrationApi ending with the + * actual withdrawal operation ID. That can be fixed by + * trimming the parts-list. FIXME + */ const withdrawId = parts[parts.length - 1]; const p = [host, ...pathSegments].join("/"); - return { - bankIntegrationApiBaseUrl: canonicalizeBaseUrl(`${pi.innerProto}://${p}/`), + const result: WithdrawUriResult = { + type: TalerUriAction.Withdraw, + bankIntegrationApiBaseUrl: canonicalizeBaseUrl( + `${pi.body.innerProto}://${p}/`, + ), withdrawalOperationId: withdrawId, }; + return opFixedSuccess(result); +} + +/** + * + * @deprecated use parseWithdrawUriWithError + */ +export function parseWithdrawUri(s: string): WithdrawUriResult | undefined { + const r = parseWithdrawUriWithError(s); + if (r.type === "fail") return undefined; + return r.body; +} + +/** + * Parse a taler[+http]://withdraw URI. + * Return undefined if not passed a valid URI. + */ +export function parseAddExchangeUriWithError(s: string) { + const pi = parseProtoInfoWithError(s, "add-exchange"); + if (pi.type === "fail") { + return pi; + } + const parts = pi.body.rest.split("/"); + + if (parts.length < 2) { + return opKnownTalerFailure(TalerErrorCode.WALLET_TALER_URI_MALFORMED, { + code: TalerErrorCode.WALLET_TALER_URI_MALFORMED, + }); + } + + const host = parts[0].toLowerCase(); + const pathSegments = parts.slice(1, parts.length - 1); + /** + * The statement below does not tolerate a slash-ended URI. + * This results in (1) the withdrawalId being passed as the + * empty string, and (2) the bankIntegrationApi ending with the + * actual withdrawal operation ID. That can be fixed by + * trimming the parts-list. FIXME + */ + const p = [host, ...pathSegments].join("/"); + + const result: AddExchangeUri = { + type: TalerUriAction.AddExchange, + exchangeBaseUrl: canonicalizeBaseUrl( + `${pi.body.innerProto}://${p}/`, + ), + }; + return opFixedSuccess(result); +} + +/** + * + * @deprecated use parseWithdrawUriWithError + */ +export function parseAddExchangeUri(s: string): AddExchangeUri | undefined { + const r = parseAddExchangeUriWithError(s); + if (r.type === "fail") return undefined; + return r.body; } +/** + * @deprecated use TalerUriAction + */ export enum TalerUriType { TalerPay = "taler-pay", + TalerTemplate = "taler-template", + TalerPayTemplate = "taler-pay-template", TalerWithdraw = "taler-withdraw", TalerTip = "taler-tip", TalerRefund = "taler-refund", - TalerNotifyReserve = "taler-notify-reserve", + TalerPayPush = "taler-pay-push", + TalerPayPull = "taler-pay-pull", + TalerRecovery = "taler-recovery", + TalerDevExperiment = "taler-dev-experiment", Unknown = "unknown", } -/** - * Classify a taler:// URI. - */ -export function classifyTalerUri(s: string): TalerUriType { - const sl = s.toLowerCase(); - if (sl.startsWith("taler://pay/")) { - return TalerUriType.TalerPay; - } - if (sl.startsWith("taler+http://pay/")) { - return TalerUriType.TalerPay; - } - if (sl.startsWith("taler://tip/")) { - return TalerUriType.TalerTip; - } - if (sl.startsWith("taler+http://tip/")) { - return TalerUriType.TalerTip; - } - if (sl.startsWith("taler://refund/")) { - return TalerUriType.TalerRefund; - } - if (sl.startsWith("taler+http://refund/")) { - return TalerUriType.TalerRefund; - } - if (sl.startsWith("taler://withdraw/")) { - return TalerUriType.TalerWithdraw; - } - if (sl.startsWith("taler+http://withdraw/")) { - return TalerUriType.TalerWithdraw; - } - if (sl.startsWith("taler://notify-reserve/")) { - return TalerUriType.TalerNotifyReserve; - } - return TalerUriType.Unknown; +export enum TalerUriAction { + Pay = "pay", + Withdraw = "withdraw", + Refund = "refund", + PayPull = "pay-pull", + PayPush = "pay-push", + PayTemplate = "pay-template", + Restore = "restore", + DevExperiment = "dev-experiment", + WithdrawExchange = "withdraw-exchange", + AddExchange = "add-exchange", } interface TalerUriProtoInfo { @@ -136,6 +285,95 @@ function parseProtoInfo( } } +function parseProtoInfoWithError(s: string, action: string) { + if ( + !s.toLowerCase().startsWith("taler://") && + !s.toLowerCase().startsWith("taler+http://") + ) { + return opKnownTalerFailure(TalerErrorCode.WALLET_TALER_URI_MALFORMED, { + code: TalerErrorCode.WALLET_TALER_URI_MALFORMED, + }); + } + const pfxPlain = `taler://${action}/`; + const pfxHttp = `taler+http://${action}/`; + if (s.toLowerCase().startsWith(pfxPlain)) { + return opFixedSuccess({ + innerProto: "https", + rest: s.substring(pfxPlain.length), + }); + } else if (s.toLowerCase().startsWith(pfxHttp)) { + return opFixedSuccess({ + innerProto: "http", + rest: s.substring(pfxHttp.length), + }); + } else { + return opKnownTalerFailure(TalerErrorCode.WALLET_TALER_URI_MALFORMED, { + code: TalerErrorCode.WALLET_TALER_URI_MALFORMED, + }); + } +} + +type Parser = (s: string) => TalerUri | undefined; +const parsers: { [A in TalerUriAction]: Parser } = { + [TalerUriAction.Pay]: parsePayUri, + [TalerUriAction.PayPull]: parsePayPullUri, + [TalerUriAction.PayPush]: parsePayPushUri, + [TalerUriAction.PayTemplate]: parsePayTemplateUri, + [TalerUriAction.Restore]: parseRestoreUri, + [TalerUriAction.Refund]: parseRefundUri, + [TalerUriAction.Withdraw]: parseWithdrawUri, + [TalerUriAction.DevExperiment]: parseDevExperimentUri, + [TalerUriAction.WithdrawExchange]: parseWithdrawExchangeUri, + [TalerUriAction.AddExchange]: parseAddExchangeUri, +}; + +export function parseTalerUri(string: string): TalerUri | undefined { + const https = string.startsWith("taler://"); + const http = string.startsWith("taler+http://"); + if (!https && !http) return undefined; + const actionStart = https ? 8 : 13; + const actionEnd = string.indexOf("/", actionStart + 1); + const action = string.substring(actionStart, actionEnd); + const found = Object.values(TalerUriAction).find((x) => x === action); + if (!found) return undefined; + return parsers[found](string); +} + +export function stringifyTalerUri(uri: TalerUri): string { + switch (uri.type) { + case TalerUriAction.DevExperiment: { + return stringifyDevExperimentUri(uri); + } + case TalerUriAction.Pay: { + return stringifyPayUri(uri); + } + case TalerUriAction.PayPull: { + return stringifyPayPullUri(uri); + } + case TalerUriAction.PayPush: { + return stringifyPayPushUri(uri); + } + case TalerUriAction.PayTemplate: { + return stringifyPayTemplateUri(uri); + } + case TalerUriAction.Restore: { + return stringifyRestoreUri(uri); + } + case TalerUriAction.Refund: { + return stringifyRefundUri(uri); + } + case TalerUriAction.Withdraw: { + return stringifyWithdrawUri(uri); + } + case TalerUriAction.WithdrawExchange: { + return stringifyWithdrawExchange(uri); + } + case TalerUriAction.AddExchange: { + return stringifyAddExchange(uri); + } + } +} + /** * Parse a taler[+http]://pay URI. * Return undefined if not passed a valid URI. @@ -161,37 +399,128 @@ export function parsePayUri(s: string): PayUriResult | undefined { const merchantBaseUrl = canonicalizeBaseUrl(`${pi.innerProto}://${p}/`); return { + type: TalerUriAction.Pay, merchantBaseUrl, orderId, - sessionId: sessionId, + sessionId, claimToken, noncePriv, }; } -/** - * Parse a taler[+http]://tip URI. - * Return undefined if not passed a valid URI. - */ -export function parseTipUri(s: string): TipUriResult | undefined { - const pi = parseProtoInfo(s, "tip"); +export function parsePayTemplateUri( + uriString: string, +): PayTemplateUriResult | undefined { + const pi = parseProtoInfo(uriString, TalerUriAction.PayTemplate); if (!pi) { return undefined; } - const c = pi?.rest.split("?"); + const c = pi.rest.split("?"); + const parts = c[0].split("/"); if (parts.length < 2) { return undefined; } + + const q = new URLSearchParams(c[1] ?? ""); + const params: Record<string, string> = {}; + q.forEach((v, k) => { + params[k] = v; + }); + const host = parts[0].toLowerCase(); - const tipId = parts[parts.length - 1]; + const templateId = parts[parts.length - 1]; const pathSegments = parts.slice(1, parts.length - 1); - const p = [host, ...pathSegments].join("/"); - const merchantBaseUrl = canonicalizeBaseUrl(`${pi.innerProto}://${p}/`); + const hostAndSegments = [host, ...pathSegments].join("/"); + const merchantBaseUrl = canonicalizeBaseUrl( + `${pi.innerProto}://${hostAndSegments}/`, + ); return { + type: TalerUriAction.PayTemplate, merchantBaseUrl, - merchantTipId: tipId, + templateId, + templateParams: params, + }; +} + +export function parsePayPushUri(s: string): PayPushUriResult | undefined { + const pi = parseProtoInfo(s, TalerUriAction.PayPush); + if (!pi) { + return undefined; + } + const c = pi?.rest.split("?"); + const parts = c[0].split("/"); + if (parts.length < 2) { + return undefined; + } + const host = parts[0].toLowerCase(); + const contractPriv = parts[parts.length - 1]; + const pathSegments = parts.slice(1, parts.length - 1); + const hostAndSegments = [host, ...pathSegments].join("/"); + const exchangeBaseUrl = canonicalizeBaseUrl( + `${pi.innerProto}://${hostAndSegments}/`, + ); + + return { + type: TalerUriAction.PayPush, + exchangeBaseUrl, + contractPriv, + }; +} + +export function parsePayPullUri(s: string): PayPullUriResult | undefined { + const pi = parseProtoInfo(s, TalerUriAction.PayPull); + if (!pi) { + return undefined; + } + const c = pi?.rest.split("?"); + const parts = c[0].split("/"); + if (parts.length < 2) { + return undefined; + } + const host = parts[0].toLowerCase(); + const contractPriv = parts[parts.length - 1]; + const pathSegments = parts.slice(1, parts.length - 1); + const hostAndSegments = [host, ...pathSegments].join("/"); + const exchangeBaseUrl = canonicalizeBaseUrl( + `${pi.innerProto}://${hostAndSegments}/`, + ); + + return { + type: TalerUriAction.PayPull, + exchangeBaseUrl, + contractPriv, + }; +} + +export function parseWithdrawExchangeUri( + s: string, +): WithdrawExchangeUri | undefined { + const pi = parseProtoInfo(s, "withdraw-exchange"); + if (!pi) { + return undefined; + } + const c = pi?.rest.split("?"); + const parts = c[0].split("/"); + if (parts.length < 1) { + return undefined; + } + const host = parts[0].toLowerCase(); + const exchangePub = parts.length > 1 ? parts[parts.length - 1] : undefined; + const pathSegments = parts.slice(1, parts.length - 1); + const hostAndSegments = [host, ...pathSegments].join("/"); + const exchangeBaseUrl = canonicalizeBaseUrl( + `${pi.innerProto}://${hostAndSegments}/`, + ); + const q = new URLSearchParams(c[1] ?? ""); + const amount = (q.get("a") ?? undefined) as AmountString | undefined; + + return { + type: TalerUriAction.WithdrawExchange, + exchangeBaseUrl, + exchangePub: exchangePub != "" ? exchangePub : undefined, + amount, }; } @@ -213,11 +542,190 @@ export function parseRefundUri(s: string): RefundUriResult | undefined { const sessionId = parts[parts.length - 1]; const orderId = parts[parts.length - 2]; const pathSegments = parts.slice(1, parts.length - 2); - const p = [host, ...pathSegments].join("/"); - const merchantBaseUrl = canonicalizeBaseUrl(`${pi.innerProto}://${p}/`); + const hostAndSegments = [host, ...pathSegments].join("/"); + const merchantBaseUrl = canonicalizeBaseUrl( + `${pi.innerProto}://${hostAndSegments}/`, + ); return { + type: TalerUriAction.Refund, merchantBaseUrl, orderId, }; } + +export function parseDevExperimentUri(s: string): DevExperimentUri | undefined { + const pi = parseProtoInfo(s, "dev-experiment"); + const c = pi?.rest.split("?"); + if (!c) { + return undefined; + } + const parts = c[0].split("/"); + return { + type: TalerUriAction.DevExperiment, + devExperimentId: parts[0], + }; +} + +export function parseRestoreUri(uri: string): BackupRestoreUri | undefined { + const pi = parseProtoInfo(uri, "restore"); + if (!pi) { + return undefined; + } + const c = pi.rest.split("?"); + const parts = c[0].split("/"); + if (parts.length < 2) { + return undefined; + } + + const walletRootPriv = parts[0]; + if (!walletRootPriv) return undefined; + const providers = new Array<string>(); + parts[1].split(",").map((name) => { + const url = canonicalizeBaseUrl( + `${pi.innerProto}://${decodeURIComponent(name)}/`, + ); + providers.push(url); + }); + return { + type: TalerUriAction.Restore, + walletRootPriv, + providers, + }; +} + +// ================================================ +// To string functions +// ================================================ + +export function stringifyPayUri({ + merchantBaseUrl, + orderId, + sessionId, + claimToken, + noncePriv, +}: Omit<PayUriResult, "type">): string { + const { proto, path, query } = getUrlInfo(merchantBaseUrl, { + c: claimToken, + n: noncePriv, + }); + return `${proto}://pay/${path}${orderId}/${sessionId}${query}`; +} + +export function stringifyPayPullUri({ + contractPriv, + exchangeBaseUrl, +}: Omit<PayPullUriResult, "type">): string { + const { proto, path } = getUrlInfo(exchangeBaseUrl); + return `${proto}://pay-pull/${path}${contractPriv}`; +} + +export function stringifyPayPushUri({ + contractPriv, + exchangeBaseUrl, +}: Omit<PayPushUriResult, "type">): string { + const { proto, path } = getUrlInfo(exchangeBaseUrl); + + return `${proto}://pay-push/${path}${contractPriv}`; +} + +export function stringifyRestoreUri({ + providers, + walletRootPriv, +}: Omit<BackupRestoreUri, "type">): string { + const list = providers + .map((url) => `${encodeURIComponent(new URL(url).href)}`) + .join(","); + return `taler://restore/${walletRootPriv}/${list}`; +} + +export function stringifyWithdrawExchange({ + exchangeBaseUrl, + exchangePub, + amount, +}: Omit<WithdrawExchangeUri, "type">): string { + const { proto, path, query } = getUrlInfo(exchangeBaseUrl, { + a: amount, + }); + return `${proto}://withdraw-exchange/${path}${exchangePub ?? ""}${query}`; +} + +export function stringifyAddExchange({ + exchangeBaseUrl, +}: Omit<AddExchangeUri, "type">): string { + const { proto, path } = getUrlInfo(exchangeBaseUrl); + return `${proto}://add-exchange/${path}`; +} + +export function stringifyDevExperimentUri({ + devExperimentId, +}: Omit<DevExperimentUri, "type">): string { + return `taler://dev-experiment/${devExperimentId}`; +} + +export function stringifyPayTemplateUri({ + merchantBaseUrl, + templateId, + templateParams, +}: Omit<PayTemplateUriResult, "type">): string { + const { proto, path, query } = getUrlInfo(merchantBaseUrl, templateParams); + return `${proto}://pay-template/${path}${templateId}${query}`; +} + +export function stringifyRefundUri({ + merchantBaseUrl, + orderId, +}: Omit<RefundUriResult, "type">): string { + const { proto, path } = getUrlInfo(merchantBaseUrl); + return `${proto}://refund/${path}${orderId}/`; +} + +export function stringifyWithdrawUri({ + bankIntegrationApiBaseUrl, + withdrawalOperationId, +}: Omit<WithdrawUriResult, "type">): string { + const { proto, path } = getUrlInfo(bankIntegrationApiBaseUrl); + return `${proto}://withdraw/${path}${withdrawalOperationId}`; +} + +/** + * Use baseUrl to defined http or https + * create path using host+port+pathname + * use params to create a query parameter string or empty + */ +function getUrlInfo( + baseUrl: string, + params: Record<string, string | undefined> = {}, +): { proto: string; path: string; query: string } { + const url = new URL(baseUrl); + let proto: string; + if (url.protocol === "https:") { + proto = "taler"; + } else if (url.protocol === "http:") { + proto = "taler+http"; + } else { + throw Error(`Unsupported URL protocol in ${baseUrl}`); + } + let path = url.hostname; + if (url.port) { + path = path + ":" + url.port; + } + if (url.pathname) { + path = path + url.pathname; + } + if (!path.endsWith("/")) { + path = path + "/"; + } + + const qp = new URLSearchParams(); + let withParams = false; + Object.entries(params).forEach(([name, value]) => { + if (value !== undefined) { + withParams = true; + qp.append(name, value); + } + }); + const query = withParams ? "?" + qp.toString() : ""; + + return { proto, path, query }; +} |