i18ntk 3.1.2 → 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.
@@ -544,13 +544,13 @@ class AdminAuth {
544
544
  config.lastModified = new Date().toISOString();
545
545
  const success = await this.saveConfig(config);
546
546
  if (success) {
547
- SecurityUtils.logSecurityEvent('pin_protection_disabled', 'info', 'PIN protection disabled (PIN retained)');
547
+ SecurityUtils.logSecurityEvent('pin_protection_disabled', 'info', { message: 'PIN protection disabled (PIN retained)' });
548
548
  }
549
549
  return success;
550
550
  }
551
551
  return true;
552
552
  } catch (error) {
553
- SecurityUtils.logSecurityEvent('pin_protection_disable_error', 'error', `Failed to disable PIN protection: ${error.message}`);
553
+ SecurityUtils.logSecurityEvent('pin_protection_disable_error', 'error', { message: `Failed to disable PIN protection: ${error.message}` });
554
554
  return false;
555
555
  }
556
556
  }
@@ -566,13 +566,13 @@ class AdminAuth {
566
566
  config.lastModified = new Date().toISOString();
567
567
  const success = await this.saveConfig(config);
568
568
  if (success) {
569
- SecurityUtils.logSecurityEvent('pin_protection_enabled', 'info', 'PIN protection enabled');
569
+ SecurityUtils.logSecurityEvent('pin_protection_enabled', 'info', { message: 'PIN protection enabled' });
570
570
  }
571
571
  return success;
572
572
  }
573
573
  return false;
574
574
  } catch (error) {
575
- SecurityUtils.logSecurityEvent('pin_protection_enable_error', 'error', `Failed to enable PIN protection: ${error.message}`);
575
+ SecurityUtils.logSecurityEvent('pin_protection_enable_error', 'error', { message: `Failed to enable PIN protection: ${error.message}` });
576
576
  return false;
577
577
  }
578
578
  }
@@ -618,7 +618,7 @@ class AdminAuth {
618
618
  destroySession(sessionId) {
619
619
  const deleted = this.activeSessions.delete(sessionId);
620
620
  if (deleted) {
621
- SecurityUtils.logSecurityEvent('admin_session_destroyed', 'info', 'Admin session destroyed');
621
+ SecurityUtils.logSecurityEvent('admin_session_destroyed', 'info', { message: 'Admin session destroyed' });
622
622
  }
623
623
  return deleted;
624
624
  }
@@ -643,7 +643,7 @@ class AdminAuth {
643
643
  }
644
644
 
645
645
  if (cleaned > 0) {
646
- SecurityUtils.logSecurityEvent('admin_sessions_cleaned', 'info', `Cleaned up ${cleaned} expired sessions`);
646
+ SecurityUtils.logSecurityEvent('admin_sessions_cleaned', 'info', { message: `Cleaned up ${cleaned} expired sessions` });
647
647
  }
648
648
  }
649
649
 
