@crowdin/app-project-module 2.4.0 → 2.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,14 +2,14 @@ import { CrowdinContextInfo, ModuleKey } from '../../types';
2
2
  import Crowdin from '@crowdin/crowdin-api-client';
3
3
  export interface AiRequestProcessorModule extends ModuleKey {
4
4
  /**
5
- * updates request data
5
+ * updates request data; the returned value is sent back as the processed request
6
6
  */
7
7
  processRequest: (options: {
8
8
  requestData: any;
9
9
  requestContext: any;
10
10
  client: Crowdin;
11
11
  context: CrowdinContextInfo;
12
- }) => Promise<never>;
12
+ }) => Promise<any>;
13
13
  }
14
14
  export interface AiStreamProcessorModule extends AiRequestProcessorModule {
15
15
  /**
@@ -1,5 +1,11 @@
1
1
  import { CrowdinContextInfo, Environments, ModuleKey, UiModule } from '../../types';
2
2
  import Crowdin, { SourceFilesModel, ProjectsGroupsModel, LanguagesModel, SourceStringsModel, StringTranslationsModel } from '@crowdin/crowdin-api-client';
3
+ export interface QaCheckTranslation extends StringTranslationsModel.StringTranslation {
4
+ /**
5
+ * id of the source string this translation belongs to
6
+ */
7
+ stringId: number;
8
+ }
3
9
  export interface ExternalQaCheckModule extends Environments, ModuleKey {
4
10
  /**
5
11
  * module description
@@ -20,7 +26,7 @@ export interface ExternalQaCheckModule extends Environments, ModuleKey {
20
26
  sourceLanguage: LanguagesModel.Language;
21
27
  strings: SourceStringsModel.String[];
22
28
  targetLanguage: LanguagesModel.Language;
23
- translations: StringTranslationsModel.StringTranslation[];
29
+ translations: QaCheckTranslation[];
24
30
  context: CrowdinContextInfo;
25
31
  }) => Promise<ExternalQaCheckResponse>;
26
32
  /**
@@ -34,7 +40,7 @@ export interface ExternalQaCheckRequest {
34
40
  sourceLanguage: LanguagesModel.Language;
35
41
  strings: SourceStringsModel.String[];
36
42
  targetLanguage: LanguagesModel.Language;
37
- translations: StringTranslationsModel.StringTranslation[];
43
+ translations: QaCheckTranslation[];
38
44
  }
39
45
  interface SuggestedFix {
40
46
  indexStart: number;
@@ -1,9 +1,10 @@
1
1
  import { Response } from 'express';
2
2
  import { CrowdinClientRequest, FileStore } from '../../../types';
3
3
  import { CustomFileFormatLogic } from '../types';
4
- export default function handle({ baseUrl, folder, config, fileStore, }: {
4
+ export default function handle({ baseUrl, folder, config, fileStore, clientSecret, }: {
5
5
  baseUrl: string;
6
6
  folder: string;
7
7
  config: CustomFileFormatLogic;
8
8
  fileStore?: FileStore;
9
+ clientSecret: string;
9
10
  }): (req: import("express").Request | CrowdinClientRequest, res: Response, next: Function) => void;
@@ -17,8 +17,9 @@ const fs_1 = __importDefault(require("fs"));
17
17
  const path_1 = __importDefault(require("path"));
18
18
  const util_1 = require("../../../util");
19
19
  const files_1 = require("../util/files");
20
+ const signed_url_1 = require("../util/signed-url");
20
21
  const types_1 = require("../types");
21
- function handle({ baseUrl, folder, config, fileStore, }) {
22
+ function handle({ baseUrl, folder, config, fileStore, clientSecret, }) {
22
23
  if (!fileStore) {
23
24
  if (!fs_1.default.existsSync(path_1.default.join(folder, 'custom-file-format'))) {
24
25
  fs_1.default.mkdirSync(path_1.default.join(folder, 'custom-file-format'), { recursive: true });
@@ -56,6 +57,7 @@ function handle({ baseUrl, folder, config, fileStore, }) {
56
57
  projectId: req.crowdinContext.jwtPayload.context.project_id,
57
58
  file,
58
59
  fileStore,
60
+ clientSecret,
59
61
  });
60
62
  response = buildFileResult.response;
61
63
  error = buildFileResult.error;
@@ -71,6 +73,7 @@ function handle({ baseUrl, folder, config, fileStore, }) {
71
73
  projectId: req.crowdinContext.jwtPayload.context.project_id,
72
74
  file,
73
75
  fileStore,
76
+ clientSecret,
74
77
  });
75
78
  response = parseFileResult.response;
76
79
  error = parseFileResult.error;
@@ -82,7 +85,7 @@ function handle({ baseUrl, folder, config, fileStore, }) {
82
85
  }));
83
86
  }
84
87
  function handleBuildFile(_a) {
85
- return __awaiter(this, arguments, void 0, function* ({ baseUrl, dataFolder, config, req, client, context, projectId, file, fileStore, }) {
88
+ return __awaiter(this, arguments, void 0, function* ({ baseUrl, dataFolder, config, req, client, context, projectId, file, fileStore, clientSecret, }) {
86
89
  const response = {};
87
90
  if (!req.strings && !req.stringsUrl) {
88
91
  throw new Error('Bad payload received: No strings found');
@@ -112,7 +115,7 @@ function handleBuildFile(_a) {
112
115
  let url;
113
116
  if (fileStore) {
114
117
  const fileRef = yield fileStore.storeFile(res.contentFile);
115
- url = `${baseUrl}?file=${fileRef}`;
118
+ url = (0, signed_url_1.signDownloadUrl)(baseUrl, fileRef, clientSecret);
116
119
  }
117
120
  else {
118
121
  if (config.storeFile) {
@@ -120,7 +123,7 @@ function handleBuildFile(_a) {
120
123
  }
121
124
  else {
122
125
  const storedFile = yield (0, files_1.storeFile)(res.contentFile, path_1.default.join(dataFolder, 'custom-file-format'));
123
- url = `${baseUrl}?file=${storedFile}`;
126
+ url = (0, signed_url_1.signDownloadUrl)(baseUrl, storedFile, clientSecret);
124
127
  }
125
128
  }
126
129
  response.contentUrl = url;
@@ -129,7 +132,7 @@ function handleBuildFile(_a) {
129
132
  });
130
133
  }
131
134
  function handleParseFile(_a) {
132
- return __awaiter(this, arguments, void 0, function* ({ baseUrl, dataFolder, config, req, client, context, projectId, file, fileStore, }) {
135
+ return __awaiter(this, arguments, void 0, function* ({ baseUrl, dataFolder, config, req, client, context, projectId, file, fileStore, clientSecret, }) {
133
136
  const response = {};
134
137
  if (!config.parseFile || !file) {
135
138
  return { response };
@@ -147,7 +150,7 @@ function handleParseFile(_a) {
147
150
  let url;
148
151
  if (fileStore) {
149
152
  const fileRef = yield fileStore.storeFile(res.previewFile);
150
- url = `${baseUrl}?file=${fileRef}`;
153
+ url = (0, signed_url_1.signDownloadUrl)(baseUrl, fileRef, clientSecret);
151
154
  }
152
155
  else {
153
156
  if (config.storeFile) {
@@ -155,7 +158,7 @@ function handleParseFile(_a) {
155
158
  }
156
159
  else {
157
160
  const storedFile = yield (0, files_1.storeFile)(res.previewFile, path_1.default.join(dataFolder, 'custom-file-format'));
158
- url = `${baseUrl}?file=${storedFile}`;
161
+ url = (0, signed_url_1.signDownloadUrl)(baseUrl, storedFile, clientSecret);
159
162
  }
160
163
  }
161
164
  response.previewUrl = url;
@@ -181,7 +184,7 @@ function handleParseFile(_a) {
181
184
  let url;
182
185
  if (fileStore) {
183
186
  const fileRef = yield fileStore.storeFile(bufferData);
184
- url = `${baseUrl}?file=${fileRef}`;
187
+ url = (0, signed_url_1.signDownloadUrl)(baseUrl, fileRef, clientSecret);
185
188
  }
186
189
  else {
187
190
  if (config.storeFile) {
@@ -189,7 +192,7 @@ function handleParseFile(_a) {
189
192
  }
190
193
  else {
191
194
  const storedFile = yield (0, files_1.storeFile)(bufferData, path_1.default.join(dataFolder, 'custom-file-format'));
192
- url = `${baseUrl}?file=${storedFile}`;
195
+ url = (0, signed_url_1.signDownloadUrl)(baseUrl, storedFile, clientSecret);
193
196
  }
194
197
  }
195
198
  response.stringsUrl = url;
@@ -18,15 +18,23 @@ const path_1 = __importDefault(require("path"));
18
18
  const util_1 = require("../../../util");
19
19
  const logger_1 = require("../../../util/logger");
20
20
  const folder_1 = require("../util/folder");
21
+ const signed_url_1 = require("../util/signed-url");
21
22
  function handle(config, processingConfig, folderName) {
22
23
  const folder = (0, folder_1.resolveModuleFilesFolder)(config.dbFolder, processingConfig.filesFolder);
23
24
  return (0, util_1.runAsyncWrapper)((req, res) => __awaiter(this, void 0, void 0, function* () {
25
+ const fileRef = req.query.file;
26
+ const expires = req.query.expires;
27
+ const sig = req.query.sig;
28
+ if (!fileRef || !expires || !sig || !(0, signed_url_1.verifySignedDownloadUrl)(fileRef, expires, sig, config.clientSecret)) {
29
+ res.sendStatus(403);
30
+ return;
31
+ }
24
32
  if (config.fileStore) {
25
- const fileContent = yield config.fileStore.getFile(req.query.file);
33
+ const fileContent = yield config.fileStore.getFile(fileRef);
26
34
  res.send(fileContent);
27
35
  return;
28
36
  }
29
- const filePath = path_1.default.join(folder, folderName, req.query.file);
37
+ const filePath = path_1.default.join(folder, folderName, path_1.default.basename(fileRef));
30
38
  (0, logger_1.log)(`Downloading file ${filePath}`);
31
39
  res.download(filePath, function (err) {
32
40
  if (err) {
@@ -19,6 +19,7 @@ const util_1 = require("../../../util");
19
19
  const types_1 = require("../types");
20
20
  const folder_1 = require("../util/folder");
21
21
  const files_1 = require("../util/files");
22
+ const signed_url_1 = require("../util/signed-url");
22
23
  function handle(baseConfig, config, folderName) {
23
24
  const folderPath = (0, folder_1.resolveModuleFilesFolder)(baseConfig.dbFolder, config.filesFolder);
24
25
  if (!baseConfig.fileStore) {
@@ -84,7 +85,7 @@ function handle(baseConfig, config, folderName) {
84
85
  let url;
85
86
  if (baseConfig.fileStore) {
86
87
  const fileRef = yield baseConfig.fileStore.storeFile(contentFile);
87
- url = `${baseFilesUrl}?file=${fileRef}`;
88
+ url = (0, signed_url_1.signDownloadUrl)(baseFilesUrl, fileRef, baseConfig.clientSecret);
88
89
  }
89
90
  else {
90
91
  if (config.storeFile) {
@@ -92,7 +93,7 @@ function handle(baseConfig, config, folderName) {
92
93
  }
93
94
  else {
94
95
  const storedFile = yield (0, files_1.storeFile)(contentFile, path_1.default.join(folderPath, folderName));
95
- url = `${baseFilesUrl}?file=${storedFile}`;
96
+ url = (0, signed_url_1.signDownloadUrl)(baseFilesUrl, storedFile, baseConfig.clientSecret);
96
97
  }
97
98
  }
98
99
  response.contentUrl = url;
@@ -121,7 +122,7 @@ function handle(baseConfig, config, folderName) {
121
122
  let url;
122
123
  if (baseConfig.fileStore) {
123
124
  const fileRef = yield baseConfig.fileStore.storeFile(previewFile);
124
- url = `${baseFilesUrl}?file=${fileRef}`;
125
+ url = (0, signed_url_1.signDownloadUrl)(baseFilesUrl, fileRef, baseConfig.clientSecret);
125
126
  }
126
127
  else {
127
128
  if (config.storeFile) {
@@ -129,7 +130,7 @@ function handle(baseConfig, config, folderName) {
129
130
  }
130
131
  else {
131
132
  const storedFile = yield (0, files_1.storeFile)(previewFile, path_1.default.join(folderPath, folderName));
132
- url = `${baseFilesUrl}?file=${storedFile}`;
133
+ url = (0, signed_url_1.signDownloadUrl)(baseFilesUrl, storedFile, baseConfig.clientSecret);
133
134
  }
134
135
  }
135
136
  response.previewUrl = url;
@@ -145,7 +146,7 @@ function handle(baseConfig, config, folderName) {
145
146
  let url;
146
147
  if (baseConfig.fileStore) {
147
148
  const fileRef = yield baseConfig.fileStore.storeFile(bufferData);
148
- url = `${baseFilesUrl}?file=${fileRef}`;
149
+ url = (0, signed_url_1.signDownloadUrl)(baseFilesUrl, fileRef, baseConfig.clientSecret);
149
150
  }
150
151
  else {
151
152
  if (config.storeFile) {
@@ -153,7 +154,7 @@ function handle(baseConfig, config, folderName) {
153
154
  }
154
155
  else {
155
156
  const storedFile = yield (0, files_1.storeFile)(bufferData, path_1.default.join(folderPath, folderName));
156
- url = `${baseFilesUrl}?file=${storedFile}`;
157
+ url = (0, signed_url_1.signDownloadUrl)(baseFilesUrl, storedFile, baseConfig.clientSecret);
157
158
  }
158
159
  }
159
160
  response.stringsUrl = url;
@@ -18,6 +18,7 @@ const path_1 = __importDefault(require("path"));
18
18
  const util_1 = require("../../../util");
19
19
  const folder_1 = require("../util/folder");
20
20
  const files_1 = require("../util/files");
21
+ const signed_url_1 = require("../util/signed-url");
21
22
  function handle(baseConfig, config, folderName) {
22
23
  const folderPath = (0, folder_1.resolveModuleFilesFolder)(baseConfig.dbFolder, config.filesFolder);
23
24
  if (!fs_1.default.existsSync(path_1.default.join(folderPath, folderName))) {
@@ -65,12 +66,16 @@ function handle(baseConfig, config, folderName) {
65
66
  }
66
67
  else {
67
68
  let url;
68
- if (config.storeFile) {
69
+ if (baseConfig.fileStore) {
70
+ const fileRef = yield baseConfig.fileStore.storeFile(bufferData);
71
+ url = (0, signed_url_1.signDownloadUrl)(baseFilesUrl, fileRef, baseConfig.clientSecret);
72
+ }
73
+ else if (config.storeFile) {
69
74
  url = yield config.storeFile(bufferData);
70
75
  }
71
76
  else {
72
77
  const storedFile = yield (0, files_1.storeFile)(bufferData, path_1.default.join(folderPath, folderName));
73
- url = `${baseFilesUrl}?file=${storedFile}`;
78
+ url = (0, signed_url_1.signDownloadUrl)(baseFilesUrl, storedFile, baseConfig.clientSecret);
74
79
  }
75
80
  response.translationsUrl = url;
76
81
  }
@@ -39,6 +39,7 @@ function registerCustomFileFormat({ config, app }) {
39
39
  folder: (0, folder_1.resolveModuleFilesFolder)(config.dbFolder, ff.filesFolder),
40
40
  config: ff,
41
41
  fileStore: config.fileStore,
42
+ clientSecret: config.clientSecret,
42
43
  }));
43
44
  app.get(isSingle ? '/file/download' : `/file/${key}/download`, (0, file_download_1.default)(config, ff, 'custom-file-format'));
44
45
  }
@@ -54,6 +55,7 @@ function registerCustomFileFormat({ config, app }) {
54
55
  folder: (0, folder_1.resolveModuleFilesFolder)(config.dbFolder, ff.filesFolder),
55
56
  config: ff,
56
57
  fileStore: config.fileStore,
58
+ clientSecret: config.clientSecret,
57
59
  }));
58
60
  // END TEMPORARY CODE
59
61
  }
@@ -17,11 +17,12 @@ exports.storeFile = storeFile;
17
17
  exports.getFileContent = getFileContent;
18
18
  exports.getFileStrings = getFileStrings;
19
19
  const axios_1 = __importDefault(require("axios"));
20
+ const crypto_1 = require("crypto");
20
21
  const fs_1 = __importDefault(require("fs"));
21
22
  const path_1 = __importDefault(require("path"));
22
23
  exports.MAX_BODY_SIZE = 4.9 * 1024 * 1024; //4.9mb
23
24
  function storeFile(fileContent, folder) {
24
- const fileName = `file${Date.now()}`;
25
+ const fileName = (0, crypto_1.randomBytes)(16).toString('hex');
25
26
  return new Promise((res, rej) => fs_1.default.writeFile(path_1.default.join(folder, fileName), fileContent, (err) => {
26
27
  if (err) {
27
28
  rej(err);
@@ -0,0 +1,2 @@
1
+ export declare function signDownloadUrl(baseUrl: string, fileRef: string, secret: string, expiresInMs?: number): string;
2
+ export declare function verifySignedDownloadUrl(fileRef: string, expires: string, sig: string, secret: string): boolean;
@@ -0,0 +1,27 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.signDownloadUrl = signDownloadUrl;
4
+ exports.verifySignedDownloadUrl = verifySignedDownloadUrl;
5
+ const crypto_1 = require("crypto");
6
+ const DEFAULT_EXPIRY_MS = 60 * 60 * 1000; // 1 hour
7
+ function signDownloadUrl(baseUrl, fileRef, secret, expiresInMs = DEFAULT_EXPIRY_MS) {
8
+ const expires = Date.now() + expiresInMs;
9
+ const sig = computeHmac(fileRef, expires, secret);
10
+ return `${baseUrl}?file=${encodeURIComponent(fileRef)}&expires=${expires}&sig=${sig}`;
11
+ }
12
+ function verifySignedDownloadUrl(fileRef, expires, sig, secret) {
13
+ const expiresMs = parseInt(expires, 10);
14
+ if (isNaN(expiresMs) || Date.now() > expiresMs) {
15
+ return false;
16
+ }
17
+ const expected = computeHmac(fileRef, expiresMs, secret);
18
+ const expectedBuf = Buffer.from(expected, 'hex');
19
+ const sigBuf = Buffer.from(sig, 'hex');
20
+ if (expectedBuf.length !== sigBuf.length) {
21
+ return false;
22
+ }
23
+ return (0, crypto_1.timingSafeEqual)(expectedBuf, sigBuf);
24
+ }
25
+ function computeHmac(fileRef, expires, secret) {
26
+ return (0, crypto_1.createHmac)('sha256', secret).update(`${fileRef}:${expires}`).digest('hex');
27
+ }
@@ -16,6 +16,7 @@ exports.filterFilesFromIntegrationRequest = filterFilesFromIntegrationRequest;
16
16
  exports.createOrUpdateSyncSettings = createOrUpdateSyncSettings;
17
17
  exports.removeFinishedJobs = removeFinishedJobs;
18
18
  const storage_1 = require("../../../storage");
19
+ const util_1 = require("../../../util");
19
20
  const token_1 = require("../../../util/app-functions/token");
20
21
  const connection_1 = require("../../../util/connection");
21
22
  const logger_1 = require("../../../util/logger");
@@ -486,14 +487,25 @@ function getAllNewFiles(_a) {
486
487
  function getNewFoldersFile(folders, snapshotFiles) {
487
488
  let files = [];
488
489
  for (const folder of folders) {
489
- const folderFiles = snapshotFiles.filter((file) => Number(file.parentId) === Number(folder.id));
490
+ const folderFiles = snapshotFiles.filter((file) => isSameFolder(file.parentId, folder.id));
490
491
  if (folderFiles.length) {
491
492
  files = files.concat(folderFiles);
492
493
  }
493
494
  }
494
- files = files.filter((file) => 'type' in file);
495
+ files = (0, util_1.uniqBy)(files, 'id').filter((file) => Boolean(file.type));
495
496
  return files;
496
497
  }
498
+ function isSameFolder(parentId, folderId) {
499
+ if (parentId === null || parentId === undefined || folderId === null || folderId === undefined) {
500
+ return false;
501
+ }
502
+ const numParentId = Number(parentId);
503
+ const numFolderId = Number(folderId);
504
+ if (!Number.isNaN(numParentId) && !Number.isNaN(numFolderId)) {
505
+ return numParentId === numFolderId;
506
+ }
507
+ return String(parentId) === String(folderId) || String(parentId).startsWith(`${folderId}/`);
508
+ }
497
509
  function getOnlyTranslatedOrApprovedFiles(_a) {
498
510
  return __awaiter(this, arguments, void 0, function* ({ projectId, crowdinFiles, crowdinClient, onlyApproved, onlyTranslated, context, integration, }) {
499
511
  const deletedFiles = [];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crowdin/app-project-module",
3
- "version": "2.4.0",
3
+ "version": "2.5.1",
4
4
  "description": "Module that generates for you all common endpoints for serving standalone Crowdin App",
5
5
  "main": "out/index.js",
6
6
  "types": "out/index.d.ts",
@@ -37,6 +37,7 @@
37
37
  }
38
38
  },
39
39
  "scripts": {
40
+ "sandbox": "vite --config sandbox/vite.config.ts",
40
41
  "build": "npm run build-css && npm run build-main && npm run build-ui",
41
42
  "build-css": "tailwindcss -i ./ui/styles/tailwind.css -o ./static/css/styles.css --minify",
42
43
  "build-main": "tsc -p ./ && cp -R static out && cp logo.png out && npm run copy-migrations",
@@ -121,6 +122,7 @@
121
122
  "@types/swagger-jsdoc": "^6.0.4",
122
123
  "@typescript-eslint/eslint-plugin": "^8.56.0",
123
124
  "@typescript-eslint/parser": "^8.56.0",
125
+ "@vitejs/plugin-react": "^4.7.0",
124
126
  "auto-changelog": "^2.5.0",
125
127
  "drizzle-kit": "^0.31.10",
126
128
  "eslint": "^10.0.1",
@@ -132,7 +134,8 @@
132
134
  "rollup": "^4.59.0",
133
135
  "tailwindcss": "^4.3.2",
134
136
  "ts-jest": "^29.4.6",
135
- "typescript": "^5.9.3"
137
+ "typescript": "^5.9.3",
138
+ "vite": "^6.4.3"
136
139
  },
137
140
  "repository": {
138
141
  "type": "git",