/* This file is part of GNU Taler (C) 2019-2020 Taler Systems S.A. GNU Taler is free software; you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation; either version 3, or (at your option) any later version. GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with GNU Taler; see the file COPYING. If not, see */ import { Codec, Context, DecodingError, renderContext } from "./codec.js"; import { canonicalizeBaseUrl } from "./helpers.js"; import { AmountString } from "./taler-types.js"; import { URLSearchParams, URL } from "./url.js"; export type TalerUri = | PayUriResult | PayTemplateUriResult | DevExperimentUri | PayPullUriResult | PayPushUriResult | BackupRestoreUri | RefundUriResult | RewardUriResult | WithdrawUriResult | ExchangeUri | WithdrawExchangeUri | AuditorUri; declare const __action_str: unique symbol; export type TalerActionString = string & { [__action_str]: true }; export function codecForTalerActionString(): Codec { return { decode(x: any, c?: Context): TalerActionString { 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 action at ${renderContext(c)} but got "${x}"`, ); } return x as TalerActionString; }, }; } export interface PayUriResult { type: TalerUriAction.Pay; merchantBaseUrl: string; orderId: string; sessionId: string; claimToken?: string; noncePriv?: string; } export interface PayTemplateUriResult { type: TalerUriAction.PayTemplate; merchantBaseUrl: string; templateId: string; templateParams: Record; } export interface WithdrawUriResult { type: TalerUriAction.Withdraw; bankIntegrationApiBaseUrl: string; withdrawalOperationId: string; } export interface RefundUriResult { type: TalerUriAction.Refund; merchantBaseUrl: string; orderId: string; } export interface RewardUriResult { type: TalerUriAction.Reward; merchantBaseUrl: string; merchantRewardId: string; } export interface ExchangeUri { type: TalerUriAction.Exchange; exchangeBaseUrl: string; exchangePub: string; } export interface AuditorUri { type: TalerUriAction.Auditor; auditorBaseUrl: string; auditorPub: 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; } export interface WithdrawExchangeUri { type: TalerUriAction.WithdrawExchange; exchangeBaseUrl: string; exchangePub: string; amount?: AmountString; } /** * 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; } const parts = pi.rest.split("/"); if (parts.length < 2) { return undefined; } 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 { type: TalerUriAction.Withdraw, bankIntegrationApiBaseUrl: canonicalizeBaseUrl(`${pi.innerProto}://${p}/`), withdrawalOperationId: withdrawId, }; } /** * @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", TalerPayPush = "taler-pay-push", TalerPayPull = "taler-pay-pull", TalerRecovery = "taler-recovery", TalerDevExperiment = "taler-dev-experiment", Unknown = "unknown", } const talerActionPayPull = "pay-pull"; const talerActionPayPush = "pay-push"; const talerActionPayTemplate = "pay-template"; export enum TalerUriAction { Pay = "pay", Withdraw = "withdraw", Refund = "refund", Reward = "reward", PayPull = "pay-pull", PayPush = "pay-push", PayTemplate = "pay-template", Exchange = "exchange", Auditor = "auditor", Restore = "restore", DevExperiment = "dev-experiment", WithdrawExchange = "withdraw-exchange", } interface TalerUriProtoInfo { innerProto: "http" | "https"; rest: string; } function parseProtoInfo( s: string, action: string, ): TalerUriProtoInfo | undefined { const pfxPlain = `taler://${action}/`; const pfxHttp = `taler+http://${action}/`; if (s.toLowerCase().startsWith(pfxPlain)) { return { innerProto: "https", rest: s.substring(pfxPlain.length), }; } else if (s.toLowerCase().startsWith(pfxHttp)) { return { innerProto: "http", rest: s.substring(pfxHttp.length), }; } else { return undefined; } } 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.Reward]: parseRewardUri, [TalerUriAction.Withdraw]: parseWithdrawUri, [TalerUriAction.DevExperiment]: parseDevExperimentUri, [TalerUriAction.Exchange]: parseExchangeUri, [TalerUriAction.Auditor]: parseAuditorUri, [TalerUriAction.WithdrawExchange]: parseWithdrawExchangeUri, }; 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.Reward: { return stringifyRewardUri(uri); } case TalerUriAction.Withdraw: { return stringifyWithdrawUri(uri); } case TalerUriAction.Exchange: { return stringifyExchangeUri(uri); } case TalerUriAction.WithdrawExchange: { return stringifyWithdrawExchange(uri); } case TalerUriAction.Auditor: { return stringifyAuditorUri(uri); } } } /** * Parse a taler[+http]://pay URI. * Return undefined if not passed a valid URI. */ export function parsePayUri(s: string): PayUriResult | undefined { const pi = parseProtoInfo(s, "pay"); if (!pi) { return undefined; } const c = pi?.rest.split("?"); const q = new URLSearchParams(c[1] ?? ""); const claimToken = q.get("c") ?? undefined; const noncePriv = q.get("n") ?? undefined; const parts = c[0].split("/"); if (parts.length < 3) { return undefined; } const host = parts[0].toLowerCase(); 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}/`); return { type: TalerUriAction.Pay, merchantBaseUrl, orderId, sessionId, claimToken, noncePriv, }; } export function parsePayTemplateUri( uriString: string, ): PayTemplateUriResult | undefined { const pi = parseProtoInfo(uriString, talerActionPayTemplate); if (!pi) { return undefined; } 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 = {}; q.forEach((v, k) => { params[k] = v; }); const host = parts[0].toLowerCase(); const templateId = parts[parts.length - 1]; const pathSegments = parts.slice(1, parts.length - 1); const hostAndSegments = [host, ...pathSegments].join("/"); const merchantBaseUrl = canonicalizeBaseUrl( `${pi.innerProto}://${hostAndSegments}/`, ); return { type: TalerUriAction.PayTemplate, merchantBaseUrl, templateId, templateParams: params, }; } export function parsePayPushUri(s: string): PayPushUriResult | undefined { const pi = parseProtoInfo(s, talerActionPayPush); 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, talerActionPayPull); 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, }; } /** * Parse a taler[+http]://reward URI. * Return undefined if not passed a valid URI. */ export function parseRewardUri(s: string): RewardUriResult | undefined { const pi = parseProtoInfo(s, "reward"); 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 rewardId = parts[parts.length - 1]; const pathSegments = parts.slice(1, parts.length - 1); const hostAndSegments = [host, ...pathSegments].join("/"); const merchantBaseUrl = canonicalizeBaseUrl( `${pi.innerProto}://${hostAndSegments}/`, ); return { type: TalerUriAction.Reward, merchantBaseUrl, merchantRewardId: rewardId, }; } export function parseExchangeUri(s: string): ExchangeUri | undefined { const pi = parseProtoInfo(s, "exchange"); 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 exchangePub = 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.Exchange, exchangeBaseUrl, exchangePub, }; } 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 < 2) { return undefined; } const host = parts[0].toLowerCase(); const exchangePub = parts[parts.length - 1]; 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, amount, }; } export function parseAuditorUri(s: string): AuditorUri | undefined { const pi = parseProtoInfo(s, "auditor"); 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 auditorPub = parts[parts.length - 1]; const pathSegments = parts.slice(1, parts.length - 1); const hostAndSegments = [host, ...pathSegments].join("/"); const auditorBaseUrl = canonicalizeBaseUrl( `${pi.innerProto}://${hostAndSegments}/`, ); return { type: TalerUriAction.Auditor, auditorBaseUrl, auditorPub, }; } /** * Parse a taler[+http]://refund URI. * Return undefined if not passed a valid URI. */ export function parseRefundUri(s: string): RefundUriResult | undefined { const pi = parseProtoInfo(s, "refund"); if (!pi) { return undefined; } const c = pi?.rest.split("?"); const parts = c[0].split("/"); if (parts.length < 3) { return undefined; } const host = parts[0].toLowerCase(); const sessionId = parts[parts.length - 1]; const orderId = parts[parts.length - 2]; const pathSegments = parts.slice(1, parts.length - 2); 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(); parts[1].split(",").map((name) => { const url = canonicalizeBaseUrl( `${pi.innerProto}://${decodeURIComponent(name)}/`, ); providers.push(url); }); return { type: TalerUriAction.Restore, walletRootPriv, providers, }; } // ================================================ // To string functions // ================================================ /** * @deprecated use stringifyRecoveryUri */ export function constructRecoveryUri(args: { walletRootPriv: string; providers: string[]; }): string { return stringifyRestoreUri(args); } /** * @deprecated stringifyPayPullUri */ export function constructPayPullUri(args: { exchangeBaseUrl: string; contractPriv: string; }): string { return stringifyPayPullUri(args); } /** * @deprecated use stringifyPayPushUri */ export function constructPayPushUri(args: { exchangeBaseUrl: string; contractPriv: string; }): string { return stringifyPayPushUri(args); } /** * * @deprecated use stringifyPayUri */ export function constructPayUri( merchantBaseUrl: string, orderId: string, sessionId: string, claimToken?: string, noncePriv?: string, ): string { return stringifyPayUri({ merchantBaseUrl, orderId, sessionId, claimToken, noncePriv, }); } export function stringifyPayUri({ merchantBaseUrl, orderId, sessionId, claimToken, noncePriv, }: Omit): string { const { proto, path, query } = getUrlInfo(merchantBaseUrl, { c: claimToken, n: noncePriv, }); return `${proto}://pay/${path}${orderId}/${sessionId}/${query}`; } export function stringifyPayPullUri({ contractPriv, exchangeBaseUrl, }: Omit): string { const { proto, path } = getUrlInfo(exchangeBaseUrl); return `${proto}://pay-pull/${path}${contractPriv}/`; } export function stringifyPayPushUri({ contractPriv, exchangeBaseUrl, }: Omit): string { const { proto, path } = getUrlInfo(exchangeBaseUrl); return `${proto}://pay-push/${path}${contractPriv}/`; } export function stringifyRestoreUri({ providers, walletRootPriv, }: Omit): string { const list = providers .map((url) => `${encodeURIComponent(new URL(url).href)}`) .join(","); return `taler://restore/${walletRootPriv}/${list}`; } export function stringifyWithdrawExchange({ exchangeBaseUrl, exchangePub, amount, }: Omit): string { const { proto, path, query } = getUrlInfo(exchangeBaseUrl, { a: amount, }); return `${proto}://withdraw-exchange/${path}${exchangePub}/${query}`; } export function stringifyDevExperimentUri({ devExperimentId, }: Omit): string { return `taler://dev-experiment/${devExperimentId}/`; } export function stringifyPayTemplateUri({ merchantBaseUrl, templateId, templateParams, }: Omit): string { const { proto, path, query } = getUrlInfo(merchantBaseUrl, templateParams); return `${proto}://pay-template/${path}${templateId}/${query}`; } export function stringifyRefundUri({ merchantBaseUrl, orderId, }: Omit): string { const { proto, path } = getUrlInfo(merchantBaseUrl); return `${proto}://refund/${path}${orderId}/`; } export function stringifyRewardUri({ merchantBaseUrl, merchantRewardId, }: Omit): string { const { proto, path } = getUrlInfo(merchantBaseUrl); return `${proto}://reward/${path}${merchantRewardId}/`; } export function stringifyExchangeUri({ exchangeBaseUrl, exchangePub, }: Omit): string { const { proto, path } = getUrlInfo(exchangeBaseUrl); return `${proto}://exchange/${path}${exchangePub}/`; } export function stringifyAuditorUri({ auditorBaseUrl, auditorPub, }: Omit): string { const { proto, path } = getUrlInfo(auditorBaseUrl); return `${proto}://auditor/${path}${auditorPub}/`; } export function stringifyWithdrawUri({ bankIntegrationApiBaseUrl, withdrawalOperationId, }: Omit): 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 * * @param baseUrl * @param params * @returns */ function getUrlInfo( baseUrl: string, params: Record = {}, ): { 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 }; }