sqlite-hub 0.17.0 → 1.0.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 +101 -19
- package/bin/sqlite-hub.js +83 -401
- 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 +183 -26
- package/frontend/js/components/formControls.js +38 -0
- package/frontend/js/components/modal.js +79 -19
- package/frontend/js/components/queryResults.js +18 -1
- package/frontend/js/components/rowEditorPanel.js +42 -34
- package/frontend/js/store.js +129 -2
- package/frontend/js/utils/jsonPreview.js +31 -0
- package/frontend/js/utils/rowEditorValues.js +41 -0
- package/frontend/js/utils/tableScrollState.js +39 -0
- package/frontend/js/views/charts.js +8 -0
- package/frontend/js/views/data.js +4 -1
- package/frontend/js/views/editor.js +2 -0
- package/frontend/js/views/settings.js +141 -5
- package/frontend/styles/components.css +6 -0
- package/frontend/styles/tailwind.generated.css +2 -2
- package/package.json +6 -6
- package/server/middleware/apiTokenAuth.js +30 -0
- package/server/middleware/localRequestSecurity.js +84 -0
- package/server/routes/connections.js +35 -1
- package/server/routes/externalApi.js +222 -0
- package/server/routes/settings.js +64 -4
- package/server/server.js +34 -2
- package/server/services/apiTokenService.js +101 -0
- package/server/services/databaseCommandService.js +399 -0
- package/server/services/nativeFileDialogService.js +260 -0
- package/server/services/sqlite/dataBrowserService.js +0 -4
- package/server/services/sqlite/exportService.js +5 -1
- package/server/services/sqlite/sqlExecutor.js +51 -2
- package/server/services/storage/appStateStore.js +113 -0
- package/server/utils/errors.js +7 -0
- package/server/utils/sqliteTypes.js +17 -8
- package/tests/api-token-auth.test.js +127 -0
- package/tests/cli-service-delegation.test.js +43 -0
- package/tests/connections-file-dialog-route.test.js +89 -0
- package/tests/copy-column-modal.test.js +48 -0
- package/tests/database-command-service.test.js +102 -0
- package/tests/export-blob.test.js +46 -0
- package/tests/form-controls.test.js +34 -0
- package/tests/json-preview.test.js +49 -0
- package/tests/local-request-security.test.js +85 -0
- package/tests/native-file-dialog.test.js +105 -0
- package/tests/query-results-truncation.test.js +20 -0
- package/tests/row-editor-null-values.test.js +131 -0
- package/tests/settings-api-tokens-route.test.js +76 -0
- package/tests/settings-view.test.js +47 -0
- package/tests/sql-identifier-safety.test.js +9 -3
- package/tests/sql-result-limit.test.js +66 -0
- package/tests/table-scroll-state.test.js +70 -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,222 @@
|
|
|
1
|
+
const express = require("express");
|
|
2
|
+
const { createApiTokenAuth } = require("../middleware/apiTokenAuth");
|
|
3
|
+
const { route, successResponse } = require("../utils/errors");
|
|
4
|
+
|
|
5
|
+
function createExternalApiRouter({ databaseService, tokenService }) {
|
|
6
|
+
const router = express.Router();
|
|
7
|
+
|
|
8
|
+
router.use(
|
|
9
|
+
"/databases/:databaseId",
|
|
10
|
+
createApiTokenAuth({ tokenService })
|
|
11
|
+
);
|
|
12
|
+
|
|
13
|
+
router.get(
|
|
14
|
+
"/databases/:databaseId",
|
|
15
|
+
route((req, res) => {
|
|
16
|
+
res.json(
|
|
17
|
+
successResponse({
|
|
18
|
+
data: databaseService.getDatabase(req.params.databaseId),
|
|
19
|
+
metadata: { databaseId: req.params.databaseId },
|
|
20
|
+
})
|
|
21
|
+
);
|
|
22
|
+
})
|
|
23
|
+
);
|
|
24
|
+
|
|
25
|
+
router.get(
|
|
26
|
+
"/databases/:databaseId/tables",
|
|
27
|
+
route((req, res) => {
|
|
28
|
+
res.json(
|
|
29
|
+
successResponse({
|
|
30
|
+
data: { items: databaseService.listTables(req.params.databaseId) },
|
|
31
|
+
metadata: { databaseId: req.params.databaseId },
|
|
32
|
+
})
|
|
33
|
+
);
|
|
34
|
+
})
|
|
35
|
+
);
|
|
36
|
+
|
|
37
|
+
router.get(
|
|
38
|
+
"/databases/:databaseId/tables/:tableName",
|
|
39
|
+
route((req, res) => {
|
|
40
|
+
res.json(
|
|
41
|
+
successResponse({
|
|
42
|
+
data: databaseService.getTable(req.params.databaseId, req.params.tableName),
|
|
43
|
+
metadata: { databaseId: req.params.databaseId },
|
|
44
|
+
})
|
|
45
|
+
);
|
|
46
|
+
})
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
router.post(
|
|
50
|
+
"/databases/:databaseId/tables/:tableName/row",
|
|
51
|
+
route((req, res) => {
|
|
52
|
+
const result = databaseService.getTableRow(
|
|
53
|
+
req.params.databaseId,
|
|
54
|
+
req.params.tableName,
|
|
55
|
+
req.body?.key
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
res.json(
|
|
59
|
+
successResponse({
|
|
60
|
+
data: result.data,
|
|
61
|
+
metadata: {
|
|
62
|
+
databaseId: req.params.databaseId,
|
|
63
|
+
filename: result.filename,
|
|
64
|
+
identity: result.identity,
|
|
65
|
+
tableName: result.table.name,
|
|
66
|
+
},
|
|
67
|
+
})
|
|
68
|
+
);
|
|
69
|
+
})
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
router.get(
|
|
73
|
+
"/databases/:databaseId/queries",
|
|
74
|
+
route((req, res) => {
|
|
75
|
+
res.json(
|
|
76
|
+
successResponse({
|
|
77
|
+
data: databaseService.listSavedQueries(req.params.databaseId),
|
|
78
|
+
metadata: { databaseId: req.params.databaseId },
|
|
79
|
+
})
|
|
80
|
+
);
|
|
81
|
+
})
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
router.post(
|
|
85
|
+
"/databases/:databaseId/queries/:queryName/execute",
|
|
86
|
+
route((req, res) => {
|
|
87
|
+
const { query, result } = databaseService.executeSavedQuery(
|
|
88
|
+
req.params.databaseId,
|
|
89
|
+
req.params.queryName
|
|
90
|
+
);
|
|
91
|
+
|
|
92
|
+
res.json(
|
|
93
|
+
successResponse({
|
|
94
|
+
message: "Saved query executed successfully.",
|
|
95
|
+
data: result,
|
|
96
|
+
metadata: {
|
|
97
|
+
databaseId: req.params.databaseId,
|
|
98
|
+
query,
|
|
99
|
+
},
|
|
100
|
+
timingMs: result.timingMs,
|
|
101
|
+
})
|
|
102
|
+
);
|
|
103
|
+
})
|
|
104
|
+
);
|
|
105
|
+
|
|
106
|
+
router.get(
|
|
107
|
+
"/databases/:databaseId/queries/:queryName/export",
|
|
108
|
+
route((req, res) => {
|
|
109
|
+
const { query, result } = databaseService.exportSavedQuery(
|
|
110
|
+
req.params.databaseId,
|
|
111
|
+
req.params.queryName,
|
|
112
|
+
req.query.format || "csv"
|
|
113
|
+
);
|
|
114
|
+
|
|
115
|
+
res.json(
|
|
116
|
+
successResponse({
|
|
117
|
+
data: {
|
|
118
|
+
filename: result.filename,
|
|
119
|
+
content: result.content ?? result.csv ?? "",
|
|
120
|
+
format: result.format,
|
|
121
|
+
mimeType: result.mimeType,
|
|
122
|
+
columns: result.columns,
|
|
123
|
+
rowCount: result.rowCount,
|
|
124
|
+
},
|
|
125
|
+
metadata: {
|
|
126
|
+
databaseId: req.params.databaseId,
|
|
127
|
+
query,
|
|
128
|
+
},
|
|
129
|
+
})
|
|
130
|
+
);
|
|
131
|
+
})
|
|
132
|
+
);
|
|
133
|
+
|
|
134
|
+
router.get(
|
|
135
|
+
"/databases/:databaseId/queries/:queryName/notes",
|
|
136
|
+
route((req, res) => {
|
|
137
|
+
const query = databaseService.getSavedQuery(
|
|
138
|
+
req.params.databaseId,
|
|
139
|
+
req.params.queryName
|
|
140
|
+
);
|
|
141
|
+
|
|
142
|
+
res.json(
|
|
143
|
+
successResponse({
|
|
144
|
+
data: { notes: String(query.notes ?? "") },
|
|
145
|
+
metadata: { databaseId: req.params.databaseId, query },
|
|
146
|
+
})
|
|
147
|
+
);
|
|
148
|
+
})
|
|
149
|
+
);
|
|
150
|
+
|
|
151
|
+
router.get(
|
|
152
|
+
"/databases/:databaseId/queries/:queryName",
|
|
153
|
+
route((req, res) => {
|
|
154
|
+
res.json(
|
|
155
|
+
successResponse({
|
|
156
|
+
data: databaseService.getSavedQuery(
|
|
157
|
+
req.params.databaseId,
|
|
158
|
+
req.params.queryName
|
|
159
|
+
),
|
|
160
|
+
metadata: { databaseId: req.params.databaseId },
|
|
161
|
+
})
|
|
162
|
+
);
|
|
163
|
+
})
|
|
164
|
+
);
|
|
165
|
+
|
|
166
|
+
router.get(
|
|
167
|
+
"/databases/:databaseId/documents",
|
|
168
|
+
route((req, res) => {
|
|
169
|
+
res.json(
|
|
170
|
+
successResponse({
|
|
171
|
+
data: { items: databaseService.listDocuments(req.params.databaseId) },
|
|
172
|
+
metadata: { databaseId: req.params.databaseId },
|
|
173
|
+
})
|
|
174
|
+
);
|
|
175
|
+
})
|
|
176
|
+
);
|
|
177
|
+
|
|
178
|
+
router.get(
|
|
179
|
+
"/databases/:databaseId/documents/:documentName/export",
|
|
180
|
+
route((req, res) => {
|
|
181
|
+
const result = databaseService.exportDocument(
|
|
182
|
+
req.params.databaseId,
|
|
183
|
+
req.params.documentName
|
|
184
|
+
);
|
|
185
|
+
|
|
186
|
+
res.json(
|
|
187
|
+
successResponse({
|
|
188
|
+
data: {
|
|
189
|
+
filename: result.filename,
|
|
190
|
+
content: result.content,
|
|
191
|
+
mimeType: result.mimeType,
|
|
192
|
+
},
|
|
193
|
+
metadata: {
|
|
194
|
+
databaseId: req.params.databaseId,
|
|
195
|
+
document: result.document,
|
|
196
|
+
},
|
|
197
|
+
})
|
|
198
|
+
);
|
|
199
|
+
})
|
|
200
|
+
);
|
|
201
|
+
|
|
202
|
+
router.get(
|
|
203
|
+
"/databases/:databaseId/documents/:documentName",
|
|
204
|
+
route((req, res) => {
|
|
205
|
+
res.json(
|
|
206
|
+
successResponse({
|
|
207
|
+
data: databaseService.getDocument(
|
|
208
|
+
req.params.databaseId,
|
|
209
|
+
req.params.documentName
|
|
210
|
+
),
|
|
211
|
+
metadata: { databaseId: req.params.databaseId },
|
|
212
|
+
})
|
|
213
|
+
);
|
|
214
|
+
})
|
|
215
|
+
);
|
|
216
|
+
|
|
217
|
+
return router;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
module.exports = {
|
|
221
|
+
createExternalApiRouter,
|
|
222
|
+
};
|
|
@@ -2,7 +2,7 @@ const express = require("express");
|
|
|
2
2
|
const Database = require("better-sqlite3");
|
|
3
3
|
const fs = require("node:fs");
|
|
4
4
|
const path = require("node:path");
|
|
5
|
-
const { route, successResponse } = require("../utils/errors");
|
|
5
|
+
const { DatabaseRequiredError, route, successResponse } = require("../utils/errors");
|
|
6
6
|
|
|
7
7
|
function readAppVersion() {
|
|
8
8
|
const packageJsonPath = path.resolve(__dirname, "..", "..", "package.json");
|
|
@@ -27,8 +27,35 @@ function readSettingsMetadata() {
|
|
|
27
27
|
};
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
-
function
|
|
30
|
+
function getActiveTokenContext({ connectionManager, tokenService }) {
|
|
31
|
+
const activeDatabase = connectionManager?.getActiveConnection?.() ?? null;
|
|
32
|
+
|
|
33
|
+
return {
|
|
34
|
+
activeDatabase,
|
|
35
|
+
apiTokens: activeDatabase && tokenService ? tokenService.listTokens(activeDatabase.id) : [],
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function requireActiveDatabase(connectionManager) {
|
|
40
|
+
const activeDatabase = connectionManager?.getActiveConnection?.() ?? null;
|
|
41
|
+
|
|
42
|
+
if (!activeDatabase) {
|
|
43
|
+
throw new DatabaseRequiredError("Select a database before managing API tokens.");
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return activeDatabase;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function buildSettingsMetadata(context) {
|
|
50
|
+
return {
|
|
51
|
+
...readSettingsMetadata(),
|
|
52
|
+
...getActiveTokenContext(context),
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function createSettingsRouter({ appStateStore, connectionManager, tokenService }) {
|
|
31
57
|
const router = express.Router();
|
|
58
|
+
const context = { connectionManager, tokenService };
|
|
32
59
|
|
|
33
60
|
router.get(
|
|
34
61
|
"/",
|
|
@@ -36,7 +63,7 @@ function createSettingsRouter({ appStateStore }) {
|
|
|
36
63
|
res.json(
|
|
37
64
|
successResponse({
|
|
38
65
|
data: appStateStore.getSettings(),
|
|
39
|
-
metadata:
|
|
66
|
+
metadata: buildSettingsMetadata(context),
|
|
40
67
|
})
|
|
41
68
|
);
|
|
42
69
|
})
|
|
@@ -50,7 +77,39 @@ function createSettingsRouter({ appStateStore }) {
|
|
|
50
77
|
successResponse({
|
|
51
78
|
message: "Settings updated.",
|
|
52
79
|
data: settings,
|
|
53
|
-
metadata:
|
|
80
|
+
metadata: buildSettingsMetadata(context),
|
|
81
|
+
})
|
|
82
|
+
);
|
|
83
|
+
})
|
|
84
|
+
);
|
|
85
|
+
|
|
86
|
+
router.post(
|
|
87
|
+
"/api-tokens",
|
|
88
|
+
route((req, res) => {
|
|
89
|
+
const activeDatabase = requireActiveDatabase(connectionManager);
|
|
90
|
+
const token = tokenService.createToken(activeDatabase.id, req.body?.name);
|
|
91
|
+
|
|
92
|
+
res.status(201).json(
|
|
93
|
+
successResponse({
|
|
94
|
+
message: "API token created. It will only be shown once.",
|
|
95
|
+
data: token,
|
|
96
|
+
metadata: buildSettingsMetadata(context),
|
|
97
|
+
})
|
|
98
|
+
);
|
|
99
|
+
})
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
router.delete(
|
|
103
|
+
"/api-tokens/:tokenId",
|
|
104
|
+
route((req, res) => {
|
|
105
|
+
const activeDatabase = requireActiveDatabase(connectionManager);
|
|
106
|
+
const result = tokenService.deleteToken(activeDatabase.id, req.params.tokenId);
|
|
107
|
+
|
|
108
|
+
res.json(
|
|
109
|
+
successResponse({
|
|
110
|
+
message: "API token deleted.",
|
|
111
|
+
data: result,
|
|
112
|
+
metadata: buildSettingsMetadata(context),
|
|
54
113
|
})
|
|
55
114
|
);
|
|
56
115
|
})
|
|
@@ -61,6 +120,7 @@ function createSettingsRouter({ appStateStore }) {
|
|
|
61
120
|
|
|
62
121
|
module.exports = {
|
|
63
122
|
createSettingsRouter,
|
|
123
|
+
buildSettingsMetadata,
|
|
64
124
|
readSettingsMetadata,
|
|
65
125
|
readSqliteVersion,
|
|
66
126
|
};
|
package/server/server.js
CHANGED
|
@@ -3,6 +3,11 @@ const helmet = require("helmet");
|
|
|
3
3
|
const rateLimit = require("express-rate-limit");
|
|
4
4
|
const path = require("node:path");
|
|
5
5
|
const { errorMiddleware } = require("./utils/errors");
|
|
6
|
+
const {
|
|
7
|
+
LOOPBACK_HOST,
|
|
8
|
+
listenOnLoopback,
|
|
9
|
+
localRequestSecurity,
|
|
10
|
+
} = require("./middleware/localRequestSecurity");
|
|
6
11
|
const { resolveAppStatePaths } = require("./utils/appPaths");
|
|
7
12
|
const { AppStateStore } = require("./services/storage/appStateStore");
|
|
8
13
|
const { ConnectionManager } = require("./services/sqlite/connectionManager");
|
|
@@ -10,11 +15,14 @@ const { OverviewService } = require("./services/sqlite/overviewService");
|
|
|
10
15
|
const { SqlExecutor } = require("./services/sqlite/sqlExecutor");
|
|
11
16
|
const { ImportService } = require("./services/sqlite/importService");
|
|
12
17
|
const { BackupService } = require("./services/sqlite/backupService");
|
|
18
|
+
const { NativeFileDialogService } = require("./services/nativeFileDialogService");
|
|
13
19
|
const { ExportService } = require("./services/sqlite/exportService");
|
|
14
20
|
const { StructureService } = require("./services/sqlite/structureService");
|
|
15
21
|
const { DataBrowserService } = require("./services/sqlite/dataBrowserService");
|
|
16
22
|
const { TableDesignerService } = require("./services/sqlite/tableDesignerService");
|
|
17
23
|
const { MediaTaggingService } = require("./services/sqlite/mediaTaggingService");
|
|
24
|
+
const { ApiTokenService } = require("./services/apiTokenService");
|
|
25
|
+
const { DatabaseCommandService } = require("./services/databaseCommandService");
|
|
18
26
|
const { createConnectionsRouter } = require("./routes/connections");
|
|
19
27
|
const { createOverviewRouter } = require("./routes/overview");
|
|
20
28
|
const { createSqlRouter } = require("./routes/sql");
|
|
@@ -26,6 +34,7 @@ const { createMediaTaggingRouter } = require("./routes/mediaTagging");
|
|
|
26
34
|
const { createSettingsRouter } = require("./routes/settings");
|
|
27
35
|
const { createExportRouter } = require("./routes/export");
|
|
28
36
|
const { createDocumentsRouter } = require("./routes/documents");
|
|
37
|
+
const { createExternalApiRouter } = require("./routes/externalApi");
|
|
29
38
|
|
|
30
39
|
const PACKAGE_ROOT = path.resolve(__dirname, "..");
|
|
31
40
|
const FRONTEND_ROOT = path.join(PACKAGE_ROOT, "frontend");
|
|
@@ -37,6 +46,7 @@ const {
|
|
|
37
46
|
legacyDatabasePaths: LEGACY_DATABASE_PATHS,
|
|
38
47
|
} = resolveAppStatePaths(PACKAGE_ROOT);
|
|
39
48
|
const DEFAULT_PORT = 4173;
|
|
49
|
+
const DEFAULT_HOST = LOOPBACK_HOST;
|
|
40
50
|
|
|
41
51
|
const appStateStore = new AppStateStore(APP_STATE_DB_PATH, {
|
|
42
52
|
legacyFilePath: LEGACY_STATE_PATH,
|
|
@@ -47,6 +57,7 @@ const overviewService = new OverviewService({ connectionManager });
|
|
|
47
57
|
const sqlExecutor = new SqlExecutor({ connectionManager, appStateStore });
|
|
48
58
|
const importService = new ImportService({ connectionManager });
|
|
49
59
|
const backupService = new BackupService({ connectionManager });
|
|
60
|
+
const nativeFileDialogService = new NativeFileDialogService();
|
|
50
61
|
const exportService = new ExportService({
|
|
51
62
|
appStateStore,
|
|
52
63
|
connectionManager,
|
|
@@ -56,6 +67,8 @@ const structureService = new StructureService({ connectionManager, appStateStore
|
|
|
56
67
|
const dataBrowserService = new DataBrowserService({ connectionManager });
|
|
57
68
|
const tableDesignerService = new TableDesignerService({ connectionManager });
|
|
58
69
|
const mediaTaggingService = new MediaTaggingService({ connectionManager, appStateStore });
|
|
70
|
+
const apiTokenService = new ApiTokenService({ appStateStore });
|
|
71
|
+
const databaseCommandService = new DatabaseCommandService({ appStateStore });
|
|
59
72
|
|
|
60
73
|
connectionManager.initialize();
|
|
61
74
|
|
|
@@ -66,6 +79,7 @@ app.use(
|
|
|
66
79
|
contentSecurityPolicy: false,
|
|
67
80
|
})
|
|
68
81
|
);
|
|
82
|
+
app.use("/api", localRequestSecurity);
|
|
69
83
|
app.use(
|
|
70
84
|
"/api",
|
|
71
85
|
rateLimit({
|
|
@@ -97,6 +111,7 @@ app.use(
|
|
|
97
111
|
connectionManager,
|
|
98
112
|
importService,
|
|
99
113
|
backupService,
|
|
114
|
+
nativeFileDialogService,
|
|
100
115
|
})
|
|
101
116
|
);
|
|
102
117
|
app.use("/api/db", createOverviewRouter({ overviewService }));
|
|
@@ -106,9 +121,23 @@ app.use("/api/structure", createStructureRouter({ structureService }));
|
|
|
106
121
|
app.use("/api/data", createDataRouter({ dataBrowserService }));
|
|
107
122
|
app.use("/api/table-designer", createTableDesignerRouter({ tableDesignerService }));
|
|
108
123
|
app.use("/api/media-tagging", createMediaTaggingRouter({ mediaTaggingService }));
|
|
109
|
-
app.use(
|
|
124
|
+
app.use(
|
|
125
|
+
"/api/settings",
|
|
126
|
+
createSettingsRouter({
|
|
127
|
+
appStateStore,
|
|
128
|
+
connectionManager,
|
|
129
|
+
tokenService: apiTokenService,
|
|
130
|
+
})
|
|
131
|
+
);
|
|
110
132
|
app.use("/api/export", createExportRouter({ exportService }));
|
|
111
133
|
app.use("/api/documents", createDocumentsRouter({ appStateStore, connectionManager }));
|
|
134
|
+
app.use(
|
|
135
|
+
"/api/v1",
|
|
136
|
+
createExternalApiRouter({
|
|
137
|
+
databaseService: databaseCommandService,
|
|
138
|
+
tokenService: apiTokenService,
|
|
139
|
+
})
|
|
140
|
+
);
|
|
112
141
|
|
|
113
142
|
// auth: public favicon response; it exposes no application data.
|
|
114
143
|
app.get("/favicon.ico", (req, res) => {
|
|
@@ -191,7 +220,7 @@ function startServer({ port } = {}) {
|
|
|
191
220
|
const resolvedPort = resolvePort(port);
|
|
192
221
|
|
|
193
222
|
return new Promise((resolve, reject) => {
|
|
194
|
-
const server = app
|
|
223
|
+
const server = listenOnLoopback(app, resolvedPort);
|
|
195
224
|
|
|
196
225
|
server.once("error", reject);
|
|
197
226
|
server.once("listening", () => {
|
|
@@ -217,7 +246,10 @@ if (require.main === module) {
|
|
|
217
246
|
module.exports = {
|
|
218
247
|
app,
|
|
219
248
|
appStateStore,
|
|
249
|
+
apiTokenService,
|
|
220
250
|
connectionManager,
|
|
251
|
+
databaseCommandService,
|
|
252
|
+
DEFAULT_HOST,
|
|
221
253
|
DEFAULT_PORT,
|
|
222
254
|
parsePortArgument,
|
|
223
255
|
resolvePort,
|
|
@@ -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
|
+
};
|