/*
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;
}
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;
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,
};
}
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();
// Used to be the reserve public key, now it's empty!
const lastPathComponent =
parts.length > 1 ? parts[parts.length - 1] : undefined;
if (lastPathComponent) {
// invalid taler://withdraw-exchange URI, must end with a slash
return 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,
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,
amount,
}: Omit): string {
const { proto, path, query } = getUrlInfo(exchangeBaseUrl, {
a: amount,
});
return `${proto}://withdraw-exchange/${path}${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,
}: Omit): string {
const { proto, path, query } = getUrlInfo(merchantBaseUrl);
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 };
}