UNPKG

@crowdin/app-project-module

Version:

Module that generates for you all common endpoints for serving standalone Crowdin App

535 lines (534 loc) 25.7 kB
"use strict"; var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { if (k2 === undefined) k2 = k; var desc = Object.getOwnPropertyDescriptor(m, k); if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { desc = { enumerable: true, get: function() { return m[k]; } }; } Object.defineProperty(o, k2, desc); }) : (function(o, m, k, k2) { if (k2 === undefined) k2 = k; o[k2] = m[k]; })); var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { Object.defineProperty(o, "default", { enumerable: true, value: v }); }) : function(o, v) { o["default"] = v; }); var __importStar = (this && this.__importStar) || function (mod) { if (mod && mod.__esModule) return mod; var result = {}; if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k); __setModuleDefault(result, mod); return result; }; var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } return new (P || (P = Promise))(function (resolve, reject) { function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } step((generator = generator.apply(thisArg, _arguments || [])).next()); }); }; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.listenQueueMessage = exports.updateCrowdinFromWebhookRequest = exports.prepareWebhookData = exports.unregisterAllCrowdinWebhooks = exports.unregisterWebhooks = exports.registerWebhooks = exports.HookEvents = void 0; const logsFormatter = __importStar(require("@crowdin/logs-formatter")); const crowdinAppFunctions = __importStar(require("@crowdin/crowdin-apps-functions")); const amqplib_1 = __importDefault(require("amqplib")); const types_1 = require("../types"); const storage_1 = require("../../../storage"); const connection_1 = require("../../../util/connection"); const defaults_1 = require("./defaults"); const index_1 = require("../../../util/index"); const logger_1 = require("../../../util/logger"); const files_1 = require("./files"); const prefetchCount = 10; const forceProcessDelay = 10000; const maxReconnectAttempts = 5; const baseReconnectDelay = 1000; const connectionHeartbeat = 60; exports.HookEvents = { fileAdded: 'file.added', fileDeleted: 'file.deleted', fileApproved: 'file.approved', fileTranslated: 'file.translated', translationUpdated: 'translation.updated', }; const HookConditionEvents = { ALL: [exports.HookEvents.translationUpdated, exports.HookEvents.fileDeleted], TRANSLATED: [exports.HookEvents.fileTranslated, exports.HookEvents.fileDeleted], APPROVED: [exports.HookEvents.fileApproved, exports.HookEvents.fileDeleted], }; function encodedUrlParam({ config, crowdinContext, integration, }) { var _a; const params = { projectId: +crowdinContext.jwtPayload.context.project_id, clientId: crowdinContext.clientId, crowdinId: crowdinContext.crowdinId, }; const encryptedParams = (0, index_1.encryptData)(config, JSON.stringify(params)); return `${(_a = integration.webhooks) === null || _a === void 0 ? void 0 : _a.urlParam}=${encodeURIComponent(encryptedParams)}`; } function decodedUrlParam(config, data) { const params = (0, index_1.decryptData)(config, data); return JSON.parse(params); } function makeCrowdinWebhookUrl({ config, crowdinContext, integration, }) { var _a; const urlParam = encodedUrlParam({ config, integration, crowdinContext }); return (`${config.baseUrl}${((_a = integration.webhooks) === null || _a === void 0 ? void 0 : _a.crowdinWebhookUrl) ? integration.webhooks.crowdinWebhookUrl : '/api/crowdin/webhook'}` + `?${urlParam}`); } function registerWebhooks({ config, apiCredentials, appSettings, client, crowdinContext, integration, }) { var _a, _b; return __awaiter(this, void 0, void 0, function* () { const isWebhookSync = Number(appSettings.schedule) !== types_1.SyncSchedule.DISABLED; const projectId = crowdinContext.jwtPayload.context.project_id; const urlParam = encodedUrlParam({ config, integration, crowdinContext }); if ((_a = integration.webhooks) === null || _a === void 0 ? void 0 : _a.crowdinWebhooks) { yield integration.webhooks.crowdinWebhooks(client, projectId, isWebhookSync, appSettings); } else { const webhookName = `${config.name} application hook ${crowdinContext.jwtPayload.sub}`; const webhookUrl = makeCrowdinWebhookUrl({ config, integration, crowdinContext, }); const syncCondition = types_1.SyncCondition[Number(appSettings.condition)]; const events = [...HookConditionEvents[syncCondition]]; const webhook = yield getCrowdinProjectWebhook({ client, projectId, name: webhookName, }); if (appSettings['new-crowdin-files']) { events.push(exports.HookEvents.fileAdded); } if (isWebhookSync && webhook) { yield updateCrowdinWebhooks({ client, projectId, webhook, events, }); } else if (isWebhookSync && !webhook) { yield registerCrowdinWebhook({ config, url: webhookUrl, client, crowdinContext, events, }); } else if (!isWebhookSync && webhook) { yield unregisterCrowdinWebhooks({ client, projectId, webhook }); } } if ((_b = integration.webhooks) === null || _b === void 0 ? void 0 : _b.integrationWebhooks) { yield integration.webhooks.integrationWebhooks(apiCredentials, urlParam, isWebhookSync, appSettings); } }); } exports.registerWebhooks = registerWebhooks; function unregisterWebhooks({ apiCredentials, appSettings, client, config, crowdinContext, integration, }) { var _a, _b; return __awaiter(this, void 0, void 0, function* () { if ((_a = integration.webhooks) === null || _a === void 0 ? void 0 : _a.crowdinWebhooks) { yield integration.webhooks.crowdinWebhooks(client, crowdinContext.jwtPayload.context.project_id, false); } else { const webhookName = `${config.name} application hook ${crowdinContext.jwtPayload.sub}`; const webhook = yield getCrowdinProjectWebhook({ client, projectId: crowdinContext.jwtPayload.context.project_id, name: webhookName, }); if (webhook) { yield unregisterCrowdinWebhooks({ client, projectId: crowdinContext.jwtPayload.context.project_id, webhook, }); } } if ((_b = integration.webhooks) === null || _b === void 0 ? void 0 : _b.integrationWebhooks) { const urlParam = encodedUrlParam({ config, integration, crowdinContext }); yield integration.webhooks.integrationWebhooks(apiCredentials, urlParam, false, appSettings); } }); } exports.unregisterWebhooks = unregisterWebhooks; function getCrowdinProjectWebhook({ client, name, projectId, }) { return __awaiter(this, void 0, void 0, function* () { const hooks = (yield client.webhooksApi.withFetchAll().listWebhooks(projectId)).data.map((e) => e.data); return hooks.find((h) => h.name === name); }); } function getAllCrowdinProjectWebhooks({ client, config, projectId, }) { return __awaiter(this, void 0, void 0, function* () { const hooks = (yield client.webhooksApi.withFetchAll().listWebhooks(projectId)).data.map((e) => e.data); return hooks.filter((h) => h.name.startsWith(`${config.name} application hook `)); }); } function createPayload(events) { const payload = {}; for (const event of events) { payload[event] = { event: '{{event}}', projectId: '{{projectId}}', language: '{{targetLanguageId}}', fileId: '{{fileId}}', }; } return payload; } function registerCrowdinWebhook({ client, config, crowdinContext, events, url, }) { return __awaiter(this, void 0, void 0, function* () { const name = `${config.name} application hook ${crowdinContext.jwtPayload.sub}`; const payload = createPayload(events); yield client.webhooksApi.addWebhook(crowdinContext.jwtPayload.context.project_id, { name, url, events, requestType: 'POST', batchingEnabled: true, payload, }); }); } function updateCrowdinWebhooks({ client, events, projectId, webhook, }) { return __awaiter(this, void 0, void 0, function* () { const payload = createPayload(events); yield client.webhooksApi.editWebhook(projectId, webhook.id, [ { value: events, op: 'replace', path: '/events', }, { value: payload, op: 'replace', path: '/payload', }, ]); }); } function unregisterCrowdinWebhooks({ client, projectId, webhook, }) { return __awaiter(this, void 0, void 0, function* () { yield client.webhooksApi.deleteWebhook(projectId, webhook.id); }); } function unregisterAllCrowdinWebhooks({ config, crowdinId, integration, }) { return __awaiter(this, void 0, void 0, function* () { if (integration.webhooks) { const crowdinCredentials = yield (0, storage_1.getStorage)().getCrowdinCredentials(crowdinId); if (crowdinCredentials) { const credentials = yield (0, storage_1.getStorage)().getAllIntegrationCredentials(crowdinId); const projectIds = credentials.map((c) => crowdinAppFunctions.getProjectId(c.id)); const crowdinClient = yield (0, connection_1.prepareCrowdinClient)({ config, credentials: crowdinCredentials }); yield Promise.all(projectIds.map((projectId) => __awaiter(this, void 0, void 0, function* () { const webhooks = yield getAllCrowdinProjectWebhooks({ config, client: crowdinClient.client, projectId, }); yield Promise.all(webhooks.map((hook) => __awaiter(this, void 0, void 0, function* () { return yield unregisterCrowdinWebhooks({ client: crowdinClient.client, projectId, webhook: hook, }); }))); }))); } } }); } exports.unregisterAllCrowdinWebhooks = unregisterAllCrowdinWebhooks; function prepareWebhookData({ config, integration, provider, webhookUrlParam, }) { return __awaiter(this, void 0, void 0, function* () { let rootFolder = undefined; let syncSettings = null; let crowdinClient = null; let preparedIntegrationCredentials = null; let appSettings = {}; const { projectId, crowdinId, clientId } = decodedUrlParam(config, webhookUrlParam); const crowdinCredentials = yield (0, storage_1.getStorage)().getCrowdinCredentials(crowdinId); const integrationCredentials = yield (0, storage_1.getStorage)().getIntegrationCredentials(clientId); const integrationConfig = yield (0, storage_1.getStorage)().getIntegrationConfig(clientId); if (!crowdinCredentials) { return { projectId, crowdinClient, rootFolder, appSettings, syncSettings, preparedIntegrationCredentials }; } const context = Object.assign({ jwtPayload: { context: { project_id: projectId, organization_id: crowdinCredentials === null || crowdinCredentials === void 0 ? void 0 : crowdinCredentials.organizationId, user_id: crowdinCredentials === null || crowdinCredentials === void 0 ? void 0 : crowdinCredentials.userId, }, }, crowdinId: crowdinCredentials.id }, ((integrationCredentials === null || integrationCredentials === void 0 ? void 0 : integrationCredentials.id) && { clientId: integrationCredentials.id })); logsFormatter.resetContext(); logsFormatter.setContext(context); crowdinClient = yield (0, connection_1.prepareCrowdinClient)({ config, credentials: crowdinCredentials, context, }); if (!integrationCredentials) { return { projectId, crowdinClient, rootFolder, appSettings, syncSettings, preparedIntegrationCredentials }; } preparedIntegrationCredentials = yield (0, connection_1.prepareIntegrationCredentials)(config, integration, integrationCredentials); if (integrationConfig === null || integrationConfig === void 0 ? void 0 : integrationConfig.config) { appSettings = JSON.parse(integrationConfig.config); const isWebhookSync = Number(appSettings.schedule) !== types_1.SyncSchedule.DISABLED; if (isWebhookSync) { syncSettings = (yield (0, storage_1.getStorage)().getSyncSettings(clientId, crowdinId, 'schedule', provider)); // We can get an error in getRootFolder when the project has been deleted but the webhooks have been created try { rootFolder = yield (0, defaults_1.getRootFolder)(config, integration, crowdinClient.client, projectId); } catch (e) { (0, logger_1.logError)(e); } } } return { projectId, crowdinClient, preparedIntegrationCredentials, rootFolder, appSettings, syncSettings, }; }); } exports.prepareWebhookData = prepareWebhookData; function updateCrowdinFromWebhookRequest(args) { var _a, _b; return __awaiter(this, void 0, void 0, function* () { const { integration, webhookData, req } = args; let filesToSync = []; const { projectId, crowdinClient, preparedIntegrationCredentials, rootFolder, appSettings, syncSettings } = webhookData; const syncFiles = (syncSettings === null || syncSettings === void 0 ? void 0 : syncSettings.files) ? (0, files_1.prepareSyncFiles)(JSON.parse(syncSettings.files)) : []; if ((_a = integration.webhooks) === null || _a === void 0 ? void 0 : _a.integrationWebhookInterceptor) { filesToSync = yield ((_b = integration.webhooks) === null || _b === void 0 ? void 0 : _b.integrationWebhookInterceptor(projectId, crowdinClient.client, preparedIntegrationCredentials, rootFolder, appSettings, syncSettings, req)); } for (const file of filesToSync) { const fileNodeType = file.nodeType || file.node_type || '1'; if (fileNodeType !== '1') { continue; } const initFile = Object.assign({ id: file.id, name: file.name, sync: false, schedule: true, parent_id: file.parent_id || file.parentId, node_type: file.nodeType || file.node_type || '1' }, (file.type ? { type: file.type } : {})); if (syncSettings) { if (!syncFiles.find((obj) => obj.id === initFile.id)) { yield (0, storage_1.getStorage)().updateSyncSettings(JSON.stringify([...syncFiles, initFile]), syncSettings.integrationId, syncSettings.crowdinId, 'schedule', syncSettings.provider); } const webhook = yield (0, storage_1.getStorage)().getWebhooks(initFile.id, syncSettings.integrationId, syncSettings.crowdinId, syncSettings.provider); if (!webhook) { yield (0, storage_1.getStorage)().saveWebhooks(initFile.id, syncSettings.integrationId, syncSettings.crowdinId, syncSettings.provider); } } } }); } exports.updateCrowdinFromWebhookRequest = updateCrowdinFromWebhookRequest; function listenQueueMessage({ config, integration, queueName, queueUrl, }) { return __awaiter(this, void 0, void 0, function* () { let reconnectAttempts = 0; const connect = () => __awaiter(this, void 0, void 0, function* () { try { const connection = yield amqplib_1.default.connect(queueUrl, { heartbeat: connectionHeartbeat, // 60 seconds heartbeat }); // Reset reconnect attempts on successful connection reconnectAttempts = 0; connection.on('error', (err) => { (0, logger_1.logError)(`AMQP connection error: ${err.message}`); }); connection.on('close', () => __awaiter(this, void 0, void 0, function* () { (0, logger_1.logError)('AMQP connection closed, attempting to reconnect...'); yield scheduleReconnect(); })); const channel = yield connection.createChannel(); if (channel) { yield channel.assertQueue(queueName, { durable: true }); yield channel.prefetch(prefetchCount); channel.on('error', (err) => { (0, logger_1.logError)(`AMQP channel error: ${err.message}`); }); channel.on('close', () => { (0, logger_1.logError)('AMQP channel closed'); }); const onMessage = consumer({ channel, config, integration }); yield channel.consume(queueName, onMessage, { noAck: false }); } } catch (e) { (0, logger_1.logError)(`Failed to connect to AMQP: ${e}`); yield scheduleReconnect(); } }); const scheduleReconnect = () => __awaiter(this, void 0, void 0, function* () { if (reconnectAttempts >= maxReconnectAttempts) { (0, logger_1.logError)(`Max reconnection attempts (${maxReconnectAttempts}) reached. Stopping reconnection.`); return; } reconnectAttempts++; const delay = Math.min(baseReconnectDelay * Math.pow(2, reconnectAttempts - 1), 30000); // Exponential backoff up to 30s setTimeout(() => __awaiter(this, void 0, void 0, function* () { yield connect(); }), delay); }); yield connect(); }); } exports.listenQueueMessage = listenQueueMessage; function consumer({ channel, config, integration, }) { let messagesCounter = 0; let webhooksInfo = {}; let webhooksData = []; let timeoutId; let messagesToAck = []; // Track messages for individual acknowledgment const resetStateVariables = () => { messagesCounter = 0; webhooksInfo = {}; webhooksData = []; messagesToAck = []; }; return function (msg) { var _a; return __awaiter(this, void 0, void 0, function* () { if (!msg) { return; } messagesCounter++; messagesToAck.push(msg); // Add message to acknowledgment queue clearTimeout(timeoutId); try { const data = JSON.parse(msg.content.toString()); const urlParam = (_a = integration.webhooks) === null || _a === void 0 ? void 0 : _a.urlParam; const webhookUrlParam = data.query[urlParam]; const { clientId } = decodedUrlParam(config, webhookUrlParam); if (!webhooksInfo[clientId]) { webhooksInfo[clientId] = {}; webhooksInfo[clientId].data = [data]; webhooksInfo[clientId].integration = integration; webhooksData.push(prepareWebhookData({ config, integration, webhookUrlParam, provider: types_1.Provider.INTEGRATION, }).then((res) => { if (res) { webhooksInfo[clientId].webhookData = res; } })); } else { webhooksInfo[clientId].data.push(data); } if (messagesCounter < prefetchCount) { // if all messages are not received, wait 10 seconds to force process messages timeoutId = setTimeout(() => __awaiter(this, void 0, void 0, function* () { yield processMessages({ webhooksData, webhooksInfo, channel, messagesToAck, }); resetStateVariables(); }), forceProcessDelay); return; } clearTimeout(timeoutId); yield processMessages({ webhooksData, webhooksInfo, channel, messagesToAck, }); resetStateVariables(); } catch (e) { (0, logger_1.logError)(`Error processing message: ${e}`); // Acknowledge the current message even if there's an error to prevent reprocessing try { channel.ack(msg); } catch (ackError) { (0, logger_1.logError)(`Error acknowledging message: ${ackError}`); } } }); }; } function isRetryableError(error) { var _a; if ((_a = error === null || error === void 0 ? void 0 : error.response) === null || _a === void 0 ? void 0 : _a.status) { const status = error.response.status; if (status >= 500) { return true; } } // Network errors, timeouts, and other non-HTTP errors are retryable if ((error === null || error === void 0 ? void 0 : error.code) === 'ECONNRESET' || (error === null || error === void 0 ? void 0 : error.code) === 'ETIMEDOUT' || (error === null || error === void 0 ? void 0 : error.code) === 'ENOTFOUND') { return true; } return false; } function processMessages({ channel, messagesToAck, webhooksData, webhooksInfo, }) { return __awaiter(this, void 0, void 0, function* () { try { // Prepare all webhook data first yield Promise.all(webhooksData); // Process each client's webhook data individually for (const { data, integration, webhookData } of Object.values(webhooksInfo)) { if (webhookData && webhookData.crowdinClient) { try { yield updateCrowdinFromWebhookRequest({ integration: integration, webhookData: webhookData, req: data, }); } catch (processingError) { (0, logger_1.logError)(`Error processing webhook request: ${processingError}`); // Continue processing other clients even if one fails } } } // Acknowledge all messages individually after successful processing for (const msg of messagesToAck) { try { channel.ack(msg); } catch (ackError) { (0, logger_1.logError)(`Error acknowledging individual message: ${ackError}`); } } } catch (e) { (0, logger_1.log)(`Error in processMessages: ${e}`); for (const msg of messagesToAck) { try { if (isRetryableError(e)) { // For retryable errors (5xx, network issues), requeue the message channel.nack(msg, false, true); } else { // For non-retryable errors (4xx client errors), acknowledge to remove from queue (0, logger_1.log)(`Non-retryable error encountered, discarding message: ${e}`); channel.ack(msg); } } catch (handleError) { (0, logger_1.logError)(`Error handling message acknowledgment: ${handleError}`); } } } }); }