i18ntk 2.3.4 → 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 -9
- package/main/i18ntk-backup-class.js +80 -66
- package/package.json +7 -9
- package/settings/settings-manager.js +60 -39
- package/utils/config-manager.js +199 -194
- package/utils/npm-version-warning.js +5 -0
- 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,15 +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 now checks npm registry metadata at startup and warns when your installed version is out of date.
|
|
19
|
-
Set `I18NTK_DISABLE_UPDATE_CHECK=true` to disable this warning in restricted/offline environments.
|
|
20
|
-
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.
|
|
21
18
|
|
|
22
19
|
## What i18ntk Does
|
|
23
20
|
|
|
@@ -154,7 +151,7 @@ Example `.i18ntk-config`:
|
|
|
154
151
|
|
|
155
152
|
```json
|
|
156
153
|
{
|
|
157
|
-
"version": "2.3.
|
|
154
|
+
"version": "2.3.6",
|
|
158
155
|
"sourceDir": "./locales",
|
|
159
156
|
"i18nDir": "./locales",
|
|
160
157
|
"outputDir": "./i18ntk-reports",
|
|
@@ -177,7 +174,7 @@ See [docs/api/CONFIGURATION.md](docs/api/CONFIGURATION.md) for the full configur
|
|
|
177
174
|
- [Runtime API Guide](docs/runtime.md)
|
|
178
175
|
- [Scanner Guide](docs/scanner-guide.md)
|
|
179
176
|
- [Environment Variables](docs/environment-variables.md)
|
|
180
|
-
- [Migration Guide v2.3.
|
|
177
|
+
- [Migration Guide v2.3.5](docs/migration-guide-v2.3.6.md)
|
|
181
178
|
- [Optimization Prompt](docs/development/package-optimization-prompt.md)
|
|
182
179
|
|
|
183
180
|
## License
|
|
@@ -15,12 +15,20 @@ const SecurityUtils = require('../utils/security');
|
|
|
15
15
|
*
|
|
16
16
|
* Class-based implementation of backup functionality for use with CommandRouter
|
|
17
17
|
*/
|
|
18
|
-
class I18nBackup {
|
|
18
|
+
class I18nBackup {
|
|
19
19
|
constructor(config = {}) {
|
|
20
20
|
this.config = config;
|
|
21
21
|
this.backupDir = path.join(process.cwd(), 'i18ntk-backups');
|
|
22
22
|
this.maxBackups = Math.min(Math.max(parseInt(config.backup?.maxBackups, 10) || 1, 1), 3);
|
|
23
|
-
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
validateInProject(targetPath, label = 'path') {
|
|
26
|
+
const validated = SecurityUtils.validatePath(targetPath, process.cwd());
|
|
27
|
+
if (!validated) {
|
|
28
|
+
throw new Error(`Invalid ${label}: ${targetPath}`);
|
|
29
|
+
}
|
|
30
|
+
return validated;
|
|
31
|
+
}
|
|
24
32
|
|
|
25
33
|
/**
|
|
26
34
|
* Main run method for the backup command
|
|
@@ -79,13 +87,15 @@ Options:
|
|
|
79
87
|
}
|
|
80
88
|
|
|
81
89
|
// Command handlers
|
|
82
|
-
async handleCreate(options = {}) {
|
|
83
|
-
// Use absolute path for the locales directory
|
|
84
|
-
const
|
|
85
|
-
const
|
|
86
|
-
const
|
|
87
|
-
const
|
|
88
|
-
const
|
|
90
|
+
async handleCreate(options = {}) {
|
|
91
|
+
// Use absolute path for the locales directory
|
|
92
|
+
const requestedDir = (options._ && options._[1]) || options.dir || path.join(process.cwd(), 'locales');
|
|
93
|
+
const requestedOutputDir = options.output || this.backupDir;
|
|
94
|
+
const dir = this.validateInProject(path.resolve(process.cwd(), requestedDir), 'source directory');
|
|
95
|
+
const outputDir = this.validateInProject(path.resolve(process.cwd(), requestedOutputDir), 'backup directory');
|
|
96
|
+
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
97
|
+
const backupName = `backup-${timestamp}.json`;
|
|
98
|
+
const backupPath = this.validateInProject(path.join(outputDir, backupName), 'backup file');
|
|
89
99
|
|
|
90
100
|
// Log the paths for debugging
|
|
91
101
|
logger.debug(`Source directory: ${dir}`);
|
|
@@ -104,7 +114,7 @@ Options:
|
|
|
104
114
|
}
|
|
105
115
|
|
|
106
116
|
// Validate directory
|
|
107
|
-
const sourceDir =
|
|
117
|
+
const sourceDir = dir;
|
|
108
118
|
try {
|
|
109
119
|
const stats = await fsp.stat(sourceDir);
|
|
110
120
|
if (!stats.isDirectory()) {
|
|
@@ -163,16 +173,17 @@ Options:
|
|
|
163
173
|
};
|
|
164
174
|
}
|
|
165
175
|
|
|
166
|
-
async handleRestore(options = {}) {
|
|
167
|
-
const backupFile = options._ && options._[1];
|
|
168
|
-
if (!backupFile) {
|
|
169
|
-
throw new Error('Backup file path is required');
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
const backupPath = path.resolve(process.cwd(), backupFile);
|
|
173
|
-
const outputDir =
|
|
174
|
-
? path.resolve(process.cwd(), options.output)
|
|
175
|
-
|
|
176
|
+
async handleRestore(options = {}) {
|
|
177
|
+
const backupFile = options._ && options._[1];
|
|
178
|
+
if (!backupFile) {
|
|
179
|
+
throw new Error('Backup file path is required');
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const backupPath = this.validateInProject(path.resolve(process.cwd(), backupFile), 'backup file');
|
|
183
|
+
const outputDir = this.validateInProject(
|
|
184
|
+
options.output ? path.resolve(process.cwd(), options.output) : path.join(process.cwd(), 'restored'),
|
|
185
|
+
'restore output directory'
|
|
186
|
+
);
|
|
176
187
|
|
|
177
188
|
// Validate backup file
|
|
178
189
|
if (!SecurityUtils.safeExistsSync(backupPath, process.cwd())) {
|
|
@@ -183,21 +194,21 @@ Options:
|
|
|
183
194
|
|
|
184
195
|
try {
|
|
185
196
|
// Read the backup file
|
|
186
|
-
const backupData = await
|
|
187
|
-
const translations = JSON.parse(backupData);
|
|
197
|
+
const backupData = await fsp.readFile(backupPath, 'utf8');
|
|
198
|
+
const translations = JSON.parse(backupData);
|
|
188
199
|
|
|
189
200
|
// Create output directory if it doesn't exist
|
|
190
201
|
try {
|
|
191
|
-
await
|
|
192
|
-
} catch (err) {
|
|
193
|
-
if (err.code !== 'EEXIST') throw err;
|
|
194
|
-
}
|
|
202
|
+
await fsp.mkdir(outputDir, { recursive: true });
|
|
203
|
+
} catch (err) {
|
|
204
|
+
if (err.code !== 'EEXIST') throw err;
|
|
205
|
+
}
|
|
195
206
|
|
|
196
207
|
// Write the restored files
|
|
197
|
-
for (const [file, content] of Object.entries(translations)) {
|
|
198
|
-
const filePath = path.join(outputDir, file);
|
|
199
|
-
await
|
|
200
|
-
}
|
|
208
|
+
for (const [file, content] of Object.entries(translations)) {
|
|
209
|
+
const filePath = this.validateInProject(path.join(outputDir, file), 'restore file');
|
|
210
|
+
await fsp.writeFile(filePath, JSON.stringify(content, null, 2), 'utf8');
|
|
211
|
+
}
|
|
201
212
|
|
|
202
213
|
logger.success('Backup restored successfully');
|
|
203
214
|
logger.info(` Restored ${Object.keys(translations).length} files to: ${outputDir}`);
|
|
@@ -214,12 +225,13 @@ Options:
|
|
|
214
225
|
}
|
|
215
226
|
}
|
|
216
227
|
|
|
217
|
-
async handleList() {
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
228
|
+
async handleList() {
|
|
229
|
+
const backupDir = this.validateInProject(this.backupDir, 'backup directory');
|
|
230
|
+
try {
|
|
231
|
+
// Ensure backup directory exists
|
|
232
|
+
try {
|
|
233
|
+
await fsp.access(backupDir);
|
|
234
|
+
} catch (err) {
|
|
223
235
|
if (err.code === 'ENOENT') {
|
|
224
236
|
logger.warn('No backups found. The backup directory does not exist yet.');
|
|
225
237
|
} else {
|
|
@@ -228,13 +240,13 @@ Options:
|
|
|
228
240
|
return { success: true, backups: [] };
|
|
229
241
|
}
|
|
230
242
|
|
|
231
|
-
const files = await fsp.readdir(
|
|
243
|
+
const files = await fsp.readdir(backupDir);
|
|
232
244
|
const backups = [];
|
|
233
245
|
|
|
234
246
|
for (const file of files) {
|
|
235
247
|
if (file.startsWith('backup-') && file.endsWith('.json')) {
|
|
236
248
|
try {
|
|
237
|
-
const filePath = path.join(
|
|
249
|
+
const filePath = this.validateInProject(path.join(backupDir, file), 'backup file');
|
|
238
250
|
const stats = await fsp.stat(filePath);
|
|
239
251
|
backups.push({
|
|
240
252
|
name: file,
|
|
@@ -288,7 +300,7 @@ Options:
|
|
|
288
300
|
throw new Error('Backup file path is required');
|
|
289
301
|
}
|
|
290
302
|
|
|
291
|
-
const backupPath = path.resolve(process.cwd(), backupFile);
|
|
303
|
+
const backupPath = this.validateInProject(path.resolve(process.cwd(), backupFile), 'backup file');
|
|
292
304
|
|
|
293
305
|
// Validate backup file
|
|
294
306
|
if (!SecurityUtils.safeExistsSync(backupPath, process.cwd())) {
|
|
@@ -328,21 +340,22 @@ Options:
|
|
|
328
340
|
}
|
|
329
341
|
}
|
|
330
342
|
|
|
331
|
-
async handleCleanup(options = {}) {
|
|
332
|
-
const keep = options.keep ? parseInt(options.keep, 10) : this.maxBackups;
|
|
343
|
+
async handleCleanup(options = {}) {
|
|
344
|
+
const keep = options.keep ? parseInt(options.keep, 10) : this.maxBackups;
|
|
345
|
+
const backupDir = this.validateInProject(this.backupDir, 'backup directory');
|
|
333
346
|
|
|
334
347
|
logger.info('\nCleaning up old backups...');
|
|
335
348
|
|
|
336
349
|
try {
|
|
337
|
-
const files = await fsp.readdir(
|
|
338
|
-
const backupFiles = files
|
|
339
|
-
.filter(file => file.startsWith('backup-') && file.endsWith('.json'))
|
|
340
|
-
.map(file => ({
|
|
341
|
-
name: file,
|
|
342
|
-
path: path.join(
|
|
343
|
-
time:
|
|
350
|
+
const files = await fsp.readdir(backupDir);
|
|
351
|
+
const backupFiles = files
|
|
352
|
+
.filter(file => file.startsWith('backup-') && file.endsWith('.json'))
|
|
353
|
+
.map(file => ({
|
|
354
|
+
name: file,
|
|
355
|
+
path: this.validateInProject(path.join(backupDir, file), 'backup file'),
|
|
356
|
+
time: (SecurityUtils.safeStatSync(path.join(backupDir, file), process.cwd()) || { mtime: new Date(0) }).mtime.getTime()
|
|
344
357
|
}))
|
|
345
|
-
.sort((a, b) => b.time - a.time);
|
|
358
|
+
.sort((a, b) => b.time - a.time);
|
|
346
359
|
|
|
347
360
|
// Keep only the most recent 'keep' files
|
|
348
361
|
const toDelete = backupFiles.slice(keep);
|
|
@@ -379,27 +392,28 @@ Options:
|
|
|
379
392
|
}
|
|
380
393
|
}
|
|
381
394
|
|
|
382
|
-
async cleanupOldBackups(outputDir) {
|
|
383
|
-
try {
|
|
384
|
-
const
|
|
385
|
-
const
|
|
386
|
-
|
|
387
|
-
.
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
395
|
+
async cleanupOldBackups(outputDir) {
|
|
396
|
+
try {
|
|
397
|
+
const safeOutputDir = this.validateInProject(outputDir, 'backup output directory');
|
|
398
|
+
const files = await fsp.readdir(safeOutputDir);
|
|
399
|
+
const backupFiles = files
|
|
400
|
+
.filter(file => file.startsWith('backup-') && file.endsWith('.json'))
|
|
401
|
+
.map(file => ({
|
|
402
|
+
name: file,
|
|
403
|
+
path: this.validateInProject(path.join(safeOutputDir, file), 'backup file'),
|
|
404
|
+
time: (SecurityUtils.safeStatSync(path.join(safeOutputDir, file), process.cwd()) || { mtime: new Date(0) }).mtime.getTime()
|
|
405
|
+
}))
|
|
406
|
+
.sort((a, b) => b.time - a.time);
|
|
393
407
|
|
|
394
408
|
// Keep only the most recent files
|
|
395
409
|
const toDelete = backupFiles.slice(this.maxBackups);
|
|
396
410
|
|
|
397
|
-
for (const file of toDelete) {
|
|
398
|
-
try {
|
|
399
|
-
await
|
|
400
|
-
} catch (err) {
|
|
401
|
-
// Ignore cleanup errors
|
|
402
|
-
}
|
|
411
|
+
for (const file of toDelete) {
|
|
412
|
+
try {
|
|
413
|
+
await fsp.unlink(file.path);
|
|
414
|
+
} catch (err) {
|
|
415
|
+
// Ignore cleanup errors
|
|
416
|
+
}
|
|
403
417
|
}
|
|
404
418
|
} catch (error) {
|
|
405
419
|
// Ignore cleanup errors
|
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",
|
|
@@ -227,7 +224,7 @@
|
|
|
227
224
|
},
|
|
228
225
|
"preferGlobal": true,
|
|
229
226
|
"versionInfo": {
|
|
230
|
-
"version": "2.3.
|
|
227
|
+
"version": "2.3.5",
|
|
231
228
|
"releaseDate": "12/04/2026",
|
|
232
229
|
"lastUpdated": "12/04/2026",
|
|
233
230
|
"maintainer": "Vlad Noskov",
|
|
@@ -238,9 +235,10 @@
|
|
|
238
235
|
"HOTFIX: Removed deprecated package-path fallback that caused production build warnings for non-exported subpaths.",
|
|
239
236
|
"CRITICAL FIX: Resolved sizing and usage-analysis regressions in v2 command flow.",
|
|
240
237
|
"PACKAGING: Reduced publish footprint by removing internal development scripts and legacy fixed-file artifacts.",
|
|
241
|
-
"SECURITY: Hardened release checks and added explicit support guidance to update from pre-2.3.
|
|
238
|
+
"SECURITY: Hardened release checks and added explicit support guidance to update from pre-2.3.5 versions.",
|
|
242
239
|
"CONFIG: Added cross-process file locking for .i18ntk-config writes to prevent production rename races.",
|
|
243
240
|
"CONFIG: Made autosave runtime-safe with non-throwing save failures and I18NTK_DISABLE_AUTOSAVE support.",
|
|
241
|
+
"SECURITY: Hardened reset/backup path handling and made npm update checks explicit opt-in.",
|
|
244
242
|
"CLI: Added npm registry version check with upgrade warning for out-of-date installs.",
|
|
245
243
|
"I18N: Completed internal UI locale parity and actionable untranslated-key cleanup across supported languages."
|
|
246
244
|
],
|
|
@@ -267,7 +265,7 @@
|
|
|
267
265
|
"spring-boot": ">=2.5.0",
|
|
268
266
|
"laravel": ">=8.0.0"
|
|
269
267
|
},
|
|
270
|
-
"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."
|
|
271
269
|
},
|
|
272
270
|
"_comment": "This package is zero-dependency and uses only native Node.js modules"
|
|
273
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",
|
|
@@ -312,6 +313,18 @@ class SettingsManager {
|
|
|
312
313
|
this.settings = this.loadSettings();
|
|
313
314
|
}
|
|
314
315
|
|
|
316
|
+
resolveSafePath(targetPath, basePath = process.cwd()) {
|
|
317
|
+
return SecurityUtils.validatePath(path.resolve(targetPath), basePath);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
safeDeleteFile(targetPath, basePath = process.cwd()) {
|
|
321
|
+
const validated = this.resolveSafePath(targetPath, basePath);
|
|
322
|
+
if (!validated) return false;
|
|
323
|
+
if (!SecurityUtils.safeExistsSync(validated, basePath)) return false;
|
|
324
|
+
fs.unlinkSync(validated);
|
|
325
|
+
return true;
|
|
326
|
+
}
|
|
327
|
+
|
|
315
328
|
/**
|
|
316
329
|
* Load settings from file or return default settings
|
|
317
330
|
* @returns {object} Settings object
|
|
@@ -432,12 +445,18 @@ class SettingsManager {
|
|
|
432
445
|
*/
|
|
433
446
|
_saveImmediately() {
|
|
434
447
|
try {
|
|
435
|
-
|
|
436
|
-
|
|
448
|
+
const configDir = path.dirname(this.configFile);
|
|
449
|
+
const validatedConfigDir = this.resolveSafePath(configDir, process.cwd());
|
|
450
|
+
if (!validatedConfigDir) {
|
|
451
|
+
throw new Error('Invalid configuration directory');
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
if (!SecurityUtils.safeExistsSync(validatedConfigDir, process.cwd())) {
|
|
455
|
+
SecurityUtils.safeMkdirSync(validatedConfigDir, process.cwd(), { recursive: true });
|
|
437
456
|
}
|
|
438
457
|
|
|
439
458
|
const content = JSON.stringify(this.settings, null, 4);
|
|
440
|
-
SecurityUtils.safeWriteFileSync(this.configFile, content,
|
|
459
|
+
SecurityUtils.safeWriteFileSync(this.configFile, content, validatedConfigDir, 'utf8');
|
|
441
460
|
|
|
442
461
|
// Create backup if enabled
|
|
443
462
|
if (this.settings.backup?.enabled) {
|
|
@@ -466,13 +485,19 @@ class SettingsManager {
|
|
|
466
485
|
}
|
|
467
486
|
|
|
468
487
|
if (!SecurityUtils.safeExistsSync(validatedBackupDir, process.cwd())) {
|
|
469
|
-
|
|
488
|
+
SecurityUtils.safeMkdirSync(validatedBackupDir, process.cwd(), { recursive: true });
|
|
470
489
|
}
|
|
471
490
|
|
|
472
491
|
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
473
492
|
const backupFile = path.join(validatedBackupDir, `config-${timestamp}.json`);
|
|
493
|
+
const validatedConfigFile = this.resolveSafePath(this.configFile, process.cwd());
|
|
494
|
+
const validatedBackupFile = this.resolveSafePath(backupFile, process.cwd());
|
|
495
|
+
if (!validatedConfigFile || !validatedBackupFile) {
|
|
496
|
+
console.error('Error creating backup: Invalid source or destination path');
|
|
497
|
+
return;
|
|
498
|
+
}
|
|
474
499
|
|
|
475
|
-
fs.copyFileSync(
|
|
500
|
+
fs.copyFileSync(validatedConfigFile, validatedBackupFile);
|
|
476
501
|
|
|
477
502
|
// Clean old backups
|
|
478
503
|
this.cleanupOldBackups(validatedBackupDir);
|
|
@@ -486,17 +511,20 @@ class SettingsManager {
|
|
|
486
511
|
*/
|
|
487
512
|
cleanupOldBackups(backupDirectory = null) {
|
|
488
513
|
try {
|
|
489
|
-
const activeBackupDir = backupDirectory || this.backupDir;
|
|
514
|
+
const activeBackupDir = this.resolveSafePath(backupDirectory || this.backupDir, process.cwd());
|
|
515
|
+
if (!activeBackupDir) {
|
|
516
|
+
return;
|
|
517
|
+
}
|
|
490
518
|
if (!SecurityUtils.safeExistsSync(activeBackupDir, process.cwd())) {
|
|
491
519
|
return;
|
|
492
520
|
}
|
|
493
521
|
|
|
494
|
-
const files =
|
|
522
|
+
const files = (SecurityUtils.safeReaddirSync(activeBackupDir, process.cwd()) || [])
|
|
495
523
|
.filter(file => file.startsWith('config-') && file.endsWith('.json'))
|
|
496
524
|
.map(file => ({
|
|
497
525
|
name: file,
|
|
498
526
|
path: path.join(activeBackupDir, file),
|
|
499
|
-
mtime:
|
|
527
|
+
mtime: (SecurityUtils.safeStatSync(path.join(activeBackupDir, file), process.cwd()) || { mtime: new Date(0) }).mtime
|
|
500
528
|
}))
|
|
501
529
|
.sort((a, b) => b.mtime - a.mtime);
|
|
502
530
|
|
|
@@ -506,7 +534,7 @@ class SettingsManager {
|
|
|
506
534
|
: 1;
|
|
507
535
|
if (files.length > maxBackups) {
|
|
508
536
|
files.slice(maxBackups).forEach(file => {
|
|
509
|
-
|
|
537
|
+
this.safeDeleteFile(file.path, process.cwd());
|
|
510
538
|
});
|
|
511
539
|
}
|
|
512
540
|
} catch (error) {
|
|
@@ -546,52 +574,49 @@ class SettingsManager {
|
|
|
546
574
|
|
|
547
575
|
// 2. Remove actual configuration files used by the system
|
|
548
576
|
const packageDir = path.resolve(__dirname, '..');
|
|
549
|
-
const
|
|
577
|
+
const projectRoot = path.resolve(process.cwd());
|
|
578
|
+
const settingsDir = SecurityUtils.safeJoin(packageDir, 'settings');
|
|
550
579
|
|
|
551
580
|
// Main configuration file
|
|
552
581
|
const mainConfigPath = path.join(settingsDir, 'i18ntk-config.json');
|
|
553
|
-
if (
|
|
554
|
-
fs.unlinkSync(mainConfigPath);
|
|
582
|
+
if (this.safeDeleteFile(mainConfigPath, packageDir)) {
|
|
555
583
|
console.log('✅ Main configuration file removed');
|
|
556
584
|
}
|
|
557
585
|
|
|
558
586
|
// Project configuration file
|
|
559
587
|
const projectConfigPath = path.join(settingsDir, 'project-config.json');
|
|
560
|
-
if (
|
|
561
|
-
fs.unlinkSync(projectConfigPath);
|
|
588
|
+
if (this.safeDeleteFile(projectConfigPath, packageDir)) {
|
|
562
589
|
console.log('✅ Project configuration removed');
|
|
563
590
|
}
|
|
564
591
|
|
|
565
592
|
// Setup tracking file
|
|
566
593
|
const setupFile = path.join(settingsDir, 'setup.json');
|
|
567
|
-
if (
|
|
568
|
-
fs.unlinkSync(setupFile);
|
|
594
|
+
if (this.safeDeleteFile(setupFile, packageDir)) {
|
|
569
595
|
console.log('✅ Setup tracking cleared');
|
|
570
596
|
}
|
|
571
597
|
|
|
572
598
|
// 3. Clear all backup files from backups directory
|
|
573
599
|
const backupsDir = path.join(packageDir, 'backups');
|
|
574
|
-
if (SecurityUtils.safeExistsSync(backupsDir)) {
|
|
575
|
-
const backupFiles =
|
|
600
|
+
if (SecurityUtils.safeExistsSync(backupsDir, packageDir)) {
|
|
601
|
+
const backupFiles = SecurityUtils.safeReaddirSync(backupsDir, packageDir) || [];
|
|
576
602
|
for (const file of backupFiles) {
|
|
577
603
|
if (file.endsWith('.json') || file.endsWith('.bak')) {
|
|
578
|
-
|
|
604
|
+
this.safeDeleteFile(path.join(backupsDir, file), packageDir);
|
|
579
605
|
}
|
|
580
606
|
}
|
|
581
607
|
console.log('✅ All backup files cleared');
|
|
582
608
|
}
|
|
583
609
|
|
|
584
|
-
// 4. Clear admin PIN configuration (
|
|
610
|
+
// 4. Clear admin PIN configuration (package/project scope only)
|
|
585
611
|
const adminConfigPaths = [
|
|
586
612
|
path.join(packageDir, '.i18n-admin-config.json'),
|
|
587
613
|
path.join(settingsDir, '.i18n-admin-config.json'),
|
|
588
614
|
path.join(settingsDir, 'admin-config.json'),
|
|
589
|
-
|
|
615
|
+
SecurityUtils.safeJoin(projectRoot, '.i18n-admin-config.json') || path.join(projectRoot, '.i18n-admin-config.json')
|
|
590
616
|
];
|
|
591
617
|
|
|
592
618
|
for (const adminConfigPath of adminConfigPaths) {
|
|
593
|
-
if (
|
|
594
|
-
fs.unlinkSync(adminConfigPath);
|
|
619
|
+
if (this.safeDeleteFile(adminConfigPath, projectRoot)) {
|
|
595
620
|
console.log('✅ Admin PIN configuration cleared');
|
|
596
621
|
}
|
|
597
622
|
}
|
|
@@ -603,8 +628,7 @@ class SettingsManager {
|
|
|
603
628
|
];
|
|
604
629
|
|
|
605
630
|
for (const initFile of initFiles) {
|
|
606
|
-
if (
|
|
607
|
-
fs.unlinkSync(initFile);
|
|
631
|
+
if (this.safeDeleteFile(initFile, packageDir)) {
|
|
608
632
|
console.log('✅ Initialization tracking cleared');
|
|
609
633
|
}
|
|
610
634
|
}
|
|
@@ -616,10 +640,10 @@ class SettingsManager {
|
|
|
616
640
|
];
|
|
617
641
|
|
|
618
642
|
for (const cacheDir of cacheDirs) {
|
|
619
|
-
if (SecurityUtils.safeExistsSync(cacheDir)) {
|
|
620
|
-
const cacheFiles =
|
|
643
|
+
if (SecurityUtils.safeExistsSync(cacheDir, packageDir)) {
|
|
644
|
+
const cacheFiles = SecurityUtils.safeReaddirSync(cacheDir, packageDir) || [];
|
|
621
645
|
for (const file of cacheFiles) {
|
|
622
|
-
|
|
646
|
+
this.safeDeleteFile(path.join(cacheDir, file), packageDir);
|
|
623
647
|
}
|
|
624
648
|
console.log('✅ Cache cleared');
|
|
625
649
|
}
|
|
@@ -639,9 +663,7 @@ class SettingsManager {
|
|
|
639
663
|
];
|
|
640
664
|
|
|
641
665
|
for (const tempFile of tempFiles) {
|
|
642
|
-
|
|
643
|
-
fs.unlinkSync(tempFile);
|
|
644
|
-
}
|
|
666
|
+
this.safeDeleteFile(tempFile, packageDir);
|
|
645
667
|
}
|
|
646
668
|
console.log('✅ Temporary files cleared');
|
|
647
669
|
|
|
@@ -656,8 +678,7 @@ class SettingsManager {
|
|
|
656
678
|
];
|
|
657
679
|
|
|
658
680
|
for (const file of additionalFiles) {
|
|
659
|
-
if (
|
|
660
|
-
fs.unlinkSync(file);
|
|
681
|
+
if (this.safeDeleteFile(file, packageDir)) {
|
|
661
682
|
console.log(`✅ Removed ${path.basename(file)}`);
|
|
662
683
|
}
|
|
663
684
|
}
|
|
@@ -982,4 +1003,4 @@ class SettingsManager {
|
|
|
982
1003
|
}
|
|
983
1004
|
|
|
984
1005
|
module.exports = SettingsManager;
|
|
985
|
-
|
|
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
|
+
}
|
|
@@ -108,6 +108,11 @@ async function printUpgradeWarningIfOutdated({
|
|
|
108
108
|
currentVersion,
|
|
109
109
|
timeoutMs = DEFAULT_TIMEOUT_MS
|
|
110
110
|
}) {
|
|
111
|
+
const enabled = String(process.env.I18NTK_ENABLE_UPDATE_CHECK || '').toLowerCase();
|
|
112
|
+
if (!(enabled === '1' || enabled === 'true' || enabled === 'yes')) {
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
|
|
111
116
|
if (process.env.I18NTK_DISABLE_UPDATE_CHECK === 'true') {
|
|
112
117
|
return;
|
|
113
118
|
}
|
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 => {
|