sqlite-hub 0.9.3 → 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 (46) 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 +2 -158
  5. package/frontend/js/app.js +5 -1
  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 +145 -195
  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 +81 -99
  13. package/frontend/js/components/queryResults.js +3 -1
  14. package/frontend/js/components/rowEditorPanel.js +28 -31
  15. package/frontend/js/components/tableDesignerEditor.js +91 -116
  16. package/frontend/js/store.js +39 -3
  17. package/frontend/js/utils/tableDesigner.js +8 -2
  18. package/frontend/js/views/charts.js +23 -43
  19. package/frontend/js/views/data.js +116 -132
  20. package/frontend/js/views/mediaTagging.js +131 -164
  21. package/frontend/js/views/structure.js +52 -48
  22. package/frontend/styles/tailwind.css +81 -0
  23. package/frontend/styles/tailwind.generated.css +1 -0
  24. package/frontend/styles/tokens.css +3 -3
  25. package/package.json +16 -3
  26. package/server/routes/mediaTagging.js +2 -10
  27. package/server/routes/sql.js +35 -10
  28. package/server/server.js +24 -0
  29. package/server/services/sqlite/dataBrowserService.js +25 -5
  30. package/server/services/sqlite/exportService.js +4 -2
  31. package/server/services/sqlite/introspection.js +2 -2
  32. package/server/services/sqlite/mediaTaggingService.js +166 -53
  33. package/server/services/sqlite/structureService.js +2 -2
  34. package/server/services/sqlite/tableDesigner/sql.js +19 -3
  35. package/server/services/storage/appStateStore.js +227 -87
  36. package/server/utils/appPaths.js +55 -19
  37. package/server/utils/fileValidation.js +94 -8
  38. package/tailwind.config.cjs +73 -0
  39. package/tests/security-paths.test.js +84 -0
  40. package/tests/sql-identifier-safety.test.js +66 -0
  41. package/.npmingnore +0 -4
  42. package/changelog.md +0 -84
  43. package/docs/DESIGN_GUIDELINES.md +0 -36
  44. package/scripts/publish_brew.sh +0 -466
  45. package/scripts/publish_npm.sh +0 -241
  46. 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,84 +0,0 @@
1
- # v0.9.3
2
-
3
- - improving rerendering
4
- - fixed the sorting bug in charts
5
- - data row size is stored in localstorage
6
- - saved charts
7
-
8
- # v0.9.1
9
-
10
- - Improvements in tagging queue
11
- - rotate right, rotate left
12
-
13
- # v0.9.0
14
-
15
- - tagging view
16
- - filtered out the create, update, delete queries from the chart section
17
-
18
- # v0.8.8
19
-
20
- - modal window fix in delete query
21
- - csv export filename based on query name
22
- - unsaved query history tab
23
-
24
- # v0.8.7
25
-
26
- - UX fixes
27
- - fixing a lot of the vibe slop
28
- - trying to build reuseable components
29
-
30
- # v0.8.0
31
-
32
- - DDL copy button
33
- - open in charts button in query details
34
- - clear makes editor active
35
- - UI fixes
36
- - Overview improvement
37
-
38
- # v0.7.0
39
-
40
- - hide query history, hide editor
41
- - better cli interface
42
- - plotting and graphs
43
- - removing inconsistencies in the UI
44
-
45
- # v0.6.0
46
-
47
- - table designer, create tables in sqlit_hub
48
-
49
- # v0.5.1
50
-
51
- - bug fix in sql editor
52
-
53
- # v0.5.0
54
-
55
- - sql query history
56
- - open in finder in overview
57
- - sort columns in data & sql_editor
58
- - json view in row editor
59
-
60
- # v0.4.0
61
-
62
- - search in data
63
- - shift + enter
64
- - logos for databases
65
- - clean up some chaotic code things :)
66
-
67
- # v0.3.2
68
-
69
- - delete rows
70
- - modal windows for rows
71
-
72
- # v0.3.1
73
-
74
- - visualize tables
75
- - export csv from data and sql_editor
76
- - Backup mode
77
-
78
- # v0.2.0
79
-
80
- - db fix
81
-
82
- # v0.1.3
83
-
84
- - 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.