sqlite-hub 0.9.1 → 0.9.4

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.
Files changed (56) hide show
  1. package/.github/workflows/ci.yml +36 -0
  2. package/README.md +2 -2
  3. package/bin/sqlite-hub.js +1 -1
  4. package/frontend/index.html +3 -158
  5. package/frontend/js/app.js +231 -5
  6. package/frontend/js/components/connectionCard.js +62 -87
  7. package/frontend/js/components/emptyState.js +20 -23
  8. package/frontend/js/components/modal.js +158 -199
  9. package/frontend/js/components/pageHeader.js +1 -1
  10. package/frontend/js/components/queryEditor.js +16 -30
  11. package/frontend/js/components/queryHistoryDetail.js +93 -164
  12. package/frontend/js/components/queryHistoryPanel.js +90 -100
  13. package/frontend/js/components/queryResults.js +3 -1
  14. package/frontend/js/components/rowEditorPanel.js +76 -56
  15. package/frontend/js/components/tableDesignerEditor.js +91 -116
  16. package/frontend/js/lib/queryChartOptions.js +5 -1
  17. package/frontend/js/lib/queryCharts.js +50 -2
  18. package/frontend/js/store.js +161 -23
  19. package/frontend/js/utils/tableDesigner.js +8 -2
  20. package/frontend/js/views/charts.js +126 -73
  21. package/frontend/js/views/data.js +147 -133
  22. package/frontend/js/views/editor.js +1 -0
  23. package/frontend/js/views/mediaTagging.js +131 -164
  24. package/frontend/js/views/structure.js +52 -48
  25. package/frontend/styles/base.css +57 -13
  26. package/frontend/styles/components.css +166 -96
  27. package/frontend/styles/layout.css +1 -1
  28. package/frontend/styles/structure-graph.css +11 -11
  29. package/frontend/styles/tailwind.css +81 -0
  30. package/frontend/styles/tailwind.generated.css +1 -0
  31. package/frontend/styles/tokens.css +50 -7
  32. package/frontend/styles/utilities.css +21 -0
  33. package/frontend/styles/views.css +94 -86
  34. package/package.json +16 -3
  35. package/server/routes/mediaTagging.js +2 -10
  36. package/server/routes/sql.js +35 -10
  37. package/server/server.js +24 -0
  38. package/server/services/sqlite/dataBrowserService.js +25 -5
  39. package/server/services/sqlite/exportService.js +4 -2
  40. package/server/services/sqlite/introspection.js +2 -2
  41. package/server/services/sqlite/mediaTaggingService.js +166 -53
  42. package/server/services/sqlite/structureService.js +2 -2
  43. package/server/services/sqlite/tableDesigner/sql.js +19 -3
  44. package/server/services/storage/appStateStore.js +227 -87
  45. package/server/services/storage/queryHistoryChartUtils.js +19 -2
  46. package/server/utils/appPaths.js +55 -19
  47. package/server/utils/fileValidation.js +94 -8
  48. package/tailwind.config.cjs +73 -0
  49. package/tests/security-paths.test.js +84 -0
  50. package/tests/sql-identifier-safety.test.js +66 -0
  51. package/.npmingnore +0 -4
  52. package/changelog.md +0 -77
  53. package/docs/DESIGN_GUIDELINES.md +0 -36
  54. package/scripts/publish_brew.sh +0 -466
  55. package/scripts/publish_npm.sh +0 -241
  56. package/shortkeys.md +0 -5
@@ -6,25 +6,108 @@ const { ConflictError, NotFoundError, ValidationError } = require("./errors");
6
6
  const SQLITE_EXTENSIONS = new Set([".db", ".sqlite", ".sqlite3"]);
7
7
  const SQL_DUMP_EXTENSIONS = new Set([".sql"]);
8
8
  const SQLITE_HEADER = Buffer.from("SQLite format 3\0", "utf8");
