/* 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 */ /** * @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 { 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 { 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; 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 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; } export interface AddExchangeUri { type: TalerUriAction.AddExchange; exchangeBaseUrl: string; } /** * Parse a taler[+http]://withdraw URI. * Return undefined if not passed a valid URI. */ export function parseWithdrawUriWithError(s: string) { const pi = parseProtoInfoWithError(s, "withdraw"); 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 withdrawId = parts[parts.length - 1]; const p = [host, ...pathSegments].join("/"); 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", TalerPayPush = "taler-pay-push", TalerPayPull = "taler-pay-pull", TalerRecovery = "taler-recovery", TalerDevExperiment = "taler-dev-experiment", Unknown = "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 { 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; } } 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. */ 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, TalerUriAction.PayTemplate); 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, 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, }; } /** * 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 // ================================================ 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 stringifyAddExchange({ exchangeBaseUrl, }: Omit): string { const { proto, path } = getUrlInfo(exchangeBaseUrl); return `${proto}://add-exchange/${path}`; } 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 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 */ 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 }; }