i18ntk 3.2.0 → 4.0.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.
@@ -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
+ };
@@ -1,36 +1,183 @@
1
- const fs = require('fs');
2
- const path = require('path');
3
- const SecurityUtils = require('./security');
4
-
5
- function watchDirectory(dir, callback, watchers) {
6
- if (!SecurityUtils.safeExistsSync(dir, path.dirname(dir))) return;
7
- const watcher = fs.watch(dir, (event, filename) => {
8
- if (filename && filename.endsWith('.json')) {
9
- callback(path.join(dir, filename));
10
- }
11
- });
12
- watchers.push(watcher);
13
-
14
- try {
15
- const items = SecurityUtils.safeReaddirSync(dir, path.dirname(dir), { withFileTypes: true });
16
- if (items) {
17
- items.forEach(entry => {
18
- if (entry.isDirectory()) {
19
- watchDirectory(path.join(dir, entry.name), callback, watchers);
20
- }
21
- });
22
- }
23
- } catch (_) {
24
- // Cannot read directory contents
25
- }
26
- }
27
-
28
- function watchLocales(dirs, onChange) {
29
- const directories = Array.isArray(dirs) ? dirs : [dirs];
30
- const watchers = [];
31
- directories.forEach(d => watchDirectory(path.resolve(d), onChange, watchers));
32
- console.log(`Watching for changes in: ${directories.join(', ')}`);
33
- return () => watchers.forEach(w => w.close());
34
- }
35
-
36
- module.exports = watchLocales;
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+ const EventEmitter = require('events');
4
+ const crypto = require('crypto');
5
+ const SecurityUtils = require('./security');
6
+
7
+ const DEFAULT_DEBOUNCE_MS = 300;
8
+ const DEFAULT_MAX_DIRECTORIES = 50;
9
+
10
+ function sha256File(filePath) {
11
+ try {
12
+ const content = SecurityUtils.safeReadFileSync(filePath, path.dirname(filePath));
13
+ if (content === null || content === undefined) return null;
14
+ return crypto.createHash('sha256').update(content).digest('hex');
15
+ } catch (_) {
16
+ return null;
17
+ }
18
+ }
19
+
20
+ function watchDirectory(dir, emitter, watchers, options = {}) {
21
+ const {
22
+ debounceMs = DEFAULT_DEBOUNCE_MS,
23
+ hashTracking = true,
24
+ watchState = { count: 0, maxDirectories: DEFAULT_MAX_DIRECTORIES }
25
+ } = options;
26
+
27
+ if (!SecurityUtils.safeExistsSync(dir, path.dirname(dir))) return;
28
+ if (watchState.count >= watchState.maxDirectories) {
29
+ emitter.emit('error', new Error(`Maximum watched directories (${watchState.maxDirectories}) exceeded`));
30
+ return;
31
+ }
32
+
33
+ const fileHashes = new Map();
34
+ const debounceTimers = new Map();
35
+
36
+ if (hashTracking) {
37
+ try {
38
+ const items = SecurityUtils.safeReaddirSync(dir, path.dirname(dir), { withFileTypes: true });
39
+ if (items) {
40
+ for (const entry of items) {
41
+ if (entry.isFile() && entry.name.endsWith('.json')) {
42
+ const fullPath = path.join(dir, entry.name);
43
+ const h = sha256File(fullPath);
44
+ if (h) fileHashes.set(fullPath, h);
45
+ }
46
+ }
47
+ }
48
+ } catch (_) { /* initial read may fail */ }
49
+ }
50
+
51
+ let watcher;
52
+ try {
53
+ watcher = fs.watch(dir, (event, filename) => {
54
+ if (!filename || !filename.endsWith('.json')) return;
55
+
56
+ const fullPath = path.join(dir, filename);
57
+ const validated = SecurityUtils.validatePath(fullPath, path.dirname(dir));
58
+ if (!validated) return;
59
+
60
+ if (debounceTimers.has(fullPath)) {
61
+ clearTimeout(debounceTimers.get(fullPath));
62
+ }
63
+
64
+ debounceTimers.set(fullPath, setTimeout(() => {
65
+ debounceTimers.delete(fullPath);
66
+
67
+ try {
68
+ if (event === 'rename') {
69
+ if (SecurityUtils.safeExistsSync(fullPath, path.dirname(fullPath))) {
70
+ if (hashTracking) {
71
+ const h = sha256File(fullPath);
72
+ if (h) fileHashes.set(fullPath, h);
73
+ }
74
+ emitter.emit('add', fullPath);
75
+ } else {
76
+ fileHashes.delete(fullPath);
77
+ emitter.emit('unlink', fullPath);
78
+ }
79
+ } else if (event === 'change') {
80
+ if (hashTracking) {
81
+ const newHash = sha256File(fullPath);
82
+ const oldHash = fileHashes.get(fullPath);
83
+ if (newHash && newHash === oldHash) return;
84
+ if (newHash) fileHashes.set(fullPath, newHash);
85
+ }
86
+ emitter.emit('change', fullPath);
87
+ }
88
+ } catch (err) {
89
+ emitter.emit('error', err);
90
+ }
91
+ }, debounceMs));
92
+ });
93
+
94
+ watcher.on('error', (err) => {
95
+ emitter.emit('error', err);
96
+ });
97
+ } catch (err) {
98
+ emitter.emit('error', err);
99
+ return;
100
+ }
101
+
102
+ watchers.push({ watcher, path: dir });
103
+ watchState.count++;
104
+
105
+ try {
106
+ const items = SecurityUtils.safeReaddirSync(dir, path.dirname(dir), { withFileTypes: true });
107
+ if (items) {
108
+ for (const entry of items) {
109
+ if (entry.isDirectory()) {
110
+ watchDirectory(path.join(dir, entry.name), emitter, watchers, options);
111
+ }
112
+ }
113
+ }
114
+ } catch (_) {
115
+ // Cannot read directory contents
116
+ }
117
+ }
118
+
119
+ function watchLocales(dirs, onChange, options = {}) {
120
+ const directories = Array.isArray(dirs) ? dirs : [dirs];
121
+ const emitter = new EventEmitter();
122
+ emitter.on('error', () => {});
123
+ const watchers = [];
124
+
125
+ // Backward-compatible onChange callback
126
+ if (typeof onChange === 'function') {
127
+ emitter.on('change', onChange);
128
+ emitter.on('add', onChange);
129
+ }
130
+
131
+ const {
132
+ debounceMs = DEFAULT_DEBOUNCE_MS,
133
+ hashTracking = true,
134
+ maxDirectories = DEFAULT_MAX_DIRECTORIES
135
+ } = (typeof onChange === 'object' && onChange !== null) ? onChange : options;
136
+
137
+ const watchState = { count: 0, maxDirectories };
138
+ for (const d of directories) {
139
+ if (watchState.count >= watchState.maxDirectories) {
140
+ emitter.emit('error', new Error(`Maximum watched directories (${watchState.maxDirectories}) exceeded`));
141
+ break;
142
+ }
143
+
144
+ const resolved = path.resolve(d);
145
+ const validated = SecurityUtils.validatePath(resolved, process.cwd());
146
+ if (!validated) {
147
+ emitter.emit('error', new Error(`Path validation failed for: ${d}`));
148
+ continue;
149
+ }
150
+
151
+ const projectRoot = path.resolve(process.cwd());
152
+ const rel = path.relative(projectRoot, validated);
153
+ if (rel.startsWith('..')) {
154
+ emitter.emit('error', new Error(`Directory outside project root: ${d}`));
155
+ continue;
156
+ }
157
+
158
+ watchDirectory(validated, emitter, watchers, { debounceMs, hashTracking, watchState });
159
+ }
160
+
161
+ const stop = () => {
162
+ for (const entry of watchers) {
163
+ try { entry.watcher.close(); } catch (_) { /* ignore */ }
164
+ }
165
+ watchers.length = 0;
166
+ emitter.removeAllListeners();
167
+ };
168
+
169
+ stop.stop = stop;
170
+ stop.emitter = emitter;
171
+ stop.on = emitter.on.bind(emitter);
172
+ stop.once = emitter.once.bind(emitter);
173
+ stop.off = emitter.off ? emitter.off.bind(emitter) : emitter.removeListener.bind(emitter);
174
+ stop.emit = emitter.emit.bind(emitter);
175
+ stop.removeListener = emitter.removeListener.bind(emitter);
176
+ stop.removeAllListeners = emitter.removeAllListeners.bind(emitter);
177
+ stop.getWatchedPaths = () => watchers.map(w => w.path);
178
+ stop.getDebounceMs = () => debounceMs;
179
+
180
+ return stop;
181
+ }
182
+
183
+ module.exports = watchLocales;