9
+ const CONTROL_CHARACTER_PATTERN = /[\u0000-\u001f]/;
9
10
 
10
- function expandHome(filePath) {
11
+ function splitPathSegments(filePath) {
12
+ return String(filePath)
13
+ .split(/[\\/]+/)
14
+ .filter((segment) => segment && segment !== ".");
15
+ }
16
+
17
+ function hasParentTraversal(filePath) {
18
+ return splitPathSegments(filePath).includes("..");
19
+ }
20
+
21
+ function isAbsolutePathInput(filePath) {
22
+ return path.isAbsolute(filePath) || path.win32.isAbsolute(filePath);
23
+ }
24
+
25
+ function toAbsolutePath(filePath, baseDirectory = process.cwd()) {
26
+ const normalizedPath = String(filePath);
27
+
28
+ if (isAbsolutePathInput(normalizedPath)) {
29
+ return path.normalize(normalizedPath);
30
+ }
31
+
32
+ return path.normalize(`${baseDirectory}${path.sep}${normalizedPath}`);
33
+ }
34
+
35
+ function assertSafePathInput(filePath, label = "File path") {
11
36
  if (typeof filePath !== "string" || !filePath.trim()) {
12
- throw new ValidationError("A file path is required.");
37
+ throw new ValidationError(`${label} is required.`);
38
+ }
39
+
40
+ if (CONTROL_CHARACTER_PATTERN.test(filePath)) {
41
+ throw new ValidationError(`${label} contains unsupported control characters.`);
42
+ }
43
+
44
+ if (hasParentTraversal(filePath)) {
45
+ throw new ValidationError(`${label} must not contain parent directory segments.`);
13
46
  }
14
47
 
15
- if (filePath === "~") {
48
+ return filePath.trim();
49
+ }
50
+
51
+ function expandHome(filePath, label = "File path") {
52
+ const normalizedPath = assertSafePathInput(filePath, label);
53
+
54
+ if (normalizedPath === "~") {
16
55
  return os.homedir();
17
56
  }
18
57
 
19
- if (filePath.startsWith("~/")) {
20
- return path.join(os.homedir(), filePath.slice(2));
58
+ if (normalizedPath.startsWith("~/")) {
59
+ return toAbsolutePath(normalizedPath.slice(2), os.homedir());
21
60
  }
22
61
 
23
- return filePath;
62
+ return normalizedPath;
63
+ }
64
+
65
+ function resolveBaseDirectory(baseDirectory, label = "Base directory") {
66
+ return toAbsolutePath(assertSafePathInput(baseDirectory, label));
67
+ }
68
+
69
+ function isInsideDirectory(filePath, baseDirectory) {
70
+ const relativePath = path.relative(baseDirectory, filePath);
71
+ return (
72
+ relativePath === "" ||
73
+ (!relativePath.startsWith("..") && !path.isAbsolute(relativePath))
74
+ );
75
+ }
76
+
77
+ function assertPathInsideDirectory(filePath, baseDirectory, label = "File path") {
78
+ const resolvedBaseDirectory = resolveBaseDirectory(baseDirectory);
79
+ const resolvedPath = toAbsolutePath(filePath);
80
+
81
+ if (!isInsideDirectory(resolvedPath, resolvedBaseDirectory)) {
82
+ throw new ValidationError(`${label} must stay inside ${resolvedBaseDirectory}.`);
83
+ }
84
+
85
+ return resolvedPath;
86
+ }
87
+
88
+ function resolveUserPath(filePath, options = {}) {
89
+ const label = options.label ?? "File path";
90
+ const expandedPath = expandHome(filePath, label);
91
+ const baseDirectory = options.baseDirectory
92
+ ? resolveBaseDirectory(options.baseDirectory)
93
+ : null;
94
+ const resolvedPath =
95
+ baseDirectory && !isAbsolutePathInput(expandedPath)
96
+ ? toAbsolutePath(expandedPath, baseDirectory)
97
+ : toAbsolutePath(expandedPath);
98
+
99
+ if (baseDirectory) {
100
+ return assertPathInsideDirectory(resolvedPath, baseDirectory, label);
101
+ }
102
+
103
+ return resolvedPath;
24
104
  }
25
105
 
26
- function resolveUserPath(filePath) {
27
- return path.resolve(expandHome(filePath));
106
+ function resolvePathInsideDirectory(baseDirectory, inputPath, label = "File path") {
107
+ return resolveUserPath(inputPath, {
108
+ baseDirectory,
109
+ label,
110
+ });
28
111
  }
29
112
 
30
113
  function assertExtension(filePath, allowedExtensions, label) {
@@ -124,11 +207,14 @@ function getFileMetadata(filePath) {
124
207
 
125
208
  module.exports = {
126
209
  SQLITE_EXTENSIONS,
210
+ assertPathInsideDirectory,
211
+ assertSafePathInput,
127
212
  ensureFileDoesNotExist,
128
213
  ensureParentDirectory,
129
214
  getFileMetadata,
130
215
  isRealSqliteDatabase,
131
216
  isWritable,
217
+ resolvePathInsideDirectory,
132
218
  resolveUserPath,
133
219
  validateSqlDumpPath,
134
220
  validateSqlitePath,
@@ -0,0 +1,73 @@
1
+ const forms = require("@tailwindcss/forms");
2
+ const containerQueries = require("@tailwindcss/container-queries");
3
+
4
+ module.exports = {
5
+ darkMode: "class",
6
+ content: ["./frontend/index.html", "./frontend/js/**/*.js"],
7
+ theme: {
8
+ extend: {
9
+ colors: {
10
+ "on-secondary-fixed": "#1c1b1b",
11
+ background: "rgb(var(--rgb-background) / <alpha-value>)",
12
+ "inverse-primary": "#6a5f00",
13
+ "primary-fixed": "#fde403",
14
+ tertiary: "#fbfffe",
15
+ outline: "#979177",
16
+ "on-secondary": "#313030",
17
+ "error-container": "#93000a",
18
+ "on-primary-fixed-variant": "#504700",
19
+ "on-tertiary-container": "#006f72",
20
+ "on-tertiary-fixed-variant": "#004f51",
21
+ "secondary-fixed-dim": "#c8c6c5",
22
+ "tertiary-fixed-dim": "#00dce1",
23
+ "on-background": "rgb(var(--rgb-on-surface) / <alpha-value>)",
24
+ "surface-tint": "#dec800",
25
+ "primary-fixed-dim": "#dec800",
26
+ "on-surface": "rgb(var(--rgb-on-surface) / <alpha-value>)",
27
+ "surface-container-low": "rgb(var(--rgb-surface-low) / <alpha-value>)",
28
+ "on-tertiary": "#003738",
29
+ "surface-container": "rgb(var(--rgb-surface-container) / <alpha-value>)",
30
+ "on-surface-variant": "rgb(var(--rgb-on-surface-variant) / <alpha-value>)",
31
+ "on-primary-container": "#706400",
32
+ "on-error": "#690005",
33
+ "surface-container-high": "rgb(var(--rgb-surface-high) / <alpha-value>)",
34
+ "surface-container-highest": "rgb(var(--rgb-surface-highest) / <alpha-value>)",
35
+ error: "rgb(var(--rgb-error) / <alpha-value>)",
36
+ primary: "#fffeff",
37
+ "outline-variant": "rgb(var(--rgb-outline) / <alpha-value>)",
38
+ "on-error-container": "#ffdad6",
39
+ "on-secondary-fixed-variant": "#474746",
40
+ "surface-container-lowest": "rgb(var(--rgb-surface-lowest) / <alpha-value>)",
41
+ "inverse-surface": "#e5e2e1",
42
+ surface: "rgb(var(--rgb-surface) / <alpha-value>)",
43
+ "tertiary-container": "#04faff",
44
+ "on-tertiary-fixed": "#002021",
45
+ "tertiary-fixed": "#2dfaff",
46
+ "surface-variant": "#353534",
47
+ "on-primary": "#373100",
48
+ "primary-container": "rgb(var(--rgb-primary) / <alpha-value>)",
49
+ "inverse-on-surface": "#313030",
50
+ secondary: "#c8c6c5",
51
+ "on-secondary-container": "#b7b5b4",
52
+ "on-primary-fixed": "#201c00",
53
+ "secondary-container": "#474746",
54
+ "surface-dim": "rgb(var(--rgb-surface) / <alpha-value>)",
55
+ "surface-bright": "rgb(var(--rgb-surface-bright) / <alpha-value>)",
56
+ "secondary-fixed": "#e5e2e1",
57
+ },
58
+ fontFamily: {
59
+ headline: "var(--font-family-headline)",
60
+ body: "var(--font-family-body)",
61
+ label: "var(--font-family-body)",
62
+ mono: "var(--font-family-mono)",
63
+ },
64
+ borderRadius: {
65
+ DEFAULT: "0px",
66
+ lg: "0px",
67
+ xl: "0px",
68
+ full: "9999px",
69
+ },
70
+ },
71
+ },
72
+ plugins: [forms, containerQueries],
73
+ };
@@ -0,0 +1,84 @@
1
+ const fs = require("node:fs");
2
+ const os = require("node:os");
3
+ const path = require("node:path");
4
+ const { pathToFileURL } = require("node:url");
5
+ const assert = require("node:assert/strict");
6
+ const test = require("node:test");
7
+ const { ValidationError } = require("../server/utils/errors");
8
+ const {
9
+ resolvePathInsideDirectory,
10
+ resolveUserPath,
11
+ } = require("../server/utils/fileValidation");
12
+ const { MediaTaggingService } = require("../server/services/sqlite/mediaTaggingService");
13
+
14
+ function createTempDirectory(t) {
15
+ const directoryPath = fs.mkdtempSync(path.join(os.tmpdir(), "sqlite-hub-security-"));
16
+ t.after(() => fs.rmSync(directoryPath, { recursive: true, force: true }));
17
+ return directoryPath;
18
+ }
19
+
20
+ test("resolveUserPath rejects parent traversal segments", () => {
21
+ assert.throws(() => resolveUserPath("../outside.db"), ValidationError);
22
+ assert.throws(() => resolveUserPath("~/../outside.db"), ValidationError);
23
+ assert.throws(() => resolveUserPath("data/\0outside.db"), ValidationError);
24
+ });
25
+
26
+ test("resolvePathInsideDirectory resolves only paths under the supplied root", (t) => {
27
+ const root = createTempDirectory(t);
28
+ const insidePath = path.join(root, "media", "photo.jpg");
29
+
30
+ assert.equal(
31
+ resolvePathInsideDirectory(root, "media/photo.jpg", "Media file path"),
32
+ insidePath
33
+ );
34
+ assert.equal(
35
+ resolvePathInsideDirectory(root, insidePath, "Media file path"),
36
+ insidePath
37
+ );
38
+ assert.throws(
39
+ () =>
40
+ resolvePathInsideDirectory(
41
+ root,
42
+ path.join(path.dirname(root), "secret.txt"),
43
+ "Media file path"
44
+ ),
45
+ ValidationError
46
+ );
47
+ assert.throws(
48
+ () => resolvePathInsideDirectory(root, "../secret.txt", "Media file path"),
49
+ ValidationError
50
+ );
51
+ });
52
+
53
+ test("media file resolution is scoped to the active database directory", (t) => {
54
+ const root = createTempDirectory(t);
55
+ const mediaDirectory = path.join(root, "media");
56
+ const mediaPath = path.join(mediaDirectory, "photo.jpg");
57
+
58
+ fs.mkdirSync(mediaDirectory, { recursive: true });
59
+ fs.writeFileSync(mediaPath, "image");
60
+
61
+ const service = new MediaTaggingService({
62
+ connectionManager: {
63
+ getActiveConnection: () => ({
64
+ path: path.join(root, "database.sqlite"),
65
+ }),
66
+ },
67
+ appStateStore: {},
68
+ });
69
+
70
+ assert.equal(service.resolveMediaFilePath("media/photo.jpg"), mediaPath);
71
+ assert.equal(
72
+ service.resolveMediaFilePath(pathToFileURL(mediaPath).toString()),
73
+ mediaPath
74
+ );
75
+ assert.deepEqual(service.getMediaFileForRequest("media/photo.jpg"), {
76
+ directory: mediaDirectory,
77
+ fileName: "photo.jpg",
78
+ });
79
+ assert.throws(() => service.resolveMediaFilePath("../secret.jpg"), ValidationError);
80
+ assert.throws(
81
+ () => service.resolveMediaFilePath(path.join(path.dirname(root), "secret.jpg")),
82
+ ValidationError
83
+ );
84
+ });
@@ -0,0 +1,66 @@
1
+ const Database = require("better-sqlite3");
2
+ const assert = require("node:assert/strict");
3
+ const test = require("node:test");
4
+ const { DataBrowserService } = require("../server/services/sqlite/dataBrowserService");
5
+ const { quoteIdentifier } = require("../server/utils/identifier");
6
+
7
+ test("data browser mutations preserve quoted dynamic identifiers", () => {
8
+ const db = new Database(":memory:");
9
+ const tableName = 'items" archived';
10
+ const valueColumn = 'display"name';
11
+
12
+ try {
13
+ db.exec(
14
+ [
15
+ "CREATE TABLE",
16
+ quoteIdentifier(tableName),
17
+ "(",
18
+ quoteIdentifier(valueColumn),
19
+ "TEXT, status INTEGER",
20
+ ")",
21
+ ].join(" ")
22
+ );
23
+ db.prepare(
24
+ [
25
+ "INSERT INTO",
26
+ quoteIdentifier(tableName),
27
+ "(" + quoteIdentifier(valueColumn) + ", status)",
28
+ "VALUES (?, ?)",
29
+ ].join(" ")
30
+ ).run("before", 0);
31
+
32
+ const service = new DataBrowserService({
33
+ connectionManager: {
34
+ assertWritable() {},
35
+ getActiveDatabase: () => db,
36
+ },
37
+ });
38
+ const tableData = service.getTableData(tableName, { limit: 10, offset: 0 });
39
+ const identity = tableData.rows[0].__identity;
40
+
41
+ const updated = service.updateTableRow(tableName, {
42
+ identity,
43
+ values: {
44
+ [valueColumn]: "after",
45
+ },
46
+ });
47
+
48
+ assert.equal(updated.row[valueColumn], "after");
49
+ assert.equal(
50
+ db.prepare(["SELECT", quoteIdentifier(valueColumn), "FROM", quoteIdentifier(tableName)].join(" ")).get()[valueColumn],
51
+ "after"
52
+ );
53
+
54
+ const deleted = service.deleteTableRow(tableName, {
55
+ identity: updated.row.__identity,
56
+ });
57
+
58
+ assert.equal(deleted.affectedRowCount, 1);
59
+ assert.equal(
60
+ db.prepare(["SELECT COUNT(*) AS count FROM", quoteIdentifier(tableName)].join(" ")).get().count,
61
+ 0
62
+ );
63
+ } finally {
64
+ db.close();
65
+ }
66
+ });
package/.npmingnore DELETED
@@ -1,4 +0,0 @@
1
- publish_brew.sh
2
- pbulish_npm.sh
3
- changelog.md
4
- gitignore.md
package/changelog.md DELETED
@@ -1,77 +0,0 @@
1
- # v0.9.1
2
-
3
- - Improvements in tagging queue
4
- - rotate right, rotate left
5
-
6
- # v0.9.0
7
-
8
- - tagging view
9
- - filtered out the create, update, delete queries from the chart section
10
-
11
- # v0.8.8
12
-
13
- - modal window fix in delete query
14
- - csv export filename based on query name
15
- - unsaved query history tab
16
-
17
- # v0.8.7
18
-
19
- - UX fixes
20
- - fixing a lot of the vibe slop
21
- - trying to build reuseable components
22
-
23
- # v0.8.0
24
-
25
- - DDL copy button
26
- - open in charts button in query details
27
- - clear makes editor active
28
- - UI fixes
29
- - Overview improvement
30
-
31
- # v0.7.0
32
-
33
- - hide query history, hide editor
34
- - better cli interface
35
- - plotting and graphs
36
- - removing inconsistencies in the UI
37
-
38
- # v0.6.0
39
-
40
- - table designer, create tables in sqlit_hub
41
-
42
- # v0.5.1
43
-
44
- - bug fix in sql editor
45
-
46
- # v0.5.0
47
-
48
- - sql query history
49
- - open in finder in overview
50
- - sort columns in data & sql_editor
51
- - json view in row editor
52
-
53
- # v0.4.0
54
-
55
- - search in data
56
- - shift + enter
57
- - logos for databases
58
- - clean up some chaotic code things :)
59
-
60
- # v0.3.2
61
-
62
- - delete rows
63
- - modal windows for rows
64
-
65
- # v0.3.1
66
-
67
- - visualize tables
68
- - export csv from data and sql_editor
69
- - Backup mode
70
-
71
- # v0.2.0
72
-
73
- - db fix
74
-
75
- # v0.1.3
76
-
77
- - edit in sql editor
@@ -1,36 +0,0 @@
1
- # SQLite Hub UI Rules for Codex
2
-
3
- Follow these rules exactly when creating or modifying UI controls.
4
-
5
- 1. Reuse existing shared components before creating anything new.
6
- 2. Use exactly one semantic main class per control.
7
- 3. Never combine multiple button-style classes on the same element.
8
- 4. View-specific classes may change layout, width, spacing, or position only.
9
- 5. View-specific classes must not redefine the base style of shared components.
10
- 6. The standard height for interactive controls is `36px`.
11
- 7. The source of truth for control height is `--control-height` in `frontend/styles/tokens.css`.
12
- 8. Controls in the same row must have the same height.
13
- 9. All regular buttons must use one of these classes only: `standard-button`, `signature-button`, `delete-button`.
14
- 10. Use `standard-button` for normal actions.
15
- 11. Use `signature-button` only for the primary yellow CTA in a context.
16
- 12. Use `delete-button` only for destructive actions.
17
- 13. `signature-button` must keep its hover state and chamfered corner.
18
- 14. All visible clickable buttons must have a hover state.
19
- 15. Disabled states must come from the shared component, not from local view CSS.
20
- 16. All reusable checkboxes must use `standard-checkbox`.
21
- 17. Do not build feature-specific checkbox shells or checkbox base styles.
22
- 18. Inputs and selects must follow the shared base rules in `frontend/styles/base.css`.
23
- 19. Ghost or transparent inputs must not use a white background, including unfocused state.
24
- 20. When a new control pattern appears in multiple places, promote it to a shared component first, then migrate existing usages.
25
-
26
- ## Source of Truth
27
-
28
- - `frontend/styles/tokens.css`
29
- - `frontend/styles/base.css`
30
- - `frontend/index.html`
31
-
32
- ## Default Decision Rules
33
-
34
- - Prefer reuse over invention.
35
- - Prefer migration over local override.
36
- - Prefer shared component updates over per-view fixes.