sqlite-hub 0.17.2 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +143 -47
- package/bin/sqlite-hub.js +206 -444
- package/examples/api/queries.js +31 -0
- package/examples/api/rows.js +27 -0
- package/frontend/assets/mockups/charts_1_bars_1200.webp +0 -0
- package/frontend/assets/mockups/charts_2_pie_1200.webp +0 -0
- package/frontend/assets/mockups/charts_3_scatter_plot_1200.webp +0 -0
- package/frontend/assets/mockups/connections_1200.webp +0 -0
- package/frontend/assets/mockups/data_1_1200.webp +0 -0
- package/frontend/assets/mockups/data_2_row_editor_1200.webp +0 -0
- package/frontend/assets/mockups/documents_1200.webp +0 -0
- package/frontend/assets/mockups/media_tagging_1_setup_1200.webp +0 -0
- package/frontend/assets/mockups/media_tagging_2_tagging_queue_1200.webp +0 -0
- package/frontend/assets/mockups/media_tagging_3_media_viewer_1200.webp +0 -0
- package/frontend/assets/mockups/overview_1200.webp +0 -0
- package/frontend/assets/mockups/settings_1200.webp +0 -0
- package/frontend/assets/mockups/sql_editor_1_1200.webp +0 -0
- package/frontend/assets/mockups/sql_editor_2_query_details_1200.webp +0 -0
- package/frontend/assets/mockups/sql_editor_3_export_column_1200.webp +0 -0
- package/frontend/assets/mockups/sql_editor_4_export_column_as_markdown_1200.webp +0 -0
- package/frontend/assets/mockups/sql_editor_5_export_query_result_1200.webp +0 -0
- package/frontend/assets/mockups/structure_1_1200.webp +0 -0
- package/frontend/assets/mockups/structure_2_inspector_1200.webp +0 -0
- package/frontend/assets/mockups/table_designer_1_1200.webp +0 -0
- package/frontend/assets/mockups/table_designer_2_checks_1200.webp +0 -0
- package/frontend/js/api.js +25 -0
- package/frontend/js/app.js +263 -34
- package/frontend/js/components/formControls.js +38 -0
- package/frontend/js/components/modal.js +77 -21
- package/frontend/js/components/queryChartRenderer.js +28 -3
- package/frontend/js/components/queryEditor.js +20 -24
- package/frontend/js/components/queryHistoryDetail.js +1 -1
- package/frontend/js/components/queryHistoryHeader.js +48 -0
- package/frontend/js/components/queryHistoryList.js +132 -0
- package/frontend/js/components/queryHistoryPanel.js +72 -136
- package/frontend/js/components/structureGraph.js +699 -89
- package/frontend/js/components/tableDesignerEditor.js +3 -5
- package/frontend/js/store.js +188 -7
- package/frontend/js/utils/exportFilenames.js +3 -1
- package/frontend/js/views/charts.js +320 -169
- package/frontend/js/views/connections.js +59 -64
- package/frontend/js/views/data.js +12 -20
- package/frontend/js/views/editor.js +0 -2
- package/frontend/js/views/settings.js +219 -5
- package/frontend/js/views/structure.js +27 -13
- package/frontend/styles/components.css +155 -0
- package/frontend/styles/structure-graph.css +140 -35
- package/frontend/styles/tailwind.generated.css +2 -2
- package/frontend/styles/views.css +12 -6
- package/package.json +7 -6
- package/server/middleware/apiTokenAuth.js +30 -0
- package/server/routes/connections.js +17 -0
- package/server/routes/export.js +89 -22
- package/server/routes/externalApi.js +304 -0
- package/server/routes/settings.js +90 -21
- package/server/server.js +22 -1
- package/server/services/apiTokenService.js +101 -0
- package/server/services/appInfoService.js +215 -0
- package/server/services/databaseCommandService.js +443 -0
- package/server/services/nativeFileDialogService.js +93 -1
- package/server/services/sqlite/exportService.js +307 -22
- package/server/services/storage/appStateStore.js +113 -0
- package/server/utils/errors.js +7 -0
- package/tests/api-token-auth.test.js +236 -0
- package/tests/cli-args.test.js +16 -3
- package/tests/cli-service-delegation.test.js +43 -0
- package/tests/connections-file-dialog-route.test.js +43 -0
- package/tests/copy-column-modal.test.js +34 -0
- package/tests/database-command-service.test.js +139 -0
- package/tests/export-blob.test.js +54 -1
- package/tests/export-filenames.test.js +4 -0
- package/tests/form-controls.test.js +34 -0
- package/tests/native-file-dialog.test.js +27 -0
- package/tests/settings-api-tokens-route.test.js +97 -0
- package/tests/settings-metadata.test.js +99 -1
- package/tests/settings-view.test.js +75 -0
- package/frontend/assets/mockups/connections.png +0 -0
- package/frontend/assets/mockups/data.png +0 -0
- package/frontend/assets/mockups/data_row_editor.png +0 -0
- package/frontend/assets/mockups/home.png +0 -0
- package/frontend/assets/mockups/sql_editor.png +0 -0
- package/frontend/assets/mockups/sql_editor_croped.png +0 -0
- package/frontend/assets/mockups/sql_editor_querydetail.png +0 -0
- package/frontend/assets/mockups/structure.png +0 -0
- package/frontend/assets/mockups/structure_inspector.png +0 -0
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
const express = require("express");
|
|
2
|
+
const { createApiTokenAuth } = require("../middleware/apiTokenAuth");
|
|
3
|
+
const { readBearerToken } = require("../middleware/apiTokenAuth");
|
|
4
|
+
const { AuthenticationError, ValidationError, route, successResponse } = require("../utils/errors");
|
|
5
|
+
const { buildAppInfo } = require("../services/appInfoService");
|
|
6
|
+
|
|
7
|
+
function buildRequestBaseUrl(req) {
|
|
8
|
+
const host = req.get("host") ?? `127.0.0.1:${req.socket.localPort ?? ""}`;
|
|
9
|
+
return `${req.protocol}://${host}`;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function readDatabaseId(req) {
|
|
13
|
+
return String(req.body?.databaseId ?? req.query.databaseId ?? "").trim();
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function readSqlText(req) {
|
|
17
|
+
return String(req.body?.sql ?? req.body?.query ?? req.query.sql ?? req.query.query ?? "");
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function readStoreName(req) {
|
|
21
|
+
return String(
|
|
22
|
+
req.body?.store ??
|
|
23
|
+
req.body?.storeName ??
|
|
24
|
+
req.body?.name ??
|
|
25
|
+
req.query.store ??
|
|
26
|
+
req.query.storeName ??
|
|
27
|
+
req.query.name ??
|
|
28
|
+
""
|
|
29
|
+
).trim();
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function authenticateDatabaseRequest(req, tokenService, databaseId) {
|
|
33
|
+
const token = readBearerToken(req.get("authorization"));
|
|
34
|
+
|
|
35
|
+
if (!token) {
|
|
36
|
+
throw new AuthenticationError("Bearer API token is required.", {
|
|
37
|
+
code: "API_TOKEN_REQUIRED",
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return tokenService.authenticate(databaseId, token);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function createExternalApiRouter({ databaseService, tokenService, appInfoService = buildAppInfo }) {
|
|
45
|
+
const router = express.Router();
|
|
46
|
+
|
|
47
|
+
router.get(
|
|
48
|
+
"/info",
|
|
49
|
+
route(async (req, res) => {
|
|
50
|
+
const port = Number(req.socket.localPort);
|
|
51
|
+
const data = await appInfoService({
|
|
52
|
+
port: Number.isInteger(port) ? port : null,
|
|
53
|
+
url: buildRequestBaseUrl(req),
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
res.json(successResponse({ data }));
|
|
57
|
+
})
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
router.post(
|
|
61
|
+
"/query",
|
|
62
|
+
route((req, res) => {
|
|
63
|
+
const databaseId = readDatabaseId(req);
|
|
64
|
+
const sql = readSqlText(req);
|
|
65
|
+
const storeName = readStoreName(req);
|
|
66
|
+
|
|
67
|
+
if (!databaseId) {
|
|
68
|
+
throw new ValidationError("databaseId is required.");
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
authenticateDatabaseRequest(req, tokenService, databaseId);
|
|
72
|
+
|
|
73
|
+
const { result } = databaseService.executeRawQuery(databaseId, sql, { storeName });
|
|
74
|
+
|
|
75
|
+
res.json(
|
|
76
|
+
successResponse({
|
|
77
|
+
message: "SQL executed successfully.",
|
|
78
|
+
data: result,
|
|
79
|
+
metadata: {
|
|
80
|
+
databaseId,
|
|
81
|
+
stored: Boolean(result.storedQuery),
|
|
82
|
+
},
|
|
83
|
+
timingMs: result.timingMs,
|
|
84
|
+
readOnly: false,
|
|
85
|
+
})
|
|
86
|
+
);
|
|
87
|
+
})
|
|
88
|
+
);
|
|
89
|
+
|
|
90
|
+
router.use(
|
|
91
|
+
"/databases/:databaseId",
|
|
92
|
+
createApiTokenAuth({ tokenService })
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
router.get(
|
|
96
|
+
"/databases/:databaseId",
|
|
97
|
+
route((req, res) => {
|
|
98
|
+
res.json(
|
|
99
|
+
successResponse({
|
|
100
|
+
data: databaseService.getDatabase(req.params.databaseId),
|
|
101
|
+
metadata: { databaseId: req.params.databaseId },
|
|
102
|
+
})
|
|
103
|
+
);
|
|
104
|
+
})
|
|
105
|
+
);
|
|
106
|
+
|
|
107
|
+
router.get(
|
|
108
|
+
"/databases/:databaseId/tables",
|
|
109
|
+
route((req, res) => {
|
|
110
|
+
res.json(
|
|
111
|
+
successResponse({
|
|
112
|
+
data: { items: databaseService.listTables(req.params.databaseId) },
|
|
113
|
+
metadata: { databaseId: req.params.databaseId },
|
|
114
|
+
})
|
|
115
|
+
);
|
|
116
|
+
})
|
|
117
|
+
);
|
|
118
|
+
|
|
119
|
+
router.get(
|
|
120
|
+
"/databases/:databaseId/tables/:tableName",
|
|
121
|
+
route((req, res) => {
|
|
122
|
+
res.json(
|
|
123
|
+
successResponse({
|
|
124
|
+
data: databaseService.getTable(req.params.databaseId, req.params.tableName),
|
|
125
|
+
metadata: { databaseId: req.params.databaseId },
|
|
126
|
+
})
|
|
127
|
+
);
|
|
128
|
+
})
|
|
129
|
+
);
|
|
130
|
+
|
|
131
|
+
router.post(
|
|
132
|
+
"/databases/:databaseId/tables/:tableName/row",
|
|
133
|
+
route((req, res) => {
|
|
134
|
+
const result = databaseService.getTableRow(
|
|
135
|
+
req.params.databaseId,
|
|
136
|
+
req.params.tableName,
|
|
137
|
+
req.body?.key
|
|
138
|
+
);
|
|
139
|
+
|
|
140
|
+
res.json(
|
|
141
|
+
successResponse({
|
|
142
|
+
data: result.data,
|
|
143
|
+
metadata: {
|
|
144
|
+
databaseId: req.params.databaseId,
|
|
145
|
+
filename: result.filename,
|
|
146
|
+
identity: result.identity,
|
|
147
|
+
tableName: result.table.name,
|
|
148
|
+
},
|
|
149
|
+
})
|
|
150
|
+
);
|
|
151
|
+
})
|
|
152
|
+
);
|
|
153
|
+
|
|
154
|
+
router.get(
|
|
155
|
+
"/databases/:databaseId/queries",
|
|
156
|
+
route((req, res) => {
|
|
157
|
+
res.json(
|
|
158
|
+
successResponse({
|
|
159
|
+
data: databaseService.listSavedQueries(req.params.databaseId),
|
|
160
|
+
metadata: { databaseId: req.params.databaseId },
|
|
161
|
+
})
|
|
162
|
+
);
|
|
163
|
+
})
|
|
164
|
+
);
|
|
165
|
+
|
|
166
|
+
router.post(
|
|
167
|
+
"/databases/:databaseId/queries/:queryName/execute",
|
|
168
|
+
route((req, res) => {
|
|
169
|
+
const { query, result } = databaseService.executeSavedQuery(
|
|
170
|
+
req.params.databaseId,
|
|
171
|
+
req.params.queryName
|
|
172
|
+
);
|
|
173
|
+
|
|
174
|
+
res.json(
|
|
175
|
+
successResponse({
|
|
176
|
+
message: "Saved query executed successfully.",
|
|
177
|
+
data: result,
|
|
178
|
+
metadata: {
|
|
179
|
+
databaseId: req.params.databaseId,
|
|
180
|
+
query,
|
|
181
|
+
},
|
|
182
|
+
timingMs: result.timingMs,
|
|
183
|
+
})
|
|
184
|
+
);
|
|
185
|
+
})
|
|
186
|
+
);
|
|
187
|
+
|
|
188
|
+
router.get(
|
|
189
|
+
"/databases/:databaseId/queries/:queryName/export",
|
|
190
|
+
route((req, res) => {
|
|
191
|
+
const { query, result } = databaseService.exportSavedQuery(
|
|
192
|
+
req.params.databaseId,
|
|
193
|
+
req.params.queryName,
|
|
194
|
+
req.query.format || "csv"
|
|
195
|
+
);
|
|
196
|
+
|
|
197
|
+
res.json(
|
|
198
|
+
successResponse({
|
|
199
|
+
data: {
|
|
200
|
+
filename: result.filename,
|
|
201
|
+
content: result.content ?? result.csv ?? "",
|
|
202
|
+
format: result.format,
|
|
203
|
+
mimeType: result.mimeType,
|
|
204
|
+
columns: result.columns,
|
|
205
|
+
rowCount: result.rowCount,
|
|
206
|
+
},
|
|
207
|
+
metadata: {
|
|
208
|
+
databaseId: req.params.databaseId,
|
|
209
|
+
query,
|
|
210
|
+
},
|
|
211
|
+
})
|
|
212
|
+
);
|
|
213
|
+
})
|
|
214
|
+
);
|
|
215
|
+
|
|
216
|
+
router.get(
|
|
217
|
+
"/databases/:databaseId/queries/:queryName/notes",
|
|
218
|
+
route((req, res) => {
|
|
219
|
+
const query = databaseService.getSavedQuery(
|
|
220
|
+
req.params.databaseId,
|
|
221
|
+
req.params.queryName
|
|
222
|
+
);
|
|
223
|
+
|
|
224
|
+
res.json(
|
|
225
|
+
successResponse({
|
|
226
|
+
data: { notes: String(query.notes ?? "") },
|
|
227
|
+
metadata: { databaseId: req.params.databaseId, query },
|
|
228
|
+
})
|
|
229
|
+
);
|
|
230
|
+
})
|
|
231
|
+
);
|
|
232
|
+
|
|
233
|
+
router.get(
|
|
234
|
+
"/databases/:databaseId/queries/:queryName",
|
|
235
|
+
route((req, res) => {
|
|
236
|
+
res.json(
|
|
237
|
+
successResponse({
|
|
238
|
+
data: databaseService.getSavedQuery(
|
|
239
|
+
req.params.databaseId,
|
|
240
|
+
req.params.queryName
|
|
241
|
+
),
|
|
242
|
+
metadata: { databaseId: req.params.databaseId },
|
|
243
|
+
})
|
|
244
|
+
);
|
|
245
|
+
})
|
|
246
|
+
);
|
|
247
|
+
|
|
248
|
+
router.get(
|
|
249
|
+
"/databases/:databaseId/documents",
|
|
250
|
+
route((req, res) => {
|
|
251
|
+
res.json(
|
|
252
|
+
successResponse({
|
|
253
|
+
data: { items: databaseService.listDocuments(req.params.databaseId) },
|
|
254
|
+
metadata: { databaseId: req.params.databaseId },
|
|
255
|
+
})
|
|
256
|
+
);
|
|
257
|
+
})
|
|
258
|
+
);
|
|
259
|
+
|
|
260
|
+
router.get(
|
|
261
|
+
"/databases/:databaseId/documents/:documentName/export",
|
|
262
|
+
route((req, res) => {
|
|
263
|
+
const result = databaseService.exportDocument(
|
|
264
|
+
req.params.databaseId,
|
|
265
|
+
req.params.documentName
|
|
266
|
+
);
|
|
267
|
+
|
|
268
|
+
res.json(
|
|
269
|
+
successResponse({
|
|
270
|
+
data: {
|
|
271
|
+
filename: result.filename,
|
|
272
|
+
content: result.content,
|
|
273
|
+
mimeType: result.mimeType,
|
|
274
|
+
},
|
|
275
|
+
metadata: {
|
|
276
|
+
databaseId: req.params.databaseId,
|
|
277
|
+
document: result.document,
|
|
278
|
+
},
|
|
279
|
+
})
|
|
280
|
+
);
|
|
281
|
+
})
|
|
282
|
+
);
|
|
283
|
+
|
|
284
|
+
router.get(
|
|
285
|
+
"/databases/:databaseId/documents/:documentName",
|
|
286
|
+
route((req, res) => {
|
|
287
|
+
res.json(
|
|
288
|
+
successResponse({
|
|
289
|
+
data: databaseService.getDocument(
|
|
290
|
+
req.params.databaseId,
|
|
291
|
+
req.params.documentName
|
|
292
|
+
),
|
|
293
|
+
metadata: { databaseId: req.params.databaseId },
|
|
294
|
+
})
|
|
295
|
+
);
|
|
296
|
+
})
|
|
297
|
+
);
|
|
298
|
+
|
|
299
|
+
return router;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
module.exports = {
|
|
303
|
+
createExternalApiRouter,
|
|
304
|
+
};
|
|
@@ -1,34 +1,43 @@
|
|
|
1
1
|
const express = require("express");
|
|
2
|
-
const
|
|
3
|
-
const
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
2
|
+
const { AppError, DatabaseRequiredError, route, successResponse } = require("../utils/errors");
|
|
3
|
+
const {
|
|
4
|
+
checkLatestAppVersion,
|
|
5
|
+
compareSemver,
|
|
6
|
+
isNewerVersion,
|
|
7
|
+
readSettingsMetadata,
|
|
8
|
+
readSqliteVersion,
|
|
9
|
+
} = require("../services/appInfoService");
|
|
10
|
+
|
|
11
|
+
function getActiveTokenContext({ connectionManager, tokenService }) {
|
|
12
|
+
const activeDatabase = connectionManager?.getActiveConnection?.() ?? null;
|
|
13
|
+
|
|
14
|
+
return {
|
|
15
|
+
activeDatabase,
|
|
16
|
+
apiTokens: activeDatabase && tokenService ? tokenService.listTokens(activeDatabase.id) : [],
|
|
17
|
+
};
|
|
11
18
|
}
|
|
12
19
|
|
|
13
|
-
function
|
|
14
|
-
const
|
|
20
|
+
function requireActiveDatabase(connectionManager) {
|
|
21
|
+
const activeDatabase = connectionManager?.getActiveConnection?.() ?? null;
|
|
15
22
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
} finally {
|
|
19
|
-
db.close();
|
|
23
|
+
if (!activeDatabase) {
|
|
24
|
+
throw new DatabaseRequiredError("Select a database before managing API tokens.");
|
|
20
25
|
}
|
|
26
|
+
|
|
27
|
+
return activeDatabase;
|
|
21
28
|
}
|
|
22
29
|
|
|
23
|
-
function
|
|
30
|
+
function buildSettingsMetadata(context) {
|
|
24
31
|
return {
|
|
25
|
-
|
|
26
|
-
|
|
32
|
+
...readSettingsMetadata(),
|
|
33
|
+
...getActiveTokenContext(context),
|
|
27
34
|
};
|
|
28
35
|
}
|
|
29
36
|
|
|
30
|
-
function createSettingsRouter({ appStateStore }) {
|
|
37
|
+
function createSettingsRouter({ appStateStore, connectionManager, tokenService, versionCheckService }) {
|
|
31
38
|
const router = express.Router();
|
|
39
|
+
const context = { connectionManager, tokenService };
|
|
40
|
+
const checkVersion = versionCheckService ?? checkLatestAppVersion;
|
|
32
41
|
|
|
33
42
|
router.get(
|
|
34
43
|
"/",
|
|
@@ -36,7 +45,7 @@ function createSettingsRouter({ appStateStore }) {
|
|
|
36
45
|
res.json(
|
|
37
46
|
successResponse({
|
|
38
47
|
data: appStateStore.getSettings(),
|
|
39
|
-
metadata:
|
|
48
|
+
metadata: buildSettingsMetadata(context),
|
|
40
49
|
})
|
|
41
50
|
);
|
|
42
51
|
})
|
|
@@ -50,7 +59,63 @@ function createSettingsRouter({ appStateStore }) {
|
|
|
50
59
|
successResponse({
|
|
51
60
|
message: "Settings updated.",
|
|
52
61
|
data: settings,
|
|
53
|
-
metadata:
|
|
62
|
+
metadata: buildSettingsMetadata(context),
|
|
63
|
+
})
|
|
64
|
+
);
|
|
65
|
+
})
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
router.get(
|
|
69
|
+
"/version-check",
|
|
70
|
+
route(async (req, res) => {
|
|
71
|
+
try {
|
|
72
|
+
const result = await checkVersion();
|
|
73
|
+
|
|
74
|
+
res.json(
|
|
75
|
+
successResponse({
|
|
76
|
+
data: result,
|
|
77
|
+
metadata: readSettingsMetadata(),
|
|
78
|
+
})
|
|
79
|
+
);
|
|
80
|
+
} catch (error) {
|
|
81
|
+
throw new AppError("Version check failed. Check your internet connection and try again.", 502, {
|
|
82
|
+
code: "VERSION_CHECK_FAILED",
|
|
83
|
+
details: {
|
|
84
|
+
source: "npm",
|
|
85
|
+
message: error.message,
|
|
86
|
+
},
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
})
|
|
90
|
+
);
|
|
91
|
+
|
|
92
|
+
router.post(
|
|
93
|
+
"/api-tokens",
|
|
94
|
+
route((req, res) => {
|
|
95
|
+
const activeDatabase = requireActiveDatabase(connectionManager);
|
|
96
|
+
const token = tokenService.createToken(activeDatabase.id, req.body?.name);
|
|
97
|
+
|
|
98
|
+
res.status(201).json(
|
|
99
|
+
successResponse({
|
|
100
|
+
message: "API token created. It will only be shown once.",
|
|
101
|
+
data: token,
|
|
102
|
+
metadata: buildSettingsMetadata(context),
|
|
103
|
+
})
|
|
104
|
+
);
|
|
105
|
+
})
|
|
106
|
+
);
|
|
107
|
+
|
|
108
|
+
router.delete(
|
|
109
|
+
"/api-tokens/:tokenId",
|
|
110
|
+
route((req, res) => {
|
|
111
|
+
const activeDatabase = requireActiveDatabase(connectionManager);
|
|
112
|
+
const result = tokenService.deleteToken(activeDatabase.id, req.params.tokenId);
|
|
113
|
+
|
|
114
|
+
res.json(
|
|
115
|
+
successResponse({
|
|
116
|
+
message: "API token deleted.",
|
|
117
|
+
data: result,
|
|
118
|
+
metadata: buildSettingsMetadata(context),
|
|
54
119
|
})
|
|
55
120
|
);
|
|
56
121
|
})
|
|
@@ -61,6 +126,10 @@ function createSettingsRouter({ appStateStore }) {
|
|
|
61
126
|
|
|
62
127
|
module.exports = {
|
|
63
128
|
createSettingsRouter,
|
|
129
|
+
buildSettingsMetadata,
|
|
130
|
+
checkLatestAppVersion,
|
|
131
|
+
compareSemver,
|
|
132
|
+
isNewerVersion,
|
|
64
133
|
readSettingsMetadata,
|
|
65
134
|
readSqliteVersion,
|
|
66
135
|
};
|
package/server/server.js
CHANGED
|
@@ -21,6 +21,8 @@ const { StructureService } = require("./services/sqlite/structureService");
|
|
|
21
21
|
const { DataBrowserService } = require("./services/sqlite/dataBrowserService");
|
|
22
22
|
const { TableDesignerService } = require("./services/sqlite/tableDesignerService");
|
|
23
23
|
const { MediaTaggingService } = require("./services/sqlite/mediaTaggingService");
|
|
24
|
+
const { ApiTokenService } = require("./services/apiTokenService");
|
|
25
|
+
const { DatabaseCommandService } = require("./services/databaseCommandService");
|
|
24
26
|
const { createConnectionsRouter } = require("./routes/connections");
|
|
25
27
|
const { createOverviewRouter } = require("./routes/overview");
|
|
26
28
|
const { createSqlRouter } = require("./routes/sql");
|
|
@@ -32,6 +34,7 @@ const { createMediaTaggingRouter } = require("./routes/mediaTagging");
|
|
|
32
34
|
const { createSettingsRouter } = require("./routes/settings");
|
|
33
35
|
const { createExportRouter } = require("./routes/export");
|
|
34
36
|
const { createDocumentsRouter } = require("./routes/documents");
|
|
37
|
+
const { createExternalApiRouter } = require("./routes/externalApi");
|
|
35
38
|
|
|
36
39
|
const PACKAGE_ROOT = path.resolve(__dirname, "..");
|
|
37
40
|
const FRONTEND_ROOT = path.join(PACKAGE_ROOT, "frontend");
|
|
@@ -64,6 +67,8 @@ const structureService = new StructureService({ connectionManager, appStateStore
|
|
|
64
67
|
const dataBrowserService = new DataBrowserService({ connectionManager });
|
|
65
68
|
const tableDesignerService = new TableDesignerService({ connectionManager });
|
|
66
69
|
const mediaTaggingService = new MediaTaggingService({ connectionManager, appStateStore });
|
|
70
|
+
const apiTokenService = new ApiTokenService({ appStateStore });
|
|
71
|
+
const databaseCommandService = new DatabaseCommandService({ appStateStore });
|
|
67
72
|
|
|
68
73
|
connectionManager.initialize();
|
|
69
74
|
|
|
@@ -116,9 +121,23 @@ app.use("/api/structure", createStructureRouter({ structureService }));
|
|
|
116
121
|
app.use("/api/data", createDataRouter({ dataBrowserService }));
|
|
117
122
|
app.use("/api/table-designer", createTableDesignerRouter({ tableDesignerService }));
|
|
118
123
|
app.use("/api/media-tagging", createMediaTaggingRouter({ mediaTaggingService }));
|
|
119
|
-
app.use(
|
|
124
|
+
app.use(
|
|
125
|
+
"/api/settings",
|
|
126
|
+
createSettingsRouter({
|
|
127
|
+
appStateStore,
|
|
128
|
+
connectionManager,
|
|
129
|
+
tokenService: apiTokenService,
|
|
130
|
+
})
|
|
131
|
+
);
|
|
120
132
|
app.use("/api/export", createExportRouter({ exportService }));
|
|
121
133
|
app.use("/api/documents", createDocumentsRouter({ appStateStore, connectionManager }));
|
|
134
|
+
app.use(
|
|
135
|
+
"/api/v1",
|
|
136
|
+
createExternalApiRouter({
|
|
137
|
+
databaseService: databaseCommandService,
|
|
138
|
+
tokenService: apiTokenService,
|
|
139
|
+
})
|
|
140
|
+
);
|
|
122
141
|
|
|
123
142
|
// auth: public favicon response; it exposes no application data.
|
|
124
143
|
app.get("/favicon.ico", (req, res) => {
|
|
@@ -227,7 +246,9 @@ if (require.main === module) {
|
|
|
227
246
|
module.exports = {
|
|
228
247
|
app,
|
|
229
248
|
appStateStore,
|
|
249
|
+
apiTokenService,
|
|
230
250
|
connectionManager,
|
|
251
|
+
databaseCommandService,
|
|
231
252
|
DEFAULT_HOST,
|
|
232
253
|
DEFAULT_PORT,
|
|
233
254
|
parsePortArgument,
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
const crypto = require("node:crypto");
|
|
2
|
+
const { AuthenticationError, NotFoundError, ValidationError } = require("../utils/errors");
|
|
3
|
+
|
|
4
|
+
const TOKEN_PREFIX = "shub_";
|
|
5
|
+
|
|
6
|
+
function hashApiToken(token) {
|
|
7
|
+
return crypto.createHash("sha256").update(String(token ?? ""), "utf8").digest("hex");
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function normalizeDatabaseKey(databaseKey) {
|
|
11
|
+
const normalized = String(databaseKey ?? "").trim();
|
|
12
|
+
|
|
13
|
+
if (!normalized) {
|
|
14
|
+
throw new ValidationError("Database key is required.");
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
return normalized;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function normalizeTokenName(name) {
|
|
21
|
+
const normalized = String(name ?? "").trim() || "API token";
|
|
22
|
+
|
|
23
|
+
if (normalized.length > 80) {
|
|
24
|
+
throw new ValidationError("Token name must not exceed 80 characters.");
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
return normalized;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
class ApiTokenService {
|
|
31
|
+
constructor({ appStateStore }) {
|
|
32
|
+
this.appStateStore = appStateStore;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
assertDatabaseExists(databaseKey) {
|
|
36
|
+
const normalizedDatabaseKey = normalizeDatabaseKey(databaseKey);
|
|
37
|
+
const connection = this.appStateStore
|
|
38
|
+
.getRecentConnections()
|
|
39
|
+
.find((candidate) => candidate.id === normalizedDatabaseKey);
|
|
40
|
+
|
|
41
|
+
if (!connection) {
|
|
42
|
+
throw new NotFoundError(`Database not found: ${normalizedDatabaseKey}`);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return connection;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
listTokens(databaseKey) {
|
|
49
|
+
const connection = this.assertDatabaseExists(databaseKey);
|
|
50
|
+
return this.appStateStore.listApiTokens(connection.id);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
createToken(databaseKey, name) {
|
|
54
|
+
const connection = this.assertDatabaseExists(databaseKey);
|
|
55
|
+
const token = `${TOKEN_PREFIX}${crypto.randomBytes(32).toString("base64url")}`;
|
|
56
|
+
const record = this.appStateStore.createApiToken({
|
|
57
|
+
databaseKey: connection.id,
|
|
58
|
+
name: normalizeTokenName(name),
|
|
59
|
+
tokenHash: hashApiToken(token),
|
|
60
|
+
tokenPrefix: token.slice(0, 13),
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
return {
|
|
64
|
+
...record,
|
|
65
|
+
token,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
deleteToken(databaseKey, tokenId) {
|
|
70
|
+
const connection = this.assertDatabaseExists(databaseKey);
|
|
71
|
+
return this.appStateStore.deleteApiToken(connection.id, tokenId);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
authenticate(databaseKey, token) {
|
|
75
|
+
const normalizedDatabaseKey = normalizeDatabaseKey(databaseKey);
|
|
76
|
+
const normalizedToken = String(token ?? "").trim();
|
|
77
|
+
|
|
78
|
+
if (!normalizedToken) {
|
|
79
|
+
throw new AuthenticationError("API token is required.", {
|
|
80
|
+
code: "API_TOKEN_REQUIRED",
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const record = this.appStateStore.findApiTokenByHash(hashApiToken(normalizedToken));
|
|
85
|
+
|
|
86
|
+
if (!record || record.databaseKey !== normalizedDatabaseKey) {
|
|
87
|
+
throw new AuthenticationError("API token is invalid for this database.", {
|
|
88
|
+
code: "API_TOKEN_INVALID",
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
this.appStateStore.touchApiToken(record.id);
|
|
93
|
+
return record;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
module.exports = {
|
|
98
|
+
ApiTokenService,
|
|
99
|
+
TOKEN_PREFIX,
|
|
100
|
+
hashApiToken,
|
|
101
|
+
};
|