@roastcodes/ttdash 6.1.8 → 6.1.9
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 +14 -5
- package/dist/assets/AutoImportModal-C8gA0_mL.js +3 -0
- package/dist/assets/CustomTooltip-BXro6tIF.js +1 -0
- package/dist/assets/DrillDownModal-BnZ6q6tF.js +1 -0
- package/dist/assets/button-B26tLVFw.js +1 -0
- package/dist/assets/dialog-CA-ZSHjK.js +1 -0
- package/dist/assets/{icons-vendor-DFoaijFJ.js → icons-vendor-z59La6A4.js} +1 -1
- package/dist/assets/index-BfNaLs3g.js +4 -0
- package/dist/assets/index-BkGSNAne.css +2 -0
- package/dist/index.html +6 -6
- package/package.json +3 -1
- package/server/http-utils.js +165 -0
- package/server/report/utils.js +12 -457
- package/server/runtime.js +78 -0
- package/server.js +280 -165
- package/shared/dashboard-domain.d.ts +19 -0
- package/shared/dashboard-domain.js +615 -0
- package/shared/dashboard-preferences.json +43 -0
- package/shared/dashboard-types.d.ts +62 -0
- package/src/locales/de/common.json +189 -123
- package/src/locales/en/common.json +69 -3
- package/dist/assets/AutoImportModal-Dqbl8H04.js +0 -2
- package/dist/assets/CustomTooltip-BxopDd3O.js +0 -1
- package/dist/assets/DrillDownModal-B7ZU15xQ.js +0 -1
- package/dist/assets/button-D7Ib8H7t.js +0 -1
- package/dist/assets/dialog-Cn1m7WhC.js +0 -1
- package/dist/assets/index-DDw3UUhU.js +0 -4
- package/dist/assets/index-g2F-z39N.css +0 -2
package/server.js
CHANGED
|
@@ -6,10 +6,18 @@ const os = require('os');
|
|
|
6
6
|
const path = require('path');
|
|
7
7
|
const readline = require('readline/promises');
|
|
8
8
|
const { spawn } = require('child_process');
|
|
9
|
+
const spawnCrossPlatform = require('cross-spawn');
|
|
9
10
|
const { parseArgs } = require('util');
|
|
10
11
|
const { normalizeIncomingData } = require('./usage-normalizer');
|
|
11
12
|
const { generatePdfReport } = require('./server/report');
|
|
12
13
|
const { version: APP_VERSION } = require('./package.json');
|
|
14
|
+
const dashboardPreferences = require('./shared/dashboard-preferences.json');
|
|
15
|
+
const { createHttpUtils } = require('./server/http-utils');
|
|
16
|
+
const {
|
|
17
|
+
ensureBindHostAllowed,
|
|
18
|
+
isLoopbackHost,
|
|
19
|
+
listenOnAvailablePort,
|
|
20
|
+
} = require('./server/runtime');
|
|
13
21
|
|
|
14
22
|
const ROOT = __dirname;
|
|
15
23
|
const STATIC_ROOT = path.join(ROOT, 'dist');
|
|
@@ -23,9 +31,12 @@ const ENV_START_PORT = parseInt(process.env.PORT, 10);
|
|
|
23
31
|
const START_PORT = CLI_OPTIONS.port ?? (Number.isFinite(ENV_START_PORT) ? ENV_START_PORT : 3000);
|
|
24
32
|
const MAX_PORT = Math.min(START_PORT + 100, 65535);
|
|
25
33
|
const BIND_HOST = process.env.HOST || '127.0.0.1';
|
|
26
|
-
const
|
|
34
|
+
const ALLOW_REMOTE_BIND = process.env.TTDASH_ALLOW_REMOTE === '1';
|
|
35
|
+
const API_PREFIX = process.env.API_PREFIX || '/api';
|
|
27
36
|
const MAX_BODY_SIZE = 10 * 1024 * 1024; // 10 MB
|
|
28
37
|
const IS_WINDOWS = process.platform === 'win32';
|
|
38
|
+
const SECURE_DIR_MODE = 0o700;
|
|
39
|
+
const SECURE_FILE_MODE = 0o600;
|
|
29
40
|
const TOKTRACK_LOCAL_BIN = path.join(
|
|
30
41
|
ROOT,
|
|
31
42
|
'node_modules',
|
|
@@ -46,22 +57,8 @@ const USAGE_BACKUP_KIND = 'ttdash-usage-backup';
|
|
|
46
57
|
const IS_BACKGROUND_CHILD = process.env.TTDASH_BACKGROUND_CHILD === '1';
|
|
47
58
|
const FORCE_OPEN_BROWSER = process.env.TTDASH_FORCE_OPEN_BROWSER === '1';
|
|
48
59
|
const BACKGROUND_START_TIMEOUT_MS = 15000;
|
|
49
|
-
const DASHBOARD_DATE_PRESETS =
|
|
50
|
-
const DASHBOARD_SECTION_IDS =
|
|
51
|
-
'insights',
|
|
52
|
-
'metrics',
|
|
53
|
-
'today',
|
|
54
|
-
'currentMonth',
|
|
55
|
-
'activity',
|
|
56
|
-
'forecastCache',
|
|
57
|
-
'limits',
|
|
58
|
-
'costAnalysis',
|
|
59
|
-
'tokenAnalysis',
|
|
60
|
-
'requestAnalysis',
|
|
61
|
-
'advancedAnalysis',
|
|
62
|
-
'comparisons',
|
|
63
|
-
'tables',
|
|
64
|
-
];
|
|
60
|
+
const DASHBOARD_DATE_PRESETS = dashboardPreferences.datePresets;
|
|
61
|
+
const DASHBOARD_SECTION_IDS = dashboardPreferences.sectionDefinitions.map((section) => section.id);
|
|
65
62
|
const DEFAULT_SETTINGS = {
|
|
66
63
|
language: 'de',
|
|
67
64
|
theme: 'dark',
|
|
@@ -129,6 +126,7 @@ function printHelp() {
|
|
|
129
126
|
console.log(' PORT=3010 ttdash');
|
|
130
127
|
console.log(' NO_OPEN_BROWSER=1 ttdash');
|
|
131
128
|
console.log(' HOST=127.0.0.1 ttdash');
|
|
129
|
+
console.log(' TTDASH_ALLOW_REMOTE=1 HOST=0.0.0.0 ttdash');
|
|
132
130
|
}
|
|
133
131
|
|
|
134
132
|
function parseCliArgs(rawArgs) {
|
|
@@ -290,7 +288,10 @@ const MIME_TYPES = {
|
|
|
290
288
|
};
|
|
291
289
|
|
|
292
290
|
function ensureDir(dirPath) {
|
|
293
|
-
fs.mkdirSync(dirPath, { recursive: true });
|
|
291
|
+
fs.mkdirSync(dirPath, { recursive: true, mode: SECURE_DIR_MODE });
|
|
292
|
+
if (!IS_WINDOWS) {
|
|
293
|
+
fs.chmodSync(dirPath, SECURE_DIR_MODE);
|
|
294
|
+
}
|
|
294
295
|
}
|
|
295
296
|
|
|
296
297
|
function ensureAppDirs() {
|
|
@@ -304,7 +305,12 @@ function ensureAppDirs() {
|
|
|
304
305
|
function writeJsonAtomic(filePath, data) {
|
|
305
306
|
ensureDir(path.dirname(filePath));
|
|
306
307
|
const tempPath = `${filePath}.${process.pid}.${Date.now()}.tmp`;
|
|
307
|
-
fs.writeFileSync(tempPath, JSON.stringify(data, null, 2)
|
|
308
|
+
fs.writeFileSync(tempPath, JSON.stringify(data, null, 2), {
|
|
309
|
+
mode: SECURE_FILE_MODE,
|
|
310
|
+
});
|
|
311
|
+
if (!IS_WINDOWS) {
|
|
312
|
+
fs.chmodSync(tempPath, SECURE_FILE_MODE);
|
|
313
|
+
}
|
|
308
314
|
fs.renameSync(tempPath, filePath);
|
|
309
315
|
}
|
|
310
316
|
|
|
@@ -337,11 +343,12 @@ async function fetchRuntimeIdentity(url, timeoutMs = 1000) {
|
|
|
337
343
|
return null;
|
|
338
344
|
}
|
|
339
345
|
|
|
346
|
+
const runtimePath = `${API_PREFIX.replace(/\/+$/, '')}/runtime`;
|
|
340
347
|
const controller = new AbortController();
|
|
341
348
|
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
|
342
349
|
|
|
343
350
|
try {
|
|
344
|
-
const response = await fetch(new URL(
|
|
351
|
+
const response = await fetch(new URL(runtimePath, `${url}/`), {
|
|
345
352
|
signal: controller.signal,
|
|
346
353
|
});
|
|
347
354
|
|
|
@@ -473,7 +480,7 @@ async function withBackgroundInstancesLock(
|
|
|
473
480
|
|
|
474
481
|
while (true) {
|
|
475
482
|
try {
|
|
476
|
-
fs.mkdirSync(BACKGROUND_INSTANCES_LOCK_DIR);
|
|
483
|
+
fs.mkdirSync(BACKGROUND_INSTANCES_LOCK_DIR, { mode: SECURE_DIR_MODE });
|
|
477
484
|
break;
|
|
478
485
|
} catch (error) {
|
|
479
486
|
if (!error || error.code !== 'EEXIST') {
|
|
@@ -753,11 +760,15 @@ function shouldBackgroundChildOpenBrowser() {
|
|
|
753
760
|
}
|
|
754
761
|
|
|
755
762
|
async function startInBackground() {
|
|
763
|
+
ensureBindHostAllowed(BIND_HOST, ALLOW_REMOTE_BIND);
|
|
756
764
|
ensureAppDirs();
|
|
757
765
|
|
|
758
766
|
const logFile = buildBackgroundLogFilePath();
|
|
759
767
|
const childArgs = NORMALIZED_CLI_ARGS.filter((arg) => arg !== '--background');
|
|
760
|
-
const logFd = fs.openSync(logFile, 'a');
|
|
768
|
+
const logFd = fs.openSync(logFile, 'a', SECURE_FILE_MODE);
|
|
769
|
+
if (!IS_WINDOWS) {
|
|
770
|
+
fs.fchmodSync(logFd, SECURE_FILE_MODE);
|
|
771
|
+
}
|
|
761
772
|
|
|
762
773
|
let child;
|
|
763
774
|
try {
|
|
@@ -846,6 +857,46 @@ function normalizeIsoTimestamp(value) {
|
|
|
846
857
|
return new Date(timestamp).toISOString();
|
|
847
858
|
}
|
|
848
859
|
|
|
860
|
+
function createPersistedStateError(kind, filePath, cause) {
|
|
861
|
+
const label = kind === 'settings' ? 'Settings file' : 'Usage data file';
|
|
862
|
+
const error = new Error(`${label} is unreadable or corrupted.`);
|
|
863
|
+
error.code = 'PERSISTED_STATE_INVALID';
|
|
864
|
+
error.kind = kind;
|
|
865
|
+
error.filePath = filePath;
|
|
866
|
+
error.cause = cause;
|
|
867
|
+
return error;
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
function isPersistedStateError(error, kind) {
|
|
871
|
+
return (
|
|
872
|
+
Boolean(error) &&
|
|
873
|
+
error.code === 'PERSISTED_STATE_INVALID' &&
|
|
874
|
+
(kind ? error.kind === kind : true)
|
|
875
|
+
);
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
function isPayloadTooLargeError(error) {
|
|
879
|
+
return Boolean(error) && error.code === 'PAYLOAD_TOO_LARGE';
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
function readJsonFile(filePath, kind) {
|
|
883
|
+
try {
|
|
884
|
+
return {
|
|
885
|
+
status: 'ok',
|
|
886
|
+
value: JSON.parse(fs.readFileSync(filePath, 'utf-8')),
|
|
887
|
+
};
|
|
888
|
+
} catch (error) {
|
|
889
|
+
if (error && error.code === 'ENOENT') {
|
|
890
|
+
return {
|
|
891
|
+
status: 'missing',
|
|
892
|
+
value: null,
|
|
893
|
+
};
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
throw createPersistedStateError(kind, filePath, error);
|
|
897
|
+
}
|
|
898
|
+
}
|
|
899
|
+
|
|
849
900
|
function sanitizeCurrency(value) {
|
|
850
901
|
if (typeof value !== 'number' || !Number.isFinite(value)) return 0;
|
|
851
902
|
return Math.max(0, Number(value.toFixed(2)));
|
|
@@ -855,6 +906,49 @@ function isPlainObject(value) {
|
|
|
855
906
|
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
|
856
907
|
}
|
|
857
908
|
|
|
909
|
+
function createAutoImportMessageEvent(key, vars = {}) {
|
|
910
|
+
return {
|
|
911
|
+
key,
|
|
912
|
+
vars,
|
|
913
|
+
};
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
function createAutoImportError(message, key, vars = {}) {
|
|
917
|
+
const error = new Error(message);
|
|
918
|
+
error.messageKey = key;
|
|
919
|
+
error.messageVars = vars;
|
|
920
|
+
return error;
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
function toAutoImportErrorEvent(error) {
|
|
924
|
+
if (error && typeof error.messageKey === 'string') {
|
|
925
|
+
return createAutoImportMessageEvent(error.messageKey, error.messageVars || {});
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
return createAutoImportMessageEvent('errorPrefix', {
|
|
929
|
+
message: error && error.message ? error.message : 'Unknown error',
|
|
930
|
+
});
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
function formatAutoImportMessageEvent(event) {
|
|
934
|
+
switch (event?.key) {
|
|
935
|
+
case 'startingLocalImport':
|
|
936
|
+
return 'Starting local toktrack import...';
|
|
937
|
+
case 'loadingUsageData':
|
|
938
|
+
return `Loading usage data via ${event.vars?.command || 'unknown command'}...`;
|
|
939
|
+
case 'processingUsageData':
|
|
940
|
+
return `Processing usage data... (${event.vars?.seconds || 0}s)`;
|
|
941
|
+
case 'autoImportRunning':
|
|
942
|
+
return 'An auto-import is already running. Please wait.';
|
|
943
|
+
case 'noRunnerFound':
|
|
944
|
+
return 'No local toktrack, Bun, or npm exec installation found.';
|
|
945
|
+
case 'errorPrefix':
|
|
946
|
+
return `Error: ${event.vars?.message || 'Unknown error'}`;
|
|
947
|
+
default:
|
|
948
|
+
return 'Auto-import update';
|
|
949
|
+
}
|
|
950
|
+
}
|
|
951
|
+
|
|
858
952
|
function computeUsageTotals(daily) {
|
|
859
953
|
return daily.reduce(
|
|
860
954
|
(totals, day) => ({
|
|
@@ -1191,6 +1285,7 @@ function printStartupSummary(url, port) {
|
|
|
1191
1285
|
const browserMode = shouldOpenBrowser() ? 'enabled' : 'disabled';
|
|
1192
1286
|
const autoLoadMode = CLI_OPTIONS.autoLoad ? 'enabled' : 'disabled';
|
|
1193
1287
|
const runtimeMode = IS_BACKGROUND_CHILD ? 'background' : 'foreground';
|
|
1288
|
+
const remoteBind = !isLoopbackHost(BIND_HOST);
|
|
1194
1289
|
|
|
1195
1290
|
console.log('');
|
|
1196
1291
|
console.log(`${APP_LABEL} v${APP_VERSION} is ready`);
|
|
@@ -1198,6 +1293,9 @@ function printStartupSummary(url, port) {
|
|
|
1198
1293
|
console.log(` API: ${url}/api/usage`);
|
|
1199
1294
|
console.log(` Port: ${port}`);
|
|
1200
1295
|
console.log(` Host: ${BIND_HOST}`);
|
|
1296
|
+
if (remoteBind) {
|
|
1297
|
+
console.log(` Exposure: network-accessible via ${BIND_HOST}`);
|
|
1298
|
+
}
|
|
1201
1299
|
console.log(` Mode: ${runtimeMode}`);
|
|
1202
1300
|
console.log(` Static Root: ${STATIC_ROOT}`);
|
|
1203
1301
|
console.log(` Data File: ${DATA_FILE}`);
|
|
@@ -1208,6 +1306,13 @@ function printStartupSummary(url, port) {
|
|
|
1208
1306
|
console.log(` Data Status: ${describeDataFile()}`);
|
|
1209
1307
|
console.log(` Browser Open: ${browserMode}`);
|
|
1210
1308
|
console.log(` Auto-Load: ${autoLoadMode}`);
|
|
1309
|
+
if (remoteBind) {
|
|
1310
|
+
console.log('');
|
|
1311
|
+
console.log(
|
|
1312
|
+
'Security warning: this bind host can expose local data and destructive API routes.',
|
|
1313
|
+
);
|
|
1314
|
+
console.log('Use non-loopback hosts only on trusted networks.');
|
|
1315
|
+
}
|
|
1211
1316
|
console.log('');
|
|
1212
1317
|
console.log('Available ways to load data:');
|
|
1213
1318
|
console.log(' 1. Start auto-import from the app');
|
|
@@ -1219,6 +1324,7 @@ function printStartupSummary(url, port) {
|
|
|
1219
1324
|
console.log(' ttdash --background');
|
|
1220
1325
|
console.log(' ttdash stop');
|
|
1221
1326
|
console.log(` NO_OPEN_BROWSER=1 PORT=${port} node server.js`);
|
|
1327
|
+
console.log(` TTDASH_ALLOW_REMOTE=1 HOST=${BIND_HOST} PORT=${port} node server.js`);
|
|
1222
1328
|
console.log(` curl ${url}/api/usage`);
|
|
1223
1329
|
console.log('');
|
|
1224
1330
|
}
|
|
@@ -1271,11 +1377,16 @@ function serveFile(res, reqPath) {
|
|
|
1271
1377
|
// --- API helpers ---
|
|
1272
1378
|
|
|
1273
1379
|
function readData() {
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
} catch {
|
|
1380
|
+
const file = readJsonFile(DATA_FILE, 'usage');
|
|
1381
|
+
if (file.status === 'missing') {
|
|
1277
1382
|
return null;
|
|
1278
1383
|
}
|
|
1384
|
+
|
|
1385
|
+
try {
|
|
1386
|
+
return normalizeIncomingData(file.value);
|
|
1387
|
+
} catch (error) {
|
|
1388
|
+
throw createPersistedStateError('usage', DATA_FILE, error);
|
|
1389
|
+
}
|
|
1279
1390
|
}
|
|
1280
1391
|
|
|
1281
1392
|
function writeData(data) {
|
|
@@ -1283,14 +1394,30 @@ function writeData(data) {
|
|
|
1283
1394
|
}
|
|
1284
1395
|
|
|
1285
1396
|
function readSettings() {
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
} catch {
|
|
1397
|
+
const file = readJsonFile(SETTINGS_FILE, 'settings');
|
|
1398
|
+
if (file.status === 'missing') {
|
|
1289
1399
|
return toSettingsResponse({
|
|
1290
1400
|
...DEFAULT_SETTINGS,
|
|
1291
1401
|
providerLimits: {},
|
|
1292
1402
|
});
|
|
1293
1403
|
}
|
|
1404
|
+
|
|
1405
|
+
return toSettingsResponse(file.value);
|
|
1406
|
+
}
|
|
1407
|
+
|
|
1408
|
+
function readSettingsForWrite() {
|
|
1409
|
+
try {
|
|
1410
|
+
return readSettings();
|
|
1411
|
+
} catch (error) {
|
|
1412
|
+
if (isPersistedStateError(error, 'settings')) {
|
|
1413
|
+
return toSettingsResponse({
|
|
1414
|
+
...DEFAULT_SETTINGS,
|
|
1415
|
+
providerLimits: {},
|
|
1416
|
+
});
|
|
1417
|
+
}
|
|
1418
|
+
|
|
1419
|
+
throw error;
|
|
1420
|
+
}
|
|
1294
1421
|
}
|
|
1295
1422
|
|
|
1296
1423
|
function writeSettings(settings) {
|
|
@@ -1298,7 +1425,7 @@ function writeSettings(settings) {
|
|
|
1298
1425
|
}
|
|
1299
1426
|
|
|
1300
1427
|
function updateSettings(patch) {
|
|
1301
|
-
const current =
|
|
1428
|
+
const current = readSettingsForWrite();
|
|
1302
1429
|
const next = {
|
|
1303
1430
|
...current,
|
|
1304
1431
|
...(patch && typeof patch === 'object' ? patch : {}),
|
|
@@ -1318,7 +1445,7 @@ function updateSettings(patch) {
|
|
|
1318
1445
|
}
|
|
1319
1446
|
|
|
1320
1447
|
function recordDataLoad(source) {
|
|
1321
|
-
const current =
|
|
1448
|
+
const current = readSettingsForWrite();
|
|
1322
1449
|
const next = {
|
|
1323
1450
|
...current,
|
|
1324
1451
|
lastLoadedAt: new Date().toISOString(),
|
|
@@ -1330,7 +1457,7 @@ function recordDataLoad(source) {
|
|
|
1330
1457
|
}
|
|
1331
1458
|
|
|
1332
1459
|
function clearDataLoadState() {
|
|
1333
|
-
const current =
|
|
1460
|
+
const current = readSettingsForWrite();
|
|
1334
1461
|
const next = {
|
|
1335
1462
|
...current,
|
|
1336
1463
|
lastLoadedAt: null,
|
|
@@ -1340,63 +1467,11 @@ function clearDataLoadState() {
|
|
|
1340
1467
|
writeSettings(next);
|
|
1341
1468
|
return toSettingsResponse(next);
|
|
1342
1469
|
}
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
req.on('data', (c) => {
|
|
1349
|
-
totalSize += c.length;
|
|
1350
|
-
if (totalSize > MAX_BODY_SIZE) {
|
|
1351
|
-
req.destroy();
|
|
1352
|
-
reject(new Error('Payload too large'));
|
|
1353
|
-
return;
|
|
1354
|
-
}
|
|
1355
|
-
chunks.push(c);
|
|
1356
|
-
});
|
|
1357
|
-
req.on('end', () => {
|
|
1358
|
-
try {
|
|
1359
|
-
resolve(JSON.parse(Buffer.concat(chunks).toString()));
|
|
1360
|
-
} catch (e) {
|
|
1361
|
-
reject(e);
|
|
1362
|
-
}
|
|
1363
|
-
});
|
|
1364
|
-
req.on('error', reject);
|
|
1365
|
-
});
|
|
1366
|
-
}
|
|
1367
|
-
|
|
1368
|
-
function json(res, status, data) {
|
|
1369
|
-
res.writeHead(status, {
|
|
1370
|
-
'Content-Type': 'application/json; charset=utf-8',
|
|
1371
|
-
...SECURITY_HEADERS,
|
|
1372
|
-
});
|
|
1373
|
-
res.end(JSON.stringify(data));
|
|
1374
|
-
}
|
|
1375
|
-
|
|
1376
|
-
function sendBuffer(res, status, headers, buffer) {
|
|
1377
|
-
res.writeHead(status, {
|
|
1378
|
-
'Content-Length': buffer.length,
|
|
1379
|
-
...headers,
|
|
1380
|
-
...SECURITY_HEADERS,
|
|
1381
|
-
});
|
|
1382
|
-
res.end(buffer);
|
|
1383
|
-
}
|
|
1384
|
-
|
|
1385
|
-
function resolveApiPath(pathname) {
|
|
1386
|
-
if (pathname.startsWith(API_PREFIX + '/')) {
|
|
1387
|
-
return pathname.slice(API_PREFIX.length);
|
|
1388
|
-
}
|
|
1389
|
-
if (pathname === API_PREFIX) {
|
|
1390
|
-
return '/';
|
|
1391
|
-
}
|
|
1392
|
-
if (pathname.startsWith('/api/')) {
|
|
1393
|
-
return pathname.slice(4);
|
|
1394
|
-
}
|
|
1395
|
-
if (pathname === '/api') {
|
|
1396
|
-
return '/';
|
|
1397
|
-
}
|
|
1398
|
-
return null;
|
|
1399
|
-
}
|
|
1470
|
+
const { json, readBody, resolveApiPath, sendBuffer, validateMutationRequest } = createHttpUtils({
|
|
1471
|
+
apiPrefix: API_PREFIX,
|
|
1472
|
+
maxBodySize: MAX_BODY_SIZE,
|
|
1473
|
+
securityHeaders: SECURITY_HEADERS,
|
|
1474
|
+
});
|
|
1400
1475
|
|
|
1401
1476
|
// --- SSE helpers ---
|
|
1402
1477
|
|
|
@@ -1406,10 +1481,6 @@ function sendSSE(res, event, data) {
|
|
|
1406
1481
|
|
|
1407
1482
|
let autoImportRunning = false;
|
|
1408
1483
|
|
|
1409
|
-
function shouldUseShell(command) {
|
|
1410
|
-
return IS_WINDOWS && /\.(cmd|bat)$/i.test(command);
|
|
1411
|
-
}
|
|
1412
|
-
|
|
1413
1484
|
function getExecutableName(baseName, isWindows = IS_WINDOWS) {
|
|
1414
1485
|
if (!isWindows) {
|
|
1415
1486
|
return baseName;
|
|
@@ -1427,9 +1498,10 @@ function getExecutableName(baseName, isWindows = IS_WINDOWS) {
|
|
|
1427
1498
|
}
|
|
1428
1499
|
|
|
1429
1500
|
function spawnCommand(command, args, options = {}) {
|
|
1430
|
-
|
|
1501
|
+
// cross-spawn resolves Windows command shims without relying on shell=true,
|
|
1502
|
+
// which avoids the DEP0190 warning from Node's child_process APIs.
|
|
1503
|
+
return spawnCrossPlatform(command, args, {
|
|
1431
1504
|
...options,
|
|
1432
|
-
shell: options.shell ?? shouldUseShell(command),
|
|
1433
1505
|
windowsHide: options.windowsHide ?? true,
|
|
1434
1506
|
});
|
|
1435
1507
|
}
|
|
@@ -1527,24 +1599,30 @@ async function performAutoImport({
|
|
|
1527
1599
|
signalOnClose,
|
|
1528
1600
|
} = {}) {
|
|
1529
1601
|
if (autoImportRunning) {
|
|
1530
|
-
throw
|
|
1602
|
+
throw createAutoImportError(
|
|
1603
|
+
'An auto-import is already running. Please wait.',
|
|
1604
|
+
'autoImportRunning',
|
|
1605
|
+
);
|
|
1531
1606
|
}
|
|
1532
1607
|
|
|
1533
1608
|
autoImportRunning = true;
|
|
1534
1609
|
let progressSeconds = 0;
|
|
1535
1610
|
const progressInterval = setInterval(() => {
|
|
1536
1611
|
progressSeconds += 5;
|
|
1537
|
-
|
|
1612
|
+
onProgress(createAutoImportMessageEvent('processingUsageData', { seconds: progressSeconds }));
|
|
1538
1613
|
}, 5000);
|
|
1539
1614
|
|
|
1540
1615
|
try {
|
|
1541
1616
|
onCheck({ tool: 'toktrack', status: 'checking' });
|
|
1542
|
-
onProgress(
|
|
1617
|
+
onProgress(createAutoImportMessageEvent('startingLocalImport'));
|
|
1543
1618
|
|
|
1544
1619
|
const runner = await resolveToktrackRunner();
|
|
1545
1620
|
if (!runner) {
|
|
1546
1621
|
onCheck({ tool: 'toktrack', status: 'not_found' });
|
|
1547
|
-
throw
|
|
1622
|
+
throw createAutoImportError(
|
|
1623
|
+
'No local toktrack, Bun, or npm exec installation found.',
|
|
1624
|
+
'noRunnerFound',
|
|
1625
|
+
);
|
|
1548
1626
|
}
|
|
1549
1627
|
|
|
1550
1628
|
const versionResult = await runToktrack(runner, ['--version']);
|
|
@@ -1554,7 +1632,11 @@ async function performAutoImport({
|
|
|
1554
1632
|
method: runner.label,
|
|
1555
1633
|
version: String(versionResult).replace(/^toktrack\s+/, ''),
|
|
1556
1634
|
});
|
|
1557
|
-
onProgress(
|
|
1635
|
+
onProgress(
|
|
1636
|
+
createAutoImportMessageEvent('loadingUsageData', {
|
|
1637
|
+
command: runner.displayCommand,
|
|
1638
|
+
}),
|
|
1639
|
+
);
|
|
1558
1640
|
|
|
1559
1641
|
const rawJson = await runToktrack(runner, ['daily', '--json'], {
|
|
1560
1642
|
streamStderr: true,
|
|
@@ -1590,7 +1672,7 @@ async function runStartupAutoLoad({ source = 'cli-auto-load' } = {}) {
|
|
|
1590
1672
|
}
|
|
1591
1673
|
},
|
|
1592
1674
|
onProgress: (event) => {
|
|
1593
|
-
console.log(event
|
|
1675
|
+
console.log(formatAutoImportMessageEvent(event));
|
|
1594
1676
|
},
|
|
1595
1677
|
onOutput: (line) => {
|
|
1596
1678
|
console.log(line);
|
|
@@ -1610,15 +1692,34 @@ async function runStartupAutoLoad({ source = 'cli-auto-load' } = {}) {
|
|
|
1610
1692
|
// --- Server ---
|
|
1611
1693
|
|
|
1612
1694
|
const server = http.createServer(async (req, res) => {
|
|
1613
|
-
|
|
1614
|
-
|
|
1695
|
+
let url;
|
|
1696
|
+
let pathname;
|
|
1697
|
+
|
|
1698
|
+
try {
|
|
1699
|
+
url = new URL(req.url, 'http://localhost');
|
|
1700
|
+
pathname = decodeURIComponent(url.pathname);
|
|
1701
|
+
} catch {
|
|
1702
|
+
return json(res, 400, { message: 'Invalid request path' });
|
|
1703
|
+
}
|
|
1615
1704
|
|
|
1616
1705
|
// API routing
|
|
1617
1706
|
const apiPath = resolveApiPath(pathname);
|
|
1618
1707
|
|
|
1708
|
+
if (apiPath === null && (pathname === '/api' || pathname.startsWith('/api/'))) {
|
|
1709
|
+
return json(res, 404, { message: 'Not Found' });
|
|
1710
|
+
}
|
|
1711
|
+
|
|
1619
1712
|
if (apiPath === '/usage') {
|
|
1620
1713
|
if (req.method === 'GET') {
|
|
1621
|
-
|
|
1714
|
+
let data;
|
|
1715
|
+
try {
|
|
1716
|
+
data = readData();
|
|
1717
|
+
} catch (error) {
|
|
1718
|
+
if (isPersistedStateError(error, 'usage')) {
|
|
1719
|
+
return json(res, 500, { message: error.message });
|
|
1720
|
+
}
|
|
1721
|
+
throw error;
|
|
1722
|
+
}
|
|
1622
1723
|
return json(
|
|
1623
1724
|
res,
|
|
1624
1725
|
200,
|
|
@@ -1638,6 +1739,10 @@ const server = http.createServer(async (req, res) => {
|
|
|
1638
1739
|
);
|
|
1639
1740
|
}
|
|
1640
1741
|
if (req.method === 'DELETE') {
|
|
1742
|
+
const validationError = validateMutationRequest(req);
|
|
1743
|
+
if (validationError) {
|
|
1744
|
+
return json(res, validationError.status, { message: validationError.message });
|
|
1745
|
+
}
|
|
1641
1746
|
try {
|
|
1642
1747
|
fs.unlinkSync(DATA_FILE);
|
|
1643
1748
|
} catch {
|
|
@@ -1666,10 +1771,21 @@ const server = http.createServer(async (req, res) => {
|
|
|
1666
1771
|
|
|
1667
1772
|
if (apiPath === '/settings') {
|
|
1668
1773
|
if (req.method === 'GET') {
|
|
1669
|
-
|
|
1774
|
+
try {
|
|
1775
|
+
return json(res, 200, readSettings());
|
|
1776
|
+
} catch (error) {
|
|
1777
|
+
if (isPersistedStateError(error, 'settings')) {
|
|
1778
|
+
return json(res, 500, { message: error.message });
|
|
1779
|
+
}
|
|
1780
|
+
throw error;
|
|
1781
|
+
}
|
|
1670
1782
|
}
|
|
1671
1783
|
|
|
1672
1784
|
if (req.method === 'DELETE') {
|
|
1785
|
+
const validationError = validateMutationRequest(req);
|
|
1786
|
+
if (validationError) {
|
|
1787
|
+
return json(res, validationError.status, { message: validationError.message });
|
|
1788
|
+
}
|
|
1673
1789
|
try {
|
|
1674
1790
|
fs.unlinkSync(SETTINGS_FILE);
|
|
1675
1791
|
} catch {
|
|
@@ -1679,10 +1795,17 @@ const server = http.createServer(async (req, res) => {
|
|
|
1679
1795
|
}
|
|
1680
1796
|
|
|
1681
1797
|
if (req.method === 'PATCH') {
|
|
1798
|
+
const validationError = validateMutationRequest(req, { requiresJsonContentType: true });
|
|
1799
|
+
if (validationError) {
|
|
1800
|
+
return json(res, validationError.status, { message: validationError.message });
|
|
1801
|
+
}
|
|
1682
1802
|
try {
|
|
1683
1803
|
const body = await readBody(req);
|
|
1684
1804
|
return json(res, 200, updateSettings(body));
|
|
1685
1805
|
} catch (e) {
|
|
1806
|
+
if (isPayloadTooLargeError(e)) {
|
|
1807
|
+
return json(res, 413, { message: 'Settings request too large' });
|
|
1808
|
+
}
|
|
1686
1809
|
return json(res, 400, { message: e.message || 'Invalid settings request' });
|
|
1687
1810
|
}
|
|
1688
1811
|
}
|
|
@@ -1695,18 +1818,31 @@ const server = http.createServer(async (req, res) => {
|
|
|
1695
1818
|
return json(res, 405, { message: 'Method Not Allowed' });
|
|
1696
1819
|
}
|
|
1697
1820
|
|
|
1821
|
+
const validationError = validateMutationRequest(req, { requiresJsonContentType: true });
|
|
1822
|
+
if (validationError) {
|
|
1823
|
+
return json(res, validationError.status, { message: validationError.message });
|
|
1824
|
+
}
|
|
1825
|
+
|
|
1698
1826
|
try {
|
|
1699
1827
|
const body = await readBody(req);
|
|
1700
1828
|
const importedSettings = normalizeSettings(extractSettingsImportPayload(body));
|
|
1701
1829
|
writeSettings(importedSettings);
|
|
1702
1830
|
return json(res, 200, toSettingsResponse(importedSettings));
|
|
1703
1831
|
} catch (e) {
|
|
1832
|
+
if (isPayloadTooLargeError(e)) {
|
|
1833
|
+
return json(res, 413, { message: 'Settings file too large' });
|
|
1834
|
+
}
|
|
1704
1835
|
return json(res, 400, { message: e.message || 'Invalid settings file' });
|
|
1705
1836
|
}
|
|
1706
1837
|
}
|
|
1707
1838
|
|
|
1708
1839
|
if (apiPath === '/upload') {
|
|
1709
1840
|
if (req.method === 'POST') {
|
|
1841
|
+
const validationError = validateMutationRequest(req, { requiresJsonContentType: true });
|
|
1842
|
+
if (validationError) {
|
|
1843
|
+
return json(res, validationError.status, { message: validationError.message });
|
|
1844
|
+
}
|
|
1845
|
+
|
|
1710
1846
|
try {
|
|
1711
1847
|
const body = await readBody(req);
|
|
1712
1848
|
const normalized = normalizeIncomingData(body);
|
|
@@ -1716,11 +1852,10 @@ const server = http.createServer(async (req, res) => {
|
|
|
1716
1852
|
const totalCost = normalized.totals.totalCost;
|
|
1717
1853
|
return json(res, 200, { days, totalCost });
|
|
1718
1854
|
} catch (e) {
|
|
1719
|
-
const status = e
|
|
1720
|
-
const message =
|
|
1721
|
-
|
|
1722
|
-
|
|
1723
|
-
: e.message || 'Invalid JSON';
|
|
1855
|
+
const status = isPayloadTooLargeError(e) ? 413 : 400;
|
|
1856
|
+
const message = isPayloadTooLargeError(e)
|
|
1857
|
+
? 'File too large (max. 10 MB)'
|
|
1858
|
+
: e.message || 'Invalid JSON';
|
|
1724
1859
|
return json(res, status, { message });
|
|
1725
1860
|
}
|
|
1726
1861
|
}
|
|
@@ -1732,6 +1867,11 @@ const server = http.createServer(async (req, res) => {
|
|
|
1732
1867
|
return json(res, 405, { message: 'Method Not Allowed' });
|
|
1733
1868
|
}
|
|
1734
1869
|
|
|
1870
|
+
const validationError = validateMutationRequest(req, { requiresJsonContentType: true });
|
|
1871
|
+
if (validationError) {
|
|
1872
|
+
return json(res, validationError.status, { message: validationError.message });
|
|
1873
|
+
}
|
|
1874
|
+
|
|
1735
1875
|
try {
|
|
1736
1876
|
const body = await readBody(req);
|
|
1737
1877
|
const importedData = normalizeIncomingData(extractUsageImportPayload(body));
|
|
@@ -1741,15 +1881,26 @@ const server = http.createServer(async (req, res) => {
|
|
|
1741
1881
|
recordDataLoad('file');
|
|
1742
1882
|
return json(res, 200, result.summary);
|
|
1743
1883
|
} catch (e) {
|
|
1884
|
+
if (isPayloadTooLargeError(e)) {
|
|
1885
|
+
return json(res, 413, { message: 'Usage backup file too large' });
|
|
1886
|
+
}
|
|
1887
|
+
if (isPersistedStateError(e, 'usage')) {
|
|
1888
|
+
return json(res, 500, { message: e.message });
|
|
1889
|
+
}
|
|
1744
1890
|
return json(res, 400, { message: e.message || 'Invalid usage backup file' });
|
|
1745
1891
|
}
|
|
1746
1892
|
}
|
|
1747
1893
|
|
|
1748
1894
|
if (apiPath === '/auto-import/stream') {
|
|
1749
|
-
if (req.method !== '
|
|
1895
|
+
if (req.method !== 'POST') {
|
|
1750
1896
|
return json(res, 405, { message: 'Method Not Allowed' });
|
|
1751
1897
|
}
|
|
1752
1898
|
|
|
1899
|
+
const validationError = validateMutationRequest(req);
|
|
1900
|
+
if (validationError) {
|
|
1901
|
+
return json(res, validationError.status, { message: validationError.message });
|
|
1902
|
+
}
|
|
1903
|
+
|
|
1753
1904
|
res.writeHead(200, {
|
|
1754
1905
|
'Content-Type': 'text/event-stream',
|
|
1755
1906
|
'Cache-Control': 'no-cache',
|
|
@@ -1797,7 +1948,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
1797
1948
|
if (aborted) {
|
|
1798
1949
|
return;
|
|
1799
1950
|
}
|
|
1800
|
-
sendSSE(res, 'error',
|
|
1951
|
+
sendSSE(res, 'error', toAutoImportErrorEvent(err));
|
|
1801
1952
|
sendSSE(res, 'done', {});
|
|
1802
1953
|
res.end();
|
|
1803
1954
|
}
|
|
@@ -1809,7 +1960,20 @@ const server = http.createServer(async (req, res) => {
|
|
|
1809
1960
|
return json(res, 405, { message: 'Method Not Allowed' });
|
|
1810
1961
|
}
|
|
1811
1962
|
|
|
1812
|
-
const
|
|
1963
|
+
const validationError = validateMutationRequest(req, { requiresJsonContentType: true });
|
|
1964
|
+
if (validationError) {
|
|
1965
|
+
return json(res, validationError.status, { message: validationError.message });
|
|
1966
|
+
}
|
|
1967
|
+
|
|
1968
|
+
let data;
|
|
1969
|
+
try {
|
|
1970
|
+
data = readData();
|
|
1971
|
+
} catch (error) {
|
|
1972
|
+
if (isPersistedStateError(error, 'usage')) {
|
|
1973
|
+
return json(res, 500, { message: error.message });
|
|
1974
|
+
}
|
|
1975
|
+
throw error;
|
|
1976
|
+
}
|
|
1813
1977
|
if (!data || !Array.isArray(data.daily) || data.daily.length === 0) {
|
|
1814
1978
|
return json(res, 400, { message: 'No data available for the report.' });
|
|
1815
1979
|
}
|
|
@@ -1818,10 +1982,9 @@ const server = http.createServer(async (req, res) => {
|
|
|
1818
1982
|
try {
|
|
1819
1983
|
body = await readBody(req);
|
|
1820
1984
|
} catch (e) {
|
|
1821
|
-
const status = e
|
|
1985
|
+
const status = isPayloadTooLargeError(e) ? 413 : 400;
|
|
1822
1986
|
return json(res, status, {
|
|
1823
|
-
message:
|
|
1824
|
-
e.message === 'Payload too large' ? 'Report request too large' : 'Invalid report request',
|
|
1987
|
+
message: isPayloadTooLargeError(e) ? 'Report request too large' : 'Invalid report request',
|
|
1825
1988
|
});
|
|
1826
1989
|
}
|
|
1827
1990
|
|
|
@@ -1861,61 +2024,12 @@ const server = http.createServer(async (req, res) => {
|
|
|
1861
2024
|
serveFile(res, filePath);
|
|
1862
2025
|
});
|
|
1863
2026
|
|
|
1864
|
-
function createNoFreePortError(rangeStartPort, maxPort) {
|
|
1865
|
-
return new Error(`No free port found (${rangeStartPort}-${maxPort})`);
|
|
1866
|
-
}
|
|
1867
|
-
|
|
1868
|
-
async function listenOnAvailablePort(
|
|
1869
|
-
serverInstance,
|
|
1870
|
-
port,
|
|
1871
|
-
maxPort,
|
|
1872
|
-
bindHost,
|
|
1873
|
-
log = console.log,
|
|
1874
|
-
rangeStartPort = port,
|
|
1875
|
-
) {
|
|
1876
|
-
if (port > maxPort) {
|
|
1877
|
-
throw createNoFreePortError(rangeStartPort, maxPort);
|
|
1878
|
-
}
|
|
1879
|
-
|
|
1880
|
-
for (let currentPort = port; currentPort <= maxPort; currentPort += 1) {
|
|
1881
|
-
try {
|
|
1882
|
-
await new Promise((resolve, reject) => {
|
|
1883
|
-
const onError = (err) => {
|
|
1884
|
-
serverInstance.off('listening', onListening);
|
|
1885
|
-
reject(err);
|
|
1886
|
-
};
|
|
1887
|
-
|
|
1888
|
-
const onListening = () => {
|
|
1889
|
-
serverInstance.off('error', onError);
|
|
1890
|
-
resolve();
|
|
1891
|
-
};
|
|
1892
|
-
|
|
1893
|
-
serverInstance.once('error', onError);
|
|
1894
|
-
serverInstance.once('listening', onListening);
|
|
1895
|
-
serverInstance.listen(currentPort, bindHost);
|
|
1896
|
-
});
|
|
1897
|
-
|
|
1898
|
-
return currentPort;
|
|
1899
|
-
} catch (err) {
|
|
1900
|
-
if (err && err.code === 'EADDRINUSE') {
|
|
1901
|
-
if (currentPort >= maxPort) {
|
|
1902
|
-
throw createNoFreePortError(rangeStartPort, maxPort);
|
|
1903
|
-
}
|
|
1904
|
-
log(`Port ${currentPort} is in use, trying ${currentPort + 1}...`);
|
|
1905
|
-
continue;
|
|
1906
|
-
}
|
|
1907
|
-
throw err;
|
|
1908
|
-
}
|
|
1909
|
-
}
|
|
1910
|
-
|
|
1911
|
-
throw createNoFreePortError(rangeStartPort, maxPort);
|
|
1912
|
-
}
|
|
1913
|
-
|
|
1914
2027
|
function tryListen(port) {
|
|
1915
2028
|
return listenOnAvailablePort(server, port, MAX_PORT, BIND_HOST, console.log, START_PORT);
|
|
1916
2029
|
}
|
|
1917
2030
|
|
|
1918
2031
|
async function start() {
|
|
2032
|
+
ensureBindHostAllowed(BIND_HOST, ALLOW_REMOTE_BIND);
|
|
1919
2033
|
ensureAppDirs();
|
|
1920
2034
|
migrateLegacyDataFile();
|
|
1921
2035
|
|
|
@@ -1979,6 +2093,7 @@ module.exports = {
|
|
|
1979
2093
|
bootstrapCli,
|
|
1980
2094
|
runCli,
|
|
1981
2095
|
__test__: {
|
|
2096
|
+
commandExists,
|
|
1982
2097
|
getExecutableName,
|
|
1983
2098
|
listenOnAvailablePort,
|
|
1984
2099
|
},
|