diff options
Diffstat (limited to 'src/util')
-rw-r--r-- | src/util/amounts.ts | 340 | ||||
-rw-r--r-- | src/util/assertUnreachable.ts | 19 | ||||
-rw-r--r-- | src/util/asyncMemo.ts | 52 | ||||
-rw-r--r-- | src/util/checkable.ts | 417 | ||||
-rw-r--r-- | src/util/helpers-test.ts | 38 | ||||
-rw-r--r-- | src/util/helpers.ts | 204 | ||||
-rw-r--r-- | src/util/http.ts | 109 | ||||
-rw-r--r-- | src/util/libtoolVersion-test.ts | 30 | ||||
-rw-r--r-- | src/util/libtoolVersion.ts | 86 | ||||
-rw-r--r-- | src/util/logging.ts | 25 | ||||
-rw-r--r-- | src/util/payto-test.ts | 31 | ||||
-rw-r--r-- | src/util/payto.ts | 54 | ||||
-rw-r--r-- | src/util/promiseUtils.ts | 39 | ||||
-rw-r--r-- | src/util/query.ts | 446 | ||||
-rw-r--r-- | src/util/taleruri-test.ts | 230 | ||||
-rw-r--r-- | src/util/taleruri.ts | 200 | ||||
-rw-r--r-- | src/util/timer.ts | 145 | ||||
-rw-r--r-- | src/util/wire.ts | 53 |
18 files changed, 2518 insertions, 0 deletions
diff --git a/src/util/amounts.ts b/src/util/amounts.ts new file mode 100644 index 000000000..b90d54a31 --- /dev/null +++ b/src/util/amounts.ts @@ -0,0 +1,340 @@ +/* + This file is part of TALER + (C) 2018 GNUnet e.V. and INRIA + + 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. + + 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 + TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + + +/** + * Types and helper functions for dealing with Taler amounts. + */ + + +/** + * Imports. + */ +import { Checkable } from "./checkable"; + + +/** + * Number of fractional units that one value unit represents. + */ +export const fractionalBase = 1e8; + +/** + * How many digits behind the comma are required to represent the + * fractional value in human readable decimal format? Must match + * lg(fractionalBase) + */ +export const fractionalLength = 8; + +/** + * Maximum allowed value field of an amount. + */ +export const maxAmountValue = 2 ** 52; + + +/** + * Non-negative financial amount. Fractional values are expressed as multiples + * of 1e-8. + */ +@Checkable.Class() +export class AmountJson { + /** + * Value, must be an integer. + */ + @Checkable.Number() + readonly value: number; + + /** + * Fraction, must be an integer. Represent 1/1e8 of a unit. + */ + @Checkable.Number() + readonly fraction: number; + + /** + * Currency of the amount. + */ + @Checkable.String() + readonly currency: string; + + /** + * Verify that a value matches the schema of this class and convert it into a + * member. + */ + static checked: (obj: any) => AmountJson; +} + + +/** + * Result of a possibly overflowing operation. + */ +export interface Result { + /** + * Resulting, possibly saturated amount. + */ + amount: AmountJson; + /** + * Was there an over-/underflow? + */ + saturated: boolean; +} + + +/** + * Get an amount that represents zero units of a currency. + */ +export function getZero(currency: string): AmountJson { + return { + currency, + fraction: 0, + value: 0, + }; +} + + +export function sum(amounts: AmountJson[]) { + if (amounts.length <= 0) { + throw Error("can't sum zero amounts"); + } + return add(amounts[0], ...amounts.slice(1)); +} + + +/** + * Add two amounts. Return the result and whether + * the addition overflowed. The overflow is always handled + * by saturating and never by wrapping. + * + * Throws when currencies don't match. + */ +export function add(first: AmountJson, ...rest: AmountJson[]): Result { + const currency = first.currency; + let value = first.value + Math.floor(first.fraction / fractionalBase); + if (value > maxAmountValue) { + return { + amount: { currency, value: maxAmountValue, fraction: fractionalBase - 1 }, + saturated: true + }; + } + let fraction = first.fraction % fractionalBase; + for (const x of rest) { + if (x.currency !== currency) { + throw Error(`Mismatched currency: ${x.currency} and ${currency}`); + } + + value = value + x.value + Math.floor((fraction + x.fraction) / fractionalBase); + fraction = Math.floor((fraction + x.fraction) % fractionalBase); + if (value > maxAmountValue) { + return { + amount: { currency, value: maxAmountValue, fraction: fractionalBase - 1 }, + saturated: true + }; + } + } + return { amount: { currency, value, fraction }, saturated: false }; +} + + +/** + * Subtract two amounts. Return the result and whether + * the subtraction overflowed. The overflow is always handled + * by saturating and never by wrapping. + * + * Throws when currencies don't match. + */ +export function sub(a: AmountJson, ...rest: AmountJson[]): Result { + const currency = a.currency; + let value = a.value; + let fraction = a.fraction; + + for (const b of rest) { + if (b.currency !== currency) { + throw Error(`Mismatched currency: ${b.currency} and ${currency}`); + } + if (fraction < b.fraction) { + if (value < 1) { + return { amount: { currency, value: 0, fraction: 0 }, saturated: true }; + } + value--; + fraction += fractionalBase; + } + console.assert(fraction >= b.fraction); + fraction -= b.fraction; + if (value < b.value) { + return { amount: { currency, value: 0, fraction: 0 }, saturated: true }; + } + value -= b.value; + } + + return { amount: { currency, value, fraction }, saturated: false }; +} + + +/** + * Compare two amounts. Returns 0 when equal, -1 when a < b + * and +1 when a > b. Throws when currencies don't match. + */ +export function cmp(a: AmountJson, b: AmountJson): number { + if (a.currency !== b.currency) { + throw Error(`Mismatched currency: ${a.currency} and ${b.currency}`); + } + const av = a.value + Math.floor(a.fraction / fractionalBase); + const af = a.fraction % fractionalBase; + const bv = b.value + Math.floor(b.fraction / fractionalBase); + const bf = b.fraction % fractionalBase; + switch (true) { + case av < bv: + return -1; + case av > bv: + return 1; + case af < bf: + return -1; + case af > bf: + return 1; + case af === bf: + return 0; + default: + throw Error("assertion failed"); + } +} + + +/** + * Create a copy of an amount. + */ +export function copy(a: AmountJson): AmountJson { + return { + currency: a.currency, + fraction: a.fraction, + value: a.value, + }; +} + + +/** + * Divide an amount. Throws on division by zero. + */ +export function divide(a: AmountJson, n: number): AmountJson { + if (n === 0) { + throw Error(`Division by 0`); + } + if (n === 1) { + return {value: a.value, fraction: a.fraction, currency: a.currency}; + } + const r = a.value % n; + return { + currency: a.currency, + fraction: Math.floor(((r * fractionalBase) + a.fraction) / n), + value: Math.floor(a.value / n), + }; +} + + +/** + * Check if an amount is non-zero. + */ +export function isNonZero(a: AmountJson): boolean { + return a.value > 0 || a.fraction > 0; +} + + +/** + * Parse an amount like 'EUR:20.5' for 20 Euros and 50 ct. + */ +export function parse(s: string): AmountJson|undefined { + const res = s.match(/^([a-zA-Z0-9_*-]+):([0-9]+)([.][0-9]+)?$/); + if (!res) { + return undefined; + } + const tail = res[3] || ".0"; + if (tail.length > fractionalLength + 1) { + return undefined; + } + let value = Number.parseInt(res[2]); + if (value > maxAmountValue) { + return undefined; + } + return { + currency: res[1], + fraction: Math.round(fractionalBase * Number.parseFloat(tail)), + value, + }; +} + + +/** + * Parse amount in standard string form (like 'EUR:20.5'), + * throw if the input is not a valid amount. + */ +export function parseOrThrow(s: string): AmountJson { + const res = parse(s); + if (!res) { + throw Error(`Can't parse amount: "${s}"`); + } + return res; +} + + +/** + * Convert a float to a Taler amount. + * Loss of precision possible. + */ +export function fromFloat(floatVal: number, currency: string) { + return { + currency, + fraction: Math.floor((floatVal - Math.floor(floatVal)) * fractionalBase), + value: Math.floor(floatVal), + }; +} + + +/** + * Convert to standard human-readable string representation that's + * also used in JSON formats. + */ +export function toString(a: AmountJson): string { + const av = a.value + Math.floor(a.fraction / fractionalBase); + const af = a.fraction % fractionalBase; + let s = av.toString() + + if (af) { + s = s + "."; + let n = af; + for (let i = 0; i < fractionalLength; i++) { + if (!n) { + break; + } + s = s + Math.floor(n / fractionalBase * 10).toString(); + n = (n * 10) % fractionalBase; + } + } + + return `${a.currency}:${s}`; +} + + +/** + * Check if the argument is a valid amount in string form. + */ +export function check(a: any): boolean { + if (typeof a !== "string") { + return false; + } + try { + const parsedAmount = parse(a); + return !!parsedAmount; + } catch { + return false; + } +} diff --git a/src/util/assertUnreachable.ts b/src/util/assertUnreachable.ts new file mode 100644 index 000000000..90f2476b4 --- /dev/null +++ b/src/util/assertUnreachable.ts @@ -0,0 +1,19 @@ +/* + This file is part of GNU Taler + (C) 2019 GNUnet e.V. + + 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 <http://www.gnu.org/licenses/> + */ + +export function assertUnreachable(x: never): never { + throw new Error("Didn't expect to get here"); +}
\ No newline at end of file diff --git a/src/util/asyncMemo.ts b/src/util/asyncMemo.ts new file mode 100644 index 000000000..8b7b1c9bb --- /dev/null +++ b/src/util/asyncMemo.ts @@ -0,0 +1,52 @@ +/* + This file is part of GNU Taler + (C) 2019 GNUnet e.V. + + 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 <http://www.gnu.org/licenses/> + */ + +export interface MemoEntry<T> { + p: Promise<T>; + t: number; + n: number; +} + +export class AsyncOpMemo<T> { + n = 0; + memo: { [k: string]: MemoEntry<T> } = {}; + put(key: string, p: Promise<T>): Promise<T> { + const n = this.n++; + this.memo[key] = { + p, + n, + t: new Date().getTime(), + }; + p.finally(() => { + const r = this.memo[key]; + if (r && r.n === n) { + delete this.memo[key]; + } + }); + return p; + } + find(key: string): Promise<T> | undefined { + const res = this.memo[key]; + const tNow = new Date().getTime(); + if (res && res.t < tNow - 10 * 1000) { + delete this.memo[key]; + return; + } else if (res) { + return res.p; + } + return; + } +}
\ No newline at end of file diff --git a/src/util/checkable.ts b/src/util/checkable.ts new file mode 100644 index 000000000..3c9fe5bc1 --- /dev/null +++ b/src/util/checkable.ts @@ -0,0 +1,417 @@ +/* + This file is part of TALER + (C) 2016 GNUnet e.V. + + 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. + + 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 + TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + + +/** + * Decorators for validating JSON objects and converting them to a typed + * object. + * + * The decorators are put onto classes, and the validation is done + * via a static method that is filled in by the annotation. + * + * Example: + * ``` + * @Checkable.Class + * class Person { + * @Checkable.String + * name: string; + * @Checkable.Number + * age: number; + * + * // Method will be implemented automatically + * static checked(obj: any): Person; + * } + * ``` + */ +export namespace Checkable { + + type Path = Array<number | string>; + + interface SchemaErrorConstructor { + new (err: string): SchemaError; + } + + interface SchemaError { + name: string; + message: string; + } + + interface Prop { + propertyKey: any; + checker: any; + type?: any; + typeThunk?: () => any; + elementChecker?: any; + elementProp?: any; + keyProp?: any; + stringChecker?: (s: string) => boolean; + valueProp?: any; + optional?: boolean; + } + + interface CheckableInfo { + extraAllowed: boolean; + props: Prop[]; + } + + // tslint:disable-next-line:no-shadowed-variable + export const SchemaError = (function SchemaError(this: any, message: string) { + const that: any = this as any; + that.name = "SchemaError"; + that.message = message; + that.stack = (new Error() as any).stack; + }) as any as SchemaErrorConstructor; + + + SchemaError.prototype = new Error(); + + /** + * Classes that are checkable are annotated with this + * checkable info symbol, which contains the information necessary + * to check if they're valid. + */ + const checkableInfoSym = Symbol("checkableInfo"); + + /** + * Get the current property list for a checkable type. + */ + function getCheckableInfo(target: any): CheckableInfo { + let chk = target[checkableInfoSym] as CheckableInfo|undefined; + if (!chk) { + chk = { props: [], extraAllowed: false }; + target[checkableInfoSym] = chk; + } + return chk; + } + + + function checkNumber(target: any, prop: Prop, path: Path): any { + if ((typeof target) !== "number") { + throw new SchemaError(`expected number for ${path}`); + } + return target; + } + + + function checkString(target: any, prop: Prop, path: Path): any { + if (typeof target !== "string") { + throw new SchemaError(`expected string for ${path}, got ${typeof target} instead`); + } + if (prop.stringChecker && !prop.stringChecker(target)) { + throw new SchemaError(`string property ${path} malformed`); + } + return target; + } + + function checkBoolean(target: any, prop: Prop, path: Path): any { + if (typeof target !== "boolean") { + throw new SchemaError(`expected boolean for ${path}, got ${typeof target} instead`); + } + return target; + } + + + function checkAnyObject(target: any, prop: Prop, path: Path): any { + if (typeof target !== "object") { + throw new SchemaError(`expected (any) object for ${path}, got ${typeof target} instead`); + } + return target; + } + + + function checkAny(target: any, prop: Prop, path: Path): any { + return target; + } + + + function checkList(target: any, prop: Prop, path: Path): any { + if (!Array.isArray(target)) { + throw new SchemaError(`array expected for ${path}, got ${typeof target} instead`); + } + for (let i = 0; i < target.length; i++) { + const v = target[i]; + prop.elementChecker(v, prop.elementProp, path.concat([i])); + } + return target; + } + + function checkMap(target: any, prop: Prop, path: Path): any { + if (typeof target !== "object") { + throw new SchemaError(`expected object for ${path}, got ${typeof target} instead`); + } + for (const key in target) { + prop.keyProp.checker(key, prop.keyProp, path.concat([key])); + const value = target[key]; + prop.valueProp.checker(value, prop.valueProp, path.concat([key])); + } + return target; + } + + + function checkOptional(target: any, prop: Prop, path: Path): any { + console.assert(prop.propertyKey); + prop.elementChecker(target, + prop.elementProp, + path.concat([prop.propertyKey])); + return target; + } + + + function checkValue(target: any, prop: Prop, path: Path): any { + let type; + if (prop.type) { + type = prop.type; + } else if (prop.typeThunk) { + type = prop.typeThunk(); + if (!type) { + throw Error(`assertion failed: typeThunk returned null (prop is ${JSON.stringify(prop)})`); + } + } else { + throw Error(`assertion failed: type/typeThunk missing (prop is ${JSON.stringify(prop)})`); + } + const typeName = type.name || "??"; + const v = target; + if (!v || typeof v !== "object") { + throw new SchemaError( + `expected object for ${path.join(".")}, got ${typeof v} instead`); + } + const chk = type.prototype[checkableInfoSym]; + const props = chk.props; + const remainingPropNames = new Set(Object.getOwnPropertyNames(v)); + const obj = new type(); + for (const innerProp of props) { + if (!remainingPropNames.has(innerProp.propertyKey)) { + if (innerProp.optional) { + continue; + } + throw new SchemaError(`Property '${innerProp.propertyKey}' missing on '${path}' of '${typeName}'`); + } + if (!remainingPropNames.delete(innerProp.propertyKey)) { + throw new SchemaError("assertion failed"); + } + const propVal = v[innerProp.propertyKey]; + obj[innerProp.propertyKey] = innerProp.checker(propVal, + innerProp, + path.concat([innerProp.propertyKey])); + } + + if (!chk.extraAllowed && remainingPropNames.size !== 0) { + const err = `superfluous properties ${JSON.stringify(Array.from(remainingPropNames.values()))} of ${typeName}`; + throw new SchemaError(err); + } + return obj; + } + + + /** + * Class with checkable annotations on fields. + * This annotation adds the implementation of the `checked` + * static method. + */ + export function Class(opts: {extra?: boolean, validate?: boolean} = {}) { + return (target: any) => { + const chk = getCheckableInfo(target.prototype); + chk.extraAllowed = !!opts.extra; + target.checked = (v: any) => { + const cv = checkValue(v, { + checker: checkValue, + propertyKey: "(root)", + type: target, + }, ["(root)"]); + if (opts.validate) { + if (typeof target.validate !== "function") { + throw Error("invalid Checkable annotion: validate method required"); + } + // May throw exception + target.validate(cv); + } + return cv; + }; + return target; + }; + } + + + /** + * Target property must be a Checkable object of the given type. + */ + export function Value(typeThunk: () => any) { + function deco(target: object, propertyKey: string | symbol): void { + const chk = getCheckableInfo(target); + chk.props.push({ + checker: checkValue, + propertyKey, + typeThunk, + }); + } + + return deco; + } + + + /** + * List of values that match the given annotation. For example, `@Checkable.List(Checkable.String)` is + * an annotation for a list of strings. + */ + export function List(type: any) { + const stub = {}; + type(stub, "(list-element)"); + const elementProp = getCheckableInfo(stub).props[0]; + const elementChecker = elementProp.checker; + if (!elementChecker) { + throw Error("assertion failed"); + } + function deco(target: object, propertyKey: string | symbol): void { + const chk = getCheckableInfo(target); + chk.props.push({ + checker: checkList, + elementChecker, + elementProp, + propertyKey, + }); + } + + return deco; + } + + + /** + * Map from the key type to value type. Takes two annotations, + * one for the key type and one for the value type. + */ + export function Map(keyType: any, valueType: any) { + const keyStub = {}; + keyType(keyStub, "(map-key)"); + const keyProp = getCheckableInfo(keyStub).props[0]; + if (!keyProp) { + throw Error("assertion failed"); + } + const valueStub = {}; + valueType(valueStub, "(map-value)"); + const valueProp = getCheckableInfo(valueStub).props[0]; + if (!valueProp) { + throw Error("assertion failed"); + } + function deco(target: object, propertyKey: string | symbol): void { + const chk = getCheckableInfo(target); + chk.props.push({ + checker: checkMap, + keyProp, + propertyKey, + valueProp, + }); + } + + return deco; + } + + + /** + * Makes another annotation optional, for example `@Checkable.Optional(Checkable.Number)`. + */ + export function Optional(type: (target: object, propertyKey: string | symbol) => void | any) { + const stub = {}; + type(stub, "(optional-element)"); + const elementProp = getCheckableInfo(stub).props[0]; + const elementChecker = elementProp.checker; + if (!elementChecker) { + throw Error("assertion failed"); + } + function deco(target: object, propertyKey: string | symbol): void { + const chk = getCheckableInfo(target); + chk.props.push({ + checker: checkOptional, + elementChecker, + elementProp, + optional: true, + propertyKey, + }); + } + + return deco; + } + + + /** + * Target property must be a number. + */ + export function Number(): (target: object, propertyKey: string | symbol) => void { + const deco = (target: object, propertyKey: string | symbol) => { + const chk = getCheckableInfo(target); + chk.props.push({checker: checkNumber, propertyKey}); + }; + return deco; + } + + + /** + * Target property must be an arbitary object. + */ + export function AnyObject(): (target: object, propertyKey: string | symbol) => void { + const deco = (target: object, propertyKey: string | symbol) => { + const chk = getCheckableInfo(target); + chk.props.push({ + checker: checkAnyObject, + propertyKey, + }); + }; + return deco; + } + + + /** + * Target property can be anything. + * + * Not useful by itself, but in combination with higher-order annotations + * such as List or Map. + */ + export function Any(): (target: object, propertyKey: string | symbol) => void { + const deco = (target: object, propertyKey: string | symbol) => { + const chk = getCheckableInfo(target); + chk.props.push({ + checker: checkAny, + optional: true, + propertyKey, + }); + }; + return deco; + } + + + /** + * Target property must be a string. + */ + export function String( + stringChecker?: (s: string) => boolean): (target: object, propertyKey: string | symbol, + ) => void { + const deco = (target: object, propertyKey: string | symbol) => { + const chk = getCheckableInfo(target); + chk.props.push({ checker: checkString, propertyKey, stringChecker }); + }; + return deco; + } + + /** + * Target property must be a boolean value. + */ + export function Boolean(): (target: object, propertyKey: string | symbol) => void { + const deco = (target: object, propertyKey: string | symbol) => { + const chk = getCheckableInfo(target); + chk.props.push({ checker: checkBoolean, propertyKey }); + }; + return deco; + } +} diff --git a/src/util/helpers-test.ts b/src/util/helpers-test.ts new file mode 100644 index 000000000..74817120a --- /dev/null +++ b/src/util/helpers-test.ts @@ -0,0 +1,38 @@ +/* + This file is part of TALER + (C) 2017 Inria and GNUnet e.V. + + 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. + + 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 + TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + + +import test from "ava"; +import * as helpers from "./helpers"; + + +test("URL canonicalization", (t) => { + // converts to relative, adds https + t.is( + "https://alice.example.com/exchange/", + helpers.canonicalizeBaseUrl("alice.example.com/exchange")); + + // keeps http, adds trailing slash + t.is( + "http://alice.example.com/exchange/", + helpers.canonicalizeBaseUrl("http://alice.example.com/exchange")); + + // keeps http, adds trailing slash + t.is( + "http://alice.example.com/exchange/", + helpers.canonicalizeBaseUrl("http://alice.example.com/exchange#foobar")); + t.pass(); +}); diff --git a/src/util/helpers.ts b/src/util/helpers.ts new file mode 100644 index 000000000..eb8a1c7b2 --- /dev/null +++ b/src/util/helpers.ts @@ -0,0 +1,204 @@ +/* + This file is part of TALER + (C) 2016 GNUnet e.V. + + 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. + + 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 + TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +/** + * Small helper functions that don't fit anywhere else. + */ + +/** + * Imports. + */ +import { AmountJson } from "./amounts"; +import * as Amounts from "./amounts"; + +import { Timestamp } from "../walletTypes"; + +/** + * Show an amount in a form suitable for the user. + * FIXME: In the future, this should consider currency-specific + * settings such as significant digits or currency symbols. + */ +export function amountToPretty(amount: AmountJson): string { + const x = amount.value + amount.fraction / Amounts.fractionalBase; + return `${x} ${amount.currency}`; +} + + +/** + * Canonicalize a base url, typically for the exchange. + * + * See http://api.taler.net/wallet.html#general + */ +export function canonicalizeBaseUrl(url: string) { + if (!url.startsWith("http") && !url.startsWith("https")) { + url = "https://" + url; + } + const x = new URL(url); + if (!x.pathname.endsWith("/")) { + x.pathname = x.pathname + "/"; + } + x.search = ""; + x.hash = ""; + return x.href; +} + + +/** + * Convert object to JSON with canonical ordering of keys + * and whitespace omitted. + */ +export function canonicalJson(obj: any): string { + // Check for cycles, etc. + JSON.stringify(obj); + if (typeof obj === "string" || typeof obj === "number" || obj === null) { + return JSON.stringify(obj); + } + if (Array.isArray(obj)) { + const objs: string[] = obj.map((e) => canonicalJson(e)); + return `[${objs.join(",")}]`; + } + const keys: string[] = []; + for (const key in obj) { + keys.push(key); + } + keys.sort(); + let s = "{"; + for (let i = 0; i < keys.length; i++) { + const key = keys[i]; + s += JSON.stringify(key) + ":" + canonicalJson(obj[key]); + if (i !== keys.length - 1) { + s += ","; + } + } + return s + "}"; +} + + +/** + * Check for deep equality of two objects. + * Only arrays, objects and primitives are supported. + */ +export function deepEquals(x: any, y: any): boolean { + if (x === y) { + return true; + } + + if (Array.isArray(x) && x.length !== y.length) { + return false; + } + + const p = Object.keys(x); + return Object.keys(y).every((i) => p.indexOf(i) !== -1) && + p.every((i) => deepEquals(x[i], y[i])); +} + + +/** + * Map from a collection to a list or results and then + * concatenate the results. + */ +export function flatMap<T, U>(xs: T[], f: (x: T) => U[]): U[] { + return xs.reduce((acc: U[], next: T) => [...f(next), ...acc], []); +} + + +/** + * Extract a numeric timstamp (in seconds) from the Taler date format + * ("/Date([n])/"). Returns null if input is not in the right format. + */ +export function getTalerStampSec(stamp: string): number | null { + const m = stamp.match(/\/?Date\(([0-9]*)\)\/?/); + if (!m || !m[1]) { + return null; + } + return parseInt(m[1], 10); +} + +/** + * Extract a timestamp from a Taler timestamp string. + */ +export function extractTalerStamp(stamp: string): Timestamp | undefined { + const m = stamp.match(/\/?Date\(([0-9]*)\)\/?/); + if (!m || !m[1]) { + return undefined; + } + return { + t_ms: parseInt(m[1], 10) * 1000, + }; +} + +/** + * Extract a timestamp from a Taler timestamp string. + */ +export function extractTalerStampOrThrow(stamp: string): Timestamp { + const r = extractTalerStamp(stamp); + if (!r) { + throw Error("invalid time stamp"); + } + return r; +} + +/** + * Check if a timestamp is in the right format. + */ +export function timestampCheck(stamp: string): boolean { + return getTalerStampSec(stamp) !== null; +} + + +/** + * Get a JavaScript Date object from a Taler date string. + * Returns null if input is not in the right format. + */ +export function getTalerStampDate(stamp: string): Date | null { + const sec = getTalerStampSec(stamp); + if (sec == null) { + return null; + } + return new Date(sec * 1000); +} + +/** + * Compute the hash function of a JSON object. + */ +export function hash(val: any): number { + const str = canonicalJson(val); + // https://github.com/darkskyapp/string-hash + let h = 5381; + let i = str.length; + while (i) { + h = (h * 33) ^ str.charCodeAt(--i); + } + + /* JavaScript does bitwise operations (like XOR, above) on 32-bit signed + * integers. Since we want the results to be always positive, convert the + * signed int to an unsigned by doing an unsigned bitshift. */ + return h >>> 0; +} + + +/** + * Lexically compare two strings. + */ +export function strcmp(s1: string, s2: string): number { + if (s1 < s2) { + return -1; + } + if (s1 > s2) { + return 1; + } + return 0; +} diff --git a/src/util/http.ts b/src/util/http.ts new file mode 100644 index 000000000..a2bfab279 --- /dev/null +++ b/src/util/http.ts @@ -0,0 +1,109 @@ +/* + This file is part of TALER + (C) 2016 GNUnet e.V. + + 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. + + 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 + TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +/** + * Helpers for doing XMLHttpRequest-s that are based on ES6 promises. + * Allows for easy mocking for test cases. + */ + +/** + * An HTTP response that is returned by all request methods of this library. + */ +export interface HttpResponse { + status: number; + responseJson: object & any; +} + +/** + * The request library is bundled into an interface to make mocking easy. + */ +export interface HttpRequestLibrary { + get(url: string): Promise<HttpResponse>; + + postJson(url: string, body: any): Promise<HttpResponse>; +} + +/** + * An implementation of the [[HttpRequestLibrary]] using the + * browser's XMLHttpRequest. + */ +export class BrowserHttpLib implements HttpRequestLibrary { + private req( + method: string, + url: string, + options?: any, + ): Promise<HttpResponse> { + return new Promise<HttpResponse>((resolve, reject) => { + const myRequest = new XMLHttpRequest(); + myRequest.open(method, url); + if (options && options.req) { + myRequest.send(options.req); + } else { + myRequest.send(); + } + + myRequest.onerror = e => { + console.error("http request error"); + reject(Error("could not make XMLHttpRequest")); + }; + + myRequest.addEventListener("readystatechange", e => { + if (myRequest.readyState === XMLHttpRequest.DONE) { + if (myRequest.status === 0) { + reject(Error("HTTP Request failed (status code 0, maybe URI scheme is wrong?)")) + return; + } + if (myRequest.status != 200) { + reject( + Error( + `HTTP Response with unexpected status code ${myRequest.status}: ${myRequest.statusText}`, + ), + ); + return; + } + let responseJson; + try { + responseJson = JSON.parse(myRequest.responseText); + } catch (e) { + reject(Error("Invalid JSON from HTTP response")); + return; + } + if (responseJson === null || typeof responseJson !== "object") { + reject(Error("Invalid JSON from HTTP response")); + return; + } + const resp = { + responseJson: responseJson, + status: myRequest.status, + }; + resolve(resp); + } + }); + }); + } + + get(url: string) { + return this.req("get", url); + } + + postJson(url: string, body: any) { + return this.req("post", url, { req: JSON.stringify(body) }); + } + + postForm(url: string, form: any) { + return this.req("post", url, { req: form }); + } +} diff --git a/src/util/libtoolVersion-test.ts b/src/util/libtoolVersion-test.ts new file mode 100644 index 000000000..0a610e455 --- /dev/null +++ b/src/util/libtoolVersion-test.ts @@ -0,0 +1,30 @@ +/* + This file is part of TALER + (C) 2017 GNUnet e.V. + + 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. + + 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 + TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +import * as LibtoolVersion from "./libtoolVersion"; + +import test from "ava"; + +test("version comparison", (t) => { + t.deepEqual(LibtoolVersion.compare("0:0:0", "0:0:0"), {compatible: true, currentCmp: 0}); + t.deepEqual(LibtoolVersion.compare("0:0:0", ""), undefined); + t.deepEqual(LibtoolVersion.compare("foo", "0:0:0"), undefined); + t.deepEqual(LibtoolVersion.compare("0:0:0", "1:0:1"), {compatible: true, currentCmp: -1}); + t.deepEqual(LibtoolVersion.compare("0:0:0", "1:5:1"), {compatible: true, currentCmp: -1}); + t.deepEqual(LibtoolVersion.compare("0:0:0", "1:5:0"), {compatible: false, currentCmp: -1}); + t.deepEqual(LibtoolVersion.compare("1:0:0", "0:5:0"), {compatible: false, currentCmp: 1}); + t.deepEqual(LibtoolVersion.compare("1:0:1", "1:5:1"), {compatible: true, currentCmp: 0}); +}); diff --git a/src/util/libtoolVersion.ts b/src/util/libtoolVersion.ts new file mode 100644 index 000000000..cc2435b94 --- /dev/null +++ b/src/util/libtoolVersion.ts @@ -0,0 +1,86 @@ +/* + This file is part of TALER + (C) 2017 GNUnet e.V. + + 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. + + 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 + TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +/** + * Semantic versioning, but libtool-style. + * See https://www.gnu.org/software/libtool/manual/html_node/Libtool-versioning.html + */ + + +/** + * Result of comparing two libtool versions. + */ +export interface VersionMatchResult { + /** + * Is the first version compatible with the second? + */ + compatible: boolean; + /** + * Is the first version older (-1), newser (+1) or + * identical (0)? + */ + currentCmp: number; +} + +interface Version { + current: number; + revision: number; + age: number; +} + +/** + * Compare two libtool-style version strings. + */ +export function compare(me: string, other: string): VersionMatchResult|undefined { + const meVer = parseVersion(me); + const otherVer = parseVersion(other); + + if (!(meVer && otherVer)) { + return undefined; + } + + const compatible = (meVer.current - meVer.age <= otherVer.current && + meVer.current >= (otherVer.current - otherVer.age)); + + const currentCmp = Math.sign(meVer.current - otherVer.current); + + return {compatible, currentCmp}; +} + + +function parseVersion(v: string): Version|undefined { + const [currentStr, revisionStr, ageStr, ...rest] = v.split(":"); + if (rest.length !== 0) { + return undefined; + } + const current = Number.parseInt(currentStr); + const revision = Number.parseInt(revisionStr); + const age = Number.parseInt(ageStr); + + if (Number.isNaN(current)) { + return undefined; + } + + if (Number.isNaN(revision)) { + return undefined; + } + + if (Number.isNaN(age)) { + return undefined; + } + + return {current, revision, age}; +} diff --git a/src/util/logging.ts b/src/util/logging.ts new file mode 100644 index 000000000..309d1593b --- /dev/null +++ b/src/util/logging.ts @@ -0,0 +1,25 @@ +/* + This file is part of TALER + (C) 2019 GNUnet e.V. + + 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. + + 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 + TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +export class Logger { + constructor(private tag: string) {} + info(message: string, ...args: any[]) { + console.log(`${new Date().toISOString()} ${this.tag} INFO ` + message, ...args); + } + trace(message: any, ...args: any[]) { + console.log(`${new Date().toISOString()} ${this.tag} TRACE ` + message, ...args) + } +}
\ No newline at end of file diff --git a/src/util/payto-test.ts b/src/util/payto-test.ts new file mode 100644 index 000000000..82daff164 --- /dev/null +++ b/src/util/payto-test.ts @@ -0,0 +1,31 @@ +/* + This file is part of GNU Taler + (C) 2019 GNUnet e.V. + + 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 <http://www.gnu.org/licenses/> + */ + +import test from "ava"; + +import { parsePaytoUri } from "./payto"; + +test("basic payto parsing", (t) => { + const r1 = parsePaytoUri("https://example.com/"); + t.is(r1, undefined); + + const r2 = parsePaytoUri("payto:blabla"); + t.is(r2, undefined); + + const r3 = parsePaytoUri("payto://x-taler-bank/123"); + t.is(r3?.targetType, "x-taler-bank"); + t.is(r3?.targetPath, "123"); +});
\ No newline at end of file diff --git a/src/util/payto.ts b/src/util/payto.ts new file mode 100644 index 000000000..0926fdeed --- /dev/null +++ b/src/util/payto.ts @@ -0,0 +1,54 @@ +/* + This file is part of GNU Taler + (C) 2019 GNUnet e.V. + + 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 <http://www.gnu.org/licenses/> + */ + +interface PaytoUri { + targetType: string; + targetPath: string; + params: { [name: string]: string }; +} + + +export function parsePaytoUri(s: string): PaytoUri | undefined { + const pfx = "payto://" + if (!s.startsWith(pfx)) { + return undefined; + } + + const [acct, search] = s.slice(pfx.length).split("?"); + + const firstSlashPos = acct.indexOf("/"); + + if (firstSlashPos === -1) { + return undefined; + } + + const targetType = acct.slice(0, firstSlashPos); + const targetPath = acct.slice(firstSlashPos + 1); + + const params: { [k: string]: string } = {}; + + const searchParams = new URLSearchParams(search || ""); + + searchParams.forEach((v, k) => { + params[v] = k; + }); + + return { + targetPath, + targetType, + params, + } +}
\ No newline at end of file diff --git a/src/util/promiseUtils.ts b/src/util/promiseUtils.ts new file mode 100644 index 000000000..eb649471b --- /dev/null +++ b/src/util/promiseUtils.ts @@ -0,0 +1,39 @@ +/* + This file is part of GNU Taler + (C) 2019 GNUnet e.V. + + 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. + + 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 + TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + + export interface OpenedPromise<T> { + promise: Promise<T>; + resolve: (val: T) => void; + reject: (err: any) => void; + } + +/** + * Get an unresolved promise together with its extracted resolve / reject + * function. + */ +export function openPromise<T>(): OpenedPromise<T> { + let resolve: ((x?: any) => void) | null = null; + let reject: ((reason?: any) => void) | null = null; + const promise = new Promise<T>((res, rej) => { + resolve = res; + reject = rej; + }); + if (!(resolve && reject)) { + // Never happens, unless JS implementation is broken + throw Error(); + } + return { resolve, reject, promise }; +}
\ No newline at end of file diff --git a/src/util/query.ts b/src/util/query.ts new file mode 100644 index 000000000..5726bcaa6 --- /dev/null +++ b/src/util/query.ts @@ -0,0 +1,446 @@ +/* + This file is part of TALER + (C) 2016 GNUnet e.V. + + 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. + + 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 + TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +/** + * Database query abstractions. + * @module Query + * @author Florian Dold + */ + +/** + * Imports. + */ +import { openPromise } from "./promiseUtils"; + + +/** + * Result of an inner join. + */ +export interface JoinResult<L, R> { + left: L; + right: R; +} + +/** + * Result of a left outer join. + */ +export interface JoinLeftResult<L, R> { + left: L; + right?: R; +} + +/** + * Definition of an object store. + */ +export class Store<T> { + constructor( + public name: string, + public storeParams?: IDBObjectStoreParameters, + public validator?: (v: T) => T, + ) {} +} + +/** + * Options for an index. + */ +export interface IndexOptions { + /** + * If true and the path resolves to an array, create an index entry for + * each member of the array (instead of one index entry containing the full array). + * + * Defaults to false. + */ + multiEntry?: boolean; +} + +function requestToPromise(req: IDBRequest): Promise<any> { + const stack = Error("Failed request was started here.") + return new Promise((resolve, reject) => { + req.onsuccess = () => { + resolve(req.result); + }; + req.onerror = () => { + console.log("error in DB request", req.error); + reject(req.error); + console.log("Request failed:", stack); + }; + }); +} + +function transactionToPromise(tx: IDBTransaction): Promise<void> { + const stack = Error("Failed transaction was started here."); + return new Promise((resolve, reject) => { + tx.onabort = () => { + reject(TransactionAbort); + }; + tx.oncomplete = () => { + resolve(); + }; + tx.onerror = () => { + console.error("Transaction failed:", stack); + reject(tx.error); + }; + }); +} + +export async function oneShotGet<T>( + db: IDBDatabase, + store: Store<T>, + key: any, +): Promise<T | undefined> { + const tx = db.transaction([store.name], "readonly"); + const req = tx.objectStore(store.name).get(key); + const v = await requestToPromise(req) + await transactionToPromise(tx); + return v; +} + +export async function oneShotGetIndexed<S extends IDBValidKey, T>( + db: IDBDatabase, + index: Index<S, T>, + key: any, +): Promise<T | undefined> { + const tx = db.transaction([index.storeName], "readonly"); + const req = tx + .objectStore(index.storeName) + .index(index.indexName) + .get(key); + const v = await requestToPromise(req); + await transactionToPromise(tx); + return v; +} + +export async function oneShotPut<T>( + db: IDBDatabase, + store: Store<T>, + value: T, + key?: any, +): Promise<any> { + const tx = db.transaction([store.name], "readwrite"); + const req = tx.objectStore(store.name).put(value, key); + const v = await requestToPromise(req); + await transactionToPromise(tx); + return v; +} + +function applyMutation<T>( + req: IDBRequest, + f: (x: T) => T | undefined, +): Promise<void> { + return new Promise((resolve, reject) => { + req.onsuccess = () => { + const cursor = req.result; + if (cursor) { + const val = cursor.value; + const modVal = f(val); + if (modVal !== undefined && modVal !== null) { + const req2: IDBRequest = cursor.update(modVal); + req2.onerror = () => { + reject(req2.error); + }; + req2.onsuccess = () => { + cursor.continue(); + }; + } else { + cursor.continue(); + } + } else { + resolve(); + } + }; + req.onerror = () => { + reject(req.error); + }; + }); +} + +export async function oneShotMutate<T>( + db: IDBDatabase, + store: Store<T>, + key: any, + f: (x: T) => T | undefined, +): Promise<void> { + const tx = db.transaction([store.name], "readwrite"); + const req = tx.objectStore(store.name).openCursor(key); + await applyMutation(req, f); + await transactionToPromise(tx); +} + +type CursorResult<T> = CursorEmptyResult<T> | CursorValueResult<T>; + +interface CursorEmptyResult<T> { + hasValue: false; +} + +interface CursorValueResult<T> { + hasValue: true; + value: T; +} + +class ResultStream<T> { + private currentPromise: Promise<void>; + private gotCursorEnd: boolean = false; + private awaitingResult: boolean = false; + + constructor(private req: IDBRequest) { + this.awaitingResult = true; + let p = openPromise<void>(); + this.currentPromise = p.promise; + req.onsuccess = () => { + if (!this.awaitingResult) { + throw Error("BUG: invariant violated"); + } + const cursor = req.result; + if (cursor) { + this.awaitingResult = false; + p.resolve(); + p = openPromise<void>(); + this.currentPromise = p.promise; + } else { + this.gotCursorEnd = true; + p.resolve(); + } + }; + req.onerror = () => { + p.reject(req.error); + }; + } + + async toArray(): Promise<T[]> { + const arr: T[] = []; + while (true) { + const x = await this.next(); + if (x.hasValue) { + arr.push(x.value); + } else { + break; + } + } + return arr; + } + + async map<R>(f: (x: T) => R): Promise<R[]> { + const arr: R[] = []; + while (true) { + const x = await this.next(); + if (x.hasValue) { + arr.push(f(x.value)); + } else { + break; + } + } + return arr; + } + + async forEach(f: (x: T) => void): Promise<void> { + while (true) { + const x = await this.next(); + if (x.hasValue) { + f(x.value); + } else { + break; + } + } + } + + async filter(f: (x: T) => boolean): Promise<T[]> { + const arr: T[] = []; + while (true) { + const x = await this.next(); + if (x.hasValue) { + if (f(x.value)) { + arr.push(x.value); + } + } else { + break; + } + } + return arr; + } + + async next(): Promise<CursorResult<T>> { + if (this.gotCursorEnd) { + return { hasValue: false }; + } + if (!this.awaitingResult) { + const cursor = this.req.result; + if (!cursor) { + throw Error("assertion failed"); + } + this.awaitingResult = true; + cursor.continue(); + } + await this.currentPromise; + if (this.gotCursorEnd) { + return { hasValue: false }; + } + const cursor = this.req.result; + if (!cursor) { + throw Error("assertion failed"); + } + return { hasValue: true, value: cursor.value }; + } +} + +export function oneShotIter<T>( + db: IDBDatabase, + store: Store<T>, +): ResultStream<T> { + const tx = db.transaction([store.name], "readonly"); + const req = tx.objectStore(store.name).openCursor(); + return new ResultStream<T>(req); +} + +export function oneShotIterIndex<S extends IDBValidKey, T>( + db: IDBDatabase, + index: Index<S, T>, + query?: any, +): ResultStream<T> { + const tx = db.transaction([index.storeName], "readonly"); + const req = tx + .objectStore(index.storeName) + .index(index.indexName) + .openCursor(query); + return new ResultStream<T>(req); +} + +class TransactionHandle { + constructor(private tx: IDBTransaction) {} + + put<T>(store: Store<T>, value: T, key?: any): Promise<any> { + const req = this.tx.objectStore(store.name).put(value, key); + return requestToPromise(req); + } + + add<T>(store: Store<T>, value: T, key?: any): Promise<any> { + const req = this.tx.objectStore(store.name).add(value, key); + return requestToPromise(req); + } + + get<T>(store: Store<T>, key: any): Promise<T | undefined> { + const req = this.tx.objectStore(store.name).get(key); + return requestToPromise(req); + } + + iter<T>(store: Store<T>, key?: any): ResultStream<T> { + const req = this.tx.objectStore(store.name).openCursor(key); + return new ResultStream<T>(req); + } + + delete<T>(store: Store<T>, key: any): Promise<void> { + const req = this.tx.objectStore(store.name).delete(key); + return requestToPromise(req); + } + + mutate<T>(store: Store<T>, key: any, f: (x: T) => T | undefined) { + const req = this.tx.objectStore(store.name).openCursor(key); + return applyMutation(req, f); + } +} + +export function runWithWriteTransaction<T>( + db: IDBDatabase, + stores: Store<any>[], + f: (t: TransactionHandle) => Promise<T>, +): Promise<T> { + const stack = Error("Failed transaction was started here."); + return new Promise((resolve, reject) => { + const storeName = stores.map(x => x.name); + const tx = db.transaction(storeName, "readwrite"); + let funResult: any = undefined; + let gotFunResult: boolean = false; + tx.oncomplete = () => { + // This is a fatal error: The transaction completed *before* + // the transaction function returned. Likely, the transaction + // function waited on a promise that is *not* resolved in the + // microtask queue, thus triggering the auto-commit behavior. + // Unfortunately, the auto-commit behavior of IDB can't be switched + // of. There are some proposals to add this functionality in the future. + if (!gotFunResult) { + const msg = + "BUG: transaction closed before transaction function returned"; + console.error(msg); + reject(Error(msg)); + } + resolve(funResult); + }; + tx.onerror = () => { + console.error("error in transaction"); + }; + tx.onabort = () => { + if (tx.error) { + console.error("Transaction aborted with error:", tx.error); + } else { + console.log("Trasaction aborted (no error)"); + } + reject(TransactionAbort); + }; + const th = new TransactionHandle(tx); + const resP = f(th); + resP.then(result => { + gotFunResult = true; + funResult = result; + }).catch((e) => { + if (e == TransactionAbort) { + console.info("aborting transaction"); + } else { + tx.abort(); + console.error("Transaction failed:", e); + console.error(stack); + } + }); + }); +} + +/** + * Definition of an index. + */ +export class Index<S extends IDBValidKey, T> { + /** + * Name of the store that this index is associated with. + */ + storeName: string; + + /** + * Options to use for the index. + */ + options: IndexOptions; + + constructor( + s: Store<T>, + public indexName: string, + public keyPath: string | string[], + options?: IndexOptions, + ) { + const defaultOptions = { + multiEntry: false, + }; + this.options = { ...defaultOptions, ...(options || {}) }; + this.storeName = s.name; + } + + /** + * We want to have the key type parameter in use somewhere, + * because otherwise the compiler complains. In iterIndex the + * key type is pretty useful. + */ + protected _dummyKey: S | undefined; +} + +/** + * Exception that should be thrown by client code to abort a transaction. + */ +export const TransactionAbort = Symbol("transaction_abort"); diff --git a/src/util/taleruri-test.ts b/src/util/taleruri-test.ts new file mode 100644 index 000000000..02eecf209 --- /dev/null +++ b/src/util/taleruri-test.ts @@ -0,0 +1,230 @@ +/* + This file is part of GNU Taler + (C) 2019 GNUnet e.V. + + 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 <http://www.gnu.org/licenses/> + */ + +import test from "ava"; +import { + parsePayUri, + parseWithdrawUri, + parseRefundUri, + parseTipUri, +} from "./taleruri"; + +test("taler pay url parsing: http(s)", t => { + const url1 = "https://example.com/bar?spam=eggs"; + const r1 = parsePayUri(url1); + if (!r1) { + t.fail(); + return; + } + t.is(r1.downloadUrl, url1); + t.is(r1.sessionId, undefined); + const url2 = "http://example.com/bar?spam=eggs"; + const r2 = parsePayUri(url2); + if (!r2) { + t.fail(); + return; + } +}); + +test("taler pay url parsing: wrong scheme", t => { + const url1 = "talerfoo://"; + const r1 = parsePayUri(url1); + t.is(r1, undefined); + + const url2 = "taler://refund/a/b/c/d/e/f"; + const r2 = parsePayUri(url1); + t.is(r2, undefined); +}); + +test("taler pay url parsing: defaults", t => { + const url1 = "taler://pay/example.com/-/-/myorder"; + const r1 = parsePayUri(url1); + if (!r1) { + t.fail(); + return; + } + t.is(r1.downloadUrl, "https://example.com/public/proposal?order_id=myorder"); + t.is(r1.sessionId, undefined); + + const url2 = "taler://pay/example.com/-/-/myorder/mysession"; + const r2 = parsePayUri(url2); + if (!r2) { + t.fail(); + return; + } + t.is(r2.downloadUrl, "https://example.com/public/proposal?order_id=myorder"); + t.is(r2.sessionId, "mysession"); +}); + +test("taler pay url parsing: trailing parts", t => { + const url1 = "taler://pay/example.com/-/-/myorder/mysession/spam/eggs"; + const r1 = parsePayUri(url1); + if (!r1) { + t.fail(); + return; + } + t.is(r1.downloadUrl, "https://example.com/public/proposal?order_id=myorder"); + t.is(r1.sessionId, "mysession"); +}); + +test("taler pay url parsing: instance", t => { + const url1 = "taler://pay/example.com/-/myinst/myorder"; + const r1 = parsePayUri(url1); + if (!r1) { + t.fail(); + return; + } + t.is( + r1.downloadUrl, + "https://example.com/public/instances/myinst/proposal?order_id=myorder", + ); +}); + +test("taler pay url parsing: path prefix and instance", t => { + const url1 = "taler://pay/example.com/mypfx/myinst/myorder"; + const r1 = parsePayUri(url1); + if (!r1) { + t.fail(); + return; + } + t.is( + r1.downloadUrl, + "https://example.com/mypfx/instances/myinst/proposal?order_id=myorder", + ); +}); + +test("taler pay url parsing: complex path prefix", t => { + const url1 = "taler://pay/example.com/mypfx%2Fpublic/-/myorder"; + const r1 = parsePayUri(url1); + if (!r1) { + t.fail(); + return; + } + t.is( + r1.downloadUrl, + "https://example.com/mypfx/public/proposal?order_id=myorder", + ); +}); + +test("taler pay url parsing: complex path prefix and instance", t => { + const url1 = "taler://pay/example.com/mypfx%2Fpublic/foo/myorder"; + const r1 = parsePayUri(url1); + if (!r1) { + t.fail(); + return; + } + t.is( + r1.downloadUrl, + "https://example.com/mypfx/public/instances/foo/proposal?order_id=myorder", + ); +}); + +test("taler pay url parsing: non-https #1", t => { + const url1 = "taler://pay/example.com/-/-/myorder?insecure=1"; + const r1 = parsePayUri(url1); + if (!r1) { + t.fail(); + return; + } + t.is(r1.downloadUrl, "http://example.com/public/proposal?order_id=myorder"); +}); + +test("taler pay url parsing: non-https #2", t => { + const url1 = "taler://pay/example.com/-/-/myorder?insecure=2"; + const r1 = parsePayUri(url1); + if (!r1) { + t.fail(); + return; + } + t.is(r1.downloadUrl, "https://example.com/public/proposal?order_id=myorder"); +}); + +test("taler withdraw uri parsing", t => { + const url1 = "taler://withdraw/bank.example.com/-/12345"; + const r1 = parseWithdrawUri(url1); + if (!r1) { + t.fail(); + return; + } + t.is(r1.statusUrl, "https://bank.example.com/api/withdraw-operation/12345"); +}); + +test("taler refund uri parsing", t => { + const url1 = "taler://refund/merchant.example.com/-/-/1234"; + const r1 = parseRefundUri(url1); + if (!r1) { + t.fail(); + return; + } + t.is( + r1.refundUrl, + "https://merchant.example.com/public/refund?order_id=1234", + ); +}); + +test("taler refund uri parsing with instance", t => { + const url1 = "taler://refund/merchant.example.com/-/myinst/1234"; + const r1 = parseRefundUri(url1); + if (!r1) { + t.fail(); + return; + } + t.is( + r1.refundUrl, + "https://merchant.example.com/public/instances/myinst/refund?order_id=1234", + ); +}); + +test("taler tip pickup uri", t => { + const url1 = "taler://tip/merchant.example.com/-/-/tipid"; + const r1 = parseTipUri(url1); + if (!r1) { + t.fail(); + return; + } + t.is( + r1.merchantBaseUrl, + "https://merchant.example.com/public/tip-pickup?tip_id=tipid", + ); +}); + +test("taler tip pickup uri with instance", t => { + const url1 = "taler://tip/merchant.example.com/-/tipm/tipid"; + const r1 = parseTipUri(url1); + if (!r1) { + t.fail(); + return; + } + t.is( + r1.merchantBaseUrl, + "https://merchant.example.com/public/instances/tipm/", + ); + t.is(r1.merchantTipId, "tipid"); +}); + +test("taler tip pickup uri with instance and prefix", t => { + const url1 = "taler://tip/merchant.example.com/my%2fpfx/tipm/tipid"; + const r1 = parseTipUri(url1); + if (!r1) { + t.fail(); + return; + } + t.is( + r1.merchantBaseUrl, + "https://merchant.example.com/my/pfx/instances/tipm/", + ); + t.is(r1.merchantTipId, "tipid"); +}); diff --git a/src/util/taleruri.ts b/src/util/taleruri.ts new file mode 100644 index 000000000..aa6705c07 --- /dev/null +++ b/src/util/taleruri.ts @@ -0,0 +1,200 @@ +/* + This file is part of GNU Taler + (C) 2019 GNUnet e.V. + + 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 <http://www.gnu.org/licenses/> + */ + +export interface PayUriResult { + downloadUrl: string; + sessionId?: string; +} + +export interface WithdrawUriResult { + statusUrl: string; +} + +export interface RefundUriResult { + refundUrl: string; +} + +export interface TipUriResult { + merchantTipId: string; + merchantOrigin: string; + merchantBaseUrl: string; +} + +export function parseWithdrawUri(s: string): WithdrawUriResult | undefined { + const pfx = "taler://withdraw/"; + if (!s.startsWith(pfx)) { + return undefined; + } + + const rest = s.substring(pfx.length); + + let [host, path, withdrawId] = rest.split("/"); + + if (path === "-") { + path = "api/withdraw-operation"; + } + + return { + statusUrl: `https://${host}/${path}/${withdrawId}`, + }; +} + +export function parsePayUri(s: string): PayUriResult | undefined { + if (s.startsWith("https://") || s.startsWith("http://")) { + return { + downloadUrl: s, + sessionId: undefined, + }; + } + const pfx = "taler://pay/"; + if (!s.startsWith(pfx)) { + return undefined; + } + + const [path, search] = s.slice(pfx.length).split("?"); + + let [host, maybePath, maybeInstance, orderId, maybeSessionid] = path.split( + "/", + ); + + if (!host) { + return undefined; + } + + if (!maybePath) { + return undefined; + } + + if (!orderId) { + return undefined; + } + + if (maybePath === "-") { + maybePath = "public/"; + } else { + maybePath = decodeURIComponent(maybePath) + "/"; + } + let maybeInstancePath = ""; + if (maybeInstance !== "-") { + maybeInstancePath = `instances/${maybeInstance}/`; + } + + let protocol = "https"; + const searchParams = new URLSearchParams(search); + if (searchParams.get("insecure") === "1") { + protocol = "http"; + } + + const downloadUrl = + `${protocol}://${host}/` + + decodeURIComponent(maybePath) + + maybeInstancePath + + `proposal?order_id=${orderId}`; + + return { + downloadUrl, + sessionId: maybeSessionid, + }; +} + +export function parseTipUri(s: string): TipUriResult | undefined { + const pfx = "taler://tip/"; + if (!s.startsWith(pfx)) { + return undefined; + } + + const path = s.slice(pfx.length); + + let [host, maybePath, maybeInstance, tipId] = path.split("/"); + + if (!host) { + return undefined; + } + + if (!maybePath) { + return undefined; + } + + if (!tipId) { + return undefined; + } + + if (maybePath === "-") { + maybePath = "public/"; + } else { + maybePath = decodeURIComponent(maybePath) + "/"; + } + let maybeInstancePath = ""; + if (maybeInstance !== "-") { + maybeInstancePath = `instances/${maybeInstance}/`; + } + + const merchantBaseUrl = `https://${host}/${maybePath}${maybeInstancePath}`; + + return { + merchantTipId: tipId, + merchantOrigin: new URL(merchantBaseUrl).origin, + merchantBaseUrl, + }; +} + +export function parseRefundUri(s: string): RefundUriResult | undefined { + const pfx = "taler://refund/"; + + if (!s.startsWith(pfx)) { + return undefined; + } + + const path = s.slice(pfx.length); + + let [host, maybePath, maybeInstance, orderId] = path.split("/"); + + if (!host) { + return undefined; + } + + if (!maybePath) { + return undefined; + } + + if (!orderId) { + return undefined; + } + + if (maybePath === "-") { + maybePath = "public/"; + } else { + maybePath = decodeURIComponent(maybePath) + "/"; + } + let maybeInstancePath = ""; + if (maybeInstance !== "-") { + maybeInstancePath = `instances/${maybeInstance}/`; + } + + const refundUrl = + "https://" + + host + + "/" + + maybePath + + maybeInstancePath + + "refund" + + "?order_id=" + + orderId; + + return { + refundUrl, + }; +} diff --git a/src/util/timer.ts b/src/util/timer.ts new file mode 100644 index 000000000..d3bb5d485 --- /dev/null +++ b/src/util/timer.ts @@ -0,0 +1,145 @@ +/* + This file is part of TALER + (C) 2017 GNUnet e.V. + + 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. + + 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 + TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +/** + * Cross-platform timers. + * + * NodeJS and the browser use slightly different timer API, + * this abstracts over these differences. + */ + +/** + * Cancelable timer. + */ +export interface TimerHandle { + clear(): void; +} + +class IntervalHandle { + constructor(public h: any) { + } + + clear() { + clearInterval(this.h); + } +} + +class TimeoutHandle { + constructor(public h: any) { + } + + clear() { + clearTimeout(this.h); + } +} + +/** + * Get a performance counter in milliseconds. + */ +export const performanceNow: () => number = (() => { + if (typeof process !== "undefined" && process.hrtime) { + return () => { + const t = process.hrtime(); + return t[0] * 1e9 + t[1]; + }; + } else if (typeof "performance" !== "undefined") { + return () => performance.now(); + } else { + return () => 0; + } +})(); + +/** + * Call a function every time the delay given in milliseconds passes. + */ +export function every(delayMs: number, callback: () => void): TimerHandle { + return new IntervalHandle(setInterval(callback, delayMs)); +} + +/** + * Call a function after the delay given in milliseconds passes. + */ +export function after(delayMs: number, callback: () => void): TimerHandle { + return new TimeoutHandle(setTimeout(callback, delayMs)); +} + + +const nullTimerHandle = { + clear() { + // do nothing + return; + }, +}; + +/** + * Group of timers that can be destroyed at once. + */ +export class TimerGroup { + private stopped: boolean = false; + + private timerMap: { [index: number]: TimerHandle } = {}; + + private idGen = 1; + + stopCurrentAndFutureTimers() { + this.stopped = true; + for (const x in this.timerMap) { + if (!this.timerMap.hasOwnProperty(x)) { + continue; + } + this.timerMap[x].clear(); + delete this.timerMap[x]; + } + } + + after(delayMs: number, callback: () => void): TimerHandle { + if (this.stopped) { + console.warn("dropping timer since timer group is stopped"); + return nullTimerHandle; + } + const h = after(delayMs, callback); + const myId = this.idGen++; + this.timerMap[myId] = h; + + const tm = this.timerMap; + + return { + clear() { + h.clear(); + delete tm[myId]; + }, + }; + } + + every(delayMs: number, callback: () => void): TimerHandle { + if (this.stopped) { + console.warn("dropping timer since timer group is stopped"); + return nullTimerHandle; + } + const h = every(delayMs, callback); + const myId = this.idGen++; + this.timerMap[myId] = h; + + const tm = this.timerMap; + + return { + clear() { + h.clear(); + delete tm[myId]; + }, + }; + } +} diff --git a/src/util/wire.ts b/src/util/wire.ts new file mode 100644 index 000000000..63b73d864 --- /dev/null +++ b/src/util/wire.ts @@ -0,0 +1,53 @@ +/* + This file is part of TALER + (C) 2017 GNUnet e.V. + + 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. + + 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 + TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + + +/** + * Display and manipulate wire information. + * + * Right now, all types are hard-coded. In the future, there might be plugins / configurable + * methods or support for the "payto://" URI scheme. + */ + +/** + * Imports. + */ +import * as i18n from "../i18n"; + +/** + * Short summary of the wire information. + * + * Might abbreviate and return the same summary for different + * wire details. + */ +export function summarizeWire(w: any): string { + if (!w.type) { + return i18n.str`Invalid Wire`; + } + switch (w.type.toLowerCase()) { + case "test": + if (!w.account_number && w.account_number !== 0) { + return i18n.str`Invalid Test Wire Detail`; + } + if (!w.bank_uri) { + return i18n.str`Invalid Test Wire Detail`; + } + return i18n.str`Test Wire Acct #${w.account_number} on ${w.bank_uri}`; + default: + return i18n.str`Unknown Wire Detail`; + } +} + |