i18ntk 2.3.5 → 2.3.6
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.
- package/README.md +6 -10
- package/package.json +4 -7
- package/settings/settings-manager.js +10 -9
- package/utils/config-manager.js +199 -194
- package/utils/security.js +69 -34
package/README.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# i18ntk v2.3.
|
|
1
|
+
# i18ntk v2.3.6
|
|
2
2
|
|
|
3
3
|
Zero-dependency internationalization toolkit for setup, scanning, analysis, validation, usage tracking, and translation completion.
|
|
4
4
|
|
|
@@ -9,16 +9,12 @@ Zero-dependency internationalization toolkit for setup, scanning, analysis, vali
|
|
|
9
9
|
[](https://nodejs.org)
|
|
10
10
|
[](https://www.npmjs.com/package/i18ntk)
|
|
11
11
|
[](LICENSE)
|
|
12
|
-
[](https://socket.dev/npm/package/i18ntk/overview/2.3.6)
|
|
13
13
|
|
|
14
14
|
## Upgrade Notice
|
|
15
15
|
|
|
16
|
-
Versions earlier than `2.3.
|
|
17
|
-
They are considered unsupported for production use. Upgrade to `2.3.
|
|
18
|
-
The CLI can check npm registry metadata and warn when your installed version is out of date.
|
|
19
|
-
Set `I18NTK_ENABLE_UPDATE_CHECK=true` to enable this behavior.
|
|
20
|
-
Set `I18NTK_DISABLE_UPDATE_CHECK=true` to force-disable it in restricted/offline environments.
|
|
21
|
-
Set `I18NTK_DISABLE_AUTOSAVE=1` in server/runtime environments to keep config in memory and skip disk writes.
|
|
16
|
+
Versions earlier than `2.3.6` may contain known stability and security issues.
|
|
17
|
+
They are considered unsupported for production use. Upgrade to `2.3.6` or newer.
|
|
22
18
|
|
|
23
19
|
## What i18ntk Does
|
|
24
20
|
|
|
@@ -155,7 +151,7 @@ Example `.i18ntk-config`:
|
|
|
155
151
|
|
|
156
152
|
```json
|
|
157
153
|
{
|
|
158
|
-
"version": "2.3.
|
|
154
|
+
"version": "2.3.6",
|
|
159
155
|
"sourceDir": "./locales",
|
|
160
156
|
"i18nDir": "./locales",
|
|
161
157
|
"outputDir": "./i18ntk-reports",
|
|
@@ -178,7 +174,7 @@ See [docs/api/CONFIGURATION.md](docs/api/CONFIGURATION.md) for the full configur
|
|
|
178
174
|
- [Runtime API Guide](docs/runtime.md)
|
|
179
175
|
- [Scanner Guide](docs/scanner-guide.md)
|
|
180
176
|
- [Environment Variables](docs/environment-variables.md)
|
|
181
|
-
- [Migration Guide v2.3.5](docs/migration-guide-v2.3.
|
|
177
|
+
- [Migration Guide v2.3.5](docs/migration-guide-v2.3.6.md)
|
|
182
178
|
- [Optimization Prompt](docs/development/package-optimization-prompt.md)
|
|
183
179
|
|
|
184
180
|
## License
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "i18ntk",
|
|
3
|
-
"version": "2.3.
|
|
3
|
+
"version": "2.3.6",
|
|
4
4
|
"description": "🚀 The fastest internationalization toolkit with 97% performance boost! Zero-dependency, enterprise-grade internationalization for React, Vue, Angular, Python, Java, PHP & more. Features PIN protection, auto framework detection, 7+ UI languages, and comprehensive translation management. Perfect for startups to enterprises.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"i18n",
|
|
@@ -112,7 +112,7 @@
|
|
|
112
112
|
},
|
|
113
113
|
"license": "MIT",
|
|
114
114
|
"author": {
|
|
115
|
-
"name": "
|
|
115
|
+
"name": "Vlad Noskov",
|
|
116
116
|
"url": "https://github.com/vladnoskv"
|
|
117
117
|
},
|
|
118
118
|
"type": "commonjs",
|
|
@@ -213,10 +213,7 @@
|
|
|
213
213
|
"languages:select": "node settings/settings-cli.js",
|
|
214
214
|
"languages:list": "node settings/settings-cli.js --list-languages",
|
|
215
215
|
"languages:status": "node settings/settings-cli.js --language-status",
|
|
216
|
-
"lint:locales": "node scripts/lint-locales.js"
|
|
217
|
-
"deprecate:versions": "node scripts/deprecate-versions.js",
|
|
218
|
-
"deprecate:dry-run": "node scripts/deprecate-versions.js --dry-run",
|
|
219
|
-
"deprecate:verify": "node scripts/verify-deprecations.js"
|
|
216
|
+
"lint:locales": "node scripts/lint-locales.js"
|
|
220
217
|
},
|
|
221
218
|
"engines": {
|
|
222
219
|
"node": ">=16.0.0",
|
|
@@ -268,7 +265,7 @@
|
|
|
268
265
|
"spring-boot": ">=2.5.0",
|
|
269
266
|
"laravel": ">=8.0.0"
|
|
270
267
|
},
|
|
271
|
-
"supportPolicy": "Versions earlier than 2.3.
|
|
268
|
+
"supportPolicy": "Versions earlier than 2.3.6 may be unstable or insecure. Upgrade to 2.3.6 or newer."
|
|
272
269
|
},
|
|
273
270
|
"_comment": "This package is zero-dependency and uses only native Node.js modules"
|
|
274
271
|
}
|
|
@@ -6,14 +6,15 @@ const SecurityUtils = require('../utils/security');
|
|
|
6
6
|
|
|
7
7
|
class SettingsManager {
|
|
8
8
|
constructor() {
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
9
|
+
// Use centralized .i18ntk-settings file as single source of truth
|
|
10
|
+
this.configDir = path.resolve(__dirname, '..');
|
|
11
|
+
const projectRoot = path.resolve(process.cwd());
|
|
12
|
+
this.configFile = SecurityUtils.safeJoin(projectRoot, '.i18ntk-settings') || path.join(projectRoot, '.i18ntk-settings');
|
|
13
|
+
this.backupDir = SecurityUtils.safeJoin(projectRoot, 'i18ntk-backups') || path.join(projectRoot, 'i18ntk-backups');
|
|
13
14
|
this.saveTimeout = null;
|
|
14
15
|
|
|
15
16
|
this.defaultConfig = {
|
|
16
|
-
"version": "
|
|
17
|
+
"version": "2.3.6",
|
|
17
18
|
"language": "en",
|
|
18
19
|
"uiLanguage": "en",
|
|
19
20
|
"theme": "dark",
|
|
@@ -573,8 +574,8 @@ class SettingsManager {
|
|
|
573
574
|
|
|
574
575
|
// 2. Remove actual configuration files used by the system
|
|
575
576
|
const packageDir = path.resolve(__dirname, '..');
|
|
576
|
-
const projectRoot = process.cwd();
|
|
577
|
-
const settingsDir =
|
|
577
|
+
const projectRoot = path.resolve(process.cwd());
|
|
578
|
+
const settingsDir = SecurityUtils.safeJoin(packageDir, 'settings');
|
|
578
579
|
|
|
579
580
|
// Main configuration file
|
|
580
581
|
const mainConfigPath = path.join(settingsDir, 'i18ntk-config.json');
|
|
@@ -611,7 +612,7 @@ class SettingsManager {
|
|
|
611
612
|
path.join(packageDir, '.i18n-admin-config.json'),
|
|
612
613
|
path.join(settingsDir, '.i18n-admin-config.json'),
|
|
613
614
|
path.join(settingsDir, 'admin-config.json'),
|
|
614
|
-
path.join(projectRoot, '.i18n-admin-config.json')
|
|
615
|
+
SecurityUtils.safeJoin(projectRoot, '.i18n-admin-config.json') || path.join(projectRoot, '.i18n-admin-config.json')
|
|
615
616
|
];
|
|
616
617
|
|
|
617
618
|
for (const adminConfigPath of adminConfigPaths) {
|
|
@@ -1002,4 +1003,4 @@ class SettingsManager {
|
|
|
1002
1003
|
}
|
|
1003
1004
|
|
|
1004
1005
|
module.exports = SettingsManager;
|
|
1005
|
-
|
|
1006
|
+
|
package/utils/config-manager.js
CHANGED
|
@@ -1,22 +1,25 @@
|
|
|
1
|
-
const fs = require('fs');
|
|
2
|
-
const path = require('path');
|
|
3
|
-
const os = require('os');
|
|
4
|
-
const crypto = require('crypto');
|
|
5
|
-
const SecurityUtils = require('./security');
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const os = require('os');
|
|
4
|
+
const crypto = require('crypto');
|
|
5
|
+
const SecurityUtils = require('./security');
|
|
6
6
|
|
|
7
7
|
// Determine package directory and user project root
|
|
8
8
|
const packageDir = path.resolve(__dirname, '..');
|
|
9
|
-
const userProjectRoot = process.cwd();
|
|
9
|
+
const userProjectRoot = path.resolve(process.cwd());
|
|
10
10
|
|
|
11
11
|
// Always use current working directory for settings to support test environments
|
|
12
12
|
// This ensures config works correctly when tests change the working directory
|
|
13
|
-
const PROJECT_CONFIG_PATH =
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
const
|
|
18
|
-
const
|
|
19
|
-
|
|
13
|
+
const PROJECT_CONFIG_PATH = SecurityUtils.safeJoin(userProjectRoot, '.i18ntk-config');
|
|
14
|
+
if (!PROJECT_CONFIG_PATH) {
|
|
15
|
+
throw new Error('Invalid project config path - potential path traversal attempt');
|
|
16
|
+
}
|
|
17
|
+
const PROJECT_SETTINGS_DIR = path.dirname(PROJECT_CONFIG_PATH);
|
|
18
|
+
const CONFIG_LOCK_PATH = `${PROJECT_CONFIG_PATH}.lock`;
|
|
19
|
+
const CONFIG_LOCK_TIMEOUT_MS = 5000;
|
|
20
|
+
const CONFIG_LOCK_STALE_MS = 15000;
|
|
21
|
+
const CONFIG_LOCK_RETRY_MS = 50;
|
|
22
|
+
let autosaveDisabledWarned = false;
|
|
20
23
|
|
|
21
24
|
// Setup tracking file
|
|
22
25
|
const SETUP_COMPLETED_FILE = path.join(PROJECT_SETTINGS_DIR, 'setup.json');
|
|
@@ -175,14 +178,14 @@ const DEFAULT_CONFIG = {
|
|
|
175
178
|
"memoryPooling": true,
|
|
176
179
|
"stringInterning": true
|
|
177
180
|
},
|
|
178
|
-
"backup": {
|
|
179
|
-
"enabled": false,
|
|
180
|
-
"location": "./i18ntk-backups",
|
|
181
|
-
"singleFileMode": false,
|
|
182
|
-
"singleBackupFile": "i18ntk-central-backup.json",
|
|
183
|
-
"retentionDays": 30,
|
|
184
|
-
"maxBackups": 1
|
|
185
|
-
},
|
|
181
|
+
"backup": {
|
|
182
|
+
"enabled": false,
|
|
183
|
+
"location": "./i18ntk-backups",
|
|
184
|
+
"singleFileMode": false,
|
|
185
|
+
"singleBackupFile": "i18ntk-central-backup.json",
|
|
186
|
+
"retentionDays": 30,
|
|
187
|
+
"maxBackups": 1
|
|
188
|
+
},
|
|
186
189
|
"security": {
|
|
187
190
|
"adminPinEnabled": false,
|
|
188
191
|
"adminPinPromptOnInit": true,
|
|
@@ -250,69 +253,69 @@ const DEFAULT_CONFIG = {
|
|
|
250
253
|
|
|
251
254
|
// Environment variable support has been removed in favor of exclusive .i18ntk-config configuration
|
|
252
255
|
|
|
253
|
-
let currentConfig = null;
|
|
254
|
-
let configLoadInProgress = false;
|
|
255
|
-
let recursionDepth = 0;
|
|
256
|
-
const MAX_RECURSION_DEPTH = 15; // Increased to handle legitimate sequential calls
|
|
257
|
-
let configSaveQueue = Promise.resolve();
|
|
258
|
-
|
|
259
|
-
function sleep(ms) {
|
|
260
|
-
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
261
|
-
}
|
|
262
|
-
|
|
263
|
-
async function acquireConfigLock(timeoutMs = CONFIG_LOCK_TIMEOUT_MS) {
|
|
264
|
-
const start = Date.now();
|
|
265
|
-
let lockHandle = null;
|
|
266
|
-
|
|
267
|
-
while (!lockHandle) {
|
|
268
|
-
try {
|
|
269
|
-
lockHandle = await fs.promises.open(CONFIG_LOCK_PATH, 'wx');
|
|
270
|
-
await lockHandle.writeFile(String(process.pid), 'utf8');
|
|
271
|
-
break;
|
|
272
|
-
} catch (error) {
|
|
273
|
-
if (!error || error.code !== 'EEXIST') {
|
|
274
|
-
throw error;
|
|
275
|
-
}
|
|
276
|
-
|
|
277
|
-
// Recover from stale lock files left by crashed processes.
|
|
278
|
-
try {
|
|
279
|
-
const stats = await fs.promises.stat(CONFIG_LOCK_PATH);
|
|
280
|
-
if (Date.now() - stats.mtimeMs > CONFIG_LOCK_STALE_MS) {
|
|
281
|
-
await fs.promises.unlink(CONFIG_LOCK_PATH);
|
|
282
|
-
continue;
|
|
283
|
-
}
|
|
284
|
-
} catch (_) {
|
|
285
|
-
// Lock may have been released between exists check and stat/unlink.
|
|
286
|
-
}
|
|
287
|
-
|
|
288
|
-
if (Date.now() - start >= timeoutMs) {
|
|
289
|
-
throw new Error(`Timed out waiting for config lock after ${timeoutMs}ms`);
|
|
290
|
-
}
|
|
291
|
-
|
|
292
|
-
await sleep(CONFIG_LOCK_RETRY_MS);
|
|
293
|
-
}
|
|
294
|
-
}
|
|
295
|
-
|
|
296
|
-
return async function releaseConfigLock() {
|
|
297
|
-
try {
|
|
298
|
-
if (lockHandle) {
|
|
299
|
-
await lockHandle.close();
|
|
300
|
-
}
|
|
301
|
-
} catch (_) {
|
|
302
|
-
// Best-effort close only.
|
|
303
|
-
}
|
|
304
|
-
try {
|
|
305
|
-
await fs.promises.unlink(CONFIG_LOCK_PATH);
|
|
306
|
-
} catch (_) {
|
|
307
|
-
// Best-effort cleanup only.
|
|
308
|
-
}
|
|
309
|
-
};
|
|
310
|
-
}
|
|
311
|
-
|
|
312
|
-
function isAutosaveDisabled() {
|
|
313
|
-
const flag = String(process.env.I18NTK_DISABLE_AUTOSAVE || '').trim().toLowerCase();
|
|
314
|
-
return flag === '1' || flag === 'true' || flag === 'yes';
|
|
315
|
-
}
|
|
256
|
+
let currentConfig = null;
|
|
257
|
+
let configLoadInProgress = false;
|
|
258
|
+
let recursionDepth = 0;
|
|
259
|
+
const MAX_RECURSION_DEPTH = 15; // Increased to handle legitimate sequential calls
|
|
260
|
+
let configSaveQueue = Promise.resolve();
|
|
261
|
+
|
|
262
|
+
function sleep(ms) {
|
|
263
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
async function acquireConfigLock(timeoutMs = CONFIG_LOCK_TIMEOUT_MS) {
|
|
267
|
+
const start = Date.now();
|
|
268
|
+
let lockHandle = null;
|
|
269
|
+
|
|
270
|
+
while (!lockHandle) {
|
|
271
|
+
try {
|
|
272
|
+
lockHandle = await fs.promises.open(CONFIG_LOCK_PATH, 'wx');
|
|
273
|
+
await lockHandle.writeFile(String(process.pid), 'utf8');
|
|
274
|
+
break;
|
|
275
|
+
} catch (error) {
|
|
276
|
+
if (!error || error.code !== 'EEXIST') {
|
|
277
|
+
throw error;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// Recover from stale lock files left by crashed processes.
|
|
281
|
+
try {
|
|
282
|
+
const stats = await fs.promises.stat(CONFIG_LOCK_PATH);
|
|
283
|
+
if (Date.now() - stats.mtimeMs > CONFIG_LOCK_STALE_MS) {
|
|
284
|
+
await fs.promises.unlink(CONFIG_LOCK_PATH);
|
|
285
|
+
continue;
|
|
286
|
+
}
|
|
287
|
+
} catch (_) {
|
|
288
|
+
// Lock may have been released between exists check and stat/unlink.
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
if (Date.now() - start >= timeoutMs) {
|
|
292
|
+
throw new Error(`Timed out waiting for config lock after ${timeoutMs}ms`);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
await sleep(CONFIG_LOCK_RETRY_MS);
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
return async function releaseConfigLock() {
|
|
300
|
+
try {
|
|
301
|
+
if (lockHandle) {
|
|
302
|
+
await lockHandle.close();
|
|
303
|
+
}
|
|
304
|
+
} catch (_) {
|
|
305
|
+
// Best-effort close only.
|
|
306
|
+
}
|
|
307
|
+
try {
|
|
308
|
+
await fs.promises.unlink(CONFIG_LOCK_PATH);
|
|
309
|
+
} catch (_) {
|
|
310
|
+
// Best-effort cleanup only.
|
|
311
|
+
}
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function isAutosaveDisabled() {
|
|
316
|
+
const flag = String(process.env.I18NTK_DISABLE_AUTOSAVE || '').trim().toLowerCase();
|
|
317
|
+
return flag === '1' || flag === 'true' || flag === 'yes';
|
|
318
|
+
}
|
|
316
319
|
|
|
317
320
|
function clone(obj) {
|
|
318
321
|
return JSON.parse(JSON.stringify(obj));
|
|
@@ -382,21 +385,21 @@ function tryReadJson(filePath) {
|
|
|
382
385
|
return null;
|
|
383
386
|
}
|
|
384
387
|
|
|
385
|
-
const parsed = SecurityUtils.safeParseJSON(data);
|
|
386
|
-
if (parsed && typeof parsed === 'object') {
|
|
387
|
-
return parsed;
|
|
388
|
-
}
|
|
389
|
-
|
|
390
|
-
console.error(`[i18ntk] Error parsing JSON from ${filePath}: Invalid JSON content`);
|
|
391
|
-
// Create a backup of the corrupted file
|
|
392
|
-
const backupPath = `${filePath}.corrupted-${Date.now()}.bak`;
|
|
393
|
-
try {
|
|
394
|
-
SecurityUtils.safeWriteFileSync(backupPath, data, path.dirname(backupPath), 'utf8');
|
|
395
|
-
console.warn(`[i18ntk] Created backup of corrupted config at ${backupPath}`);
|
|
396
|
-
} catch (backupError) {
|
|
397
|
-
console.error(`[i18ntk] Failed to create backup of corrupted config: ${backupError.message}`);
|
|
398
|
-
}
|
|
399
|
-
return null;
|
|
388
|
+
const parsed = SecurityUtils.safeParseJSON(data);
|
|
389
|
+
if (parsed && typeof parsed === 'object') {
|
|
390
|
+
return parsed;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
console.error(`[i18ntk] Error parsing JSON from ${filePath}: Invalid JSON content`);
|
|
394
|
+
// Create a backup of the corrupted file
|
|
395
|
+
const backupPath = `${filePath}.corrupted-${Date.now()}.bak`;
|
|
396
|
+
try {
|
|
397
|
+
SecurityUtils.safeWriteFileSync(backupPath, data, path.dirname(backupPath), 'utf8');
|
|
398
|
+
console.warn(`[i18ntk] Created backup of corrupted config at ${backupPath}`);
|
|
399
|
+
} catch (backupError) {
|
|
400
|
+
console.error(`[i18ntk] Failed to create backup of corrupted config: ${backupError.message}`);
|
|
401
|
+
}
|
|
402
|
+
return null;
|
|
400
403
|
} catch (error) {
|
|
401
404
|
console.error(`[i18ntk] Error reading config file at ${filePath}: ${error.message}`);
|
|
402
405
|
return null;
|
|
@@ -417,13 +420,13 @@ async function migrateLegacyIfNeeded(baseCfg) {
|
|
|
417
420
|
// Best-effort removal of legacy file to prevent future use
|
|
418
421
|
try { fs.unlinkSync(LEGACY_CONFIG_PATH); } catch (_) {}
|
|
419
422
|
// Deprecation notice
|
|
420
|
-
console.warn('[i18ntk] Deprecated config location detected (~/.i18ntk). Configuration was migrated to project .i18ntk-config.');
|
|
421
|
-
return merged;
|
|
422
|
-
} catch (_) {
|
|
423
|
-
// If write fails, fall back to in-memory config without deleting legacy
|
|
424
|
-
console.warn('[i18ntk] Deprecated config location detected (~/.i18ntk). Using migrated settings in memory; failed to persist to .i18ntk-config.');
|
|
425
|
-
return merged;
|
|
426
|
-
}
|
|
423
|
+
console.warn('[i18ntk] Deprecated config location detected (~/.i18ntk). Configuration was migrated to project .i18ntk-config.');
|
|
424
|
+
return merged;
|
|
425
|
+
} catch (_) {
|
|
426
|
+
// If write fails, fall back to in-memory config without deleting legacy
|
|
427
|
+
console.warn('[i18ntk] Deprecated config location detected (~/.i18ntk). Using migrated settings in memory; failed to persist to .i18ntk-config.');
|
|
428
|
+
return merged;
|
|
429
|
+
}
|
|
427
430
|
}
|
|
428
431
|
}
|
|
429
432
|
return null;
|
|
@@ -487,73 +490,75 @@ function loadConfig() {
|
|
|
487
490
|
}
|
|
488
491
|
}
|
|
489
492
|
|
|
490
|
-
async function saveConfig(cfg = currentConfig) {
|
|
491
|
-
if (!cfg || typeof cfg !== 'object') return;
|
|
492
|
-
|
|
493
|
-
// Runtime/server safety valve: allow disabling disk writes entirely.
|
|
494
|
-
if (isAutosaveDisabled()) {
|
|
495
|
-
currentConfig = cfg;
|
|
496
|
-
if (!autosaveDisabledWarned) {
|
|
497
|
-
autosaveDisabledWarned = true;
|
|
498
|
-
console.warn('[i18ntk] Autosave disabled by I18NTK_DISABLE_AUTOSAVE. Keeping configuration in memory only.');
|
|
499
|
-
}
|
|
500
|
-
return false;
|
|
501
|
-
}
|
|
502
|
-
|
|
503
|
-
configSaveQueue = configSaveQueue.then(async () => {
|
|
504
|
-
let tempPath = null;
|
|
505
|
-
let releaseLock = null;
|
|
506
|
-
try {
|
|
507
|
-
// Ensure settings directory exists before any lock/file operations.
|
|
508
|
-
await fs.promises.mkdir(PROJECT_SETTINGS_DIR, { recursive: true });
|
|
509
|
-
|
|
510
|
-
releaseLock = await acquireConfigLock();
|
|
511
|
-
|
|
512
|
-
const serialized = JSON.stringify(cfg, null, 2);
|
|
513
|
-
if (typeof serialized !== 'string' || serialized.length === 0) {
|
|
514
|
-
throw new Error('Cannot save empty configuration payload');
|
|
515
|
-
}
|
|
516
|
-
|
|
517
|
-
// Use a unique temp file to avoid concurrent writer races.
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
}
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
}
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
493
|
+
async function saveConfig(cfg = currentConfig) {
|
|
494
|
+
if (!cfg || typeof cfg !== 'object') return;
|
|
495
|
+
|
|
496
|
+
// Runtime/server safety valve: allow disabling disk writes entirely.
|
|
497
|
+
if (isAutosaveDisabled()) {
|
|
498
|
+
currentConfig = cfg;
|
|
499
|
+
if (!autosaveDisabledWarned) {
|
|
500
|
+
autosaveDisabledWarned = true;
|
|
501
|
+
console.warn('[i18ntk] Autosave disabled by I18NTK_DISABLE_AUTOSAVE. Keeping configuration in memory only.');
|
|
502
|
+
}
|
|
503
|
+
return false;
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
configSaveQueue = configSaveQueue.then(async () => {
|
|
507
|
+
let tempPath = null;
|
|
508
|
+
let releaseLock = null;
|
|
509
|
+
try {
|
|
510
|
+
// Ensure settings directory exists before any lock/file operations.
|
|
511
|
+
await fs.promises.mkdir(PROJECT_SETTINGS_DIR, { recursive: true });
|
|
512
|
+
|
|
513
|
+
releaseLock = await acquireConfigLock();
|
|
514
|
+
|
|
515
|
+
const serialized = JSON.stringify(cfg, null, 2);
|
|
516
|
+
if (typeof serialized !== 'string' || serialized.length === 0) {
|
|
517
|
+
throw new Error('Cannot save empty configuration payload');
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
// Use a unique temp file to avoid concurrent writer races.
|
|
521
|
+
// Create temp files in the same directory as the config file to ensure they're safe
|
|
522
|
+
const nonce = `${process.pid}.${Date.now()}.${crypto.randomUUID()}`;
|
|
523
|
+
const tempFileName = `.i18ntk-config.${nonce}.tmp`;
|
|
524
|
+
tempPath = path.join(PROJECT_SETTINGS_DIR, tempFileName);
|
|
525
|
+
await fs.promises.writeFile(tempPath, serialized, 'utf8');
|
|
526
|
+
|
|
527
|
+
try {
|
|
528
|
+
await fs.promises.rename(tempPath, PROJECT_CONFIG_PATH);
|
|
529
|
+
} catch (renameError) {
|
|
530
|
+
// If destination dir disappeared between checks, recreate and retry once.
|
|
531
|
+
if (renameError && renameError.code === 'ENOENT') {
|
|
532
|
+
await fs.promises.mkdir(PROJECT_SETTINGS_DIR, { recursive: true });
|
|
533
|
+
await fs.promises.rename(tempPath, PROJECT_CONFIG_PATH);
|
|
534
|
+
} else {
|
|
535
|
+
throw renameError;
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
currentConfig = cfg;
|
|
540
|
+
return true;
|
|
541
|
+
} catch (error) {
|
|
542
|
+
console.error('[i18ntk] Error saving configuration:', error.message);
|
|
543
|
+
return false;
|
|
544
|
+
} finally {
|
|
545
|
+
if (releaseLock) {
|
|
546
|
+
await releaseLock();
|
|
547
|
+
}
|
|
548
|
+
if (tempPath) {
|
|
549
|
+
try {
|
|
550
|
+
if (SecurityUtils.safeExistsSync(tempPath, path.dirname(tempPath))) {
|
|
551
|
+
fs.unlinkSync(tempPath);
|
|
552
|
+
}
|
|
553
|
+
} catch (_) {
|
|
554
|
+
// Best-effort temp cleanup only.
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
});
|
|
559
|
+
|
|
560
|
+
return configSaveQueue;
|
|
561
|
+
}
|
|
557
562
|
|
|
558
563
|
function getConfig() {
|
|
559
564
|
// Check for recursion
|
|
@@ -575,28 +580,28 @@ function getConfig() {
|
|
|
575
580
|
// No need to check here - handled by getUnifiedConfig
|
|
576
581
|
|
|
577
582
|
// Check if config file exists
|
|
578
|
-
if (SecurityUtils.safeExistsSync(PROJECT_CONFIG_PATH)) {
|
|
579
|
-
const rawConfig = SecurityUtils.safeReadFileSync(PROJECT_CONFIG_PATH, path.dirname(PROJECT_CONFIG_PATH), 'utf8');
|
|
580
|
-
const config = SecurityUtils.safeParseJSON(rawConfig);
|
|
581
|
-
if (config && typeof config === 'object') {
|
|
582
|
-
currentConfig = config;
|
|
583
|
-
return resolvePaths(config);
|
|
584
|
-
}
|
|
585
|
-
throw new Error('Invalid project configuration JSON');
|
|
586
|
-
}
|
|
583
|
+
if (SecurityUtils.safeExistsSync(PROJECT_CONFIG_PATH)) {
|
|
584
|
+
const rawConfig = SecurityUtils.safeReadFileSync(PROJECT_CONFIG_PATH, path.dirname(PROJECT_CONFIG_PATH), 'utf8');
|
|
585
|
+
const config = SecurityUtils.safeParseJSON(rawConfig);
|
|
586
|
+
if (config && typeof config === 'object') {
|
|
587
|
+
currentConfig = config;
|
|
588
|
+
return resolvePaths(config);
|
|
589
|
+
}
|
|
590
|
+
throw new Error('Invalid project configuration JSON');
|
|
591
|
+
}
|
|
587
592
|
|
|
588
593
|
// Check for legacy config for migration
|
|
589
594
|
if (SecurityUtils.safeExistsSync(LEGACY_CONFIG_PATH)) {
|
|
590
595
|
console.log('📦 Migrating legacy configuration...');
|
|
591
|
-
const legacyRaw = SecurityUtils.safeReadFileSync(LEGACY_CONFIG_PATH, path.dirname(LEGACY_CONFIG_PATH), 'utf8');
|
|
592
|
-
const legacyConfig = SecurityUtils.safeParseJSON(legacyRaw);
|
|
593
|
-
if (!legacyConfig || typeof legacyConfig !== 'object') {
|
|
594
|
-
throw new Error('Invalid legacy configuration JSON');
|
|
595
|
-
}
|
|
596
|
+
const legacyRaw = SecurityUtils.safeReadFileSync(LEGACY_CONFIG_PATH, path.dirname(LEGACY_CONFIG_PATH), 'utf8');
|
|
597
|
+
const legacyConfig = SecurityUtils.safeParseJSON(legacyRaw);
|
|
598
|
+
if (!legacyConfig || typeof legacyConfig !== 'object') {
|
|
599
|
+
throw new Error('Invalid legacy configuration JSON');
|
|
600
|
+
}
|
|
596
601
|
const migratedConfig = { ...DEFAULT_CONFIG, ...legacyConfig };
|
|
597
|
-
saveConfig(migratedConfig).catch((err) => {
|
|
598
|
-
console.warn('[i18ntk] Warning: failed to persist migrated configuration:', err.message);
|
|
599
|
-
});
|
|
602
|
+
saveConfig(migratedConfig).catch((err) => {
|
|
603
|
+
console.warn('[i18ntk] Warning: failed to persist migrated configuration:', err.message);
|
|
604
|
+
});
|
|
600
605
|
currentConfig = migratedConfig;
|
|
601
606
|
|
|
602
607
|
// Clean up legacy config
|
|
@@ -614,9 +619,9 @@ function getConfig() {
|
|
|
614
619
|
|
|
615
620
|
// Use package defaults for new installation
|
|
616
621
|
console.log('📦 Initializing with default configuration...');
|
|
617
|
-
saveConfig(DEFAULT_CONFIG).catch((err) => {
|
|
618
|
-
console.warn('[i18ntk] Warning: failed to persist default configuration:', err.message);
|
|
619
|
-
});
|
|
622
|
+
saveConfig(DEFAULT_CONFIG).catch((err) => {
|
|
623
|
+
console.warn('[i18ntk] Warning: failed to persist default configuration:', err.message);
|
|
624
|
+
});
|
|
620
625
|
currentConfig = DEFAULT_CONFIG;
|
|
621
626
|
return resolvePaths(DEFAULT_CONFIG);
|
|
622
627
|
|
|
@@ -686,4 +691,4 @@ module.exports = {
|
|
|
686
691
|
resolvePaths,
|
|
687
692
|
toRelative,
|
|
688
693
|
normalizePathValue,
|
|
689
|
-
}
|
|
694
|
+
}
|
package/utils/security.js
CHANGED
|
@@ -27,7 +27,7 @@ function getI18n() {
|
|
|
27
27
|
i18n = require('./i18n-helper');
|
|
28
28
|
} catch (error) {
|
|
29
29
|
// Fallback to simple identity function if i18n fails to load
|
|
30
|
-
|
|
30
|
+
SecurityUtils.logSecurityEvent('i18n-helper not available, using fallback messages', 'warn');
|
|
31
31
|
return { t: (key, params = {}) => key };
|
|
32
32
|
}
|
|
33
33
|
}
|
|
@@ -93,9 +93,12 @@ class SecurityUtils {
|
|
|
93
93
|
|
|
94
94
|
return result;
|
|
95
95
|
} catch (error) {
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
96
|
+
const i18n = getI18n();
|
|
97
|
+
SecurityUtils.logSecurityEvent('Operation error', 'error', {
|
|
98
|
+
operation: operationName,
|
|
99
|
+
error: error.message
|
|
100
|
+
});
|
|
101
|
+
return null;
|
|
99
102
|
} finally {
|
|
100
103
|
SecurityUtils._operationStack.delete(operationName);
|
|
101
104
|
}
|
|
@@ -108,17 +111,20 @@ class SecurityUtils {
|
|
|
108
111
|
* @param {object} details - Additional details
|
|
109
112
|
*/
|
|
110
113
|
static logSecurityEvent(event, level = 'info', details = {}) {
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
114
|
+
// Prevent recursive logging which can occur during configuration loading
|
|
115
|
+
if (SecurityUtils._logging) {
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
SecurityUtils._logging = true;
|
|
120
|
+
try {
|
|
121
|
+
const cfg = getConfigManager()?.getConfig?.() || {};
|
|
122
|
+
const envLevel = (process.env.SECURITY_LOG_LEVEL || process.env.I18NTK_SECURITY_LOG_LEVEL || '').toLowerCase();
|
|
123
|
+
const configLevel = (cfg.security?.logLevel || cfg.security?.audit?.logLevel || '').toLowerCase();
|
|
124
|
+
|
|
125
|
+
// Check for debug mode
|
|
126
|
+
const debugMode = process.env.I18N_DEBUG === 'true' || process.env.DEBUG === 'true';
|
|
127
|
+
const currentLevel = debugMode ? 'info' : (envLevel || configLevel || 'warn');
|
|
122
128
|
|
|
123
129
|
const levels = { error: 0, warn: 1, warning: 1, info: 2 };
|
|
124
130
|
const messageLevel = levels[level.toLowerCase()] ?? 2;
|
|
@@ -263,14 +269,14 @@ class SecurityUtils {
|
|
|
263
269
|
// Read file with size limit (10MB max)
|
|
264
270
|
const stats = fs.statSync(validatedPath);
|
|
265
271
|
if (stats.size > 10 * 1024 * 1024) {
|
|
266
|
-
|
|
267
|
-
|
|
272
|
+
SecurityUtils.logSecurityEvent('File too large', 'warn', { filePath: validatedPath });
|
|
273
|
+
return null;
|
|
268
274
|
}
|
|
269
275
|
|
|
270
276
|
return fs.readFileSync(validatedPath, encoding);
|
|
271
277
|
} catch (error) {
|
|
272
|
-
|
|
273
|
-
|
|
278
|
+
SecurityUtils.logSecurityEvent('File read error', 'error', { errorMessage: error.message });
|
|
279
|
+
return null;
|
|
274
280
|
}
|
|
275
281
|
}
|
|
276
282
|
|
|
@@ -284,7 +290,7 @@ class SecurityUtils {
|
|
|
284
290
|
// Validate content is a string or Buffer
|
|
285
291
|
if (typeof content !== 'string' && !Buffer.isBuffer(content)) {
|
|
286
292
|
const i18n = getI18n();
|
|
287
|
-
|
|
293
|
+
SecurityUtils.logSecurityEvent('File write error: Content must be a string or Buffer', 'error');
|
|
288
294
|
return false;
|
|
289
295
|
}
|
|
290
296
|
|
|
@@ -292,7 +298,7 @@ class SecurityUtils {
|
|
|
292
298
|
const contentSize = typeof content === 'string' ? content.length : content.length;
|
|
293
299
|
if (contentSize > 10 * 1024 * 1024) {
|
|
294
300
|
const i18n = getI18n();
|
|
295
|
-
|
|
301
|
+
SecurityUtils.logSecurityEvent('Content too large for file', 'warn', { filePath: validatedPath });
|
|
296
302
|
return false;
|
|
297
303
|
}
|
|
298
304
|
|
|
@@ -305,7 +311,7 @@ class SecurityUtils {
|
|
|
305
311
|
return true;
|
|
306
312
|
} catch (error) {
|
|
307
313
|
const i18n = getI18n();
|
|
308
|
-
|
|
314
|
+
SecurityUtils.logSecurityEvent('File write error', 'error', { errorMessage: error.message });
|
|
309
315
|
return false;
|
|
310
316
|
}
|
|
311
317
|
}
|
|
@@ -399,8 +405,8 @@ class SecurityUtils {
|
|
|
399
405
|
const normalized = trimmed.charCodeAt(0) === 0xFEFF ? trimmed.slice(1) : trimmed;
|
|
400
406
|
return JSON.parse(normalized);
|
|
401
407
|
} catch (error) {
|
|
402
|
-
|
|
403
|
-
|
|
408
|
+
SecurityUtils.logSecurityEvent('Invalid JSON content', 'error', { error: error.message });
|
|
409
|
+
return fallback;
|
|
404
410
|
}
|
|
405
411
|
}
|
|
406
412
|
|
|
@@ -443,7 +449,7 @@ class SecurityUtils {
|
|
|
443
449
|
const isCommonContent = sanitized.length < 1000 && !sanitized.includes('<script');
|
|
444
450
|
if (!isFilePath && !isCommonContent) {
|
|
445
451
|
const i18n = getI18n();
|
|
446
|
-
|
|
452
|
+
SecurityUtils.logSecurityEvent('Input contains disallowed characters', 'warn');
|
|
447
453
|
}
|
|
448
454
|
// Allow more characters for file paths and content
|
|
449
455
|
sanitized = sanitized.replace(/[^a-zA-Z0-9\s\-_\.\,\!\?\(\)\[\]\{\}\:\;"'\/\\]/g, '');
|
|
@@ -456,10 +462,39 @@ class SecurityUtils {
|
|
|
456
462
|
return crypto.createHash('sha256').update(content).digest('hex');
|
|
457
463
|
}
|
|
458
464
|
|
|
465
|
+
static safeJoin(basePath, ...paths) {
|
|
466
|
+
if (!basePath || typeof basePath !== 'string') {
|
|
467
|
+
return false;
|
|
468
|
+
}
|
|
469
|
+
try {
|
|
470
|
+
const resolvedBase = path.resolve(basePath);
|
|
471
|
+
const joinedPath = path.join(resolvedBase, ...paths);
|
|
472
|
+
const resolvedPath = path.resolve(joinedPath);
|
|
473
|
+
|
|
474
|
+
// Ensure the final path is within the base directory
|
|
475
|
+
if (!resolvedPath.startsWith(resolvedBase)) {
|
|
476
|
+
SecurityUtils.logSecurityEvent('Path traversal attempt detected in safeJoin', 'error', {
|
|
477
|
+
basePath,
|
|
478
|
+
paths,
|
|
479
|
+
resolvedPath
|
|
480
|
+
});
|
|
481
|
+
return false;
|
|
482
|
+
}
|
|
483
|
+
return resolvedPath;
|
|
484
|
+
} catch (error) {
|
|
485
|
+
SecurityUtils.logSecurityEvent('Error in safeJoin', 'error', {
|
|
486
|
+
basePath,
|
|
487
|
+
paths,
|
|
488
|
+
error: error.message
|
|
489
|
+
});
|
|
490
|
+
return false;
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
|
|
459
494
|
static isSafePath(filePath) {
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
495
|
+
if (!filePath || typeof filePath !== 'string') {
|
|
496
|
+
return false;
|
|
497
|
+
}
|
|
463
498
|
|
|
464
499
|
// Allow legitimate Windows drive letter paths
|
|
465
500
|
if (filePath.match(/^[A-Z]:[\/\\]/)) {
|
|
@@ -531,12 +566,12 @@ class SecurityUtils {
|
|
|
531
566
|
'theme', 'ui', 'setup', 'reports', 'display', 'interface',
|
|
532
567
|
// Security and settings
|
|
533
568
|
'security', 'settings', 'preferences', 'config', 'configuration',
|
|
534
|
-
// Additional common properties
|
|
535
|
-
'autoSave', 'autoBackup', 'validateOnSave', 'showWarnings', 'verbose',
|
|
536
|
-
'timeout', 'retries', 'batchSize', 'maxConcurrency', 'cacheEnabled',
|
|
537
|
-
// Date and reporting options used by existing settings
|
|
538
|
-
'dateFormat', 'timeFormat', 'timezone', 'reportLanguage', 'dateTime'
|
|
539
|
-
]);
|
|
569
|
+
// Additional common properties
|
|
570
|
+
'autoSave', 'autoBackup', 'validateOnSave', 'showWarnings', 'verbose',
|
|
571
|
+
'timeout', 'retries', 'batchSize', 'maxConcurrency', 'cacheEnabled',
|
|
572
|
+
// Date and reporting options used by existing settings
|
|
573
|
+
'dateFormat', 'timeFormat', 'timezone', 'reportLanguage', 'dateTime'
|
|
574
|
+
]);
|
|
540
575
|
|
|
541
576
|
// Remove unknown properties
|
|
542
577
|
Object.keys(sanitized).forEach(key => {
|