i18ntk 3.2.0 → 3.3.0

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,39 +1,11 @@
1
- const https = require('https');
2
1
  const { URL } = require('url');
2
+ const { safeHttpGet, safeHttpPost, buildGoogleTranslateUrl } = require('./safe-network');
3
3
 
4
4
  const DEFAULT_CONCURRENCY = 3;
5
5
  const DEFAULT_RETRY_COUNT = 3;
6
6
  const DEFAULT_RETRY_DELAY = 1000;
7
7
  const MAX_BACKOFF_DELAY = 30000;
8
8
 
9
- function httpGet(urlString, timeout = 15000) {
10
- return new Promise((resolve) => {
11
- const url = new URL(urlString);
12
- if (url.protocol !== 'https:') {
13
- resolve({ ok: false, error: 'ProtocolError', message: 'Only HTTPS requests are supported' });
14
- return;
15
- }
16
- const req = https.get(urlString, { headers: { 'User-Agent': 'i18ntk/3.2.0' } }, (res) => {
17
- let data = '';
18
- res.on('data', (chunk) => { data += chunk; });
19
- res.on('end', () => {
20
- try {
21
- const parsed = JSON.parse(data);
22
- resolve({ ok: true, data: parsed, status: res.statusCode });
23
- } catch (e) {
24
- resolve({ ok: false, error: 'ParseError', raw: data.substring(0, 500), status: res.statusCode });
25
- }
26
- });
27
- });
28
- req.setTimeout(timeout, () => {
29
- req.destroy();
30
- resolve({ ok: false, error: 'TimeoutError', message: 'Request timed out' });
31
- });
32
- req.on('error', (e) => {
33
- resolve({ ok: false, error: e.code || 'NetworkError', message: e.message });
34
- });
35
- });
36
- }
37
9
 
