2016-01-05 14:20:13 +01:00
|
|
|
/*
|
2022-06-06 17:05:26 +02:00
|
|
|
This file is part of GNU Taler
|
|
|
|
(C) 2022 Taler Systems S.A.
|
2016-01-05 14:20:13 +01:00
|
|
|
|
2022-06-06 17:05:26 +02:00
|
|
|
GNU Taler is free software; you can redistribute it and/or modify it under the
|
2016-01-05 14:20:13 +01:00
|
|
|
terms of the GNU General Public License as published by the Free Software
|
|
|
|
Foundation; either version 3, or (at your option) any later version.
|
|
|
|
|
2022-06-06 17:05:26 +02:00
|
|
|
GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
|
2016-01-05 14:20:13 +01:00
|
|
|
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
|
2022-06-06 17:05:26 +02:00
|
|
|
GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
|
2016-01-05 14:20:13 +01:00
|
|
|
*/
|
|
|
|
|
2017-05-24 16:33:10 +02:00
|
|
|
/**
|
|
|
|
* Messaging for the WebExtensions wallet. Should contain
|
|
|
|
* parts that are specific for WebExtensions, but as little business
|
|
|
|
* logic as possible.
|
|
|
|
*/
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Imports.
|
|
|
|
*/
|
2021-03-27 14:35:58 +01:00
|
|
|
import {
|
|
|
|
classifyTalerUri,
|
2022-06-06 05:09:25 +02:00
|
|
|
Logger,
|
|
|
|
TalerErrorCode,
|
2021-03-27 14:35:58 +01:00
|
|
|
TalerUriType,
|
2022-06-06 05:09:25 +02:00
|
|
|
WalletDiagnostics,
|
2021-03-27 14:35:58 +01:00
|
|
|
} from "@gnu-taler/taler-util";
|
2022-01-16 21:54:48 +01:00
|
|
|
import {
|
2022-02-03 14:36:37 +01:00
|
|
|
DbAccess,
|
|
|
|
deleteTalerDatabase,
|
2022-04-29 18:16:29 +02:00
|
|
|
exportDb,
|
2022-12-21 20:21:25 +01:00
|
|
|
getErrorDetailFromException,
|
2022-04-29 18:16:29 +02:00
|
|
|
importDb,
|
2022-03-22 21:16:38 +01:00
|
|
|
makeErrorDetail,
|
2022-02-03 14:36:37 +01:00
|
|
|
OpenedPromise,
|
2022-01-16 21:54:48 +01:00
|
|
|
openPromise,
|
2022-02-03 14:36:37 +01:00
|
|
|
openTalerDatabase,
|
2022-12-21 20:21:25 +01:00
|
|
|
SetTimeoutTimerAPI,
|
2022-02-03 14:36:37 +01:00
|
|
|
Wallet,
|
2022-12-21 20:21:25 +01:00
|
|
|
WalletOperations,
|
2022-06-06 05:09:25 +02:00
|
|
|
WalletStoresV1,
|
2022-01-16 21:54:48 +01:00
|
|
|
} from "@gnu-taler/taler-wallet-core";
|
2022-03-29 04:41:07 +02:00
|
|
|
import { BrowserHttpLib } from "./browserHttpLib.js";
|
2022-12-21 20:21:25 +01:00
|
|
|
import {
|
|
|
|
MessageFromBackend,
|
|
|
|
MessageFromFrontend,
|
|
|
|
MessageResponse,
|
|
|
|
platform,
|
|
|
|
} from "./platform/api.js";
|
2022-03-29 04:41:07 +02:00
|
|
|
import { SynchronousCryptoWorkerFactory } from "./serviceWorkerCryptoWorkerFactory.js";
|
|
|
|
import { ServiceWorkerHttpLib } from "./serviceWorkerHttpLib.js";
|
2022-12-21 20:21:25 +01:00
|
|
|
import { BackgroundOperations, ExtendedPermissionsResponse } from "./wxApi.js";
|
2020-08-03 09:30:48 +02:00
|
|
|
|
2020-04-06 20:02:01 +02:00
|
|
|
/**
|
|
|
|
* Currently active wallet instance. Might be unloaded and
|
|
|
|
* re-instantiated when the database is reset.
|
2021-08-19 15:12:33 +02:00
|
|
|
*
|
2021-07-14 14:34:58 +02:00
|
|
|
* FIXME: Maybe move the wallet resetting into the Wallet class?
|
2020-04-06 20:02:01 +02:00
|
|
|
*/
|
2021-06-17 21:06:45 +02:00
|
|
|
let currentWallet: Wallet | undefined;
|
2020-04-06 20:02:01 +02:00
|
|
|
|
2021-06-09 15:14:17 +02:00
|
|
|
let currentDatabase: DbAccess<typeof WalletStoresV1> | undefined;
|
2020-04-06 20:02:01 +02:00
|
|
|
|
|
|
|
/**
|
2022-02-03 14:36:37 +01:00
|
|
|
* Last version of an outdated DB, if applicable.
|
2020-04-06 20:02:01 +02:00
|
|
|
*/
|
|
|
|
let outdatedDbVersion: number | undefined;
|
|
|
|
|
2020-08-12 09:11:00 +02:00
|
|
|
const walletInit: OpenedPromise<void> = openPromise<void>();
|
2020-04-06 20:02:01 +02:00
|
|
|
|
2022-04-21 20:39:30 +02:00
|
|
|
const logger = new Logger("wxBackend.ts");
|
|
|
|
|
2020-08-21 17:26:25 +02:00
|
|
|
async function getDiagnostics(): Promise<WalletDiagnostics> {
|
2022-09-05 15:04:56 +02:00
|
|
|
const manifestData = platform.getWalletWebExVersion();
|
2020-08-21 17:26:25 +02:00
|
|
|
const errors: string[] = [];
|
|
|
|
let firefoxIdbProblem = false;
|
|
|
|
let dbOutdated = false;
|
|
|
|
try {
|
|
|
|
await walletInit.promise;
|
|
|
|
} catch (e) {
|
|
|
|
errors.push("Error during wallet initialization: " + e);
|
|
|
|
if (
|
|
|
|
currentDatabase === undefined &&
|
|
|
|
outdatedDbVersion === undefined &&
|
2022-03-23 14:50:12 +01:00
|
|
|
platform.isFirefox()
|
2020-08-21 17:26:25 +02:00
|
|
|
) {
|
|
|
|
firefoxIdbProblem = true;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
if (!currentWallet) {
|
|
|
|
errors.push("Could not create wallet backend.");
|
|
|
|
}
|
|
|
|
if (!currentDatabase) {
|
|
|
|
errors.push("Could not open database");
|
|
|
|
}
|
|
|
|
if (outdatedDbVersion !== undefined) {
|
|
|
|
errors.push(`Outdated DB version: ${outdatedDbVersion}`);
|
|
|
|
dbOutdated = true;
|
|
|
|
}
|
|
|
|
const diagnostics: WalletDiagnostics = {
|
|
|
|
walletManifestDisplayVersion: manifestData.version_name || "(undefined)",
|
|
|
|
walletManifestVersion: manifestData.version,
|
|
|
|
errors,
|
|
|
|
firefoxIdbProblem,
|
|
|
|
dbOutdated,
|
|
|
|
};
|
|
|
|
return diagnostics;
|
|
|
|
}
|
|
|
|
|
2022-12-21 20:21:25 +01:00
|
|
|
type BackendHandlerType = {
|
|
|
|
[Op in keyof BackgroundOperations]: (
|
|
|
|
req: BackgroundOperations[Op]["request"],
|
|
|
|
) => Promise<BackgroundOperations[Op]["response"]>;
|
|
|
|
};
|
|
|
|
|
|
|
|
async function containsHeaderListener(): Promise<ExtendedPermissionsResponse> {
|
|
|
|
const result = await platform.containsTalerHeaderListener();
|
|
|
|
return { newValue: result };
|
|
|
|
}
|
|
|
|
|
|
|
|
async function resetDb(): Promise<void> {
|
|
|
|
await deleteTalerDatabase(indexedDB as any);
|
|
|
|
await reinitWallet();
|
|
|
|
}
|
|
|
|
|
|
|
|
async function runGarbageCollector(): Promise<void> {
|
|
|
|
const dbBeforeGc = currentDatabase;
|
|
|
|
if (!dbBeforeGc) {
|
|
|
|
throw Error("no current db before running gc");
|
|
|
|
}
|
|
|
|
const dump = await exportDb(dbBeforeGc.idbHandle());
|
|
|
|
|
|
|
|
await deleteTalerDatabase(indexedDB as any);
|
|
|
|
logger.info("cleaned");
|
|
|
|
await reinitWallet();
|
|
|
|
logger.info("init");
|
|
|
|
|
|
|
|
const dbAfterGc = currentDatabase;
|
|
|
|
if (!dbAfterGc) {
|
|
|
|
throw Error("no current db before running gc");
|
|
|
|
}
|
|
|
|
await importDb(dbAfterGc.idbHandle(), dump);
|
|
|
|
logger.info("imported");
|
|
|
|
}
|
|
|
|
|
|
|
|
async function toggleHeaderListener(
|
|
|
|
newVal: boolean,
|
|
|
|
): Promise<ExtendedPermissionsResponse> {
|
|
|
|
logger.trace("new extended permissions value", newVal);
|
|
|
|
if (newVal) {
|
|
|
|
platform.registerTalerHeaderListener(parseTalerUriAndRedirect);
|
|
|
|
return { newValue: true };
|
|
|
|
}
|
|
|
|
|
|
|
|
const rem = await platform.getPermissionsApi().removeHostPermissions();
|
|
|
|
logger.trace("permissions removed:", rem);
|
|
|
|
return { newValue: false };
|
|
|
|
}
|
2020-08-21 17:26:25 +02:00
|
|
|
|
2022-12-21 20:36:24 +01:00
|
|
|
function freeze(time: number): Promise<void> {
|
|
|
|
return new Promise((res, rej) => {
|
|
|
|
setTimeout(res, time)
|
|
|
|
})
|
|
|
|
}
|
|
|
|
|
|
|
|
async function sum(ns: Array<number>): Promise<number> {
|
|
|
|
return ns.reduce((prev, cur) => prev + cur, 0)
|
|
|
|
}
|
|
|
|
|
2022-12-21 20:21:25 +01:00
|
|
|
const backendHandlers: BackendHandlerType = {
|
2022-12-21 20:36:24 +01:00
|
|
|
freeze,
|
|
|
|
sum,
|
2022-12-21 20:21:25 +01:00
|
|
|
containsHeaderListener,
|
|
|
|
getDiagnostics,
|
|
|
|
resetDb,
|
|
|
|
runGarbageCollector,
|
|
|
|
toggleHeaderListener,
|
|
|
|
};
|
|
|
|
|
|
|
|
async function dispatch<Op extends WalletOperations | BackgroundOperations>(
|
|
|
|
req: MessageFromFrontend<Op> & { id: string },
|
|
|
|
): Promise<MessageResponse> {
|
|
|
|
if (req.channel === "background") {
|
|
|
|
const handler = backendHandlers[req.operation] as (req: any) => any;
|
|
|
|
if (!handler) {
|
|
|
|
return {
|
|
|
|
type: "error",
|
|
|
|
id: req.id,
|
|
|
|
operation: String(req.operation),
|
|
|
|
error: getErrorDetailFromException(
|
|
|
|
Error(`unknown background operation`),
|
|
|
|
),
|
|
|
|
};
|
|
|
|
}
|
|
|
|
const result = await handler(req.payload);
|
2020-08-21 17:26:25 +02:00
|
|
|
return {
|
|
|
|
type: "response",
|
|
|
|
id: req.id,
|
2022-12-21 20:21:25 +01:00
|
|
|
operation: String(req.operation),
|
2020-08-21 17:26:25 +02:00
|
|
|
result,
|
|
|
|
};
|
2022-12-21 20:21:25 +01:00
|
|
|
}
|
2020-08-21 17:26:25 +02:00
|
|
|
|
2022-12-21 20:21:25 +01:00
|
|
|
if (req.channel === "wallet") {
|
|
|
|
const w = currentWallet;
|
|
|
|
if (!w) {
|
|
|
|
return {
|
|
|
|
type: "error",
|
|
|
|
id: req.id,
|
|
|
|
operation: req.operation,
|
|
|
|
error: makeErrorDetail(
|
|
|
|
TalerErrorCode.WALLET_CORE_NOT_AVAILABLE,
|
|
|
|
{},
|
|
|
|
"wallet core not available",
|
|
|
|
),
|
|
|
|
};
|
2021-05-07 15:38:28 +02:00
|
|
|
}
|
2020-08-13 20:43:51 +02:00
|
|
|
|
2022-12-21 20:21:25 +01:00
|
|
|
return await w.handleCoreApiRequest(req.operation, req.id, req.payload);
|
2016-02-17 15:56:48 +01:00
|
|
|
}
|
2022-12-21 20:21:25 +01:00
|
|
|
|
|
|
|
const anyReq = req as any;
|
|
|
|
return {
|
|
|
|
type: "error",
|
|
|
|
id: anyReq.id,
|
|
|
|
operation: String(anyReq.operation),
|
|
|
|
error: getErrorDetailFromException(
|
|
|
|
Error(`unknown channel ${anyReq.channel}`),
|
|
|
|
),
|
|
|
|
};
|
2016-02-17 15:56:48 +01:00
|
|
|
}
|
|
|
|
|
2020-04-06 20:02:01 +02:00
|
|
|
async function reinitWallet(): Promise<void> {
|
2017-06-05 02:00:03 +02:00
|
|
|
if (currentWallet) {
|
|
|
|
currentWallet.stop();
|
|
|
|
currentWallet = undefined;
|
|
|
|
}
|
2019-09-05 16:10:53 +02:00
|
|
|
currentDatabase = undefined;
|
2022-03-23 14:50:12 +01:00
|
|
|
// setBadgeText({ text: "" });
|
2017-06-05 02:00:03 +02:00
|
|
|
try {
|
2022-01-05 17:28:56 +01:00
|
|
|
currentDatabase = await openTalerDatabase(indexedDB as any, reinitWallet);
|
2017-06-05 02:00:03 +02:00
|
|
|
} catch (e) {
|
2022-04-21 20:39:30 +02:00
|
|
|
logger.error("could not open database", e);
|
2019-09-05 16:10:53 +02:00
|
|
|
walletInit.reject(e);
|
2017-06-05 02:00:03 +02:00
|
|
|
return;
|
|
|
|
}
|
2022-01-16 21:54:48 +01:00
|
|
|
let httpLib;
|
|
|
|
let cryptoWorker;
|
2022-04-11 20:11:44 +02:00
|
|
|
let timer;
|
2022-01-16 21:54:48 +01:00
|
|
|
|
2022-03-23 14:50:12 +01:00
|
|
|
if (platform.useServiceWorkerAsBackgroundProcess()) {
|
2022-02-03 14:36:37 +01:00
|
|
|
httpLib = new ServiceWorkerHttpLib();
|
2022-01-16 21:54:48 +01:00
|
|
|
cryptoWorker = new SynchronousCryptoWorkerFactory();
|
2022-04-28 18:26:29 +02:00
|
|
|
timer = new SetTimeoutTimerAPI();
|
2022-01-16 21:54:48 +01:00
|
|
|
} else {
|
2022-02-03 14:36:37 +01:00
|
|
|
httpLib = new BrowserHttpLib();
|
2022-10-05 12:52:49 +02:00
|
|
|
// We could (should?) use the BrowserCryptoWorkerFactory here,
|
|
|
|
// but right now we don't, to have less platform differences.
|
2022-10-05 15:45:10 +02:00
|
|
|
// cryptoWorker = new BrowserCryptoWorkerFactory();
|
2022-09-30 13:11:17 +02:00
|
|
|
cryptoWorker = new SynchronousCryptoWorkerFactory();
|
2022-04-11 20:11:44 +02:00
|
|
|
timer = new SetTimeoutTimerAPI();
|
2022-01-16 21:54:48 +01:00
|
|
|
}
|
|
|
|
|
2022-04-21 20:39:30 +02:00
|
|
|
logger.info("Setting up wallet");
|
2022-06-06 05:09:25 +02:00
|
|
|
const wallet = await Wallet.create(
|
|
|
|
currentDatabase,
|
|
|
|
httpLib,
|
|
|
|
timer,
|
|
|
|
cryptoWorker,
|
|
|
|
);
|
2021-08-09 15:42:56 +02:00
|
|
|
try {
|
|
|
|
await wallet.handleCoreApiRequest("initWallet", "native-init", {});
|
|
|
|
} catch (e) {
|
2022-04-21 20:39:30 +02:00
|
|
|
logger.error("could not initialize wallet", e);
|
2021-08-09 15:42:56 +02:00
|
|
|
walletInit.reject(e);
|
|
|
|
return;
|
|
|
|
}
|
2020-05-04 14:11:22 +02:00
|
|
|
wallet.addNotificationListener((x) => {
|
2022-03-23 14:50:12 +01:00
|
|
|
const message: MessageFromBackend = { type: x.type };
|
2022-06-06 05:09:25 +02:00
|
|
|
platform.sendMessageToAllChannels(message);
|
2020-05-04 14:11:22 +02:00
|
|
|
});
|
2022-04-28 18:26:29 +02:00
|
|
|
|
|
|
|
platform.keepAlive(() => {
|
2022-05-06 22:29:42 +02:00
|
|
|
return wallet.runTaskLoop().catch((e) => {
|
2022-04-28 18:26:29 +02:00
|
|
|
logger.error("error during wallet task loop", e);
|
|
|
|
});
|
2022-06-06 05:09:25 +02:00
|
|
|
});
|
2017-06-05 02:00:03 +02:00
|
|
|
// Useful for debugging in the background page.
|
2022-01-16 21:54:48 +01:00
|
|
|
if (typeof window !== "undefined") {
|
|
|
|
(window as any).talerWallet = wallet;
|
|
|
|
}
|
2017-06-05 02:00:03 +02:00
|
|
|
currentWallet = wallet;
|
2022-04-11 20:11:44 +02:00
|
|
|
return walletInit.resolve();
|
2017-06-05 02:00:03 +02:00
|
|
|
}
|
|
|
|
|
2022-03-29 15:02:35 +02:00
|
|
|
function parseTalerUriAndRedirect(tabId: number, talerUri: string): void {
|
2022-03-23 14:50:12 +01:00
|
|
|
const uriType = classifyTalerUri(talerUri);
|
|
|
|
switch (uriType) {
|
|
|
|
case TalerUriType.TalerWithdraw:
|
|
|
|
return platform.redirectTabToWalletPage(
|
|
|
|
tabId,
|
|
|
|
`/cta/withdraw?talerWithdrawUri=${talerUri}`,
|
|
|
|
);
|
|
|
|
case TalerUriType.TalerPay:
|
|
|
|
return platform.redirectTabToWalletPage(
|
|
|
|
tabId,
|
|
|
|
`/cta/pay?talerPayUri=${talerUri}`,
|
|
|
|
);
|
|
|
|
case TalerUriType.TalerTip:
|
|
|
|
return platform.redirectTabToWalletPage(
|
|
|
|
tabId,
|
|
|
|
`/cta/tip?talerTipUri=${talerUri}`,
|
|
|
|
);
|
|
|
|
case TalerUriType.TalerRefund:
|
|
|
|
return platform.redirectTabToWalletPage(
|
|
|
|
tabId,
|
|
|
|
`/cta/refund?talerRefundUri=${talerUri}`,
|
|
|
|
);
|
2022-08-31 05:20:35 +02:00
|
|
|
case TalerUriType.TalerPayPull:
|
|
|
|
return platform.redirectTabToWalletPage(
|
|
|
|
tabId,
|
|
|
|
`/cta/invoice/pay?talerPayPullUri=${talerUri}`,
|
|
|
|
);
|
|
|
|
case TalerUriType.TalerPayPush:
|
|
|
|
return platform.redirectTabToWalletPage(
|
|
|
|
tabId,
|
|
|
|
`/cta/transfer/pickup?talerPayPushUri=${talerUri}`,
|
|
|
|
);
|
2022-10-20 19:54:29 +02:00
|
|
|
case TalerUriType.TalerRecovery:
|
|
|
|
return platform.redirectTabToWalletPage(
|
|
|
|
tabId,
|
|
|
|
`/cta/transfer/recovery?talerBackupUri=${talerUri}`,
|
|
|
|
);
|
2022-08-31 05:20:35 +02:00
|
|
|
case TalerUriType.Unknown:
|
|
|
|
logger.warn(
|
|
|
|
`Response with HTTP 402 the Taler header but could not classify ${talerUri}`,
|
|
|
|
);
|
|
|
|
return;
|
2022-10-13 14:14:41 +02:00
|
|
|
case TalerUriType.TalerDevExperiment:
|
|
|
|
// FIXME: Implement!
|
|
|
|
logger.warn("not implemented");
|
|
|
|
return;
|
2022-08-31 05:20:35 +02:00
|
|
|
default: {
|
|
|
|
const error: never = uriType;
|
|
|
|
logger.warn(
|
|
|
|
`Response with HTTP 402 the Taler header "${error}", but header value is not a taler:// URI.`,
|
|
|
|
);
|
|
|
|
return;
|
|
|
|
}
|
2020-05-01 10:46:56 +02:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2019-11-02 00:46:57 +01:00
|
|
|
/**
|
|
|
|
* Main function to run for the WebExtension backend.
|
|
|
|
*
|
|
|
|
* Sets up all event handlers and other machinery.
|
|
|
|
*/
|
2020-04-06 20:02:01 +02:00
|
|
|
export async function wxMain(): Promise<void> {
|
2022-06-06 05:09:25 +02:00
|
|
|
logger.trace("starting");
|
2022-01-16 21:54:48 +01:00
|
|
|
const afterWalletIsInitialized = reinitWallet();
|
2016-11-20 08:58:04 +01:00
|
|
|
|
2022-03-23 14:50:12 +01:00
|
|
|
platform.registerReloadOnNewVersion();
|
|
|
|
|
2016-11-20 08:58:04 +01:00
|
|
|
// Handlers for messages coming directly from the content
|
|
|
|
// script on the page
|
2022-12-21 20:21:25 +01:00
|
|
|
platform.listenToAllChannels(async (message) => {
|
|
|
|
//wait until wallet is initialized
|
|
|
|
await afterWalletIsInitialized;
|
|
|
|
const result = await dispatch(message);
|
|
|
|
return result;
|
2022-06-06 05:09:25 +02:00
|
|
|
});
|
2016-10-12 02:55:53 +02:00
|
|
|
|
2022-06-06 05:09:25 +02:00
|
|
|
platform.registerAllIncomingConnections();
|
2020-05-04 14:11:22 +02:00
|
|
|
|
2020-06-03 12:51:09 +02:00
|
|
|
try {
|
2022-09-12 19:28:53 +02:00
|
|
|
platform.registerOnInstalled(() => {
|
|
|
|
platform.openWalletPage("/welcome");
|
|
|
|
|
|
|
|
//
|
|
|
|
try {
|
|
|
|
platform.registerTalerHeaderListener(parseTalerUriAndRedirect);
|
|
|
|
} catch (e) {
|
|
|
|
logger.error("could not register header listener", e);
|
|
|
|
}
|
|
|
|
});
|
2020-06-03 12:51:09 +02:00
|
|
|
} catch (e) {
|
2022-09-12 19:28:53 +02:00
|
|
|
console.error(e);
|
2020-06-03 12:51:09 +02:00
|
|
|
}
|
2020-05-04 14:11:22 +02:00
|
|
|
|
2020-06-03 12:51:09 +02:00
|
|
|
// On platforms that support it, also listen to external
|
|
|
|
// modification of permissions.
|
2022-03-25 20:57:27 +01:00
|
|
|
platform.getPermissionsApi().addPermissionsListener((perm, lastError) => {
|
2022-03-23 14:50:12 +01:00
|
|
|
if (lastError) {
|
2022-06-06 05:09:25 +02:00
|
|
|
logger.error(
|
|
|
|
`there was a problem trying to get permission ${perm}`,
|
|
|
|
lastError,
|
|
|
|
);
|
2020-05-01 11:34:12 +02:00
|
|
|
return;
|
|
|
|
}
|
2022-03-23 14:50:12 +01:00
|
|
|
platform.registerTalerHeaderListener(parseTalerUriAndRedirect);
|
2020-05-01 11:34:12 +02:00
|
|
|
});
|
2016-10-11 20:26:37 +02:00
|
|
|
}
|