@crowdin/app-project-module 3.1.0 → 3.2.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.
@@ -1,13 +1,15 @@
1
- import Crowdin from '@crowdin/crowdin-api-client';
1
+ import type Crowdin from '@crowdin/crowdin-api-client';
2
2
  import { Response } from 'express';
3
3
  import { Config, CrowdinClientRequest, CrowdinContextInfo, SubscriptionInfo } from '../types';
4
4
  import { LogErrorFunction, LogFunction } from '../util/logger';
5
- export declare function prepareCrowdinRequest({ jwtToken, config, optional, checkSubscriptionExpiration, moduleKey, }: {
5
+ export declare function prepareCrowdinRequest({ jwtToken, config, optional, checkSubscriptionExpiration, moduleKey, crowdinToken, projectId, }: {
6
6
  jwtToken: string;
7
7
  config: Config;
8
8
  optional: boolean;
9
9
  checkSubscriptionExpiration: boolean;
10
10
  moduleKey: string[] | string | undefined;
11
+ crowdinToken?: string;
12
+ projectId?: number;
11
13
  }): Promise<{
12
14
  context: CrowdinContextInfo;
13
15
  logInfo: LogFunction;
@@ -20,8 +20,17 @@ const logger_1 = require("../util/logger");
20
20
  const subscription_1 = require("../util/subscription");
21
21
  const integration_access_denied_1 = require("./integration-access-denied");
22
22
  const token_1 = require("../util/app-functions/token");
23
+ function assertModuleKey(moduleKey, jwtModule) {
24
+ if (!moduleKey || !jwtModule) {
25
+ return;
26
+ }
27
+ const matches = typeof moduleKey === 'string' ? moduleKey === jwtModule : moduleKey.includes(jwtModule);
28
+ if (!matches) {
29
+ throw new Error("Module key doesn't match");
30
+ }
31
+ }
23
32
  function prepareCrowdinRequest(_a) {
24
- return __awaiter(this, arguments, void 0, function* ({ jwtToken, config, optional = false, checkSubscriptionExpiration = true, moduleKey, }) {
33
+ return __awaiter(this, arguments, void 0, function* ({ jwtToken, config, optional = false, checkSubscriptionExpiration = true, moduleKey, crowdinToken, projectId, }) {
25
34
  (0, logger_1.log)('Validating jwt token from incoming request');
26
35
  const jwtPayload = yield (0, token_1.validateJwtToken)(jwtToken, config.clientSecret, config.jwtValidationOptions);
27
36
  const context = {
@@ -32,6 +41,29 @@ function prepareCrowdinRequest(_a) {
32
41
  };
33
42
  const logInfo = (0, logger_1.withContext)(context);
34
43
  const logError = (0, logger_1.withContextError)(context);
44
+ if (crowdinToken) {
45
+ logInfo('Using caller-supplied Crowdin token');
46
+ assertModuleKey(moduleKey, jwtPayload.module);
47
+ const client = yield (0, connection_1.buildCrowdinClientFromPat)({
48
+ config,
49
+ token: crowdinToken,
50
+ // For enterprise the org-specific URL implicitly restricts the token to this org.
51
+ // For Crowdin.com (no domain) we fall back to project-level access validation below.
52
+ organization: jwtPayload.domain || undefined,
53
+ });
54
+ try {
55
+ yield client.usersApi.getAuthenticatedUser();
56
+ // For non-enterprise (no domain) there is no org-scoped URL, so validate by
57
+ // checking that the PAT has access to the project named in the JWT / request.
58
+ if (!jwtPayload.domain && projectId) {
59
+ yield client.projectsGroupsApi.getProject(projectId);
60
+ }
61
+ }
62
+ catch (_b) {
63
+ throw new util_1.CodeError('Supplied crowdinToken is invalid or does not belong to this organization', 403);
64
+ }
65
+ return { context, client, logInfo, logError };
66
+ }
35
67
  logInfo('Loading crowdin credentials');
36
68
  const credentials = yield (0, storage_1.getStorage)().getCrowdinCredentials(context.crowdinId);
37
69
  if (!credentials) {
@@ -40,14 +72,7 @@ function prepareCrowdinRequest(_a) {
40
72
  }
41
73
  throw new Error("Can't find organization by id");
42
74
  }
43
- if (!!moduleKey && !!jwtPayload.module) {
44
- if (typeof moduleKey === 'string' && moduleKey !== jwtPayload.module) {
45
- throw new Error("Module key doesn't match");
46
- }
47
- if (Array.isArray(moduleKey) && !moduleKey.includes(jwtPayload.module)) {
48
- throw new Error("Module key doesn't match");
49
- }
50
- }
75
+ assertModuleKey(moduleKey, jwtPayload.module);
51
76
  logInfo('Building crowdin client instance');
52
77
  const { client, token } = yield (0, connection_1.prepareCrowdinClient)({ config, credentials, autoRenew: true, context });
53
78
  let subscriptionInfo;
@@ -67,22 +92,25 @@ function prepareCrowdinRequest(_a) {
67
92
  }
68
93
  function handle({ config, optional = false, checkSubscriptionExpiration = true, moduleKey, isIntegration = false, isLogout = false, }) {
69
94
  return (0, util_1.runAsyncWrapper)((req, res, next) => __awaiter(this, void 0, void 0, function* () {
70
- var _a, _b;
95
+ var _a, _b, _c, _d, _e, _f, _g, _h, _j;
71
96
  const jwtToken = getToken(req);
72
97
  if (!jwtToken) {
73
98
  (0, logger_1.temporaryErrorDebug)('Access denied: crowdin-client', req);
74
99
  return res.status(403).send({ error: 'Access denied' });
75
100
  }
76
101
  try {
102
+ const isAgentCall = req.isApiCall && !!((_a = req.body) === null || _a === void 0 ? void 0 : _a.crowdinToken);
77
103
  const data = yield prepareCrowdinRequest({
78
104
  jwtToken,
79
105
  config,
80
106
  optional,
81
107
  checkSubscriptionExpiration,
82
108
  moduleKey,
109
+ crowdinToken: isAgentCall ? req.body.crowdinToken : undefined,
110
+ projectId: isAgentCall ? (_b = req.body) === null || _b === void 0 ? void 0 : _b.projectId : undefined,
83
111
  });
84
- if ((_a = config.api) === null || _a === void 0 ? void 0 : _a.default) {
85
- if (req.isApiCall && !((_b = req.body) === null || _b === void 0 ? void 0 : _b.projectId)) {
112
+ if ((_c = config.api) === null || _c === void 0 ? void 0 : _c.default) {
113
+ if (req.isApiCall && !((_d = req.body) === null || _d === void 0 ? void 0 : _d.projectId)) {
86
114
  return res.status(400).json({
87
115
  error: {
88
116
  message: 'Missing required parameter: projectId',
@@ -91,7 +119,10 @@ function handle({ config, optional = false, checkSubscriptionExpiration = true,
91
119
  }
92
120
  data.context = (0, api_1.updateCrowdinContext)(req, data.context);
93
121
  }
94
- if (isIntegration && data.client) {
122
+ // Skip integration access check when the caller supplies their own credentials —
123
+ // integration-credentials middleware handles those via the injected path.
124
+ const hasInjectedIntegrationCreds = req.isApiCall && !!((_e = req.body) === null || _e === void 0 ? void 0 : _e.integrationCredentials);
125
+ if (isIntegration && data.client && !hasInjectedIntegrationCreds) {
95
126
  const result = yield (0, connection_1.resolveIntegrationAccess)(data.context.clientId);
96
127
  if (!result.integrationCredentials && !isLogout) {
97
128
  const owners = yield (0, integration_access_denied_1.getIntegrationManagedBy)({
@@ -121,12 +152,18 @@ function handle({ config, optional = false, checkSubscriptionExpiration = true,
121
152
  req.subscriptionInfo = data.subscriptionInfo;
122
153
  req.logInfo = data.logInfo;
123
154
  req.logError = data.logError;
155
+ // Strip secrets from req.body so no downstream handler ever sees raw tokens.
156
+ // crowdinToken has been consumed; move integrationCredentials to a typed slot
157
+ // so the integration-credentials middleware can still pick it up.
158
+ req.injectedIntegrationCredentials = (_f = req.body) === null || _f === void 0 ? void 0 : _f.integrationCredentials;
159
+ (_g = req.body) === null || _g === void 0 ? true : delete _g.crowdinToken;
160
+ (_h = req.body) === null || _h === void 0 ? true : delete _h.integrationCredentials;
124
161
  next();
125
162
  }
126
163
  catch (e) {
127
164
  const errorMessage = (0, logger_1.getErrorMessage)(e);
128
165
  if (e instanceof util_1.CodeError) {
129
- return res.status(402).send({ message: errorMessage || 'Error', code: e.code });
166
+ return res.status((_j = e.code) !== null && _j !== void 0 ? _j : 402).send({ message: errorMessage || 'Error', code: e.code });
130
167
  }
131
168
  (0, logger_1.logError)(e);
132
169
  return res.status(403).send({ error: errorMessage || 'Error' });
@@ -15,9 +15,42 @@ const storage_1 = require("../storage");
15
15
  const util_1 = require("../util");
16
16
  const connection_1 = require("../util/connection");
17
17
  const integration_access_denied_1 = require("./integration-access-denied");
18
+ function loadIntegrationSettings(clientId, integration, req) {
19
+ return __awaiter(this, void 0, void 0, function* () {
20
+ const integrationConfig = yield (0, storage_1.getStorage)().getIntegrationConfig(clientId);
21
+ let settings = (integrationConfig === null || integrationConfig === void 0 ? void 0 : integrationConfig.config) ? JSON.parse(integrationConfig.config) || {} : {};
22
+ if (integration.normalizeSettings) {
23
+ settings = yield integration.normalizeSettings({
24
+ settings,
25
+ credentials: req.integrationCredentials,
26
+ client: req.crowdinApiClient,
27
+ });
28
+ }
29
+ return settings;
30
+ });
31
+ }
18
32
  function handle({ config, integration, optional = false, isLogout = false, }) {
19
33
  return (0, util_1.runAsyncWrapper)((req, res, next) => __awaiter(this, void 0, void 0, function* () {
20
34
  const isApiCall = req === null || req === void 0 ? void 0 : req.isApiCall;
35
+ // crowdin-client middleware moves integrationCredentials out of req.body before next(),
36
+ // so secrets are never visible to handlers. Read from the typed slot it left behind.
37
+ const rawInjected = req.injectedIntegrationCredentials;
38
+ if (isApiCall && rawInjected) {
39
+ delete req.injectedIntegrationCredentials;
40
+ req.logInfo('Using caller-supplied integration credentials');
41
+ req.integrationCredentials = typeof rawInjected === 'string' ? { accessToken: rawInjected } : rawInjected;
42
+ if (integration.checkConnection) {
43
+ try {
44
+ yield integration.checkConnection(req.integrationCredentials);
45
+ }
46
+ catch (e) {
47
+ return res.status(401).json({ error: { message: 'Supplied integrationCredentials are invalid' } });
48
+ }
49
+ }
50
+ const clientId = req.crowdinContext.clientId;
51
+ req.integrationSettings = yield loadIntegrationSettings(clientId, integration, req);
52
+ return next();
53
+ }
21
54
  req.logInfo(`Loading integration credentials for client ${req.crowdinContext.clientId}`);
22
55
  const { integrationCredentials: resolvedIntegrationCredentials, projectIntegrationCredentials, projectId, clientId: resolvedClientId, ownerIds, } = yield (0, connection_1.resolveIntegrationAccess)(req.crowdinContext.clientId);
23
56
  let integrationCredentials = resolvedIntegrationCredentials;
@@ -79,18 +112,7 @@ function handle({ config, integration, optional = false, isLogout = false, }) {
79
112
  throw new util_1.CodeError(message, 401);
80
113
  }
81
114
  }
82
- const integrationConfig = yield (0, storage_1.getStorage)().getIntegrationConfig(clientId);
83
- if (integrationConfig === null || integrationConfig === void 0 ? void 0 : integrationConfig.config) {
84
- let integrationSettings = JSON.parse(integrationConfig.config) || {};
85
- if (integration.normalizeSettings) {
86
- integrationSettings = yield integration.normalizeSettings({
87
- settings: integrationSettings,
88
- credentials: req.integrationCredentials,
89
- client: req.crowdinApiClient,
90
- });
91
- }
92
- req.integrationSettings = integrationSettings;
93
- }
115
+ req.integrationSettings = yield loadIntegrationSettings(clientId, integration, req);
94
116
  next();
95
117
  }));
96
118
  }
@@ -13,11 +13,24 @@ exports.default = handle;
13
13
  const util_1 = require("../../../util");
14
14
  const defaults_1 = require("../util/defaults");
15
15
  const logger_1 = require("../../../util/logger");
16
+ const types_1 = require("../types");
16
17
  const files_1 = require("../util/files");
18
+ const storage_1 = require("../../../storage");
17
19
  function handle(config, integration) {
18
20
  return (0, util_1.runAsyncWrapper)((req, res) => __awaiter(this, void 0, void 0, function* () {
19
- var _a;
21
+ var _a, _b;
20
22
  const mode = (_a = req.query) === null || _a === void 0 ? void 0 : _a.mode;
23
+ const externalId = (_b = req.query) === null || _b === void 0 ? void 0 : _b.externalId;
24
+ // Reverse lookup: given an integration file ID, return the mapped Crowdin file entry.
25
+ if (externalId) {
26
+ const syncedData = yield (0, storage_1.getStorage)().getSyncedData(req.crowdinContext.clientId, req.crowdinContext.crowdinId, types_1.Provider.INTEGRATION);
27
+ const syncedFiles = (syncedData === null || syncedData === void 0 ? void 0 : syncedData.files) ? JSON.parse(syncedData.files) : [];
28
+ const entry = syncedFiles.find((f) => f.id === externalId);
29
+ if (!(entry === null || entry === void 0 ? void 0 : entry.crowdinFileId)) {
30
+ return res.status(404).json({ error: { message: 'No Crowdin file mapped to this external ID' } });
31
+ }
32
+ return res.json({ id: String(entry.crowdinFileId), externalFileId: externalId });
33
+ }
21
34
  req.logInfo(`Loading crowdin files${mode ? ` (mode: ${mode})` : ''}`);
22
35
  if (integration.getCrowdinFiles) {
23
36
  const rootFolder = yield (0, defaults_1.getRootFolder)(config, integration, req.crowdinApiClient, req.crowdinContext.jwtPayload.context.project_id);
@@ -41,6 +54,26 @@ function handle(config, integration) {
41
54
  files,
42
55
  });
43
56
  }
57
+ // Path B: populate externalFileId from stored integrationFileId→crowdinFileId mapping
58
+ // for files where the app developer hasn't supplied it directly (Path A).
59
+ // A leaf is either a typed file or explicitly flagged with nodeType '1';
60
+ // folders (nodeType '0') and branches (nodeType '2') without a type are skipped.
61
+ const isFileLeaf = (f) => f.type !== undefined || f.nodeType === '1';
62
+ const needsMapping = files.some((f) => isFileLeaf(f) && !f.externalFileId);
63
+ if (needsMapping) {
64
+ const syncedData = yield (0, storage_1.getStorage)().getSyncedData(req.crowdinContext.clientId, req.crowdinContext.crowdinId, types_1.Provider.INTEGRATION);
65
+ if (syncedData === null || syncedData === void 0 ? void 0 : syncedData.files) {
66
+ const syncedFiles = JSON.parse(syncedData.files);
67
+ const byCrowdinFileId = new Map(syncedFiles
68
+ .filter((e) => e.crowdinFileId !== null && e.crowdinFileId !== undefined)
69
+ .map((e) => [String(e.crowdinFileId), e.id]));
70
+ if (byCrowdinFileId.size > 0) {
71
+ files = files.map((f) => !isFileLeaf(f) || f.externalFileId || !byCrowdinFileId.has(f.id)
72
+ ? f
73
+ : Object.assign(Object.assign({}, f), { externalFileId: byCrowdinFileId.get(f.id) }));
74
+ }
75
+ }
76
+ }
44
77
  req.logInfo(`Returning ${files.length} items`);
45
78
  res.send(files);
46
79
  }
@@ -451,6 +451,7 @@ export interface BaseTreeItem {
451
451
  path?: string;
452
452
  type?: string;
453
453
  syncedAt?: string;
454
+ externalFileId?: string;
454
455
  }
455
456
  export interface File extends BaseTreeItem {
456
457
  type: SourceFilesModel.FileType;
@@ -35515,14 +35515,12 @@ Defaulting to \`null\`.`;
35515
35515
  const includedIds = new Set();
35516
35516
  if (query) {
35517
35517
  model.items.forEach((item) => {
35518
- if (!matchesSearch(item, query)) {
35518
+ if (item.isLanguage || !matchesSearch(item, query)) {
35519
35519
  return;
35520
35520
  }
35521
35521
  includedIds.add(item.id);
35522
35522
  getAncestorIds(model, item.id).forEach((id) => includedIds.add(id));
35523
- if (item.nodeType !== 'file') {
35524
- getDescendantIds(model, item.id).forEach((id) => includedIds.add(id));
35525
- }
35523
+ getDescendantIds(model, item.id).forEach((id) => includedIds.add(id));
35526
35524
  });
35527
35525
  }
35528
35526
  const result = [];
@@ -36386,7 +36384,7 @@ Defaulting to \`null\`.`;
36386
36384
  if (!isFile(item)) {
36387
36385
  return result;
36388
36386
  }
36389
- const languageIds = languagesRef.current
36387
+ const languageIds = displayedLanguagesRef.current
36390
36388
  .filter((language) => selected.has(`${item.id}__${language.id}`))
36391
36389
  .map((language) => String(language.id));
36392
36390
  if (languageIds.length) {