sqlite-hub 0.9.3 → 0.9.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/.github/workflows/ci.yml +36 -0
- package/README.md +2 -2
- package/bin/sqlite-hub.js +1 -1
- package/frontend/index.html +2 -158
- package/frontend/js/app.js +41 -22
- package/frontend/js/components/connectionCard.js +62 -87
- package/frontend/js/components/emptyState.js +20 -23
- package/frontend/js/components/modal.js +145 -195
- package/frontend/js/components/pageHeader.js +1 -1
- package/frontend/js/components/queryEditor.js +16 -30
- package/frontend/js/components/queryHistoryDetail.js +93 -164
- package/frontend/js/components/queryHistoryPanel.js +81 -99
- package/frontend/js/components/queryResults.js +3 -1
- package/frontend/js/components/rowEditorPanel.js +28 -31
- package/frontend/js/components/structureGraph.js +10 -9
- package/frontend/js/components/tableDesignerEditor.js +91 -116
- package/frontend/js/store.js +39 -3
- package/frontend/js/utils/dom.js +28 -0
- package/frontend/js/utils/tableDesigner.js +8 -2
- package/frontend/js/views/charts.js +23 -43
- package/frontend/js/views/data.js +116 -132
- package/frontend/js/views/mediaTagging.js +131 -164
- package/frontend/js/views/structure.js +52 -48
- package/frontend/styles/tailwind.css +80 -0
- package/frontend/styles/tailwind.generated.css +2 -0
- package/frontend/styles/tokens.css +3 -3
- package/package.json +19 -5
- package/server/routes/mediaTagging.js +2 -10
- package/server/routes/sql.js +35 -10
- package/server/server.js +24 -0
- package/server/services/sqlite/dataBrowserService.js +25 -5
- package/server/services/sqlite/exportService.js +4 -2
- package/server/services/sqlite/introspection.js +2 -2
- package/server/services/sqlite/mediaTaggingService.js +166 -53
- package/server/services/sqlite/structureService.js +2 -2
- package/server/services/sqlite/tableDesigner/sql.js +19 -3
- package/server/services/storage/appStateStore.js +227 -87
- package/server/utils/appPaths.js +55 -19
- package/server/utils/fileValidation.js +94 -8
- package/tailwind.config.cjs +73 -0
- package/tests/security-paths.test.js +84 -0
- package/tests/sql-identifier-safety.test.js +66 -0
- package/.npmingnore +0 -4
- package/changelog.md +0 -84
- package/docs/DESIGN_GUIDELINES.md +0 -36
- package/scripts/publish_brew.sh +0 -466
- package/scripts/publish_npm.sh +0 -241
- package/shortkeys.md +0 -5
|
@@ -1,7 +1,12 @@
|
|
|
1
1
|
const fs = require("node:fs");
|
|
2
2
|
const path = require("node:path");
|
|
3
|
+
const { pathToFileURL } = require("node:url");
|
|
3
4
|
const Database = require("better-sqlite3");
|
|
4
5
|
const { ConflictError, NotFoundError, ValidationError } = require("../../utils/errors");
|
|
6
|
+
const {
|
|
7
|
+
resolvePathInsideDirectory,
|
|
8
|
+
resolveUserPath,
|
|
9
|
+
} = require("../../utils/fileValidation");
|
|
5
10
|
const {
|
|
6
11
|
buildAutoTitle,
|
|
7
12
|
buildSqlPreview,
|
|
@@ -33,6 +38,8 @@ const DEFAULT_STATE = {
|
|
|
33
38
|
};
|
|
34
39
|
|
|
35
40
|
const CONNECTION_LOGO_DIRECTORY = "db_logos";
|
|
41
|
+
const LEGACY_STATE_FILENAME = "app-state.json";
|
|
42
|
+
const STATE_DATABASE_FILENAME = "sqlite-hub-state.db";
|
|
36
43
|
const MAX_CONNECTION_LOGO_SIZE_BYTES = 5 * 1024 * 1024;
|
|
37
44
|
const CONNECTION_LOGO_EXTENSION_BY_MIME_TYPE = {
|
|
38
45
|
"image/jpeg": "jpg",
|
|
@@ -107,17 +114,81 @@ function isChartCompatibleQuery(queryType, rawSql = "") {
|
|
|
107
114
|
);
|
|
108
115
|
}
|
|
109
116
|
|
|
117
|
+
function normalizeStateStorePath(filePath, label) {
|
|
118
|
+
return resolveUserPath(filePath, { label });
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function normalizeLegacyFilePath(filePath, { currentFilePath, expectedFileName, label }) {
|
|
122
|
+
if (!filePath) {
|
|
123
|
+
return null;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const resolvedPath = normalizeStateStorePath(filePath, label);
|
|
127
|
+
|
|
128
|
+
if (resolvedPath === currentFilePath || path.basename(resolvedPath) !== expectedFileName) {
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return resolvedPath;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function normalizeLegacyDatabasePaths(legacyDatabasePaths, currentFilePath) {
|
|
136
|
+
if (!Array.isArray(legacyDatabasePaths)) {
|
|
137
|
+
return [];
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const normalizedPaths = legacyDatabasePaths
|
|
141
|
+
.map((legacyPath) =>
|
|
142
|
+
normalizeLegacyFilePath(legacyPath, {
|
|
143
|
+
currentFilePath,
|
|
144
|
+
expectedFileName: STATE_DATABASE_FILENAME,
|
|
145
|
+
label: "Legacy app state database path",
|
|
146
|
+
})
|
|
147
|
+
)
|
|
148
|
+
.filter(Boolean);
|
|
149
|
+
|
|
150
|
+
return normalizedPaths.filter(
|
|
151
|
+
(legacyPath, index, legacyPaths) => legacyPaths.indexOf(legacyPath) === index
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function readUtf8File(fileUrl) {
|
|
156
|
+
const fileHandle = fs.openSync(fileUrl, "r");
|
|
157
|
+
|
|
158
|
+
try {
|
|
159
|
+
const fileSize = fs.fstatSync(fileHandle).size;
|
|
160
|
+
const buffer = Buffer.alloc(fileSize);
|
|
161
|
+
fs.readSync(fileHandle, buffer, 0, buffer.length, 0);
|
|
162
|
+
return buffer.toString("utf8");
|
|
163
|
+
} finally {
|
|
164
|
+
fs.closeSync(fileHandle);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
110
168
|
class AppStateStore {
|
|
111
169
|
constructor(filePath, options = {}) {
|
|
112
|
-
this.filePath = filePath;
|
|
113
|
-
this.
|
|
114
|
-
this.
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
170
|
+
this.filePath = normalizeStateStorePath(filePath, "App state database path");
|
|
171
|
+
this.stateDirectory = path.dirname(this.filePath);
|
|
172
|
+
this.logoDirectory = resolvePathInsideDirectory(
|
|
173
|
+
this.stateDirectory,
|
|
174
|
+
CONNECTION_LOGO_DIRECTORY,
|
|
175
|
+
"Connection logo directory"
|
|
176
|
+
);
|
|
177
|
+
this.legacyFilePath = normalizeLegacyFilePath(options.legacyFilePath, {
|
|
178
|
+
currentFilePath: this.filePath,
|
|
179
|
+
expectedFileName: LEGACY_STATE_FILENAME,
|
|
180
|
+
label: "Legacy app state path",
|
|
181
|
+
});
|
|
182
|
+
this.legacyFileUrl = this.legacyFilePath
|
|
183
|
+
? pathToFileURL(this.legacyFilePath)
|
|
184
|
+
: null;
|
|
185
|
+
this.legacyDatabasePaths = normalizeLegacyDatabasePaths(
|
|
186
|
+
options.legacyDatabasePaths,
|
|
187
|
+
this.filePath
|
|
188
|
+
);
|
|
189
|
+
this.isFreshDatabase = !fs.existsSync(this.filePath);
|
|
190
|
+
|
|
191
|
+
fs.mkdirSync(this.stateDirectory, { recursive: true });
|
|
121
192
|
fs.mkdirSync(this.logoDirectory, { recursive: true });
|
|
122
193
|
|
|
123
194
|
this.db = new Database(this.filePath);
|
|
@@ -313,7 +384,7 @@ class AppStateStore {
|
|
|
313
384
|
}
|
|
314
385
|
|
|
315
386
|
readLegacyState() {
|
|
316
|
-
const raw =
|
|
387
|
+
const raw = readUtf8File(this.legacyFileUrl);
|
|
317
388
|
const parsed = JSON.parse(raw);
|
|
318
389
|
|
|
319
390
|
return {
|
|
@@ -328,11 +399,9 @@ class AppStateStore {
|
|
|
328
399
|
|
|
329
400
|
getExistingLegacyDatabasePaths() {
|
|
330
401
|
return this.legacyDatabasePaths
|
|
331
|
-
.filter(Boolean)
|
|
332
|
-
.map((legacyPath) => path.resolve(legacyPath))
|
|
333
402
|
.filter(
|
|
334
403
|
(legacyPath, index, legacyPaths) =>
|
|
335
|
-
legacyPath !==
|
|
404
|
+
legacyPath !== this.filePath &&
|
|
336
405
|
legacyPaths.indexOf(legacyPath) === index &&
|
|
337
406
|
fs.existsSync(legacyPath)
|
|
338
407
|
);
|
|
@@ -361,6 +430,9 @@ class AppStateStore {
|
|
|
361
430
|
.map((column) => column.name)
|
|
362
431
|
)
|
|
363
432
|
: new Set();
|
|
433
|
+
const logoPathSelection = recentConnectionColumns.has("logoPath")
|
|
434
|
+
? "logoPath"
|
|
435
|
+
: "NULL AS logoPath";
|
|
364
436
|
|
|
365
437
|
return {
|
|
366
438
|
settings: tables.has("settings")
|
|
@@ -373,19 +445,23 @@ class AppStateStore {
|
|
|
373
445
|
: {},
|
|
374
446
|
recentConnections: tables.has("recent_connections")
|
|
375
447
|
? legacyDb
|
|
376
|
-
.prepare(
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
448
|
+
.prepare(
|
|
449
|
+
[
|
|
450
|
+
"SELECT",
|
|
451
|
+
[
|
|
452
|
+
"id",
|
|
453
|
+
"label",
|
|
454
|
+
"path",
|
|
455
|
+
"lastOpenedAt",
|
|
456
|
+
"lastModifiedAt",
|
|
457
|
+
"sizeBytes",
|
|
458
|
+
"readOnly",
|
|
459
|
+
logoPathSelection,
|
|
460
|
+
].join(", "),
|
|
461
|
+
"FROM recent_connections",
|
|
462
|
+
"ORDER BY lastOpenedAt DESC, id ASC",
|
|
463
|
+
].join(" ")
|
|
464
|
+
)
|
|
389
465
|
.all()
|
|
390
466
|
: [],
|
|
391
467
|
sqlHistory: tables.has("sql_history")
|
|
@@ -939,8 +1015,8 @@ class AppStateStore {
|
|
|
939
1015
|
onlyFavorites,
|
|
940
1016
|
latestStatus,
|
|
941
1017
|
});
|
|
942
|
-
const
|
|
943
|
-
|
|
1018
|
+
const queryHistoryRowsSql = [
|
|
1019
|
+
`
|
|
944
1020
|
SELECT
|
|
945
1021
|
q.id,
|
|
946
1022
|
q.database_key,
|
|
@@ -963,20 +1039,24 @@ class AppStateStore {
|
|
|
963
1039
|
latest.status AS last_run_status,
|
|
964
1040
|
latest.error_message AS last_run_error_message,
|
|
965
1041
|
latest.affected_rows AS last_run_affected_rows
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
1042
|
+
`,
|
|
1043
|
+
baseFromSql,
|
|
1044
|
+
whereSql,
|
|
1045
|
+
orderBySql,
|
|
1046
|
+
"LIMIT ?",
|
|
1047
|
+
"OFFSET ?",
|
|
1048
|
+
].join("\n");
|
|
1049
|
+
const rows = this.db
|
|
1050
|
+
.prepare(queryHistoryRowsSql)
|
|
972
1051
|
.all(...params, normalizedLimit, normalizedOffset)
|
|
973
1052
|
.map((row) => this.decorateQueryHistoryRow(row));
|
|
1053
|
+
const queryHistoryCountSql = [
|
|
1054
|
+
"SELECT COUNT(*) AS count",
|
|
1055
|
+
baseFromSql,
|
|
1056
|
+
whereSql,
|
|
1057
|
+
].join("\n");
|
|
974
1058
|
const countRow = this.db
|
|
975
|
-
.prepare(
|
|
976
|
-
SELECT COUNT(*) AS count
|
|
977
|
-
${baseFromSql}
|
|
978
|
-
${whereSql}
|
|
979
|
-
`)
|
|
1059
|
+
.prepare(queryHistoryCountSql)
|
|
980
1060
|
.get(...params);
|
|
981
1061
|
const total = Number(countRow?.count ?? 0);
|
|
982
1062
|
|
|
@@ -1263,7 +1343,14 @@ class AppStateStore {
|
|
|
1263
1343
|
.filter((item) => item.chartsEligible);
|
|
1264
1344
|
}
|
|
1265
1345
|
|
|
1266
|
-
getQueryHistoryItemById(historyId) {
|
|
1346
|
+
getQueryHistoryItemById(historyId, databaseKey) {
|
|
1347
|
+
const normalizedDatabaseKey = this.normalizeQueryHistoryText(databaseKey);
|
|
1348
|
+
const tenantId = normalizedDatabaseKey;
|
|
1349
|
+
|
|
1350
|
+
if (!tenantId) {
|
|
1351
|
+
throw new ValidationError("Query history lookup requires a database key.");
|
|
1352
|
+
}
|
|
1353
|
+
|
|
1267
1354
|
const row = this.db
|
|
1268
1355
|
.prepare(`
|
|
1269
1356
|
SELECT
|
|
@@ -1289,6 +1376,7 @@ class AppStateStore {
|
|
|
1289
1376
|
latest.error_message AS last_run_error_message,
|
|
1290
1377
|
latest.affected_rows AS last_run_affected_rows
|
|
1291
1378
|
FROM query_history q
|
|
1379
|
+
-- tenantId scope is enforced on q.database_key for direct id lookups.
|
|
1292
1380
|
LEFT JOIN query_runs latest
|
|
1293
1381
|
ON latest.id = (
|
|
1294
1382
|
SELECT runs.id
|
|
@@ -1298,8 +1386,9 @@ class AppStateStore {
|
|
|
1298
1386
|
LIMIT 1
|
|
1299
1387
|
)
|
|
1300
1388
|
WHERE q.id = ?
|
|
1389
|
+
AND q.database_key = ?
|
|
1301
1390
|
`)
|
|
1302
|
-
.get(Number(historyId));
|
|
1391
|
+
.get(Number(historyId), tenantId);
|
|
1303
1392
|
|
|
1304
1393
|
if (!row) {
|
|
1305
1394
|
throw new NotFoundError(`Query history item not found: ${historyId}`);
|
|
@@ -1357,14 +1446,7 @@ class AppStateStore {
|
|
|
1357
1446
|
}
|
|
1358
1447
|
|
|
1359
1448
|
getQueryHistoryItemForDatabase(historyId, databaseKey) {
|
|
1360
|
-
|
|
1361
|
-
const normalizedDatabaseKey = this.normalizeQueryHistoryText(databaseKey);
|
|
1362
|
-
|
|
1363
|
-
if (normalizedDatabaseKey && item.databaseKey !== normalizedDatabaseKey) {
|
|
1364
|
-
throw new NotFoundError(`Query history item not found: ${historyId}`);
|
|
1365
|
-
}
|
|
1366
|
-
|
|
1367
|
-
return item;
|
|
1449
|
+
return this.getQueryHistoryItemById(historyId, databaseKey);
|
|
1368
1450
|
}
|
|
1369
1451
|
|
|
1370
1452
|
getChartQueryHistoryItemForDatabase(historyId, databaseKey) {
|
|
@@ -1377,8 +1459,9 @@ class AppStateStore {
|
|
|
1377
1459
|
return item;
|
|
1378
1460
|
}
|
|
1379
1461
|
|
|
1380
|
-
getQueryRunsByHistoryId(historyId, limit = 8) {
|
|
1462
|
+
getQueryRunsByHistoryId(historyId, limit = 8, databaseKey) {
|
|
1381
1463
|
const normalizedLimit = Math.max(1, Math.min(50, Number(limit) || 8));
|
|
1464
|
+
const item = this.getQueryHistoryItemById(historyId, databaseKey);
|
|
1382
1465
|
|
|
1383
1466
|
return this.db
|
|
1384
1467
|
.prepare(`
|
|
@@ -1396,7 +1479,7 @@ class AppStateStore {
|
|
|
1396
1479
|
ORDER BY executed_at DESC, id DESC
|
|
1397
1480
|
LIMIT ?
|
|
1398
1481
|
`)
|
|
1399
|
-
.all(
|
|
1482
|
+
.all(item.id, normalizedLimit)
|
|
1400
1483
|
.map((row) => this.decorateQueryRun(row));
|
|
1401
1484
|
}
|
|
1402
1485
|
|
|
@@ -1489,16 +1572,40 @@ class AppStateStore {
|
|
|
1489
1572
|
};
|
|
1490
1573
|
}
|
|
1491
1574
|
|
|
1492
|
-
updateQueryHistoryField(historyId, fieldName, value) {
|
|
1493
|
-
const
|
|
1494
|
-
|
|
1495
|
-
|
|
1575
|
+
updateQueryHistoryField(historyId, fieldName, value, databaseKey) {
|
|
1576
|
+
const normalizedDatabaseKey = this.normalizeQueryHistoryText(databaseKey);
|
|
1577
|
+
|
|
1578
|
+
if (!normalizedDatabaseKey) {
|
|
1579
|
+
throw new ValidationError("Query history update requires a database key.");
|
|
1580
|
+
}
|
|
1581
|
+
|
|
1582
|
+
const statements = {
|
|
1583
|
+
is_favorite: this.db.prepare(
|
|
1584
|
+
"UPDATE query_history SET is_favorite = ? WHERE id = ? AND database_key = ?"
|
|
1585
|
+
),
|
|
1586
|
+
is_saved: this.db.prepare(
|
|
1587
|
+
"UPDATE query_history SET is_saved = ? WHERE id = ? AND database_key = ?"
|
|
1588
|
+
),
|
|
1589
|
+
title: this.db.prepare(
|
|
1590
|
+
"UPDATE query_history SET title = ? WHERE id = ? AND database_key = ?"
|
|
1591
|
+
),
|
|
1592
|
+
notes: this.db.prepare(
|
|
1593
|
+
"UPDATE query_history SET notes = ? WHERE id = ? AND database_key = ?"
|
|
1594
|
+
),
|
|
1595
|
+
};
|
|
1596
|
+
const statement = statements[fieldName];
|
|
1597
|
+
|
|
1598
|
+
if (!statement) {
|
|
1599
|
+
throw new ValidationError(`Query history field cannot be updated: ${fieldName}`);
|
|
1600
|
+
}
|
|
1601
|
+
|
|
1602
|
+
const result = statement.run(value, Number(historyId), normalizedDatabaseKey);
|
|
1496
1603
|
|
|
1497
1604
|
if (!result.changes) {
|
|
1498
1605
|
throw new NotFoundError(`Query history item not found: ${historyId}`);
|
|
1499
1606
|
}
|
|
1500
1607
|
|
|
1501
|
-
return this.getQueryHistoryItemById(historyId);
|
|
1608
|
+
return this.getQueryHistoryItemById(historyId, normalizedDatabaseKey);
|
|
1502
1609
|
}
|
|
1503
1610
|
|
|
1504
1611
|
resolveUniqueQueryHistoryChartName(queryHistoryId, candidateName, { excludeChartId = null } = {}) {
|
|
@@ -1507,15 +1614,20 @@ class AppStateStore {
|
|
|
1507
1614
|
let suffix = 2;
|
|
1508
1615
|
|
|
1509
1616
|
while (true) {
|
|
1617
|
+
const excludeChartClause = excludeChartId ? "AND id != ?" : "";
|
|
1510
1618
|
const row = this.db
|
|
1511
|
-
.prepare(
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1619
|
+
.prepare(
|
|
1620
|
+
[
|
|
1621
|
+
"SELECT id",
|
|
1622
|
+
"FROM query_history_chart",
|
|
1623
|
+
"WHERE query_history_id = ?",
|
|
1624
|
+
"AND name = ?",
|
|
1625
|
+
excludeChartClause,
|
|
1626
|
+
"LIMIT 1",
|
|
1627
|
+
]
|
|
1628
|
+
.filter(Boolean)
|
|
1629
|
+
.join(" ")
|
|
1630
|
+
)
|
|
1519
1631
|
.get(
|
|
1520
1632
|
Number(queryHistoryId),
|
|
1521
1633
|
nextName,
|
|
@@ -1653,34 +1765,42 @@ class AppStateStore {
|
|
|
1653
1765
|
return true;
|
|
1654
1766
|
}
|
|
1655
1767
|
|
|
1656
|
-
toggleFavorite(historyId, nextValue) {
|
|
1657
|
-
return this.updateQueryHistoryField(historyId, "is_favorite", nextValue ? 1 : 0);
|
|
1768
|
+
toggleFavorite(historyId, nextValue, databaseKey) {
|
|
1769
|
+
return this.updateQueryHistoryField(historyId, "is_favorite", nextValue ? 1 : 0, databaseKey);
|
|
1658
1770
|
}
|
|
1659
1771
|
|
|
1660
|
-
toggleSaved(historyId, nextValue) {
|
|
1661
|
-
return this.updateQueryHistoryField(historyId, "is_saved", nextValue ? 1 : 0);
|
|
1772
|
+
toggleSaved(historyId, nextValue, databaseKey) {
|
|
1773
|
+
return this.updateQueryHistoryField(historyId, "is_saved", nextValue ? 1 : 0, databaseKey);
|
|
1662
1774
|
}
|
|
1663
1775
|
|
|
1664
|
-
renameQuery(historyId, title) {
|
|
1776
|
+
renameQuery(historyId, title, databaseKey) {
|
|
1665
1777
|
return this.updateQueryHistoryField(
|
|
1666
1778
|
historyId,
|
|
1667
1779
|
"title",
|
|
1668
|
-
this.normalizeQueryHistoryText(title)
|
|
1780
|
+
this.normalizeQueryHistoryText(title),
|
|
1781
|
+
databaseKey
|
|
1669
1782
|
);
|
|
1670
1783
|
}
|
|
1671
1784
|
|
|
1672
|
-
updateQueryNotes(historyId, notes) {
|
|
1785
|
+
updateQueryNotes(historyId, notes, databaseKey) {
|
|
1673
1786
|
return this.updateQueryHistoryField(
|
|
1674
1787
|
historyId,
|
|
1675
1788
|
"notes",
|
|
1676
|
-
this.normalizeQueryHistoryText(notes)
|
|
1789
|
+
this.normalizeQueryHistoryText(notes),
|
|
1790
|
+
databaseKey
|
|
1677
1791
|
);
|
|
1678
1792
|
}
|
|
1679
1793
|
|
|
1680
|
-
deleteQueryHistoryItem(historyId) {
|
|
1794
|
+
deleteQueryHistoryItem(historyId, databaseKey) {
|
|
1795
|
+
const normalizedDatabaseKey = this.normalizeQueryHistoryText(databaseKey);
|
|
1796
|
+
|
|
1797
|
+
if (!normalizedDatabaseKey) {
|
|
1798
|
+
throw new ValidationError("Query history delete requires a database key.");
|
|
1799
|
+
}
|
|
1800
|
+
|
|
1681
1801
|
const result = this.db
|
|
1682
|
-
.prepare("DELETE FROM query_history WHERE id = ?")
|
|
1683
|
-
.run(Number(historyId));
|
|
1802
|
+
.prepare("DELETE FROM query_history WHERE id = ? AND database_key = ?")
|
|
1803
|
+
.run(Number(historyId), normalizedDatabaseKey);
|
|
1684
1804
|
|
|
1685
1805
|
if (!result.changes) {
|
|
1686
1806
|
throw new NotFoundError(`Query history item not found: ${historyId}`);
|
|
@@ -1938,8 +2058,13 @@ class AppStateStore {
|
|
|
1938
2058
|
|
|
1939
2059
|
const safeConnectionId = String(connectionId ?? "connection").replace(/[^a-zA-Z0-9_-]/g, "_");
|
|
1940
2060
|
const storedFileName = `${safeConnectionId}-${Date.now()}.${extension}`;
|
|
2061
|
+
const logoFilePath = resolvePathInsideDirectory(
|
|
2062
|
+
this.logoDirectory,
|
|
2063
|
+
storedFileName,
|
|
2064
|
+
"Connection logo path"
|
|
2065
|
+
);
|
|
1941
2066
|
|
|
1942
|
-
fs.writeFileSync(
|
|
2067
|
+
fs.writeFileSync(logoFilePath, buffer);
|
|
1943
2068
|
|
|
1944
2069
|
return storedFileName;
|
|
1945
2070
|
}
|
|
@@ -1951,7 +2076,14 @@ class AppStateStore {
|
|
|
1951
2076
|
return;
|
|
1952
2077
|
}
|
|
1953
2078
|
|
|
1954
|
-
fs.rmSync(
|
|
2079
|
+
fs.rmSync(
|
|
2080
|
+
resolvePathInsideDirectory(
|
|
2081
|
+
this.logoDirectory,
|
|
2082
|
+
normalizedLogoPath,
|
|
2083
|
+
"Connection logo path"
|
|
2084
|
+
),
|
|
2085
|
+
{ force: true }
|
|
2086
|
+
);
|
|
1955
2087
|
}
|
|
1956
2088
|
|
|
1957
2089
|
decorateConnection(connection = {}) {
|
|
@@ -1972,7 +2104,13 @@ class AppStateStore {
|
|
|
1972
2104
|
return null;
|
|
1973
2105
|
}
|
|
1974
2106
|
|
|
1975
|
-
|
|
2107
|
+
const baseName = path.basename(trimmedLogoPath);
|
|
2108
|
+
|
|
2109
|
+
if (baseName !== trimmedLogoPath || baseName === "." || baseName === "..") {
|
|
2110
|
+
return null;
|
|
2111
|
+
}
|
|
2112
|
+
|
|
2113
|
+
return baseName;
|
|
1976
2114
|
}
|
|
1977
2115
|
|
|
1978
2116
|
setActiveConnectionId(id) {
|
|
@@ -2142,7 +2280,7 @@ class AppStateStore {
|
|
|
2142
2280
|
...MEDIA_TAGGING_CONFIG_FIELDS.map((field) => field.column),
|
|
2143
2281
|
];
|
|
2144
2282
|
const assignments = MEDIA_TAGGING_CONFIG_FIELDS.map(
|
|
2145
|
-
(field) =>
|
|
2283
|
+
(field) => field.column + " = excluded." + field.column
|
|
2146
2284
|
);
|
|
2147
2285
|
|
|
2148
2286
|
if (this.mediaTaggingConfigHasLegacyJsonColumn) {
|
|
@@ -2155,15 +2293,17 @@ class AppStateStore {
|
|
|
2155
2293
|
assignments.push("updated_at = excluded.updated_at");
|
|
2156
2294
|
values.push(updatedAt);
|
|
2157
2295
|
|
|
2296
|
+
const upsertSql = [
|
|
2297
|
+
"INSERT INTO media_tagging_config",
|
|
2298
|
+
"(" + explicitColumns.join(", ") + ")",
|
|
2299
|
+
"VALUES",
|
|
2300
|
+
"(" + explicitColumns.map(() => "?").join(", ") + ")",
|
|
2301
|
+
"ON CONFLICT(database_key) DO UPDATE SET",
|
|
2302
|
+
assignments.join(", "),
|
|
2303
|
+
].join(" ");
|
|
2304
|
+
|
|
2158
2305
|
this.db
|
|
2159
|
-
.prepare(
|
|
2160
|
-
`
|
|
2161
|
-
INSERT INTO media_tagging_config (${explicitColumns.join(", ")})
|
|
2162
|
-
VALUES (${explicitColumns.map(() => "?").join(", ")})
|
|
2163
|
-
ON CONFLICT(database_key) DO UPDATE SET
|
|
2164
|
-
${assignments.join(",\n ")}
|
|
2165
|
-
`
|
|
2166
|
-
)
|
|
2306
|
+
.prepare(upsertSql)
|
|
2167
2307
|
.run(...values);
|
|
2168
2308
|
|
|
2169
2309
|
return this.getMediaTaggingConfig(normalizedDatabaseKey);
|
package/server/utils/appPaths.js
CHANGED
|
@@ -1,10 +1,24 @@
|
|
|
1
1
|
const fs = require("node:fs");
|
|
2
2
|
const os = require("node:os");
|
|
3
3
|
const path = require("node:path");
|
|
4
|
+
const {
|
|
5
|
+
assertSafePathInput,
|
|
6
|
+
resolvePathInsideDirectory,
|
|
7
|
+
} = require("./fileValidation");
|
|
4
8
|
|
|
5
9
|
const APP_NAME = "sqlite-hub";
|
|
6
10
|
const APP_STATE_DB_FILENAME = "sqlite-hub-state.db";
|
|
7
11
|
const LEGACY_STATE_FILENAME = "app-state.json";
|
|
12
|
+
const SAFE_HOMEBREW_SEGMENT_PATTERN = /^[a-zA-Z0-9._+-]+$/;
|
|
13
|
+
|
|
14
|
+
function isSafeHomebrewSegment(segment) {
|
|
15
|
+
return (
|
|
16
|
+
typeof segment === "string" &&
|
|
17
|
+
SAFE_HOMEBREW_SEGMENT_PATTERN.test(segment) &&
|
|
18
|
+
segment !== "." &&
|
|
19
|
+
segment !== ".."
|
|
20
|
+
);
|
|
21
|
+
}
|
|
8
22
|
|
|
9
23
|
function resolvePackagedDataDirectories(packageRoot) {
|
|
10
24
|
return [
|
|
@@ -51,7 +65,7 @@ function resolvePackagedLegacyStatePath(packageRoot) {
|
|
|
51
65
|
}
|
|
52
66
|
|
|
53
67
|
function resolveHomebrewCellarInfo(packageRoot) {
|
|
54
|
-
const resolvedPackageRoot = path.resolve(packageRoot);
|
|
68
|
+
const resolvedPackageRoot = path.resolve(assertSafePathInput(packageRoot, "Package root"));
|
|
55
69
|
const { root } = path.parse(resolvedPackageRoot);
|
|
56
70
|
const relativeSegments = resolvedPackageRoot
|
|
57
71
|
.slice(root.length)
|
|
@@ -66,16 +80,44 @@ function resolveHomebrewCellarInfo(packageRoot) {
|
|
|
66
80
|
const formulaName = relativeSegments[cellarIndex + 1];
|
|
67
81
|
const currentVersion = relativeSegments[cellarIndex + 2];
|
|
68
82
|
|
|
69
|
-
if (
|
|
83
|
+
if (
|
|
84
|
+
formulaName !== APP_NAME ||
|
|
85
|
+
!isSafeHomebrewSegment(currentVersion) ||
|
|
86
|
+
!isSafeHomebrewSegment(formulaName)
|
|
87
|
+
) {
|
|
70
88
|
return null;
|
|
71
89
|
}
|
|
72
90
|
|
|
73
91
|
return {
|
|
74
|
-
cellarRoot: path.
|
|
92
|
+
cellarRoot: path.resolve(root, ...relativeSegments.slice(0, cellarIndex + 2)),
|
|
75
93
|
currentVersion,
|
|
76
94
|
};
|
|
77
95
|
}
|
|
78
96
|
|
|
97
|
+
function resolveLegacyStateDbPath(cellarRoot, versionName, dataSegments) {
|
|
98
|
+
const versionRoot = resolvePathInsideDirectory(
|
|
99
|
+
cellarRoot,
|
|
100
|
+
versionName,
|
|
101
|
+
"Homebrew version directory"
|
|
102
|
+
);
|
|
103
|
+
const packagedDataSegments = Array.isArray(dataSegments)
|
|
104
|
+
? dataSegments
|
|
105
|
+
: [dataSegments];
|
|
106
|
+
|
|
107
|
+
return resolvePathInsideDirectory(
|
|
108
|
+
versionRoot,
|
|
109
|
+
path.join(
|
|
110
|
+
"libexec",
|
|
111
|
+
"lib",
|
|
112
|
+
"node_modules",
|
|
113
|
+
APP_NAME,
|
|
114
|
+
...packagedDataSegments,
|
|
115
|
+
APP_STATE_DB_FILENAME
|
|
116
|
+
),
|
|
117
|
+
"Legacy app state database path"
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
|
|
79
121
|
function safeStatMtimeMs(filePath) {
|
|
80
122
|
try {
|
|
81
123
|
return fs.statSync(filePath).mtimeMs;
|
|
@@ -93,29 +135,23 @@ function collectHomebrewLegacyStateDbPaths(packageRoot) {
|
|
|
93
135
|
|
|
94
136
|
return fs
|
|
95
137
|
.readdirSync(cellarInfo.cellarRoot, { withFileTypes: true })
|
|
96
|
-
.filter(
|
|
138
|
+
.filter(
|
|
139
|
+
(entry) =>
|
|
140
|
+
entry.isDirectory() &&
|
|
141
|
+
entry.name !== cellarInfo.currentVersion &&
|
|
142
|
+
isSafeHomebrewSegment(entry.name)
|
|
143
|
+
)
|
|
97
144
|
.flatMap((entry) =>
|
|
98
145
|
[
|
|
99
|
-
|
|
146
|
+
resolveLegacyStateDbPath(
|
|
100
147
|
cellarInfo.cellarRoot,
|
|
101
148
|
entry.name,
|
|
102
|
-
"
|
|
103
|
-
"lib",
|
|
104
|
-
"node_modules",
|
|
105
|
-
APP_NAME,
|
|
106
|
-
"server",
|
|
107
|
-
"data",
|
|
108
|
-
APP_STATE_DB_FILENAME
|
|
149
|
+
["server", "data"]
|
|
109
150
|
),
|
|
110
|
-
|
|
151
|
+
resolveLegacyStateDbPath(
|
|
111
152
|
cellarInfo.cellarRoot,
|
|
112
153
|
entry.name,
|
|
113
|
-
"
|
|
114
|
-
"lib",
|
|
115
|
-
"node_modules",
|
|
116
|
-
APP_NAME,
|
|
117
|
-
"data",
|
|
118
|
-
APP_STATE_DB_FILENAME
|
|
154
|
+
["data"]
|
|
119
155
|
),
|
|
120
156
|
].map((candidatePath) => ({
|
|
121
157
|
path: candidatePath,
|