2019-12-02 00:42:40 +01:00
|
|
|
/*
|
|
|
|
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/>
|
|
|
|
*/
|
|
|
|
|
2019-12-02 17:35:47 +01:00
|
|
|
/**
|
|
|
|
* Imports.
|
|
|
|
*/
|
|
|
|
import {
|
|
|
|
ExchangeUpdateStatus,
|
|
|
|
ProposalStatus,
|
2019-12-15 19:08:07 +01:00
|
|
|
ReserveRecordStatus,
|
|
|
|
Stores,
|
2019-12-12 20:53:15 +01:00
|
|
|
} from "../types/dbTypes";
|
2019-12-15 19:08:07 +01:00
|
|
|
import {
|
|
|
|
PendingOperationsResponse,
|
|
|
|
PendingOperationType,
|
|
|
|
} from "../types/pending";
|
|
|
|
import { Duration, getTimestampNow, Timestamp } from "../types/walletTypes";
|
|
|
|
import { TransactionHandle } from "../util/query";
|
|
|
|
import { InternalWalletState } from "./state";
|
2019-12-02 00:42:40 +01:00
|
|
|
|
2019-12-05 19:38:19 +01:00
|
|
|
function updateRetryDelay(
|
|
|
|
oldDelay: Duration,
|
|
|
|
now: Timestamp,
|
|
|
|
retryTimestamp: Timestamp,
|
|
|
|
): Duration {
|
|
|
|
if (retryTimestamp.t_ms <= now.t_ms) {
|
|
|
|
return { d_ms: 0 };
|
|
|
|
}
|
|
|
|
return { d_ms: Math.min(oldDelay.d_ms, retryTimestamp.t_ms - now.t_ms) };
|
|
|
|
}
|
|
|
|
|
|
|
|
async function gatherExchangePending(
|
|
|
|
tx: TransactionHandle,
|
|
|
|
now: Timestamp,
|
|
|
|
resp: PendingOperationsResponse,
|
|
|
|
onlyDue: boolean = false,
|
|
|
|
): Promise<void> {
|
|
|
|
if (onlyDue) {
|
|
|
|
// FIXME: exchanges should also be updated regularly
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
await tx.iter(Stores.exchanges).forEach(e => {
|
|
|
|
switch (e.updateStatus) {
|
2019-12-16 12:53:22 +01:00
|
|
|
case ExchangeUpdateStatus.Finished:
|
2019-12-05 19:38:19 +01:00
|
|
|
if (e.lastError) {
|
|
|
|
resp.pendingOperations.push({
|
2019-12-15 16:59:00 +01:00
|
|
|
type: PendingOperationType.Bug,
|
2019-12-05 19:38:19 +01:00
|
|
|
givesLifeness: false,
|
|
|
|
message:
|
|
|
|
"Exchange record is in FINISHED state but has lastError set",
|
|
|
|
details: {
|
|
|
|
exchangeBaseUrl: e.baseUrl,
|
|
|
|
},
|
|
|
|
});
|
|
|
|
}
|
|
|
|
if (!e.details) {
|
|
|
|
resp.pendingOperations.push({
|
2019-12-15 16:59:00 +01:00
|
|
|
type: PendingOperationType.Bug,
|
2019-12-05 19:38:19 +01:00
|
|
|
givesLifeness: false,
|
|
|
|
message:
|
|
|
|
"Exchange record does not have details, but no update in progress.",
|
|
|
|
details: {
|
|
|
|
exchangeBaseUrl: e.baseUrl,
|
|
|
|
},
|
|
|
|
});
|
|
|
|
}
|
|
|
|
if (!e.wireInfo) {
|
|
|
|
resp.pendingOperations.push({
|
2019-12-15 16:59:00 +01:00
|
|
|
type: PendingOperationType.Bug,
|
2019-12-05 19:38:19 +01:00
|
|
|
givesLifeness: false,
|
|
|
|
message:
|
|
|
|
"Exchange record does not have wire info, but no update in progress.",
|
|
|
|
details: {
|
|
|
|
exchangeBaseUrl: e.baseUrl,
|
|
|
|
},
|
|
|
|
});
|
|
|
|
}
|
|
|
|
break;
|
2019-12-16 12:53:22 +01:00
|
|
|
case ExchangeUpdateStatus.FetchKeys:
|
2019-12-05 19:38:19 +01:00
|
|
|
resp.pendingOperations.push({
|
2019-12-15 16:59:00 +01:00
|
|
|
type: PendingOperationType.ExchangeUpdate,
|
2019-12-05 19:38:19 +01:00
|
|
|
givesLifeness: false,
|
|
|
|
stage: "fetch-keys",
|
|
|
|
exchangeBaseUrl: e.baseUrl,
|
|
|
|
lastError: e.lastError,
|
|
|
|
reason: e.updateReason || "unknown",
|
|
|
|
});
|
|
|
|
break;
|
2019-12-16 12:53:22 +01:00
|
|
|
case ExchangeUpdateStatus.FetchWire:
|
2019-12-05 19:38:19 +01:00
|
|
|
resp.pendingOperations.push({
|
2019-12-15 16:59:00 +01:00
|
|
|
type: PendingOperationType.ExchangeUpdate,
|
2019-12-05 19:38:19 +01:00
|
|
|
givesLifeness: false,
|
|
|
|
stage: "fetch-wire",
|
|
|
|
exchangeBaseUrl: e.baseUrl,
|
|
|
|
lastError: e.lastError,
|
|
|
|
reason: e.updateReason || "unknown",
|
|
|
|
});
|
|
|
|
break;
|
2019-12-16 12:53:22 +01:00
|
|
|
case ExchangeUpdateStatus.FinalizeUpdate:
|
|
|
|
resp.pendingOperations.push({
|
|
|
|
type: PendingOperationType.ExchangeUpdate,
|
|
|
|
givesLifeness: false,
|
|
|
|
stage: "finalize-update",
|
|
|
|
exchangeBaseUrl: e.baseUrl,
|
|
|
|
lastError: e.lastError,
|
|
|
|
reason: e.updateReason || "unknown",
|
|
|
|
});
|
|
|
|
break;
|
2019-12-05 19:38:19 +01:00
|
|
|
default:
|
|
|
|
resp.pendingOperations.push({
|
2019-12-15 16:59:00 +01:00
|
|
|
type: PendingOperationType.Bug,
|
2019-12-05 19:38:19 +01:00
|
|
|
givesLifeness: false,
|
|
|
|
message: "Unknown exchangeUpdateStatus",
|
|
|
|
details: {
|
|
|
|
exchangeBaseUrl: e.baseUrl,
|
|
|
|
exchangeUpdateStatus: e.updateStatus,
|
|
|
|
},
|
|
|
|
});
|
|
|
|
break;
|
|
|
|
}
|
|
|
|
});
|
|
|
|
}
|
|
|
|
|
|
|
|
async function gatherReservePending(
|
|
|
|
tx: TransactionHandle,
|
|
|
|
now: Timestamp,
|
|
|
|
resp: PendingOperationsResponse,
|
|
|
|
onlyDue: boolean = false,
|
|
|
|
): Promise<void> {
|
|
|
|
// FIXME: this should be optimized by using an index for "onlyDue==true".
|
|
|
|
await tx.iter(Stores.reserves).forEach(reserve => {
|
|
|
|
const reserveType = reserve.bankWithdrawStatusUrl ? "taler-bank" : "manual";
|
|
|
|
if (!reserve.retryInfo.active) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
switch (reserve.reserveStatus) {
|
|
|
|
case ReserveRecordStatus.DORMANT:
|
|
|
|
// nothing to report as pending
|
|
|
|
break;
|
|
|
|
case ReserveRecordStatus.UNCONFIRMED:
|
2019-12-06 03:23:35 +01:00
|
|
|
if (onlyDue) {
|
|
|
|
break;
|
|
|
|
}
|
2019-12-05 19:38:19 +01:00
|
|
|
resp.pendingOperations.push({
|
2019-12-15 16:59:00 +01:00
|
|
|
type: PendingOperationType.Reserve,
|
2019-12-06 03:23:35 +01:00
|
|
|
givesLifeness: false,
|
2019-12-05 19:38:19 +01:00
|
|
|
stage: reserve.reserveStatus,
|
|
|
|
timestampCreated: reserve.created,
|
|
|
|
reserveType,
|
|
|
|
reservePub: reserve.reservePub,
|
|
|
|
retryInfo: reserve.retryInfo,
|
|
|
|
});
|
|
|
|
break;
|
|
|
|
case ReserveRecordStatus.WAIT_CONFIRM_BANK:
|
2019-12-06 03:23:35 +01:00
|
|
|
case ReserveRecordStatus.WITHDRAWING:
|
|
|
|
case ReserveRecordStatus.QUERYING_STATUS:
|
|
|
|
case ReserveRecordStatus.REGISTERING_BANK:
|
|
|
|
resp.nextRetryDelay = updateRetryDelay(
|
|
|
|
resp.nextRetryDelay,
|
|
|
|
now,
|
|
|
|
reserve.retryInfo.nextRetry,
|
|
|
|
);
|
|
|
|
if (onlyDue && reserve.retryInfo.nextRetry.t_ms > now.t_ms) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
resp.pendingOperations.push({
|
2019-12-15 16:59:00 +01:00
|
|
|
type: PendingOperationType.Reserve,
|
2019-12-06 03:23:35 +01:00
|
|
|
givesLifeness: true,
|
|
|
|
stage: reserve.reserveStatus,
|
|
|
|
timestampCreated: reserve.created,
|
|
|
|
reserveType,
|
|
|
|
reservePub: reserve.reservePub,
|
|
|
|
retryInfo: reserve.retryInfo,
|
|
|
|
});
|
|
|
|
break;
|
2019-12-05 19:38:19 +01:00
|
|
|
default:
|
|
|
|
resp.pendingOperations.push({
|
2019-12-15 16:59:00 +01:00
|
|
|
type: PendingOperationType.Bug,
|
2019-12-05 19:38:19 +01:00
|
|
|
givesLifeness: false,
|
|
|
|
message: "Unknown reserve record status",
|
|
|
|
details: {
|
|
|
|
reservePub: reserve.reservePub,
|
|
|
|
reserveStatus: reserve.reserveStatus,
|
|
|
|
},
|
|
|
|
});
|
|
|
|
break;
|
|
|
|
}
|
|
|
|
});
|
|
|
|
}
|
|
|
|
|
|
|
|
async function gatherRefreshPending(
|
|
|
|
tx: TransactionHandle,
|
|
|
|
now: Timestamp,
|
|
|
|
resp: PendingOperationsResponse,
|
|
|
|
onlyDue: boolean = false,
|
|
|
|
): Promise<void> {
|
2019-12-15 16:59:00 +01:00
|
|
|
await tx.iter(Stores.refreshGroups).forEach(r => {
|
2019-12-05 19:38:19 +01:00
|
|
|
if (r.finishedTimestamp) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
resp.nextRetryDelay = updateRetryDelay(
|
|
|
|
resp.nextRetryDelay,
|
|
|
|
now,
|
|
|
|
r.retryInfo.nextRetry,
|
|
|
|
);
|
|
|
|
if (onlyDue && r.retryInfo.nextRetry.t_ms > now.t_ms) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
resp.pendingOperations.push({
|
2019-12-15 16:59:00 +01:00
|
|
|
type: PendingOperationType.Refresh,
|
2019-12-05 19:38:19 +01:00
|
|
|
givesLifeness: true,
|
2019-12-15 16:59:00 +01:00
|
|
|
refreshGroupId: r.refreshGroupId,
|
2019-12-05 19:38:19 +01:00
|
|
|
});
|
|
|
|
});
|
|
|
|
}
|
|
|
|
|
|
|
|
async function gatherWithdrawalPending(
|
|
|
|
tx: TransactionHandle,
|
|
|
|
now: Timestamp,
|
|
|
|
resp: PendingOperationsResponse,
|
|
|
|
onlyDue: boolean = false,
|
|
|
|
): Promise<void> {
|
|
|
|
await tx.iter(Stores.withdrawalSession).forEach(wsr => {
|
|
|
|
if (wsr.finishTimestamp) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
resp.nextRetryDelay = updateRetryDelay(
|
|
|
|
resp.nextRetryDelay,
|
|
|
|
now,
|
|
|
|
wsr.retryInfo.nextRetry,
|
|
|
|
);
|
|
|
|
if (onlyDue && wsr.retryInfo.nextRetry.t_ms > now.t_ms) {
|
|
|
|
return;
|
|
|
|
}
|
2019-12-06 03:23:35 +01:00
|
|
|
const numCoinsWithdrawn = wsr.withdrawn.reduce(
|
|
|
|
(a, x) => a + (x ? 1 : 0),
|
|
|
|
0,
|
|
|
|
);
|
2019-12-05 19:38:19 +01:00
|
|
|
const numCoinsTotal = wsr.withdrawn.length;
|
|
|
|
resp.pendingOperations.push({
|
2019-12-15 16:59:00 +01:00
|
|
|
type: PendingOperationType.Withdraw,
|
2019-12-05 19:38:19 +01:00
|
|
|
givesLifeness: true,
|
|
|
|
numCoinsTotal,
|
|
|
|
numCoinsWithdrawn,
|
|
|
|
source: wsr.source,
|
|
|
|
withdrawSessionId: wsr.withdrawSessionId,
|
|
|
|
});
|
|
|
|
});
|
|
|
|
}
|
|
|
|
|
|
|
|
async function gatherProposalPending(
|
|
|
|
tx: TransactionHandle,
|
|
|
|
now: Timestamp,
|
|
|
|
resp: PendingOperationsResponse,
|
|
|
|
onlyDue: boolean = false,
|
|
|
|
): Promise<void> {
|
|
|
|
await tx.iter(Stores.proposals).forEach(proposal => {
|
|
|
|
if (proposal.proposalStatus == ProposalStatus.PROPOSED) {
|
|
|
|
if (onlyDue) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
resp.pendingOperations.push({
|
2019-12-15 16:59:00 +01:00
|
|
|
type: PendingOperationType.ProposalChoice,
|
2019-12-05 19:38:19 +01:00
|
|
|
givesLifeness: false,
|
|
|
|
merchantBaseUrl: proposal.download!!.contractTerms.merchant_base_url,
|
|
|
|
proposalId: proposal.proposalId,
|
|
|
|
proposalTimestamp: proposal.timestamp,
|
|
|
|
});
|
|
|
|
} else if (proposal.proposalStatus == ProposalStatus.DOWNLOADING) {
|
|
|
|
resp.nextRetryDelay = updateRetryDelay(
|
|
|
|
resp.nextRetryDelay,
|
|
|
|
now,
|
|
|
|
proposal.retryInfo.nextRetry,
|
|
|
|
);
|
|
|
|
if (onlyDue && proposal.retryInfo.nextRetry.t_ms > now.t_ms) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
resp.pendingOperations.push({
|
2019-12-15 16:59:00 +01:00
|
|
|
type: PendingOperationType.ProposalDownload,
|
2019-12-05 19:38:19 +01:00
|
|
|
givesLifeness: true,
|
2019-12-06 12:47:28 +01:00
|
|
|
merchantBaseUrl: proposal.merchantBaseUrl,
|
|
|
|
orderId: proposal.orderId,
|
2019-12-05 19:38:19 +01:00
|
|
|
proposalId: proposal.proposalId,
|
|
|
|
proposalTimestamp: proposal.timestamp,
|
2019-12-06 11:01:39 +01:00
|
|
|
lastError: proposal.lastError,
|
|
|
|
retryInfo: proposal.retryInfo,
|
2019-12-05 19:38:19 +01:00
|
|
|
});
|
|
|
|
}
|
|
|
|
});
|
|
|
|
}
|
|
|
|
|
|
|
|
async function gatherTipPending(
|
|
|
|
tx: TransactionHandle,
|
|
|
|
now: Timestamp,
|
|
|
|
resp: PendingOperationsResponse,
|
|
|
|
onlyDue: boolean = false,
|
|
|
|
): Promise<void> {
|
|
|
|
await tx.iter(Stores.tips).forEach(tip => {
|
|
|
|
if (tip.pickedUp) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
resp.nextRetryDelay = updateRetryDelay(
|
|
|
|
resp.nextRetryDelay,
|
|
|
|
now,
|
|
|
|
tip.retryInfo.nextRetry,
|
|
|
|
);
|
|
|
|
if (onlyDue && tip.retryInfo.nextRetry.t_ms > now.t_ms) {
|
|
|
|
return;
|
|
|
|
}
|
2019-12-16 12:53:22 +01:00
|
|
|
if (tip.acceptedTimestamp) {
|
2019-12-05 19:38:19 +01:00
|
|
|
resp.pendingOperations.push({
|
2019-12-15 16:59:00 +01:00
|
|
|
type: PendingOperationType.TipPickup,
|
2019-12-05 19:38:19 +01:00
|
|
|
givesLifeness: true,
|
|
|
|
merchantBaseUrl: tip.merchantBaseUrl,
|
|
|
|
tipId: tip.tipId,
|
|
|
|
merchantTipId: tip.merchantTipId,
|
|
|
|
});
|
|
|
|
}
|
|
|
|
});
|
|
|
|
}
|
|
|
|
|
|
|
|
async function gatherPurchasePending(
|
|
|
|
tx: TransactionHandle,
|
|
|
|
now: Timestamp,
|
|
|
|
resp: PendingOperationsResponse,
|
|
|
|
onlyDue: boolean = false,
|
|
|
|
): Promise<void> {
|
2019-12-06 03:23:35 +01:00
|
|
|
await tx.iter(Stores.purchases).forEach(pr => {
|
2019-12-10 12:22:29 +01:00
|
|
|
if (pr.paymentSubmitPending) {
|
2019-12-06 00:24:34 +01:00
|
|
|
resp.nextRetryDelay = updateRetryDelay(
|
|
|
|
resp.nextRetryDelay,
|
|
|
|
now,
|
|
|
|
pr.payRetryInfo.nextRetry,
|
|
|
|
);
|
2019-12-07 20:35:47 +01:00
|
|
|
if (!onlyDue || pr.payRetryInfo.nextRetry.t_ms <= now.t_ms) {
|
|
|
|
resp.pendingOperations.push({
|
2019-12-15 16:59:00 +01:00
|
|
|
type: PendingOperationType.Pay,
|
2019-12-07 20:35:47 +01:00
|
|
|
givesLifeness: true,
|
|
|
|
isReplay: false,
|
|
|
|
proposalId: pr.proposalId,
|
|
|
|
retryInfo: pr.payRetryInfo,
|
|
|
|
lastError: pr.lastPayError,
|
|
|
|
});
|
|
|
|
}
|
2019-12-05 19:38:19 +01:00
|
|
|
}
|
2019-12-06 00:24:34 +01:00
|
|
|
if (pr.refundStatusRequested) {
|
|
|
|
resp.nextRetryDelay = updateRetryDelay(
|
|
|
|
resp.nextRetryDelay,
|
|
|
|
now,
|
|
|
|
pr.refundStatusRetryInfo.nextRetry,
|
|
|
|
);
|
2019-12-07 20:35:47 +01:00
|
|
|
if (!onlyDue || pr.refundStatusRetryInfo.nextRetry.t_ms <= now.t_ms) {
|
|
|
|
resp.pendingOperations.push({
|
2019-12-15 16:59:00 +01:00
|
|
|
type: PendingOperationType.RefundQuery,
|
2019-12-07 20:35:47 +01:00
|
|
|
givesLifeness: true,
|
|
|
|
proposalId: pr.proposalId,
|
|
|
|
retryInfo: pr.refundStatusRetryInfo,
|
|
|
|
lastError: pr.lastRefundStatusError,
|
|
|
|
});
|
|
|
|
}
|
2019-12-06 00:24:34 +01:00
|
|
|
}
|
2019-12-15 19:04:14 +01:00
|
|
|
const numRefundsPending = Object.keys(pr.refundState.refundsPending).length;
|
2019-12-06 00:24:34 +01:00
|
|
|
if (numRefundsPending > 0) {
|
2019-12-15 19:04:14 +01:00
|
|
|
const numRefundsDone = Object.keys(pr.refundState.refundsDone).length;
|
2019-12-06 00:24:34 +01:00
|
|
|
resp.nextRetryDelay = updateRetryDelay(
|
|
|
|
resp.nextRetryDelay,
|
|
|
|
now,
|
|
|
|
pr.refundApplyRetryInfo.nextRetry,
|
|
|
|
);
|
2019-12-07 20:35:47 +01:00
|
|
|
if (!onlyDue || pr.refundApplyRetryInfo.nextRetry.t_ms <= now.t_ms) {
|
|
|
|
resp.pendingOperations.push({
|
2019-12-15 16:59:00 +01:00
|
|
|
type: PendingOperationType.RefundApply,
|
2019-12-07 20:35:47 +01:00
|
|
|
numRefundsDone,
|
|
|
|
numRefundsPending,
|
|
|
|
givesLifeness: true,
|
|
|
|
proposalId: pr.proposalId,
|
|
|
|
retryInfo: pr.refundApplyRetryInfo,
|
|
|
|
lastError: pr.lastRefundApplyError,
|
|
|
|
});
|
|
|
|
}
|
2019-12-05 19:38:19 +01:00
|
|
|
}
|
|
|
|
});
|
|
|
|
}
|
|
|
|
|
2019-12-02 00:42:40 +01:00
|
|
|
export async function getPendingOperations(
|
|
|
|
ws: InternalWalletState,
|
2019-12-05 19:38:19 +01:00
|
|
|
onlyDue: boolean = false,
|
2019-12-02 00:42:40 +01:00
|
|
|
): Promise<PendingOperationsResponse> {
|
2019-12-05 19:38:19 +01:00
|
|
|
const resp: PendingOperationsResponse = {
|
|
|
|
nextRetryDelay: { d_ms: Number.MAX_SAFE_INTEGER },
|
|
|
|
pendingOperations: [],
|
|
|
|
};
|
|
|
|
const now = getTimestampNow();
|
2019-12-12 22:39:45 +01:00
|
|
|
await ws.db.runWithReadTransaction(
|
2019-12-03 00:52:15 +01:00
|
|
|
[
|
|
|
|
Stores.exchanges,
|
|
|
|
Stores.reserves,
|
2019-12-15 16:59:00 +01:00
|
|
|
Stores.refreshGroups,
|
2019-12-03 00:52:15 +01:00
|
|
|
Stores.coins,
|
|
|
|
Stores.withdrawalSession,
|
|
|
|
Stores.proposals,
|
|
|
|
Stores.tips,
|
2019-12-05 19:38:19 +01:00
|
|
|
Stores.purchases,
|
2019-12-03 00:52:15 +01:00
|
|
|
],
|
|
|
|
async tx => {
|
2019-12-05 19:38:19 +01:00
|
|
|
await gatherExchangePending(tx, now, resp, onlyDue);
|
|
|
|
await gatherReservePending(tx, now, resp, onlyDue);
|
|
|
|
await gatherRefreshPending(tx, now, resp, onlyDue);
|
|
|
|
await gatherWithdrawalPending(tx, now, resp, onlyDue);
|
|
|
|
await gatherProposalPending(tx, now, resp, onlyDue);
|
|
|
|
await gatherTipPending(tx, now, resp, onlyDue);
|
|
|
|
await gatherPurchasePending(tx, now, resp, onlyDue);
|
2019-12-03 00:52:15 +01:00
|
|
|
},
|
|
|
|
);
|
2019-12-05 19:38:19 +01:00
|
|
|
return resp;
|
2019-12-02 00:42:40 +01:00
|
|
|
}
|