@crowdin/app-project-module
Version:
Module that generates for you all common endpoints for serving standalone Crowdin App
361 lines (360 loc) • 13.3 kB
JavaScript
;
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 () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__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());
});
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.CodeError = void 0;
exports.extractBaseUrlFromRequest = extractBaseUrlFromRequest;
exports.runAsyncWrapper = runAsyncWrapper;
exports.encryptData = encryptData;
exports.decryptData = decryptData;
exports.executeWithRetry = executeWithRetry;
exports.getLogoUrl = getLogoUrl;
exports.serveLogo = serveLogo;
exports.isAuthorizedConfig = isAuthorizedConfig;
exports.isJson = isJson;
exports.isDefined = isDefined;
exports.isString = isString;
exports.getPreviousDate = getPreviousDate;
exports.prepareFormDataMetadataId = prepareFormDataMetadataId;
exports.validateEmail = validateEmail;
exports.getFormattedDate = getFormattedDate;
exports.kebabCase = kebabCase;
exports.snakeCase = snakeCase;
exports.uniqBy = uniqBy;
const crypto = __importStar(require("crypto"));
const storage_1 = require("../storage");
const types_1 = require("../types");
const jsx_renderer_1 = require("./jsx-renderer");
const logger_1 = require("./logger");
const static_files_1 = require("./static-files");
const token_1 = require("./app-functions/token");
/**
* Extract file name from path (works in both Node.js and Cloudflare Workers)
* Supports both Unix (/) and Windows (\) path separators
*/
function basename(filePath) {
// Replace all backslashes with forward slashes, then get the last segment
return filePath.replace(/\\/g, '/').split('/').pop();
}
class CodeError extends Error {
constructor(message, code) {
super(message);
this.code = code;
}
}
exports.CodeError = CodeError;
function extractBaseUrlFromRequest(req) {
const protocol = req.protocol;
const host = req.get('host');
return `${protocol}://${host}`;
}
function isCrowdinClientRequest(req) {
return req.crowdinContext;
}
function handleError(err, req, res) {
return __awaiter(this, void 0, void 0, function* () {
const code = err.code && typeof err.code === 'number' ? err.code : 500;
if (code === 401 && isCrowdinClientRequest(req)) {
yield (0, storage_1.getStorage)().deleteIntegrationCredentials(req.crowdinContext.clientId);
}
if (code === 401 && req.path === '/') {
res.redirect('/');
return;
}
if (!res.headersSent) {
const errorMessage = { message: (0, logger_1.getErrorMessage)(err), code };
const isApiCall = isCrowdinClientRequest(req) && req.isApiCall;
const isUiIntegration = 'integrationCredentials' in req && !isApiCall;
if (isUiIntegration) {
const html = (0, jsx_renderer_1.renderJSXOnClient)({ name: 'error', props: errorMessage });
res.setHeader('Content-Type', 'text/html; charset=utf-8');
res.send(html);
}
else {
res.status(code).send({ error: errorMessage });
}
}
});
}
function runAsyncWrapper(callback) {
return (req, res, next) => {
callback(req, res, next).catch((e) => {
if (isCrowdinClientRequest(req)) {
req.logError(e);
}
else {
(0, logger_1.logError)(e);
}
handleError(e, req, res);
});
};
}
function encryptData(config, data) {
const secret = config.cryptoSecret || config.clientSecret;
const salt = crypto.randomBytes(8);
const password = Buffer.concat([Buffer.from(secret, 'binary'), salt]);
const hash = [];
let digest = password;
for (let i = 0; i < 3; i++) {
hash[i] = crypto.createHash('md5').update(digest).digest();
digest = Buffer.concat([hash[i], password]);
}
const keyDerivation = Buffer.concat(hash);
const key = keyDerivation.subarray(0, 32);
const iv = keyDerivation.subarray(32);
const cipher = crypto.createCipheriv('aes-256-cbc', key, iv);
return Buffer.concat([Buffer.from('Salted__', 'utf8'), salt, cipher.update(data), cipher.final()]).toString('base64');
}
function decryptData(config, data) {
const secret = config.cryptoSecret || config.clientSecret;
const cypher = Buffer.from(data, 'base64');
const salt = cypher.subarray(8, 16);
const password = Buffer.concat([Buffer.from(secret, 'binary'), salt]);
const md5Hashes = [];
let digest = password;
for (let i = 0; i < 3; i++) {
md5Hashes[i] = crypto.createHash('md5').update(digest).digest();
digest = Buffer.concat([md5Hashes[i], password]);
}
const key = Buffer.concat([md5Hashes[0], md5Hashes[1]]);
const iv = md5Hashes[2];
const contents = cypher.subarray(16);
const decipher = crypto.createDecipheriv('aes-256-cbc', key, iv);
return decipher.update(contents, undefined, 'utf-8') + decipher.final('utf-8');
}
function executeWithRetry(func_1) {
return __awaiter(this, arguments, void 0, function* (func, numOfRetries = 2) {
for (let i = 0; i <= numOfRetries; i++) {
try {
const result = yield func();
return result;
}
catch (error) {
if (i === numOfRetries) {
throw error;
}
}
}
throw new Error('Failed to process request with retry');
});
}
function getLogoUrl(config, moduleConfig, modulePath) {
if (moduleConfig) {
if (moduleConfig.imageUrl) {
if (modulePath) {
return `/logo${modulePath}/logo.png`;
}
return '/logo.png';
}
else if (moduleConfig.imagePath) {
const imagePath = moduleConfig.imagePath;
const fileName = basename(imagePath);
if (!modulePath) {
return `/${fileName}`;
}
return `/logo${modulePath}/${fileName}`;
}
}
if (config.imageUrl) {
if (modulePath) {
return `/logo${modulePath}/logo.png`;
}
return '/logo.png';
}
// Extract file name from imagePath with fallback to config.imagePath
const imagePath = config.imagePath;
const fileName = basename(imagePath);
if (!modulePath) {
return `/${fileName}`;
}
return `/logo${modulePath}/${fileName}`;
}
/**
* Logo middleware with backwards compatibility
* Serves both /logo.png (backwards-compatible) and actual file name
* @param config - App configuration (required for Cloudflare Workers Assets support)
* @param moduleConfig - Module configuration with imagePath or imageUrl (optional, falls back to config.imagePath)
* @returns Express middleware
*/
function serveLogo(config, moduleConfig) {
if (moduleConfig) {
if (moduleConfig.imageUrl) {
return (req, res, next) => __awaiter(this, void 0, void 0, function* () {
if (req.path === '/logo.png') {
res.redirect(moduleConfig.imageUrl);
return;
}
next();
});
}
else if (moduleConfig.imagePath) {
const fileHandler = (0, static_files_1.serveFile)(config, moduleConfig.imagePath);
const fileName = basename(moduleConfig.imagePath);
return (req, res, next) => __awaiter(this, void 0, void 0, function* () {
// Match exact paths: /logo.png (backwards-compatible) or /{actual-file-name}
if (req.path === '/logo.png' || req.path === `/${fileName}`) {
return fileHandler(req, res, next);
}
next();
});
}
}
if (config.imageUrl) {
return (req, res, next) => __awaiter(this, void 0, void 0, function* () {
if (req.path === '/logo.png') {
res.redirect(config.imageUrl);
return;
}
next();
});
}
const imagePath = config.imagePath;
const fileName = basename(imagePath);
const fileHandler = (0, static_files_1.serveFile)(config, imagePath);
return (req, res, next) => __awaiter(this, void 0, void 0, function* () {
// Match exact paths: /logo.png (backwards-compatible) or /{actual-file-name}
if (req.path === '/logo.png' || req.path === `/${fileName}`) {
return fileHandler(req, res, next);
}
next();
});
}
function isAuthorizedConfig(config) {
return !!config.clientId && !!config.clientSecret && config.authenticationType !== types_1.AuthenticationType.NONE;
}
function isJson(string) {
try {
JSON.parse(string);
}
catch (e) {
return false;
}
return true;
}
function isDefined(value) {
return value !== undefined && value !== null;
}
function isString(value) {
return typeof value === 'string' || value instanceof String;
}
function getPreviousDate(days) {
const date = new Date();
date.setDate(date.getDate() - days);
return date;
}
function prepareFormDataMetadataId(req, config) {
return __awaiter(this, void 0, void 0, function* () {
const jwtToken = req.query.jwtToken;
const jwtPayload = yield (0, token_1.validateJwtToken)(jwtToken, config.clientSecret, config.jwtValidationOptions);
const context = jwtPayload.context;
const id = ['form-data'];
if (context.organization_id) {
id.push(`${context.organization_id}`);
}
if (context.project_id) {
id.push(`${context.project_id}`);
}
return id.join('-');
});
}
function validateEmail(email) {
if (!isNaN(+email)) {
return false;
}
if (`${email}`.trim().length > 76) {
return false;
}
const emailRegExp = /^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/;
return emailRegExp.test(String(email).toLowerCase());
}
// Format the date as 'MMM DD, YYYY HH:mm'
function getFormattedDate({ date, userTimezone }) {
if (!userTimezone) {
userTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
}
return new Intl.DateTimeFormat('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
hour12: false,
timeZone: userTimezone,
}).format(date);
}
function kebabCase(str) {
if (!str) {
return '';
}
return (str
// Insert dash between lowercase & uppercase
.replace(/([a-z])([A-Z])/g, '$1-$2')
// Insert dash between letters & numbers
.replace(/([a-zA-Z])([0-9])/g, '$1-$2')
.replace(/([0-9])([a-zA-Z])/g, '$1-$2')
// Replace spaces & underscores with dash
.replace(/[\s_]+/g, '-')
// Normalize multiple dashes
.replace(/-+/g, '-')
// Lowercase everything
.toLowerCase());
}
function snakeCase(str) {
return kebabCase(str).replace(/-/g, '_');
}
function uniqBy(array, key) {
if (!Array.isArray(array)) {
return [];
}
const seen = new Set();
return array.filter((item) => {
const k = item[key];
if (seen.has(k)) {
return false;
}
else {
seen.add(k);
return true;
}
});
}