@@ -720,9 +720,10 @@ async function setConfig(cfg) {
720
720
 
721
721
  async function updateConfig(patch) {
722
722
  const cfg = loadConfig();
723
- deepMerge(cfg, patch);
724
- // Don't save to disk - use in-memory config only
725
- return cfg;
723
+ const cloned = clone(cfg);
724
+ deepMerge(cloned, patch);
725
+ currentConfig = cloned;
726
+ return cloned;
726
727
  }
727
728
 
728
729
  async function resetToDefaults() {
@@ -764,6 +765,8 @@ module.exports = {
764
765
  DEFAULT_CONFIG,
765
766
  loadConfig,
766
767
  saveConfig,
768
+ loadSettings: loadConfig,
769
+ saveSettings: saveConfig,
767
770
  getConfig,
768
771
  updateConfig,
769
772
  setConfig,
@@ -1,37 +1,11 @@
1
- const https = require('https');
2
- const http = require('http');
3
1
  const { URL } = require('url');
2
+ const { safeHttpGet, safeHttpPost, buildGoogleTranslateUrl } = require('./safe-network');
4
3
 
5
4
  const DEFAULT_CONCURRENCY = 3;
6
5
  const DEFAULT_RETRY_COUNT = 3;
7
6
  const DEFAULT_RETRY_DELAY = 1000;
8
7
  const MAX_BACKOFF_DELAY = 30000;
9
8
 
10
- function httpGet(urlString, timeout = 15000) {
11
- return new Promise((resolve) => {
12
- const url = new URL(urlString);
13
- const client = url.protocol === 'https:' ? https : http;
14
- const req = client.get(urlString, { timeout }, (res) => {
15
- let data = '';
16
- res.on('data', (chunk) => { data += chunk; });
17
- res.on('end', () => {
18
- try {
19
- const parsed = JSON.parse(data);
20
- resolve({ ok: true, data: parsed, status: res.statusCode });
21
- } catch (e) {
22
- resolve({ ok: false, error: 'ParseError', raw: data.substring(0, 500), status: res.statusCode });
23
- }
24
- });
25
- });
26
- req.on('error', (e) => {
27
- resolve({ ok: false, error: e.code || 'NetworkError', message: e.message });
28
- });
29
- req.on('timeout', () => {
30
- req.destroy();
31
- resolve({ ok: false, error: 'TimeoutError', message: 'Request timed out' });
32
- });
33
- });
34
- }
35
9
 
36
10
  function extractTranslation(result) {
37
11
  if (result && Array.isArray(result) && result[0]) {
@@ -44,6 +18,154 @@ function extractTranslation(result) {
44
18
  return null;
45
19
  }
46
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
+
47
169
  function detectRateLimitError(result) {
48
170
  if (!result.ok && result.status === 429) return true;
49
171
  if (result.ok && result.status === 429) return true;
@@ -59,6 +181,8 @@ async function translateText(text, targetLang, options = {}) {
59
181
  retryDelay = DEFAULT_RETRY_DELAY,
60
182
  customFn,
61
183
  timeout = 15000,
184
+ httpGet = safeHttpGet,
185
+ httpPost = safeHttpPost,
62
186
  } = options;
63
187
 
64
188
  if (!text || text.trim().length === 0) return { ok: true, translated: text };
@@ -72,14 +196,10 @@ async function translateText(text, targetLang, options = {}) {
72
196
  }
73
197
  }
74
198
 
75
- const params = new URLSearchParams({
76
- client: 'gtx',
77
- sl: sourceLang,
78
- tl: targetLang,
79
- dt: 't',
80
- q: text,
81
- });
82
- 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
+ }
83
203
 
84
204
  let lastError = null;
85
205
  for (let attempt = 0; attempt < retryCount; attempt++) {
@@ -88,18 +208,20 @@ async function translateText(text, targetLang, options = {}) {
88
208
  await new Promise((resolve) => setTimeout(resolve, delay));
89
209
  }
90
210
 
91
- 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);
92
214
 
93
215
  if (result.ok) {
94
- const translated = extractTranslation(result.data);
216
+ const translated = request.extract(result.data);
95
217
  if (translated !== null && translated !== text) {
96
218
  return { ok: true, translated };
97
219
  }
98
220
  if (translated === text) {
99
221
  return { ok: true, translated: text };
100
222
  }
101
- if (result.status === 429) {
102
- lastError = { error: 'RateLimited', message: 'Google Translate rate limit hit' };
223
+ if (result.status === 429 || (translated === null && result.status >= 400)) {
224
+ lastError = { error: 'RateLimited', message: `${request.provider} rate limit hit` };
103
225
  continue;
104
226
  }
105
227
  }
@@ -109,6 +231,11 @@ async function translateText(text, targetLang, options = {}) {
109
231
  continue;
110
232
  }
111
233
 
234
+ if (result.error === 'TimeoutError' || result.error === 'NetworkError') {
235
+ lastError = { error: result.error, message: result.message || 'Network request failed, retrying' };
236
+ continue;
237
+ }
238
+
112
239
  lastError = { error: result.error || 'UnknownError', message: result.message || 'Request failed' };
113
240
  }
114
241
 
@@ -149,16 +276,20 @@ async function translateBatch(batch, targetLang, options = {}) {
149
276
  }
150
277
  }
151
278
 
152
- const workerCount = Math.min(concurrency, batch.length);
279
+ const workerCount = Math.min(concurrency > 0 ? concurrency : DEFAULT_CONCURRENCY, batch.length);
153
280
  const workers = Array.from({ length: workerCount }, () => worker());
281
+
154
282
  await Promise.all(workers);
155
283
 
156
284
  return results;
157
285
  }
158
286
 
159
287
  module.exports = {
160
- httpGet,
161
288
  extractTranslation,
289
+ extractDeepLTranslation,
290
+ extractLibreTranslateTranslation,
291
+ buildProviderRequest,
292
+ normalizeProvider,
162
293
  detectRateLimitError,
163
294
  translateText,
164
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
+ };