38
10
  function extractTranslation(result) {
39
11
  if (result && Array.isArray(result) && result[0]) {
@@ -46,6 +18,154 @@ function extractTranslation(result) {
46
18
  return null;
47
19
  }
48
20
 
21
+ function normalizeProvider(provider) {
22
+ const value = String(provider || process.env.I18NTK_TRANSLATE_PROVIDER || 'google').trim().toLowerCase();
23
+ if (value === 'deepl-free' || value === 'deepl-pro') return 'deepl';
24
+ if (value === 'libre' || value === 'libretranslate') return 'libretranslate';
25
+ if (value === 'google' || value === 'gtx') return 'google';
26
+ return value;
27
+ }
28
+
29
+ function normalizeDeepLLanguage(code) {
30
+ return String(code || '').replace('-', '_').toUpperCase();
31
+ }
32
+
33
+ function getDeepLApiUrl(options = {}) {
34
+ if (options.deeplApiUrl) return options.deeplApiUrl;
35
+ if (process.env.DEEPL_API_URL) return process.env.DEEPL_API_URL;
36
+ return 'https://api-free.deepl.com/v2/translate';
37
+ }
38
+
39
+ function isEnabled(value) {
40
+ return ['1', 'true', 'yes', 'on'].includes(String(value || '').trim().toLowerCase());
41
+ }
42
+
43
+ function getDeepLAllowedHosts(url, options = {}) {
44
+ const hosts = ['api-free.deepl.com', 'api.deepl.com'];
45
+ if (options.allowCustomTranslateHosts || isEnabled(process.env.I18NTK_ALLOW_CUSTOM_TRANSLATE_HOSTS)) {
46
+ hosts.push(new URL(url).hostname);
47
+ }
48
+ return [...new Set(hosts)];
49
+ }
50
+
51
+ function parseProviderUrl(url, provider) {
52
+ try {
53
+ return { ok: true, parsed: new URL(url) };
54
+ } catch (error) {
55
+ return {
56
+ ok: false,
57
+ error: 'InvalidProviderUrl',
58
+ message: `${provider} provider URL is invalid: ${error.message}`,
59
+ };
60
+ }
61
+ }
62
+
63
+ function extractDeepLTranslation(data) {
64
+ const value = data && Array.isArray(data.translations) && data.translations[0] && data.translations[0].text;
65
+ return typeof value === 'string' && value.trim() ? value : null;
66
+ }
67
+
68
+ function getLibreTranslateUrl(options = {}) {
69
+ if (options.libreTranslateUrl) return options.libreTranslateUrl;
70
+ if (process.env.LIBRETRANSLATE_URL) return process.env.LIBRETRANSLATE_URL;
71
+ return 'https://libretranslate.com/translate';
72
+ }
73
+
74
+ function getLibreTranslateAllowedHosts(url) {
75
+ return [...new Set(['libretranslate.com', new URL(url).hostname])];
76
+ }
77
+
78
+ function extractLibreTranslateTranslation(data) {
79
+ const value = data && data.translatedText;
80
+ return typeof value === 'string' && value.trim() ? value : null;
81
+ }
82
+
83
+ function buildProviderRequest(text, targetLang, options = {}) {
84
+ const provider = normalizeProvider(options.provider);
85
+ const sourceLang = options.sourceLang || 'en';
86
+
87
+ if (provider === 'google') {
88
+ return {
89
+ provider,
90
+ method: 'GET',
91
+ url: buildGoogleTranslateUrl(text, sourceLang, targetLang),
92
+ extract: extractTranslation,
93
+ };
94
+ }
95
+
96
+ if (provider === 'deepl') {
97
+ const apiKey = options.deeplApiKey || process.env.DEEPL_API_KEY;
98
+ if (!apiKey) {
99
+ return {
100
+ provider,
101
+ error: 'MissingApiKey',
102
+ message: 'DeepL provider requires DEEPL_API_KEY in the environment or deeplApiKey in options.',
103
+ };
104
+ }
105
+
106
+ const url = getDeepLApiUrl(options);
107
+ const parsedUrl = parseProviderUrl(url, 'DeepL');
108
+ if (!parsedUrl.ok) return { provider, error: parsedUrl.error, message: parsedUrl.message };
109
+
110
+ return {
111
+ provider,
112
+ method: 'POST',
113
+ url,
114
+ body: {
115
+ text: [text],
116
+ target_lang: normalizeDeepLLanguage(targetLang),
117
+ source_lang: normalizeDeepLLanguage(sourceLang),
118
+ },
119
+ requestOptions: {
120
+ provider,
121
+ allowedHosts: getDeepLAllowedHosts(url, options),
122
+ allowedPaths: ['/v2/translate'],
123
+ headers: {
124
+ Authorization: `DeepL-Auth-Key ${apiKey}`,
125
+ },
126
+ },
127
+ extract: extractDeepLTranslation,
128
+ };
129
+ }
130
+
131
+ if (provider === 'libretranslate') {
132
+ const apiKey = options.libreTranslateApiKey || process.env.LIBRETRANSLATE_API_KEY || '';
133
+ const url = getLibreTranslateUrl(options);
134
+ const parsedUrl = parseProviderUrl(url, 'LibreTranslate');
135
+ if (!parsedUrl.ok) return { provider, error: parsedUrl.error, message: parsedUrl.message };
136
+
137
+ const params = new URLSearchParams({
138
+ q: text,
139
+ source: sourceLang,
140
+ target: targetLang,
141
+ format: 'text',
142
+ });
143
+ if (apiKey) params.set('api_key', apiKey);
144
+
145
+ return {
146
+ provider,
147
+ method: 'POST',
148
+ url,
149
+ body: params.toString(),
150
+ requestOptions: {
151
+ provider,
152
+ allowedHosts: getLibreTranslateAllowedHosts(url),
153
+ allowedPaths: ['/translate'],
154
+ headers: {
155
+ 'Content-Type': 'application/x-www-form-urlencoded',
156
+ },
157
+ },
158
+ extract: extractLibreTranslateTranslation,
159
+ };
160
+ }
161
+
162
+ return {
163
+ provider,
164
+ error: 'UnsupportedProvider',
165
+ message: `Unsupported translation provider "${provider}". Use google, deepl, or libretranslate.`,
166
+ };
167
+ }
168
+
49
169
  function detectRateLimitError(result) {
50
170
  if (!result.ok && result.status === 429) return true;
51
171
  if (result.ok && result.status === 429) return true;
@@ -61,6 +181,8 @@ async function translateText(text, targetLang, options = {}) {
61
181
  retryDelay = DEFAULT_RETRY_DELAY,
62
182
  customFn,
63
183
  timeout = 15000,
184
+ httpGet = safeHttpGet,
185
+ httpPost = safeHttpPost,
64
186
  } = options;
65
187
 
66
188
  if (!text || text.trim().length === 0) return { ok: true, translated: text };
@@ -74,14 +196,10 @@ async function translateText(text, targetLang, options = {}) {
74
196
  }
75
197
  }
76
198
 
77
- const params = new URLSearchParams({
78
- client: 'gtx',
79
- sl: sourceLang,
80
- tl: targetLang,
81
- dt: 't',
82
- q: text,
83
- });
84
- const url = `https://translate.googleapis.com/translate_a/single?${params.toString()}`;
199
+ const request = buildProviderRequest(text, targetLang, { ...options, sourceLang });
200
+ if (request.error) {
201
+ return { ok: false, translated: null, error: request.error, message: request.message };
202
+ }
85
203
 
86
204
  let lastError = null;
87
205
  for (let attempt = 0; attempt < retryCount; attempt++) {
@@ -90,10 +208,12 @@ async function translateText(text, targetLang, options = {}) {
90
208
  await new Promise((resolve) => setTimeout(resolve, delay));
91
209
  }
92
210
 
93
- const result = await httpGet(url, timeout);
211
+ const result = request.method === 'POST'
212
+ ? await httpPost(request.url, request.body, { ...(request.requestOptions || {}), timeout })
213
+ : await httpGet(request.url, timeout);
94
214
 
95
215
  if (result.ok) {
96
- const translated = extractTranslation(result.data);
216
+ const translated = request.extract(result.data);
97
217
  if (translated !== null && translated !== text) {
98
218
  return { ok: true, translated };
99
219
  }
@@ -101,7 +221,7 @@ async function translateText(text, targetLang, options = {}) {
101
221
  return { ok: true, translated: text };
102
222
  }
103
223
  if (result.status === 429 || (translated === null && result.status >= 400)) {
104
- lastError = { error: 'RateLimited', message: 'Google Translate rate limit hit' };
224
+ lastError = { error: 'RateLimited', message: `${request.provider} rate limit hit` };
105
225
  continue;
106
226
  }
107
227
  }
@@ -165,8 +285,11 @@ async function translateBatch(batch, targetLang, options = {}) {
165
285
  }
166
286
 
167
287
  module.exports = {
168
- httpGet,
169
288
  extractTranslation,
289
+ extractDeepLTranslation,
290
+ extractLibreTranslateTranslation,
291
+ buildProviderRequest,
292
+ normalizeProvider,
170
293
  detectRateLimitError,
171
294
  translateText,
172
295
  translateBatch,
@@ -0,0 +1,280 @@
1
+ const https = require('https');
2
+ const { URL } = require('url');
3
+ const { logger } = require('../logger');
4
+
5
+ const MAX_RESPONSE_SIZE = 100 * 1024;
6
+ const ALLOWED_HOSTS = ['translate.googleapis.com', 'api-free.deepl.com', 'api.deepl.com', 'libretranslate.com'];
7
+ const ALLOWED_PATHS = ['/translate_a/single', '/v2/translate', '/translate'];
8
+ const USER_AGENT = 'i18ntk/3.3.0';
9
+
10
+ function isEnabled(value) {
11
+ return ['1', 'true', 'yes', 'on'].includes(String(value || '').trim().toLowerCase());
12
+ }
13
+
14
+ function isPrivateIPv4(hostname) {
15
+ const parts = String(hostname || '').split('.');
16
+ if (parts.length !== 4) return false;
17
+
18
+ const bytes = parts.map((part) => {
19
+ if (!/^\d+$/.test(part)) return null;
20
+ const value = Number(part);
21
+ return value >= 0 && value <= 255 ? value : null;
22
+ });
23
+ if (bytes.some((part) => part === null)) return false;
24
+
25
+ const [a, b] = bytes;
26
+ return a === 10
27
+ || a === 127
28
+ || (a === 172 && b >= 16 && b <= 31)
29
+ || (a === 192 && b === 168)
30
+ || (a === 169 && b === 254)
31
+ || a === 0;
32
+ }
33
+
34
+ function isPrivateHost(hostname) {
35
+ const normalized = String(hostname || '').trim().toLowerCase().replace(/^\[|\]$/g, '').replace(/\.$/, '');
36
+ return normalized === 'localhost'
37
+ || normalized.endsWith('.localhost')
38
+ || normalized === '::1'
39
+ || normalized.startsWith('fe80:')
40
+ || normalized.startsWith('fc')
41
+ || normalized.startsWith('fd')
42
+ || isPrivateIPv4(normalized);
43
+ }
44
+
45
+ function redactUrlForLog(urlString) {
46
+ try {
47
+ const parsed = new URL(urlString);
48
+ parsed.search = '';
49
+ parsed.hash = '';
50
+ return parsed.toString();
51
+ } catch (e) {
52
+ return '[invalid-url]';
53
+ }
54
+ }
55
+
56
+ function validateUrl(urlString, options = {}) {
57
+ if (!urlString || typeof urlString !== 'string') {
58
+ return { valid: false, error: 'InvalidUrl', message: 'URL must be a non-empty string' };
59
+ }
60
+
61
+ try {
62
+ const parsed = new URL(urlString);
63
+
64
+ if (parsed.protocol !== 'https:') {
65
+ return { valid: false, error: 'ProtocolError', message: 'Only HTTPS protocol is supported' };
66
+ }
67
+
68
+ const allowPrivateHosts = options.allowPrivateHosts || isEnabled(process.env.I18NTK_ALLOW_PRIVATE_TRANSLATE_URLS);
69
+ if (!allowPrivateHosts && isPrivateHost(parsed.hostname)) {
70
+ return {
71
+ valid: false,
72
+ error: 'PrivateHostNotAllowed',
73
+ message: `Host "${parsed.hostname}" is private or local. Set I18NTK_ALLOW_PRIVATE_TRANSLATE_URLS=1 only for trusted local testing.`,
74
+ };
75
+ }
76
+
77
+ const allowedHosts = options.allowedHosts || ALLOWED_HOSTS;
78
+ const allowedPaths = options.allowedPaths || ALLOWED_PATHS;
79
+
80
+ if (!allowedHosts.includes(parsed.hostname)) {
81
+ return { valid: false, error: 'HostNotAllowed', message: `Host "${parsed.hostname}" is not in the allowed list` };
82
+ }
83
+
84
+ if (!allowedPaths.includes(parsed.pathname)) {
85
+ return { valid: false, error: 'PathNotAllowed', message: `Path "${parsed.pathname}" is not in the allowed list` };
86
+ }
87
+
88
+ const suspiciousParams = ['redirect', 'callback', 'jsonp', 'script', 'src', 'eval', 'exec', 'url'];
89
+ for (const key of parsed.searchParams.keys()) {
90
+ const lower = key.toLowerCase();
91
+ if (suspiciousParams.some((s) => lower.includes(s))) {
92
+ return { valid: false, error: 'SuspiciousParam', message: `Query parameter "${key}" is not allowed` };
93
+ }
94
+ const value = parsed.searchParams.get(key);
95
+ if (value && (value.includes('<script') || value.includes('javascript:') || value.includes('data:'))) {
96
+ return { valid: false, error: 'SuspiciousParamValue', message: `Query parameter "${key}" contains potentially dangerous content` };
97
+ }
98
+ }
99
+
100
+ return { valid: true, url: parsed };
101
+ } catch (e) {
102
+ return { valid: false, error: 'UrlParseError', message: `Failed to parse URL: ${e.message}` };
103
+ }
104
+ }
105
+
106
+ function safeHttpGet(urlString, timeout = 15000) {
107
+ return new Promise((resolve) => {
108
+ const validation = validateUrl(urlString);
109
+ if (!validation.valid) {
110
+ logger.security('warn', 'Network request blocked by safe-network', {
111
+ url: redactUrlForLog(urlString),
112
+ reason: validation.error,
113
+ detail: validation.message,
114
+ });
115
+ resolve({ ok: false, error: validation.error, message: validation.message });
116
+ return;
117
+ }
118
+
119
+ const req = https.get(urlString, { headers: { 'User-Agent': USER_AGENT } }, (res) => {
120
+ let data = '';
121
+ let sizeExceeded = false;
122
+
123
+ res.on('data', (chunk) => {
124
+ if (data.length + chunk.length > MAX_RESPONSE_SIZE) {
125
+ if (!sizeExceeded) {
126
+ sizeExceeded = true;
127
+ req.destroy();
128
+ logger.security('warn', 'Network response size limit exceeded', {
129
+ url: redactUrlForLog(urlString),
130
+ limit: MAX_RESPONSE_SIZE,
131
+ });
132
+ resolve({ ok: false, error: 'ResponseTooLarge', message: `Response exceeds ${MAX_RESPONSE_SIZE} bytes limit` });
133
+ }
134
+ return;
135
+ }
136
+ data += chunk;
137
+ });
138
+
139
+ res.on('end', () => {
140
+ if (sizeExceeded) return;
141
+ try {
142
+ const parsed = JSON.parse(data);
143
+ logger.security('info', 'Network request completed', {
144
+ status: res.statusCode,
145
+ responseLength: data.length,
146
+ });
147
+ resolve({ ok: true, data: parsed, status: res.statusCode });
148
+ } catch (e) {
149
+ logger.security('warn', 'Network response parse error', {
150
+ status: res.statusCode,
151
+ responseLength: data.length,
152
+ });
153
+ resolve({ ok: false, error: 'ParseError', raw: data.substring(0, 500), status: res.statusCode });
154
+ }
155
+ });
156
+ });
157
+
158
+ req.setTimeout(timeout, () => {
159
+ req.destroy();
160
+ logger.security('warn', 'Network request timed out', { timeoutMs: timeout });
161
+ resolve({ ok: false, error: 'TimeoutError', message: 'Request timed out' });
162
+ });
163
+
164
+ req.on('error', (e) => {
165
+ logger.security('warn', 'Network request error', {
166
+ code: e.code,
167
+ message: e.message,
168
+ });
169
+ resolve({ ok: false, error: e.code || 'NetworkError', message: e.message });
170
+ });
171
+ });
172
+ }
173
+
174
+ function safeHttpPost(urlString, body, options = {}) {
175
+ const timeout = options.timeout || 15000;
176
+
177
+ return new Promise((resolve) => {
178
+ const validation = validateUrl(urlString, {
179
+ allowedHosts: options.allowedHosts,
180
+ allowedPaths: options.allowedPaths,
181
+ allowPrivateHosts: options.allowPrivateHosts,
182
+ });
183
+ if (!validation.valid) {
184
+ logger.security('warn', 'Network request blocked by safe-network', {
185
+ url: redactUrlForLog(urlString),
186
+ reason: validation.error,
187
+ detail: validation.message,
188
+ });
189
+ resolve({ ok: false, error: validation.error, message: validation.message });
190
+ return;
191
+ }
192
+
193
+ const payload = typeof body === 'string' ? body : JSON.stringify(body || {});
194
+ const headers = {
195
+ 'User-Agent': USER_AGENT,
196
+ 'Content-Type': options.contentType || 'application/json',
197
+ 'Content-Length': Buffer.byteLength(payload),
198
+ ...(options.headers || {}),
199
+ };
200
+
201
+ const req = https.request(validation.url, { method: 'POST', headers }, (res) => {
202
+ let data = '';
203
+ let sizeExceeded = false;
204
+
205
+ res.on('data', (chunk) => {
206
+ if (data.length + chunk.length > MAX_RESPONSE_SIZE) {
207
+ if (!sizeExceeded) {
208
+ sizeExceeded = true;
209
+ req.destroy();
210
+ logger.security('warn', 'Network response size limit exceeded', {
211
+ url: redactUrlForLog(urlString),
212
+ limit: MAX_RESPONSE_SIZE,
213
+ });
214
+ resolve({ ok: false, error: 'ResponseTooLarge', message: `Response exceeds ${MAX_RESPONSE_SIZE} bytes limit` });
215
+ }
216
+ return;
217
+ }
218
+ data += chunk;
219
+ });
220
+
221
+ res.on('end', () => {
222
+ if (sizeExceeded) return;
223
+ try {
224
+ const parsed = JSON.parse(data || '{}');
225
+ logger.security('info', 'Network request completed', {
226
+ status: res.statusCode,
227
+ responseLength: data.length,
228
+ });
229
+ resolve({ ok: res.statusCode >= 200 && res.statusCode < 300, data: parsed, status: res.statusCode });
230
+ } catch (e) {
231
+ logger.security('warn', 'Network response parse error', {
232
+ status: res.statusCode,
233
+ responseLength: data.length,
234
+ });
235
+ resolve({ ok: false, error: 'ParseError', raw: data.substring(0, 500), status: res.statusCode });
236
+ }
237
+ });
238
+ });
239
+
240
+ req.setTimeout(timeout, () => {
241
+ req.destroy();
242
+ logger.security('warn', 'Network request timed out', { timeoutMs: timeout });
243
+ resolve({ ok: false, error: 'TimeoutError', message: 'Request timed out' });
244
+ });
245
+
246
+ req.on('error', (e) => {
247
+ logger.security('warn', 'Network request error', {
248
+ code: e.code,
249
+ message: e.message,
250
+ });
251
+ resolve({ ok: false, error: e.code || 'NetworkError', message: e.message });
252
+ });
253
+
254
+ req.write(payload);
255
+ req.end();
256
+ });
257
+ }
258
+
259
+ function buildGoogleTranslateUrl(text, sourceLang, targetLang) {
260
+ const params = new URLSearchParams({
261
+ client: 'gtx',
262
+ sl: sourceLang,
263
+ tl: targetLang,
264
+ dt: 't',
265
+ q: text,
266
+ });
267
+ return `https://translate.googleapis.com/translate_a/single?${params.toString()}`;
268
+ }
269
+
270
+ module.exports = {
271
+ safeHttpGet,
272
+ safeHttpPost,
273
+ buildGoogleTranslateUrl,
274
+ validateUrl,
275
+ isPrivateHost,
276
+ redactUrlForLog,
277
+ MAX_RESPONSE_SIZE,
278
+ ALLOWED_HOSTS,
279
+ ALLOWED_PATHS,
280
+ };