server/controllers/accesses.ts
Rama main · réplica de lectura del repositorio original.
import type express from 'express';
import { assert, asyncErr, getErrorCode, KError, makeLogger, unwrap } from '../helpers';
import accountManager, { GLOBAL_CONTEXT, type UserActionOrValue } from '../lib/accounts-manager';
import { fullPoll } from '../lib/poller';
import { Access, AccessField, Account, type Transaction, View } from '../models';
import { bankVendorByUuid } from '../providers';
import type { UserActionResponse } from '../shared/types';
import { hasForbiddenField, hasMissingField } from '../shared/validators';
import * as AccountController from './accounts';
import { registerStartupTask } from './all';
import { isDemoEnabled } from './instance';
import type { IdentifiedRequest, PreloadedRequest } from './routes';
const log = makeLogger('controllers/accesses');
type FetchAccountsAndTransactionsResult = {
accounts: Account[];
views: View[];
newTransactions: Transaction[];
errors?: string[];
};
// Preloads a bank access (sets @access).
export async function preloadAccess(
req: IdentifiedRequest<Access>,
res: express.Response,
nextHandler: () => void,
accessId: number
) {
try {
const { id: userId } = req.user;
const access = await Access.find(userId, accessId);
if (!access) {
throw new KError('bank access not found', 404);
}
req.preloaded = { access };
nextHandler();
} catch (err) {
asyncErr(res, err, 'when finding bank access');
}
}
export async function destroyWithData(userId: number, access: Access) {
log.info(`Removing access ${access.id} for bank ${access.vendorId}...`);
await Access.destroy(userId, access.id);
await AccountController.fixupDefaultAccount(userId);
log.info('Done!');
}
// Destroy a given access, including accounts, alerts and transactions.
export async function destroy(req: PreloadedRequest<Access>, res: express.Response) {
try {
const {
user: { id: userId },
} = req;
if (await isDemoEnabled(userId)) {
throw new KError("access deletion isn't allowed in demo mode", 400);
}
await destroyWithData(userId, req.preloaded.access);
res.status(204).end();
} catch (err) {
asyncErr(res, err, 'when destroying an access');
}
}
export async function deleteSession(req: PreloadedRequest<Access>, res: express.Response) {
try {
const {
user: { id: userId },
} = req;
const { access } = req.preloaded;
const session = GLOBAL_CONTEXT.getUserSession(userId);
await session.reset(access);
res.status(204).end();
} catch (err) {
asyncErr(res, err, 'when deleting an access session');
}
}
export interface CreateAndRetrieveDataResult {
accessId: number;
accounts: Account[];
views: View[];
newTransactions: Transaction[];
label: string;
errors?: string[];
enabled: boolean;
}
export function extractUserActionFields(body: Record<string, string> | undefined) {
if (!body) {
return null;
}
const fields = (body.userActionFields || null) as Record<string, string> | null;
delete body.userActionFields;
return fields;
}
export async function createAndRetrieveData(
userId: number,
params: Record<string, unknown>,
storeCredentials = true
): Promise<UserActionOrValue<CreateAndRetrieveDataResult>> {
const error =
hasMissingField(params, ['vendorId', 'fields']) ||
hasForbiddenField(params, [
'vendorId',
'fields',
'customLabel',
'userActionFields',
'excludeFromPoll',
'accessId',
]);
if (error) {
throw new KError(`when creating a new access: ${error}`, 400);
}
assert(Array.isArray(params.fields), 'fields should be an array');
const userActionFields = extractUserActionFields(params as Record<string, string>);
let access: Access | null = null;
try {
if (userActionFields !== null) {
// The access must exist, as this is a second step of a 2FA. Use the access id
// provided in the first step's response to find it.
assert(
typeof params.accessId === 'number',
'accessId is required for 2FA continuation'
);
access = unwrap(await Access.find(userId, params.accessId));
} else if (storeCredentials) {
access = await Access.create(userId, params);
} else {
// Create the access without storing fields.
access = await Access.create(userId, {
...params,
fields: undefined,
});
}
// Store the access enablement status now, before re-injecting fields.
const wasAccessEnabled = access.isEnabled();
// Re-inject fields in memory only for accesses that need it (storeCredentials set to false).
if (!wasAccessEnabled && params.fields) {
access.fields = params.fields as AccessField[];
}
let providerErrors: string[] = [];
const accountResponse = await accountManager.syncAccounts(userId, access, {
addNewAccounts: true,
updateProvider: false, // TODO infer from setting?
isInteractive: true,
userActionFields,
});
if (accountResponse.kind === 'user_action') {
// The whole system relies on the Access object existing (in
// particular, the session with 2fa information is tied to the
// Access), so we can't delete the Access object here.
//
// Unfortunately, because of 2fa, this means the user can abort the
// access creation and leave an inconsistent state in the database,
// where we have an Access but there's no Account/Transaction tied.
//
// So we register a special task that gets run on /api/all (= next
// loading of Kresus), which will clean the access if it has no
// associated accounts, as a proxy of meaning the 2fa has never
// completed.
const prevAccess: Access = access;
registerStartupTask(userId, async () => {
const accounts = await Account.byAccess(userId, prevAccess);
if (accounts.length === 0) {
log.info(`Cleaning up incomplete access with id ${prevAccess.id}`);
await Access.destroy(userId, prevAccess.id);
}
});
return { ...accountResponse, accessId: access.id };
}
const accountInfoMap = accountResponse.value;
if (accountResponse.errors) {
providerErrors = providerErrors.concat(accountResponse.errors);
}
const transactionResponse = await accountManager.syncTransactions(
userId,
access,
accountInfoMap,
/* isInteractive */ true
);
assert(
transactionResponse.kind !== 'user_action',
'user action should have been requested when fetching accounts'
);
if (transactionResponse.errors) {
providerErrors = providerErrors.concat(transactionResponse.errors);
}
const { accounts, createdTransactions: newTransactions } = transactionResponse.value;
// New views have automatically been created along with accounts.
const views = await View.all(userId);
return {
kind: 'value',
value: {
accessId: access.id,
accounts,
views,
newTransactions,
label: bankVendorByUuid(access.vendorId).name,
errors: providerErrors,
enabled: wasAccessEnabled,
},
};
} catch (err) {
log.error('The access process creation failed, cleaning up...');
// Let sql remove all the dependent data for us.
if (access !== null) {
log.info('\tdeleting access...');
await Access.destroy(userId, access.id);
}
// Rethrow the error
throw err;
}
}
// Creates a new bank access (expecting at least (vendorId / login /
// password)), and retrieves its accounts and transactions.
export async function create(req: IdentifiedRequest<any>, res: express.Response) {
try {
const {
user: { id: userId },
} = req;
if (await isDemoEnabled(userId)) {
throw new KError("access creation isn't allowed in demo mode", 400);
}
const storeCredentials = req.body.storeCredentials !== false;
delete req.body.storeCredentials;
const data = await createAndRetrieveData(userId, req.body, storeCredentials);
if (data.kind === 'user_action') {
res.status(200).json(data);
} else {
res.status(201).json(data.value);
}
} catch (err) {
asyncErr(res, err, 'when creating a bank access');
}
}
// Do not use this method as a controller directly: express will pass a `next` middleware as third
// argument instead of the expected params object.
const _fetchAccountsAndTransactions = async (
req: PreloadedRequest<Access>,
params: {
// Whether accounts returned by the fetch and absent from database should be created.
addNewAccounts: boolean;
// Whether an error while fetching accounts should stop the whole fetch (there are cases
// where we still want to fetch the transactions from working accounts).
requiresSuccessfulAccountsSync: boolean;
}
): Promise<UserActionResponse | FetchAccountsAndTransactionsResult> => {
const { addNewAccounts, requiresSuccessfulAccountsSync } = params;
const { id: userId } = req.user;
const access = req.preloaded.access;
const bankVendor = bankVendorByUuid(access.vendorId);
if (!access.isEnabled() || bankVendor.deprecated) {
const errcode = getErrorCode('DISABLED_ACCESS');
throw new KError('disabled access', 403, errcode);
}
const userActionFields = extractUserActionFields(req.body);
let providerErrors: string[] = [];
let accountResponse: Awaited<ReturnType<typeof accountManager.syncAccounts>> | null = null;
// To deal with banks that often throw errors when dealing with recurrent requests,
// we wrap the accounts update requests in a try/catch, and still fetch the transactions
// if it fails, if addnewsAccounts is true: we likely are in a poll and the updated accounts
// are really important: we should throw an error. Else, in a transactions fetch,
// the balance might be out of sync with the new transactions but we consider it
// a minor issue.
try {
accountResponse = await accountManager.syncAccounts(userId, access, {
addNewAccounts,
updateProvider: false, // TODO shouldn't this be inferred from the settings?
isInteractive: true,
userActionFields,
});
} catch (err) {
if (requiresSuccessfulAccountsSync) {
throw err;
}
}
if (accountResponse && accountResponse.kind === 'user_action') {
return accountResponse;
}
const accountInfoMap = accountResponse ? accountResponse.value : null;
if (accountResponse && accountResponse.errors) {
providerErrors = providerErrors.concat(accountResponse.errors);
}
const transactionResponse = await accountManager.syncTransactions(
userId,
access,
accountInfoMap,
/* isInteractive */ true
);
assert(
transactionResponse.kind !== 'user_action',
'user action should have been requested when fetching accounts'
);
if (transactionResponse.errors) {
providerErrors = providerErrors.concat(transactionResponse.errors);
}
const { accounts, createdTransactions: newTransactions } = transactionResponse.value;
// New views have automatically been created along with accounts.
const views = await View.all(userId);
return { accounts, views, newTransactions, errors: providerErrors };
};
// Fetch accounts, including new accounts, and transactions using the backend and
// return both to the client.
export async function fetchAccountsAndTransactions(
req: PreloadedRequest<Access>,
res: express.Response
) {
try {
const result = await _fetchAccountsAndTransactions(req, {
addNewAccounts: true,
requiresSuccessfulAccountsSync: true,
});
res.status(200).json(result);
} catch (err) {
asyncErr(res, err, 'when fetching accounts and transactions');
}
}
// Fetch accounts (for up-to-date balances) transactions using the backend and return the transactions to the client.
// Does not add new found accounts.
export async function fetchTransactions(req: PreloadedRequest<Access>, res: express.Response) {
try {
const result = await _fetchAccountsAndTransactions(req, {
addNewAccounts: false,
requiresSuccessfulAccountsSync: false,
});
res.status(200).json(result);
} catch (err) {
asyncErr(res, err, 'when fetching accounts and transactions');
}
}
// Fetch all the transactions / accounts for all the accesses, as is done during
// any regular poll.
export async function poll(req: IdentifiedRequest<any>, res: express.Response) {
try {
const { id: userId } = req.user;
await fullPoll(userId);
res.status(200).json({
status: 'OK',
});
} catch (err) {
log.warn(`Error when doing a full poll: ${err.message}`);
res.status(500).json({
status: 'error',
message: err.message,
});
}
}
// Updates a bank access.
export async function update(req: PreloadedRequest<Access>, res: express.Response) {
try {
const { id: userId } = req.user;
const { access } = req.preloaded;
const attrs = req.body;
const error = hasForbiddenField(attrs, ['enabled', 'customLabel', 'excludeFromPoll']);
if (error) {
throw new KError(`when updating an access: ${error}`, 400);
}
if (attrs.enabled === false) {
await AccessField.destroyAllFromAccessId(userId, access.id);
delete attrs.enabled;
}
if (attrs.customLabel === '') {
attrs.customLabel = null;
}
await Access.update(userId, access.id, attrs);
res.status(201).json({ status: 'OK' });
} catch (err) {
asyncErr(res, err, 'when updating bank access');
}
}
export async function updateAndFetchAccounts(req: PreloadedRequest<Access>, res: express.Response) {
try {
const { id: userId } = req.user;
const { access } = req.preloaded;
const attrs = req.body;
const error = hasForbiddenField(attrs, [
'login',
'password',
'fields',
'userActionFields',
'storeCredentials',
]);
if (error) {
throw new KError(`when updating and polling an access: ${error}`, 400);
}
// Hack: temporarily remove userActionFields from the entity, so the
// ORM accepts it. Oh well.
const { userActionFields } = attrs;
delete attrs.userActionFields;
const providedAccessFields: AccessField[] | undefined = attrs.fields;
delete attrs.fields;
const wasAccessEnabled = access.isEnabled();
const storeCredentials = !!attrs.storeCredentials;
delete attrs.storeCredentials;
if (typeof providedAccessFields !== 'undefined') {
if (storeCredentials) {
// Persist the credential updates to DB.
for (const { name, value } of providedAccessFields) {
const previous = access.fields.find(existing => existing.name === name);
if (value === null) {
// Delete the custom field if necessary.
if (typeof previous !== 'undefined') {
await AccessField.destroy(userId, previous.id);
}
} else if (typeof previous !== 'undefined') {
// Update the custom field if necessary.
if (previous.value !== value) {
await AccessField.update(userId, previous.id, { name, value });
}
} else {
// Create it.
await AccessField.create(userId, { name, value, accessId: access.id });
}
}
}
} else if (!wasAccessEnabled) {
throw new KError(
'when updating and fetching bank access: access disabled and no access fields provided by user',
401
);
}
// The preloaded access needs to be updated before calling fetchAccounts.
req.preloaded.access = await Access.update(userId, access.id, attrs);
if (!storeCredentials) {
// Make sure previous credentials are erased.
await AccessField.destroyAllFromAccessId(userId, access.id);
// Since fields are not stored in DB, re-apply in-memory fields after the DB reload (Access.update reloads from DB).
if (providedAccessFields) {
req.preloaded.access.fields = providedAccessFields;
}
}
// Hack: reset userActionFields (see above comment).
req.body.userActionFields = userActionFields;
const result = await _fetchAccountsAndTransactions(req, {
addNewAccounts: true,
requiresSuccessfulAccountsSync: true,
});
if ('kind' in result) {
res.status(200).json(result);
return;
}
res.status(200).json({ ...result, enabled: storeCredentials });
} catch (err) {
asyncErr(res, err, 'when updating and fetching bank access');
}
}